From a64ce0a861e4044ea35e021f79c8fa999e54c453 Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 2 Apr 2026 10:34:44 +0200 Subject: [PATCH 1/6] feat(scanner): add support for MIDI model with blackshield configuration --- openscan_firmware/models/scanner.py | 1 + scripts/openapi/openapi_v0.8.json | 1 + settings/device/default_midi_blackshield.json | 46 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 settings/device/default_midi_blackshield.json diff --git a/openscan_firmware/models/scanner.py b/openscan_firmware/models/scanner.py index 76c1456..2132bec 100644 --- a/openscan_firmware/models/scanner.py +++ b/openscan_firmware/models/scanner.py @@ -14,6 +14,7 @@ class ScannerModel(Enum): CLASSIC = "classic" MINI = "mini" + MIDI = "midi" CUSTOM = "custom" class ScannerShield(Enum): diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index 28bf910..b891094 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -5682,6 +5682,7 @@ "enum": [ "classic", "mini", + "midi", "custom" ], "title": "ScannerModel" diff --git a/settings/device/default_midi_blackshield.json b/settings/device/default_midi_blackshield.json new file mode 100644 index 0000000..ec93e1c --- /dev/null +++ b/settings/device/default_midi_blackshield.json @@ -0,0 +1,46 @@ +{ + "name": "Midi v2.1", + "model": "midi", + "shield": "blackshield", + "cameras": {}, + "motors": { + "rotor": { + "direction_pin": 23, + "enable_pin": 22, + "step_pin": 27, + "acceleration": 10000, + "max_speed": 5000, + "direction": -1, + "steps_per_rotation": 61440, + "min_angle": 0, + "max_angle": 150 + }, + "turntable": { + "direction_pin": 6, + "enable_pin": 22, + "step_pin": 16, + "acceleration": 10000, + "max_speed": 5000, + "direction": 1, + "steps_per_rotation": 3200 + } + }, + "endstops": { + "rotor-endstop": { + "name": "rotor-endstop", + "settings": { + "pin": 17, + "angular_position": 153, + "motor_name": "rotor", + "pull_up": true, + "bounce_time": 0.005 + } + } + }, + "lights": { + "Blackshield Ringlight": { + "pins": [24,26], + "pwm_support": false + } + } +} \ No newline at end of file From 51821f947b8afa03d9eed1a13401262f280a91b6 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 8 Apr 2026 14:45:31 +0200 Subject: [PATCH 2/6] Feature/gphoto2 (#95) * feat(camera): enhance diagnostics, error handling - Introduced detailed diagnostics in `gphoto2` controllers with expanded configuration and error reporting. - Implemented fallback logic for "Unspecified error" during capture on specific camera models. - Enhanced camera detection and diagnostics in `/next/develop`. - Added logging for unexpected errors in photo requests and runtime failures. - Introduced a dedicated Nikon D7100 GPhoto2 profile with tailored startup configuration, RAW capture support, and ISO mapping. - Introduced `profile_helpers` for reusable profile implementations, including ISO mapping, shutter choice selection, and RAW filename checks. - Modified `profile_registry` to dynamically discover and register all camera profiles at startup. - Updated documentation to reflect the automatic profile discovery process. --- docs/Camera/GPHOTO2_ADD_CAMERA.md | 94 ++++++ openscan_firmware/config/scan.py | 4 +- .../controllers/hardware/cameras/camera.py | 1 + .../hardware/cameras/gphoto2/controller.py | 84 +++++- .../hardware/cameras/gphoto2/profile.py | 18 +- .../cameras/gphoto2/profile_helpers.py | 121 ++++++++ .../cameras/gphoto2/profile_registry.py | 60 +++- .../cameras/gphoto2/profiles/__init__.py | 3 +- .../gphoto2/profiles/canon_eos_700d.py | 51 ++-- .../cameras/gphoto2/profiles/generic.py | 51 +--- .../cameras/gphoto2/profiles/nikon_d7100.py | 130 +++++++++ .../gphoto2/profiles/template_camera.py | 60 ++++ .../hardware/cameras/gphoto2/session.py | 271 +++++++++++++++--- .../controllers/services/cloud.py | 4 +- .../controllers/services/projects.py | 12 +- openscan_firmware/models/camera.py | 2 +- openscan_firmware/routers/next/cameras.py | 56 +++- openscan_firmware/routers/next/develop.py | 45 +++ tests/routers/test_next_cameras_router.py | 52 ++++ 19 files changed, 997 insertions(+), 122 deletions(-) create mode 100644 docs/Camera/GPHOTO2_ADD_CAMERA.md create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/profile_helpers.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/nikon_d7100.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/template_camera.py diff --git a/docs/Camera/GPHOTO2_ADD_CAMERA.md b/docs/Camera/GPHOTO2_ADD_CAMERA.md new file mode 100644 index 0000000..5699186 --- /dev/null +++ b/docs/Camera/GPHOTO2_ADD_CAMERA.md @@ -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 ` output. diff --git a/openscan_firmware/config/scan.py b/openscan_firmware/config/scan.py index 09c5db0..7ac8e56 100644 --- a/openscan_firmware/config/scan.py +++ b/openscan_firmware/config/scan.py @@ -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).' ) @@ -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) - ] \ No newline at end of file + ] diff --git a/openscan_firmware/controllers/hardware/cameras/camera.py b/openscan_firmware/controllers/hardware/cameras/camera.py index 37bed3a..31b8cbb 100644 --- a/openscan_firmware/controllers/hardware/cameras/camera.py +++ b/openscan_firmware/controllers/hardware/cameras/camera.py @@ -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, diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py index 3b9f248..2b3b30d 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py @@ -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: @@ -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) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py index dc97fe4..134126d 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py @@ -8,6 +8,8 @@ from openscan_firmware.config.camera import CameraSettings +from .profile_helpers import select_best_shutter_choice + logger = logging.getLogger(__name__) @@ -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 @@ -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) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_helpers.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_helpers.py new file mode 100644 index 0000000..634466b --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_helpers.py @@ -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'}')." + ) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py index 83cfd8e..0cdf409 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py @@ -2,13 +2,63 @@ from __future__ import annotations +import importlib +import inspect +import logging +import pkgutil + from .profile import CameraIdentity, GPhoto2Profile -from .profiles import CanonEOS700DProfile, GenericGPhoto2Profile +from .profiles.generic import GenericGPhoto2Profile + +logger = logging.getLogger(__name__) + + +def _iter_profile_classes() -> list[type[GPhoto2Profile]]: + profile_classes: list[type[GPhoto2Profile]] = [] + + # Import every module in the profiles package so new profile files are + # discovered automatically without manual registry edits. + import openscan_firmware.controllers.hardware.cameras.gphoto2.profiles as profiles_package + + for module_info in pkgutil.iter_modules(profiles_package.__path__, profiles_package.__name__ + "."): + try: + module = importlib.import_module(module_info.name) + except Exception: + logger.exception("Failed to import gphoto2 profile module '%s'.", module_info.name) + continue + + for _, class_obj in inspect.getmembers(module, inspect.isclass): + if not issubclass(class_obj, GPhoto2Profile): + continue + if class_obj is GPhoto2Profile: + continue + if not getattr(class_obj, "register_in_registry", True): + continue + if class_obj in profile_classes: + continue + profile_classes.append(class_obj) + + return _sorted_profile_classes(profile_classes) + + +def _sorted_profile_classes(profile_classes: list[type[GPhoto2Profile]]) -> list[type[GPhoto2Profile]]: + # Keep generic as final fallback regardless of module filename ordering. + generic_classes: list[type[GPhoto2Profile]] = [] + specific_classes: list[type[GPhoto2Profile]] = [] + + for profile_class in profile_classes: + if profile_class is GenericGPhoto2Profile or getattr(profile_class, "profile_id", "") == "generic": + generic_classes.append(profile_class) + else: + specific_classes.append(profile_class) + + specific_classes.sort(key=lambda cls: f"{cls.__module__}.{cls.__name__}") + if not generic_classes: + generic_classes = [GenericGPhoto2Profile] + return specific_classes + generic_classes + -_PROFILE_CLASSES: list[type[GPhoto2Profile]] = [ - CanonEOS700DProfile, - GenericGPhoto2Profile, -] +_PROFILE_CLASSES: list[type[GPhoto2Profile]] = _iter_profile_classes() def get_profile_for_identity(identity: CameraIdentity) -> GPhoto2Profile: diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py index 8a20eb6..4da7bcf 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py @@ -2,5 +2,6 @@ from .canon_eos_700d import CanonEOS700DProfile from .generic import GenericGPhoto2Profile +from .nikon_d7100 import NikonD7100Profile -__all__ = ["CanonEOS700DProfile", "GenericGPhoto2Profile"] +__all__ = ["CanonEOS700DProfile", "NikonD7100Profile", "GenericGPhoto2Profile"] diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/canon_eos_700d.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/canon_eos_700d.py index 72ae186..b666c15 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/canon_eos_700d.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/canon_eos_700d.py @@ -7,6 +7,7 @@ from openscan_firmware.config.camera import CameraSettings from ..profile import CameraIdentity +from ..profile_helpers import is_raw_filename, map_gain_to_iso_choice, restore_previous_config_value from .generic import GenericGPhoto2Profile logger = logging.getLogger(__name__) @@ -32,9 +33,9 @@ def matches(self, identity: CameraIdentity) -> bool: def apply_startup_config(self, session, settings: CameraSettings) -> None: # For tethered capture on EOS 700D we prefer Internal RAM. - session.set_first_config_value(self._CAPTURE_TARGET_KEYS, "Internal RAM") - session.set_first_config_value(self._EXPOSURE_MODE_KEYS, "Manual") - session.set_first_config_value(self._FOCUS_MODE_KEYS, "One Shot") + self._set_first(session, self._CAPTURE_TARGET_KEYS, "Internal RAM") + self._set_first(session, self._EXPOSURE_MODE_KEYS, "Manual") + self._set_first(session, self._FOCUS_MODE_KEYS, "One Shot") self.apply_settings(session, settings) def apply_settings(self, session, settings: CameraSettings) -> None: @@ -42,7 +43,7 @@ def apply_settings(self, session, settings: CameraSettings) -> None: iso_value = _map_gain_to_iso_choice(settings.gain) if iso_value is not None: - applied = session.set_first_config_value(self._ISO_KEYS, iso_value) + applied = self._set_first(session, self._ISO_KEYS, iso_value) if not applied: logger.debug("ISO mapping unsupported on this EOS 700D config tree.") @@ -50,26 +51,36 @@ def supports_dng(self) -> bool: return True def capture_dng(self, session): - previous = session.get_first_config_details(self._DNG_KEYS) + previous = self._get_first_details(session, self._DNG_KEYS) previous_value = None if previous is None else previous.get("value") - session.set_first_config_value(self._DNG_KEYS, "RAW") + write_result = session.write_first_config(self._DNG_KEYS, "RAW") + if not write_result.success: + raise RuntimeError( + "Could not set Canon RAW mode " + f"(requested='RAW', attempted_keys={write_result.attempted_keys}, " + f"error={write_result.error!r})." + ) try: - import gphoto2 as gp - - return session.capture_image(gp_file_type=gp.GP_FILE_TYPE_RAW) - except Exception: - logger.debug("RAW file capture path failed; falling back to normal file type.", exc_info=True) - return session.capture_image() + # EOS 700D is more stable with normal file download after forcing + # imageformat=RAW than with GP_FILE_TYPE_RAW. + content, extra = session.capture_image() + capture_name = str(extra.get("capture_name", "")).lower() + if _is_raw_filename(capture_name): + return content, extra + raise RuntimeError( + "Camera returned a non-RAW file while RAW was requested " + f"(capture_name='{capture_name or 'unknown'}')." + ) + except Exception as exc: + raise RuntimeError(f"RAW capture failed on Canon EOS 700D: {exc}") from exc finally: - if previous_value: - session.set_first_config_value(self._DNG_KEYS, previous_value) + restore_previous_config_value(session, self._DNG_KEYS, previous_value) def _map_gain_to_iso_choice(gain: float | None) -> str | None: - if gain is None: - return None # CameraSettings.gain is generic analogue gain; for DSLR map to nearest ISO stop. - target = max(float(gain), 0.0) * 100.0 - iso_choices = [100, 200, 400, 800, 1600, 3200, 6400, 12800] - nearest = min(iso_choices, key=lambda iso: abs(iso - target)) - return str(nearest) + return map_gain_to_iso_choice(gain, [100, 200, 400, 800, 1600, 3200, 6400, 12800]) + + +def _is_raw_filename(name: str) -> bool: + return is_raw_filename(name, (".cr2", ".cr3", ".crw", ".raw", ".dng")) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py index 4cb853c..7aece32 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py @@ -3,21 +3,21 @@ from __future__ import annotations import logging -from fractions import Fraction from openscan_firmware.config.camera import CameraSettings from ..profile import CameraIdentity, GPhoto2Profile +from ..profile_helpers import ( + format_shutter_value_ms, + parse_shutter_choice_seconds, + select_best_shutter_choice, +) logger = logging.getLogger(__name__) 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)}" + return format_shutter_value_ms(shutter_ms) class GenericGPhoto2Profile(GPhoto2Profile): @@ -43,49 +43,24 @@ def matches(self, identity: CameraIdentity) -> bool: return True def apply_startup_config(self, session, settings: CameraSettings) -> None: - session.set_first_config_value(self._CAPTURE_TARGET_KEYS, "Memory card") + self._set_first(session, self._CAPTURE_TARGET_KEYS, "Memory card") self.apply_settings(session, settings) def apply_settings(self, session, settings: CameraSettings) -> None: if settings.shutter is not None: shutter_str = self._select_best_shutter_choice(session, settings.shutter) - applied = session.set_first_config_value(self._SHUTTER_KEYS, shutter_str) + applied = self._set_first(session, self._SHUTTER_KEYS, shutter_str) if not applied: logger.debug("No generic shutter config key found on camera.") if settings.jpeg_quality is not None and settings.jpeg_quality >= 85: - session.set_first_config_value(self._JPEG_QUALITY_KEYS, "JPEG Fine") + self._set_first(session, self._JPEG_QUALITY_KEYS, "JPEG Fine") def _select_best_shutter_choice(self, session, shutter_ms: float) -> str: - details = session.get_first_config_details(self._SHUTTER_KEYS) - target_seconds = max(shutter_ms / 1000.0, 0.000125) - if not details or not details.get("choices"): - return _format_shutter_value_ms(shutter_ms) - - best = None - best_err = float("inf") - for choice in details["choices"]: - parsed = _parse_shutter_choice_seconds(str(choice)) - if parsed is None: - continue - err = abs(parsed - target_seconds) - if err < best_err: - best_err = err - best = str(choice) - - return best or _format_shutter_value_ms(shutter_ms) + details = self._get_first_details(session, self._SHUTTER_KEYS) + choices = [] if details is None else list(details.get("choices") or []) + return select_best_shutter_choice(shutter_ms, choices) def _parse_shutter_choice_seconds(value: str) -> float | None: - v = value.strip().lower() - if not v or v == "bulb": - return None - if "/" in v: - try: - return float(Fraction(v)) - except Exception: - return None - try: - return float(v) - except Exception: - return None + return parse_shutter_choice_seconds(value) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/nikon_d7100.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/nikon_d7100.py new file mode 100644 index 0000000..f725852 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/nikon_d7100.py @@ -0,0 +1,130 @@ +"""Nikon D7100 specific GPhoto2 profile.""" + +from __future__ import annotations + +import logging +import time + +from openscan_firmware.config.camera import CameraSettings + +from ..profile import CameraIdentity +from ..profile_helpers import ( + capture_with_route_fallbacks, + is_raw_filename, + map_gain_to_iso_choice, + pick_raw_choice_from_details, + restore_previous_config_value, +) +from .generic import GenericGPhoto2Profile + +logger = logging.getLogger(__name__) + + +class NikonD7100Profile(GenericGPhoto2Profile): + """Nikon D7100 tuning on top of generic DSLR behavior.""" + + profile_id = "nikon_d7100" + + _MODEL_MARKERS = ("nikon dsc d7100", "nikon d7100") + _CAPTURE_TARGET_KEYS = ["/main/settings/capturetarget", "capturetarget"] + _RECORDING_MEDIA_KEYS = ["/main/settings/recordingmedia", "recordingmedia"] + _APPLICATION_MODE_KEYS = ["/main/other/applicationmode", "applicationmode"] + _SHUTTER_KEYS = ["/main/capturesettings/shutterspeed", "/main/settings/shutterspeed", "shutterspeed"] + _JPEG_QUALITY_KEYS = ["/main/imgsettings/imagequality", "/main/imgsettings/imageformat", "imagequality", "imageformat"] + _DNG_KEYS = ["/main/imgsettings/imagequality", "/main/imgsettings/imageformat", "imagequality", "imageformat"] + _ISO_KEYS = ["/main/imgsettings/iso", "/main/capturesettings/iso", "iso"] + + def matches(self, identity: CameraIdentity) -> bool: + model = (identity.model or "").strip().lower() + return any(marker in model for marker in self._MODEL_MARKERS) + + def apply_startup_config(self, session, settings: CameraSettings) -> None: + # Keep startup conservative and prefer the camera's normal card-backed routing. + super().apply_startup_config(session, settings) + self._set_first(session, self._CAPTURE_TARGET_KEYS, "Memory card") + self._set_first(session, self._RECORDING_MEDIA_KEYS, "Card") + + def apply_settings(self, session, settings: CameraSettings) -> None: + super().apply_settings(session, settings) + iso_value = _map_gain_to_iso_choice(settings.gain) + if iso_value is not None: + applied = self._set_first(session, self._ISO_KEYS, iso_value) + if not applied: + logger.debug("ISO mapping unsupported on Nikon D7100 config tree.") + + def supports_dng(self) -> bool: + return True + + def capture_dng(self, session): + previous = self._get_first_details(session, self._DNG_KEYS) + previous_value = None if previous is None else previous.get("value") + + raw_choice = _pick_nikon_raw_choice(previous) + write_result = session.write_first_config(self._DNG_KEYS, raw_choice) + if not write_result.success: + raise RuntimeError( + "Could not set Nikon RAW mode " + f"(requested choice='{raw_choice}', attempted_keys={write_result.attempted_keys}, " + f"error={write_result.error!r})." + ) + + try: + # Nikon bodies can need a short settling delay after mode switch. + time.sleep(0.12) + content, extra, diagnostics = capture_with_route_fallbacks( + session=session, + routes=_capture_routes(), + capture_route_applier=_apply_capture_route, + raw_filename_checker=_is_raw_filename, + ) + extra.update(diagnostics) + return content, extra + except Exception as exc: + logger.exception("RAW capture failed in Nikon D7100 profile.") + raise RuntimeError(f"RAW capture failed on Nikon D7100: {exc}") from exc + finally: + restore_previous_config_value(session, self._DNG_KEYS, previous_value) + + +def _pick_nikon_raw_choice(details: dict | None) -> str: + return pick_raw_choice_from_details(details, markers=("raw", "nef")) + + +def _capture_routes() -> list[dict[str, str]]: + # Try the camera's current routing first, then explicit card-backed capture, + # and only fall back to the older remote/RAM mode last. + return [ + {}, + { + "capturetarget": "Memory card", + "recordingmedia": "Card", + "applicationmode": "Application Mode 0", + }, + { + "capturetarget": "Internal RAM", + "recordingmedia": "SDRAM", + "applicationmode": "Application Mode 1", + }, + ] + + +def _apply_capture_route(session, route: dict[str, str]) -> None: + capturetarget = route.get("capturetarget") + if capturetarget: + session.write_first_config(NikonD7100Profile._CAPTURE_TARGET_KEYS, capturetarget) + + recordingmedia = route.get("recordingmedia") + if recordingmedia: + session.write_first_config(NikonD7100Profile._RECORDING_MEDIA_KEYS, recordingmedia) + + applicationmode = route.get("applicationmode") + if applicationmode: + session.write_first_config(NikonD7100Profile._APPLICATION_MODE_KEYS, applicationmode) + + +def _map_gain_to_iso_choice(gain: float | None) -> str | None: + return map_gain_to_iso_choice(gain, [100, 200, 400, 800, 1600, 3200, 6400]) + + +def _is_raw_filename(name: str) -> bool: + return is_raw_filename(name, (".nef", ".nrw", ".raw", ".dng", ".tif", ".tiff")) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/template_camera.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/template_camera.py new file mode 100644 index 0000000..b334cb3 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/template_camera.py @@ -0,0 +1,60 @@ +"""Template profile for adding a new gphoto2-compatible camera.""" + +from __future__ import annotations + +from openscan_firmware.config.camera import CameraSettings + +from ..profile import CameraIdentity +from ..profile_helpers import map_gain_to_iso_choice, restore_previous_config_value +from .generic import GenericGPhoto2Profile + + +class TemplateCameraProfile(GenericGPhoto2Profile): + """Copy this class and replace values for your own camera model.""" + + profile_id = "template_camera" + register_in_registry = False + + # 1) Model markers: use lowercase fragments from `gphoto2 --auto-detect`. + _MODEL_MARKERS = ("replace with model marker",) + + # 2) Key lists: inspect keys with `gphoto2 --list-config`. + _CAPTURE_TARGET_KEYS = ["/main/settings/capturetarget", "capturetarget"] + _SHUTTER_KEYS = ["/main/capturesettings/shutterspeed", "shutterspeed"] + _JPEG_QUALITY_KEYS = ["/main/imgsettings/imagequality", "imagequality"] + _ISO_KEYS = ["/main/imgsettings/iso", "iso"] + _RAW_FORMAT_KEYS = ["/main/imgsettings/imageformat", "imageformat"] + + def matches(self, identity: CameraIdentity) -> bool: + model = (identity.model or "").strip().lower() + return any(marker in model for marker in self._MODEL_MARKERS) + + def apply_startup_config(self, session, settings: CameraSettings) -> None: + # 3) Startup defaults: configure stable tethered behavior first. + self._set_first(session, self._CAPTURE_TARGET_KEYS, "Memory card") + self.apply_settings(session, settings) + + def apply_settings(self, session, settings: CameraSettings) -> None: + # 4) Runtime settings: keep mapping logic explicit and readable. + super().apply_settings(session, settings) + iso_value = map_gain_to_iso_choice(settings.gain, [100, 200, 400, 800, 1600, 3200]) + if iso_value is not None: + self._set_first(session, self._ISO_KEYS, iso_value) + + def supports_dng(self) -> bool: + return True + + def capture_dng(self, session): + # 5) RAW capture: only override if generic capture is not enough. + previous = self._get_first_details(session, self._RAW_FORMAT_KEYS) + previous_value = None if previous is None else previous.get("value") + write_result = session.write_first_config(self._RAW_FORMAT_KEYS, "RAW") + if not write_result.success: + raise RuntimeError( + f"Could not set RAW mode (attempted_keys={write_result.attempted_keys}, " + f"error={write_result.error!r})." + ) + try: + return session.capture_image() + finally: + restore_previous_config_value(session, self._RAW_FORMAT_KEYS, previous_value) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py index cfbeea8..bdaa4ca 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py @@ -4,6 +4,7 @@ import logging import time +from dataclasses import dataclass, field from typing import Any import gphoto2 as gp @@ -13,6 +14,29 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class ConfigReadResult: + requested_key: str + matched_key: str | None + success: bool + value: Any | None = None + details: dict[str, Any] | None = None + choices: list[Any] = field(default_factory=list) + error: str | None = None + attempted_keys: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class ConfigWriteResult: + requested_key: str + requested_value: Any + matched_key: str | None + actual_value: Any | None + success: bool + error: str | None = None + attempted_keys: list[str] = field(default_factory=list) + + class GPhoto2Session: """Manage a gphoto2 camera session for one physical device.""" @@ -85,7 +109,20 @@ def capture_preview(self) -> bytes: def capture_image(self, gp_file_type: int = gp.GP_FILE_TYPE_NORMAL) -> tuple[bytes, dict[str, Any]]: camera = self.ensure_connected() - file_path = camera.capture(gp.GP_CAPTURE_IMAGE) + try: + file_path = camera.capture(gp.GP_CAPTURE_IMAGE) + except Exception as exc: + message = str(exc) + # Nikon (and some other DSLRs) can fail with "Unspecified error" + # on camera.capture(), but succeed via trigger + event polling. + if "Unspecified error" in message or "[-1]" in message: + logger.debug( + "camera.capture failed with '%s'; trying trigger-capture fallback.", + message, + ) + file_path = self._trigger_capture_and_wait_for_file(camera) + else: + raise camera_file = camera.file_get(file_path.folder, file_path.name, gp_file_type) payload = bytes(camera_file.get_data_and_size()) metadata = { @@ -95,31 +132,82 @@ def capture_image(self, gp_file_type: int = gp.GP_FILE_TYPE_NORMAL) -> tuple[byt } return payload, metadata - def set_config_value(self, key: str, value: Any) -> bool: + def trigger_capture_and_wait_for_file(self, timeout_s: float = 12.0): + camera = self.ensure_connected() + return self._trigger_capture_and_wait_for_file(camera, timeout_s=timeout_s) + + def _trigger_capture_and_wait_for_file(self, camera: Any, timeout_s: float = 12.0): + start = time.monotonic() + + if hasattr(camera, "trigger_capture"): + camera.trigger_capture() + else: + gp.gp_camera_trigger_capture(camera) + + while time.monotonic() - start < timeout_s: + event_type, event_data = camera.wait_for_event(1000) + if event_type == gp.GP_EVENT_FILE_ADDED and event_data is not None: + return event_data + if event_type == gp.GP_EVENT_TIMEOUT: + continue + if event_type == gp.GP_EVENT_UNKNOWN: + continue + + raise RuntimeError("Trigger-capture fallback timed out waiting for GP_EVENT_FILE_ADDED.") + + def write_config(self, key: str, value: Any) -> ConfigWriteResult: camera = self.ensure_connected() - config = self._get_config_with_retry(camera, key_context=key) + config, config_error = self._get_config_with_retry(camera, key_context=key) if config is None: - return False + return ConfigWriteResult( + requested_key=key, + requested_value=value, + matched_key=None, + actual_value=None, + success=False, + error=config_error or f"Failed to read config tree for key '{key}'.", + attempted_keys=[key], + ) + child = self._find_widget(config, key) if child is None: - return False - # Normalize enum-like choices to avoid trivial casing mismatches. - choices = self._extract_choices(child) - if choices: - selected = self._match_choice(choices, value) - else: - selected = value + return ConfigWriteResult( + requested_key=key, + requested_value=value, + matched_key=None, + actual_value=None, + success=False, + error=f"Config key '{key}' was not found.", + attempted_keys=[key], + ) + choices = self._extract_choices(child) + selected = self._match_choice(choices, value) if choices else value current = self._safe_call(child, "get_value") if current is not None and str(current) == str(selected): - return True + return ConfigWriteResult( + requested_key=key, + requested_value=value, + matched_key=key, + actual_value=current, + success=True, + attempted_keys=[key], + ) try: child.set_value(selected) camera.set_config(config) except Exception as exc: logger.debug("Setting config '%s' to '%s' failed: %s", key, selected, exc) - return False + return ConfigWriteResult( + requested_key=key, + requested_value=value, + matched_key=key, + actual_value=current, + success=False, + error=f"Writing key '{key}' failed: {exc}", + attempted_keys=[key], + ) verified = self._safe_call(child, "get_value") if verified is not None and str(verified) != str(selected): @@ -129,26 +217,91 @@ def set_config_value(self, key: str, value: Any) -> bool: selected, verified, ) - return False - return True + return ConfigWriteResult( + requested_key=key, + requested_value=value, + matched_key=key, + actual_value=verified, + success=False, + error=( + f"Config key '{key}' did not persist expected value " + f"(requested={selected} actual={verified})." + ), + attempted_keys=[key], + ) - def set_first_config_value(self, keys: list[str], value: Any) -> bool: + return ConfigWriteResult( + requested_key=key, + requested_value=value, + matched_key=key, + actual_value=verified if verified is not None else selected, + success=True, + attempted_keys=[key], + ) + + def write_first_config(self, keys: list[str], value: Any) -> ConfigWriteResult: + last_result: ConfigWriteResult | None = None for key in keys: try: - if self.set_config_value(key, value): - return True - except Exception: + result = self.write_config(key, value) + except Exception as exc: logger.debug("Setting config '%s' failed.", key, exc_info=True) - return False - - def get_config_details(self, key: str) -> dict[str, Any] | None: + result = ConfigWriteResult( + requested_key=key, + requested_value=value, + matched_key=None, + actual_value=None, + success=False, + error=f"Writing key '{key}' raised an exception: {exc}", + attempted_keys=[key], + ) + if result.success: + return ConfigWriteResult( + requested_key=keys[0] if keys else key, + requested_value=value, + matched_key=result.matched_key, + actual_value=result.actual_value, + success=True, + attempted_keys=list(keys), + ) + last_result = result + + error = ( + last_result.error + if last_result is not None and last_result.error + else "No provided config key accepted the requested value." + ) + return ConfigWriteResult( + requested_key=keys[0] if keys else "", + requested_value=value, + matched_key=None, + actual_value=None, + success=False, + error=error, + attempted_keys=list(keys), + ) + + def read_config(self, key: str) -> ConfigReadResult: camera = self.ensure_connected() - config = self._get_config_with_retry(camera, key_context=key) + config, config_error = self._get_config_with_retry(camera, key_context=key) if config is None: - return None + return ConfigReadResult( + requested_key=key, + matched_key=None, + success=False, + error=config_error or f"Failed to read config tree for key '{key}'.", + attempted_keys=[key], + ) + child = self._find_widget(config, key) if child is None: - return None + return ConfigReadResult( + requested_key=key, + matched_key=None, + success=False, + error=f"Config key '{key}' was not found.", + attempted_keys=[key], + ) details: dict[str, Any] = { "key": key, @@ -159,32 +312,70 @@ def get_config_details(self, key: str) -> dict[str, Any] | None: "value": self._safe_call(child, "get_value"), "choices": self._extract_choices(child), } - return details - - def get_first_config_details(self, keys: list[str]) -> dict[str, Any] | None: + return ConfigReadResult( + requested_key=key, + matched_key=key, + success=True, + value=details.get("value"), + details=details, + choices=list(details.get("choices") or []), + attempted_keys=[key], + ) + + def read_first_config(self, keys: list[str]) -> ConfigReadResult: + last_result: ConfigReadResult | None = None for key in keys: try: - details = self.get_config_details(key) - except Exception: + result = self.read_config(key) + except Exception as exc: logger.debug("Reading config '%s' failed.", key) - continue - if details is not None: - return details - return None - - def _get_config_with_retry(self, camera: Any, key_context: str) -> Any | None: + result = ConfigReadResult( + requested_key=key, + matched_key=None, + success=False, + error=f"Reading key '{key}' raised an exception: {exc}", + attempted_keys=[key], + ) + if result.success: + return ConfigReadResult( + requested_key=keys[0] if keys else key, + matched_key=result.matched_key, + success=True, + value=result.value, + details=result.details, + choices=result.choices, + attempted_keys=list(keys), + ) + last_result = result + + error = ( + last_result.error + if last_result is not None and last_result.error + else "None of the provided config keys were readable." + ) + return ConfigReadResult( + requested_key=keys[0] if keys else "", + matched_key=None, + success=False, + error=error, + attempted_keys=list(keys), + ) + + def _get_config_with_retry(self, camera: Any, key_context: str) -> tuple[Any | None, str | None]: + last_error: str | None = None for attempt in range(self._io_retry_attempts): try: - return camera.get_config() + return camera.get_config(), None except Exception as exc: message = str(exc) is_io_in_progress = "I/O in progress" in message or "[-110]" in message if is_io_in_progress and attempt < self._io_retry_attempts - 1: time.sleep(self._io_retry_delay_s) continue - logger.debug("Reading config '%s' failed: %s", key_context, exc) - return None - return None + last_error = f"Reading config '{key_context}' failed: {exc}" + logger.debug(last_error) + return None, last_error + return None, last_error or f"Reading config '{key_context}' failed." @staticmethod def _find_widget(config_root: Any, key: str) -> Any | None: diff --git a/openscan_firmware/controllers/services/cloud.py b/openscan_firmware/controllers/services/cloud.py index 28230b4..fb3ed48 100644 --- a/openscan_firmware/controllers/services/cloud.py +++ b/openscan_firmware/controllers/services/cloud.py @@ -23,7 +23,7 @@ REQUEST_TIMEOUT = 60 ALLOWED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} -UNSUPPORTED_PHOTO_SUFFIXES = {".png", ".dng", ".npy"} +UNSUPPORTED_PHOTO_SUFFIXES = {".png", ".dng", ".raw", ".cr2", ".cr3", ".crw", ".npy"} class CloudServiceError(RuntimeError): @@ -484,4 +484,4 @@ def _count_project_photos(project: Project) -> int: def _iter_chunks(file_obj: BinaryIO, chunk_size: int) -> Iterator[io.BytesIO]: file_obj.seek(0) while chunk := file_obj.read(chunk_size): - yield io.BytesIO(chunk) \ No newline at end of file + yield io.BytesIO(chunk) diff --git a/openscan_firmware/controllers/services/projects.py b/openscan_firmware/controllers/services/projects.py index a7488ce..6c7937a 100644 --- a/openscan_firmware/controllers/services/projects.py +++ b/openscan_firmware/controllers/services/projects.py @@ -191,6 +191,7 @@ async def _save_photo_async(photo_data: PhotoData, photo_path: str) -> str: handlers = { "jpeg": (_save_photo_jpeg, ".jpg"), "dng": (_save_photo_dng, ".dng"), + "raw": (_save_photo_dng, _raw_extension_from_metadata(photo_data)), "rgb_array": (_save_photo_rgb, ".npy"), "yuv_array": (_save_photo_yuv, ".npy"), } @@ -205,6 +206,15 @@ async def _save_photo_async(photo_data: PhotoData, photo_path: str) -> str: logger.info("Saved %s to %s", photo_data.format, final_path) return final_path + +def _raw_extension_from_metadata(photo_data: PhotoData) -> str: + raw_metadata = photo_data.camera_metadata.raw_metadata if photo_data.camera_metadata else {} + capture_name = str(raw_metadata.get("capture_name", "")).lower() + for ext in (".cr2", ".cr3", ".crw", ".dng", ".raw"): + if capture_name.endswith(ext): + return ext + return ".raw" + async def _save_photo_jpeg(photo_data: PhotoData, file_path: str): """Save a JPEG photo to a file. @@ -879,4 +889,4 @@ def get_project_manager(path: Optional[pathlib.PurePath] = None) -> ProjectManag raise RuntimeError( "ProjectManager is already initialized with a different path. " f"Current: '{current_manager_path}', Requested: '{resolved_path}'" - ) \ No newline at end of file + ) diff --git a/openscan_firmware/models/camera.py b/openscan_firmware/models/camera.py index 77b7d22..a22da4c 100644 --- a/openscan_firmware/models/camera.py +++ b/openscan_firmware/models/camera.py @@ -39,7 +39,7 @@ class PhotoData(BaseModel): ..., description="Image data (JPEG/DNG) or as numpy array" ) - format: Literal['jpeg','dng','rgb_array', 'yuv_array'] + format: Literal['jpeg', 'raw', 'dng', 'rgb_array', 'yuv_array'] camera_metadata: CameraMetadata scan_metadata: Optional[ScanMetadata] = None diff --git a/openscan_firmware/routers/next/cameras.py b/openscan_firmware/routers/next/cameras.py index 51ffebf..e68b59c 100644 --- a/openscan_firmware/routers/next/cameras.py +++ b/openscan_firmware/routers/next/cameras.py @@ -1,9 +1,11 @@ import asyncio import io +import logging import time from dataclasses import dataclass from threading import Lock from typing import Literal, Optional +from urllib.parse import quote, urlsplit, urlunsplit from uuid import uuid4 import numpy as np @@ -28,7 +30,9 @@ responses={404: {"description": "Not found"}}, ) -PhotoFormat = Literal["jpeg", "dng", "rgb_array", "yuv_array"] +logger = logging.getLogger(__name__) + +PhotoFormat = Literal["jpeg", "raw", "dng", "rgb_array", "yuv_array"] _PAYLOAD_TTL_SECONDS = 90 _MAX_PAYLOAD_CACHE_ENTRIES = 8 _MAX_PAYLOAD_CACHE_BYTES = 256 * 1024 * 1024 @@ -90,16 +94,15 @@ def _serialize_photo_payload(photo: PhotoData) -> tuple[bytes, str, str]: if photo.format == "jpeg": media_type = "image/jpeg" filename = "photo.jpg" - elif photo.format == "dng": - media_type = "image/x-adobe-dng" - filename = "photo.dng" + elif photo.format in ("raw", "dng"): + media_type, filename = _infer_raw_file_info(photo) elif photo.format in ("rgb_array", "yuv_array"): media_type = "application/x-npy" filename = f"photo_{photo.format}.npy" else: raise ValueError(f"Unsupported photo format: {photo.format}") - if photo.format in ("jpeg", "dng"): + if photo.format in ("jpeg", "raw", "dng"): if isinstance(photo.data, io.BytesIO): content = photo.data.getvalue() elif isinstance(photo.data, (bytes, bytearray)): @@ -119,6 +122,28 @@ def _serialize_photo_payload(photo: PhotoData) -> tuple[bytes, str, str]: return content, media_type, filename +def _infer_raw_file_info(photo: PhotoData) -> tuple[str, str]: + raw_metadata = photo.camera_metadata.raw_metadata if photo.camera_metadata else {} + capture_name = str(raw_metadata.get("capture_name", "")).lower() + + if capture_name.endswith(".cr2"): + return "image/x-canon-cr2", "photo.cr2" + if capture_name.endswith(".cr3"): + return "image/x-canon-cr3", "photo.cr3" + if capture_name.endswith(".crw"): + return "image/x-canon-crw", "photo.crw" + if capture_name.endswith(".dng"): + return "image/x-adobe-dng", "photo.dng" + if capture_name.endswith(".raw"): + return "application/octet-stream", "photo.raw" + + # Legacy fallback for controllers that still report dng without capture_name. + if photo.format == "dng": + return "image/x-adobe-dng", "photo.dng" + + return "application/octet-stream", "photo.raw" + + def _store_photo_payload( camera_name: str, content: bytes, @@ -142,6 +167,12 @@ def _store_photo_payload( return payload_id, _PAYLOAD_TTL_SECONDS +def _encode_url_path(url: str) -> str: + split = urlsplit(url) + encoded_path = quote(split.path, safe="/") + return urlunsplit((split.scheme, split.netloc, encoded_path, split.query, split.fragment)) + + def _get_cached_photo_payload(camera_name: str, payload_id: str) -> _CachedPhotoPayload: now_monotonic = time.monotonic() with _photo_payload_cache_lock: @@ -280,10 +311,13 @@ async def get_photo( try: photo = await controller.photo_async(image_format=image_format) except ValueError as exc: + logger.warning("Photo request failed for camera '%s' (bad request): %s", camera_name, exc) raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: + logger.warning("Photo request failed for camera '%s' (runtime): %s", camera_name, exc) raise HTTPException(status_code=503, detail=str(exc)) from exc except Exception as exc: + logger.exception("Photo request failed for camera '%s' (unexpected error).", camera_name) raise HTTPException(status_code=500, detail=str(exc)) from exc try: @@ -300,11 +334,13 @@ async def get_photo( media_type=media_type, filename=filename, ) - payload_url = str( - request.url_for( - "get_photo_payload", - camera_name=camera_name, - payload_id=payload_id, + payload_url = _encode_url_path( + str( + request.url_for( + "get_photo_payload", + camera_name=camera_name, + payload_id=payload_id, + ) ) ) return PhotoMetadataResponse( diff --git a/openscan_firmware/routers/next/develop.py b/openscan_firmware/routers/next/develop.py index 4997038..57d9862 100644 --- a/openscan_firmware/routers/next/develop.py +++ b/openscan_firmware/routers/next/develop.py @@ -14,7 +14,9 @@ from fastapi import APIRouter, HTTPException, status, Response, Query from fastapi.responses import PlainTextResponse +from openscan_firmware.controllers.hardware.cameras.camera import get_all_camera_controllers from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager +from openscan_firmware.models.camera import CameraType from openscan_firmware.models.task import TaskStatus, Task from openscan_firmware.models.paths import PolarPoint3D @@ -145,10 +147,52 @@ def _collect_gphoto2_diagnostics() -> dict: "cameras": [], } + gphoto2_controllers = [] + for controller in get_all_camera_controllers().values(): + camera_model = getattr(controller, "camera", None) + if camera_model is None: + continue + if getattr(camera_model, "type", None) != CameraType.GPHOTO2: + continue + gphoto2_controllers.append(controller) + + def _find_active_controller(model: str | None, path: str | None): + for ctrl in gphoto2_controllers: + cam = getattr(ctrl, "camera", None) + if cam is None: + continue + if path and getattr(cam, "path", None) == path: + return ctrl + if model and getattr(cam, "name", None) == model: + return ctrl + return None + cameras: list[dict] = [] for row in rows: model = row.get("model") path = row.get("path") + active_controller = _find_active_controller(model, path) + if active_controller is not None: + get_diag = getattr(active_controller, "get_diagnostics", None) + if callable(get_diag): + try: + cameras.append(get_diag()) + continue + except Exception as exc: + cameras.append( + { + "model": model, + "path": path, + "summary": None, + "about": None, + "config_groups": [], + "relevant_config": [], + "in_use_by_openscan": True, + "error": f"controller diagnostics failed: {exc}", + } + ) + continue + camera_diag = { "model": model, "path": path, @@ -156,6 +200,7 @@ def _collect_gphoto2_diagnostics() -> dict: "about": None, "config_groups": [], "relevant_config": [], + "in_use_by_openscan": False, "error": None, } camera = None diff --git a/tests/routers/test_next_cameras_router.py b/tests/routers/test_next_cameras_router.py index 9bbfe4c..6516f74 100644 --- a/tests/routers/test_next_cameras_router.py +++ b/tests/routers/test_next_cameras_router.py @@ -261,6 +261,58 @@ async def test_get_photo_with_metadata_returns_payload_url_for_dng( assert payload_response.content == b"dng-bytes" +@pytest.mark.asyncio +async def test_get_photo_with_metadata_returns_payload_url_for_raw_cr2( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + photo = _make_photo_data(io.BytesIO(b"raw-bytes"), "raw") + photo.camera_metadata.raw_metadata["capture_name"] = "IMG_0001.CR2" + controller = _FakeCameraController(photo) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "raw", "with_metadata": "true"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["format"] == "raw" + assert payload["media_type"] == "image/x-canon-cr2" + assert payload["filename"] == "photo.cr2" + assert controller.requested_formats == ["raw"] + + payload_response = await cameras_client.get(payload["payload_url"]) + assert payload_response.status_code == 200 + assert payload_response.headers["content-type"] == "image/x-canon-cr2" + assert payload_response.content == b"raw-bytes" + + +@pytest.mark.asyncio +async def test_get_photo_with_metadata_encodes_camera_name_in_payload_url( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + photo = _make_photo_data(io.BytesIO(b"jpeg-bytes"), "jpeg") + controller = _FakeCameraController(photo) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get( + "/next/cameras/Canon%20EOS%20700D/photo", + params={"image_format": "jpeg", "with_metadata": "true"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert "Canon%20EOS%20700D" in payload["payload_url"] + assert "Canon EOS 700D" not in payload["payload_url"] + + @pytest.mark.asyncio async def test_get_photo_rgb_array_returns_npy_payload(monkeypatch, cameras_client, cameras_router_path): module_path = cameras_router_path("cameras") From 6e87abe90d2c92a602b12775f6b6dfca16e45ba1 Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 9 Apr 2026 11:29:50 +0200 Subject: [PATCH 3/6] feat(gpio): improve error handling and add auto-initialize option - Added detailed HTTP exception handling for GPIO endpoints in `v0.8`, `v0.9`, and `next` routers. - Enhanced `gpio.set_output_pin` to support auto-initialization of unconfigured pins. - Updated return types for GPIO endpoints to include response models. - Improved validation and logging in `gpio` hardware controller to handle invalid operations consistently. --- .../controllers/hardware/gpio.py | 38 +++++++++++++++---- openscan_firmware/routers/next/gpio.py | 23 ++++++++--- openscan_firmware/routers/v0_8/gpio.py | 23 ++++++++--- openscan_firmware/routers/v0_9/gpio.py | 23 ++++++++--- 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/openscan_firmware/controllers/hardware/gpio.py b/openscan_firmware/controllers/hardware/gpio.py index e2e0d55..ff8407a 100644 --- a/openscan_firmware/controllers/hardware/gpio.py +++ b/openscan_firmware/controllers/hardware/gpio.py @@ -32,16 +32,37 @@ def toggle_output_pin(pin: int): """Toggles the state of an output pin.""" if pin in _output_pins: _output_pins[pin].toggle() + return bool(_output_pins[pin].value) else: - logger.warning(f"Warning: Cannot toggle pin {pin}. Not initialized as output.") + message = f"Cannot toggle pin {pin}. Pin is not initialized as output." + logger.warning(f"Warning: {message}") + raise ValueError(message) -def set_output_pin(pin: int, status: bool): +def set_output_pin(pin: int, status: bool, auto_initialize: bool = False): """Sets the state of an output pin.""" if pin in _output_pins: _output_pins[pin].value = status - else: - logger.warning(f"Warning: Cannot set pin {pin}. Not initialized as output.") + return bool(_output_pins[pin].value) + + if pin in _buttons: + message = f"Cannot set pin {pin}. Pin is initialized as button input." + logger.warning(f"Warning: {message}") + raise ValueError(message) + + if auto_initialize: + initialize_output_pins([pin]) + if pin in _output_pins: + _output_pins[pin].value = status + return bool(_output_pins[pin].value) + + message = f"Cannot set pin {pin}. Pin could not be initialized as output." + logger.error(f"Error: {message}") + raise RuntimeError(message) + + message = f"Cannot set pin {pin}. Pin is not initialized as output." + logger.warning(f"Warning: {message}") + raise ValueError(message) def get_initialized_pins() -> Dict[str, List[int]]: @@ -55,10 +76,11 @@ def get_initialized_pins() -> Dict[str, List[int]]: def get_output_pin(pin: int): """Returns the state of an output pin.""" if pin in _output_pins: - return _output_pins[pin].value + return bool(_output_pins[pin].value) else: - logger.warning(f"Warning: Pin {pin} not initialized as output.") - return None + message = f"Cannot read pin {pin}. Pin is not initialized as output." + logger.warning(f"Warning: {message}") + raise ValueError(message) def initialize_button(pin: int, pull_up: Optional[bool] = True, bounce_time: Optional[float] = 0.05): @@ -188,4 +210,4 @@ def cleanup_all_pins(): if not _output_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())}") \ No newline at end of file + logger.warning(f"GPIO cleanup potentially incomplete. Remaining outputs: {list(_output_pins.keys())}, Remaining buttons: {list(_buttons.keys())}") diff --git a/openscan_firmware/routers/next/gpio.py b/openscan_firmware/routers/next/gpio.py index a4d5a0c..8061799 100644 --- a/openscan_firmware/routers/next/gpio.py +++ b/openscan_firmware/routers/next/gpio.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from openscan_firmware.controllers.hardware import gpio @@ -29,10 +29,13 @@ async def get_pin(pin_id: int): Returns: bool: The output value of the GPIO pin """ - return gpio.get_output_pin(pin_id) + try: + return gpio.get_output_pin(pin_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc -@router.patch("/{pin_id}") +@router.patch("/{pin_id}", response_model=bool) async def set_pin(pin_id: int, status: bool): """Set GPIO pin output value @@ -40,14 +43,22 @@ async def set_pin(pin_id: int, status: bool): pin_id: The ID (int) of the GPIO pin to set the value of status: The output value to set for the GPIO pin """ - return gpio.set_output_pin(pin_id, status) + try: + return gpio.set_output_pin(pin_id, status, auto_initialize=True) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc -@router.patch("/{pin_id}/toggle") +@router.patch("/{pin_id}/toggle", response_model=bool) async def toggle_pin(pin_id: int): """Toggle GPIO pin output value Args: pin_id: The ID (int) of the GPIO pin to toggle """ - return gpio.toggle_output_pin(pin_id) + try: + return gpio.toggle_output_pin(pin_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc diff --git a/openscan_firmware/routers/v0_8/gpio.py b/openscan_firmware/routers/v0_8/gpio.py index a4d5a0c..8061799 100644 --- a/openscan_firmware/routers/v0_8/gpio.py +++ b/openscan_firmware/routers/v0_8/gpio.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from openscan_firmware.controllers.hardware import gpio @@ -29,10 +29,13 @@ async def get_pin(pin_id: int): Returns: bool: The output value of the GPIO pin """ - return gpio.get_output_pin(pin_id) + try: + return gpio.get_output_pin(pin_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc -@router.patch("/{pin_id}") +@router.patch("/{pin_id}", response_model=bool) async def set_pin(pin_id: int, status: bool): """Set GPIO pin output value @@ -40,14 +43,22 @@ async def set_pin(pin_id: int, status: bool): pin_id: The ID (int) of the GPIO pin to set the value of status: The output value to set for the GPIO pin """ - return gpio.set_output_pin(pin_id, status) + try: + return gpio.set_output_pin(pin_id, status, auto_initialize=True) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc -@router.patch("/{pin_id}/toggle") +@router.patch("/{pin_id}/toggle", response_model=bool) async def toggle_pin(pin_id: int): """Toggle GPIO pin output value Args: pin_id: The ID (int) of the GPIO pin to toggle """ - return gpio.toggle_output_pin(pin_id) + try: + return gpio.toggle_output_pin(pin_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc diff --git a/openscan_firmware/routers/v0_9/gpio.py b/openscan_firmware/routers/v0_9/gpio.py index a4d5a0c..8061799 100644 --- a/openscan_firmware/routers/v0_9/gpio.py +++ b/openscan_firmware/routers/v0_9/gpio.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from openscan_firmware.controllers.hardware import gpio @@ -29,10 +29,13 @@ async def get_pin(pin_id: int): Returns: bool: The output value of the GPIO pin """ - return gpio.get_output_pin(pin_id) + try: + return gpio.get_output_pin(pin_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc -@router.patch("/{pin_id}") +@router.patch("/{pin_id}", response_model=bool) async def set_pin(pin_id: int, status: bool): """Set GPIO pin output value @@ -40,14 +43,22 @@ async def set_pin(pin_id: int, status: bool): pin_id: The ID (int) of the GPIO pin to set the value of status: The output value to set for the GPIO pin """ - return gpio.set_output_pin(pin_id, status) + try: + return gpio.set_output_pin(pin_id, status, auto_initialize=True) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc -@router.patch("/{pin_id}/toggle") +@router.patch("/{pin_id}/toggle", response_model=bool) async def toggle_pin(pin_id: int): """Toggle GPIO pin output value Args: pin_id: The ID (int) of the GPIO pin to toggle """ - return gpio.toggle_output_pin(pin_id) + try: + return gpio.toggle_output_pin(pin_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc From 49af55bc3f8a88797cbefc7fe5d590073bff2768 Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 9 Apr 2026 12:28:57 +0200 Subject: [PATCH 4/6] test(tasks, projects): update tests for network readiness and datetime handling --- .../services/tasks/test_qr_scan_task.py | 6 ++++-- tests/routers/test_projects_api.py | 20 +++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/controllers/services/tasks/test_qr_scan_task.py b/tests/controllers/services/tasks/test_qr_scan_task.py index 230a8bb..6f13be7 100644 --- a/tests/controllers/services/tasks/test_qr_scan_task.py +++ b/tests/controllers/services/tasks/test_qr_scan_task.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock @@ -72,6 +72,7 @@ def feed(self, _frame): monkeypatch.setattr("openscan_firmware.utils.qr_reader.ZxingQRReader", lambda: object()) monkeypatch.setattr("openscan_firmware.utils.qr_reader.StableQRConsensus", DummyConsensus) + monkeypatch.setattr("openscan_firmware.utils.wifi.is_network_ready_for_qr_scan", lambda: False) def fake_parse_wifi_qr(_text: str) -> SimpleNamespace: return SimpleNamespace(ssid="TestNet", security="WPA2", hidden=False) @@ -121,6 +122,7 @@ def feed(self, _frame): monkeypatch.setattr("openscan_firmware.utils.qr_reader.ZxingQRReader", lambda: object()) monkeypatch.setattr("openscan_firmware.utils.qr_reader.StableQRConsensus", AlwaysFoundConsensus) + monkeypatch.setattr("openscan_firmware.utils.wifi.is_network_ready_for_qr_scan", lambda: False) def fake_parse_wifi_qr(_text: str) -> SimpleNamespace: return SimpleNamespace(ssid="BrokenNet", security="WPA2", hidden=False) @@ -193,7 +195,7 @@ async def test_cleanup_stale_qr_tasks_removes_cancelled_and_limits_errors(monkey monkeypatch.setattr(qr_module, "get_task_manager", lambda: qr_task_manager) - now = datetime.utcnow() + now = datetime.now(UTC) statuses = [ (TaskStatus.CANCELLED, -10), (TaskStatus.INTERRUPTED, -9), diff --git a/tests/routers/test_projects_api.py b/tests/routers/test_projects_api.py index 1253dc2..77d7f72 100644 --- a/tests/routers/test_projects_api.py +++ b/tests/routers/test_projects_api.py @@ -24,7 +24,7 @@ from openscan_firmware.controllers.services.tasks import task_manager as task_manager_module from openscan_firmware.controllers.services.tasks.core.cloud_task import CloudUploadTask from openscan_firmware.controllers.services.tasks.task_manager import TaskManager -from openscan_firmware.main import app +from openscan_firmware.main import app, LATEST from openscan_firmware.models.project import Project from openscan_firmware.models.task import Task, TaskStatus from openscan_firmware.config.scan import ScanSetting @@ -39,6 +39,7 @@ def project_manager(monkeypatch: pytest.MonkeyPatch, tmp_path_factory) -> Genera pm = ProjectManager(path=temp_dir) module_path_v0_8 = "openscan_firmware.routers.v0_8.projects" + latest_module_path = f"openscan_firmware.routers.v{LATEST.replace('.', '_')}.projects" next_module_path = "openscan_firmware.routers.next.projects" monkeypatch.setattr( @@ -46,7 +47,22 @@ def project_manager(monkeypatch: pytest.MonkeyPatch, tmp_path_factory) -> Genera lambda path=None: pm, raising=False, ) - for module_path in (module_path_v0_8, next_module_path): + monkeypatch.setattr( + "openscan_firmware.controllers.device.get_project_manager", + lambda: pm, + raising=False, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.device._detect_cameras", + lambda: {}, + raising=False, + ) + monkeypatch.setattr( + "openscan_firmware.main.is_network_ready_for_qr_scan", + lambda: True, + raising=False, + ) + for module_path in (module_path_v0_8, latest_module_path, next_module_path): monkeypatch.setattr( module_path + ".get_project_manager", lambda: pm, From 0c82de67fd1e5d683cd79a69396ce189d1caa7e1 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 8 Apr 2026 16:19:10 +0200 Subject: [PATCH 5/6] feat(firmware): add camera preview setting and extend trigger functionality - Introduced `camera_preview_enabled` setting for trigger-only setups without live preview. - Extended QR WiFi auto-start logic to respect camera preview setting. - Introduced unified `Trigger` interface for standardizing triggerable hardware. - Added support for defining and executing external trigger runs via GPIO. - Added `ExternalTriggerRunTask` for managing trigger sequences and paths. - Updated device, settings, and schema to include new trigger objects. - Reorganized `next` and OpenAPI routes to reflect trigger consolidation. - Introduced comprehensive tests for `external_trigger` and `external_trigger_runs` routers. - Removed `EXTERNAL` camera type, refactored code accordingly. --- .../config/external_trigger_run.py | 65 + openscan_firmware/config/firmware.py | 7 + openscan_firmware/config/trigger.py | 30 + openscan_firmware/controllers/device.py | 39 + .../controllers/hardware/interfaces.py | 10 + .../controllers/hardware/triggers.py | 128 ++ .../services/external_trigger_runs.py | 126 ++ .../tasks/core/external_trigger_run_task.py | 118 ++ .../services/tasks/task_manager.py | 4 + openscan_firmware/main.py | 11 +- openscan_firmware/models/camera.py | 1 - .../models/external_trigger_run.py | 21 + openscan_firmware/models/scanner.py | 4 + openscan_firmware/models/trigger.py | 8 + openscan_firmware/routers/next/device.py | 4 +- .../routers/next/external_trigger_runs.py | 95 ++ openscan_firmware/routers/next/triggers.py | 83 + scripts/openapi/openapi_latest.json | 70 +- scripts/openapi/openapi_next.json | 1401 ++++++++++++++--- scripts/openapi/openapi_v0.8.json | 80 +- scripts/openapi/openapi_v0.9.json | 70 +- .../test_external_trigger_run_task.py | 76 + .../test_external_trigger_runs_service.py | 191 +++ .../services/test_external_trigger_service.py | 77 + tests/routers/test_device_router.py | 2 + tests/routers/test_firmware_router.py | 64 +- .../test_next_external_trigger_router.py | 87 + .../test_next_external_trigger_runs_router.py | 157 ++ 28 files changed, 2778 insertions(+), 251 deletions(-) create mode 100644 openscan_firmware/config/external_trigger_run.py create mode 100644 openscan_firmware/config/trigger.py create mode 100644 openscan_firmware/controllers/hardware/triggers.py create mode 100644 openscan_firmware/controllers/services/external_trigger_runs.py create mode 100644 openscan_firmware/controllers/services/tasks/core/external_trigger_run_task.py create mode 100644 openscan_firmware/models/external_trigger_run.py create mode 100644 openscan_firmware/models/trigger.py create mode 100644 openscan_firmware/routers/next/external_trigger_runs.py create mode 100644 openscan_firmware/routers/next/triggers.py create mode 100644 tests/controllers/services/test_external_trigger_run_task.py create mode 100644 tests/controllers/services/test_external_trigger_runs_service.py create mode 100644 tests/controllers/services/test_external_trigger_service.py create mode 100644 tests/routers/test_next_external_trigger_router.py create mode 100644 tests/routers/test_next_external_trigger_runs_router.py diff --git a/openscan_firmware/config/external_trigger_run.py b/openscan_firmware/config/external_trigger_run.py new file mode 100644 index 0000000..3129a1a --- /dev/null +++ b/openscan_firmware/config/external_trigger_run.py @@ -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", + ) diff --git a/openscan_firmware/config/firmware.py b/openscan_firmware/config/firmware.py index 545fa75..98005d1 100644 --- a/openscan_firmware/config/firmware.py +++ b/openscan_firmware/config/firmware.py @@ -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( @@ -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. diff --git a/openscan_firmware/config/trigger.py b/openscan_firmware/config/trigger.py new file mode 100644 index 0000000..7709070 --- /dev/null +++ b/openscan_firmware/config/trigger.py @@ -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 diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index ea89379..6e71744 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -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, @@ -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, @@ -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 @@ -88,6 +95,7 @@ def _create_default_scanner_device() -> ScannerDevice: cameras={}, motors={}, lights={}, + triggers={}, endstops={}, ) # beware, PrivateAttr are NOT initialized in constructor @@ -105,6 +113,7 @@ def _create_default_scanner_device() -> ScannerDevice: cameras={}, motors={}, lights={}, + triggers={}, endstops={}, ).model_dump(mode="json") @@ -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() @@ -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, @@ -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: @@ -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() @@ -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: @@ -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() @@ -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 diff --git a/openscan_firmware/controllers/hardware/interfaces.py b/openscan_firmware/controllers/hardware/interfaces.py index 18920f7..7ae09bc 100644 --- a/openscan_firmware/controllers/hardware/interfaces.py +++ b/openscan_firmware/controllers/hardware/interfaces.py @@ -62,6 +62,16 @@ def is_on(self) -> bool: """Check if hardware is turned on""" ... + +@runtime_checkable +class TriggerableHardware(StatefulHardware[T], Protocol[T]): + """Interface for hardware that can be explicitly triggered.""" + + @abstractmethod + async def trigger(self, pre_trigger_delay_ms: int = 0, post_trigger_delay_ms: int = 0): + """Fire the trigger once and optionally wait before/after the pulse.""" + ... + @runtime_checkable class EventHardware(HardwareInterface[T], Protocol[T]): """Interface for hardware that generates events (buttons, sensors, etc.)""" diff --git a/openscan_firmware/controllers/hardware/triggers.py b/openscan_firmware/controllers/hardware/triggers.py new file mode 100644 index 0000000..89a57c8 --- /dev/null +++ b/openscan_firmware/controllers/hardware/triggers.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime + +from openscan_firmware.config.trigger import TriggerActiveLevel, TriggerConfig +from openscan_firmware.controllers.hardware import gpio +from openscan_firmware.controllers.hardware.interfaces import TriggerableHardware, create_controller_registry +from openscan_firmware.controllers.settings import Settings +from openscan_firmware.controllers.services.device_events import notify_busy_change, schedule_device_status_broadcast +from openscan_firmware.models.trigger import Trigger + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TriggerExecution: + triggered_at: datetime + completed_at: datetime + duration_ms: int + + +class TriggerController(TriggerableHardware[TriggerConfig]): + """GPIO-backed trigger controller with persistent device-level settings.""" + + def __init__(self, trigger: Trigger): + self.model = trigger + self.settings = Settings(trigger.settings, on_change=self._apply_settings_to_hardware) + self._busy = False + self._last_execution: TriggerExecution | None = None + self._apply_settings_to_hardware(self.settings.model) + + def _resolve_logic_levels(self, settings: TriggerConfig) -> tuple[bool, bool]: + active_state = settings.active_level == TriggerActiveLevel.ACTIVE_HIGH + inactive_state = not active_state + return active_state, inactive_state + + def _apply_settings_to_hardware(self, settings: TriggerConfig) -> None: + self.model.settings = settings + _, inactive_state = self._resolve_logic_levels(settings) + gpio.initialize_output_pins([settings.pin]) + gpio.set_output_pin(settings.pin, inactive_state) + schedule_device_status_broadcast([f"triggers.{self.model.name}.settings"]) + + def get_status(self) -> dict: + return { + "name": self.model.name, + "busy": self._busy, + "settings": self.get_config().model_dump(), + "last_triggered_at": self._last_execution.triggered_at if self._last_execution else None, + "last_completed_at": self._last_execution.completed_at if self._last_execution else None, + "last_duration_ms": self._last_execution.duration_ms if self._last_execution else None, + } + + def get_config(self) -> TriggerConfig: + return self.settings.model + + def is_busy(self) -> bool: + return self._busy + + def _set_busy(self, busy: bool) -> None: + if self._busy == busy: + return + self._busy = busy + notify_busy_change("triggers", self.model.name) + + async def trigger( + self, + pre_trigger_delay_ms: int = 0, + post_trigger_delay_ms: int = 0, + ) -> TriggerExecution: + settings = self.settings.model + if not settings.enabled: + raise RuntimeError(f"Trigger '{self.model.name}' is disabled.") + if self._busy: + raise RuntimeError(f"Trigger '{self.model.name}' is already busy.") + + active_state, inactive_state = self._resolve_logic_levels(settings) + self._set_busy(True) + try: + if pre_trigger_delay_ms: + await asyncio.sleep(pre_trigger_delay_ms / 1000) + + triggered_at = datetime.now() + gpio.set_output_pin(settings.pin, active_state) + await asyncio.sleep(settings.pulse_width_ms / 1000) + gpio.set_output_pin(settings.pin, inactive_state) + + if post_trigger_delay_ms: + await asyncio.sleep(post_trigger_delay_ms / 1000) + + completed_at = datetime.now() + execution = TriggerExecution( + triggered_at=triggered_at, + completed_at=completed_at, + duration_ms=max(0, int((completed_at - triggered_at).total_seconds() * 1000)), + ) + self._last_execution = execution + schedule_device_status_broadcast([f"triggers.{self.model.name}"]) + return execution + finally: + self._set_busy(False) + + async def reset(self) -> None: + settings = self.settings.model + _, inactive_state = self._resolve_logic_levels(settings) + gpio.initialize_output_pins([settings.pin]) + gpio.set_output_pin(settings.pin, inactive_state) + + def cleanup(self) -> None: + try: + settings = self.settings.model + _, inactive_state = self._resolve_logic_levels(settings) + gpio.initialize_output_pins([settings.pin]) + gpio.set_output_pin(settings.pin, inactive_state) + except Exception as exc: # pragma: no cover - defensive cleanup + logger.warning("Failed to cleanup trigger '%s': %s", self.model.name, exc) + + +create_trigger_controller, get_trigger_controller, remove_trigger_controller, _trigger_registry = create_controller_registry(TriggerController) + + +def get_all_trigger_controllers(): + """Get all currently registered trigger controllers.""" + return _trigger_registry.copy() diff --git a/openscan_firmware/controllers/services/external_trigger_runs.py b/openscan_firmware/controllers/services/external_trigger_runs.py new file mode 100644 index 0000000..bd7b12c --- /dev/null +++ b/openscan_firmware/controllers/services/external_trigger_runs.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from openscan_firmware.config.external_trigger_run import ExternalTriggerRunSettings +from openscan_firmware.controllers.hardware.triggers import get_trigger_controller +from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager +from openscan_firmware.models.external_trigger_run import ExternalTriggerRunPath +from openscan_firmware.models.task import Task +from openscan_firmware.utils.dir_paths import resolve_runtime_dir + + +logger = logging.getLogger(__name__) + + +RUN_STORAGE_DIRNAME = "external-trigger-runs" +PATH_FILE_NAME = "path.json" +LEGACY_MANIFEST_FILE_NAME = "manifest.json" + +_run_manager_instance: "ExternalTriggerRunManager | None" = None + + +def _write_text_atomic(file_path: Path, payload: str) -> None: + file_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = file_path.with_name(f".tmp_{file_path.name}") + tmp_path.write_text(payload, encoding="utf-8") + tmp_path.replace(file_path) + + +class ExternalTriggerRunManager: + """Persistence manager for static path data of external trigger runs.""" + + def __init__(self, path: str | Path | None = None): + self._path = Path(path) if path is not None else resolve_runtime_dir(RUN_STORAGE_DIRNAME) + self._path.mkdir(parents=True, exist_ok=True) + + @property + def path(self) -> Path: + return self._path + + def _run_dir(self, task_id: str) -> Path: + return self._path / task_id + + def path_file(self, task_id: str) -> Path: + return self._run_dir(task_id) / PATH_FILE_NAME + + def _legacy_manifest_file(self, task_id: str) -> Path: + return self._run_dir(task_id) / LEGACY_MANIFEST_FILE_NAME + + def get_path_data(self, task_id: str) -> ExternalTriggerRunPath | None: + path_file = self.path_file(task_id) + if path_file.exists(): + return ExternalTriggerRunPath.model_validate_json(path_file.read_text(encoding="utf-8")) + + legacy_manifest_file = self._legacy_manifest_file(task_id) + if not legacy_manifest_file.exists(): + return None + return ExternalTriggerRunPath.model_validate_json(legacy_manifest_file.read_text(encoding="utf-8")) + + def save_path_data(self, path_data: ExternalTriggerRunPath | dict) -> ExternalTriggerRunPath: + if not isinstance(path_data, ExternalTriggerRunPath): + path_data = ExternalTriggerRunPath.model_validate(path_data) + _write_text_atomic(self.path_file(path_data.task_id), path_data.model_dump_json(indent=2)) + return path_data + + +def get_external_trigger_run_manager(path: str | Path | None = None) -> ExternalTriggerRunManager: + global _run_manager_instance + + if path is not None: + return ExternalTriggerRunManager(path=path) + + if _run_manager_instance is None: + _run_manager_instance = ExternalTriggerRunManager() + return _run_manager_instance + + +def reset_external_trigger_run_manager() -> None: + global _run_manager_instance + _run_manager_instance = None + + +def _is_external_trigger_task(task: Task) -> bool: + return task.name == "external_trigger_run_task" or task.task_type == "external_trigger_run_task" + + +def get_external_trigger_task(task_id: str) -> Task | None: + task = get_task_manager().get_task_info(task_id) + if task is None or not _is_external_trigger_task(task): + return None + return task + + +def list_external_trigger_tasks() -> list[Task]: + tasks = [task for task in get_task_manager().get_all_tasks_info() if _is_external_trigger_task(task)] + return sorted(tasks, key=lambda task: task.created_at, reverse=True) + + +async def start_external_trigger_run( + *, + settings: ExternalTriggerRunSettings, + label: str | None = None, + description: str | None = None, + start_from_step: int = 0, +) -> Task: + get_trigger_controller(settings.trigger_name) + return await get_task_manager().create_and_run_task( + "external_trigger_run_task", + settings.model_dump(mode="json"), + label=label, + description=description, + start_from_step=start_from_step, + ) + + +async def cancel_external_trigger_run(task_id: str) -> Task | None: + return await get_task_manager().cancel_task(task_id) + + +async def pause_external_trigger_run(task_id: str) -> Task | None: + return await get_task_manager().pause_task(task_id) + + +async def resume_external_trigger_run(task_id: str) -> Task | None: + return await get_task_manager().resume_task(task_id) diff --git a/openscan_firmware/controllers/services/tasks/core/external_trigger_run_task.py b/openscan_firmware/controllers/services/tasks/core/external_trigger_run_task.py new file mode 100644 index 0000000..e59a7ab --- /dev/null +++ b/openscan_firmware/controllers/services/tasks/core/external_trigger_run_task.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import logging +from typing import AsyncGenerator + +from openscan_firmware.config.external_trigger_run import ExternalTriggerRunSettings +from openscan_firmware.controllers.hardware.triggers import TriggerController, get_trigger_controller +from openscan_firmware.controllers.services.external_trigger_runs import get_external_trigger_run_manager +from openscan_firmware.controllers.services.tasks.base_task import BaseTask +from openscan_firmware.controllers.services.tasks.core.scan_task import generate_scan_path +from openscan_firmware.models.external_trigger_run import ( + ExternalTriggerPoint, + ExternalTriggerRunPath, +) +from openscan_firmware.models.paths import PolarPoint3D +from openscan_firmware.models.task import TaskProgress +from openscan_firmware.utils.paths.paths import polar_to_cartesian + + +logger = logging.getLogger(__name__) + + +class ExternalTriggerRunTask(BaseTask): + """Execute a motor path while triggering an external camera over GPIO.""" + + task_name = "external_trigger_run_task" + task_category = "core" + is_exclusive = True + + async def _cleanup_run(self, trigger: TriggerController) -> None: + """Reset trigger state and move motors back to the default origin.""" + from openscan_firmware.controllers.hardware import motors + + try: + await trigger.reset() + except Exception as exc: + logger.error("Error while resetting external trigger after run: %s", exc, exc_info=True) + + try: + await motors.move_to_point(PolarPoint3D(90, 90)) + except Exception as exc: + logger.error("Error while moving motors back to origin after external trigger run: %s", exc, exc_info=True) + + async def run( + self, + settings: ExternalTriggerRunSettings | dict, + *, + label: str | None = None, + description: str | None = None, + start_from_step: int = 0, + ) -> AsyncGenerator[TaskProgress, None]: + del label, description + + if not isinstance(settings, ExternalTriggerRunSettings): + settings = ExternalTriggerRunSettings.model_validate(settings) + + manager = get_external_trigger_run_manager() + path_dict = generate_scan_path(settings.to_scan_settings()) + total_steps = len(path_dict) + + path_data = ExternalTriggerRunPath( + task_id=self.id, + total_steps=total_steps, + points=[ + ExternalTriggerPoint( + execution_step=execution_step, + original_step=original_step, + polar_coordinates=polar_point, + cartesian_coordinates=polar_to_cartesian(polar_point), + ) + for execution_step, (polar_point, original_step) in enumerate(path_dict.items()) + ], + ) + manager.save_path_data(path_data) + trigger = get_trigger_controller(settings.trigger_name) + try: + current_step = min(int(self._task_model.progress.current), total_steps) + resume_from_step = max(start_from_step, current_step) + path_items = list(path_dict.items()) + + from openscan_firmware.controllers.hardware import motors + + for execution_step in range(resume_from_step, total_steps): + if self.is_cancelled(): + yield TaskProgress(current=self._task_model.progress.current, total=total_steps, message="External trigger run cancelled.") + return + + await self.wait_for_pause() + + if self.is_cancelled(): + yield TaskProgress(current=self._task_model.progress.current, total=total_steps, message="External trigger run cancelled.") + return + + polar_point, original_step = path_items[execution_step] + await motors.move_to_point(polar_point) + await trigger.trigger( + pre_trigger_delay_ms=settings.pre_trigger_delay_ms, + post_trigger_delay_ms=settings.post_trigger_delay_ms, + ) + + progress = TaskProgress( + current=execution_step + 1, + total=total_steps, + message="External trigger run in progress.", + ) + self._task_model.progress = progress + yield progress + + self._task_model.result = { + "task_id": self.id, + "path_path": str(manager.path_file(self.id)), + } + yield TaskProgress(current=total_steps, total=total_steps, message="External trigger run completed successfully.") + except Exception as exc: + logger.error("External trigger run %s failed: %s", self.id, exc, exc_info=True) + raise + finally: + await self._cleanup_run(trigger) diff --git a/openscan_firmware/controllers/services/tasks/task_manager.py b/openscan_firmware/controllers/services/tasks/task_manager.py index 4d12d9a..34a5fef 100644 --- a/openscan_firmware/controllers/services/tasks/task_manager.py +++ b/openscan_firmware/controllers/services/tasks/task_manager.py @@ -128,6 +128,9 @@ def initialize_core_tasks( def _register_builtin_core_tasks(self) -> None: """Register the built-in core tasks for manual/fallback mode.""" from openscan_firmware.controllers.services.tasks.core.scan_task import ScanTask as CoreScanTask + from openscan_firmware.controllers.services.tasks.core.external_trigger_run_task import ( + ExternalTriggerRunTask as CoreExternalTriggerRunTask, + ) from openscan_firmware.controllers.services.tasks.core.focus_stacking_task import ( FocusStackingTask as CoreFocusStackingTask, ) @@ -141,6 +144,7 @@ def _register_builtin_core_tasks(self) -> None: fallback_tasks = { "scan_task": CoreScanTask, + "external_trigger_run_task": CoreExternalTriggerRunTask, "focus_stacking_task": CoreFocusStackingTask, "cloud_upload_task": CoreCloudUploadTask, "cloud_download_task": CoreCloudDownloadTask, diff --git a/openscan_firmware/main.py b/openscan_firmware/main.py index d23d21d..98dd318 100644 --- a/openscan_firmware/main.py +++ b/openscan_firmware/main.py @@ -42,8 +42,10 @@ # next routers from openscan_firmware.routers.next import ( cameras as cameras_next, + external_trigger_runs as external_trigger_runs_next, motors as motors_next, lights as lights_next, + triggers as triggers_next, firmware as firmware_next, projects as projects_next, gpio as gpio_next, @@ -77,6 +79,10 @@ async def _maybe_start_qr_wifi_scan(task_manager) -> None: logger.info("QR WiFi scan is disabled in firmware settings – skipping auto-start.") return + if not firmware_settings.camera_preview_enabled: + logger.info("Camera preview is disabled in firmware settings – skipping QR WiFi scan auto-start.") + return + if is_network_ready_for_qr_scan(): logger.info("Network is already connected (WiFi/LAN) – skipping QR WiFi scan auto-start.") return @@ -99,6 +105,7 @@ async def _maybe_start_qr_wifi_scan(task_manager) -> None: REQUIRED_CORE_TASKS = [ "scan_task", + "external_trigger_run_task", "focus_stacking_task", "cloud_upload_task", "cloud_download_task", @@ -193,10 +200,12 @@ async def lifespan(app: FastAPI): lights_next.router, firmware_next.router, projects_next.router, - gpio_next.router, openscan_next.router, device_next.router, tasks_next.router, + gpio_next.router, + triggers_next.router, + external_trigger_runs_next.router, develop_next.router, cloud_next.router, websocket_router.router, diff --git a/openscan_firmware/models/camera.py b/openscan_firmware/models/camera.py index a22da4c..051477c 100644 --- a/openscan_firmware/models/camera.py +++ b/openscan_firmware/models/camera.py @@ -15,7 +15,6 @@ class CameraType(Enum): GPHOTO2 = "gphoto2" LINUXPY = "linuxpy" PICAMERA2 = "picamera2" - EXTERNAL = "external" class Camera(BaseModel): diff --git a/openscan_firmware/models/external_trigger_run.py b/openscan_firmware/models/external_trigger_run.py new file mode 100644 index 0000000..d3f0053 --- /dev/null +++ b/openscan_firmware/models/external_trigger_run.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import AliasChoices, BaseModel, Field + +from openscan_firmware.models.paths import CartesianPoint3D, PolarPoint3D + + +class ExternalTriggerPoint(BaseModel): + execution_step: int + original_step: int + polar_coordinates: PolarPoint3D + cartesian_coordinates: CartesianPoint3D + + +class ExternalTriggerRunPath(BaseModel): + task_id: str = Field(validation_alias=AliasChoices("task_id", "run_id")) + generated_at: datetime = Field(default_factory=datetime.now) + total_steps: int = Field(0, ge=0) + points: list[ExternalTriggerPoint] = Field(default_factory=list) diff --git a/openscan_firmware/models/scanner.py b/openscan_firmware/models/scanner.py index 2132bec..61baf49 100644 --- a/openscan_firmware/models/scanner.py +++ b/openscan_firmware/models/scanner.py @@ -7,9 +7,11 @@ from openscan_firmware.config.endstop import EndstopConfig from openscan_firmware.config.light import LightConfig from openscan_firmware.config.motor import MotorConfig +from openscan_firmware.config.trigger import TriggerConfig from openscan_firmware.models.camera import Camera, CameraType from openscan_firmware.models.light import Light from openscan_firmware.models.motor import Motor, Endstop +from openscan_firmware.models.trigger import Trigger class ScannerModel(Enum): CLASSIC = "classic" @@ -42,6 +44,7 @@ class ScannerDevice(BaseModel): cameras: dict[str, Camera] motors: dict[str, Motor] lights: dict[str, Light] + triggers: dict[str, Trigger] = Field(default_factory=dict) endstops: Optional[dict[str, Endstop]] # motors timeout in seconds - 0 to disable @@ -75,6 +78,7 @@ class ScannerDeviceConfig(BaseModel): cameras: dict[str, PersistedCameraConfig] = Field(default_factory=dict) motors: dict[str, MotorConfig] = Field(default_factory=dict) lights: dict[str, LightConfig] = Field(default_factory=dict) + triggers: dict[str, TriggerConfig] = Field(default_factory=dict) endstops: dict[str, PersistedEndstopConfig] | None = None motors_timeout: float = 0.0 startup_mode: ScannerStartupMode | str = ScannerStartupMode.STARTUP_ENABLED diff --git a/openscan_firmware/models/trigger.py b/openscan_firmware/models/trigger.py new file mode 100644 index 0000000..731bb95 --- /dev/null +++ b/openscan_firmware/models/trigger.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + +from openscan_firmware.config.trigger import TriggerConfig + + +class Trigger(BaseModel): + name: str + settings: TriggerConfig diff --git a/openscan_firmware/routers/next/device.py b/openscan_firmware/routers/next/device.py index d1dc1eb..fe83d54 100644 --- a/openscan_firmware/routers/next/device.py +++ b/openscan_firmware/routers/next/device.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, HTTPException, UploadFile, File -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, Field, ValidationError from pathlib import Path import os import json @@ -14,6 +14,7 @@ from .cameras import CameraStatusResponse from .motors import MotorStatusResponse from .lights import LightStatusResponse +from .triggers import TriggerStatusResponse router = APIRouter( prefix="/device", @@ -34,6 +35,7 @@ class DeviceStatusResponse(BaseModel): cameras: dict[str, CameraStatusResponse] motors: dict[str, MotorStatusResponse] lights: dict[str, LightStatusResponse] + triggers: dict[str, TriggerStatusResponse] = Field(default_factory=dict) motors_timeout: float startup_mode: ScannerStartupMode calibrate_mode: ScannerCalibrateMode diff --git a/openscan_firmware/routers/next/external_trigger_runs.py b/openscan_firmware/routers/next/external_trigger_runs.py new file mode 100644 index 0000000..0129c92 --- /dev/null +++ b/openscan_firmware/routers/next/external_trigger_runs.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel + +from openscan_firmware.config.external_trigger_run import ExternalTriggerRunSettings +from openscan_firmware.controllers.services.external_trigger_runs import ( + cancel_external_trigger_run, + get_external_trigger_task, + get_external_trigger_run_manager, + list_external_trigger_tasks, + pause_external_trigger_run, + resume_external_trigger_run, + start_external_trigger_run, +) +from openscan_firmware.models.external_trigger_run import ExternalTriggerRunPath +from openscan_firmware.models.task import Task + + +router = APIRouter( + prefix="/external-trigger/runs", + tags=["external-trigger"], + responses={404: {"description": "Not found"}}, +) + + +class ExternalTriggerRunCreateRequest(BaseModel): + label: str | None = None + description: str | None = None + settings: ExternalTriggerRunSettings + + +def _get_existing_task_or_404(task_id: str) -> Task: + task = get_external_trigger_task(task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"External trigger run '{task_id}' not found.") + return task + + +@router.get("/", response_model=list[Task]) +async def list_external_trigger_runs() -> list[Task]: + return list_external_trigger_tasks() + + +@router.post("/", response_model=Task, status_code=status.HTTP_202_ACCEPTED) +async def create_external_trigger_run(request: ExternalTriggerRunCreateRequest) -> Task: + try: + task = await start_external_trigger_run( + label=request.label, + description=request.description, + settings=request.settings, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + return task + + +@router.get("/{task_id}", response_model=Task) +async def get_external_trigger_run(task_id: str) -> Task: + return _get_existing_task_or_404(task_id) + + +@router.get("/{task_id}/path", response_model=ExternalTriggerRunPath) +async def get_external_trigger_run_path(task_id: str) -> ExternalTriggerRunPath: + path_data = get_external_trigger_run_manager().get_path_data(task_id) + if path_data is not None: + return path_data + + if get_external_trigger_task(task_id) is None: + raise HTTPException(status_code=404, detail=f"External trigger run '{task_id}' not found.") + raise HTTPException(status_code=404, detail=f"Path for external trigger run '{task_id}' not available.") + + +@router.patch("/{task_id}/cancel", response_model=Task) +async def cancel_external_trigger_run_endpoint(task_id: str) -> Task: + task = await cancel_external_trigger_run(task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"External trigger run '{task_id}' not found.") + return task + + +@router.patch("/{task_id}/pause", response_model=Task) +async def pause_external_trigger_run_endpoint(task_id: str) -> Task: + task = await pause_external_trigger_run(task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"External trigger run '{task_id}' not found.") + return task + + +@router.patch("/{task_id}/resume", response_model=Task) +async def resume_external_trigger_run_endpoint(task_id: str) -> Task: + task = await resume_external_trigger_run(task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"External trigger run '{task_id}' not found.") + return task diff --git a/openscan_firmware/routers/next/triggers.py b/openscan_firmware/routers/next/triggers.py new file mode 100644 index 0000000..98199a7 --- /dev/null +++ b/openscan_firmware/routers/next/triggers.py @@ -0,0 +1,83 @@ +from datetime import datetime + +from fastapi import APIRouter, Body, HTTPException +from pydantic import BaseModel, Field + +from openscan_firmware.config.trigger import TriggerConfig +from openscan_firmware.controllers.hardware.triggers import get_all_trigger_controllers, get_trigger_controller +from .settings_utils import create_settings_endpoints + + +router = APIRouter( + prefix="/triggers", + tags=["triggers"], + responses={404: {"description": "Not found"}}, +) + + +class TriggerStatusResponse(BaseModel): + name: str + busy: bool + settings: TriggerConfig + last_triggered_at: datetime | None = None + last_completed_at: datetime | None = None + last_duration_ms: int | None = None + + +class TriggerExecutionRequest(BaseModel): + pre_trigger_delay_ms: int = Field(default=0, ge=0, le=30_000) + post_trigger_delay_ms: int = Field(default=0, ge=0, le=30_000) + + +class TriggerExecutionResponse(BaseModel): + name: str + triggered_at: datetime + completed_at: datetime + duration_ms: int + + +@router.get("/", response_model=dict[str, TriggerStatusResponse]) +async def get_triggers(): + return { + name: controller.get_status() + for name, controller in get_all_trigger_controllers().items() + } + + +@router.get("/{trigger_name}", response_model=TriggerStatusResponse) +async def get_trigger(trigger_name: str): + try: + return get_trigger_controller(trigger_name).get_status() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.post("/{trigger_name}/trigger", response_model=TriggerExecutionResponse) +async def trigger_once( + trigger_name: str, + request: TriggerExecutionRequest | None = Body(default=None), +): + request = request or TriggerExecutionRequest() + try: + controller = get_trigger_controller(trigger_name) + execution = await controller.trigger( + pre_trigger_delay_ms=request.pre_trigger_delay_ms, + post_trigger_delay_ms=request.post_trigger_delay_ms, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + return TriggerExecutionResponse( + name=trigger_name, + triggered_at=execution.triggered_at, + completed_at=execution.completed_at, + duration_ms=execution.duration_ms, + ) + + +create_settings_endpoints( + router=router, + resource_name="trigger_name", + get_controller=get_trigger_controller, + settings_model=TriggerConfig, +) diff --git a/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index 78054b0..ec722fd 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -2709,7 +2709,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" + } } } }, @@ -2753,7 +2756,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" + } } } }, @@ -4831,8 +4837,7 @@ "enum": [ "gphoto2", "linuxpy", - "picamera2", - "external" + "picamera2" ], "title": "CameraType" }, @@ -5950,6 +5955,7 @@ "type": "string", "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -6080,6 +6086,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { @@ -6180,6 +6193,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { @@ -6480,6 +6500,48 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerConfig": { + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether this trigger can be fired.", + "default": true + }, + "pin": { + "type": "integer", + "minimum": 0.0, + "title": "Pin", + "description": "BCM GPIO pin used for the trigger line." + }, + "polarity": { + "$ref": "#/components/schemas/TriggerPolarity", + "description": "Defines whether the trigger line is active-high or active-low.", + "default": "active_high" + }, + "pulse_width_ms": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Pulse Width Ms", + "description": "How long the trigger line stays active for each trigger pulse.", + "default": 100 + } + }, + "type": "object", + "required": [ + "pin" + ], + "title": "TriggerConfig" + }, + "TriggerPolarity": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerPolarity" + }, "ValidationError": { "properties": { "loc": { diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index 1e1d28f..d797a93 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -172,6 +172,7 @@ "schema": { "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -535,43 +536,551 @@ } } }, + "/external-trigger/runs/": { + "get": { + "tags": [ + "external-trigger" + ], + "summary": "List External Trigger Runs", + "operationId": "list_external_trigger_runs", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Task" + }, + "type": "array", + "title": "Response List External Trigger Runs External Trigger Runs Get" + } + } + } + }, + "404": { + "description": "Not found" + } + } + }, + "post": { + "tags": [ + "external-trigger" + ], + "summary": "Create External Trigger Run", + "operationId": "create_external_trigger_run", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalTriggerRunCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/external-trigger/runs/{task_id}": { + "get": { + "tags": [ + "external-trigger" + ], + "summary": "Get External Trigger Run", + "operationId": "get_external_trigger_run", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/external-trigger/runs/{task_id}/path": { + "get": { + "tags": [ + "external-trigger" + ], + "summary": "Get External Trigger Run Path", + "operationId": "get_external_trigger_run_path", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalTriggerRunPath" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/external-trigger/runs/{task_id}/cancel": { + "patch": { + "tags": [ + "external-trigger" + ], + "summary": "Cancel External Trigger Run Endpoint", + "operationId": "cancel_external_trigger_run_endpoint", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/external-trigger/runs/{task_id}/pause": { + "patch": { + "tags": [ + "external-trigger" + ], + "summary": "Pause External Trigger Run Endpoint", + "operationId": "pause_external_trigger_run_endpoint", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/external-trigger/runs/{task_id}/resume": { + "patch": { + "tags": [ + "external-trigger" + ], + "summary": "Resume External Trigger Run Endpoint", + "operationId": "resume_external_trigger_run_endpoint", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/motors/": { "get": { "tags": [ "motors" ], - "summary": "Get Motors", - "description": "Get all motors with their current status\n\nReturns:\n dict[str, MotorStatusResponse]: A dictionary of motor name to a motor status object", - "operationId": "get_motors", + "summary": "Get Motors", + "description": "Get all motors with their current status\n\nReturns:\n dict[str, MotorStatusResponse]: A dictionary of motor name to a motor status object", + "operationId": "get_motors", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "$ref": "#/components/schemas/MotorStatusResponse" + }, + "type": "object", + "title": "Response Get Motors Motors Get" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/motors/{motor_name}": { + "get": { + "tags": [ + "motors" + ], + "summary": "Get Motor", + "description": "Get motor status\n\nArgs:\n motor_name: The name of the motor to get the status of\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor", + "operationId": "get_motor", + "parameters": [ + { + "name": "motor_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Motor Name" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MotorStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/motors/{motor_name}/angle": { + "put": { + "tags": [ + "motors" + ], + "summary": "Move Motor To Angle", + "description": "Move motor to absolute position\n\nArgs:\n motor_name: The name of the motor to move\n degrees: Number of degrees to move\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor after the move", + "operationId": "move_motor_to_angle", + "parameters": [ + { + "name": "motor_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Motor Name" + } + }, + { + "name": "degrees", + "in": "query", + "required": true, + "schema": { + "type": "number", + "title": "Degrees" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MotorStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "motors" + ], + "summary": "Move Motor By Degree", + "description": "Move motor by degrees\n\nArgs:\n motor_name: The name of the motor to move\n degrees: Number of degrees to move\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor after the move", + "operationId": "move_motor_by_degree", + "parameters": [ + { + "name": "motor_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Motor Name" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_move_motor_by_degree_motors__motor_name__angle_patch" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MotorStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/motors/{motor_name}/angle-override": { + "put": { + "tags": [ + "motors" + ], + "summary": "Override Motor Angle", + "description": "Override the internal motor angle model.\n\nThis endpoint forces the controller's model to a specific angle without moving hardware. The\ndefault of 90\u00b0 assumes the motor was manually aligned beforehand. Changing this value without\nconfirming the actual motor position can desynchronize the model from reality and cause motion\nissues. The override is rejected while the controller reports a busy state to avoid writing an\ninconsistent angle during movements.\n\nArgs:\n motor_name: Identifier of the motor whose model should be overwritten.\n angle: The new angle to store in the model (defaults to 90\u00b0).\n\nReturns:\n MotorStatusResponse: Updated status after overriding the model angle.", + "operationId": "override_motor_angle", + "parameters": [ + { + "name": "motor_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Motor Name" + } + }, + { + "name": "angle", + "in": "query", + "required": false, + "schema": { + "type": "number", + "description": "Angle value that will overwrite the controller's internal model. Only change this after verifying the physical motor position because no positional feedback is available.", + "default": 90.0, + "title": "Angle" + }, + "description": "Angle value that will overwrite the controller's internal model. Only change this after verifying the physical motor position because no positional feedback is available." + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": { - "$ref": "#/components/schemas/MotorStatusResponse" - }, - "type": "object", - "title": "Response Get Motors Motors Get" + "$ref": "#/components/schemas/MotorStatusResponse" } } } }, "404": { "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } }, - "/motors/{motor_name}": { - "get": { + "/motors/{motor_name}/endstop-calibration": { + "put": { "tags": [ "motors" ], - "summary": "Get Motor", - "description": "Get motor status\n\nArgs:\n motor_name: The name of the motor to get the status of\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor", - "operationId": "get_motor", + "summary": "Motor Endstop Calibration", + "description": "Move motor to home through endstop sensing\n\nThis endpoint moves the motor to the home position using the endstop calibration.\n\nArgs:\n motor_name: The name of the motor to move to the home position\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor after the move", + "operationId": "motor_endstop_calibration", "parameters": [ { "name": "motor_name", @@ -581,6 +1090,18 @@ "type": "string", "title": "Motor Name" } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Force recalibration even if the controller already considers the motor calibrated.", + "default": false, + "title": "Force" + }, + "description": "Force recalibration even if the controller already considers the motor calibrated." } ], "responses": { @@ -610,14 +1131,14 @@ } } }, - "/motors/{motor_name}/angle": { + "/motors/{motor_name}/home": { "put": { "tags": [ "motors" ], - "summary": "Move Motor To Angle", - "description": "Move motor to absolute position\n\nArgs:\n motor_name: The name of the motor to move\n degrees: Number of degrees to move\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor after the move", - "operationId": "move_motor_to_angle", + "summary": "Motor Move Home", + "description": "Move motor to home \n\nThis endpoint moves the motor to the home position uning method depending on config param\n\nArgs:\n motor_name: The name of the motor to move to the home position\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor after the move", + "operationId": "motor_move_home", "parameters": [ { "name": "motor_name", @@ -627,14 +1148,51 @@ "type": "string", "title": "Motor Name" } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MotorStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/motors/{name}/settings": { + "get": { + "tags": [ + "motors" + ], + "summary": "Get Motor Name Settings", + "description": "Get settings for a specific resource", + "operationId": "get_motor_name_settings", + "parameters": [ { - "name": "degrees", - "in": "query", + "name": "name", + "in": "path", "required": true, "schema": { - "type": "number", - "title": "Degrees" + "type": "string", + "title": "Name" } } ], @@ -644,7 +1202,61 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/MotorConfig" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "motors" + ], + "summary": "Replace Motor Name Settings", + "description": "Replace all settings for a specific resource", + "operationId": "replace_motor_name_settings", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Name" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MotorConfig" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MotorConfig" } } } @@ -666,39 +1278,121 @@ }, "patch": { "tags": [ - "motors" + "motors" + ], + "summary": "Update Motor Name Settings", + "description": "Update one or more specific settings for a resource\n\nArgs:\n name: The name of the resource to update settings for\n settings: A dictionary of settings to update\n\nReturns:\n The updated settings for the resource", + "operationId": "update_motor_name_settings", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Name" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "examples": [ + { + "some_setting": 123 + } + ], + "title": "Settings" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MotorConfig" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/lights/": { + "get": { + "tags": [ + "lights" + ], + "summary": "Get Lights", + "description": "Get all lights with their current status\n\nReturns:\n dict[str, LightStatusResponse]: A dictionary of light name to a light status object", + "operationId": "get_lights", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "$ref": "#/components/schemas/LightStatusResponse" + }, + "type": "object", + "title": "Response Get Lights Lights Get" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/lights/{light_name}": { + "get": { + "tags": [ + "lights" ], - "summary": "Move Motor By Degree", - "description": "Move motor by degrees\n\nArgs:\n motor_name: The name of the motor to move\n degrees: Number of degrees to move\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor after the move", - "operationId": "move_motor_by_degree", + "summary": "Get Light", + "description": "Get light status\n\nArgs:\n light_name: The name of the light to get the status of\n\nReturns:\n LightStatusResponse: A response object containing the status of the light", + "operationId": "get_light", "parameters": [ { - "name": "motor_name", + "name": "light_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Motor Name" + "title": "Light Name" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_move_motor_by_degree_motors__motor_name__angle_patch" - } - } - } - }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/LightStatusResponse" } } } @@ -719,35 +1413,23 @@ } } }, - "/motors/{motor_name}/angle-override": { - "put": { + "/lights/{light_name}/turn_on": { + "patch": { "tags": [ - "motors" + "lights" ], - "summary": "Override Motor Angle", - "description": "Override the internal motor angle model.\n\nThis endpoint forces the controller's model to a specific angle without moving hardware. The\ndefault of 90\u00b0 assumes the motor was manually aligned beforehand. Changing this value without\nconfirming the actual motor position can desynchronize the model from reality and cause motion\nissues. The override is rejected while the controller reports a busy state to avoid writing an\ninconsistent angle during movements.\n\nArgs:\n motor_name: Identifier of the motor whose model should be overwritten.\n angle: The new angle to store in the model (defaults to 90\u00b0).\n\nReturns:\n MotorStatusResponse: Updated status after overriding the model angle.", - "operationId": "override_motor_angle", + "summary": "Turn On Light", + "description": "Turn on light\n\nArgs:\n light_name: The name of the light to turn on\n\nReturns:\n LightStatusResponse: A response object containing the status of the light after the turn on operation", + "operationId": "turn_on_light", "parameters": [ { - "name": "motor_name", + "name": "light_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Motor Name" + "title": "Light Name" } - }, - { - "name": "angle", - "in": "query", - "required": false, - "schema": { - "type": "number", - "description": "Angle value that will overwrite the controller's internal model. Only change this after verifying the physical motor position because no positional feedback is available.", - "default": 90.0, - "title": "Angle" - }, - "description": "Angle value that will overwrite the controller's internal model. Only change this after verifying the physical motor position because no positional feedback is available." } ], "responses": { @@ -756,7 +1438,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/LightStatusResponse" } } } @@ -777,35 +1459,23 @@ } } }, - "/motors/{motor_name}/endstop-calibration": { - "put": { + "/lights/{light_name}/turn_off": { + "patch": { "tags": [ - "motors" + "lights" ], - "summary": "Motor Endstop Calibration", - "description": "Move motor to home through endstop sensing\n\nThis endpoint moves the motor to the home position using the endstop calibration.\n\nArgs:\n motor_name: The name of the motor to move to the home position\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor after the move", - "operationId": "motor_endstop_calibration", + "summary": "Turn Off Light", + "description": "Turn of light\n\nArgs:\n light_name: The name of the light to turn off\n\nReturns:\n LightStatusResponse: A response object containing the status of the light after the turn off operation", + "operationId": "turn_off_light", "parameters": [ { - "name": "motor_name", + "name": "light_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Motor Name" + "title": "Light Name" } - }, - { - "name": "force", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "description": "Force recalibration even if the controller already considers the motor calibrated.", - "default": false, - "title": "Force" - }, - "description": "Force recalibration even if the controller already considers the motor calibrated." } ], "responses": { @@ -814,7 +1484,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/LightStatusResponse" } } } @@ -835,22 +1505,22 @@ } } }, - "/motors/{motor_name}/home": { - "put": { + "/lights/{light_name}/toggle": { + "patch": { "tags": [ - "motors" + "lights" ], - "summary": "Motor Move Home", - "description": "Move motor to home \n\nThis endpoint moves the motor to the home position uning method depending on config param\n\nArgs:\n motor_name: The name of the motor to move to the home position\n\nReturns:\n MotorStatusResponse: A response object containing the status of the motor after the move", - "operationId": "motor_move_home", + "summary": "Toggle Light", + "description": "Toggle light on or off\n\nArgs:\n light_name: The name of the light to toggle\n\nReturns:\n LightStatusResponse: A response object containing the status of the light after the toggle operation", + "operationId": "toggle_light", "parameters": [ { - "name": "motor_name", + "name": "light_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Motor Name" + "title": "Light Name" } } ], @@ -860,7 +1530,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/LightStatusResponse" } } } @@ -881,14 +1551,14 @@ } } }, - "/motors/{name}/settings": { + "/lights/{name}/settings": { "get": { "tags": [ - "motors" + "lights" ], - "summary": "Get Motor Name Settings", + "summary": "Get Light Name Settings", "description": "Get settings for a specific resource", - "operationId": "get_motor_name_settings", + "operationId": "get_light_name_settings", "parameters": [ { "name": "name", @@ -906,7 +1576,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorConfig" + "$ref": "#/components/schemas/LightConfig" } } } @@ -928,11 +1598,11 @@ }, "put": { "tags": [ - "motors" + "lights" ], - "summary": "Replace Motor Name Settings", + "summary": "Replace Light Name Settings", "description": "Replace all settings for a specific resource", - "operationId": "replace_motor_name_settings", + "operationId": "replace_light_name_settings", "parameters": [ { "name": "name", @@ -949,7 +1619,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorConfig" + "$ref": "#/components/schemas/LightConfig" } } } @@ -960,7 +1630,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorConfig" + "$ref": "#/components/schemas/LightConfig" } } } @@ -982,11 +1652,11 @@ }, "patch": { "tags": [ - "motors" + "lights" ], - "summary": "Update Motor Name Settings", + "summary": "Update Light Name Settings", "description": "Update one or more specific settings for a resource\n\nArgs:\n name: The name of the resource to update settings for\n settings: A dictionary of settings to update\n\nReturns:\n The updated settings for the resource", - "operationId": "update_motor_name_settings", + "operationId": "update_light_name_settings", "parameters": [ { "name": "name", @@ -1021,7 +1691,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorConfig" + "$ref": "#/components/schemas/LightConfig" } } } @@ -1042,14 +1712,13 @@ } } }, - "/lights/": { + "/triggers/": { "get": { "tags": [ - "lights" + "triggers" ], - "summary": "Get Lights", - "description": "Get all lights with their current status\n\nReturns:\n dict[str, LightStatusResponse]: A dictionary of light name to a light status object", - "operationId": "get_lights", + "summary": "Get Triggers", + "operationId": "get_triggers", "responses": { "200": { "description": "Successful Response", @@ -1057,10 +1726,10 @@ "application/json": { "schema": { "additionalProperties": { - "$ref": "#/components/schemas/LightStatusResponse" + "$ref": "#/components/schemas/TriggerStatusResponse" }, "type": "object", - "title": "Response Get Lights Lights Get" + "title": "Response Get Triggers Triggers Get" } } } @@ -1071,68 +1740,21 @@ } } }, - "/lights/{light_name}": { + "/triggers/{trigger_name}": { "get": { "tags": [ - "lights" - ], - "summary": "Get Light", - "description": "Get light status\n\nArgs:\n light_name: The name of the light to get the status of\n\nReturns:\n LightStatusResponse: A response object containing the status of the light", - "operationId": "get_light", - "parameters": [ - { - "name": "light_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Light Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LightStatusResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/lights/{light_name}/turn_on": { - "patch": { - "tags": [ - "lights" + "triggers" ], - "summary": "Turn On Light", - "description": "Turn on light\n\nArgs:\n light_name: The name of the light to turn on\n\nReturns:\n LightStatusResponse: A response object containing the status of the light after the turn on operation", - "operationId": "turn_on_light", + "summary": "Get Trigger", + "operationId": "get_trigger", "parameters": [ { - "name": "light_name", + "name": "trigger_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Light Name" + "title": "Trigger Name" } } ], @@ -1142,7 +1764,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightStatusResponse" + "$ref": "#/components/schemas/TriggerStatusResponse" } } } @@ -1163,78 +1785,48 @@ } } }, - "/lights/{light_name}/turn_off": { - "patch": { + "/triggers/{trigger_name}/trigger": { + "post": { "tags": [ - "lights" + "triggers" ], - "summary": "Turn Off Light", - "description": "Turn of light\n\nArgs:\n light_name: The name of the light to turn off\n\nReturns:\n LightStatusResponse: A response object containing the status of the light after the turn off operation", - "operationId": "turn_off_light", + "summary": "Trigger Once", + "operationId": "trigger_once", "parameters": [ { - "name": "light_name", + "name": "trigger_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Light Name" + "title": "Trigger Name" } } ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LightStatusResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/TriggerExecutionRequest" + }, + { + "type": "null" + } + ], + "title": "Request" } } } - } - } - }, - "/lights/{light_name}/toggle": { - "patch": { - "tags": [ - "lights" - ], - "summary": "Toggle Light", - "description": "Toggle light on or off\n\nArgs:\n light_name: The name of the light to toggle\n\nReturns:\n LightStatusResponse: A response object containing the status of the light after the toggle operation", - "operationId": "toggle_light", - "parameters": [ - { - "name": "light_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Light Name" - } - } - ], + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightStatusResponse" + "$ref": "#/components/schemas/TriggerExecutionResponse" } } } @@ -1255,14 +1847,14 @@ } } }, - "/lights/{name}/settings": { + "/triggers/{name}/settings": { "get": { "tags": [ - "lights" + "triggers" ], - "summary": "Get Light Name Settings", + "summary": "Get Trigger Name Settings", "description": "Get settings for a specific resource", - "operationId": "get_light_name_settings", + "operationId": "get_trigger_name_settings", "parameters": [ { "name": "name", @@ -1280,7 +1872,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightConfig" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -1302,11 +1894,11 @@ }, "put": { "tags": [ - "lights" + "triggers" ], - "summary": "Replace Light Name Settings", + "summary": "Replace Trigger Name Settings", "description": "Replace all settings for a specific resource", - "operationId": "replace_light_name_settings", + "operationId": "replace_trigger_name_settings", "parameters": [ { "name": "name", @@ -1323,7 +1915,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightConfig" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -1334,7 +1926,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightConfig" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -1356,11 +1948,11 @@ }, "patch": { "tags": [ - "lights" + "triggers" ], - "summary": "Update Light Name Settings", + "summary": "Update Trigger Name Settings", "description": "Update one or more specific settings for a resource\n\nArgs:\n name: The name of the resource to update settings for\n settings: A dictionary of settings to update\n\nReturns:\n The updated settings for the resource", - "operationId": "update_light_name_settings", + "operationId": "update_trigger_name_settings", "parameters": [ { "name": "name", @@ -1395,7 +1987,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightConfig" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -2709,7 +3301,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" + } } } }, @@ -2753,7 +3348,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" + } } } }, @@ -4831,8 +5429,7 @@ "enum": [ "gphoto2", "linuxpy", - "picamera2", - "external" + "picamera2" ], "title": "CameraType" }, @@ -5188,6 +5785,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerStatusResponse" + }, + "type": "object", + "title": "Triggers" + }, "motors_timeout": { "type": "number", "title": "Motors Timeout" @@ -5370,6 +5974,169 @@ ], "title": "EndstopStatusResponse" }, + "ExternalTriggerPoint": { + "properties": { + "execution_step": { + "type": "integer", + "title": "Execution Step" + }, + "original_step": { + "type": "integer", + "title": "Original Step" + }, + "polar_coordinates": { + "$ref": "#/components/schemas/PolarPoint3D" + }, + "cartesian_coordinates": { + "$ref": "#/components/schemas/CartesianPoint3D" + } + }, + "type": "object", + "required": [ + "execution_step", + "original_step", + "polar_coordinates", + "cartesian_coordinates" + ], + "title": "ExternalTriggerPoint" + }, + "ExternalTriggerRunCreateRequest": { + "properties": { + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "settings": { + "$ref": "#/components/schemas/ExternalTriggerRunSettings" + } + }, + "type": "object", + "required": [ + "settings" + ], + "title": "ExternalTriggerRunCreateRequest" + }, + "ExternalTriggerRunPath": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "generated_at": { + "type": "string", + "format": "date-time", + "title": "Generated At" + }, + "total_steps": { + "type": "integer", + "minimum": 0.0, + "title": "Total Steps", + "default": 0 + }, + "points": { + "items": { + "$ref": "#/components/schemas/ExternalTriggerPoint" + }, + "type": "array", + "title": "Points" + } + }, + "type": "object", + "required": [ + "task_id" + ], + "title": "ExternalTriggerRunPath" + }, + "ExternalTriggerRunSettings": { + "properties": { + "path_method": { + "$ref": "#/components/schemas/PathMethod", + "description": "Scanning path generator for the external trigger run.", + "default": "fibonacci" + }, + "points": { + "type": "integer", + "maximum": 999.0, + "minimum": 1.0, + "title": "Points", + "description": "Number of trigger positions.", + "default": 130 + }, + "min_theta": { + "type": "number", + "maximum": 180.0, + "minimum": 0.0, + "title": "Min Theta", + "description": "Minimum theta angle in degrees for constrained paths.", + "default": 12.0 + }, + "max_theta": { + "type": "number", + "maximum": 180.0, + "minimum": 0.0, + "title": "Max Theta", + "description": "Maximum theta angle in degrees for constrained paths.", + "default": 125.0 + }, + "optimize_path": { + "type": "boolean", + "title": "Optimize Path", + "description": "Enable path optimization based on the configured motor parameters.", + "default": true + }, + "optimization_algorithm": { + "type": "string", + "title": "Optimization Algorithm", + "description": "Path optimization algorithm to use when optimize_path is enabled.", + "default": "nearest_neighbor" + }, + "trigger_name": { + "type": "string", + "minLength": 1, + "title": "Trigger Name", + "description": "Name of the configured trigger device to fire at each scan point." + }, + "pre_trigger_delay_ms": { + "type": "integer", + "maximum": 30000.0, + "minimum": 0.0, + "title": "Pre Trigger Delay Ms", + "description": "Delay after reaching the scan position and before asserting the trigger.", + "default": 0 + }, + "post_trigger_delay_ms": { + "type": "integer", + "maximum": 30000.0, + "minimum": 0.0, + "title": "Post Trigger Delay Ms", + "description": "Delay after releasing the trigger before the next scan step starts.", + "default": 0 + } + }, + "type": "object", + "required": [ + "trigger_name" + ], + "title": "ExternalTriggerRunSettings" + }, "FirmwareSettingPatchRequest": { "properties": { "value": { @@ -5950,6 +6717,7 @@ "type": "string", "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -6080,6 +6848,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { @@ -6180,6 +6955,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { @@ -6480,6 +7262,155 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerConfig": { + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether this trigger can be fired.", + "default": true + }, + "pin": { + "type": "integer", + "minimum": 0.0, + "title": "Pin", + "description": "BCM GPIO pin used for the trigger line." + }, + "polarity": { + "$ref": "#/components/schemas/TriggerPolarity", + "description": "Defines whether the trigger line is active-high or active-low.", + "default": "active_high" + }, + "pulse_width_ms": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Pulse Width Ms", + "description": "How long the trigger line stays active for each trigger pulse.", + "default": 100 + } + }, + "type": "object", + "required": [ + "pin" + ], + "title": "TriggerConfig" + }, + "TriggerExecutionRequest": { + "properties": { + "pre_trigger_delay_ms": { + "type": "integer", + "maximum": 30000.0, + "minimum": 0.0, + "title": "Pre Trigger Delay Ms", + "default": 0 + }, + "post_trigger_delay_ms": { + "type": "integer", + "maximum": 30000.0, + "minimum": 0.0, + "title": "Post Trigger Delay Ms", + "default": 0 + } + }, + "type": "object", + "title": "TriggerExecutionRequest" + }, + "TriggerExecutionResponse": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "triggered_at": { + "type": "string", + "format": "date-time", + "title": "Triggered At" + }, + "completed_at": { + "type": "string", + "format": "date-time", + "title": "Completed At" + }, + "duration_ms": { + "type": "integer", + "title": "Duration Ms" + } + }, + "type": "object", + "required": [ + "name", + "triggered_at", + "completed_at", + "duration_ms" + ], + "title": "TriggerExecutionResponse" + }, + "TriggerPolarity": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerPolarity" + }, + "TriggerStatusResponse": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "busy": { + "type": "boolean", + "title": "Busy" + }, + "settings": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "last_triggered_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Triggered At" + }, + "last_completed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Completed At" + }, + "last_duration_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Duration Ms" + } + }, + "type": "object", + "required": [ + "name", + "busy", + "settings" + ], + "title": "TriggerStatusResponse" + }, "ValidationError": { "properties": { "loc": { diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index b891094..117e83c 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -2507,7 +2507,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" + } } } }, @@ -2551,7 +2554,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" + } } } }, @@ -4489,8 +4495,7 @@ "enum": [ "gphoto2", "linuxpy", - "picamera2", - "external" + "picamera2" ], "title": "CameraType" }, @@ -5509,6 +5514,7 @@ "type": "string", "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -5637,6 +5643,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/Trigger" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { @@ -5945,6 +5958,65 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "Trigger": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "settings": { + "$ref": "#/components/schemas/TriggerConfig" + } + }, + "type": "object", + "required": [ + "name", + "settings" + ], + "title": "Trigger" + }, + "TriggerConfig": { + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether this trigger can be fired.", + "default": true + }, + "pin": { + "type": "integer", + "minimum": 0.0, + "title": "Pin", + "description": "BCM GPIO pin used for the trigger line." + }, + "polarity": { + "$ref": "#/components/schemas/TriggerPolarity", + "description": "Defines whether the trigger line is active-high or active-low.", + "default": "active_high" + }, + "pulse_width_ms": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Pulse Width Ms", + "description": "How long the trigger line stays active for each trigger pulse.", + "default": 100 + } + }, + "type": "object", + "required": [ + "pin" + ], + "title": "TriggerConfig" + }, + "TriggerPolarity": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerPolarity" + }, "ValidationError": { "properties": { "loc": { diff --git a/scripts/openapi/openapi_v0.9.json b/scripts/openapi/openapi_v0.9.json index 78054b0..ec722fd 100644 --- a/scripts/openapi/openapi_v0.9.json +++ b/scripts/openapi/openapi_v0.9.json @@ -2709,7 +2709,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" + } } } }, @@ -2753,7 +2756,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" + } } } }, @@ -4831,8 +4837,7 @@ "enum": [ "gphoto2", "linuxpy", - "picamera2", - "external" + "picamera2" ], "title": "CameraType" }, @@ -5950,6 +5955,7 @@ "type": "string", "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -6080,6 +6086,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { @@ -6180,6 +6193,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { @@ -6480,6 +6500,48 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerConfig": { + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether this trigger can be fired.", + "default": true + }, + "pin": { + "type": "integer", + "minimum": 0.0, + "title": "Pin", + "description": "BCM GPIO pin used for the trigger line." + }, + "polarity": { + "$ref": "#/components/schemas/TriggerPolarity", + "description": "Defines whether the trigger line is active-high or active-low.", + "default": "active_high" + }, + "pulse_width_ms": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Pulse Width Ms", + "description": "How long the trigger line stays active for each trigger pulse.", + "default": 100 + } + }, + "type": "object", + "required": [ + "pin" + ], + "title": "TriggerConfig" + }, + "TriggerPolarity": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerPolarity" + }, "ValidationError": { "properties": { "loc": { diff --git a/tests/controllers/services/test_external_trigger_run_task.py b/tests/controllers/services/test_external_trigger_run_task.py new file mode 100644 index 0000000..cbef46c --- /dev/null +++ b/tests/controllers/services/test_external_trigger_run_task.py @@ -0,0 +1,76 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from openscan_firmware.config.external_trigger_run import ExternalTriggerRunSettings +from openscan_firmware.controllers.services.external_trigger_runs import ExternalTriggerRunManager +from openscan_firmware.controllers.services.tasks.core.external_trigger_run_task import ExternalTriggerRunTask +from openscan_firmware.models.paths import PolarPoint3D +from openscan_firmware.models.task import Task + + +@pytest.mark.asyncio +async def test_external_trigger_run_task_generates_path_without_run_log(tmp_path) -> None: + manager = ExternalTriggerRunManager(path=tmp_path) + settings = ExternalTriggerRunSettings( + points=2, + trigger_name="external-camera", + pre_trigger_delay_ms=10, + post_trigger_delay_ms=20, + ) + + task_model = Task(name="external_trigger_run_task", task_type="core") + task = ExternalTriggerRunTask(task_model) + + path_dict = { + PolarPoint3D(theta=10.0, fi=20.0): 0, + PolarPoint3D(theta=30.0, fi=40.0): 1, + } + + move_to_point = AsyncMock() + trigger_controller = AsyncMock() + fire_trigger = AsyncMock() + reset_trigger = AsyncMock() + trigger_controller.trigger = fire_trigger + trigger_controller.reset = reset_trigger + + with patch( + "openscan_firmware.controllers.services.tasks.core.external_trigger_run_task.get_external_trigger_run_manager", + return_value=manager, + ), patch( + "openscan_firmware.controllers.services.tasks.core.external_trigger_run_task.generate_scan_path", + return_value=path_dict, + ), patch( + "openscan_firmware.controllers.hardware.motors.move_to_point", + move_to_point, + ), patch( + "openscan_firmware.controllers.services.tasks.core.external_trigger_run_task.get_trigger_controller", + return_value=trigger_controller, + ): + progress_updates = [ + progress async for progress in task.run( + settings.model_dump(mode="json"), + label="gpio-seq", + ) + ] + + assert progress_updates[-1].current == 2 + assert progress_updates[-1].total == 2 + assert move_to_point.await_count == 3 + assert move_to_point.await_args_list[-1].args == (PolarPoint3D(theta=90.0, fi=90.0, r=1.0),) + + path_data = manager.get_path_data(task.id) + assert path_data is not None + assert path_data.task_id == task.id + assert path_data.total_steps == 2 + assert len(path_data.points) == 2 + + assert (manager.path / task.id / "run_log.json").exists() is False + assert (manager.path / task.id / "run.json").exists() is False + assert task_model.result == { + "task_id": task.id, + "path_path": str(manager.path_file(task.id)), + } + assert fire_trigger.await_count == 2 + fire_trigger.assert_any_await(pre_trigger_delay_ms=10, post_trigger_delay_ms=20) + reset_trigger.assert_awaited_once() diff --git a/tests/controllers/services/test_external_trigger_runs_service.py b/tests/controllers/services/test_external_trigger_runs_service.py new file mode 100644 index 0000000..fe698f6 --- /dev/null +++ b/tests/controllers/services/test_external_trigger_runs_service.py @@ -0,0 +1,191 @@ +import json +from dataclasses import asdict +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from openscan_firmware.config.external_trigger_run import ExternalTriggerRunSettings +from openscan_firmware.controllers.services.external_trigger_runs import ( + ExternalTriggerRunManager, + cancel_external_trigger_run, + get_external_trigger_task, + list_external_trigger_tasks, + pause_external_trigger_run, + resume_external_trigger_run, + start_external_trigger_run, +) +from openscan_firmware.models.paths import CartesianPoint3D, PolarPoint3D +from openscan_firmware.models.task import Task, TaskStatus + + +def _sample_settings() -> ExternalTriggerRunSettings: + return ExternalTriggerRunSettings( + points=8, + trigger_name="external-camera", + pre_trigger_delay_ms=10, + post_trigger_delay_ms=20, + ) + + +def test_manager_save_path_data_persists_path_only(tmp_path) -> None: + manager = ExternalTriggerRunManager(path=tmp_path) + path_data = manager.save_path_data( + { + "task_id": "task-ext-0", + "total_steps": 1, + "points": [ + { + "execution_step": 0, + "original_step": 0, + "polar_coordinates": asdict(PolarPoint3D(theta=10.0, fi=20.0)), + "cartesian_coordinates": asdict(CartesianPoint3D(x=1.0, y=2.0, z=3.0)), + } + ], + } + ) + + assert path_data.task_id == "task-ext-0" + assert manager.path_file("task-ext-0").exists() is True + assert (manager.path / "task-ext-0" / "run.json").exists() is False + + +def test_manager_get_path_data_reads_legacy_manifest_file(tmp_path) -> None: + manager = ExternalTriggerRunManager(path=tmp_path) + + legacy_manifest = { + "run_id": "task-ext-legacy", + "generated_at": datetime(2026, 4, 9, 12, 0, 0).isoformat(), + "label": "legacy-run", + "description": "legacy manifest payload", + "settings": _sample_settings().model_dump(mode="json"), + "total_steps": 1, + "points": [ + { + "execution_step": 0, + "original_step": 0, + "polar_coordinates": asdict(PolarPoint3D(theta=10.0, fi=20.0)), + "cartesian_coordinates": asdict(CartesianPoint3D(x=1.0, y=2.0, z=3.0)), + } + ], + } + (manager.path / "task-ext-legacy" / "manifest.json").parent.mkdir(parents=True, exist_ok=True) + (manager.path / "task-ext-legacy" / "manifest.json").write_text(json.dumps(legacy_manifest, indent=2), encoding="utf-8") + + path_data = manager.get_path_data("task-ext-legacy") + + assert path_data is not None + assert path_data.task_id == "task-ext-legacy" + assert path_data.total_steps == 1 + assert len(path_data.points) == 1 + + +def test_list_external_trigger_tasks_filters_and_sorts_by_created_at() -> None: + older_task = Task( + id="task-ext-older", + name="external_trigger_run_task", + task_type="external_trigger_run_task", + created_at=datetime(2026, 4, 9, 10, 0, 0), + ) + newer_task = Task( + id="task-ext-newer", + name="external_trigger_run_task", + task_type="external_trigger_run_task", + created_at=datetime(2026, 4, 9, 11, 0, 0), + ) + unrelated_task = Task( + id="task-other", + name="scan_task", + task_type="scan_task", + created_at=datetime(2026, 4, 9, 12, 0, 0), + ) + task_manager = MagicMock() + task_manager.get_all_tasks_info.return_value = [older_task, unrelated_task, newer_task] + + with patch( + "openscan_firmware.controllers.services.external_trigger_runs.get_task_manager", + return_value=task_manager, + ): + tasks = list_external_trigger_tasks() + + assert [task.id for task in tasks] == ["task-ext-newer", "task-ext-older"] + + +def test_get_external_trigger_task_returns_only_matching_task_types() -> None: + external_task = Task( + id="task-ext-1", + name="external_trigger_run_task", + task_type="external_trigger_run_task", + ) + task_manager = MagicMock() + task_manager.get_task_info.side_effect = [external_task, Task(id="task-other", name="scan_task", task_type="scan_task")] + + with patch( + "openscan_firmware.controllers.services.external_trigger_runs.get_task_manager", + return_value=task_manager, + ): + found_task = get_external_trigger_task("task-ext-1") + other_task = get_external_trigger_task("task-other") + + assert found_task is external_task + assert other_task is None + + +@pytest.mark.asyncio +async def test_start_external_trigger_run_delegates_to_task_manager() -> None: + task_manager = MagicMock() + created_task = Task( + id="task-ext-2", + name="external_trigger_run_task", + task_type="core", + status=TaskStatus.RUNNING, + ) + task_manager.create_and_run_task = AsyncMock(return_value=created_task) + + with patch( + "openscan_firmware.controllers.services.external_trigger_runs.get_trigger_controller", + return_value=MagicMock(), + ), patch( + "openscan_firmware.controllers.services.external_trigger_runs.get_task_manager", + return_value=task_manager, + ): + task = await start_external_trigger_run( + label="bench-run", + description="test run", + settings=_sample_settings(), + ) + + assert task is created_task + task_manager.create_and_run_task.assert_awaited_once_with( + "external_trigger_run_task", + _sample_settings().model_dump(mode="json"), + label="bench-run", + description="test run", + start_from_step=0, + ) + + +@pytest.mark.asyncio +async def test_cancel_pause_resume_delegate_to_task_manager() -> None: + task_manager = MagicMock() + task_manager.cancel_task = AsyncMock( + return_value=Task(id="task-ext-3", name="external_trigger_run_task", task_type="core", status=TaskStatus.CANCELLED) + ) + task_manager.pause_task = AsyncMock( + return_value=Task(id="task-ext-3", name="external_trigger_run_task", task_type="core", status=TaskStatus.PAUSED) + ) + task_manager.resume_task = AsyncMock( + return_value=Task(id="task-ext-3", name="external_trigger_run_task", task_type="core", status=TaskStatus.RUNNING) + ) + + with patch( + "openscan_firmware.controllers.services.external_trigger_runs.get_task_manager", + return_value=task_manager, + ): + cancelled = await cancel_external_trigger_run("task-ext-3") + paused = await pause_external_trigger_run("task-ext-3") + resumed = await resume_external_trigger_run("task-ext-3") + + assert cancelled.status == TaskStatus.CANCELLED + assert paused.status == TaskStatus.PAUSED + assert resumed.status == TaskStatus.RUNNING diff --git a/tests/controllers/services/test_external_trigger_service.py b/tests/controllers/services/test_external_trigger_service.py new file mode 100644 index 0000000..c5a778f --- /dev/null +++ b/tests/controllers/services/test_external_trigger_service.py @@ -0,0 +1,77 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from openscan_firmware.config.trigger import TriggerConfig +from openscan_firmware.controllers.hardware.triggers import TriggerController +from openscan_firmware.models.trigger import Trigger + + +@pytest.mark.asyncio +async def test_trigger_controller_toggles_pin_and_returns_execution() -> None: + initialize_output_pins = MagicMock() + set_output_pin = MagicMock() + + with patch( + "openscan_firmware.controllers.hardware.triggers.gpio.initialize_output_pins", + initialize_output_pins, + ), patch( + "openscan_firmware.controllers.hardware.triggers.gpio.set_output_pin", + set_output_pin, + ), patch( + "openscan_firmware.controllers.hardware.triggers.schedule_device_status_broadcast", + ), patch( + "openscan_firmware.controllers.hardware.triggers.notify_busy_change", + ): + controller = TriggerController( + Trigger( + name="External Camera", + settings=TriggerConfig( + pin=23, + active_level="active_high", + pulse_width_ms=1, + ), + ) + ) + execution = await controller.trigger(pre_trigger_delay_ms=0, post_trigger_delay_ms=0) + await controller.reset() + + assert execution.duration_ms >= 0 + assert execution.completed_at >= execution.triggered_at + assert initialize_output_pins.call_count == 2 + assert set_output_pin.call_count == 4 + + +def test_trigger_controller_settings_update_reapplies_idle_level() -> None: + initialize_output_pins = MagicMock() + set_output_pin = MagicMock() + + with patch( + "openscan_firmware.controllers.hardware.triggers.gpio.initialize_output_pins", + initialize_output_pins, + ), patch( + "openscan_firmware.controllers.hardware.triggers.gpio.set_output_pin", + set_output_pin, + ), patch( + "openscan_firmware.controllers.hardware.triggers.schedule_device_status_broadcast", + ), patch( + "openscan_firmware.controllers.hardware.triggers.notify_busy_change", + ): + controller = TriggerController( + Trigger( + name="External Camera", + settings=TriggerConfig( + pin=23, + active_level="active_high", + pulse_width_ms=10, + ), + ) + ) + + controller.settings.update(pin=24, active_level="active_low", pulse_width_ms=25) + + assert controller.settings.model.pin == 24 + assert controller.settings.model.active_level == "active_low" + assert controller.settings.model.pulse_width_ms == 25 + assert initialize_output_pins.call_args_list[-1].args == ([24],) + assert set_output_pin.call_args_list[-1].args == (24, True) diff --git a/tests/routers/test_device_router.py b/tests/routers/test_device_router.py index 0c59a79..8498e92 100644 --- a/tests/routers/test_device_router.py +++ b/tests/routers/test_device_router.py @@ -114,6 +114,7 @@ def test_get_current_config_returns_payload(monkeypatch, tmp_path, device_client "cameras": {}, "motors": {}, "lights": {}, + "triggers": {}, "endstops": None, "motors_timeout": 3.5, "startup_mode": "startup_enabled", @@ -146,6 +147,7 @@ def test_get_named_config_reads_disk(monkeypatch, tmp_path, device_client, devic "cameras": {}, "motors": {}, "lights": {}, + "triggers": {}, "endstops": None, "motors_timeout": 1.0, "startup_mode": "startup_enabled", diff --git a/tests/routers/test_firmware_router.py b/tests/routers/test_firmware_router.py index f7fd409..08e6c8e 100644 --- a/tests/routers/test_firmware_router.py +++ b/tests/routers/test_firmware_router.py @@ -3,11 +3,14 @@ from __future__ import annotations from importlib import import_module +from unittest.mock import AsyncMock import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +import openscan_firmware.main as main_module + def _next_router_module_path(name: str) -> str: return f"openscan_firmware.routers.next.{name}" @@ -39,6 +42,7 @@ def test_get_firmware_settings_returns_current_settings(monkeypatch, firmware_cl assert response.json() == { "qr_wifi_scan_enabled": True, "enable_cloud": False, + "camera_preview_enabled": True, } @@ -49,21 +53,24 @@ def test_put_firmware_settings_replaces_payload(monkeypatch, firmware_client): def fake_save(settings): captured["qr_wifi_scan_enabled"] = settings.qr_wifi_scan_enabled captured["enable_cloud"] = settings.enable_cloud + captured["camera_preview_enabled"] = settings.camera_preview_enabled monkeypatch.setattr(f"{module_path}.save_firmware_settings", fake_save) response = firmware_client.put( "/latest/firmware/settings", - json={"qr_wifi_scan_enabled": False, "enable_cloud": True}, + json={"qr_wifi_scan_enabled": False, "enable_cloud": True, "camera_preview_enabled": False}, ) assert response.status_code == 200 assert response.json() == { "qr_wifi_scan_enabled": False, "enable_cloud": True, + "camera_preview_enabled": False, } assert captured["qr_wifi_scan_enabled"] is False assert captured["enable_cloud"] is True + assert captured["camera_preview_enabled"] is False def test_patch_firmware_setting_updates_single_key(monkeypatch, firmware_client): @@ -91,6 +98,7 @@ def fake_save(settings): assert response.json() == { "qr_wifi_scan_enabled": False, "enable_cloud": False, + "camera_preview_enabled": True, } assert saved["qr_wifi_scan_enabled"] is False @@ -111,3 +119,57 @@ def test_patch_firmware_setting_unknown_key_returns_404(monkeypatch, firmware_cl assert response.status_code == 404 assert response.json()["detail"] == "Unknown firmware setting key: not_a_real_key" + + +def test_patch_camera_preview_enabled_updates_single_key(monkeypatch, firmware_client): + module_path = _next_router_module_path("firmware") + router_module = import_module(module_path) + + monkeypatch.setattr( + f"{module_path}.get_firmware_settings", + lambda: router_module.FirmwareSettings(qr_wifi_scan_enabled=True, camera_preview_enabled=True), + ) + + saved: dict[str, bool] = {} + + def fake_save(settings): + saved["camera_preview_enabled"] = settings.camera_preview_enabled + + monkeypatch.setattr(f"{module_path}.save_firmware_settings", fake_save) + + response = firmware_client.patch( + "/latest/firmware/settings/camera_preview_enabled", + json={"value": False}, + ) + + assert response.status_code == 200 + assert response.json() == { + "qr_wifi_scan_enabled": True, + "enable_cloud": False, + "camera_preview_enabled": False, + } + assert saved["camera_preview_enabled"] is False + + +@pytest.mark.asyncio +async def test_qr_wifi_autostart_skips_when_camera_preview_disabled(monkeypatch): + from openscan_firmware.config.firmware import FirmwareSettings + + task_manager = type( + "DummyTaskManager", + (), + {"create_and_run_task": AsyncMock()}, + )() + + monkeypatch.setattr( + main_module, + "get_firmware_settings", + lambda: FirmwareSettings( + qr_wifi_scan_enabled=True, + enable_cloud=False, + camera_preview_enabled=False, + ), + ) + + await main_module._maybe_start_qr_wifi_scan(task_manager) + task_manager.create_and_run_task.assert_not_called() diff --git a/tests/routers/test_next_external_trigger_router.py b/tests/routers/test_next_external_trigger_router.py new file mode 100644 index 0000000..dafebb0 --- /dev/null +++ b/tests/routers/test_next_external_trigger_router.py @@ -0,0 +1,87 @@ +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime + +import httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from openscan_firmware.config.trigger import TriggerConfig +from openscan_firmware.controllers.hardware.triggers import create_trigger_controller, remove_trigger_controller +from openscan_firmware.controllers.hardware.triggers import TriggerExecution +from openscan_firmware.models.trigger import Trigger +from openscan_firmware.routers.next.triggers import router + + +def _app() -> FastAPI: + app = FastAPI() + app.include_router(router) + return app + + +@pytest_asyncio.fixture +async def trigger_client() -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.mark.asyncio +async def test_trigger_external_camera_returns_execution_payload(trigger_client: httpx.AsyncClient) -> None: + execution = TriggerExecution( + triggered_at=datetime(2026, 4, 8, 12, 0, 0), + completed_at=datetime(2026, 4, 8, 12, 0, 1), + duration_ms=1000, + ) + controller = MagicMock() + controller.trigger = AsyncMock(return_value=execution) + + with patch( + "openscan_firmware.routers.next.triggers.get_trigger_controller", + return_value=controller, + ): + response = await trigger_client.post( + "/triggers/external-camera/trigger", + json={ + "pre_trigger_delay_ms": 10, + "post_trigger_delay_ms": 20, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["name"] == "external-camera" + assert body["duration_ms"] == 1000 + + +@pytest.mark.asyncio +async def test_patch_trigger_settings_updates_controller_settings(trigger_client: httpx.AsyncClient) -> None: + with patch( + "openscan_firmware.controllers.hardware.triggers.gpio.initialize_output_pins", + ), patch( + "openscan_firmware.controllers.hardware.triggers.gpio.set_output_pin", + ), patch( + "openscan_firmware.controllers.hardware.triggers.schedule_device_status_broadcast", + ), patch( + "openscan_firmware.controllers.hardware.triggers.notify_busy_change", + ): + controller = create_trigger_controller( + Trigger( + name="external-camera", + settings=TriggerConfig(pin=23, active_level="active_high", pulse_width_ms=100), + ) + ) + try: + response = await trigger_client.patch( + "/triggers/external-camera/settings", + json={"pin": 24, "active_level": "active_low", "pulse_width_ms": 250}, + ) + finally: + remove_trigger_controller("external-camera") + + assert response.status_code == 200 + body = response.json() + assert body["pin"] == 24 + assert body["active_level"] == "active_low" + assert body["pulse_width_ms"] == 250 + assert controller.settings.model.pin == 24 diff --git a/tests/routers/test_next_external_trigger_runs_router.py b/tests/routers/test_next_external_trigger_runs_router.py new file mode 100644 index 0000000..32a9594 --- /dev/null +++ b/tests/routers/test_next_external_trigger_runs_router.py @@ -0,0 +1,157 @@ +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from openscan_firmware.controllers.services.external_trigger_runs import ExternalTriggerRunManager +from openscan_firmware.models.external_trigger_run import ExternalTriggerPoint, ExternalTriggerRunPath +from openscan_firmware.models.paths import CartesianPoint3D, PolarPoint3D +from openscan_firmware.models.task import Task, TaskStatus +from openscan_firmware.routers.next.external_trigger_runs import router + + +def _app() -> FastAPI: + app = FastAPI() + app.include_router(router) + return app + + +@pytest_asyncio.fixture +async def external_trigger_runs_client() -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +def _sample_settings() -> dict: + return { + "points": 3, + "trigger_name": "external-camera", + "pre_trigger_delay_ms": 10, + "post_trigger_delay_ms": 20, + } + + +@pytest.mark.asyncio +async def test_create_external_trigger_run_returns_task(external_trigger_runs_client: httpx.AsyncClient) -> None: + created_task = Task( + id="task-router-1", + name="external_trigger_run_task", + task_type="core", + status=TaskStatus.RUNNING, + ) + + with patch( + "openscan_firmware.routers.next.external_trigger_runs.start_external_trigger_run", + AsyncMock(return_value=created_task), + ): + response = await external_trigger_runs_client.post( + "/external-trigger/runs/", + json={ + "label": "router-run", + "description": "test run", + "settings": _sample_settings(), + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["id"] == "task-router-1" + assert body["status"] == TaskStatus.RUNNING.value + + +@pytest.mark.asyncio +async def test_get_external_trigger_run_path_returns_json( + tmp_path, + external_trigger_runs_client: httpx.AsyncClient, +) -> None: + manager = ExternalTriggerRunManager(path=tmp_path) + manager.save_path_data( + ExternalTriggerRunPath( + task_id="task-router-path", + total_steps=1, + points=[ + ExternalTriggerPoint( + execution_step=0, + original_step=0, + polar_coordinates=PolarPoint3D(theta=10.0, fi=20.0), + cartesian_coordinates=CartesianPoint3D(x=1.0, y=2.0, z=3.0), + ) + ], + ) + ) + + with patch( + "openscan_firmware.routers.next.external_trigger_runs.get_external_trigger_run_manager", + return_value=manager, + ): + response = await external_trigger_runs_client.get("/external-trigger/runs/task-router-path/path") + + assert response.status_code == 200 + body = response.json() + assert body["task_id"] == "task-router-path" + assert body["total_steps"] == 1 + assert len(body["points"]) == 1 + + +@pytest.mark.asyncio +async def test_get_external_trigger_run_returns_task(external_trigger_runs_client: httpx.AsyncClient) -> None: + task = Task( + id="task-router-2", + name="external_trigger_run_task", + task_type="external_trigger_run_task", + status=TaskStatus.PENDING, + ) + with patch( + "openscan_firmware.routers.next.external_trigger_runs.get_external_trigger_task", + return_value=task, + ): + response = await external_trigger_runs_client.get("/external-trigger/runs/task-router-2") + + assert response.status_code == 200 + body = response.json() + assert body["id"] == "task-router-2" + assert body["status"] == TaskStatus.PENDING.value + + +@pytest.mark.asyncio +async def test_list_external_trigger_runs_returns_tasks(external_trigger_runs_client: httpx.AsyncClient) -> None: + task = Task( + id="task-router-4", + name="external_trigger_run_task", + task_type="external_trigger_run_task", + status=TaskStatus.RUNNING, + ) + with patch( + "openscan_firmware.routers.next.external_trigger_runs.list_external_trigger_tasks", + return_value=[task], + ): + response = await external_trigger_runs_client.get("/external-trigger/runs/") + + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["id"] == "task-router-4" + + +@pytest.mark.asyncio +async def test_pause_external_trigger_run_returns_task(external_trigger_runs_client: httpx.AsyncClient) -> None: + paused_task = Task( + id="task-router-5", + name="external_trigger_run_task", + task_type="core", + status=TaskStatus.PAUSED, + ) + + with patch( + "openscan_firmware.routers.next.external_trigger_runs.pause_external_trigger_run", + AsyncMock(return_value=paused_task), + ): + response = await external_trigger_runs_client.patch("/external-trigger/runs/task-router-5/pause") + + assert response.status_code == 200 + body = response.json() + assert body["id"] == "task-router-5" + assert body["status"] == TaskStatus.PAUSED.value From 0ff02326e41885b24a878e8a09ea5faaba0f121c Mon Sep 17 00:00:00 2001 From: esto Date: Fri, 10 Apr 2026 09:32:28 +0200 Subject: [PATCH 6/6] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eec3375..ff4a7f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openscan-firmware" -version = "0.11.0" +version = "0.11.1" description = "OpenScan3 - Raspberry Pi based photogrammetry scanner (FastAPI-based application)" readme = "README.md" requires-python = ">=3.11"