From 6098b7cba40a7f637fbddbf489b6eb789d3aaad2 Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:25:59 +0000 Subject: [PATCH 01/75] initial pwm support --- openscan_firmware/config/light.py | 17 ++++- .../controllers/hardware/gpio.py | 73 ++++++++++++++++--- .../controllers/hardware/lights.py | 48 ++++++++++-- openscan_firmware/routers/next/lights.py | 27 ++++++- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/openscan_firmware/config/light.py b/openscan_firmware/config/light.py index b8cd511..4ac77b8 100644 --- a/openscan_firmware/config/light.py +++ b/openscan_firmware/config/light.py @@ -12,6 +12,9 @@ class LightConfig(BaseModel): default=False, description="Indicates whether this light hardware can handle PWM (otherwise only on/off).", ) + pwm_frequency: float = Field(10000.0, ge=50.0, le=100000.0, description="PWM frequency for led driver.") + pwm_min: float = Field(0.0, ge=0, le=3.3, description="Minimum pwm voltage for led driver.") + pwm_max: float = Field(0.0, ge=0, le=3.3, description="Maximum pwm voltage for led driver.") @model_validator(mode="before") @classmethod @@ -37,4 +40,16 @@ def ensure_pins(cls, values): merged_pins.append(pin) values["pins"] = list(dict.fromkeys(merged_pins)) - return values \ No newline at end of file + return values + + @model_validator(mode="after") + def validate_pwm_range(self): + """ + Ensures pwm_min <= pwm_max and consistency with pwm_support. + """ + + if self.pwm_min >= self.pwm_max: + raise ValueError("pwm_min must be less than pwm_max") + + + return self diff --git a/openscan_firmware/controllers/hardware/gpio.py b/openscan_firmware/controllers/hardware/gpio.py index e2e0d55..5189e6e 100644 --- a/openscan_firmware/controllers/hardware/gpio.py +++ b/openscan_firmware/controllers/hardware/gpio.py @@ -1,12 +1,13 @@ import logging -from gpiozero import DigitalOutputDevice, Button +from gpiozero import DigitalOutputDevice, PWMOutputDevice, Button from typing import Dict, List, Optional, Callable logger = logging.getLogger(__name__) # Track pins and buttons _output_pins = {} +_pwm_pins = {} _buttons = {} @@ -15,8 +16,10 @@ def initialize_output_pins(pins: List[int]): for pin in pins: if pin in _output_pins: logger.warning(f"Warning: Output pin {pin} already initialized.") + elif pin in _pwm_pins: + logger.error(f"Error: Cannot initialize pin {pin} as output. Already initialized as PWM.") elif pin in _buttons: - logger.error(f"Error: Cannot initialize pin {pin} as output. Already initialized as Button.") + logger.error(f"Error: Cannot initialize pin {pin} as output. Already initialized as button.") else: try: _output_pins[pin] = DigitalOutputDevice(pin, initial_value=False) @@ -27,7 +30,6 @@ def initialize_output_pins(pins: List[int]): if pin in _output_pins: del _output_pins[pin] - def toggle_output_pin(pin: int): """Toggles the state of an output pin.""" if pin in _output_pins: @@ -44,14 +46,6 @@ def set_output_pin(pin: int, status: bool): logger.warning(f"Warning: Cannot set pin {pin}. Not initialized as output.") -def get_initialized_pins() -> Dict[str, List[int]]: - """Returns a dictionary listing initialized output pins and buttons.""" - return { - "output_pins": list(_output_pins.keys()), - "buttons": list(_buttons.keys()) - } - - def get_output_pin(pin: int): """Returns the state of an output pin.""" if pin in _output_pins: @@ -61,6 +55,41 @@ def get_output_pin(pin: int): return None +def initialize_pwm_pins(pins: List[int], freq: int): + """Initializes one or more GPIO pins as pwm outputs.""" + for pin in pins: + if pin in _pwm_pins: + logger.warning(f"Warning: PWM pin {pin} already initialized.") + elif pin in _output_pins: + logger.error(f"Error: Cannot initialize pin {pin} as PWM. Already initialized as output.") + elif pin in _buttons: + logger.error(f"Error: Cannot initialize pin {pin} as PWM. Already initialized as button.") + else: + try: + _pwm_pins[pin] = PWMOutputDevice(pin, active_high=True, initial_value=0.0, frequency=freq) + logger.debug(f"Initialized pin {pin} as PWM.") + except Exception as e: + logger.error(f"Error initializing PWM pin {pin}: {e}", exc_info=True) + # Clean up if initialization failed partially + if pin in _pwm_pins: + del _pwm_pins[pin] + +def set_pwm_pin(pin: int, value: float): + """Sets the value of a PWM pin.""" + if pin in _pwm_pins: + _pwm_pins[pin].value = value + else: + logger.warning(f"Warning: Cannot set pin {pin}. Not initialized as PWM.") + +def get_pwm_pin(pin: int): + """Returns the state of an output pin.""" + if pin in _pwm_pins: + return _pwm_pins[pin].value + else: + logger.warning(f"Warning: Pin {pin} not initialized as PWM.") + return None + + def initialize_button(pin: int, pull_up: Optional[bool] = True, bounce_time: Optional[float] = 0.05): """ Initializes a GPIO pin as button input using gpiozero.Button. @@ -76,6 +105,8 @@ def initialize_button(pin: int, pull_up: Optional[bool] = True, bounce_time: Opt logger.warning(f"Warning: Button on pin {pin} already initialized.") elif pin in _output_pins: logger.error(f"Error: Cannot initialize pin {pin} as Button. Already initialized as output.") + elif pin in _pwm_pins: + logger.error(f"Error: Cannot initialize pin {pin} as output. Already initialized as PWM.") else: try: _buttons[pin] = Button(pin, pull_up=pull_up, bounce_time=bounce_time, hold_time=0.01) @@ -160,10 +191,28 @@ def is_button_pressed(pin: int) -> Optional[bool]: # Returning None indicates it's not a known button. return None +def get_initialized_pins() -> Dict[str, List[int]]: + """Returns a dictionary listing initialized output pins and buttons.""" + return { + "output_pins": list(_output_pins.keys()), + "pwm_pins": list(_pwm_pins.keys()), + "buttons": list(_buttons.keys()) + } + def cleanup_all_pins(): """Closes all initialized GPIO devices (output pins and buttons).""" logger.debug("Cleaning up GPIO resources...") + # Close PWM pins + pins_to_remove = list(_pwm_pins.keys()) # Create a copy of keys to iterate over + for pin in pins_to_remove: + try: + _pwm_pins[pin].close() + del _pwm_pins[pin] # Remove from tracking dict after successful close + logger.debug(f"Output pin {pin} closed.") + except Exception as e: + logger.error(f"Error closing output pin {pin}: {e}", exc_info=True) + # Close output pins pins_to_remove = list(_output_pins.keys()) # Create a copy of keys to iterate over for pin in pins_to_remove: @@ -188,4 +237,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/controllers/hardware/lights.py b/openscan_firmware/controllers/hardware/lights.py index 0c08dff..dd6fd41 100644 --- a/openscan_firmware/controllers/hardware/lights.py +++ b/openscan_firmware/controllers/hardware/lights.py @@ -16,6 +16,8 @@ from openscan_firmware.controllers.hardware.interfaces import HardwareEvent, SwitchableHardware, SleepCapableHardware, create_controller_registry from openscan_firmware.controllers.services.device_events import schedule_device_status_broadcast +from openscan_firmware.utils.inactivity_timer import inactivity_timer + logger = logging.getLogger(__name__) class LightController(SwitchableHardware, SleepCapableHardware): @@ -26,17 +28,25 @@ def __init__(self, light: Light): on_change=self._apply_settings_to_hardware ) self._is_on = False - # idle helpers must exist before first refresh - self.is_idle = lambda: False + self._value = self.settings.pwm_max + + # no idle callbacks + self.is_idle = lambda: True self.send_event = None + self._apply_settings_to_hardware(self.settings.model) logger.debug(f"Light controller for '{self.model.name}' initialized.") - + def _apply_settings_to_hardware(self, settings: LightConfig): """Apply settings to hardware and preserve light state.""" self.model.settings = settings - gpio.initialize_output_pins(self.settings.pins) + if self.settings.pwm_support: + logger.info(f"Light '{self.model.name}' initializing PWM.") + gpio.initialize_pwm_pins(self.settings.pins, self.settings.pwm_frequency) + else: + logger.info(f"Light '{self.model.name}' initializing digital.") + gpio.initialize_output_pins(self.settings.pins) # Re-apply desired state synchronously; refresh handles idle logic self.refresh() @@ -47,6 +57,7 @@ def get_status(self): return { "name": self.model.name, "is_on": self.is_on, + "value": self._value, "settings": self.get_config().model_dump() } @@ -54,14 +65,24 @@ def get_config(self) -> LightConfig: return self.settings.model def refresh(self): + inactivity_timer.reset() if self.is_idle(): logger.info(f"Light '{self.model.name}' idle.") for pin in self.settings.pins: - gpio.set_output_pin(pin, False) + if self.settings.pwm_support: + gpio.set_pwm_pin(pin, self.settings.pwm_min) + else: + gpio.set_output_pin(pin, False) else: logger.info(f"Light '{self.model.name}' active.") for pin in self.settings.pins: - gpio.set_output_pin(pin, self._is_on) + if self.settings.pwm_support: + _minVal = self.settings.pwm_min / 3.3 + _maxVal = self.settings.pwm_max / 3.3 + _val = self._value / 100.0 * (_maxVal - _minVal) + _minVal + gpio.set_pwm_pin(pin, _val if self._is_on else _minVal) + else: + gpio.set_output_pin(pin, self._is_on) def set_idle_callbacks(self, is_idle: Callable[[], bool], send_event: Callable[[HardwareEvent], Awaitable[None]]) -> None: @@ -91,6 +112,21 @@ async def turn_off(self): await self._wake_if_idle(HardwareEvent.LIGHT_EVENT) logger.info(f"Light '{self.model.name}' turned off.") schedule_device_status_broadcast([f"lights.{self.model.name}.is_on"]) + + async def set_value(self, value: float): + if value < 0: + self._value = 0 + elif value > 100: + self._value = 100 + else: + self._value = value + #resume from idle + if self.is_idle(): + logger.info("Device idle, must exit before") + await self.send_event(HardwareEvent.LIGHT_EVENT) + else: + self.refresh() + logger.info(f"Light '{self.model.name}' value set to {self._value}.") create_light_controller, get_light_controller, remove_light_controller, _light_registry = create_controller_registry(LightController) diff --git a/openscan_firmware/routers/next/lights.py b/openscan_firmware/routers/next/lights.py index 8bc8a08..5e467f4 100644 --- a/openscan_firmware/routers/next/lights.py +++ b/openscan_firmware/routers/next/lights.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from openscan_firmware.controllers.hardware.lights import get_light_controller, get_all_light_controllers @@ -102,6 +102,31 @@ async def toggle_light(light_name: str): except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) +@router.put("/{light_name}/intensity", response_model=LightStatusResponse) +async def pwm_light( + light_name: str, + value: float = Query( + 100, + description=( + "sets light intensity, from 0 to 100%" + ), + ), +): + """Set light intensity + + Args: + light_name: The name of the light to toggle + value: intensity of light, from 0% to 100% + + Returns: + LightStatusResponse: A response object containing the status of the light after the toggle operation + """ + try: + controller = get_light_controller(light_name) + await controller.set_value(value) + return controller.get_status() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) create_settings_endpoints( router=router, From e57ffd61e1027434b2645d76d953eb13dad2fd97 Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:15:58 +0000 Subject: [PATCH 02/75] added hardware pwm --- .../controllers/hardware/gpio.py | 33 ++- openscan_firmware/utils/pwm_hardware.py | 203 ++++++++++++++++++ 2 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 openscan_firmware/utils/pwm_hardware.py diff --git a/openscan_firmware/controllers/hardware/gpio.py b/openscan_firmware/controllers/hardware/gpio.py index 5189e6e..08a9de2 100644 --- a/openscan_firmware/controllers/hardware/gpio.py +++ b/openscan_firmware/controllers/hardware/gpio.py @@ -3,6 +3,9 @@ from gpiozero import DigitalOutputDevice, PWMOutputDevice, Button from typing import Dict, List, Optional, Callable +# hardware PWM module +from openscan_firmware.utils.pwm_hardware import hwpwm + logger = logging.getLogger(__name__) # Track pins and buttons @@ -66,8 +69,14 @@ def initialize_pwm_pins(pins: List[int], freq: int): logger.error(f"Error: Cannot initialize pin {pin} as PWM. Already initialized as button.") else: try: - _pwm_pins[pin] = PWMOutputDevice(pin, active_high=True, initial_value=0.0, frequency=freq) - logger.debug(f"Initialized pin {pin} as PWM.") + if hwpwm.supports(pin): + _pwm_pins[pin] = pin + hwpwm.setup(pin) + hwpwm.set_frequency(pin, freq) + logger.info(f"Initialized pin {pin} as hardware PWM.") + else: + _pwm_pins[pin] = PWMOutputDevice(pin, active_high=True, initial_value=0.0, frequency=freq) + logger.info(f"Initialized pin {pin} as software PWM.") except Exception as e: logger.error(f"Error initializing PWM pin {pin}: {e}", exc_info=True) # Clean up if initialization failed partially @@ -77,14 +86,30 @@ def initialize_pwm_pins(pins: List[int], freq: int): def set_pwm_pin(pin: int, value: float): """Sets the value of a PWM pin.""" if pin in _pwm_pins: - _pwm_pins[pin].value = value + dev = _pwm_pins[pin] + + # on hw pwm we store just pin number here, not the device + if isinstance(dev, int): + # hw pwm + hwpwm.set_duty_cycle(dev, value) + else: + # soft pwm + _pwm_pins[pin].value = value else: logger.warning(f"Warning: Cannot set pin {pin}. Not initialized as PWM.") def get_pwm_pin(pin: int): """Returns the state of an output pin.""" if pin in _pwm_pins: - return _pwm_pins[pin].value + dev = _pwm_pins[pin] + + # on hw pwm we store just pin number here, not the device + if isinstance(dev, int): + # hw pwm + return hwpwm.get_duty_cycle(dev) + else: + # soft pwm + return _pwm_pins[pin].value else: logger.warning(f"Warning: Pin {pin} not initialized as PWM.") return None diff --git a/openscan_firmware/utils/pwm_hardware.py b/openscan_firmware/utils/pwm_hardware.py new file mode 100644 index 0000000..fabb51a --- /dev/null +++ b/openscan_firmware/utils/pwm_hardware.py @@ -0,0 +1,203 @@ +import subprocess +from pathlib import Path + +from dataclasses import dataclass + +import atexit +import signal +import sys + +@dataclass +class _HwPWM: + + _PWMCHIP = Path("/sys/class/pwm/pwmchip0") + + _PIN_INFO = { + 12: {"channel": 0, "alt": "a0"}, + 18: {"channel": 0, "alt": "a5"}, + 13: {"channel": 1, "alt": "a0"}, + 19: {"channel": 1, "alt": "a5"}, + } + + _pins = {} + + # register cleanup at exit + def __init__(self): + atexit.register(_HwPWM._cleanup) + signal.signal(signal.SIGTERM, _HwPWM._signal_handler) + signal.signal(signal.SIGINT, _HwPWM._signal_handler) + + @staticmethod + def _run(cmd): + result = subprocess.run(cmd, check=True, capture_output=True, text=True).stdout + try: + return result.split(":", 1)[1].split()[0] + except: + return "" + + @staticmethod + def _pwm_path(channel): + return _HwPWM._PWMCHIP / f"pwm{channel}" + + + @staticmethod + def _write(path, value): + path.write_text(str(value)) + + + @staticmethod + def _export(channel): + p = _HwPWM._pwm_path(channel) + if not p.exists(): + (_HwPWM._PWMCHIP / "export").write_text(str(channel)) + + + @staticmethod + def _unexport(channel): + p = _HwPWM._pwm_path(channel) + if p.exists(): + (_HwPWM._PWMCHIP / "unexport").write_text(str(channel)) + + + @staticmethod + def supports(pin: int): + # first check if pin is a supported one + if not pin in _HwPWM._PIN_INFO: + return False + + # then check if its PWM is not already in use + chan = _HwPWM._PIN_INFO[pin]["channel"] + for p in _HwPWM._pins.keys(): + # harmless re-set already set pin + if p == pin: + return True + # if using same channel as already setup pin don't accept it + if chan == _HwPWM._PIN_INFO[p]["channel"]: + return False + + # available PWM pin and channel not used, ok + return True + + @staticmethod + def setup(pin: int): + if not _HwPWM.supports(pin): + raise ValueError("unsupported pin or pwm channel in use") + + info = _HwPWM._PIN_INFO[pin] + ch = info["channel"] + + # configure pin mux + old_func = _HwPWM._run(["pinctrl", str(pin)]) + _HwPWM._run(["pinctrl", str(pin), info["alt"]]) + + # enable pwm channel + _HwPWM._export(ch) + + pwm = _HwPWM._pwm_path(ch) + + # ensure disabled before configuration + try: + _HwPWM._write(pwm / "enable", 0) + except: + pass + + _HwPWM._pins[pin] = { "freq": 20000.0, "duty": 1.0, "oldfunc": old_func } + + + @staticmethod + def release(pin: int): + if not pin in _HwPWM._pins: + return + + info = _HwPWM._PIN_INFO[pin] + ch = info["channel"] + + pwm = _HwPWM._pwm_path(ch) + + if pwm.exists(): + try: + _HwPWM.write(pwm / "enable", 0) + except: + pass + + # return pin to input + _HwPWM._run(["pinctrl", str(pin), _HwPWM._pins[pin]["oldfunc"]]) + + del _HwPWM._pins[pin] + + @staticmethod + def _set_freq_duty(pin: int, freq: float, duty: float): + + info = _HwPWM._PIN_INFO[pin] + ch = info["channel"] + + pwm = _HwPWM._pwm_path(ch) + + period_ns = int(1_000_000_000 / freq) + duty_val = int(period_ns * duty) + + _HwPWM._write(pwm / "enable", 0) + _HwPWM._write(pwm / "period", period_ns) + _HwPWM._write(pwm / "duty_cycle", duty_val) + _HwPWM._write(pwm / "enable", 1) + + _HwPWM._pins[pin]["freq"] = freq + _HwPWM._pins[pin]["duty"] = duty + + @staticmethod + def set_frequency(pin: int, freq: float): + if not pin in _HwPWM._pins: + raise ValueError("pwm pin not initialized") + + info = _HwPWM._PIN_INFO[pin] + ch = info["channel"] + + duty = _HwPWM._pins[pin]["duty"] + _HwPWM._set_freq_duty(pin, freq, duty) + + @staticmethod + def get_frequency(pin: int): + if not pin in _HwPWM._pins: + raise ValueError("pwm pin not initialized") + + return _HwPWM._pins[pin]["freq"] + + @staticmethod + def set_duty_cycle(pin: int, duty: float): + if not pin in _HwPWM._pins: + raise ValueError("pwm pin not initialized") + + info = _HwPWM._PIN_INFO[pin] + ch = info["channel"] + + freq = _HwPWM._pins[pin]["freq"] + _HwPWM._set_freq_duty(pin, freq, duty) + + + @staticmethod + def get_duty_cycle(pin: int): + if not pin in _HwPWM._pins: + raise ValueError("pwm pin not initialized") + + return _HwPWM._pins[pin]["duty"] + + # cleanup routines -- resets PWM pins + + @staticmethod + def _cleanup(): + to_clean = [] + for pin in _HwPWM._pins.keys(): + to_clean.append(pin) + for pin in to_clean: + _HwPWM.release(pin) + + def _signal_handler(signum, frame): + _HwPWM._cleanup() + + +# ========================================================== +# SINGLETON +# ========================================================== + +# hardware pw, singleton +hwpwm = _HwPWM() From fc2de7c5ca31890311cbfede830f9e8c0ed32eb4 Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:37:54 +0000 Subject: [PATCH 03/75] updated example device config --- settings/device/example_custom.json | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/settings/device/example_custom.json b/settings/device/example_custom.json index 6236f8c..fee54b0 100644 --- a/settings/device/example_custom.json +++ b/settings/device/example_custom.json @@ -26,10 +26,15 @@ } }, "lights": { - "Openscan.eu Ringlight": { - "pins": [12], - "pwm_support": true - } + "Openscan.eu Ringlight": { + "pins": [ + 12 + ], + "pwm_support": true, + "pwm_frequency": 50000.0, + "pwm_min": 0.0, + "pwm_max": 3.3 + } }, "endstops": { "rotor-endstop": { @@ -44,7 +49,7 @@ } } }, - "motors_timeout": 180, + "motors_timeout": 30.0, "startup_mode": "startup_idle", - "calibrate_mode": "calibrate_on_wake" + "calibrate_mode": "calibrate_on_home" } From a15f537a98cb2365a82d2d49c09da897800943e4 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 9 Mar 2026 13:39:50 +0100 Subject: [PATCH 04/75] fix(scan_task): fix photo capture to support non-JPEG formats - Replaced `photo_async` with `photo` for single photo captures, now again correctly allowing different image formats. - Added regression tests to ensure proper handling of configured image formats. --- .../services/tasks/core/scan_task.py | 2 +- tests/controllers/services/test_scan_task.py | 88 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/openscan_firmware/controllers/services/tasks/core/scan_task.py b/openscan_firmware/controllers/services/tasks/core/scan_task.py index 9173527..d7098a6 100644 --- a/openscan_firmware/controllers/services/tasks/core/scan_task.py +++ b/openscan_firmware/controllers/services/tasks/core/scan_task.py @@ -415,7 +415,7 @@ async def _capture_photos_at_position(self, current_point: PolarPoint3D, index: if not self._ctx.focus_context or not self._ctx.focus_context["enabled"]: # Single photo capture - photo_data = await self._ctx.camera_controller.photo_async( + photo_data = self._ctx.camera_controller.photo( self._ctx.scan.settings.image_format ) photo_data.scan_metadata = ScanMetadata( diff --git a/tests/controllers/services/test_scan_task.py b/tests/controllers/services/test_scan_task.py index 9323c8e..38b9b2f 100644 --- a/tests/controllers/services/test_scan_task.py +++ b/tests/controllers/services/test_scan_task.py @@ -556,6 +556,94 @@ async def test_focus_stacking_sets_manual_focus_per_stack( assert project_manager.add_photo_async.call_count == len(focus_positions) + @pytest.mark.asyncio + async def test_single_capture_uses_configured_image_format( + self, + sample_scan_model: Scan, + fake_photo_data: PhotoData, + ): + """Ensure single captures request the ScanSetting.image_format from the camera.""" + + scan = sample_scan_model.model_copy(deep=True) + scan.settings.image_format = "dng" + + photo_payload = fake_photo_data.model_copy(deep=True) + photo_payload.format = "dng" + + camera_controller = MagicMock() + camera_controller.photo = MagicMock(return_value=photo_payload) + camera_controller.photo_async = AsyncMock() + camera_controller.settings = MagicMock() + + project_manager = MagicMock() + project_manager.add_photo_async = AsyncMock(return_value=None) + + task_model = Task(name="scan_task", task_type="core") + scan_task = ScanTask(task_model) + scan_task._ctx = ScanRuntime( + scan=scan, + camera_controller=camera_controller, + project_manager=project_manager, + path_dict={PolarPoint3D(theta=0, fi=0): 0}, + focus_context=None, + ) + + await scan_task._capture_photos_at_position(PolarPoint3D(theta=0, fi=0), 0) + await asyncio.sleep(0) + + camera_controller.photo.assert_called_once() + assert camera_controller.photo.call_args.args == ("dng",) + + + @pytest.mark.asyncio + async def test_focus_stacking_uses_configured_image_format( + self, + sample_scan_model: Scan, + fake_photo_data: PhotoData, + ): + """Ensure focus stacked captures request the ScanSetting.image_format via photo_async.""" + + scan = sample_scan_model.model_copy(deep=True) + scan.settings.image_format = "rgb_array" + scan.settings.focus_stacks = 3 + focus_positions = scan.settings.focus_positions + + focus_settings = FocusTrackingSettings(AF=True, manual_focus=0.0) + + base_payload = fake_photo_data.model_copy(deep=True) + base_payload.format = "rgb_array" + + camera_controller = MagicMock() + camera_controller.photo = AsyncMock() + camera_controller.photo_async = AsyncMock(return_value=base_payload) + camera_controller.settings = focus_settings + + project_manager = MagicMock() + project_manager.add_photo_async = AsyncMock(return_value=None) + + task_model = Task(name="scan_task", task_type="core") + scan_task = ScanTask(task_model) + scan_task._ctx = ScanRuntime( + scan=scan, + camera_controller=camera_controller, + project_manager=project_manager, + path_dict={PolarPoint3D(theta=0, fi=0): 0}, + focus_context={ + "enabled": True, + "positions": focus_positions, + "previous_settings": (focus_settings.AF, focus_settings.manual_focus), + }, + ) + + await scan_task._capture_photos_at_position(PolarPoint3D(theta=0, fi=0), 0) + await asyncio.sleep(0) + + assert camera_controller.photo.await_count == 0 + assert camera_controller.photo_async.await_count == len(focus_positions) + for awaited_call in camera_controller.photo_async.await_args_list: + assert awaited_call.args == ("rgb_array",) + + class TestScanTaskIntegration: """Integration tests for ScanTask persistence behavior with real ProjectManager.""" From a7f64cab44d7f32ce9acac354af0ff121903ccc9 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 11 Mar 2026 16:04:19 +0100 Subject: [PATCH 05/75] fix(device): scanner initialization and add safe defaults - Refactored `_scanner_device` creation into `_create_default_scanner_device` for better reusability. - Introduced `_FACTORY_DEFAULT_CONFIG` to store factory defaults in JSON format. - Updated configuration logic to enforce safe defaults (e.g., `motors_timeout`, `startup_mode`, `calibrate_mode`). - Enhanced `initialize` method with better parameter handling and separation of concerns. - Added integration and unit tests to validate configuration handling and initialization logic. --- openscan_firmware/controllers/device.py | 69 +++-- tests/controllers/test_device_controller.py | 286 ++++++++++++++++++++ tests/routers/test_device_router.py | 171 ++++++++++++ 3 files changed, 508 insertions(+), 18 deletions(-) create mode 100644 tests/routers/test_device_router.py diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index 9afadc6..fd0a9dc 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -10,6 +10,7 @@ import os import pathlib import asyncio +from copy import deepcopy from pathlib import Path from typing import Dict, List, Optional from importlib import resources @@ -65,20 +66,26 @@ logger = logging.getLogger(__name__) # Current scanner model -_scanner_device = ScannerDevice( - name="Unknown device", - model=None, - shield=None, - cameras={}, - motors={}, - lights={}, - endstops={}, -) -# beware, PrivateAttr are NOT initialized in constructor -# nor an error message is shown... -_scanner_device._idle=False -_scanner_device._initialized=False +def _create_default_scanner_device() -> ScannerDevice: + device = ScannerDevice( + name="Unknown device", + model=None, + shield=None, + cameras={}, + motors={}, + lights={}, + endstops={}, + ) + # beware, PrivateAttr are NOT initialized in constructor + # nor an error message is shown... + device._idle = False + device._initialized = False + return device + + +_scanner_device = _create_default_scanner_device() +_FACTORY_DEFAULT_CONFIG = _create_default_scanner_device().model_dump(mode="json") # Path to device configuration file (persisted) BASE_DIR = pathlib.Path(__file__).parent.parent.parent @@ -96,8 +103,8 @@ def load_device_config(config_file=None) -> dict: Returns: bool: True if configuration was loaded successfully """ - # populate default config dictionary - config_dict = _scanner_device.model_dump(mode='json') + # populate default config dictionary from factory defaults + config_dict = deepcopy(_FACTORY_DEFAULT_CONFIG) # Determine which configuration file to load if config_file is None: @@ -127,6 +134,11 @@ def load_device_config(config_file=None) -> dict: except Exception as e: logger.error(f"Error loading device configuration: {e}") + # enforce safe defaults for critical settings + config_dict.setdefault("motors_timeout", 0.0) + config_dict.setdefault("startup_mode", ScannerStartupMode.STARTUP_ENABLED.value) + config_dict.setdefault("calibrate_mode", ScannerCalibrateMode.CALIBRATE_MANUAL.value) + return config_dict @@ -170,7 +182,13 @@ async def set_device_config(config_file) -> bool: bool: True if successful, False otherwise """ - await initialize(load_device_config(config_file)) + config = load_device_config(config_file) + await initialize(config) + + if not save_device_config(): + logger.error("Failed to persist device configuration after loading %s", config_file) + return False + return True @@ -386,8 +404,23 @@ async def handle_idle_event(event: HardwareEvent): case _: logger.info("UNKNOWN EVENT") -async def initialize(config: dict = _scanner_device.model_dump(mode='json'), detect_cameras = False): - """Detect and load hardware components""" +async def initialize(config: dict | None = None, detect_cameras: bool = False): + """Detect and load hardware components. + + Args: + config: Optional configuration dictionary. When not provided, loads the + currently active device configuration from disk. + detect_cameras: Whether to force camera auto-detection. + """ + + if config is None: + config = load_device_config() + + await _initialize_with_config(config, detect_cameras) + + +async def _initialize_with_config(config: dict, detect_cameras: bool = False): + """Internal helper that assumes the configuration dict is already resolved.""" global _scanner_device # Load environment variables load_dotenv() diff --git a/tests/controllers/test_device_controller.py b/tests/controllers/test_device_controller.py index be91450..ce74a9f 100644 --- a/tests/controllers/test_device_controller.py +++ b/tests/controllers/test_device_controller.py @@ -173,6 +173,292 @@ async def fake_init(cfg, detect_cameras=False): assert isinstance(called.get("init"), dict) +@pytest.mark.asyncio +async def test_set_device_config_persists_loaded_config(monkeypatch, tmp_path): + device = _import_device(monkeypatch) + + config_file = tmp_path / "device_config.json" + config_file.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(device, "DEVICE_CONFIG_FILE", config_file, raising=True) + + preset = tmp_path / "preset.json" + preset.write_text(json.dumps({ + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "endstops": {}, + "motors_timeout": 5.0, + "startup_mode": device.ScannerStartupMode.STARTUP_IDLE.value, + "calibrate_mode": device.ScannerCalibrateMode.CALIBRATE_ON_WAKE.value, + })) + + async def fake_initialize(config, detect_cameras=False): + device._scanner_device = device.ScannerDevice( + name=config["name"], + model=None, + shield=None, + cameras={}, + motors={}, + lights={}, + endstops={}, + motors_timeout=config["motors_timeout"], + startup_mode=device.ScannerStartupMode(config["startup_mode"]), + calibrate_mode=device.ScannerCalibrateMode(config["calibrate_mode"]), + ) + device._scanner_device._initialized = True + + monkeypatch.setattr(device, "initialize", fake_initialize, raising=True) + + ok = await device.set_device_config(str(preset)) + assert ok is True + + persisted = json.loads(config_file.read_text()) + assert persisted["name"] == "Preset" + assert persisted["motors_timeout"] == 5.0 + assert persisted["startup_mode"] == device.ScannerStartupMode.STARTUP_IDLE.value + assert persisted["calibrate_mode"] == device.ScannerCalibrateMode.CALIBRATE_ON_WAKE.value + + +def _write_minimal_preset(target: Path): + content = { + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "endstops": {}, + } + target.write_text(json.dumps(content)) + + +def test_load_device_config_ignores_existing_scanner_state(monkeypatch, tmp_path): + device = _import_device(monkeypatch) + + config_path = tmp_path / "device_config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(device, "DEVICE_CONFIG_FILE", config_path) + + preset = tmp_path / "preset.json" + _write_minimal_preset(preset) + + device._scanner_device.motors_timeout = 180.0 + device._scanner_device.startup_mode = device.ScannerStartupMode.STARTUP_IDLE + device._scanner_device.calibrate_mode = device.ScannerCalibrateMode.CALIBRATE_ON_WAKE + + loaded = device.load_device_config(str(preset)) + + assert loaded["motors_timeout"] == 0.0 + assert loaded["startup_mode"] == device.ScannerStartupMode.STARTUP_ENABLED.value + assert loaded["calibrate_mode"] == device.ScannerCalibrateMode.CALIBRATE_MANUAL.value + + +def test_load_device_config_overwrites_persisted_values(monkeypatch, tmp_path): + device = _import_device(monkeypatch) + + config_path = tmp_path / "device_config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(device, "DEVICE_CONFIG_FILE", config_path) + + config_path.write_text(json.dumps({ + "name": "Custom", + "model": "custom", + "shield": "custom", + "cameras": {}, + "motors": {}, + "lights": {}, + "endstops": {}, + "motors_timeout": 999.0, + "startup_mode": device.ScannerStartupMode.STARTUP_IDLE.value, + "calibrate_mode": device.ScannerCalibrateMode.CALIBRATE_ON_WAKE.value, + })) + + preset = tmp_path / "preset.json" + _write_minimal_preset(preset) + + loaded = device.load_device_config(str(preset)) + + persisted = json.loads(config_path.read_text()) + for cfg in (loaded, persisted): + assert cfg["motors_timeout"] == 0.0 + assert cfg["startup_mode"] == device.ScannerStartupMode.STARTUP_ENABLED.value + assert cfg["calibrate_mode"] == device.ScannerCalibrateMode.CALIBRATE_MANUAL.value + + +@pytest.mark.asyncio +async def test_initialize_recreates_controllers_on_reinitialize(monkeypatch, tmp_path): + device = _import_device(monkeypatch) + + # fresh scanner state and redirected config path + monkeypatch.setattr(device, "_scanner_device", device._create_default_scanner_device(), raising=True) + config_path = tmp_path / "device_config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(device, "DEVICE_CONFIG_FILE", config_path, raising=True) + + config_payload = { + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": { + "rotor": { + "direction_pin": 5, + "enable_pin": 23, + "step_pin": 6, + "acceleration": 20000, + "max_speed": 5000, + "direction": 1, + "steps_per_rotation": 42667, + "min_angle": 0, + "max_angle": 360, + "home_angle": 90, + }, + "turntable": { + "direction_pin": 9, + "enable_pin": 22, + "step_pin": 11, + "acceleration": 5000, + "max_speed": 5000, + "direction": 1, + "steps_per_rotation": 3200, + "min_angle": 0, + "max_angle": 360, + "home_angle": 0, + }, + }, + "lights": { + "ring": { + "pins": [17, 27], + "pwm_support": False, + } + }, + "endstops": {}, + "motors_timeout": 0.0, + "startup_mode": device.ScannerStartupMode.STARTUP_ENABLED.value, + "calibrate_mode": device.ScannerCalibrateMode.CALIBRATE_MANUAL.value, + } + config_path.write_text(json.dumps(config_payload)) + + controllers = {"motors": {}, "lights": {}} + creation_log = {"motors": [], "lights": []} + removal_log = {"motors": [], "lights": []} + + class DummyMotorController: + def __init__(self, model): + self.model = model + self.angle = getattr(model, "angle", 0.0) + + def set_idle_callbacks(self, *_, **__): + return None + + def refresh(self): + return None + + def get_status(self): + return { + "name": self.model.name, + "angle": self.model.angle, + "busy": False, + "target_angle": None, + "settings": self.model.settings, + "endstop": None, + } + + class DummyLightController: + def __init__(self, model): + self.model = model + + def set_idle_callbacks(self, *_, **__): + return None + + def refresh(self): + return None + + async def turn_on(self): + return None + + def get_status(self): + settings = self.model.settings + payload = settings.model_dump() if hasattr(settings, "model_dump") else {} + return {"name": self.model.name, "is_on": False, "settings": payload} + + def _create_motor_controller(motor): + controller = DummyMotorController(motor) + controllers["motors"][motor.name] = controller + creation_log["motors"].append(motor.name) + return controller + + def _remove_motor_controller(name): + removal_log["motors"].append(name) + controllers["motors"].pop(name, None) + return True + + def _create_light_controller(light): + controller = DummyLightController(light) + controllers["lights"][light.name] = controller + creation_log["lights"].append(light.name) + return controller + + def _remove_light_controller(name): + removal_log["lights"].append(name) + controllers["lights"].pop(name, None) + return True + + monkeypatch.setattr(device, "create_motor_controller", _create_motor_controller, raising=True) + monkeypatch.setattr(device, "remove_motor_controller", _remove_motor_controller, raising=True) + monkeypatch.setattr(device, "get_all_motor_controllers", lambda: controllers["motors"].copy(), raising=True) + + monkeypatch.setattr(device, "create_light_controller", _create_light_controller, raising=True) + monkeypatch.setattr(device, "remove_light_controller", _remove_light_controller, raising=True) + monkeypatch.setattr(device, "get_all_light_controllers", lambda: controllers["lights"].copy(), raising=True) + + monkeypatch.setattr(device, "create_camera_controller", lambda *_, **__: None, raising=True) + monkeypatch.setattr(device, "remove_camera_controller", lambda *_: True, raising=True) + monkeypatch.setattr(device, "get_all_camera_controllers", lambda: {}, raising=True) + monkeypatch.setattr(device, "get_available_camera_types", lambda: {}, raising=True) + monkeypatch.setattr(device, "_detect_cameras", lambda: {}, raising=True) + + dummy_timer = types.SimpleNamespace( + set_timeout=lambda *_: None, + enable=lambda: None, + disable=lambda: None, + start=lambda: None, + stop=lambda: None, + reset=lambda: None, + on_timeout=None, + ) + monkeypatch.setattr(device, "inactivity_timer", dummy_timer, raising=True) + monkeypatch.setattr(device, "cleanup_all_pins", lambda: None, raising=True) + monkeypatch.setattr(device, "schedule_device_status_broadcast", lambda *_, **__: None, raising=True) + monkeypatch.setattr(device, "get_project_manager", lambda: types.SimpleNamespace(), raising=True) + monkeypatch.setattr(device, "load_persistent_cloud_settings", lambda: None, raising=True) + monkeypatch.setattr(device, "load_cloud_settings_from_env", lambda: None, raising=True) + monkeypatch.setattr(device, "set_cloud_settings", lambda *_: None, raising=True) + monkeypatch.setattr(device, "set_active_source", lambda *_: None, raising=True) + + await device.initialize(config=config_payload, detect_cameras=False) + + assert creation_log["motors"] == ["rotor", "turntable"] + assert creation_log["lights"] == ["ring"] + first_status = device.get_device_info() + assert set(first_status["motors"].keys()) == {"rotor", "turntable"} + assert set(first_status["lights"].keys()) == {"ring"} + + await device.initialize(detect_cameras=False) + + assert removal_log["motors"] == ["rotor", "turntable"] + assert removal_log["lights"] == ["ring"] + assert creation_log["motors"] == ["rotor", "turntable", "rotor", "turntable"] + assert creation_log["lights"] == ["ring", "ring"] + + second_status = device.get_device_info() + assert set(second_status["motors"].keys()) == {"rotor", "turntable"} + assert set(second_status["lights"].keys()) == {"ring"} + + def test_reboot_and_shutdown_call_system(monkeypatch): device = _import_device(monkeypatch) diff --git a/tests/routers/test_device_router.py b/tests/routers/test_device_router.py new file mode 100644 index 0000000..e11fa56 --- /dev/null +++ b/tests/routers/test_device_router.py @@ -0,0 +1,171 @@ +"""Integration-style tests for the device router endpoints.""" + +from __future__ import annotations + +import json +from typing import Callable + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def device_client(latest_router_loader) -> TestClient: + """Provide a FastAPI client with the latest device router mounted.""" + + app = FastAPI() + device_router = latest_router_loader("device") + app.include_router(device_router.router, prefix="/latest") + with TestClient(app) as client: + yield client + + +@pytest.fixture +def device_router_path(latest_router_path) -> Callable[[str], str]: # type: ignore[override] + """Shortcut to build module paths for the latest router version.""" + + return latest_router_path + + +def test_set_config_file_returns_factory_defaults(monkeypatch, tmp_path, device_client, device_router_path): + module_path = device_router_path("device") + + preset_path = tmp_path / "mini.json" + preset_path.write_text(json.dumps({ + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "endstops": {}, + })) + + monkeypatch.setattr( + f"{module_path}.device.get_available_configs", + lambda: [ + { + "filename": "mini.json", + "path": str(preset_path), + } + ], + raising=False, + ) + + captured = {} + + async def fake_set_device_config(path: str): + captured["path"] = path + return True + + monkeypatch.setattr(f"{module_path}.device.set_device_config", fake_set_device_config, raising=False) + + status_payload = { + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "motors_timeout": 0.0, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + "initialized": False, + } + monkeypatch.setattr(f"{module_path}.device.get_device_info", lambda: status_payload, raising=False) + + response = device_client.put( + "/latest/device/configurations/current", + json={"config_file": "mini.json"}, + ) + + assert response.status_code == 200 + assert captured["path"] == str(preset_path) + + payload = response.json() + assert payload["success"] is True + assert payload["status"]["motors_timeout"] == 0.0 + assert payload["status"]["startup_mode"] == "startup_enabled" + assert payload["status"]["calibrate_mode"] == "calibrate_manual" + + +def test_reinitialize_endpoint_calls_controller(monkeypatch, device_client, device_router_path): + module_path = device_router_path("device") + + motor_settings = { + "direction_pin": 5, + "enable_pin": 23, + "step_pin": 6, + "acceleration": 20000, + "max_speed": 5000, + "direction": 1, + "steps_per_rotation": 42667, + "min_angle": 0, + "max_angle": 360, + "home_angle": 90, + } + light_settings = { + "pins": [17, 27], + "pwm_support": False, + } + + camera_settings = { + "shutter": 50.0, + "orientation_flag": 1, + } + + status_payload = { + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": { + "cam": { + "name": "cam", + "type": "linuxpy", + "busy": False, + "settings": camera_settings, + } + }, + "motors": { + "rotor": { + "name": "rotor", + "angle": 0.0, + "busy": False, + "target_angle": None, + "settings": motor_settings, + "endstop": None, + } + }, + "lights": { + "ring": { + "name": "ring", + "is_on": False, + "settings": light_settings, + } + }, + "motors_timeout": 0.0, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + "initialized": True, + } + monkeypatch.setattr(f"{module_path}.device.get_device_info", lambda: status_payload, raising=False) + + detected_args: list[bool] = [] + + async def fake_initialize(*, detect_cameras: bool = False): + detected_args.append(detect_cameras) + + monkeypatch.setattr(f"{module_path}.device.initialize", fake_initialize, raising=False) + response = device_client.post( + "/latest/device/configurations/current/initialize", + params={"detect_cameras": "true"}, + ) + + assert response.status_code == 200 + assert detected_args == [True] + payload = response.json() + assert payload["success"] is True + assert payload["status"]["initialized"] is True + assert set(payload["status"]["motors"].keys()) == {"rotor"} + assert set(payload["status"]["lights"].keys()) == {"ring"} From 9870ef07af0b1a07fc0a718f62476cc148b4afa6 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 16 Mar 2026 12:13:17 +0100 Subject: [PATCH 06/75] Feature/scan wifi qr code (#82) * feat(tasks): add QR code WiFi scan task and utility functions - Introduced `qr_scan_task` to detect and apply WiFi credentials from QR codes via the camera. - Added `/qr-scan` endpoint to start background QR code scanning. - Implemented `wifi.py` utilities for QR code parsing and network configuration. - Updated `task_manager` to register `qr_scan_task`. - Added auto-start of QR WiFi scan task on startup if enabled in firmware settings and no WiFi is connected. - Introduced `firmware_settings.json` for global firmware configuration. * feat(tasks): enhance QR WiFi scan with robust decoding and improved performance - Extended dependencies in `pyproject.toml` to include `zxing-cpp`. - Introduced `_cleanup_stale_qr_tasks` to remove cancelled/interrupted QR tasks and trim error task history. - Enhanced task initialization in `qr_scan_task` with automatic cleanup on startup. - Added tests to validate success and error scenarios for `qr_scan_task`. - Verified proper cleanup of stale tasks including cancelled and outdated error tasks. - Ensured correct task status updates and WiFi QR credential parsing logic. --- openscan_firmware/config/firmware.py | 93 +++++++ .../services/tasks/core/qr_scan_task.py | 233 ++++++++++++++++++ .../services/tasks/task_manager.py | 4 + openscan_firmware/main.py | 37 +++ openscan_firmware/routers/next/develop.py | 24 ++ openscan_firmware/utils/qr_reader.py | 170 +++++++++++++ openscan_firmware/utils/wifi.py | 142 +++++++++++ pyproject.toml | 1 + settings/firmware/firmware_settings.json | 3 + .../services/tasks/test_qr_scan_task.py | 168 +++++++++++++ 10 files changed, 875 insertions(+) create mode 100644 openscan_firmware/config/firmware.py create mode 100644 openscan_firmware/controllers/services/tasks/core/qr_scan_task.py create mode 100644 openscan_firmware/utils/qr_reader.py create mode 100644 openscan_firmware/utils/wifi.py create mode 100644 settings/firmware/firmware_settings.json create mode 100644 tests/controllers/services/tasks/test_qr_scan_task.py diff --git a/openscan_firmware/config/firmware.py b/openscan_firmware/config/firmware.py new file mode 100644 index 0000000..5f769f5 --- /dev/null +++ b/openscan_firmware/config/firmware.py @@ -0,0 +1,93 @@ +"""Firmware-level settings that are independent of the hardware configuration. + +These settings control firmware behavior such as automatic background tasks, +update preferences, and other global toggles. They are persisted in +``settings/firmware/firmware_settings.json`` and loaded once at startup. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from pydantic import BaseModel, Field + +from openscan_firmware.utils.dir_paths import resolve_settings_file, resolve_settings_dir + +logger = logging.getLogger(__name__) + +_SETTINGS_SUBDIR = "firmware" +_SETTINGS_FILENAME = "firmware_settings.json" + + +class FirmwareSettings(BaseModel): + """Global firmware behaviour toggles. + + Attributes: + qr_wifi_scan_enabled: When True the firmware automatically starts the + QR WiFi scan task on startup if no WiFi connection is detected. + Users who only use LAN can set this to False to prevent the + background task from running. + """ + + qr_wifi_scan_enabled: bool = Field( + default=True, + description="Automatically scan for WiFi QR codes on startup when no WiFi is connected.", + ) + + +# Module-level singleton – loaded once, then reused. +_firmware_settings: FirmwareSettings | None = None + + +def get_firmware_settings() -> FirmwareSettings: + """Return the current firmware settings (loads from disk on first call).""" + global _firmware_settings + if _firmware_settings is None: + _firmware_settings = load_firmware_settings() + return _firmware_settings + + +def load_firmware_settings() -> FirmwareSettings: + """Load firmware settings from disk, falling back to defaults. + + If the settings file does not exist yet it is created with default values + so the user has a file to edit. + + Returns: + FirmwareSettings populated from the JSON file or defaults. + """ + settings_file = resolve_settings_file(_SETTINGS_SUBDIR, _SETTINGS_FILENAME) + + if settings_file.exists(): + try: + raw = json.loads(settings_file.read_text(encoding="utf-8")) + settings = FirmwareSettings.model_validate(raw) + logger.info("Loaded firmware settings from %s", settings_file) + return settings + except Exception: + logger.exception("Failed to parse firmware settings from %s – using defaults", settings_file) + + # File missing or broken → create with defaults + settings = FirmwareSettings() + save_firmware_settings(settings) + return settings + + +def save_firmware_settings(settings: FirmwareSettings) -> None: + """Persist firmware settings to disk. + + Args: + settings: The settings model to write. + """ + global _firmware_settings + settings_file = resolve_settings_file(_SETTINGS_SUBDIR, _SETTINGS_FILENAME) + settings_file.parent.mkdir(parents=True, exist_ok=True) + + settings_file.write_text( + settings.model_dump_json(indent=4) + "\n", + encoding="utf-8", + ) + _firmware_settings = settings + logger.info("Saved firmware settings to %s", settings_file) diff --git a/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py new file mode 100644 index 0000000..f2c8b39 --- /dev/null +++ b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py @@ -0,0 +1,233 @@ +"""Background task for scanning QR codes via the camera. + +This task continuously captures preview frames, runs QR code detection, and +when a WiFi QR code is recognized, applies the credentials via NetworkManager. + +The task runs indefinitely until a WiFi QR code is found or the task is +cancelled. This makes the setup experience frictionless – the user can take +their time holding the QR code in front of the camera. +""" + +from __future__ import annotations + +import asyncio +import io +import logging +import time +from typing import AsyncGenerator + +import numpy as np + +from PIL import Image + +from openscan_firmware.controllers.services.tasks.base_task import BaseTask +from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager +from openscan_firmware.models.task import TaskProgress, TaskStatus + +logger = logging.getLogger(__name__) + +# How often to grab a frame (seconds) +_SCAN_INTERVAL = 0.5 +# Give the camera a short warm-up before we start scanning +_STARTUP_DELAY = 3.0 +# Downscale preview frames to keep zxing fast but detailed +_MAX_PREVIEW_EDGE = 1400 +# Throttle how often we emit "no QR yet" info messages to keep logs calm +_NO_HIT_INFO_INTERVAL = 30 +# Emit TaskProgress updates at most every N seconds while idle scanning +_PROGRESS_UPDATE_INTERVAL = 30.0 + + +class QrScanTask(BaseTask): + """Scan camera preview frames for WiFi QR codes and apply credentials. + + This is a non-exclusive async task that runs indefinitely. While it runs, + the preview stream remains usable because the existing ``_hw_lock`` in + ``CameraController`` handles concurrent access gracefully (preview returns + ``None`` while a capture is in progress and vice-versa). + + The task terminates when: + - A WiFi QR code is successfully detected and the connection is established. + - The task is cancelled (e.g. by the user or another service). + """ + + task_name = "qr_scan_task" + task_category = "core" + is_exclusive = False + is_blocking = False + + async def run( + self, + camera_name: str, + ) -> AsyncGenerator[TaskProgress, None]: + """Capture frames and look for WiFi QR codes. + + Runs indefinitely until a WiFi QR code is found or the task is + cancelled. Progress ``total`` is set to 0 to signal an indeterminate + task to the frontend. + + Args: + camera_name: Name of the camera controller to use for captures. + + Yields: + TaskProgress updates for the frontend. + """ + # Lazy imports to avoid side effects at module load time + import numpy as np + from openscan_firmware.controllers.hardware.cameras.camera import get_camera_controller + from openscan_firmware.utils.wifi import parse_wifi_qr, connect_wifi + from openscan_firmware.utils.qr_reader import ZxingQRReader, StableQRConsensus + + yield TaskProgress(current=0, total=0, message="QR scan starting – warming up the camera") + + if _STARTUP_DELAY > 0: + await asyncio.sleep(_STARTUP_DELAY) + + await _cleanup_stale_qr_tasks() + + controller = get_camera_controller(camera_name) + reader = ZxingQRReader() + # Two confirmations within the last five frames are enough; this keeps the + # scan responsive even when individual preview frames fail. + consensus = StableQRConsensus(reader, required_hits=2, window=5) + + yield TaskProgress(current=0, total=0, message="QR scan ready – hold a WiFi QR code in front of the camera") + + attempt = 0 + last_progress_emit = 0.0 + while True: + attempt += 1 + + await self.wait_for_pause() + if self.is_cancelled(): + logger.info("QR scan task cancelled at attempt %d", attempt) + return + + # Capture a preview frame (JPEG) and convert it to an RGB numpy array + try: + frame_for_decode = await _capture_preview_array(controller) + if frame_for_decode is None: + logger.debug("QR scan attempt %d: preview frame unavailable", attempt) + yield TaskProgress(current=attempt, total=0, message="Waiting for preview frame...") + await asyncio.sleep(_SCAN_INTERVAL) + continue + + except Exception as exc: + logger.warning("Preview capture failed on attempt %d: %s", attempt, exc) + yield TaskProgress(current=attempt, total=0, message=f"Preview error: {exc}") + await asyncio.sleep(_SCAN_INTERVAL) + continue + + # Detect QR codes in the frame using the robust reader with consensus + decoded_text = consensus.feed(frame_for_decode) + + if decoded_text and decoded_text.startswith("WIFI:"): + try: + credentials = parse_wifi_qr(decoded_text) + logger.info("WiFi QR code detected for SSID '%s'", credentials.ssid) + yield TaskProgress(current=attempt, total=0, message="WiFi QR code detected! Connecting...") + output = await asyncio.to_thread(connect_wifi, credentials) + result_msg = f"Connected to '{credentials.ssid}'" + logger.info(result_msg) + + self._task_model.result = { + "ssid": credentials.ssid, + "security": credentials.security, + "hidden": credentials.hidden, + "nmcli_output": output, + } + + yield TaskProgress(current=1, total=1, message=result_msg) + return + + except Exception as exc: + error_msg = f"Failed to apply WiFi credentials: {exc}" + logger.error(error_msg) + self._task_model.result = {"error": error_msg} + raise RuntimeError(error_msg) from exc + + elif decoded_text: + logger.debug("Non-WiFi QR code found: %s", decoded_text[:50]) + + now = time.monotonic() + if last_progress_emit == 0.0 or (now - last_progress_emit) >= _PROGRESS_UPDATE_INTERVAL: + yield TaskProgress(current=attempt, total=0, message=f"Scanning... (attempt {attempt})") + last_progress_emit = now + await asyncio.sleep(_SCAN_INTERVAL) + + +async def _capture_preview_array(controller) -> "np.ndarray | None": + """Fetch a preview frame from the controller and return it as an RGB numpy array.""" + preview_io = await controller.preview_async() + if preview_io is None: + return None + + if isinstance(preview_io, bytes): + data = preview_io + preview_io = None + else: + try: + data = preview_io.read() + finally: + try: + preview_io.close() + except Exception: # noqa: BLE001 + pass + + try: + with Image.open(io.BytesIO(data)) as img: + img = img.convert("RGB") + img = _downscale_image(img, _MAX_PREVIEW_EDGE) + frame = np.array(img) + except Exception as exc: + logger.debug("Failed to decode preview JPEG: %s", exc) + return None + + return frame + + +def _downscale_image(image: Image.Image, max_edge: int) -> Image.Image: + if max_edge <= 0: + return image + + width, height = image.size + current_edge = max(width, height) + if current_edge <= max_edge: + return image + + scale = max_edge / float(current_edge) + new_size = (max(1, int(width * scale)), max(1, int(height * scale))) + return image.resize(new_size, Image.LANCZOS) + + +async def _cleanup_stale_qr_tasks() -> None: + """Remove cancelled/interrupted QR tasks and trim error history to last three.""" + task_manager = get_task_manager() + relevant = [task for task in task_manager.get_all_tasks_info() if task.task_type == QrScanTask.task_name] + + stale_statuses = {TaskStatus.CANCELLED, TaskStatus.INTERRUPTED} + removed = 0 + + for task in relevant: + if task.status not in stale_statuses: + continue + try: + await task_manager.delete_task(task.id) + removed += 1 + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to delete stale QR task %s: %s", task.id, exc) + + error_tasks = sorted( + (task for task in relevant if task.status == TaskStatus.ERROR), + key=lambda task: task.created_at, + reverse=True, + ) + for task in error_tasks[3:]: + try: + await task_manager.delete_task(task.id) + removed += 1 + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to delete old QR task error %s: %s", task.id, exc) + + if removed: + logger.debug("Cleaned up %d stale QR WiFi scan tasks", removed) diff --git a/openscan_firmware/controllers/services/tasks/task_manager.py b/openscan_firmware/controllers/services/tasks/task_manager.py index 1a404e0..4d12d9a 100644 --- a/openscan_firmware/controllers/services/tasks/task_manager.py +++ b/openscan_firmware/controllers/services/tasks/task_manager.py @@ -135,12 +135,16 @@ def _register_builtin_core_tasks(self) -> None: CloudUploadTask as CoreCloudUploadTask, CloudDownloadTask as CoreCloudDownloadTask, ) + from openscan_firmware.controllers.services.tasks.core.qr_scan_task import ( + QrScanTask as CoreQrScanTask, + ) fallback_tasks = { "scan_task": CoreScanTask, "focus_stacking_task": CoreFocusStackingTask, "cloud_upload_task": CoreCloudUploadTask, "cloud_download_task": CoreCloudDownloadTask, + "qr_scan_task": CoreQrScanTask, } for task_name, task_cls in fallback_tasks.items(): diff --git a/openscan_firmware/main.py b/openscan_firmware/main.py index b5bc804..e2206f1 100644 --- a/openscan_firmware/main.py +++ b/openscan_firmware/main.py @@ -68,11 +68,45 @@ from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager from openscan_firmware.utils.firmware_state import handle_startup +from openscan_firmware.config.firmware import get_firmware_settings +from openscan_firmware.utils.wifi import is_wifi_connected logger = logging.getLogger(__name__) +async def _maybe_start_qr_wifi_scan(task_manager) -> None: + """Start the QR WiFi scan task if enabled in firmware settings and no WiFi is connected. + + This is called once during application startup. The task runs indefinitely + in the background until a WiFi QR code is found or the task is cancelled. + """ + firmware_settings = get_firmware_settings() + + if not firmware_settings.qr_wifi_scan_enabled: + logger.info("QR WiFi scan is disabled in firmware settings – skipping auto-start.") + return + + if is_wifi_connected(): + logger.info("WiFi is already connected – skipping QR WiFi scan auto-start.") + return + + # Find the first available camera to use for scanning + from openscan_firmware.controllers.hardware.cameras.camera import get_all_camera_controllers + cameras = get_all_camera_controllers() + if not cameras: + logger.warning("No camera controllers available – cannot auto-start QR WiFi scan.") + return + + camera_name = next(iter(cameras)) + logger.info("No WiFi connection detected. Starting QR WiFi scan task with camera '%s'.", camera_name) + + try: + await task_manager.create_and_run_task("qr_scan_task", camera_name=camera_name) + except Exception: + logger.exception("Failed to auto-start QR WiFi scan task.") + + REQUIRED_CORE_TASKS = [ "scan_task", "focus_stacking_task", @@ -118,6 +152,9 @@ async def lifespan(app: FastAPI): # Now that tasks are registered, restore any persisted tasks task_manager.restore_tasks_from_persistence() + # Auto-start QR WiFi scan if enabled and no WiFi is connected + await _maybe_start_qr_wifi_scan(task_manager) + yield # application runs here # Code to run on shutdown diff --git a/openscan_firmware/routers/next/develop.py b/openscan_firmware/routers/next/develop.py index 384ebc8..0b05272 100644 --- a/openscan_firmware/routers/next/develop.py +++ b/openscan_firmware/routers/next/develop.py @@ -93,6 +93,30 @@ async def hello_world_async(total_steps: int, delay: float): return task +@router.post("/qr-scan", response_model=Task, status_code=status.HTTP_202_ACCEPTED) +async def start_qr_scan( + camera_name: str = Query(description="Name of the camera controller to use"), +): + """Start a background task that scans for WiFi QR codes via the camera. + + The task runs indefinitely, capturing frames and looking for QR codes. + When it finds an Android/iOS WiFi share QR code it connects to the + network via nmcli and completes. Cancel the task to stop scanning. + + Args: + camera_name: Name of the camera controller to use for captures. + + Returns: + Task: The created task model (poll via /tasks/{id} for progress). + """ + task_manager = get_task_manager() + task = await task_manager.create_and_run_task( + "qr_scan_task", + camera_name=camera_name, + ) + return task + + @router.get("/{method}", response_model=list[paths.CartesianPoint3D]) async def get_path(method: paths.PathMethod, points: int): """Get a list of coordinates by path method and number of points""" diff --git a/openscan_firmware/utils/qr_reader.py b/openscan_firmware/utils/qr_reader.py new file mode 100644 index 0000000..be6503f --- /dev/null +++ b/openscan_firmware/utils/qr_reader.py @@ -0,0 +1,170 @@ +"""Robust QR decoding helpers used by the WiFi setup task.""" + +from __future__ import annotations + +from collections import Counter, deque +from typing import Optional + +import logging +from PIL import Image +import numpy as np + +try: # pragma: no cover - optional dependency on systems without zxingcpp + import zxingcpp # type: ignore +except Exception as exc: # noqa: BLE001 - optional import + zxingcpp = None + ZXING_IMPORT_ERROR = exc +else: + ZXING_IMPORT_ERROR = None + +logger = logging.getLogger(__name__) + + +class ZxingQRReader: + """Thin convenience wrapper around :func:`zxingcpp.read_barcodes`.""" + + def __init__(self, max_edge: int | None = None, upscale_factor: int = 2, **legacy_flags: object) -> None: + if zxingcpp is None: # pragma: no cover - executed only when dependency missing + logger.error("zxingcpp dependency missing – QR reader cannot start.") + raise RuntimeError( + "zxingcpp is not installed. Install the 'zxing-cpp' Python package to enable QR scanning." + ) from ZXING_IMPORT_ERROR + + self.max_edge = max_edge if (max_edge is None or max_edge > 0) else None + self.upscale_factor = max(1, upscale_factor) + + if legacy_flags: + logger.debug("Ignoring legacy QR reader flags: %s", ", ".join(sorted(legacy_flags.keys()))) + + def decode(self, frame: np.ndarray) -> Optional[str]: + """Return the decoded QR text or ``None`` if nothing is found.""" + if frame is None or frame.size == 0: + return None + + base = self._ensure_uint8(frame) + if self.max_edge: + base = self._resize_max_edge(base, self.max_edge) + + variants = self._variants(base) + for variant in variants: + try: + results = zxingcpp.read_barcodes(variant) + except TypeError as exc: # pragma: no cover - indicates API drift + logger.error("zxingcpp.read_barcodes signature mismatch: %s", exc) + raise + except Exception as exc: # noqa: BLE001 - decoder errors should not abort entire scan + logger.debug("zxingcpp.read_barcodes failed: %s", exc, exc_info=True) + continue + + for result in results or []: + text = getattr(result, "text", None) + if text: + logger.info("QR decode succeeded (length %d)", len(text)) + return text + + #logger.debug("QR decode attempt finished with no matches") + return None + + def _variants(self, frame: np.ndarray) -> list[np.ndarray]: + variants: list[np.ndarray] = [] + + if frame.ndim == 3: + variants.append(frame) + gray = self._to_grayscale(frame) + else: + gray = frame + + variants.append(gray) + + stretched = self._stretch_contrast(gray) + if stretched is not gray: + variants.append(stretched) + + threshold = self._threshold(gray) + variants.append(threshold) + + inverted_gray = self._invert(gray) + variants.append(inverted_gray) + variants.append(self._invert(threshold)) + + if max(gray.shape) < 960 and self.upscale_factor > 1: + upscaled = self._upscale(gray, factor=self.upscale_factor) + variants.append(upscaled) + variants.append(self._invert(upscaled)) + + return variants + + def _ensure_uint8(self, frame: np.ndarray) -> np.ndarray: + array = np.asarray(frame) + if array.dtype == np.uint8: + return array + + if np.issubdtype(array.dtype, np.floating): + scaled = np.clip(array * 255.0, 0, 255) + else: + scaled = np.clip(array, 0, 255) + return scaled.astype(np.uint8) + + def _to_grayscale(self, frame: np.ndarray) -> np.ndarray: + if frame.ndim != 3 or frame.shape[2] < 3: + return frame + # Assume RGB ordering (Picamera2 returns RGB arrays) + r, g, b = frame[..., 0], frame[..., 1], frame[..., 2] + gray = (0.299 * r + 0.587 * g + 0.114 * b) + return np.clip(gray, 0, 255).astype(np.uint8) + + def _stretch_contrast(self, image: np.ndarray) -> np.ndarray: + # Simple min/max normalization to boost local contrast + min_val = float(image.min()) + max_val = float(image.max()) + if max_val - min_val < 10: + return image + stretched = (image - min_val) * (255.0 / (max_val - min_val)) + return np.clip(stretched, 0, 255).astype(np.uint8) + + def _threshold(self, image: np.ndarray) -> np.ndarray: + median = np.median(image) + threshold = np.where(image > median, 255, 0).astype(np.uint8) + return threshold + + def _invert(self, image: np.ndarray) -> np.ndarray: + return 255 - image + + def _upscale(self, image: np.ndarray, factor: int = 2) -> np.ndarray: + return np.repeat(np.repeat(image, factor, axis=0), factor, axis=1) + + def _resize_max_edge(self, image: np.ndarray, max_edge: int) -> np.ndarray: + height, width = image.shape[:2] + current_edge = max(height, width) + if current_edge <= max_edge: + return image + + scale = max_edge / float(current_edge) + new_size = (max(1, int(width * scale)), max(1, int(height * scale))) + pil_image = Image.fromarray(image) + resized = pil_image.resize(new_size, Image.LANCZOS) + return np.array(resized) + + + +class StableQRConsensus: + """Accept a payload only after it was confirmed across multiple frames.""" + + def __init__(self, reader: ZxingQRReader, required_hits: int = 3, window: int = 5): + self.reader = reader + self.required_hits = required_hits + self.history: deque[Optional[str]] = deque(maxlen=window) + + def feed(self, frame: np.ndarray) -> Optional[str]: + text = self.reader.decode(frame) + self.history.append(text) + + valid = [value for value in self.history if value] + if not valid: + return None + + value, count = Counter(valid).most_common(1)[0] + if count >= self.required_hits: + return value + + return None diff --git a/openscan_firmware/utils/wifi.py b/openscan_firmware/utils/wifi.py new file mode 100644 index 0000000..8558a5f --- /dev/null +++ b/openscan_firmware/utils/wifi.py @@ -0,0 +1,142 @@ +"""WiFi QR code parsing and network configuration utilities. + +Parses the standard WiFi QR code format used by Android and iOS share features +and applies the credentials via NetworkManager (nmcli). +""" + +from __future__ import annotations + +import logging +import re +import subprocess +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class WifiCredentials: + """Parsed WiFi credentials from a QR code string. + + Attributes: + ssid: The network name. + password: The network password (empty string for open networks). + security: Security type, e.g. "WPA", "WEP", or "nopass". + hidden: Whether the network is hidden. + """ + ssid: str + password: str = "" + security: str = "WPA" + hidden: bool = False + + +def parse_wifi_qr(raw: str) -> WifiCredentials: + """Parse an Android/iOS WiFi share QR code string. + + The expected format is:: + + WIFI:T:;S:;P:;H:;; + + Fields may appear in any order. The ``T``, ``H``, and ``P`` fields are + optional. Semicolons inside values can be escaped with a backslash. + + Args: + raw: The raw string decoded from a QR code. + + Returns: + WifiCredentials with the extracted values. + + Raises: + ValueError: If the string is not a valid WiFi QR code or the SSID is + missing. + """ + if not raw.startswith("WIFI:"): + raise ValueError(f"Not a WiFi QR code string: {raw!r}") + + # Strip the "WIFI:" prefix and trailing ";;" + body = raw[5:] + if body.endswith(";;"): + body = body[:-2] + + fields: dict[str, str] = {} + # Match key:value pairs, allowing escaped semicolons inside values + for match in re.finditer(r"([TSPH]):((\\.|[^;])*)(?:;|$)", body): + key = match.group(1) + # Unescape backslash-escaped characters + value = re.sub(r"\\(.)", r"\1", match.group(2)) + fields[key] = value + + ssid = fields.get("S", "").strip() + if not ssid: + raise ValueError("WiFi QR code is missing the SSID (S field)") + + return WifiCredentials( + ssid=ssid, + password=fields.get("P", ""), + security=fields.get("T", "WPA"), + hidden=fields.get("H", "").lower() == "true", + ) + + +def connect_wifi(credentials: WifiCredentials) -> str: + """Connect to a WiFi network using NetworkManager (nmcli). + + This requires the process to have sufficient privileges (typically root) + to modify network connections. + + Args: + credentials: The WiFi credentials to use. + + Returns: + The stdout output from nmcli on success. + + Raises: + RuntimeError: If nmcli is not available or the connection attempt fails. + """ + cmd = [ + "nmcli", "device", "wifi", "connect", credentials.ssid, + "password", credentials.password, + ] + + if credentials.hidden: + cmd.extend(["hidden", "yes"]) + + logger.info("Attempting to connect to WiFi network '%s'", credentials.ssid) + logger.debug("Running: %s", " ".join(cmd[:5]) + " ****") # mask password + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + error_msg = result.stderr.strip() or result.stdout.strip() + logger.error("nmcli failed (rc=%d): %s", result.returncode, error_msg) + raise RuntimeError(f"Failed to connect to '{credentials.ssid}': {error_msg}") + + logger.info("Successfully connected to WiFi network '%s'", credentials.ssid) + return result.stdout.strip() + + +def is_wifi_connected() -> bool: + """Check whether any WiFi device is currently connected. + + Returns: + True if at least one WiFi device reports a connected state. + """ + try: + result = subprocess.run( + ["nmcli", "-t", "-f", "TYPE,STATE", "device"], + capture_output=True, + text=True, + timeout=5, + ) + for line in result.stdout.splitlines(): + parts = line.split(":") + if len(parts) >= 2 and parts[0] == "wifi" and parts[1] == "connected": + return True + except (subprocess.TimeoutExpired, FileNotFoundError) as exc: + logger.warning("Could not check WiFi status: %s", exc) + return False diff --git a/pyproject.toml b/pyproject.toml index 2339885..634c73c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "anyio==4.10.0", "piexif==1.1.3", "pydantic_core==2.33.1", + "zxing-cpp==2.2.0", ] [project.optional-dependencies] diff --git a/settings/firmware/firmware_settings.json b/settings/firmware/firmware_settings.json new file mode 100644 index 0000000..a696c1a --- /dev/null +++ b/settings/firmware/firmware_settings.json @@ -0,0 +1,3 @@ +{ + "qr_wifi_scan_enabled": true +} diff --git a/tests/controllers/services/tasks/test_qr_scan_task.py b/tests/controllers/services/tasks/test_qr_scan_task.py new file mode 100644 index 0000000..d847454 --- /dev/null +++ b/tests/controllers/services/tasks/test_qr_scan_task.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import numpy as np +import pytest +import pytest_asyncio + +from openscan_firmware.controllers.services.tasks.task_manager import TaskManager +from openscan_firmware.controllers.services.tasks.core import qr_scan_task as qr_module +from openscan_firmware.models.task import Task, TaskStatus + + +@pytest_asyncio.fixture +async def qr_task_manager(): + """Provide a clean TaskManager instance with autodiscovered tasks.""" + + TaskManager._instance = None + task_manager = TaskManager() + task_manager.autodiscover_tasks( + namespaces=["openscan_firmware.controllers.services.tasks"], + extra_ignore_modules={"base_task", "task_manager", "example_tasks"}, + override_on_conflict=False, + ) + + yield task_manager + + active_tasks = task_manager.get_all_tasks_info() + if active_tasks: + cancellations = [ + task_manager.cancel_task(task.id) + for task in active_tasks + if task.status in {TaskStatus.RUNNING, TaskStatus.PENDING, TaskStatus.PAUSED} + ] + if cancellations: + await asyncio.gather(*cancellations, return_exceptions=True) + + TaskManager._instance = None + + +@pytest.mark.asyncio +async def test_qr_scan_task_connects_wifi_success(monkeypatch, qr_task_manager): + """Ensure the task detects WiFi QR codes and applies credentials successfully.""" + + fake_frame = np.zeros((10, 10, 3), dtype=np.uint8) + monkeypatch.setattr(qr_module, "_STARTUP_DELAY", 0) + monkeypatch.setattr(qr_module, "_SCAN_INTERVAL", 0) + monkeypatch.setattr(qr_module, "_cleanup_stale_qr_tasks", AsyncMock()) + monkeypatch.setattr(qr_module, "_capture_preview_array", AsyncMock(return_value=fake_frame)) + + class DummyController: + async def preview_async(self): # pragma: no cover + return b"ignored" + + monkeypatch.setattr( + "openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller", + lambda name: DummyController(), + ) + + class DummyConsensus: + def __init__(self, _reader, required_hits, window): + self.calls = 0 + + def feed(self, _frame): + self.calls += 1 + if self.calls >= 2: + return "WIFI:S:TestNet;T:WPA;P:secret;H:false;;" + return None + + monkeypatch.setattr("openscan_firmware.utils.qr_reader.ZxingQRReader", lambda: object()) + monkeypatch.setattr("openscan_firmware.utils.qr_reader.StableQRConsensus", DummyConsensus) + + def fake_parse_wifi_qr(_text: str) -> SimpleNamespace: + return SimpleNamespace(ssid="TestNet", security="WPA2", hidden=False) + + def fake_connect_wifi(_credentials): + return "nmcli success" + + monkeypatch.setattr("openscan_firmware.utils.wifi.parse_wifi_qr", fake_parse_wifi_qr) + monkeypatch.setattr("openscan_firmware.utils.wifi.connect_wifi", fake_connect_wifi) + + monkeypatch.setattr(qr_module, "get_task_manager", lambda: qr_task_manager) + + task = await qr_task_manager.create_and_run_task("qr_scan_task", camera_name="mock_cam") + final = await qr_task_manager.wait_for_task(task.id) + + assert final.status == TaskStatus.COMPLETED + assert final.result == { + "ssid": "TestNet", + "security": "WPA2", + "hidden": False, + "nmcli_output": "nmcli success", + } + + +@pytest.mark.asyncio +async def test_qr_scan_task_wifi_connect_failure_marks_error(monkeypatch, qr_task_manager): + """Ensure connection errors bubble up and mark the task as ERROR.""" + + fake_frame = np.zeros((10, 10, 3), dtype=np.uint8) + monkeypatch.setattr(qr_module, "_STARTUP_DELAY", 0) + monkeypatch.setattr(qr_module, "_SCAN_INTERVAL", 0) + monkeypatch.setattr(qr_module, "_cleanup_stale_qr_tasks", AsyncMock()) + monkeypatch.setattr(qr_module, "_capture_preview_array", AsyncMock(return_value=fake_frame)) + + controller = type("DummyController", (), {"preview_async": AsyncMock(return_value=b"bytes")})() + monkeypatch.setattr( + "openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller", + lambda name: controller, + ) + + class AlwaysFoundConsensus: + def __init__(self, _reader, required_hits, window): + pass + + def feed(self, _frame): + return "WIFI:S:BrokenNet;T:WPA;P:secret;H:false;;" + + monkeypatch.setattr("openscan_firmware.utils.qr_reader.ZxingQRReader", lambda: object()) + monkeypatch.setattr("openscan_firmware.utils.qr_reader.StableQRConsensus", AlwaysFoundConsensus) + + def fake_parse_wifi_qr(_text: str) -> SimpleNamespace: + return SimpleNamespace(ssid="BrokenNet", security="WPA2", hidden=False) + + def failing_connect_wifi(_credentials): + raise RuntimeError("nmcli failure") + + monkeypatch.setattr("openscan_firmware.utils.wifi.parse_wifi_qr", fake_parse_wifi_qr) + monkeypatch.setattr("openscan_firmware.utils.wifi.connect_wifi", failing_connect_wifi) + + monkeypatch.setattr(qr_module, "get_task_manager", lambda: qr_task_manager) + + task = await qr_task_manager.create_and_run_task("qr_scan_task", camera_name="mock_cam") + final = await qr_task_manager.wait_for_task(task.id) + + assert final.status == TaskStatus.ERROR + assert "Failed to apply WiFi credentials" in (final.result or {}).get("error", "") + + +@pytest.mark.asyncio +async def test_cleanup_stale_qr_tasks_removes_cancelled_and_limits_errors(monkeypatch, qr_task_manager): + """Verify cleanup removes stale statuses and keeps only the latest three errors.""" + + monkeypatch.setattr(qr_module, "get_task_manager", lambda: qr_task_manager) + + now = datetime.utcnow() + statuses = [ + (TaskStatus.CANCELLED, -10), + (TaskStatus.INTERRUPTED, -9), + (TaskStatus.ERROR, -8), + (TaskStatus.ERROR, -7), + (TaskStatus.ERROR, -6), + (TaskStatus.ERROR, -5), + ] + + for status, offset in statuses: + task = Task(name="qr_scan_task", task_type="qr_scan_task", status=status) + task.created_at = now + timedelta(seconds=offset) + qr_task_manager._tasks[task.id] = task + + await qr_module._cleanup_stale_qr_tasks() + + remaining = qr_task_manager.get_all_tasks_info() + assert all(task.status == TaskStatus.ERROR for task in remaining) + assert len(remaining) == 3 From 18d0484936ca8258db9f044c7bb39402f8858f4a Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 16 Mar 2026 15:29:10 +0100 Subject: [PATCH 07/75] bump(version): update project to v0.10.0 and adjust dependency constraints --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 634c73c..2c8a0b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openscan-firmware" -version = "0.9.0" +version = "0.10.0" description = "OpenScan3 - Raspberry Pi based photogrammetry scanner (FastAPI-based application)" readme = "README.md" requires-python = ">=3.11" @@ -42,7 +42,7 @@ dependencies = [ "anyio==4.10.0", "piexif==1.1.3", "pydantic_core==2.33.1", - "zxing-cpp==2.2.0", + "zxing-cpp>=2.2.0", ] [project.optional-dependencies] From e4c015df657fe2a4346c08a255209098e7830f78 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 16 Mar 2026 21:29:57 +0100 Subject: [PATCH 08/75] feat(wifi): enhance connection retries and add cooldown/backoff logic - Added `max_attempts` and `rescan_delay` to fine-tune WiFi connection behavior. - Implemented retry logic with scan rescans for better network recovery. - Introduced cooldown and backoff delays for repeated connection attempts in QR scan tasks. --- .../services/tasks/core/qr_scan_task.py | 40 +++++++++- openscan_firmware/utils/wifi.py | 75 ++++++++++++++++--- 2 files changed, 101 insertions(+), 14 deletions(-) diff --git a/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py index f2c8b39..d396d39 100644 --- a/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py +++ b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py @@ -36,6 +36,10 @@ _NO_HIT_INFO_INTERVAL = 30 # Emit TaskProgress updates at most every N seconds while idle scanning _PROGRESS_UPDATE_INTERVAL = 30.0 +# Cooldown between connection attempts for the same SSID (seconds) +_CONNECT_RETRY_COOLDOWN = 6.0 +# Sleep after a failed connection attempt before resuming scanning +_CONNECT_ERROR_BACKOFF = 2.0 class QrScanTask(BaseTask): @@ -95,6 +99,8 @@ async def run( attempt = 0 last_progress_emit = 0.0 + last_credentials = None + last_connect_attempt = 0.0 while True: attempt += 1 @@ -124,8 +130,28 @@ async def run( if decoded_text and decoded_text.startswith("WIFI:"): try: credentials = parse_wifi_qr(decoded_text) - logger.info("WiFi QR code detected for SSID '%s'", credentials.ssid) - yield TaskProgress(current=attempt, total=0, message="WiFi QR code detected! Connecting...") + except ValueError as exc: + logger.warning("Ignoring invalid WiFi QR code: %s", exc) + continue + + now = time.monotonic() + same_credentials = last_credentials is not None and credentials == last_credentials + within_cooldown = same_credentials and (now - last_connect_attempt) < _CONNECT_RETRY_COOLDOWN + + if within_cooldown: + logger.debug( + "Skipping connection attempt for SSID '%s' due to cooldown.", + credentials.ssid, + ) + continue + + last_credentials = credentials + last_connect_attempt = now + + logger.info("WiFi QR code detected for SSID '%s'", credentials.ssid) + yield TaskProgress(current=attempt, total=0, message="WiFi QR code detected! Connecting...") + + try: output = await asyncio.to_thread(connect_wifi, credentials) result_msg = f"Connected to '{credentials.ssid}'" logger.info(result_msg) @@ -140,11 +166,19 @@ async def run( yield TaskProgress(current=1, total=1, message=result_msg) return + except asyncio.CancelledError: + raise except Exception as exc: error_msg = f"Failed to apply WiFi credentials: {exc}" logger.error(error_msg) self._task_model.result = {"error": error_msg} - raise RuntimeError(error_msg) from exc + yield TaskProgress( + current=attempt, + total=0, + message=f"{error_msg} – retrying shortly", + ) + await asyncio.sleep(_CONNECT_ERROR_BACKOFF) + continue elif decoded_text: logger.debug("Non-WiFi QR code found: %s", decoded_text[:50]) diff --git a/openscan_firmware/utils/wifi.py b/openscan_firmware/utils/wifi.py index 8558a5f..93e4979 100644 --- a/openscan_firmware/utils/wifi.py +++ b/openscan_firmware/utils/wifi.py @@ -9,6 +9,7 @@ import logging import re import subprocess +import time from dataclasses import dataclass logger = logging.getLogger(__name__) @@ -78,7 +79,12 @@ def parse_wifi_qr(raw: str) -> WifiCredentials: ) -def connect_wifi(credentials: WifiCredentials) -> str: +def connect_wifi( + credentials: WifiCredentials, + *, + max_attempts: int = 2, + rescan_delay: float = 1.0, +) -> str: """Connect to a WiFi network using NetworkManager (nmcli). This requires the process to have sufficient privileges (typically root) @@ -86,6 +92,10 @@ def connect_wifi(credentials: WifiCredentials) -> str: Args: credentials: The WiFi credentials to use. + max_attempts: How many times to attempt the connection before giving up. + On retries the helper performs an ``nmcli device wifi rescan`` first. + rescan_delay: Seconds to wait after triggering the rescan to give the + kernel time to refresh the scan list. Ignored if non-positive. Returns: The stdout output from nmcli on success. @@ -93,6 +103,7 @@ def connect_wifi(credentials: WifiCredentials) -> str: Raises: RuntimeError: If nmcli is not available or the connection attempt fails. """ + attempts = max(1, max_attempts) cmd = [ "nmcli", "device", "wifi", "connect", credentials.ssid, "password", credentials.password, @@ -102,22 +113,50 @@ def connect_wifi(credentials: WifiCredentials) -> str: cmd.extend(["hidden", "yes"]) logger.info("Attempting to connect to WiFi network '%s'", credentials.ssid) - logger.debug("Running: %s", " ".join(cmd[:5]) + " ****") # mask password - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30, - ) + for attempt in range(1, attempts + 1): + logger.debug( + "Running (attempt %d/%d): %s", + attempt, + attempts, + " ".join(cmd[:5]) + " ****", + ) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + logger.info("Successfully connected to WiFi network '%s'", credentials.ssid) + return result.stdout.strip() - if result.returncode != 0: error_msg = result.stderr.strip() or result.stdout.strip() logger.error("nmcli failed (rc=%d): %s", result.returncode, error_msg) + + should_retry = ( + attempt < attempts and + "not in the scan list" in error_msg.lower() + ) + + if should_retry: + logger.warning( + "Access point not in scan list; triggering nmcli rescan before retry %d/%d.", + attempt + 1, + attempts, + ) + _rescan_wifi_devices() + if rescan_delay > 0: + time.sleep(rescan_delay) + continue + raise RuntimeError(f"Failed to connect to '{credentials.ssid}': {error_msg}") - logger.info("Successfully connected to WiFi network '%s'", credentials.ssid) - return result.stdout.strip() + raise RuntimeError( + f"Failed to connect to '{credentials.ssid}' after {attempts} attempts: {error_msg}" + ) def is_wifi_connected() -> bool: @@ -140,3 +179,17 @@ def is_wifi_connected() -> bool: except (subprocess.TimeoutExpired, FileNotFoundError) as exc: logger.warning("Could not check WiFi status: %s", exc) return False + + +def _rescan_wifi_devices() -> None: + """Trigger an nmcli rescan, ignoring errors but logging them.""" + try: + subprocess.run( + ["nmcli", "device", "wifi", "rescan"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + except subprocess.CalledProcessError as exc: + logger.warning("nmcli rescan failed: %s", exc) From 6d3ab7b5c5f3c43a6a0d60c975c76d54e365eb9d Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 17 Mar 2026 13:36:37 +0100 Subject: [PATCH 09/75] feat(assets): add openscan mini icon to project assets --- dist/assets/openscan_mini_icon.png | Bin 0 -> 1646 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 dist/assets/openscan_mini_icon.png diff --git a/dist/assets/openscan_mini_icon.png b/dist/assets/openscan_mini_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..70332e96bcdddb4b1d05551dfc6b5d9b555c2f39 GIT binary patch literal 1646 zcmV-!29f!RP)MscM zbHbnR-4hvJ`)Uk;5Bjt90e*aP8UZM+u>?H*%T_doAN9bAS^w(+_2>Kdku;Vt*DWzC z^j>He9~>zJQnO}RoK@{HfDn1BfjgJ;_;#!qfUC9P{bZcUx_H_|)T^Rnp$HCUOTpy7 z?M?uOC$-Y+D$U8M65GC?VtL9&06k4M$!PsLP|IW7%7OOc}V|3G&W&^!* zJVU0W*l{$?p1~Y~`np+9R;yMLf6&qf6X3b{=X2H5karc2-_%Td!tm(kGBp;eN}&{| zqLPV%7XX;cG%%8N=}MX@8f?jd(E_^%Jlf+HJxw-BFC1Vff?bU>n5T*cXl29WU%kF} zj7_@+*l{$C(s1}(fsTdxi?eEyn(l zJS$tHbf;``r2>wpNjr*0Th`pE&MaxERz^brH>^mqByKTL@Yp+)XQ<4xN}Zy!tZzaKLE^Sdb$w&dPxlA-9&b`T&_QUUls z-m$DcKn*;%b_uo=NC8_9jWb^GkV1qSRs$SXG~0an*ww7?+LW&sKRa7)R`mrs2`251r)iB_ z{NbubT%~z(bu+pQq!OIpvZWox3%f^=!p!md@0V8X*h2vQuKCs9Ee-1Mw(qCddT;_; zilB*|J((=aN#0ceWF3>XN`d#vyc0m*heMo7mrxpB{!Ke;md2|pMhKt}AQ2I4-`vfT zxTy>;em2TqznNsb;NdDAIO6GZ3znjJoCv2xBR`I4Lb+eKb*%Brr6uk2WG}=ZBlDXK}4E1 zJ|YFjCmc5J8er!!BV!v@H1dy4zX%kf+I9-bjBmcTzwSz~V{>5C6%~;-J>`ab3 zwx8m@>r!m#ZDL(#g6>o#SRi3bl!hNB9X{&MvOHzeKk4wszVl=pLxaB6Exfh9Bj}Rm zl1fqQ;O>=M{FnEf<;?@*M5Mq|8X+#^U@9uPwk66~-eWLR;?`wxvL!|Tq;o+xBY@tv z7%#1Bta zj2Aq#hPFhwi=J(DBWMZO^4=N#dm=+bir|QN!6X>|>GFn=0T*W^$(Dj-R8n*`9gP-w zS5t5`58sgDt@WLAMzU@M{e@~iuXty+4_0}@-PzN?w$0sqbtcb!{}~9L?;Xw?C+A4> zyZ|o5e6ah53vAqZnv7#Qc1xISG}yI|1(zM5AI!<3$Nmv7xG&Jbk)f7dc7VS5JcWz< z2(@ScD|tF!TeR#-`u|crnqu>;Qe Date: Tue, 17 Mar 2026 16:55:59 +0100 Subject: [PATCH 10/75] fix(device): replace deprecated `sudo` commands with `systemctl` for reboot and shutdown --- openscan_firmware/controllers/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index fd0a9dc..ca89f80 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -635,14 +635,14 @@ def reboot(with_saving = False): if with_saving: save_device_config() cleanup_and_exit() - os.system("sudo reboot") + os.system("systemctl reboot") def shutdown(with_saving = False): if with_saving: save_device_config() cleanup_and_exit() - os.system("sudo shutdown now") + os.system("systemctl poweroff") def cleanup_and_exit(): From 75ac098761a459ff86abddbea9bd03a5bb2bced5 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 17 Mar 2026 17:06:19 +0100 Subject: [PATCH 11/75] feat(images): add firmware metadata for OpenScan3 releases - Introduced `repo.json` containing device filtering, firmware metadata, and download links. - Added support for Raspberry Pi models 3, 4, 400, and 5. - Included detailed descriptions, capabilities, and SHA256 checksums for image files. --- dist/repo.json | 184 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 dist/repo.json diff --git a/dist/repo.json b/dist/repo.json new file mode 100644 index 0000000..ce44a25 --- /dev/null +++ b/dist/repo.json @@ -0,0 +1,184 @@ +{ + "imager": { + "latest_version": "2.0.6", + "url": "https://www.raspberrypi.com/software/", + "devices": [ + { + "name": "No filtering", + "tags": [ + "all" + ], + "default": true, + "matching_type": "inclusive", + "description": "Show every OpenScan3 image." + }, + { + "name": "Raspberry Pi 5", + "tags": [ + "pi5" + ], + "matching_type": "inclusive", + "capabilities": [ + "usb_otg" + ], + "description": "Raspberry Pi 5 / 5B" + }, + { + "name": "Raspberry Pi 4 / 400", + "tags": [ + "pi4", + "pi400" + ], + "matching_type": "inclusive", + "capabilities": [ + "usb_otg" + ], + "description": "Raspberry Pi 4 Model B and Raspberry Pi 400" + }, + { + "name": "Raspberry Pi 3", + "tags": [ + "pi3" + ], + "matching_type": "inclusive", + "description": "Raspberry Pi 3 Model B / B+." + } + ] + }, + "os_list": [ + { + "name": "OpenScan3", + "subitems": [ + { + "name": "OpenScan3 (Arducam IMX519 16MP)", + "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519.zip", + "release_date": "2026-03-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1517339746, + "image_download_sha256": "1c92b5bb11f0b430445e2ed4c56aae5f58edee7cc6ce501f26e2a3e7f9bf707e", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP)", + "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental.zip", + "release_date": "2026-03-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1517428227, + "image_download_sha256": "5acd2cd9e35a213a5a03f4f1bdd7aca1107d9a0c80c872f1827369ef1e626bd7", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", + "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic.zip", + "release_date": "2026-03-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1495081943, + "image_download_sha256": "0e93b8db9c0c8de654d816a89f0e547f61d7cedd7204d0483f79a74d08452032", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", + "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519_DEVELOP.zip", + "release_date": "2026-03-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1517387682, + "image_download_sha256": "9bd3f00f235e84fc8cd2a9052da573cae933135b6c19951c7746af2c744811b4", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", + "description": "Developer image for the Arducam Hawkeye, please read docs before use!", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", + "release_date": "2026-03-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1517531113, + "image_download_sha256": "0f3f47ca3609a5f66842ea24a6fca1176e2dc4567e2bf359cda166f84cb2bfdc", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Generic camera) (Develop)", + "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic_DEVELOP.zip", + "release_date": "2026-03-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1495109590, + "image_download_sha256": "50f1dead9b178ae0ca7430b08deef98615c5f0fced6ba76e70a818da2fb2c813", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + } + ], + "description": "Firmware images for open 3d-scanner." + } + ] +} From 3bc3017b8ce2010b1ab4c5128a589481cc82379e Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 17 Mar 2026 17:49:37 +0100 Subject: [PATCH 12/75] feat(motor): add `calibrated` property and force recalibration support - Introduced a `calibrated` property in motor status responses. - Added `force` parameter to allow recalibration even when the motor is already considered calibrated. - Updated OpenAPI schemas (`v0.6`, `v0.7`, `v0.8`, and `next`) to reflect these changes. --- .../controllers/hardware/motors.py | 10 ++- openscan_firmware/routers/next/motors.py | 11 +++- openscan_firmware/routers/v0_6/motors.py | 1 + openscan_firmware/routers/v0_7/motors.py | 1 + openscan_firmware/routers/v0_8/motors.py | 11 +++- scripts/openapi/openapi_latest.json | 17 +++++ scripts/openapi/openapi_next.json | 65 +++++++++++++++++++ scripts/openapi/openapi_v0.6.json | 5 ++ scripts/openapi/openapi_v0.7.json | 5 ++ scripts/openapi/openapi_v0.8.json | 17 +++++ 10 files changed, 136 insertions(+), 7 deletions(-) diff --git a/openscan_firmware/controllers/hardware/motors.py b/openscan_firmware/controllers/hardware/motors.py index 742aa52..1ab79a2 100644 --- a/openscan_firmware/controllers/hardware/motors.py +++ b/openscan_firmware/controllers/hardware/motors.py @@ -95,6 +95,7 @@ def get_status(self) -> dict: "busy": self.is_busy(), "target_angle": self._target_angle, "settings": self.get_config(), + "calibrated": self.is_calibrated(), "endstop": None } if self.endstop is not None: @@ -326,15 +327,18 @@ async def move_to_home(self) -> None: await self.move_to(self.settings.home_angle) - async def calibrate(self) -> None: + async def calibrate(self, *, force: bool = False) -> None: """Internal method to move motor to home angle after calibrating to endstop. Args: - none """ - if self._calibrated: + force: Force calibration even if controller believes it is already calibrated. + """ + if self._calibrated and not force: # just in case, even if it doesn't move because already calibrated... inactivity_timer.reset() return + # mark as not calibrated until the routine finishes successfully + self._calibrated = False await self.move_to_endstop() await asyncio.sleep(3) await self.move_to(self.settings.home_angle) diff --git a/openscan_firmware/routers/next/motors.py b/openscan_firmware/routers/next/motors.py index d5fa4ab..0001807 100644 --- a/openscan_firmware/routers/next/motors.py +++ b/openscan_firmware/routers/next/motors.py @@ -30,6 +30,7 @@ class MotorStatusResponse(BaseModel): busy: bool target_angle: Optional[float] settings: MotorConfig + calibrated: bool endstop: Optional[dict] @@ -134,7 +135,13 @@ async def override_motor_angle( @router.put("/{motor_name}/endstop-calibration", response_model=MotorStatusResponse) -async def motor_endstop_calibration(motor_name: str): +async def motor_endstop_calibration( + motor_name: str, + force: bool = Query( + False, + description="Force recalibration even if the controller already considers the motor calibrated.", + ), +): """Move motor to home through endstop sensing This endpoint moves the motor to the home position using the endstop calibration. @@ -147,7 +154,7 @@ async def motor_endstop_calibration(motor_name: str): """ controller = _get_motor_controller_or_404(motor_name) if controller.endstop and not controller.is_busy(): - await controller.calibrate() + await controller.calibrate(force=force) return controller.get_status() else: raise HTTPException(status_code=422, detail="No endstop configured or motor is busy!") diff --git a/openscan_firmware/routers/v0_6/motors.py b/openscan_firmware/routers/v0_6/motors.py index f6e4590..66e03b2 100644 --- a/openscan_firmware/routers/v0_6/motors.py +++ b/openscan_firmware/routers/v0_6/motors.py @@ -21,6 +21,7 @@ class MotorStatusResponse(BaseModel): busy: bool target_angle: Optional[float] settings: MotorConfig + calibrated: bool endstop: Optional[dict] diff --git a/openscan_firmware/routers/v0_7/motors.py b/openscan_firmware/routers/v0_7/motors.py index f6e4590..66e03b2 100644 --- a/openscan_firmware/routers/v0_7/motors.py +++ b/openscan_firmware/routers/v0_7/motors.py @@ -21,6 +21,7 @@ class MotorStatusResponse(BaseModel): busy: bool target_angle: Optional[float] settings: MotorConfig + calibrated: bool endstop: Optional[dict] diff --git a/openscan_firmware/routers/v0_8/motors.py b/openscan_firmware/routers/v0_8/motors.py index d5fa4ab..0001807 100644 --- a/openscan_firmware/routers/v0_8/motors.py +++ b/openscan_firmware/routers/v0_8/motors.py @@ -30,6 +30,7 @@ class MotorStatusResponse(BaseModel): busy: bool target_angle: Optional[float] settings: MotorConfig + calibrated: bool endstop: Optional[dict] @@ -134,7 +135,13 @@ async def override_motor_angle( @router.put("/{motor_name}/endstop-calibration", response_model=MotorStatusResponse) -async def motor_endstop_calibration(motor_name: str): +async def motor_endstop_calibration( + motor_name: str, + force: bool = Query( + False, + description="Force recalibration even if the controller already considers the motor calibrated.", + ), +): """Move motor to home through endstop sensing This endpoint moves the motor to the home position using the endstop calibration. @@ -147,7 +154,7 @@ async def motor_endstop_calibration(motor_name: str): """ controller = _get_motor_controller_or_404(motor_name) if controller.endstop and not controller.is_busy(): - await controller.calibrate() + await controller.calibrate(force=force) return controller.get_status() else: raise HTTPException(status_code=422, detail="No endstop configured or motor is busy!") diff --git a/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index ef6d856..7857e2f 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -716,6 +716,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": { @@ -5147,6 +5159,10 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { @@ -5167,6 +5183,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index 349ece7..d0b0369 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -716,6 +716,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": { @@ -3475,6 +3487,54 @@ } } }, + "/develop/qr-scan": { + "post": { + "tags": [ + "develop" + ], + "summary": "Start Qr Scan", + "description": "Start a background task that scans for WiFi QR codes via the camera.\n\nThe task runs indefinitely, capturing frames and looking for QR codes.\nWhen it finds an Android/iOS WiFi share QR code it connects to the\nnetwork via nmcli and completes. Cancel the task to stop scanning.\n\nArgs:\n camera_name: Name of the camera controller to use for captures.\n\nReturns:\n Task: The created task model (poll via /tasks/{id} for progress).", + "operationId": "start_qr_scan", + "parameters": [ + { + "name": "camera_name", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Name of the camera controller to use", + "title": "Camera Name" + }, + "description": "Name of the camera controller to use" + } + ], + "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" + } + } + } + } + } + } + }, "/develop/{method}": { "get": { "tags": [ @@ -5147,6 +5207,10 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { @@ -5167,6 +5231,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" diff --git a/scripts/openapi/openapi_v0.6.json b/scripts/openapi/openapi_v0.6.json index 8cf2abe..ee57459 100644 --- a/scripts/openapi/openapi_v0.6.json +++ b/scripts/openapi/openapi_v0.6.json @@ -4597,6 +4597,10 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { @@ -4617,6 +4621,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" diff --git a/scripts/openapi/openapi_v0.7.json b/scripts/openapi/openapi_v0.7.json index 3c115aa..2e2b909 100644 --- a/scripts/openapi/openapi_v0.7.json +++ b/scripts/openapi/openapi_v0.7.json @@ -4837,6 +4837,10 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { @@ -4857,6 +4861,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index ef6d856..7857e2f 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -716,6 +716,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": { @@ -5147,6 +5159,10 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { @@ -5167,6 +5183,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" From 43b69917e8ed4d7d69865f41161b10a0114416cc Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 18 Mar 2026 14:27:27 +0100 Subject: [PATCH 13/75] feat(wifi): ensure WiFi radio is enabled before connection attempts - Added `ensure_wifi_radio_enabled()` to confirm the WiFi radio is on. - Introduced helper methods `is_wifi_radio_enabled()` and `_run_nmcli_command()` for consistent `nmcli` handling. - Improved error handling and logging for WiFi operations. --- openscan_firmware/utils/wifi.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/openscan_firmware/utils/wifi.py b/openscan_firmware/utils/wifi.py index 93e4979..52efe1b 100644 --- a/openscan_firmware/utils/wifi.py +++ b/openscan_firmware/utils/wifi.py @@ -103,6 +103,8 @@ def connect_wifi( Raises: RuntimeError: If nmcli is not available or the connection attempt fails. """ + ensure_wifi_radio_enabled() + attempts = max(1, max_attempts) cmd = [ "nmcli", "device", "wifi", "connect", credentials.ssid, @@ -193,3 +195,37 @@ def _rescan_wifi_devices() -> None: ) except subprocess.CalledProcessError as exc: logger.warning("nmcli rescan failed: %s", exc) + + +def _run_nmcli_command(command: list[str], timeout: float) -> subprocess.CompletedProcess[str]: + """Run an nmcli command and normalize common subprocess errors.""" + try: + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=True, + ) + except FileNotFoundError as exc: + raise RuntimeError("nmcli is not available on this system") from exc + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.strip() if exc.stderr else str(exc) + raise RuntimeError(f"Failed to run '{' '.join(command)}': {stderr}") from exc + + +def is_wifi_radio_enabled(timeout: float = 5.0) -> bool: + """Return True if NetworkManager reports the WiFi radio as enabled.""" + result = _run_nmcli_command(["nmcli", "radio", "wifi"], timeout=timeout) + state = result.stdout.strip().lower() + return state == "enabled" + + +def ensure_wifi_radio_enabled(timeout: float = 5.0) -> None: + """Enable the WiFi radio if it is currently disabled.""" + if is_wifi_radio_enabled(timeout=timeout): + logger.debug("WiFi radio already enabled – skipping toggle.") + return + + logger.info("WiFi radio disabled – enabling via nmcli.") + _run_nmcli_command(["nmcli", "radio", "wifi", "on"], timeout=timeout) From dac1af09d4349de9a72b3ce10c8139dabf096ed5 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 18 Mar 2026 14:27:47 +0100 Subject: [PATCH 14/75] feat(images): update OpenScan3 firmware metadata and add imager manifest file - Updated firmware metadata in `repo.json`, including release dates, file sizes, and SHA256 checksums. - Added `local_json.rpi-imager-manifest` file for Raspberry Pi Imager compatibility, supporting device filtering and capability tags. --- dist/local_json.rpi-imager-manifest | 184 ++++++++++++++++++++++++++++ dist/repo.json | 38 +++--- 2 files changed, 203 insertions(+), 19 deletions(-) create mode 100644 dist/local_json.rpi-imager-manifest diff --git a/dist/local_json.rpi-imager-manifest b/dist/local_json.rpi-imager-manifest new file mode 100644 index 0000000..7f69c98 --- /dev/null +++ b/dist/local_json.rpi-imager-manifest @@ -0,0 +1,184 @@ +{ + "imager": { + "latest_version": "2.0.6", + "url": "https://www.raspberrypi.com/software/", + "devices": [ + { + "name": "No filtering", + "tags": [ + "all" + ], + "default": true, + "matching_type": "inclusive", + "description": "Show every OpenScan3 image." + }, + { + "name": "Raspberry Pi 5", + "tags": [ + "pi5" + ], + "matching_type": "inclusive", + "capabilities": [ + "usb_otg" + ], + "description": "Raspberry Pi 5 / 5B" + }, + { + "name": "Raspberry Pi 4 / 400", + "tags": [ + "pi4", + "pi400" + ], + "matching_type": "inclusive", + "capabilities": [ + "usb_otg" + ], + "description": "Raspberry Pi 4 Model B and Raspberry Pi 400" + }, + { + "name": "Raspberry Pi 3", + "tags": [ + "pi3" + ], + "matching_type": "inclusive", + "description": "Raspberry Pi 3 Model B / B+." + } + ] + }, + "os_list": [ + { + "name": "OpenScan3", + "subitems": [ + { + "name": "OpenScan3 (Arducam IMX519 16MP)", + "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", + "url": "file:///replace/me/OpenScan3_v0.10.0_imx519.zip", + "release_date": "2026-03-17", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1518124739, + "image_download_sha256": "b7fe53e6df4ad206cb0d30068004106e296d60c5de41f3f74bc1e07f3d1eebcc", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP)", + "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", + "url": "file:///replace/me/OpenScan3_v0.10.0_hawkeye-experimental.zip", + "release_date": "2026-03-17", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1517971965, + "image_download_sha256": "f2d75d706e75c8979c144cb8e4dcad6efddfaeca8a82443ab66ced4c6da6d56b", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", + "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", + "url": "file:///replace/me/OpenScan3_v0.10.0_generic.zip", + "release_date": "2026-03-17", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1495686138, + "image_download_sha256": "e1ed4a13d1ac02a105e59a821404310c534364810f7164b2d33442fd8a487d0c", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", + "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", + "url": "file:///replace/me/OpenScan3_v0.10.0_imx519_DEVELOP.zip", + "release_date": "2026-03-17", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1517950770, + "image_download_sha256": "a4193d79e1c31bf7df10a35b37792dd5bf1fffc665a84ec63fd21aea48fe5498", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", + "description": "Developer image for the Arducam Hawkeye, please read docs before use!", + "url": "file:///replace/me/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", + "release_date": "2026-03-17", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1518095027, + "image_download_sha256": "244963a9ffca3fd7cbe4c62e7216aefbfa659026fb89f4cb5d06c79babdec01a", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + }, + { + "name": "OpenScan3 (Generic camera) (Develop)", + "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", + "url": "file:///replace/me/OpenScan3_v0.10.0_generic_DEVELOP.zip", + "release_date": "2026-03-17", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "image_download_size": 1495816321, + "image_download_sha256": "fbf667c1debbee06f058a6b8af9e5eb00c673e05200a7a27aa206df78b6e8bc5", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu" + } + ], + "description": "Firmware images for open 3d scanners." + } + ] +} diff --git a/dist/repo.json b/dist/repo.json index ce44a25..35c5bc9 100644 --- a/dist/repo.json +++ b/dist/repo.json @@ -53,7 +53,7 @@ "name": "OpenScan3 (Arducam IMX519 16MP)", "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519.zip", - "release_date": "2026-03-16", + "release_date": "2026-03-17", "devices": [ "pi5", "pi4", @@ -65,8 +65,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1517339746, - "image_download_sha256": "1c92b5bb11f0b430445e2ed4c56aae5f58edee7cc6ce501f26e2a3e7f9bf707e", + "image_download_size": 1518124739, + "image_download_sha256": "b7fe53e6df4ad206cb0d30068004106e296d60c5de41f3f74bc1e07f3d1eebcc", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -74,7 +74,7 @@ "name": "OpenScan3 (Arducam Hawkeye 64MP)", "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental.zip", - "release_date": "2026-03-16", + "release_date": "2026-03-17", "devices": [ "pi5", "pi4", @@ -86,8 +86,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1517428227, - "image_download_sha256": "5acd2cd9e35a213a5a03f4f1bdd7aca1107d9a0c80c872f1827369ef1e626bd7", + "image_download_size": 1517971965, + "image_download_sha256": "f2d75d706e75c8979c144cb8e4dcad6efddfaeca8a82443ab66ced4c6da6d56b", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -95,7 +95,7 @@ "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic.zip", - "release_date": "2026-03-16", + "release_date": "2026-03-17", "devices": [ "pi5", "pi4", @@ -108,8 +108,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495081943, - "image_download_sha256": "0e93b8db9c0c8de654d816a89f0e547f61d7cedd7204d0483f79a74d08452032", + "image_download_size": 1495686138, + "image_download_sha256": "e1ed4a13d1ac02a105e59a821404310c534364810f7164b2d33442fd8a487d0c", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -117,7 +117,7 @@ "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519_DEVELOP.zip", - "release_date": "2026-03-16", + "release_date": "2026-03-17", "devices": [ "pi5", "pi4", @@ -129,8 +129,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1517387682, - "image_download_sha256": "9bd3f00f235e84fc8cd2a9052da573cae933135b6c19951c7746af2c744811b4", + "image_download_size": 1517950770, + "image_download_sha256": "a4193d79e1c31bf7df10a35b37792dd5bf1fffc665a84ec63fd21aea48fe5498", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -138,7 +138,7 @@ "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", "description": "Developer image for the Arducam Hawkeye, please read docs before use!", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", - "release_date": "2026-03-16", + "release_date": "2026-03-17", "devices": [ "pi5", "pi4", @@ -150,8 +150,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1517531113, - "image_download_sha256": "0f3f47ca3609a5f66842ea24a6fca1176e2dc4567e2bf359cda166f84cb2bfdc", + "image_download_size": 1518095027, + "image_download_sha256": "244963a9ffca3fd7cbe4c62e7216aefbfa659026fb89f4cb5d06c79babdec01a", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -159,7 +159,7 @@ "name": "OpenScan3 (Generic camera) (Develop)", "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic_DEVELOP.zip", - "release_date": "2026-03-16", + "release_date": "2026-03-17", "devices": [ "pi5", "pi4", @@ -172,13 +172,13 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495109590, - "image_download_sha256": "50f1dead9b178ae0ca7430b08deef98615c5f0fced6ba76e70a818da2fb2c813", + "image_download_size": 1495816321, + "image_download_sha256": "fbf667c1debbee06f058a6b8af9e5eb00c673e05200a7a27aa206df78b6e8bc5", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" } ], - "description": "Firmware images for open 3d-scanner." + "description": "Firmware images for open 3d scanners." } ] } From fec63bf9f245170ecece57c6cbcd6a506ef6453f Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 19 Mar 2026 11:42:25 +0100 Subject: [PATCH 15/75] chore(images): update firmware metadata and adjust release dates in manifests - Updated `repo.json` and `local_json.rpi-imager-manifest` with new release dates, file sizes, and SHA256 checksums. --- dist/local_json.rpi-imager-manifest | 48 ++++++++++++++--------------- dist/repo.json | 36 +++++++++++----------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/dist/local_json.rpi-imager-manifest b/dist/local_json.rpi-imager-manifest index 7f69c98..5b2a8f8 100644 --- a/dist/local_json.rpi-imager-manifest +++ b/dist/local_json.rpi-imager-manifest @@ -52,8 +52,8 @@ { "name": "OpenScan3 (Arducam IMX519 16MP)", "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", - "url": "file:///replace/me/OpenScan3_v0.10.0_imx519.zip", - "release_date": "2026-03-17", + "url": "file:///REPLACEME/OpenScan3_v0.10.0_imx519.zip", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -65,16 +65,16 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518124739, - "image_download_sha256": "b7fe53e6df4ad206cb0d30068004106e296d60c5de41f3f74bc1e07f3d1eebcc", + "image_download_size": 1518263201, + "image_download_sha256": "cd2431458e8f0113dbfabaf0fe13f5a027891ab3e07c45a5560f7df025b9ccd3", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP)", "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", - "url": "file:///replace/me/OpenScan3_v0.10.0_hawkeye-experimental.zip", - "release_date": "2026-03-17", + "url": "file:///REPLACEME/OpenScan3_v0.10.0_hawkeye-experimental.zip", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -86,16 +86,16 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1517971965, - "image_download_sha256": "f2d75d706e75c8979c144cb8e4dcad6efddfaeca8a82443ab66ced4c6da6d56b", + "image_download_size": 1518239856, + "image_download_sha256": "0bcae0ae69d191195f9897f568acfe5c0f6b8d10c938133938044b60b9e11f1b", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, { "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", - "url": "file:///replace/me/OpenScan3_v0.10.0_generic.zip", - "release_date": "2026-03-17", + "url": "file:///REPLACEME/OpenScan3_v0.10.0_generic.zip", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -108,16 +108,16 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495686138, - "image_download_sha256": "e1ed4a13d1ac02a105e59a821404310c534364810f7164b2d33442fd8a487d0c", + "image_download_size": 1495921939, + "image_download_sha256": "ebfff63b6bfb600b58a6bba747cbe7198796e776226fc4583c1a1a9e433258c5", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, { "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", - "url": "file:///replace/me/OpenScan3_v0.10.0_imx519_DEVELOP.zip", - "release_date": "2026-03-17", + "url": "file:///REPLACEME/OpenScan3_v0.10.0_imx519_DEVELOP.zip", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -129,16 +129,16 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1517950770, - "image_download_sha256": "a4193d79e1c31bf7df10a35b37792dd5bf1fffc665a84ec63fd21aea48fe5498", + "image_download_size": 1518249970, + "image_download_sha256": "5513828b5c7b457c963908fa6e6824a92e9e1e127a60952b2cf6426482776966", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", "description": "Developer image for the Arducam Hawkeye, please read docs before use!", - "url": "file:///replace/me/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", - "release_date": "2026-03-17", + "url": "file:///REPLACEME/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -150,16 +150,16 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518095027, - "image_download_sha256": "244963a9ffca3fd7cbe4c62e7216aefbfa659026fb89f4cb5d06c79babdec01a", + "image_download_size": 1518246422, + "image_download_sha256": "49f80c13f873fc4a6096439c752b41bd5a26f6bcf707cef7c1952e8a7eb542c3", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, { "name": "OpenScan3 (Generic camera) (Develop)", "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", - "url": "file:///replace/me/OpenScan3_v0.10.0_generic_DEVELOP.zip", - "release_date": "2026-03-17", + "url": "file:///REPLACEME/OpenScan3_v0.10.0_generic_DEVELOP.zip", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -172,8 +172,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495816321, - "image_download_sha256": "fbf667c1debbee06f058a6b8af9e5eb00c673e05200a7a27aa206df78b6e8bc5", + "image_download_size": 1495928041, + "image_download_sha256": "7c27dc67b87147c4ff521474a584c6316182beb0c4eaab319b020a19913eda7a", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" } diff --git a/dist/repo.json b/dist/repo.json index 35c5bc9..2c43f3d 100644 --- a/dist/repo.json +++ b/dist/repo.json @@ -53,7 +53,7 @@ "name": "OpenScan3 (Arducam IMX519 16MP)", "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519.zip", - "release_date": "2026-03-17", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -65,8 +65,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518124739, - "image_download_sha256": "b7fe53e6df4ad206cb0d30068004106e296d60c5de41f3f74bc1e07f3d1eebcc", + "image_download_size": 1518263201, + "image_download_sha256": "cd2431458e8f0113dbfabaf0fe13f5a027891ab3e07c45a5560f7df025b9ccd3", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -74,7 +74,7 @@ "name": "OpenScan3 (Arducam Hawkeye 64MP)", "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental.zip", - "release_date": "2026-03-17", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -86,8 +86,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1517971965, - "image_download_sha256": "f2d75d706e75c8979c144cb8e4dcad6efddfaeca8a82443ab66ced4c6da6d56b", + "image_download_size": 1518239856, + "image_download_sha256": "0bcae0ae69d191195f9897f568acfe5c0f6b8d10c938133938044b60b9e11f1b", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -95,7 +95,7 @@ "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic.zip", - "release_date": "2026-03-17", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -108,8 +108,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495686138, - "image_download_sha256": "e1ed4a13d1ac02a105e59a821404310c534364810f7164b2d33442fd8a487d0c", + "image_download_size": 1495921939, + "image_download_sha256": "ebfff63b6bfb600b58a6bba747cbe7198796e776226fc4583c1a1a9e433258c5", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -117,7 +117,7 @@ "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519_DEVELOP.zip", - "release_date": "2026-03-17", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -129,8 +129,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1517950770, - "image_download_sha256": "a4193d79e1c31bf7df10a35b37792dd5bf1fffc665a84ec63fd21aea48fe5498", + "image_download_size": 1518249970, + "image_download_sha256": "5513828b5c7b457c963908fa6e6824a92e9e1e127a60952b2cf6426482776966", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -138,7 +138,7 @@ "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", "description": "Developer image for the Arducam Hawkeye, please read docs before use!", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", - "release_date": "2026-03-17", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -150,8 +150,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518095027, - "image_download_sha256": "244963a9ffca3fd7cbe4c62e7216aefbfa659026fb89f4cb5d06c79babdec01a", + "image_download_size": 1518246422, + "image_download_sha256": "49f80c13f873fc4a6096439c752b41bd5a26f6bcf707cef7c1952e8a7eb542c3", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" }, @@ -159,7 +159,7 @@ "name": "OpenScan3 (Generic camera) (Develop)", "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic_DEVELOP.zip", - "release_date": "2026-03-17", + "release_date": "2026-03-18", "devices": [ "pi5", "pi4", @@ -172,8 +172,8 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495816321, - "image_download_sha256": "fbf667c1debbee06f058a6b8af9e5eb00c673e05200a7a27aa206df78b6e8bc5", + "image_download_size": 1495928041, + "image_download_sha256": "7c27dc67b87147c4ff521474a584c6316182beb0c4eaab319b020a19913eda7a", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu" } From 23e213e1c54b237a8cf1c0f565873f84267a2efe Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 19 Mar 2026 12:44:25 +0100 Subject: [PATCH 16/75] chore(images): update firmware metadata with extract info and adjust file paths in manifests - Added `extract_size` and `extract_sha256` metadata to `repo.json` and `local_json.rpi-imager-manifest`. - Updated file paths in manifests for firmware deployment. - Included missing `icon` in firmware metadata and adjusted descriptions. --- dist/local_json.rpi-imager-manifest | 39 +++++++++++++++++++---------- dist/repo.json | 27 ++++++++++++++------ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/dist/local_json.rpi-imager-manifest b/dist/local_json.rpi-imager-manifest index 5b2a8f8..7a595b4 100644 --- a/dist/local_json.rpi-imager-manifest +++ b/dist/local_json.rpi-imager-manifest @@ -52,7 +52,7 @@ { "name": "OpenScan3 (Arducam IMX519 16MP)", "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", - "url": "file:///REPLACEME/OpenScan3_v0.10.0_imx519.zip", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_imx519.zip", "release_date": "2026-03-18", "devices": [ "pi5", @@ -68,12 +68,14 @@ "image_download_size": 1518263201, "image_download_sha256": "cd2431458e8f0113dbfabaf0fe13f5a027891ab3e07c45a5560f7df025b9ccd3", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5318377472, + "extract_sha256": "30baa7bc46b73c89af51dee7c16e7ac412ca353c156de7cbeedfb5c404d6bfd3" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP)", "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", - "url": "file:///REPLACEME/OpenScan3_v0.10.0_hawkeye-experimental.zip", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_hawkeye-experimental.zip", "release_date": "2026-03-18", "devices": [ "pi5", @@ -89,12 +91,14 @@ "image_download_size": 1518239856, "image_download_sha256": "0bcae0ae69d191195f9897f568acfe5c0f6b8d10c938133938044b60b9e11f1b", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5318377472, + "extract_sha256": "d0b3cd55f68f8c341c3e9e1dd0fd95b2a54dd434e219d73881b68d9263676f6a" }, { "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", - "url": "file:///REPLACEME/OpenScan3_v0.10.0_generic.zip", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_generic.zip", "release_date": "2026-03-18", "devices": [ "pi5", @@ -111,12 +115,14 @@ "image_download_size": 1495921939, "image_download_sha256": "ebfff63b6bfb600b58a6bba747cbe7198796e776226fc4583c1a1a9e433258c5", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5226102784, + "extract_sha256": "93a9c3e1c218dac0a725acf094950d6380e674cedf498ee39f00ff2d04d04a38" }, { "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", - "url": "file:///REPLACEME/OpenScan3_v0.10.0_imx519_DEVELOP.zip", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_imx519_DEVELOP.zip", "release_date": "2026-03-18", "devices": [ "pi5", @@ -132,12 +138,14 @@ "image_download_size": 1518249970, "image_download_sha256": "5513828b5c7b457c963908fa6e6824a92e9e1e127a60952b2cf6426482776966", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5318377472, + "extract_sha256": "7e5af5d67abfade308fb2d0829b9bdeff869c7618c9bb9370df102cc70862b3d" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", "description": "Developer image for the Arducam Hawkeye, please read docs before use!", - "url": "file:///REPLACEME/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", "release_date": "2026-03-18", "devices": [ "pi5", @@ -153,12 +161,14 @@ "image_download_size": 1518246422, "image_download_sha256": "49f80c13f873fc4a6096439c752b41bd5a26f6bcf707cef7c1952e8a7eb542c3", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5318377472, + "extract_sha256": "d6bb1cfe35a43b7be0d83dcfdcfa8556c8152d9d83103900f79e4a5ecad1e267" }, { "name": "OpenScan3 (Generic camera) (Develop)", "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", - "url": "file:///REPLACEME/OpenScan3_v0.10.0_generic_DEVELOP.zip", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_generic_DEVELOP.zip", "release_date": "2026-03-18", "devices": [ "pi5", @@ -175,10 +185,13 @@ "image_download_size": 1495928041, "image_download_sha256": "7c27dc67b87147c4ff521474a584c6316182beb0c4eaab319b020a19913eda7a", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5226102784, + "extract_sha256": "2dba9e567c6efba02146715a64c876f330b52c0cd72c3253650e682395f80da5" } ], - "description": "Firmware images for open 3d scanners." + "description": "Firmware images for open 3d scanners.", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png" } ] } diff --git a/dist/repo.json b/dist/repo.json index 2c43f3d..0618ff0 100644 --- a/dist/repo.json +++ b/dist/repo.json @@ -68,7 +68,9 @@ "image_download_size": 1518263201, "image_download_sha256": "cd2431458e8f0113dbfabaf0fe13f5a027891ab3e07c45a5560f7df025b9ccd3", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5318377472, + "extract_sha256": "30baa7bc46b73c89af51dee7c16e7ac412ca353c156de7cbeedfb5c404d6bfd3" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP)", @@ -89,7 +91,9 @@ "image_download_size": 1518239856, "image_download_sha256": "0bcae0ae69d191195f9897f568acfe5c0f6b8d10c938133938044b60b9e11f1b", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5318377472, + "extract_sha256": "d0b3cd55f68f8c341c3e9e1dd0fd95b2a54dd434e219d73881b68d9263676f6a" }, { "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", @@ -111,7 +115,9 @@ "image_download_size": 1495921939, "image_download_sha256": "ebfff63b6bfb600b58a6bba747cbe7198796e776226fc4583c1a1a9e433258c5", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5226102784, + "extract_sha256": "93a9c3e1c218dac0a725acf094950d6380e674cedf498ee39f00ff2d04d04a38" }, { "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", @@ -132,7 +138,9 @@ "image_download_size": 1518249970, "image_download_sha256": "5513828b5c7b457c963908fa6e6824a92e9e1e127a60952b2cf6426482776966", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5318377472, + "extract_sha256": "7e5af5d67abfade308fb2d0829b9bdeff869c7618c9bb9370df102cc70862b3d" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", @@ -153,7 +161,9 @@ "image_download_size": 1518246422, "image_download_sha256": "49f80c13f873fc4a6096439c752b41bd5a26f6bcf707cef7c1952e8a7eb542c3", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5318377472, + "extract_sha256": "d6bb1cfe35a43b7be0d83dcfdcfa8556c8152d9d83103900f79e4a5ecad1e267" }, { "name": "OpenScan3 (Generic camera) (Develop)", @@ -175,10 +185,13 @@ "image_download_size": 1495928041, "image_download_sha256": "7c27dc67b87147c4ff521474a584c6316182beb0c4eaab319b020a19913eda7a", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", - "website": "https://openscan.eu" + "website": "https://openscan.eu", + "extract_size": 5226102784, + "extract_sha256": "2dba9e567c6efba02146715a64c876f330b52c0cd72c3253650e682395f80da5" } ], - "description": "Firmware images for open 3d scanners." + "description": "Firmware images for open 3d scanners.", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png" } ] } From e4aec6384da5708f31459aedfccf03da272afa8e Mon Sep 17 00:00:00 2001 From: esto Date: Fri, 27 Mar 2026 17:11:12 +0100 Subject: [PATCH 17/75] feat(qr-scan): disable WiFi auto-start after manual task cancellation - Added `_disable_qr_wifi_autostart_after_cancel()` to persistently disable QR WiFi auto-start upon cancellation. - Integrated QR WiFi auto-start disable logic into the cancellation flow for improved user control. - Updated firmware settings management with `get_firmware_settings` and `save_firmware_settings` functions. --- .../controllers/services/tasks/core/qr_scan_task.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py index d396d39..7a0a157 100644 --- a/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py +++ b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py @@ -20,6 +20,7 @@ from PIL import Image +from openscan_firmware.config.firmware import get_firmware_settings, save_firmware_settings from openscan_firmware.controllers.services.tasks.base_task import BaseTask from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager from openscan_firmware.models.task import TaskProgress, TaskStatus @@ -107,6 +108,7 @@ async def run( await self.wait_for_pause() if self.is_cancelled(): logger.info("QR scan task cancelled at attempt %d", attempt) + _disable_qr_wifi_autostart_after_cancel() return # Capture a preview frame (JPEG) and convert it to an RGB numpy array @@ -265,3 +267,14 @@ async def _cleanup_stale_qr_tasks() -> None: if removed: logger.debug("Cleaned up %d stale QR WiFi scan tasks", removed) + + +def _disable_qr_wifi_autostart_after_cancel() -> None: + """Persistently disable QR WiFi auto-start after a manual cancellation.""" + settings = get_firmware_settings() + if not settings.qr_wifi_scan_enabled: + return + + settings.qr_wifi_scan_enabled = False + save_firmware_settings(settings) + logger.info("Disabled QR WiFi auto-start after QR scan task was cancelled") From 98f8f08bdc91a2f5e4577ebe0c9035f239912551 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 30 Mar 2026 13:27:49 +0200 Subject: [PATCH 18/75] add sublist.json for Raspberry Pi imager --- dist/os-sublist-openscan.json | 120 ++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 dist/os-sublist-openscan.json diff --git a/dist/os-sublist-openscan.json b/dist/os-sublist-openscan.json new file mode 100644 index 0000000..ce9a8d6 --- /dev/null +++ b/dist/os-sublist-openscan.json @@ -0,0 +1,120 @@ +{ + "os_list": [ + { + "name": "OpenScan3 (Arducam IMX519 16MP)", + "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519.zip", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu", + "release_date": "2026-03-18", + "extract_size": 5318377472, + "extract_sha256": "30baa7bc46b73c89af51dee7c16e7ac412ca353c156de7cbeedfb5c404d6bfd3", + "image_download_size": 1518263201, + "image_download_sha256": "cd2431458e8f0113dbfabaf0fe13f5a027891ab3e07c45a5560f7df025b9ccd3", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "init_format": "cloudinit-rpi" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP)", + "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental.zip", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu", + "release_date": "2026-03-18", + "extract_size": 5318377472, + "extract_sha256": "d0b3cd55f68f8c341c3e9e1dd0fd95b2a54dd434e219d73881b68d9263676f6a", + "image_download_size": 1518239856, + "image_download_sha256": "0bcae0ae69d191195f9897f568acfe5c0f6b8d10c938133938044b60b9e11f1b", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "init_format": "cloudinit-rpi" + }, + { + "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", + "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic.zip", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu", + "release_date": "2026-03-18", + "extract_size": 5226102784, + "extract_sha256": "93a9c3e1c218dac0a725acf094950d6380e674cedf498ee39f00ff2d04d04a38", + "image_download_size": 1495921939, + "image_download_sha256": "ebfff63b6bfb600b58a6bba747cbe7198796e776226fc4583c1a1a9e433258c5", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "init_format": "cloudinit-rpi" + }, + { + "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", + "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519_DEVELOP.zip", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu", + "release_date": "2026-03-18", + "extract_size": 5318377472, + "extract_sha256": "7e5af5d67abfade308fb2d0829b9bdeff869c7618c9bb9370df102cc70862b3d", + "image_download_size": 1518249970, + "image_download_sha256": "5513828b5c7b457c963908fa6e6824a92e9e1e127a60952b2cf6426482776966", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "init_format": "cloudinit-rpi" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", + "description": "Developer image for the Arducam Hawkeye, please read docs before use!", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu", + "release_date": "2026-03-18", + "extract_size": 5318377472, + "extract_sha256": "d6bb1cfe35a43b7be0d83dcfdcfa8556c8152d9d83103900f79e4a5ecad1e267", + "image_download_size": 1518246422, + "image_download_sha256": "49f80c13f873fc4a6096439c752b41bd5a26f6bcf707cef7c1952e8a7eb542c3", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "init_format": "cloudinit-rpi" + }, + { + "name": "OpenScan3 (Generic camera) (Develop)", + "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic_DEVELOP.zip", + "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", + "website": "https://openscan.eu", + "release_date": "2026-03-18", + "extract_size": 5226102784, + "extract_sha256": "2dba9e567c6efba02146715a64c876f330b52c0cd72c3253650e682395f80da5", + "image_download_size": 1495928041, + "image_download_sha256": "7c27dc67b87147c4ff521474a584c6316182beb0c4eaab319b020a19913eda7a", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "init_format": "cloudinit-rpi" + } + ] +} From 606e265577396bd6da1370283dc507169810c485 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 30 Mar 2026 14:43:06 +0200 Subject: [PATCH 19/75] feat(qr-scan): stop task if network connection becomes available - Added `is_network_ready_for_qr_scan()` to detect active WiFi or Ethernet connections. - Integrated periodic network checks into the QR scan task to stop it when a usable connection is detected. - Updated `_maybe_start_qr_wifi_scan()` to prevent task auto-start if a network is already connected. - Enhanced unit tests to ensure correct QR task behavior when network status changes. --- openscan_firmware/config/firmware.py | 7 ++- .../services/tasks/core/qr_scan_task.py | 17 ++++++- openscan_firmware/main.py | 12 ++--- openscan_firmware/utils/wifi.py | 40 +++++++++++++--- .../services/tasks/test_qr_scan_task.py | 47 +++++++++++++++++++ 5 files changed, 105 insertions(+), 18 deletions(-) diff --git a/openscan_firmware/config/firmware.py b/openscan_firmware/config/firmware.py index 5f769f5..4e02aa5 100644 --- a/openscan_firmware/config/firmware.py +++ b/openscan_firmware/config/firmware.py @@ -26,14 +26,13 @@ class FirmwareSettings(BaseModel): Attributes: qr_wifi_scan_enabled: When True the firmware automatically starts the - QR WiFi scan task on startup if no WiFi connection is detected. - Users who only use LAN can set this to False to prevent the - background task from running. + QR WiFi scan task on startup if no usable network connection is + detected. """ qr_wifi_scan_enabled: bool = Field( default=True, - description="Automatically scan for WiFi QR codes on startup when no WiFi is connected.", + description="Automatically scan for WiFi QR codes on startup when no WiFi or Ethernet connection is active.", ) diff --git a/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py index 7a0a157..ee23e80 100644 --- a/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py +++ b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py @@ -41,6 +41,8 @@ _CONNECT_RETRY_COOLDOWN = 6.0 # Sleep after a failed connection attempt before resuming scanning _CONNECT_ERROR_BACKOFF = 2.0 +# Re-check whether QR setup is still needed at most every N seconds +_NETWORK_READY_CHECK_INTERVAL = 5.0 class QrScanTask(BaseTask): @@ -80,7 +82,7 @@ async def run( # Lazy imports to avoid side effects at module load time import numpy as np from openscan_firmware.controllers.hardware.cameras.camera import get_camera_controller - from openscan_firmware.utils.wifi import parse_wifi_qr, connect_wifi + from openscan_firmware.utils.wifi import parse_wifi_qr, connect_wifi, is_network_ready_for_qr_scan from openscan_firmware.utils.qr_reader import ZxingQRReader, StableQRConsensus yield TaskProgress(current=0, total=0, message="QR scan starting – warming up the camera") @@ -102,6 +104,7 @@ async def run( last_progress_emit = 0.0 last_credentials = None last_connect_attempt = 0.0 + last_network_check = 0.0 while True: attempt += 1 @@ -111,6 +114,16 @@ async def run( _disable_qr_wifi_autostart_after_cancel() return + now = time.monotonic() + should_check_network = last_network_check == 0.0 or (now - last_network_check) >= _NETWORK_READY_CHECK_INTERVAL + if should_check_network: + last_network_check = now + if is_network_ready_for_qr_scan(): + logger.info("Network already connected while QR scan task was running. Stopping QR scan task.") + self._task_model.result = {"reason": "network_already_connected"} + yield TaskProgress(current=1, total=1, message="Network connected. QR scan no longer needed.") + return + # Capture a preview frame (JPEG) and convert it to an RGB numpy array try: frame_for_decode = await _capture_preview_array(controller) @@ -180,7 +193,7 @@ async def run( message=f"{error_msg} – retrying shortly", ) await asyncio.sleep(_CONNECT_ERROR_BACKOFF) - continue + raise RuntimeError(error_msg) elif decoded_text: logger.debug("Non-WiFi QR code found: %s", decoded_text[:50]) diff --git a/openscan_firmware/main.py b/openscan_firmware/main.py index e2206f1..ad94b1c 100644 --- a/openscan_firmware/main.py +++ b/openscan_firmware/main.py @@ -69,14 +69,14 @@ from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager from openscan_firmware.utils.firmware_state import handle_startup from openscan_firmware.config.firmware import get_firmware_settings -from openscan_firmware.utils.wifi import is_wifi_connected +from openscan_firmware.utils.wifi import is_network_ready_for_qr_scan logger = logging.getLogger(__name__) async def _maybe_start_qr_wifi_scan(task_manager) -> None: - """Start the QR WiFi scan task if enabled in firmware settings and no WiFi is connected. + """Start the QR WiFi scan task only when no usable network is connected. This is called once during application startup. The task runs indefinitely in the background until a WiFi QR code is found or the task is cancelled. @@ -87,8 +87,8 @@ 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 is_wifi_connected(): - logger.info("WiFi is already connected – skipping QR WiFi scan auto-start.") + if is_network_ready_for_qr_scan(): + logger.info("Network is already connected (WiFi/LAN) – skipping QR WiFi scan auto-start.") return # Find the first available camera to use for scanning @@ -99,7 +99,7 @@ async def _maybe_start_qr_wifi_scan(task_manager) -> None: return camera_name = next(iter(cameras)) - logger.info("No WiFi connection detected. Starting QR WiFi scan task with camera '%s'.", camera_name) + logger.info("No network connection detected. Starting QR WiFi scan task with camera '%s'.", camera_name) try: await task_manager.create_and_run_task("qr_scan_task", camera_name=camera_name) @@ -152,7 +152,7 @@ async def lifespan(app: FastAPI): # Now that tasks are registered, restore any persisted tasks task_manager.restore_tasks_from_persistence() - # Auto-start QR WiFi scan if enabled and no WiFi is connected + # Auto-start QR WiFi scan if enabled and no network is connected await _maybe_start_qr_wifi_scan(task_manager) yield # application runs here diff --git a/openscan_firmware/utils/wifi.py b/openscan_firmware/utils/wifi.py index 52efe1b..f53b79f 100644 --- a/openscan_firmware/utils/wifi.py +++ b/openscan_firmware/utils/wifi.py @@ -167,6 +167,30 @@ def is_wifi_connected() -> bool: Returns: True if at least one WiFi device reports a connected state. """ + return "wifi" in _get_connected_device_types() + + +def is_ethernet_connected() -> bool: + """Check whether any Ethernet device is currently connected. + + Returns: + True if at least one Ethernet device reports a connected state. + """ + return "ethernet" in _get_connected_device_types() + + +def is_network_ready_for_qr_scan() -> bool: + """Return True when WiFi or Ethernet is already connected. + + Returns: + True when a network connection already exists and QR setup is unnecessary. + """ + connected_types = _get_connected_device_types() + return "wifi" in connected_types or "ethernet" in connected_types + + +def _get_connected_device_types() -> set[str]: + """Return connected NetworkManager device types from nmcli.""" try: result = subprocess.run( ["nmcli", "-t", "-f", "TYPE,STATE", "device"], @@ -174,13 +198,17 @@ def is_wifi_connected() -> bool: text=True, timeout=5, ) - for line in result.stdout.splitlines(): - parts = line.split(":") - if len(parts) >= 2 and parts[0] == "wifi" and parts[1] == "connected": - return True except (subprocess.TimeoutExpired, FileNotFoundError) as exc: - logger.warning("Could not check WiFi status: %s", exc) - return False + logger.warning("Could not check network status: %s", exc) + return set() + + connected_types: set[str] = set() + for line in result.stdout.splitlines(): + parts = line.split(":") + if len(parts) >= 2 and parts[1] == "connected": + connected_types.add(parts[0]) + + return connected_types def _rescan_wifi_devices() -> None: diff --git a/tests/controllers/services/tasks/test_qr_scan_task.py b/tests/controllers/services/tasks/test_qr_scan_task.py index d847454..230a8bb 100644 --- a/tests/controllers/services/tasks/test_qr_scan_task.py +++ b/tests/controllers/services/tasks/test_qr_scan_task.py @@ -140,6 +140,53 @@ def failing_connect_wifi(_credentials): assert "Failed to apply WiFi credentials" in (final.result or {}).get("error", "") +@pytest.mark.asyncio +async def test_qr_scan_task_stops_when_network_becomes_ready(monkeypatch, qr_task_manager): + """Ensure the QR scan task exits when LAN/WiFi becomes available while running.""" + + fake_frame = np.zeros((10, 10, 3), dtype=np.uint8) + monkeypatch.setattr(qr_module, "_STARTUP_DELAY", 0) + monkeypatch.setattr(qr_module, "_SCAN_INTERVAL", 0) + monkeypatch.setattr(qr_module, "_NETWORK_READY_CHECK_INTERVAL", 0) + monkeypatch.setattr(qr_module, "_cleanup_stale_qr_tasks", AsyncMock()) + monkeypatch.setattr(qr_module, "_capture_preview_array", AsyncMock(return_value=fake_frame)) + + controller = type("DummyController", (), {"preview_async": AsyncMock(return_value=b"bytes")})() + monkeypatch.setattr( + "openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller", + lambda name: controller, + ) + + class NeverFoundConsensus: + def __init__(self, _reader, required_hits, window): + pass + + def feed(self, _frame): + return None + + monkeypatch.setattr("openscan_firmware.utils.qr_reader.ZxingQRReader", lambda: object()) + monkeypatch.setattr("openscan_firmware.utils.qr_reader.StableQRConsensus", NeverFoundConsensus) + + checks = {"count": 0} + + def fake_is_network_ready_for_qr_scan() -> bool: + checks["count"] += 1 + return checks["count"] >= 3 + + monkeypatch.setattr( + "openscan_firmware.utils.wifi.is_network_ready_for_qr_scan", + fake_is_network_ready_for_qr_scan, + ) + + monkeypatch.setattr(qr_module, "get_task_manager", lambda: qr_task_manager) + + task = await qr_task_manager.create_and_run_task("qr_scan_task", camera_name="mock_cam") + final = await qr_task_manager.wait_for_task(task.id) + + assert final.status == TaskStatus.COMPLETED + assert final.result == {"reason": "network_already_connected"} + + @pytest.mark.asyncio async def test_cleanup_stale_qr_tasks_removes_cancelled_and_limits_errors(monkeypatch, qr_task_manager): """Verify cleanup removes stale statuses and keeps only the latest three errors.""" From 038b738ee2de234b3657df78b646e631a42c4920 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 30 Mar 2026 12:08:29 +0200 Subject: [PATCH 20/75] feat(device): enhance configuration management with new API endpoints and logging - Added `/configurations/current` and `/configurations/{filename}` endpoints for device configuration retrieval. - Improved logging across configuration-related actions for better debugging. - Introduced `DeviceConfigPayload` schema to manage and validate device configuration payloads. - Updated tests to ensure functionality and coverage for new features. --- openscan_firmware/routers/next/device.py | 122 ++++++++++++++++-- tests/routers/test_device_router.py | 155 ++++++++++++++++++++++- 2 files changed, 263 insertions(+), 14 deletions(-) diff --git a/openscan_firmware/routers/next/device.py b/openscan_firmware/routers/next/device.py index 2686918..37f1c33 100644 --- a/openscan_firmware/routers/next/device.py +++ b/openscan_firmware/routers/next/device.py @@ -1,9 +1,12 @@ from fastapi import APIRouter, HTTPException, UploadFile, File -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ValidationError, Field, ConfigDict +from pathlib import Path import os import json import tempfile import shutil +import logging +from typing import Any from openscan_firmware.models.scanner import ScannerDevice, ScannerStartupMode, ScannerCalibrateMode from openscan_firmware.controllers import device @@ -19,6 +22,8 @@ responses={404: {"description": "Not found"}}, ) +logger = logging.getLogger(__name__) + class DeviceConfigRequest(BaseModel): config_file: str @@ -41,6 +46,23 @@ class DeviceControlResponse(BaseModel): status: DeviceStatusResponse +class DeviceConfigPayload(BaseModel): + """Schema reflecting the persisted device configuration format.""" + + model_config = ConfigDict(extra="ignore") + + name: str + model: str | None = None + shield: str | None = None + cameras: dict[str, dict[str, Any]] = Field(default_factory=dict) + motors: dict[str, dict[str, Any]] = Field(default_factory=dict) + lights: dict[str, dict[str, Any]] = Field(default_factory=dict) + endstops: dict[str, dict[str, Any]] | None = None + motors_timeout: float = 0.0 + startup_mode: ScannerStartupMode | str = ScannerStartupMode.STARTUP_ENABLED + calibrate_mode: ScannerCalibrateMode | str = ScannerCalibrateMode.CALIBRATE_MANUAL + + @router.get("/info", response_model=DeviceStatusResponse) async def get_device_info(): """Get information about the device @@ -73,8 +95,63 @@ async def list_config_files(): raise HTTPException(status_code=500, detail=f"Error listing configuration files: {str(e)}") +@router.get("/configurations/current") +async def get_current_config(): + """Return the currently active device configuration file.""" + try: + logger.debug("Reading current device configuration from %s", device.DEVICE_CONFIG_FILE) + config_path = Path(device.DEVICE_CONFIG_FILE) + config_payload = device.load_device_config() + return { + "status": "success", + "filename": config_path.name, + "path": str(config_path), + "config": config_payload, + } + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Error loading current configuration: {exc}") + + +@router.get("/configurations/{filename}") +async def get_config_file(filename: str): + """Return a specific configuration JSON file by filename.""" + try: + logger.debug("Reading configuration file request", extra={"filename": filename}) + normalized = filename if filename.endswith(".json") else f"{filename}.json" + safe_name = Path(normalized).name + config_path = resolve_settings_dir("device") / safe_name + + if not config_path.exists(): + raise HTTPException( + status_code=404, + detail={ + "message": f"Config file not found: {safe_name}", + "available_configs": device.get_available_configs(), + }, + ) + + try: + config_payload = json.loads(config_path.read_text()) + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=400, + detail=f"Failed to parse configuration file '{safe_name}': {exc.msg}", + ) + + return { + "status": "success", + "filename": config_path.name, + "path": str(config_path), + "config": config_payload, + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Error loading configuration file: {exc}") + + @router.post("/configurations/", response_model=DeviceControlResponse) -async def add_config_json(config_data: ScannerDevice, filename: DeviceConfigRequest): +async def add_config_json(config_data: DeviceConfigPayload, filename: DeviceConfigRequest): """Add a device configuration from a JSON object This endpoint accepts a JSON object with the device configuration, @@ -88,6 +165,7 @@ async def add_config_json(config_data: ScannerDevice, filename: DeviceConfigRequ dict: A dictionary containing the status of the operation """ try: + logger.info("Persisting uploaded configuration", extra={"filename": filename.config_file}) # Create a temporary file to save the configuration with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as temp_file: # Convert the model to a dictionary and save it as JSON @@ -99,19 +177,29 @@ async def add_config_json(config_data: ScannerDevice, filename: DeviceConfigRequ settings_dir = resolve_settings_dir("device") os.makedirs(settings_dir, exist_ok=True) - filename = f"{filename.config_file}.json" - target_path = os.path.join(settings_dir, filename) + target_filename = f"{filename.config_file}.json" + target_path = os.path.join(settings_dir, target_filename) # Move the temporary file to the target path shutil.move(temp_path, target_path) + status = device.get_device_info() + logger.info( + "Configuration saved", + extra={ + "filename": target_filename, + "motors": list(status.get("motors", {}).keys()), + }, + ) + return DeviceControlResponse( success=True, message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + status=DeviceStatusResponse.model_validate(status) ) except Exception as e: + logger.exception("Error while saving configuration", extra={"filename": filename.config_file}) raise HTTPException(status_code=500, detail=f"Error setting device configuration: {str(e)}") @@ -124,13 +212,16 @@ async def save_device_config(): Returns: dict: A dictionary containing the status of the operation """ + logger.info("Saving current runtime configuration to disk") if device.save_device_config(): + status = device.get_device_info() return DeviceControlResponse( success=True, message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + status=DeviceStatusResponse.model_validate(status) ) else: + logger.error("save_device_config returned False") raise HTTPException(status_code=500, detail="Failed to save device configuration") @router.put("/configurations/current", response_model=DeviceControlResponse) @@ -144,6 +235,7 @@ async def set_config_file(config_data: DeviceConfigRequest): dict: A dictionary containing the status of the operation """ try: + logger.info("Setting active configuration", extra={"requested": config_data.config_file}) # Get available configs available_configs = device.get_available_configs() @@ -173,12 +265,15 @@ async def set_config_file(config_data: DeviceConfigRequest): # Set device config if await device.set_device_config(config_file): + status = device.get_device_info() + logger.info("Configuration loaded", extra={"active": config_file}) return DeviceControlResponse( success=True, message="Configuration loaded successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + status=DeviceStatusResponse.model_validate(status) ) else: + logger.error("set_device_config returned False", extra={"active": config_file}) raise HTTPException(status_code=500, detail="Failed to load device configuration") except HTTPException: @@ -200,14 +295,25 @@ async def reinitialize_hardware(detect_cameras: bool = False): Returns: dict: A dictionary containing the status of the operation """ + logger.info("Reinitializing hardware", extra={"detect_cameras": detect_cameras}) try: await device.initialize(detect_cameras=detect_cameras) + status = device.get_device_info() + logger.info( + "Hardware reinitialized", + extra={ + "detect_cameras": detect_cameras, + "motors": list(status.get("motors", {}).keys()), + "lights": list(status.get("lights", {}).keys()), + }, + ) return DeviceControlResponse( success=True, message="Hardware reinitialized successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + status=DeviceStatusResponse.model_validate(status) ) except Exception as e: + logger.exception("Error reloading hardware", extra={"detect_cameras": detect_cameras}) raise HTTPException(status_code=500, detail=f"Error reloading hardware: {str(e)}") diff --git a/tests/routers/test_device_router.py b/tests/routers/test_device_router.py index e11fa56..b26e8bf 100644 --- a/tests/routers/test_device_router.py +++ b/tests/routers/test_device_router.py @@ -3,6 +3,9 @@ from __future__ import annotations import json +import shutil +from pathlib import Path +from importlib import import_module from typing import Callable import pytest @@ -10,22 +13,26 @@ from fastapi.testclient import TestClient +def _next_router_module_path(name: str) -> str: + return f"openscan_firmware.routers.next.{name}" + + @pytest.fixture -def device_client(latest_router_loader) -> TestClient: - """Provide a FastAPI client with the latest device router mounted.""" +def device_client() -> TestClient: + """Provide a FastAPI client with the next device router mounted.""" app = FastAPI() - device_router = latest_router_loader("device") + device_router = import_module(_next_router_module_path("device")) app.include_router(device_router.router, prefix="/latest") with TestClient(app) as client: yield client @pytest.fixture -def device_router_path(latest_router_path) -> Callable[[str], str]: # type: ignore[override] - """Shortcut to build module paths for the latest router version.""" +def device_router_path() -> Callable[[str], str]: + """Shortcut to build module paths for the next router version.""" - return latest_router_path + return _next_router_module_path def test_set_config_file_returns_factory_defaults(monkeypatch, tmp_path, device_client, device_router_path): @@ -75,6 +82,13 @@ async def fake_set_device_config(path: str): } monkeypatch.setattr(f"{module_path}.device.get_device_info", lambda: status_payload, raising=False) + class _PassthroughStatus: + @staticmethod + def model_validate(payload): + return payload + + monkeypatch.setattr(f"{module_path}.DeviceStatusResponse", _PassthroughStatus, raising=False) + response = device_client.put( "/latest/device/configurations/current", json={"config_file": "mini.json"}, @@ -90,6 +104,127 @@ async def fake_set_device_config(path: str): assert payload["status"]["calibrate_mode"] == "calibrate_manual" +def test_get_current_config_returns_payload(monkeypatch, tmp_path, device_client, device_router_path): + module_path = device_router_path("device") + + config_payload = { + "name": "UnitConfig", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "motors_timeout": 3.5, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + } + + config_path = tmp_path / "device_config.json" + monkeypatch.setattr(f"{module_path}.device.DEVICE_CONFIG_FILE", config_path, raising=False) + monkeypatch.setattr(f"{module_path}.device.load_device_config", lambda: config_payload.copy(), raising=False) + + response = device_client.get("/latest/device/configurations/current") + assert response.status_code == 200 + + payload = response.json() + assert payload["filename"] == "device_config.json" + assert payload["config"] == config_payload + + +def test_get_named_config_reads_disk(monkeypatch, tmp_path, device_client, device_router_path): + module_path = device_router_path("device") + + settings_root = tmp_path + device_dir = settings_root / "device" + device_dir.mkdir() + + config_payload = { + "name": "NamedConfig", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "motors_timeout": 1.0, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + } + + target_file = device_dir / "custom.json" + target_file.write_text(json.dumps(config_payload)) + + monkeypatch.setenv("OPENSCAN_SETTINGS_DIR", str(settings_root)) + + response = device_client.get("/latest/device/configurations/custom") + assert response.status_code == 200 + + payload = response.json() + assert payload["filename"] == "custom.json" + assert payload["config"] == config_payload + + +def test_config_roundtrip_flow(monkeypatch, tmp_path, device_client, device_router_path): + module_path = device_router_path("device") + + repo_root = Path(__file__).resolve().parents[2] + default_config = repo_root / "settings" / "device" / "default_mini_greenshield.json" + assert default_config.exists(), "Expected default config file to exist" + + settings_root = tmp_path + device_dir = settings_root / "device" + device_dir.mkdir() + monkeypatch.setenv("OPENSCAN_SETTINGS_DIR", str(settings_root)) + + shutil.copy(default_config, device_dir / default_config.name) + + device_config_path = device_dir / "device_config.json" + monkeypatch.setattr(f"{module_path}.device.DEVICE_CONFIG_FILE", device_config_path, raising=False) + + status_payload = { + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "motors_timeout": 0.0, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + "initialized": True, + } + monkeypatch.setattr(f"{module_path}.device.get_device_info", lambda: status_payload, raising=False) + monkeypatch.setattr(f"{module_path}.device.save_device_config", lambda: True, raising=False) + + captured: dict[str, dict] = {} + + async def fake_initialize(config: dict, detect_cameras: bool = False): + captured["config"] = config + + monkeypatch.setattr(f"{module_path}.device.initialize", fake_initialize, raising=False) + + get_response = device_client.get("/latest/device/configurations/default_mini_greenshield") + assert get_response.status_code == 200 + + config_payload = get_response.json()["config"] + config_payload["name"] = "IntegrationTest" + config_payload["motors_timeout"] = 12.5 + + new_file = device_dir / "integration_override.json" + new_file.write_text(json.dumps(config_payload)) + + put_response = device_client.put( + "/latest/device/configurations/current", + json={"config_file": "integration_override.json"}, + ) + assert put_response.status_code == 200 + assert captured["config"]["name"] == "IntegrationTest" + assert captured["config"]["motors_timeout"] == 12.5 + + payload = put_response.json() + assert payload["success"] is True + assert payload["status"]["initialized"] is True + + def test_reinitialize_endpoint_calls_controller(monkeypatch, device_client, device_router_path): module_path = device_router_path("device") @@ -134,6 +269,7 @@ def test_reinitialize_endpoint_calls_controller(monkeypatch, device_client, devi "busy": False, "target_angle": None, "settings": motor_settings, + "calibrated": True, "endstop": None, } }, @@ -151,6 +287,13 @@ def test_reinitialize_endpoint_calls_controller(monkeypatch, device_client, devi } monkeypatch.setattr(f"{module_path}.device.get_device_info", lambda: status_payload, raising=False) + class _PassthroughStatus: + @staticmethod + def model_validate(payload): + return payload + + monkeypatch.setattr(f"{module_path}.DeviceStatusResponse", _PassthroughStatus, raising=False) + detected_args: list[bool] = [] async def fake_initialize(*, detect_cameras: bool = False): From 20cf6f669611c1fff8c2e46c7789b65d6e605164 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 30 Mar 2026 12:57:27 +0200 Subject: [PATCH 21/75] refactor(device): standardize configuration handling with `ScannerDeviceConfig` - Replaced `DeviceConfigPayload` with `ScannerDeviceConfig` for consistent configuration management. - Added helper methods for converting runtime to persisted configurations. - Unified `model_dump` and `model_validate` usage throughout configuration endpoints and controllers. - Updated initialization to improve handling of hardware configs and defaults. --- openscan_firmware/controllers/device.py | 108 ++++++---- openscan_firmware/models/scanner.py | 35 +++- openscan_firmware/routers/next/device.py | 54 +++-- openscan_firmware/routers/v0_8/device.py | 54 ++++- tests/routers/test_device_router_v0_8.py | 249 +++++++++++++++++++++++ 5 files changed, 421 insertions(+), 79 deletions(-) create mode 100644 tests/routers/test_device_router_v0_8.py diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index ca89f80..f05ba90 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -24,7 +24,16 @@ 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.scanner import ScannerDevice, ScannerModel, ScannerShield, ScannerStartupMode, ScannerCalibrateMode +from openscan_firmware.models.scanner import ( + ScannerDevice, + ScannerDeviceConfig, + PersistedCameraConfig, + PersistedEndstopConfig, + ScannerModel, + ScannerShield, + ScannerStartupMode, + ScannerCalibrateMode, +) from openscan_firmware.config.camera import CameraSettings from openscan_firmware.config.motor import MotorConfig @@ -85,7 +94,15 @@ def _create_default_scanner_device() -> ScannerDevice: _scanner_device = _create_default_scanner_device() -_FACTORY_DEFAULT_CONFIG = _create_default_scanner_device().model_dump(mode="json") +_FACTORY_DEFAULT_CONFIG = ScannerDeviceConfig( + name="Unknown device", + model=None, + shield=None, + cameras={}, + motors={}, + lights={}, + endstops={}, +).model_dump(mode="json") # Path to device configuration file (persisted) BASE_DIR = pathlib.Path(__file__).parent.parent.parent @@ -93,6 +110,31 @@ def _create_default_scanner_device() -> ScannerDevice: DEVICE_CONFIG_FILE = resolve_settings_file("device", "device_config.json") +def _runtime_to_persisted_config() -> ScannerDeviceConfig: + return ScannerDeviceConfig( + name=_scanner_device.name, + model=_scanner_device.model.value if _scanner_device.model else None, + shield=_scanner_device.shield.value if _scanner_device.shield else None, + cameras={ + name: PersistedCameraConfig( + type=cam.type, + path=cam.path, + settings=cam.settings, + ) + for name, cam in _scanner_device.cameras.items() + }, + motors={name: motor.settings for name, motor in _scanner_device.motors.items()}, + lights={name: light.settings for name, light in _scanner_device.lights.items()}, + endstops={ + name: PersistedEndstopConfig(settings=endstop.settings) + for name, endstop in _scanner_device.endstops.items() + }, + motors_timeout=_scanner_device.motors_timeout, + startup_mode=_scanner_device.startup_mode.value if _scanner_device.startup_mode else None, + calibrate_mode=_scanner_device.calibrate_mode.value if _scanner_device.calibrate_mode else None, + ) + + def load_device_config(config_file=None) -> dict: """Load device configuration from a file @@ -134,12 +176,8 @@ def load_device_config(config_file=None) -> dict: except Exception as e: logger.error(f"Error loading device configuration: {e}") - # enforce safe defaults for critical settings - config_dict.setdefault("motors_timeout", 0.0) - config_dict.setdefault("startup_mode", ScannerStartupMode.STARTUP_ENABLED.value) - config_dict.setdefault("calibrate_mode", ScannerCalibrateMode.CALIBRATE_MANUAL.value) - - return config_dict + persisted_config = ScannerDeviceConfig.model_validate(config_dict) + return persisted_config.model_dump(mode="json") def save_device_config() -> bool: @@ -149,18 +187,7 @@ def save_device_config() -> bool: try: os.makedirs(os.path.dirname(DEVICE_CONFIG_FILE), exist_ok=True) - config_to_save = { - "name": _scanner_device.name, - "model": _scanner_device.model.value if _scanner_device.model else None, - "shield": _scanner_device.shield.value if _scanner_device.shield else None, - "cameras": {name: cam.model_dump(mode='json') for name, cam in _scanner_device.cameras.items()}, - "motors": {name: motor.settings.model_dump(mode='json') for name, motor in _scanner_device.motors.items()}, - "lights": {name: light.settings.model_dump(mode='json') for name, light in _scanner_device.lights.items()}, - "endstops": {name: endstop.model_dump(mode='json') for name, endstop in _scanner_device.endstops.items()}, - "motors_timeout": _scanner_device.motors_timeout, - "startup_mode": _scanner_device.startup_mode.value if _scanner_device.startup_mode else None, - "calibrate_mode": _scanner_device.calibrate_mode.value if _scanner_device.calibrate_mode else None, - } + config_to_save = _runtime_to_persisted_config().model_dump(mode="json") with open(DEVICE_CONFIG_FILE, "w") as f: json.dump(config_to_save, f, indent=4) @@ -404,7 +431,7 @@ async def handle_idle_event(event: HardwareEvent): case _: logger.info("UNKNOWN EVENT") -async def initialize(config: dict | None = None, detect_cameras: bool = False): +async def initialize(config: dict | ScannerDeviceConfig | None = None, detect_cameras: bool = False): """Detect and load hardware components. Args: @@ -419,9 +446,10 @@ async def initialize(config: dict | None = None, detect_cameras: bool = False): await _initialize_with_config(config, detect_cameras) -async def _initialize_with_config(config: dict, detect_cameras: bool = False): +async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cameras: bool = False): """Internal helper that assumes the configuration dict is already resolved.""" global _scanner_device + config_dict = ScannerDeviceConfig.model_validate(config).model_dump(mode="json") # Load environment variables load_dotenv() @@ -438,33 +466,33 @@ async def _initialize_with_config(config: dict, detect_cameras: bool = False): logger.debug("Cleaned up old controllers.") # Detect hardware - if detect_cameras or config["cameras"] == {}: + if detect_cameras or config_dict["cameras"] == {}: camera_objects = _detect_cameras() else: camera_objects = {} - for cam_name in config["cameras"]: + for cam_name in config_dict["cameras"]: camera = Camera( name=cam_name, - type=CameraType(config["cameras"][cam_name]["type"]), - path=config["cameras"][cam_name]["path"], - settings=_load_camera_config(config["cameras"][cam_name]["settings"]) + type=CameraType(config_dict["cameras"][cam_name]["type"]), + path=config_dict["cameras"][cam_name]["path"], + settings=_load_camera_config(config_dict["cameras"][cam_name]["settings"]) ) camera_objects[cam_name] = camera # Create motor objects motor_objects = {} - for motor_name in config["motors"]: + for motor_name in config_dict["motors"]: motor = Motor(name=motor_name, - settings=_load_motor_config(config["motors"][motor_name])) + settings=_load_motor_config(config_dict["motors"][motor_name])) motor_objects[motor_name] = motor logger.debug(f"Loaded motor {motor_name} with settings: {motor.settings}") # Create light objects light_objects = {} - for light_name in config["lights"]: + for light_name in config_dict["lights"]: light = Light( name=light_name, - settings=_load_light_config(config["lights"][light_name]) + settings=_load_light_config(config_dict["lights"][light_name]) ) light_objects[light_name] = light logger.debug(f"Loaded light {light_name} with settings: {light.settings}") @@ -520,10 +548,10 @@ async def _initialize_with_config(config: dict, detect_cameras: bool = False): # Create endstop objects endstop_objects = {} - if "endstops" in config: - for endstop_name in config["endstops"]: + if "endstops" in config_dict: + for endstop_name in config_dict["endstops"]: try: - settings = _load_endstop_config(config["endstops"][endstop_name]["settings"]) + settings = _load_endstop_config(config_dict["endstops"][endstop_name]["settings"]) endstop = Endstop(name=endstop_name, settings=settings) controller = get_motor_controller(settings.motor_name) if not controller: @@ -554,19 +582,19 @@ async def _initialize_with_config(config: dict, detect_cameras: bool = False): await controller.turn_on() _scanner_device = ScannerDevice( - name=config["name"], - model=ScannerModel(config.get("model")) if config.get("model") else None, - shield=ScannerShield(config.get("shield")) if config.get("shield") else None, + name=config_dict["name"], + model=ScannerModel(config_dict.get("model")) if config_dict.get("model") else None, + shield=ScannerShield(config_dict.get("shield")) if config_dict.get("shield") else None, cameras=camera_objects, motors=motor_objects, lights=light_objects, endstops=endstop_objects, # motors timeout in seconds - 0 to disable - motors_timeout=config["motors_timeout"], + motors_timeout=config_dict["motors_timeout"], - startup_mode=config["startup_mode"], - calibrate_mode=config["calibrate_mode"], + startup_mode=config_dict["startup_mode"], + calibrate_mode=config_dict["calibrate_mode"], ) # beware, PrivateAttr are NOT initialized in constructor diff --git a/openscan_firmware/models/scanner.py b/openscan_firmware/models/scanner.py index cf8ad72..76c1456 100644 --- a/openscan_firmware/models/scanner.py +++ b/openscan_firmware/models/scanner.py @@ -1,9 +1,13 @@ from enum import Enum from typing import Optional -from pydantic import BaseModel, PrivateAttr, ConfigDict +from pydantic import BaseModel, PrivateAttr, ConfigDict, Field -from openscan_firmware.models.camera import Camera +from openscan_firmware.config.camera import CameraSettings +from openscan_firmware.config.endstop import EndstopConfig +from openscan_firmware.config.light import LightConfig +from openscan_firmware.config.motor import MotorConfig +from openscan_firmware.models.camera import Camera, CameraType from openscan_firmware.models.light import Light from openscan_firmware.models.motor import Motor, Endstop @@ -47,3 +51,30 @@ class ScannerDevice(BaseModel): _idle : bool = PrivateAttr(default=False) _initialized: bool = PrivateAttr(default=False) + + +class PersistedCameraConfig(BaseModel): + type: CameraType | str + path: str + settings: CameraSettings = Field(default_factory=CameraSettings) + + +class PersistedEndstopConfig(BaseModel): + settings: EndstopConfig + + +class ScannerDeviceConfig(BaseModel): + """Persisted scanner configuration payload stored as JSON.""" + + model_config = ConfigDict(extra="ignore") + + name: str + model: str | None = None + shield: str | None = None + cameras: dict[str, PersistedCameraConfig] = Field(default_factory=dict) + motors: dict[str, MotorConfig] = Field(default_factory=dict) + lights: dict[str, LightConfig] = Field(default_factory=dict) + endstops: dict[str, PersistedEndstopConfig] | None = None + motors_timeout: float = 0.0 + startup_mode: ScannerStartupMode | str = ScannerStartupMode.STARTUP_ENABLED + calibrate_mode: ScannerCalibrateMode | str = ScannerCalibrateMode.CALIBRATE_MANUAL diff --git a/openscan_firmware/routers/next/device.py b/openscan_firmware/routers/next/device.py index 37f1c33..ce4c5c6 100644 --- a/openscan_firmware/routers/next/device.py +++ b/openscan_firmware/routers/next/device.py @@ -1,14 +1,13 @@ from fastapi import APIRouter, HTTPException, UploadFile, File -from pydantic import BaseModel, ValidationError, Field, ConfigDict +from pydantic import BaseModel, ValidationError from pathlib import Path import os import json import tempfile import shutil import logging -from typing import Any -from openscan_firmware.models.scanner import ScannerDevice, ScannerStartupMode, ScannerCalibrateMode +from openscan_firmware.models.scanner import ScannerDeviceConfig, ScannerStartupMode, ScannerCalibrateMode from openscan_firmware.controllers import device from openscan_firmware.utils.dir_paths import resolve_settings_dir @@ -46,21 +45,15 @@ class DeviceControlResponse(BaseModel): status: DeviceStatusResponse -class DeviceConfigPayload(BaseModel): - """Schema reflecting the persisted device configuration format.""" +class DeviceConfigResponse(BaseModel): + status: str + filename: str + path: str + config: ScannerDeviceConfig - model_config = ConfigDict(extra="ignore") - name: str - model: str | None = None - shield: str | None = None - cameras: dict[str, dict[str, Any]] = Field(default_factory=dict) - motors: dict[str, dict[str, Any]] = Field(default_factory=dict) - lights: dict[str, dict[str, Any]] = Field(default_factory=dict) - endstops: dict[str, dict[str, Any]] | None = None - motors_timeout: float = 0.0 - startup_mode: ScannerStartupMode | str = ScannerStartupMode.STARTUP_ENABLED - calibrate_mode: ScannerCalibrateMode | str = ScannerCalibrateMode.CALIBRATE_MANUAL +def _runtime_status_response() -> DeviceStatusResponse: + return DeviceStatusResponse.model_validate(device.get_device_info()) @router.get("/info", response_model=DeviceStatusResponse) @@ -95,7 +88,7 @@ async def list_config_files(): raise HTTPException(status_code=500, detail=f"Error listing configuration files: {str(e)}") -@router.get("/configurations/current") +@router.get("/configurations/current", response_model=DeviceConfigResponse) async def get_current_config(): """Return the currently active device configuration file.""" try: @@ -112,7 +105,7 @@ async def get_current_config(): raise HTTPException(status_code=500, detail=f"Error loading current configuration: {exc}") -@router.get("/configurations/{filename}") +@router.get("/configurations/{filename}", response_model=DeviceConfigResponse) async def get_config_file(filename: str): """Return a specific configuration JSON file by filename.""" try: @@ -151,7 +144,7 @@ async def get_config_file(filename: str): @router.post("/configurations/", response_model=DeviceControlResponse) -async def add_config_json(config_data: DeviceConfigPayload, filename: DeviceConfigRequest): +async def add_config_json(config_data: ScannerDeviceConfig, filename: DeviceConfigRequest): """Add a device configuration from a JSON object This endpoint accepts a JSON object with the device configuration, @@ -169,7 +162,7 @@ async def add_config_json(config_data: DeviceConfigPayload, filename: DeviceConf # Create a temporary file to save the configuration with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as temp_file: # Convert the model to a dictionary and save it as JSON - config_dict = config_data.dict() + config_dict = config_data.model_dump(mode="json") json.dump(config_dict, temp_file, indent=4) temp_path = temp_file.name @@ -183,19 +176,19 @@ async def add_config_json(config_data: DeviceConfigPayload, filename: DeviceConf # Move the temporary file to the target path shutil.move(temp_path, target_path) - status = device.get_device_info() + status = _runtime_status_response() logger.info( "Configuration saved", extra={ "filename": target_filename, - "motors": list(status.get("motors", {}).keys()), + "motors": list(status.motors.keys()), }, ) return DeviceControlResponse( success=True, message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(status) + status=status ) except Exception as e: @@ -214,11 +207,10 @@ async def save_device_config(): """ logger.info("Saving current runtime configuration to disk") if device.save_device_config(): - status = device.get_device_info() return DeviceControlResponse( success=True, message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(status) + status=_runtime_status_response() ) else: logger.error("save_device_config returned False") @@ -265,12 +257,12 @@ async def set_config_file(config_data: DeviceConfigRequest): # Set device config if await device.set_device_config(config_file): - status = device.get_device_info() + status = _runtime_status_response() logger.info("Configuration loaded", extra={"active": config_file}) return DeviceControlResponse( success=True, message="Configuration loaded successfully", - status=DeviceStatusResponse.model_validate(status) + status=status ) else: logger.error("set_device_config returned False", extra={"active": config_file}) @@ -298,19 +290,19 @@ async def reinitialize_hardware(detect_cameras: bool = False): logger.info("Reinitializing hardware", extra={"detect_cameras": detect_cameras}) try: await device.initialize(detect_cameras=detect_cameras) - status = device.get_device_info() + status = _runtime_status_response() logger.info( "Hardware reinitialized", extra={ "detect_cameras": detect_cameras, - "motors": list(status.get("motors", {}).keys()), - "lights": list(status.get("lights", {}).keys()), + "motors": list(status.motors.keys()), + "lights": list(status.lights.keys()), }, ) return DeviceControlResponse( success=True, message="Hardware reinitialized successfully", - status=DeviceStatusResponse.model_validate(status) + status=status ) except Exception as e: logger.exception("Error reloading hardware", extra={"detect_cameras": detect_cameras}) diff --git a/openscan_firmware/routers/v0_8/device.py b/openscan_firmware/routers/v0_8/device.py index 2686918..d10b318 100644 --- a/openscan_firmware/routers/v0_8/device.py +++ b/openscan_firmware/routers/v0_8/device.py @@ -5,7 +5,14 @@ import tempfile import shutil -from openscan_firmware.models.scanner import ScannerDevice, ScannerStartupMode, ScannerCalibrateMode +from openscan_firmware.models.scanner import ( + ScannerDevice, + ScannerDeviceConfig, + PersistedCameraConfig, + PersistedEndstopConfig, + ScannerStartupMode, + ScannerCalibrateMode, +) from openscan_firmware.controllers import device from openscan_firmware.utils.dir_paths import resolve_settings_dir @@ -41,6 +48,41 @@ class DeviceControlResponse(BaseModel): status: DeviceStatusResponse +def _runtime_status_response() -> DeviceStatusResponse: + return DeviceStatusResponse.model_validate(device.get_device_info()) + + +def _v08_payload_to_persisted_config(config_data: ScannerDevice) -> ScannerDeviceConfig: + return ScannerDeviceConfig( + name=config_data.name, + model=config_data.model.value if config_data.model else None, + shield=config_data.shield.value if config_data.shield else None, + cameras={ + name: PersistedCameraConfig( + type=camera.type, + path=camera.path, + settings=camera.settings, + ) + for name, camera in config_data.cameras.items() + }, + motors={ + name: motor.settings + for name, motor in config_data.motors.items() + }, + lights={ + name: light.settings + for name, light in config_data.lights.items() + }, + endstops={ + name: PersistedEndstopConfig(settings=endstop.settings) + for name, endstop in (config_data.endstops or {}).items() + }, + motors_timeout=config_data.motors_timeout, + startup_mode=config_data.startup_mode.value if config_data.startup_mode else None, + calibrate_mode=config_data.calibrate_mode.value if config_data.calibrate_mode else None, + ) + + @router.get("/info", response_model=DeviceStatusResponse) async def get_device_info(): """Get information about the device @@ -91,7 +133,7 @@ async def add_config_json(config_data: ScannerDevice, filename: DeviceConfigRequ # Create a temporary file to save the configuration with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as temp_file: # Convert the model to a dictionary and save it as JSON - config_dict = config_data.dict() + config_dict = _v08_payload_to_persisted_config(config_data).model_dump(mode="json") json.dump(config_dict, temp_file, indent=4) temp_path = temp_file.name @@ -108,7 +150,7 @@ async def add_config_json(config_data: ScannerDevice, filename: DeviceConfigRequ return DeviceControlResponse( success=True, message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + status=_runtime_status_response() ) except Exception as e: @@ -128,7 +170,7 @@ async def save_device_config(): return DeviceControlResponse( success=True, message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + status=_runtime_status_response() ) else: raise HTTPException(status_code=500, detail="Failed to save device configuration") @@ -176,7 +218,7 @@ async def set_config_file(config_data: DeviceConfigRequest): return DeviceControlResponse( success=True, message="Configuration loaded successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + status=_runtime_status_response() ) else: raise HTTPException(status_code=500, detail="Failed to load device configuration") @@ -205,7 +247,7 @@ async def reinitialize_hardware(detect_cameras: bool = False): return DeviceControlResponse( success=True, message="Hardware reinitialized successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + status=_runtime_status_response() ) except Exception as e: raise HTTPException(status_code=500, detail=f"Error reloading hardware: {str(e)}") diff --git a/tests/routers/test_device_router_v0_8.py b/tests/routers/test_device_router_v0_8.py new file mode 100644 index 0000000..1d73265 --- /dev/null +++ b/tests/routers/test_device_router_v0_8.py @@ -0,0 +1,249 @@ +"""Baseline integration-style tests for the v0_8 device router contract.""" + +from __future__ import annotations + +import json +from importlib import import_module +from pathlib import Path +from typing import Callable + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def _v08_router_module_path(name: str) -> str: + return f"openscan_firmware.routers.v0_8.{name}" + + +@pytest.fixture +def device_client_v08() -> TestClient: + """Provide a FastAPI client with the v0_8 device router mounted.""" + + app = FastAPI() + device_router = import_module(_v08_router_module_path("device")) + app.include_router(device_router.router, prefix="/v0.8") + with TestClient(app) as client: + yield client + + +@pytest.fixture +def device_router_path_v08() -> Callable[[str], str]: + """Shortcut to build module paths for the v0_8 router version.""" + + return _v08_router_module_path + + +def test_v08_set_config_file_uses_available_config(monkeypatch, tmp_path, device_client_v08, device_router_path_v08): + module_path = device_router_path_v08("device") + + preset_path = tmp_path / "mini.json" + preset_path.write_text("{}") + + monkeypatch.setattr( + f"{module_path}.device.get_available_configs", + lambda: [{"filename": "mini.json", "path": str(preset_path)}], + raising=False, + ) + + captured: dict[str, str] = {} + + async def fake_set_device_config(path: str): + captured["path"] = path + return True + + monkeypatch.setattr(f"{module_path}.device.set_device_config", fake_set_device_config, raising=False) + monkeypatch.setattr( + f"{module_path}.device.get_device_info", + lambda: { + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "motors_timeout": 0.0, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + "initialized": True, + }, + raising=False, + ) + + class _PassthroughStatus: + @staticmethod + def model_validate(payload): + return payload + + monkeypatch.setattr(f"{module_path}.DeviceStatusResponse", _PassthroughStatus, raising=False) + + response = device_client_v08.put( + "/v0.8/device/configurations/current", + json={"config_file": "mini.json"}, + ) + + assert response.status_code == 200 + assert captured["path"] == str(preset_path) + + payload = response.json() + assert payload["success"] is True + assert payload["message"] == "Configuration loaded successfully" + assert payload["status"]["initialized"] is True + + +def test_v08_reinitialize_endpoint_calls_controller(monkeypatch, device_client_v08, device_router_path_v08): + module_path = device_router_path_v08("device") + + monkeypatch.setattr( + f"{module_path}.device.get_device_info", + lambda: { + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "motors_timeout": 0.0, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + "initialized": True, + }, + raising=False, + ) + + class _PassthroughStatus: + @staticmethod + def model_validate(payload): + return payload + + monkeypatch.setattr(f"{module_path}.DeviceStatusResponse", _PassthroughStatus, raising=False) + + detected_args: list[bool] = [] + + async def fake_initialize(*, detect_cameras: bool = False): + detected_args.append(detect_cameras) + + monkeypatch.setattr(f"{module_path}.device.initialize", fake_initialize, raising=False) + + response = device_client_v08.post( + "/v0.8/device/configurations/current/initialize", + params={"detect_cameras": "true"}, + ) + + assert response.status_code == 200 + assert detected_args == [True] + + payload = response.json() + assert payload["success"] is True + assert payload["message"] == "Hardware reinitialized successfully" + + +def test_v08_add_config_json_rejects_persisted_shape(device_client_v08): + repo_root = Path(__file__).resolve().parents[2] + default_config = repo_root / "settings" / "device" / "default_mini_greenshield.json" + assert default_config.exists(), "Expected default config file to exist" + + persisted_payload = default_config.read_text() + + response = device_client_v08.post( + "/v0.8/device/configurations/", + json={ + "config_data": json.loads(persisted_payload), + "filename": {"config_file": "legacy_strict_contract"}, + }, + ) + + assert response.status_code == 422 + + +def test_v08_add_config_json_translates_legacy_shape(monkeypatch, tmp_path, device_client_v08, device_router_path_v08): + module_path = device_router_path_v08("device") + + settings_root = tmp_path + (settings_root / "device").mkdir() + monkeypatch.setenv("OPENSCAN_SETTINGS_DIR", str(settings_root)) + + monkeypatch.setattr( + f"{module_path}.device.get_device_info", + lambda: { + "name": "Preset", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "motors_timeout": 0.0, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + "initialized": True, + }, + raising=False, + ) + + class _PassthroughStatus: + @staticmethod + def model_validate(payload): + return payload + + monkeypatch.setattr(f"{module_path}.DeviceStatusResponse", _PassthroughStatus, raising=False) + + response = device_client_v08.post( + "/v0.8/device/configurations/", + json={ + "config_data": { + "name": "LegacyPayload", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": { + "rotor": { + "name": "rotor", + "settings": { + "direction_pin": 5, + "enable_pin": 23, + "step_pin": 6, + "acceleration": 20000, + "max_speed": 5000, + "direction": 1, + "steps_per_rotation": 42667, + "min_angle": 0, + "max_angle": 360, + "home_angle": 90, + }, + "angle": 90.0, + } + }, + "lights": { + "ring": { + "name": "ring", + "settings": { + "pins": [17, 27], + "pwm_support": False, + }, + } + }, + "endstops": {}, + "motors_timeout": 1.5, + "startup_mode": "startup_enabled", + "calibrate_mode": "calibrate_manual", + }, + "filename": {"config_file": "legacy_adapter_out"}, + }, + ) + + assert response.status_code == 200 + + written_file = settings_root / "device" / "legacy_adapter_out.json" + assert written_file.exists() + + written_payload = json.loads(written_file.read_text()) + assert written_payload["name"] == "LegacyPayload" + assert written_payload["motors"]["rotor"]["direction_pin"] == 5 + assert "name" not in written_payload["motors"]["rotor"] + assert written_payload["lights"]["ring"]["pins"] == [17, 27] + assert "name" not in written_payload["lights"]["ring"] + + +def test_v08_get_current_config_is_not_available(device_client_v08): + response = device_client_v08.get("/v0.8/device/configurations/current") + assert response.status_code == 405 From 7d75a986e040df5bb5347d4b47549ac63dab4009 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 30 Mar 2026 14:08:52 +0200 Subject: [PATCH 22/75] feat(firmware): add firmware API endpoints and integration tests - Introduced new firmware endpoints (`GET /settings`, `PUT /settings`, `PATCH /settings/{key}`) for managing firmware settings. - Added integration-style tests for endpoint functionality and validation. - Updated FastAPI app to include firmware router in the `/latest` namespace. --- openscan_firmware/main.py | 2 + openscan_firmware/routers/next/firmware.py | 53 +++ scripts/openapi/openapi_next.json | 451 +++++++++++++-------- tests/routers/test_firmware_router.py | 102 +++++ 4 files changed, 450 insertions(+), 158 deletions(-) create mode 100644 openscan_firmware/routers/next/firmware.py create mode 100644 tests/routers/test_firmware_router.py diff --git a/openscan_firmware/main.py b/openscan_firmware/main.py index ad94b1c..7fb823c 100644 --- a/openscan_firmware/main.py +++ b/openscan_firmware/main.py @@ -55,6 +55,7 @@ cameras as cameras_next, motors as motors_next, lights as lights_next, + firmware as firmware_next, projects as projects_next, gpio as gpio_next, openscan as openscan_next, @@ -231,6 +232,7 @@ async def lifespan(app: FastAPI): cameras_next.router, motors_next.router, lights_next.router, + firmware_next.router, projects_next.router, gpio_next.router, openscan_next.router, diff --git a/openscan_firmware/routers/next/firmware.py b/openscan_firmware/routers/next/firmware.py new file mode 100644 index 0000000..61c298e --- /dev/null +++ b/openscan_firmware/routers/next/firmware.py @@ -0,0 +1,53 @@ +"""Firmware settings API endpoints.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from openscan_firmware.config.firmware import ( + FirmwareSettings, + get_firmware_settings, + save_firmware_settings, +) + +router = APIRouter( + prefix="/firmware", + tags=["firmware"], + responses={404: {"description": "Not found"}}, +) + + +class FirmwareSettingPatchRequest(BaseModel): + value: Any + + +@router.get("/settings", response_model=FirmwareSettings) +async def get_settings() -> FirmwareSettings: + """Return persisted firmware settings.""" + return get_firmware_settings() + + +@router.put("/settings", response_model=FirmwareSettings) +async def replace_settings(settings: FirmwareSettings) -> FirmwareSettings: + """Replace the entire firmware settings payload.""" + save_firmware_settings(settings) + return settings + + +@router.patch("/settings/{key}", response_model=FirmwareSettings) +async def update_setting(key: str, payload: FirmwareSettingPatchRequest) -> FirmwareSettings: + """Update a single firmware settings key.""" + current_settings = get_firmware_settings() + + if key not in FirmwareSettings.model_fields: + raise HTTPException(status_code=404, detail=f"Unknown firmware setting key: {key}") + + updated_payload = current_settings.model_dump() + updated_payload[key] = payload.value + updated_settings = FirmwareSettings.model_validate(updated_payload) + + save_firmware_settings(updated_settings) + return updated_settings diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index d0b0369..a5cdbba 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -2742,19 +2742,42 @@ } } }, - "/device/configurations/": { - "post": { + "/device/configurations/current": { + "get": { "tags": [ "device" ], - "summary": "Add Config Json", - "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "add_config_json", + "summary": "Get Current Config", + "description": "Return the currently active device configuration file.", + "operationId": "get_current_config", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceConfigResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + }, + "put": { + "tags": [ + "device" + ], + "summary": "Set Config File", + "description": "Set the device configuration from a file and initialize hardware\n\nArgs:\n config_data: The device configuration to set\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "set_config_file", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" + "$ref": "#/components/schemas/DeviceConfigRequest" } } }, @@ -2785,33 +2808,57 @@ } } } - } - }, - "/device/configurations/current": { - "put": { + }, + "patch": { "tags": [ "device" ], - "summary": "Set Config File", - "description": "Set the device configuration from a file and initialize hardware\n\nArgs:\n config_data: The device configuration to set\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "set_config_file", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceConfigRequest" + "summary": "Save Device Config", + "description": "Save the current device configuration to a file\n\nThis endpoint saves the current device configuration to device_config.json.\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "save_device_config", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceControlResponse" + } } } }, - "required": true - }, + "404": { + "description": "Not found" + } + } + } + }, + "/device/configurations/{filename}": { + "get": { + "tags": [ + "device" + ], + "summary": "Get Config File", + "description": "Return a specific configuration JSON file by filename.", + "operationId": "get_config_file", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Filename" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "$ref": "#/components/schemas/DeviceConfigResponse" } } } @@ -2830,14 +2877,26 @@ } } } - }, - "patch": { + } + }, + "/device/configurations/": { + "post": { "tags": [ "device" ], - "summary": "Save Device Config", - "description": "Save the current device configuration to a file\n\nThis endpoint saves the current device configuration to device_config.json.\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "save_device_config", + "summary": "Add Config Json", + "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "add_config_json", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", @@ -2851,6 +2910,16 @@ }, "404": { "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -4151,7 +4220,7 @@ "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { - "$ref": "#/components/schemas/ScannerDevice" + "$ref": "#/components/schemas/ScannerDeviceConfig-Input" }, "filename": { "$ref": "#/components/schemas/DeviceConfigRequest" @@ -4197,32 +4266,6 @@ ], "title": "Body_move_motor_by_degree_motors__motor_name__angle_patch" }, - "Camera": { - "properties": { - "type": { - "$ref": "#/components/schemas/CameraType" - }, - "name": { - "type": "string", - "title": "Name" - }, - "path": { - "type": "string", - "title": "Path" - }, - "settings": { - "$ref": "#/components/schemas/CameraSettings" - } - }, - "type": "object", - "required": [ - "type", - "name", - "path", - "settings" - ], - "title": "Camera" - }, "CameraSettings": { "properties": { "shutter": { @@ -4773,6 +4816,33 @@ ], "title": "DeviceConfigRequest" }, + "DeviceConfigResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "path": { + "type": "string", + "title": "Path" + }, + "config": { + "$ref": "#/components/schemas/ScannerDeviceConfig-Output" + } + }, + "type": "object", + "required": [ + "status", + "filename", + "path", + "config" + ], + "title": "DeviceConfigResponse" + }, "DeviceControlResponse": { "properties": { "success": { @@ -4887,30 +4957,6 @@ "title": "DiskUsage", "description": "Filesystem usage snapshot for a directory." }, - "Endstop": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/EndstopConfig" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Endstop" - }, "EndstopConfig": { "properties": { "pin": { @@ -4990,23 +5036,6 @@ "type": "object", "title": "HTTPValidationError" }, - "Light": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "$ref": "#/components/schemas/LightConfig" - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Light" - }, "LightConfig": { "properties": { "pin": { @@ -5068,35 +5097,6 @@ ], "title": "LightStatusResponse" }, - "Motor": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/MotorConfig" - }, - { - "type": "null" - } - ] - }, - "angle": { - "type": "number", - "title": "Angle", - "default": 90.0 - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Motor" - }, "MotorConfig": { "properties": { "direction_pin": { @@ -5243,6 +5243,46 @@ ], "title": "PathMethod" }, + "PersistedCameraConfig": { + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/CameraType" + }, + { + "type": "string" + } + ], + "title": "Type" + }, + "path": { + "type": "string", + "title": "Path" + }, + "settings": { + "$ref": "#/components/schemas/CameraSettings" + } + }, + "type": "object", + "required": [ + "type", + "path" + ], + "title": "PersistedCameraConfig" + }, + "PersistedEndstopConfig": { + "properties": { + "settings": { + "$ref": "#/components/schemas/EndstopConfig" + } + }, + "type": "object", + "required": [ + "settings" + ], + "title": "PersistedEndstopConfig" + }, "PhotoResponse": { "properties": { "project_name": { @@ -5615,7 +5655,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDevice": { + "ScannerDeviceConfig-Input": { "properties": { "name": { "type": "string", @@ -5624,40 +5664,42 @@ "model": { "anyOf": [ { - "$ref": "#/components/schemas/ScannerModel" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Model" }, "shield": { "anyOf": [ { - "$ref": "#/components/schemas/ScannerShield" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Shield" }, "cameras": { "additionalProperties": { - "$ref": "#/components/schemas/Camera" + "$ref": "#/components/schemas/PersistedCameraConfig" }, "type": "object", "title": "Cameras" }, "motors": { "additionalProperties": { - "$ref": "#/components/schemas/Motor" + "$ref": "#/components/schemas/MotorConfig" }, "type": "object", "title": "Motors" }, "lights": { "additionalProperties": { - "$ref": "#/components/schemas/Light" + "$ref": "#/components/schemas/LightConfig" }, "type": "object", "title": "Lights" @@ -5666,7 +5708,7 @@ "anyOf": [ { "additionalProperties": { - "$ref": "#/components/schemas/Endstop" + "$ref": "#/components/schemas/PersistedEndstopConfig" }, "type": "object" }, @@ -5682,43 +5724,136 @@ "default": 0.0 }, "startup_mode": { - "$ref": "#/components/schemas/ScannerStartupMode", + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerStartupMode" + }, + { + "type": "string" + } + ], + "title": "Startup Mode", "default": "startup_enabled" }, "calibrate_mode": { - "$ref": "#/components/schemas/ScannerCalibrateMode", + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerCalibrateMode" + }, + { + "type": "string" + } + ], + "title": "Calibrate Mode", "default": "calibrate_manual" } }, "type": "object", "required": [ - "name", - "model", - "shield", - "cameras", - "motors", - "lights", - "endstops" + "name" ], - "title": "ScannerDevice" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, - "ScannerModel": { - "type": "string", - "enum": [ - "classic", - "mini", - "custom" - ], - "title": "ScannerModel" - }, - "ScannerShield": { - "type": "string", - "enum": [ - "greenshield", - "blackshield", - "custom" + "ScannerDeviceConfig-Output": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "shield": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Shield" + }, + "cameras": { + "additionalProperties": { + "$ref": "#/components/schemas/PersistedCameraConfig" + }, + "type": "object", + "title": "Cameras" + }, + "motors": { + "additionalProperties": { + "$ref": "#/components/schemas/MotorConfig" + }, + "type": "object", + "title": "Motors" + }, + "lights": { + "additionalProperties": { + "$ref": "#/components/schemas/LightConfig" + }, + "type": "object", + "title": "Lights" + }, + "endstops": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/PersistedEndstopConfig" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Endstops" + }, + "motors_timeout": { + "type": "number", + "title": "Motors Timeout", + "default": 0.0 + }, + "startup_mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerStartupMode" + }, + { + "type": "string" + } + ], + "title": "Startup Mode", + "default": "startup_enabled" + }, + "calibrate_mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerCalibrateMode" + }, + { + "type": "string" + } + ], + "title": "Calibrate Mode", + "default": "calibrate_manual" + } + }, + "type": "object", + "required": [ + "name" ], - "title": "ScannerShield" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, "ScannerStartupMode": { "type": "string", diff --git a/tests/routers/test_firmware_router.py b/tests/routers/test_firmware_router.py new file mode 100644 index 0000000..1228fd8 --- /dev/null +++ b/tests/routers/test_firmware_router.py @@ -0,0 +1,102 @@ +"""Integration-style tests for the firmware router endpoints.""" + +from __future__ import annotations + +from importlib import import_module + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def _next_router_module_path(name: str) -> str: + return f"openscan_firmware.routers.next.{name}" + + +@pytest.fixture +def firmware_client() -> TestClient: + """Provide a FastAPI client with the next firmware router mounted.""" + + app = FastAPI() + firmware_router = import_module(_next_router_module_path("firmware")) + app.include_router(firmware_router.router, prefix="/latest") + with TestClient(app) as client: + yield client + + +def test_get_firmware_settings_returns_current_settings(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), + ) + + response = firmware_client.get("/latest/firmware/settings") + + assert response.status_code == 200 + assert response.json() == {"qr_wifi_scan_enabled": True} + + +def test_put_firmware_settings_replaces_payload(monkeypatch, firmware_client): + module_path = _next_router_module_path("firmware") + captured: dict[str, bool] = {} + + def fake_save(settings): + captured["qr_wifi_scan_enabled"] = settings.qr_wifi_scan_enabled + + monkeypatch.setattr(f"{module_path}.save_firmware_settings", fake_save) + + response = firmware_client.put( + "/latest/firmware/settings", + json={"qr_wifi_scan_enabled": False}, + ) + + assert response.status_code == 200 + assert response.json() == {"qr_wifi_scan_enabled": False} + assert captured["qr_wifi_scan_enabled"] is False + + +def test_patch_firmware_setting_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), + ) + + saved: dict[str, bool] = {} + + def fake_save(settings): + saved["qr_wifi_scan_enabled"] = settings.qr_wifi_scan_enabled + + monkeypatch.setattr(f"{module_path}.save_firmware_settings", fake_save) + + response = firmware_client.patch( + "/latest/firmware/settings/qr_wifi_scan_enabled", + json={"value": False}, + ) + + assert response.status_code == 200 + assert response.json() == {"qr_wifi_scan_enabled": False} + assert saved["qr_wifi_scan_enabled"] is False + + +def test_patch_firmware_setting_unknown_key_returns_404(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), + ) + + response = firmware_client.patch( + "/latest/firmware/settings/not_a_real_key", + json={"value": False}, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Unknown firmware setting key: not_a_real_key" From 567c885a767cdc4a8d50774342286816f7613a88 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 30 Mar 2026 17:37:03 +0200 Subject: [PATCH 23/75] feat(firmware): add `enable_cloud` setting to firmware configuration and API - Introduced the `enable_cloud` boolean field in firmware configuration to manage cloud feature activation. - Updated firmware API endpoints (`GET`, `PUT`, `PATCH`) and tests to include `enable_cloud`. - Adjusted default firmware settings to include `enable_cloud: false`. --- openscan_firmware/config/firmware.py | 6 ++++++ settings/firmware/firmware_settings.json | 3 ++- tests/routers/test_firmware_router.py | 19 +++++++++++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/openscan_firmware/config/firmware.py b/openscan_firmware/config/firmware.py index 4e02aa5..545fa75 100644 --- a/openscan_firmware/config/firmware.py +++ b/openscan_firmware/config/firmware.py @@ -28,12 +28,18 @@ class FirmwareSettings(BaseModel): qr_wifi_scan_enabled: When True the firmware automatically starts the QR WiFi scan task on startup if no usable network connection is detected. + enable_cloud: When True the firmware enables cloud-facing features and + UX affordances. """ qr_wifi_scan_enabled: bool = Field( default=True, description="Automatically scan for WiFi QR codes on startup when no WiFi or Ethernet connection is active.", ) + enable_cloud: bool = Field( + default=False, + description="Enable integrations with OpenScan Cloud services.", + ) # Module-level singleton – loaded once, then reused. diff --git a/settings/firmware/firmware_settings.json b/settings/firmware/firmware_settings.json index a696c1a..cbdb120 100644 --- a/settings/firmware/firmware_settings.json +++ b/settings/firmware/firmware_settings.json @@ -1,3 +1,4 @@ { - "qr_wifi_scan_enabled": true + "qr_wifi_scan_enabled": true, + "enable_cloud": false } diff --git a/tests/routers/test_firmware_router.py b/tests/routers/test_firmware_router.py index 1228fd8..f7fd409 100644 --- a/tests/routers/test_firmware_router.py +++ b/tests/routers/test_firmware_router.py @@ -36,7 +36,10 @@ def test_get_firmware_settings_returns_current_settings(monkeypatch, firmware_cl response = firmware_client.get("/latest/firmware/settings") assert response.status_code == 200 - assert response.json() == {"qr_wifi_scan_enabled": True} + assert response.json() == { + "qr_wifi_scan_enabled": True, + "enable_cloud": False, + } def test_put_firmware_settings_replaces_payload(monkeypatch, firmware_client): @@ -45,17 +48,22 @@ 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 monkeypatch.setattr(f"{module_path}.save_firmware_settings", fake_save) response = firmware_client.put( "/latest/firmware/settings", - json={"qr_wifi_scan_enabled": False}, + json={"qr_wifi_scan_enabled": False, "enable_cloud": True}, ) assert response.status_code == 200 - assert response.json() == {"qr_wifi_scan_enabled": False} + assert response.json() == { + "qr_wifi_scan_enabled": False, + "enable_cloud": True, + } assert captured["qr_wifi_scan_enabled"] is False + assert captured["enable_cloud"] is True def test_patch_firmware_setting_updates_single_key(monkeypatch, firmware_client): @@ -80,7 +88,10 @@ def fake_save(settings): ) assert response.status_code == 200 - assert response.json() == {"qr_wifi_scan_enabled": False} + assert response.json() == { + "qr_wifi_scan_enabled": False, + "enable_cloud": False, + } assert saved["qr_wifi_scan_enabled"] is False From dd86c4c3e19d4714156c7d80b206680ec202fa7f Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 30 Mar 2026 17:52:24 +0200 Subject: [PATCH 24/75] chore(api): regenerated types for next endpoints --- scripts/openapi/openapi_next.json | 155 ++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index a5cdbba..fe21f39 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -1338,6 +1338,130 @@ } } }, + "/firmware/settings": { + "get": { + "tags": [ + "firmware" + ], + "summary": "Get Settings", + "description": "Return persisted firmware settings.", + "operationId": "get_settings", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + } + }, + "404": { + "description": "Not found" + } + } + }, + "put": { + "tags": [ + "firmware" + ], + "summary": "Replace Settings", + "description": "Replace the entire firmware settings payload.", + "operationId": "replace_settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/firmware/settings/{key}": { + "patch": { + "tags": [ + "firmware" + ], + "summary": "Update Setting", + "description": "Update a single firmware settings key.", + "operationId": "update_setting", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettingPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/projects/": { "get": { "tags": [ @@ -5023,6 +5147,37 @@ "title": "EndstopConfig", "description": "Configuration for a motor endstop.\n\nArgs:\n pin (int): GPIO pin number used for the endstop.\n angular_position (float): Angle at which the endstop is triggered (degrees).\n motor_name (str): Name of the assigned motor.\n pull_up (Optional[bool]): Whether to use a pull-up resistor (default: True).\n bounce_time (Optional[float]): Debounce time for the button in seconds (default: 0.005)." }, + "FirmwareSettingPatchRequest": { + "properties": { + "value": { + "title": "Value" + } + }, + "type": "object", + "required": [ + "value" + ], + "title": "FirmwareSettingPatchRequest" + }, + "FirmwareSettings": { + "properties": { + "qr_wifi_scan_enabled": { + "type": "boolean", + "title": "Qr Wifi Scan Enabled", + "description": "Automatically scan for WiFi QR codes on startup when no WiFi or Ethernet connection is active.", + "default": true + }, + "enable_cloud": { + "type": "boolean", + "title": "Enable Cloud", + "description": "Enable integrations with OpenScan Cloud services.", + "default": false + } + }, + "type": "object", + "title": "FirmwareSettings", + "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances." + }, "HTTPValidationError": { "properties": { "detail": { From 89e925f3d836d738ace87c9ee73b695d682c9883 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 30 Mar 2026 18:02:42 +0200 Subject: [PATCH 25/75] chore(routers): remove legacy `v0_6` and `v0_7` router files for cleanup --- openscan_firmware/main.py | 61 -- openscan_firmware/routers/v0_6/__init__.py | 0 openscan_firmware/routers/v0_6/cameras.py | 138 ---- openscan_firmware/routers/v0_6/cloud.py | 294 --------- openscan_firmware/routers/v0_6/develop.py | 99 --- openscan_firmware/routers/v0_6/device.py | 230 ------- .../routers/v0_6/focus_stacking.py | 68 -- openscan_firmware/routers/v0_6/gpio.py | 53 -- openscan_firmware/routers/v0_6/lights.py | 111 ---- openscan_firmware/routers/v0_6/motors.py | 120 ---- openscan_firmware/routers/v0_6/openscan.py | 226 ------- openscan_firmware/routers/v0_6/projects.py | 519 --------------- .../routers/v0_6/settings_utils.py | 80 --- openscan_firmware/routers/v0_6/tasks.py | 152 ----- openscan_firmware/routers/v0_7/__init__.py | 0 openscan_firmware/routers/v0_7/cameras.py | 138 ---- openscan_firmware/routers/v0_7/cloud.py | 331 ---------- openscan_firmware/routers/v0_7/develop.py | 99 --- openscan_firmware/routers/v0_7/device.py | 230 ------- .../routers/v0_7/focus_stacking.py | 68 -- openscan_firmware/routers/v0_7/gpio.py | 53 -- openscan_firmware/routers/v0_7/lights.py | 111 ---- openscan_firmware/routers/v0_7/motors.py | 120 ---- openscan_firmware/routers/v0_7/openscan.py | 226 ------- openscan_firmware/routers/v0_7/projects.py | 618 ------------------ .../routers/v0_7/settings_utils.py | 80 --- openscan_firmware/routers/v0_7/tasks.py | 152 ----- 27 files changed, 4377 deletions(-) delete mode 100644 openscan_firmware/routers/v0_6/__init__.py delete mode 100644 openscan_firmware/routers/v0_6/cameras.py delete mode 100644 openscan_firmware/routers/v0_6/cloud.py delete mode 100644 openscan_firmware/routers/v0_6/develop.py delete mode 100644 openscan_firmware/routers/v0_6/device.py delete mode 100644 openscan_firmware/routers/v0_6/focus_stacking.py delete mode 100644 openscan_firmware/routers/v0_6/gpio.py delete mode 100644 openscan_firmware/routers/v0_6/lights.py delete mode 100644 openscan_firmware/routers/v0_6/motors.py delete mode 100644 openscan_firmware/routers/v0_6/openscan.py delete mode 100644 openscan_firmware/routers/v0_6/projects.py delete mode 100644 openscan_firmware/routers/v0_6/settings_utils.py delete mode 100644 openscan_firmware/routers/v0_6/tasks.py delete mode 100644 openscan_firmware/routers/v0_7/__init__.py delete mode 100644 openscan_firmware/routers/v0_7/cameras.py delete mode 100644 openscan_firmware/routers/v0_7/cloud.py delete mode 100644 openscan_firmware/routers/v0_7/develop.py delete mode 100644 openscan_firmware/routers/v0_7/device.py delete mode 100644 openscan_firmware/routers/v0_7/focus_stacking.py delete mode 100644 openscan_firmware/routers/v0_7/gpio.py delete mode 100644 openscan_firmware/routers/v0_7/lights.py delete mode 100644 openscan_firmware/routers/v0_7/motors.py delete mode 100644 openscan_firmware/routers/v0_7/openscan.py delete mode 100644 openscan_firmware/routers/v0_7/projects.py delete mode 100644 openscan_firmware/routers/v0_7/settings_utils.py delete mode 100644 openscan_firmware/routers/v0_7/tasks.py diff --git a/openscan_firmware/main.py b/openscan_firmware/main.py index 7fb823c..8b6f30a 100644 --- a/openscan_firmware/main.py +++ b/openscan_firmware/main.py @@ -11,32 +11,6 @@ from openscan_firmware import __version__ from openscan_firmware.routers import websocket as websocket_router -from openscan_firmware.routers.v0_6 import ( - cameras as cameras_v0_6, - motors as motors_v0_6, - lights as lights_v0_6, - projects as projects_v0_6, - gpio as gpio_v0_6, - openscan as openscan_v0_6, - device as device_v0_6, - tasks as tasks_v0_6, - develop as develop_v0_6, - cloud as cloud_v0_6, - focus_stacking as focus_stacking_v0_6, -) -from openscan_firmware.routers.v0_7 import ( - cameras as cameras_v0_7, - motors as motors_v0_7, - lights as lights_v0_7, - projects as projects_v0_7, - gpio as gpio_v0_7, - openscan as openscan_v0_7, - device as device_v0_7, - tasks as tasks_v0_7, - develop as develop_v0_7, - cloud as cloud_v0_7, - focus_stacking as focus_stacking_v0_7, -) from openscan_firmware.routers.v0_8 import ( cameras as cameras_v0_8, motors as motors_v0_8, @@ -183,36 +157,6 @@ async def lifespan(app: FastAPI): # Create versioned sub-apps and mount them under /vX.Y and /latest # Root app intentionally has no docs; each sub-app exposes its own docs. -v0_6_ROUTERS = [ - cameras_v0_6.router, - motors_v0_6.router, - lights_v0_6.router, - projects_v0_6.router, - gpio_v0_6.router, - openscan_v0_6.router, - device_v0_6.router, - tasks_v0_6.router, - develop_v0_6.router, - cloud_v0_6.router, - websocket_router.router, - focus_stacking_v0_6.router, -] - -v0_7_ROUTERS = [ - cameras_v0_7.router, - motors_v0_7.router, - lights_v0_7.router, - projects_v0_7.router, - gpio_v0_7.router, - openscan_v0_7.router, - device_v0_7.router, - tasks_v0_7.router, - develop_v0_7.router, - cloud_v0_7.router, - focus_stacking_v0_7.router, - websocket_router.router, -] - v0_8_ROUTERS = [ cameras_v0_8.router, motors_v0_8.router, @@ -246,8 +190,6 @@ async def lifespan(app: FastAPI): ROUTERS_BY_VERSION: dict[str, list] = { - "0.6": v0_6_ROUTERS, - "0.7": v0_7_ROUTERS, "0.8": v0_8_ROUTERS, "next": next_ROUTERS, } @@ -311,10 +253,7 @@ def _use_route_names_as_operation_ids(app: FastAPI) -> None: # Supported API versions and latest alias # Define the supported API versions and explicitly set the latest alias. -# We keep 0.6 for backwards compatibility but expose v0.7 as the /latest endpoints. SUPPORTED_VERSIONS = [ - "0.6", - "0.7", "0.8", ] LATEST = "0.8" diff --git a/openscan_firmware/routers/v0_6/__init__.py b/openscan_firmware/routers/v0_6/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/openscan_firmware/routers/v0_6/cameras.py b/openscan_firmware/routers/v0_6/cameras.py deleted file mode 100644 index 7ce747d..0000000 --- a/openscan_firmware/routers/v0_6/cameras.py +++ /dev/null @@ -1,138 +0,0 @@ -import asyncio - -from fastapi import APIRouter, Body, HTTPException -from fastapi.responses import StreamingResponse, Response -from fastapi.encoders import jsonable_encoder -from pydantic import BaseModel - -from openscan_firmware.config.camera import CameraSettings -from openscan_firmware.models.camera import Camera, CameraType -from openscan_firmware.controllers.hardware.cameras.camera import get_all_camera_controllers, get_camera_controller -#from openscan_firmware.controllers.services.scans import get_active_scan_manager -from openscan_firmware.controllers.hardware.motors import get_all_motor_controllers -from .settings_utils import create_settings_endpoints - -router = APIRouter( - prefix="/cameras", - tags=["cameras"], - responses={404: {"description": "Not found"}}, -) - - -class CameraStatusResponse(BaseModel): - name: str - type: CameraType - busy: bool - settings: CameraSettings - - -@router.get("/", response_model=dict[str, CameraStatusResponse]) -async def get_cameras(): - """Get all cameras with their current status - - Returns: - dict[str, CameraStatusResponse]: A dictionary of camera name to a camera status object - """ - return { - name: controller.get_status() - for name, controller in get_all_camera_controllers().items() - } - - -@router.get("/{camera_name}", response_model=CameraStatusResponse) -async def get_camera(camera_name: str): - """Get a camera with its current status - - Args: - camera_name: The name of the camera to get the status of - - Returns: - CameraStatusResponse: A response object containing the status of the camera - """ - try: - return get_camera_controller(camera_name).get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.get("/{camera_name}/preview") -async def get_preview(camera_name: str): - """Get a camera preview stream in lower resolution - - Note: The preview is not rotated by orientation_flag and has to be rotated by client. - - Args: - camera_name: The name of the camera to get the preview stream from - - Returns: - StreamingResponse: A streaming response containing the preview stream - """ - controller = get_camera_controller(camera_name) - - async def generate(): - while True: - # Check if any motors are busy - motor_busy = any( - motor_controller.is_busy() - for motor_controller in get_all_motor_controllers().values() - ) - - - # Stop preview (wait) if motor or scan is busy, otherwise continue with 0.02s delay - # if motor_busy or scan_busy: - # await asyncio.sleep(0.1) # Small sleep to prevent busy waiting - # continue # Skip frame generation and yield - if not controller.is_busy(): - try: - frame = controller.preview() - except RuntimeError: - break - yield (b'--frame\r\n' - b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') - await asyncio.sleep(0.02) - - return StreamingResponse(generate(), media_type="multipart/x-mixed-replace;boundary=frame") - - -@router.get("/{camera_name}/photo") -async def get_photo(camera_name: str): - """Get a camera photo - - Args: - camera_name: The name of the camera to get the photo from - - Returns: - Response: A response containing the photo - """ - controller = get_camera_controller(camera_name) - try: - if not controller.is_busy(): - photo = await controller.photo_async() - return Response(content=photo.data.getvalue(), media_type="image/jpeg") - except Exception as e: - return Response(status_code=500, content=str(e)) - return Response( - status_code=409, - content="Camera is busy. If this is a bug, please restart the camera.", - ) - -@router.post("/{camera_name}/restart") -async def restart_camera(camera_name: str): - """Restart a camera - - Args: - camera_name: The name of the camera to restart - - Returns: - Response: A response containing the status code - """ - controller = get_camera_controller(camera_name) - controller.restart_camera() - return Response(status_code=200) - -create_settings_endpoints( - router=router, - resource_name="camera_name", - get_controller=get_camera_controller, - settings_model=CameraSettings -) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_6/cloud.py b/openscan_firmware/routers/v0_6/cloud.py deleted file mode 100644 index 94fa1a4..0000000 --- a/openscan_firmware/routers/v0_6/cloud.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Cloud-specific API endpoints exposing status, configuration and project helpers.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -import re - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from openscan_firmware.config.cloud import CloudSettings, mask_secret, set_cloud_settings -from openscan_firmware.controllers.services import cloud as cloud_service -from openscan_firmware.controllers.services.cloud import CloudServiceError -from openscan_firmware.controllers.services.cloud_settings import ( - get_active_source, - get_masked_active_settings, - save_persistent_cloud_settings, - set_active_source, - settings_file_exists, -) -from openscan_firmware.controllers.services.projects import get_project_manager, ProjectManager -from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager -from openscan_firmware.models.project import Project -from openscan_firmware.models.task import Task - -router = APIRouter( - prefix="/cloud", - tags=["cloud"], - responses={404: {"description": "Not found"}}, -) - -logger = logging.getLogger(__name__) -_TOKEN_PARAM_PATTERN = re.compile(r"(token=)([^&\s]+)") - - -class CloudSettingsResponse(BaseModel): - """Masked cloud settings including metadata.""" - - settings: dict[str, Any] | None = None - source: str | None = None - persisted: bool = False - - -class CloudStatusResponse(BaseModel): - """Aggregated view of the cloud backend status.""" - - status: dict[str, Any] | None = None - token_info: dict[str, Any] | None = None - queue_estimate: dict[str, Any] | None = None - settings: CloudSettingsResponse - message: str | None = None - - -class CloudProjectStatus(BaseModel): - """Local project enriched with cloud metadata and related tasks.""" - - project: Project - remote_project_name: str | None = None - remote_info: dict[str, Any] | None = None - tasks: list[Task] = Field(default_factory=list) - message: str | None = None - - -async def _fetch_remote_info(remote_name: str) -> tuple[dict[str, Any] | None, str | None]: - try: - data = await asyncio.to_thread(cloud_service.get_project_info, remote_name) - return data, None - except CloudServiceError as exc: # pragma: no cover - exercised in error test - return None, str(exc) - except Exception as exc: # pragma: no cover - defensive logging - logger.exception("Failed to fetch remote project info for %s", remote_name) - return None, str(exc) - - -def _collect_tasks_by_project() -> dict[str, list[Task]]: - task_manager = get_task_manager() - mapping: dict[str, list[Task]] = {} - for task in task_manager.get_all_tasks_info(): - if task.task_type != "cloud_upload_task" or not task.run_args: - continue - project_name = task.run_args[0] - mapping.setdefault(project_name, []).append(task) - return mapping - - -def _extract_remote_name_from_tasks(tasks: list[Task]) -> str | None: - for task in tasks: - result = task.result - if isinstance(result, dict) and "project" in result: - return str(result["project"]) - if hasattr(result, "project"): - return str(getattr(result, "project")) - return None - - -async def _build_project_status( - project: Project, - tasks_by_project: dict[str, list[Task]], - project_manager: ProjectManager, -) -> CloudProjectStatus: - remote_info = None - message = None - remote_name = project.cloud_project_name - tasks = tasks_by_project.get(project.name, []) - - if not remote_name: - remote_name = _extract_remote_name_from_tasks(tasks) - if remote_name: - try: - project_manager.mark_uploaded(project.name, True, remote_name) - refreshed = project_manager.get_project_by_name(project.name) - if refreshed is not None: - project = refreshed - except ValueError: - logger.warning( - "Failed to persist derived remote project name '%s' for '%s'", - remote_name, - project.name, - ) - elif tasks: - message = "Remote project name not available yet. Upload still running?" - - if remote_name: - fetched_info, fetch_message = await _fetch_remote_info(remote_name) - remote_info = fetched_info - if fetch_message: - message = f"{message} | {fetch_message}".strip(" |") if message else fetch_message - - return CloudProjectStatus( - project=project.model_copy(), - remote_project_name=remote_name, - remote_info=remote_info, - tasks=[task.model_copy() for task in tasks], - message=message, - ) - - -def _build_settings_response() -> CloudSettingsResponse: - return CloudSettingsResponse( - settings=get_masked_active_settings(), - source=get_active_source(), - persisted=settings_file_exists(), - ) - - -def _mask_tokens(text: str | None) -> str | None: - if not text: - return text - - return _TOKEN_PARAM_PATTERN.sub( - lambda match: f"{match.group(1)}{mask_secret(match.group(2))}", - text, - ) - - -@router.get("/status", response_model=CloudStatusResponse) -async def get_cloud_status() -> CloudStatusResponse: - """Return aggregated status information for the cloud backend. - - Returns: - CloudStatusResponse: A response object containing the status of the cloud backend - """ - - status = token_info = queue_estimate = None - messages: list[str] = [] - - try: - status = await asyncio.to_thread(cloud_service.get_status) - except CloudServiceError as exc: - messages.append(f"Status unavailable: {_mask_tokens(str(exc))}") - except Exception as exc: # pragma: no cover - defensive logging - logger.exception("Cloud status request failed") - messages.append(f"Status request failed: {_mask_tokens(str(exc))}") - - try: - token_info = await asyncio.to_thread(cloud_service.get_token_info) - except CloudServiceError as exc: - messages.append(f"Token info unavailable: {_mask_tokens(str(exc))}") - except Exception as exc: # pragma: no cover - defensive logging - logger.exception("Token info request failed") - messages.append(f"Token info request failed: {_mask_tokens(str(exc))}") - - try: - queue_estimate = await asyncio.to_thread(cloud_service.get_queue_estimate) - except CloudServiceError as exc: - messages.append(f"Queue estimate unavailable: {_mask_tokens(str(exc))}") - except Exception as exc: # pragma: no cover - defensive logging - logger.exception("Queue estimate request failed") - messages.append(f"Queue estimate request failed: {_mask_tokens(str(exc))}") - - return CloudStatusResponse( - status=status, - token_info=token_info, - queue_estimate=queue_estimate, - settings=_build_settings_response(), - message=_mask_tokens(" | ".join(messages)) if messages else None, - ) - - -@router.get("/settings", response_model=CloudSettingsResponse) -async def get_cloud_settings() -> CloudSettingsResponse: - """Return the masked active cloud configuration. - - Returns: - CloudSettingsResponse: A response object containing the masked active cloud configuration - """ - - return _build_settings_response() - - -@router.post("/settings", response_model=CloudSettingsResponse) -async def update_cloud_settings(new_settings: CloudSettings) -> CloudSettingsResponse: - """Persist and activate new cloud settings. - - Args: - new_settings: The new cloud settings to persist and activate - - Returns: - CloudSettingsResponse: A response object containing the masked active cloud configuration - """ - - set_cloud_settings(new_settings) - await asyncio.to_thread(save_persistent_cloud_settings, new_settings) - set_active_source("persistent") - return _build_settings_response() - - -@router.get("/projects", response_model=list[CloudProjectStatus]) -async def list_cloud_projects() -> list[CloudProjectStatus]: - """Return all local projects enriched with cloud metadata. - - Returns: - list[CloudProjectStatus]: A list of cloud project status objects - """ - - project_manager = get_project_manager() - tasks_by_project = _collect_tasks_by_project() - - statuses: list[CloudProjectStatus] = [] - for project in project_manager.get_all_projects().values(): - statuses.append(await _build_project_status(project, tasks_by_project, project_manager)) - return statuses - - -@router.get("/projects/{project_name}", response_model=CloudProjectStatus) -async def get_cloud_project(project_name: str) -> CloudProjectStatus: - """Return cloud details for a single local project. - - Args: - project_name: The name of the project to get the cloud details for - - Returns: - CloudProjectStatus: A response object containing the cloud project status - """ - - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if project is None: - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") - - tasks_by_project = _collect_tasks_by_project() - return await _build_project_status(project, tasks_by_project, project_manager) - - -@router.delete("/projects/{project_name}") -async def reset_cloud_project(project_name: str) -> dict[str, Any]: - """Reset the remote project and clear the local linkage. - - Args: - project_name: The name of the project to reset the remote project for - - Returns: - dict[str, Any]: A response object containing the result of the reset operation - """ - - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if project is None: - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") - - remote_name = project.cloud_project_name - if not remote_name: - raise HTTPException(status_code=404, detail=f"Project '{project_name}' has no recorded remote counterpart") - - try: - response = await asyncio.to_thread(cloud_service.reset_project, remote_name) - except CloudServiceError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - project_manager.mark_uploaded(project_name, False) - return {"project": project_name, "remote_project": remote_name, "response": response} diff --git a/openscan_firmware/routers/v0_6/develop.py b/openscan_firmware/routers/v0_6/develop.py deleted file mode 100644 index 384ebc8..0000000 --- a/openscan_firmware/routers/v0_6/develop.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Developer endpoints - -These may be removed or changed at any time. -""" - -import base64 -import time - -from fastapi import APIRouter, HTTPException, status, Response, Query - -from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager -from openscan_firmware.models.task import TaskStatus, Task - -from openscan_firmware.models.paths import PolarPoint3D -from openscan_firmware.controllers.hardware.motors import move_to_point - -from openscan_firmware.utils.paths import paths -from openscan_firmware.cli import DEFAULT_RELOAD_TRIGGER - - -router = APIRouter( - prefix="/develop", - tags=["develop"], - responses={404: {"description": "Not found"}}, -) - -@router.put("/scanner-position") -async def move_to_position(point: PolarPoint3D): - """Move Rotor and Turntable to a polar point""" - await move_to_point(point) - - -@router.post("/restart", status_code=status.HTTP_202_ACCEPTED) -async def restart_application() -> dict[str, str]: - """Trigger a Firmware reload by touching the reload sentinel file. - - Note: The application has to be started with the --reload-trigger option to enable this endpoint.""" - DEFAULT_RELOAD_TRIGGER.parent.mkdir(parents=True, exist_ok=True) - DEFAULT_RELOAD_TRIGGER.write_text(str(time.time()), encoding="utf-8") - # Ensure mtime changes even on file systems with coarse-grained timestamps - DEFAULT_RELOAD_TRIGGER.touch() - return {"detail": "Reload triggered"} - - -@router.get("/crop_image", summary="Run crop task and return visualization image", response_class=Response) -async def crop_image(camera_name: str, threshold: int | None = Query(default=None, ge=0, le=255)) -> Response: - """Run the crop task and return the visualization image with bounding boxes. - - Args: - camera_name: Name of the camera controller to use. - threshold: Optional Canny threshold passed to the analysis (tutorial uses a trackbar). If not set, defaults inside the task. - - Returns: - Response: JPEG image showing contours, rectangles and circles as detected by the task. - """ - task_manager = get_task_manager() - - # Start task - task = await task_manager.create_and_run_task("crop_task", camera_name, threshold=threshold) - - # Wait for completion (default TaskManager timeout is fine for demo; can be adjusted if needed) - try: - final_task = await task_manager.wait_for_task(task.id, timeout=120.0) - except Exception as e: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Waiting for task failed: {e}") - - if final_task.status != TaskStatus.COMPLETED: - detail = final_task.error or f"Task did not complete successfully (status={final_task.status})." - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail) - - result = final_task.result or {} - if not isinstance(result, dict) or "image_base64" not in result: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Task result does not contain an image.") - - try: - img_bytes = base64.b64decode(result["image_base64"]) - except Exception: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to decode image from task result.") - - return Response(content=img_bytes, media_type=result.get("mime", "image/jpeg")) - - - -@router.post("/hello-world-async", response_model=Task) -async def hello_world_async(total_steps: int, delay: float): - """Start the async hello world demo task.""" - - task_manager = get_task_manager() - - # Updated to explicit task_name with required _task suffix - task = await task_manager.create_and_run_task("hello_world_async_task", total_steps=total_steps, delay=delay) - return task - - -@router.get("/{method}", response_model=list[paths.CartesianPoint3D]) -async def get_path(method: paths.PathMethod, points: int): - """Get a list of coordinates by path method and number of points""" - return paths.get_path(method, points) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_6/device.py b/openscan_firmware/routers/v0_6/device.py deleted file mode 100644 index b085795..0000000 --- a/openscan_firmware/routers/v0_6/device.py +++ /dev/null @@ -1,230 +0,0 @@ -from fastapi import APIRouter, HTTPException, UploadFile, File -from pydantic import BaseModel, ValidationError -import os -import json -import tempfile -import shutil - -from openscan_firmware.models.scanner import ScannerDevice -from openscan_firmware.controllers import device - -from openscan_firmware.utils.dir_paths import resolve_settings_dir -from .cameras import CameraStatusResponse -from .motors import MotorStatusResponse -from .lights import LightStatusResponse - -router = APIRouter( - prefix="/device", - tags=["device"], - responses={404: {"description": "Not found"}}, -) - - -class DeviceConfigRequest(BaseModel): - config_file: str - -class DeviceStatusResponse(BaseModel): - name: str - model: str - shield: str - cameras: dict[str, CameraStatusResponse] - motors: dict[str, MotorStatusResponse] - lights: dict[str, LightStatusResponse] - initialized: bool - -class DeviceControlResponse(BaseModel): - success: bool - message: str - status: DeviceStatusResponse - - -@router.get("/info", response_model=DeviceStatusResponse) -async def get_device_info(): - """Get information about the device - - Returns: - dict: A dictionary containing information about the device - """ - try: - info = device.get_device_info() - return DeviceStatusResponse.model_validate(info) - except ValidationError as exc: - raise HTTPException( - status_code=503, - detail={ - "message": "Device configuration is not loaded.", - "errors": exc.errors(), - }, - ) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error getting device info: {str(e)}") - - -@router.get("/configurations") -async def list_config_files(): - """List all available device configuration files""" - try: - configs = device.get_available_configs() - return {"status": "success", "configs": configs} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error listing configuration files: {str(e)}") - - -@router.post("/configurations/", response_model=DeviceControlResponse) -async def add_config_json(config_data: ScannerDevice, filename: DeviceConfigRequest): - """Add a device configuration from a JSON object - - This endpoint accepts a JSON object with the device configuration, - validates it and saves it to a file. - - Args: - config_data: The device configuration to add - filename: The filename to save the configuration as - - Returns: - dict: A dictionary containing the status of the operation - """ - try: - # Create a temporary file to save the configuration - with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as temp_file: - # Convert the model to a dictionary and save it as JSON - config_dict = config_data.dict() - json.dump(config_dict, temp_file, indent=4) - temp_path = temp_file.name - - # Save to settings directory with a meaningful name - settings_dir = resolve_settings_dir("device") - os.makedirs(settings_dir, exist_ok=True) - - filename = f"{filename.config_file}.json" - target_path = os.path.join(settings_dir, filename) - - # Move the temporary file to the target path - shutil.move(temp_path, target_path) - - return DeviceControlResponse( - success=True, - message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error setting device configuration: {str(e)}") - - -@router.patch("/configurations/current", response_model=DeviceControlResponse) -async def save_device_config(): - """Save the current device configuration to a file - - This endpoint saves the current device configuration to device_config.json. - - Returns: - dict: A dictionary containing the status of the operation - """ - if device.save_device_config(): - return DeviceControlResponse( - success=True, - message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) - ) - else: - raise HTTPException(status_code=500, detail="Failed to save device configuration") - -@router.put("/configurations/current", response_model=DeviceControlResponse) -async def set_config_file(config_data: DeviceConfigRequest): - """Set the device configuration from a file and initialize hardware - - Args: - config_data: The device configuration to set - - Returns: - dict: A dictionary containing the status of the operation - """ - try: - # Get available configs - available_configs = device.get_available_configs() - - # Check if the config file exists in available configs - config_file = config_data.config_file - config_found = False - - # If it's just a filename (no path), try to find it in available configs - if not os.path.dirname(config_file): - for config in available_configs: - if config["filename"] == config_file: - config_file = config["path"] - config_found = True - break - else: - # Check if the full path exists - config_found = os.path.exists(config_file) - - if not config_found: - raise HTTPException( - status_code=404, - detail={ - "message": f"Config file not found: {config_data.config_file}", - "available_configs": available_configs - } - ) - - # Set device config - if await device.set_device_config(config_file): - return DeviceControlResponse( - success=True, - message="Configuration loaded successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) - ) - else: - raise HTTPException(status_code=500, detail="Failed to load device configuration") - - except HTTPException: - # Re-raise HTTP exceptions to preserve status code and detail - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error setting device configuration: {str(e)}") - - -@router.post("/configurations/current/initialize", response_model=DeviceControlResponse) -async def reinitialize_hardware(detect_cameras: bool = False): - """Reinitialize hardware components - - This can be used in case of a hardware failure or to reload the hardware components. - - Args: - detect_cameras: Whether to detect cameras - - Returns: - dict: A dictionary containing the status of the operation - """ - try: - await device.initialize(detect_cameras=detect_cameras) - return DeviceControlResponse( - success=True, - message="Hardware reinitialized successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) - ) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error reloading hardware: {str(e)}") - - -@router.post("/reboot", response_model=bool) -def reboot(save_config: bool = False): - """Reboot system and optionally save config. - - Args: - save_config: Whether to save the current configuration before rebooting - """ - device.reboot(save_config) - return True - - -@router.post("/shutdown", response_model=bool) -def shutdown(save_config: bool = False) -> None: - """Shutdown system and optionally save config. - - Args: - save_config: Whether to save the current configuration before shutting down - """ - device.shutdown(save_config) - return True \ No newline at end of file diff --git a/openscan_firmware/routers/v0_6/focus_stacking.py b/openscan_firmware/routers/v0_6/focus_stacking.py deleted file mode 100644 index 1370521..0000000 --- a/openscan_firmware/routers/v0_6/focus_stacking.py +++ /dev/null @@ -1,68 +0,0 @@ -"""API endpoints for managing focus stacking tasks.""" -from __future__ import annotations - -from fastapi import APIRouter, HTTPException - -from openscan_firmware.controllers.services import focus_stacking as focus_service -from openscan_firmware.models.task import Task - -router = APIRouter(prefix="/projects", tags=["focus_stacking"]) - - -@router.post("/{project_name}/scans/{scan_index:int}/focus-stacking/start", response_model=Task) -async def start_focus_stacking(project_name: str, scan_index: int) -> Task: - """Start focus stacking for a scan.""" - try: - return await focus_service.start_focus_stacking(project_name, scan_index) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 - raise HTTPException(status_code=500, detail=str(exc)) from exc - - -@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/pause", response_model=Task) -async def pause_focus_stacking(project_name: str, scan_index: int) -> Task: - """Pause an active focus stacking task.""" - try: - task = await focus_service.pause_focus_stacking(project_name, scan_index) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 - raise HTTPException(status_code=500, detail=str(exc)) from exc - - if task is None: - raise HTTPException(status_code=409, detail="Focus stacking is not running") - - return task - - -@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/resume", response_model=Task) -async def resume_focus_stacking(project_name: str, scan_index: int) -> Task: - """Resume a paused focus stacking task.""" - try: - task = await focus_service.resume_focus_stacking(project_name, scan_index) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 - raise HTTPException(status_code=500, detail=str(exc)) from exc - - if task is None: - raise HTTPException(status_code=409, detail="Focus stacking is not paused") - - return task - - -@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/cancel", response_model=Task) -async def cancel_focus_stacking(project_name: str, scan_index: int) -> Task: - """Cancel an active focus stacking task.""" - try: - task = await focus_service.cancel_focus_stacking(project_name, scan_index) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 - raise HTTPException(status_code=500, detail=str(exc)) from exc - - if task is None: - raise HTTPException(status_code=409, detail="Focus stacking is not running") - - return task diff --git a/openscan_firmware/routers/v0_6/gpio.py b/openscan_firmware/routers/v0_6/gpio.py deleted file mode 100644 index a4d5a0c..0000000 --- a/openscan_firmware/routers/v0_6/gpio.py +++ /dev/null @@ -1,53 +0,0 @@ -from fastapi import APIRouter - -from openscan_firmware.controllers.hardware import gpio - -router = APIRouter( - prefix="/gpio", - tags=["gpio"], - responses={404: {"description": "Not found"}}, -) - - -@router.get("/") -async def get_pins() -> dict[str, list[int]]: - """Get all initialized GPIO pins - - Returns: - dict[str, list[int]]: A dictionary of initialized output pins and buttons - """ - return gpio.get_initialized_pins() - - -@router.get("/{pin_id}", response_model=bool) -async def get_pin(pin_id: int): - """Get output value of a specific GPIO pin - - Args: - pin_id: The ID (int) of the GPIO pin to get the value of - - Returns: - bool: The output value of the GPIO pin - """ - return gpio.get_output_pin(pin_id) - - -@router.patch("/{pin_id}") -async def set_pin(pin_id: int, status: bool): - """Set GPIO pin output value - - Args: - 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) - - -@router.patch("/{pin_id}/toggle") -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) diff --git a/openscan_firmware/routers/v0_6/lights.py b/openscan_firmware/routers/v0_6/lights.py deleted file mode 100644 index 836dcbe..0000000 --- a/openscan_firmware/routers/v0_6/lights.py +++ /dev/null @@ -1,111 +0,0 @@ -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from openscan_firmware.controllers.hardware.lights import get_light_controller, get_all_light_controllers -from openscan_firmware.config.light import LightConfig -from .settings_utils import create_settings_endpoints - -router = APIRouter( - prefix="/lights", - tags=["lights"], - responses={404: {"description": "Not found"}}, -) - -class LightStatusResponse(BaseModel): - name: str - is_on: bool - settings: LightConfig - - -@router.get("/", response_model=dict[str, LightStatusResponse]) -async def get_lights(): - """Get all lights with their current status - - Returns: - dict[str, LightStatusResponse]: A dictionary of light name to a light status object - """ - return { - name: controller.get_status() - for name, controller in get_all_light_controllers().items() - } - - -@router.get("/{light_name}", response_model=LightStatusResponse) -async def get_light(light_name: str): - """Get light status - - Args: - light_name: The name of the light to get the status of - - Returns: - LightStatusResponse: A response object containing the status of the light - """ - try: - return get_light_controller(light_name).get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.patch("/{light_name}/turn_on", response_model=LightStatusResponse) -async def turn_on_light(light_name: str): - """Turn on light - - Args: - light_name: The name of the light to turn on - - Returns: - LightStatusResponse: A response object containing the status of the light after the turn on operation - """ - try: - controller = get_light_controller(light_name) - controller.turn_on() - return controller.get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.patch("/{light_name}/turn_off", response_model=LightStatusResponse) -async def turn_off_light(light_name: str): - """Turn of light - - Args: - light_name: The name of the light to turn off - - Returns: - LightStatusResponse: A response object containing the status of the light after the turn off operation - """ - try: - controller = get_light_controller(light_name) - controller.turn_off() - return controller.get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.patch("/{light_name}/toggle", response_model=LightStatusResponse) -async def toggle_light(light_name: str): - """Toggle light on or off - - Args: - light_name: The name of the light to toggle - - Returns: - LightStatusResponse: A response object containing the status of the light after the toggle operation - """ - try: - controller = get_light_controller(light_name) - if controller.is_on: - controller.turn_off() - else: - controller.turn_on() - return controller.get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -create_settings_endpoints( - router=router, - resource_name="light_name", - get_controller=get_light_controller, - settings_model=LightConfig -) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_6/motors.py b/openscan_firmware/routers/v0_6/motors.py deleted file mode 100644 index 66e03b2..0000000 --- a/openscan_firmware/routers/v0_6/motors.py +++ /dev/null @@ -1,120 +0,0 @@ -import asyncio - -from fastapi import APIRouter, Body, HTTPException -from pydantic import BaseModel -from typing import Optional - -from openscan_firmware.config.motor import MotorConfig -from openscan_firmware.controllers.hardware.motors import get_motor_controller, get_all_motor_controllers -from openscan_firmware.models.paths import PolarPoint3D -from .settings_utils import create_settings_endpoints - -router = APIRouter( - prefix="/motors", - tags=["motors"], - responses={404: {"description": "Not found"}}, -) - -class MotorStatusResponse(BaseModel): - name: str - angle: float - busy: bool - target_angle: Optional[float] - settings: MotorConfig - calibrated: bool - endstop: Optional[dict] - - -@router.get("/", response_model=dict[str, MotorStatusResponse]) -async def get_motors(): - """Get all motors with their current status - - Returns: - dict[str, MotorStatusResponse]: A dictionary of motor name to a motor status object - """ - return { - name: controller.get_status() - for name, controller in get_all_motor_controllers().items() - } - - -@router.get("/{motor_name}", response_model=MotorStatusResponse) -async def get_motor(motor_name: str): - """Get motor status - - Args: - motor_name: The name of the motor to get the status of - - Returns: - MotorStatusResponse: A response object containing the status of the motor - """ - try: - return get_motor_controller(motor_name).get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.put("/{motor_name}/angle", response_model=MotorStatusResponse) -async def move_motor_to_angle(motor_name: str, degrees: float): - """Move motor to absolute position - - Args: - motor_name: The name of the motor to move - degrees: Number of degrees to move - - Returns: - MotorStatusResponse: A response object containing the status of the motor after the move - """ - - controller = get_motor_controller(motor_name) - await controller.move_to(degrees) - return controller.get_status() - - -@router.patch("/{motor_name}/angle", response_model=MotorStatusResponse) -async def move_motor_by_degree(motor_name: str, degrees: float = Body(embed=True)): - """Move motor by degrees - - Args: - motor_name: The name of the motor to move - degrees: Number of degrees to move - - Returns: - MotorStatusResponse: A response object containing the status of the motor after the move - """ - controller = get_motor_controller(motor_name) - await controller.move_degrees(degrees) - return controller.get_status() - - -@router.put("/{motor_name}/endstop-calibration", response_model=MotorStatusResponse) -async def move_motor_to_home_position(motor_name: str): - """Move motor to home position - - This endpoint moves the motor to the home position using the endstop calibration. - - Args: - motor_name: The name of the motor to move to the home position - - Returns: - MotorStatusResponse: A response object containing the status of the motor after the move - """ - controller = get_motor_controller(motor_name) - if controller.endstop and not controller.is_busy(): - # Trigger Endstop - controller.model.angle = 0 - await controller.move_degrees(140) - # Wait for Endstop and move motor to home position - await asyncio.sleep(3) - await controller.move_to(90) - return controller.get_status() - else: - raise HTTPException(status_code=422, detail="No endstop configured or motor is busy!") - - -create_settings_endpoints( - router=router, - resource_name="motor_name", - get_controller=get_motor_controller, - settings_model=MotorConfig -) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_6/openscan.py b/openscan_firmware/routers/v0_6/openscan.py deleted file mode 100644 index 213e861..0000000 --- a/openscan_firmware/routers/v0_6/openscan.py +++ /dev/null @@ -1,226 +0,0 @@ -import asyncio -from fastapi import APIRouter, Body, HTTPException -from fastapi.responses import StreamingResponse - -from typing import Tuple - -from openscan_firmware import __version__ -from openscan_firmware.controllers.device import get_scanner_model -from typing import AsyncGenerator -from starlette.responses import FileResponse -from starlette.background import BackgroundTask -from openscan_firmware.config.logger import DEFAULT_LOGS_PATH, flush_memory_handlers -import os -import zipfile -import glob -from tempfile import NamedTemporaryFile -from datetime import datetime -from collections import deque - -router = APIRouter( - prefix="", - tags=["openscan"], - responses={404: {"description": "Not found"}}, -) - -@router.get("/") -async def get_software_info(): - """Get information about the scanner software""" - return {"model": get_scanner_model(), - "firmware_version": __version__} - - - -# ------------------------- -# Log utilities and endpoints -# ------------------------- - -def _read_last_lines(file_path: str, max_lines: int) -> str: - """Return the last max_lines from file as a single string. - - Args: - file_path: Path to the file to read. - max_lines: Maximum number of lines to return. - - Returns: - The tail content joined by newlines. - """ - if max_lines <= 0: - return "" - - lines = deque(maxlen=max_lines) - try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - for line in f: - lines.append(line.rstrip("\n")) - except FileNotFoundError: - raise HTTPException(status_code=404, detail="Log file not found") - return "\n".join(lines) + ("\n" if lines else "") - - -async def _follow_file(file_path: str, poll_interval: float = 1) -> AsyncGenerator[bytes, None]: - """Async generator that tails a file and yields new lines as bytes. - - Args: - file_path: Path to the file to follow. - poll_interval: Sleep interval between checks for new data. - - Yields: - Bytes chunks representing new lines appended to the file. - """ - f = None - last_inode = None - try: - while True: - # Open file if not open yet (or after rotation) - if f is None: - try: - f = open(file_path, "r", encoding="utf-8", errors="ignore") - f.seek(0, os.SEEK_END) - last_inode = os.fstat(f.fileno()).st_ino - except FileNotFoundError: - # File might not exist yet or just rotated; retry shortly - await asyncio.sleep(poll_interval) - continue - - line = f.readline() - if line: - yield line.encode("utf-8", errors="ignore") - continue - - # No new line yet: flush buffered handlers to force write-through - try: - flush_memory_handlers() - except Exception: - # Non-fatal; keep streaming - pass - - # Detect rotation by inode change or missing file - try: - current_inode = os.stat(file_path).st_ino - if current_inode != last_inode: - try: - f.close() - finally: - f = None - continue - except FileNotFoundError: - try: - f.close() - finally: - f = None - await asyncio.sleep(poll_interval) - continue - - await asyncio.sleep(poll_interval) - except asyncio.CancelledError: - if f is not None: - try: - f.close() - except Exception: - pass - return - except FileNotFoundError: - raise HTTPException(status_code=404, detail="Log file not found") - - -@router.get("/logs/tail") -async def tail_logs(format: str = "text", lines: int = 200, follow: bool = False, poll_interval: float = 1): - """Show or follow current logs. - - When follow=false (default), returns the last N lines of the selected log. - When follow=true (text mode only!), streams new lines as they are written (like `tail -f`). - - Args: - format: "text" for openscan_firmware.log, "json" for openscan_detailed_log.json. - lines: Number of last lines to return initially. - follow: If true, stream appended log lines in text mode. - poll_interval: Poll interval (seconds) when following in text mode. - - Returns: - A response with the requested log content. - """ - flush_memory_handlers() # Ensure buffered records are flushed to disk - - if format.lower() == "json": - log_file = os.path.join(DEFAULT_LOGS_PATH, "openscan_detailed_log.json") - media_type = "application/json" - else: - log_file = os.path.join(DEFAULT_LOGS_PATH, "openscan_firmware.log") - media_type = "text/plain" - - if not os.path.exists(log_file): - raise HTTPException(status_code=404, detail="Log file not found") - - if follow and format.lower() == "text": - # Send last N lines first, then follow new lines - async def stream() -> AsyncGenerator[bytes, None]: - head = _read_last_lines(log_file, lines).encode("utf-8") - if head: - yield head - async for chunk in _follow_file(log_file, poll_interval=poll_interval): - yield chunk - headers = { - "Cache-Control": "no-cache", - "Pragma": "no-cache", - "X-Accel-Buffering": "no", # disable nginx buffering if present - "Connection": "keep-alive", - } - return StreamingResponse(stream(), media_type=media_type, headers=headers) - - # One-shot tail of last N lines - content = _read_last_lines(log_file, lines) - return StreamingResponse(iter([content.encode("utf-8")]), media_type=media_type) - - -@router.get("/logs/archive") -async def download_logs_archive(): - """Create and download a ZIP archive containing all log files. - - The archive includes rotated files for both text and JSON logs, using - deflate compression for reasonable size to share e.g. via email. - - Returns: - FileResponse serving the generated ZIP. The temp file is deleted after send. - """ - flush_memory_handlers() # Flush buffered logs before archiving - - patterns = [ - os.path.join(DEFAULT_LOGS_PATH, "openscan_firmware.log*"), - os.path.join(DEFAULT_LOGS_PATH, "openscan_detailed_log.json*"), - ] - files = [] - for pat in patterns: - files.extend(glob.glob(pat)) - files = [f for f in files if os.path.isfile(f)] - - if not files: - raise HTTPException(status_code=404, detail="No log files found to archive") - - # Create a temporary zip file and return it; delete after response is sent - tmp = NamedTemporaryFile(delete=False, suffix=".zip") - tmp_path = tmp.name - tmp.close() - - # Use maximum compression level for smaller email-friendly files - compression = zipfile.ZIP_DEFLATED - compresslevel = 9 # Python 3.7+ supports compresslevel for ZipFile - with zipfile.ZipFile(tmp_path, mode="w", compression=compression, compresslevel=compresslevel) as zf: - for fpath in files: - arcname = os.path.basename(fpath) - zf.write(fpath, arcname=arcname) - - filename = f"openscan_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip" - - def _cleanup(path: str) -> None: - try: - os.remove(path) - except OSError: - pass - - return FileResponse( - tmp_path, - media_type="application/zip", - filename=filename, - background=BackgroundTask(_cleanup, tmp_path), - ) diff --git a/openscan_firmware/routers/v0_6/projects.py b/openscan_firmware/routers/v0_6/projects.py deleted file mode 100644 index 2776ea8..0000000 --- a/openscan_firmware/routers/v0_6/projects.py +++ /dev/null @@ -1,519 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query -from fastapi.encoders import jsonable_encoder -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -import pathlib -from typing import Optional, List -import asyncio -import os -import json -from datetime import datetime -import logging - - -from openscan_firmware.controllers.hardware.cameras.camera import get_all_camera_controllers, get_camera_controller -from openscan_firmware.controllers.services import projects, cloud -import openscan_firmware.controllers.services.scans as scans #import start_scan, cancel_scan, pause_scan, resume_scan -from openscan_firmware.models.project import Project -from openscan_firmware.config.scan import ScanSetting -from openscan_firmware.models.scan import Scan -from openscan_firmware.models.task import Task, TaskStatus - -from openscan_firmware.controllers.services.projects import get_project_manager -from openscan_firmware.controllers.services.tasks.task_manager import task_manager, get_task_manager - -router = APIRouter( - prefix="/projects", - tags=["projects"], - responses={404: {"description": "Not found"}}, -) - -logger = logging.getLogger(__name__) - -class DeleteResponse(BaseModel): - success: bool - message: str - deleted: list[str] - - -@router.get("/", response_model=dict[str, Project]) -async def get_projects(): - """Get all projects with serialized data - - Returns: - dict[str, Project]: A dictionary of project name to a project object - """ - project_manager = get_project_manager() - projects_dict = project_manager.get_all_projects() - return projects_dict - -@router.get("/{project_name}", response_model=Project) -async def get_project(project_name: str): - """Get a project - - Args: - project_name: The name of the project to get - - Returns: - Project: The project object if found, None if not - """ - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - return project - - -@router.post("/{project_name}", response_model=Project) -async def new_project(project_name: str, project_description: Optional[str] = ""): - """Create a new project - - Args: - project_name: The name of the project to create - project_description: Optional description for the project - - Returns: - Project: The newly created project if successful, None if not - """ - try: - project_manager = get_project_manager() - return project_manager.add_project(project_name, project_description) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.post("/{project_name}/scan", response_model=Task) -async def add_scan_with_description(project_name: str, - camera_name: str, - scan_settings: ScanSetting, - scan_description: Optional[str] = "") -> Task: - """Add a new scan to a project and return the created Task - - Args: - project_name: The name of the project to add the scan to - camera_name: The name of the camera to use for the scan - scan_settings: The settings for the scan - scan_description: Optional description for the scan - - Returns: - Task: The Task representing the started scan - """ - camera_controller = get_camera_controller(camera_name) - project_manager = get_project_manager() - - try: - scan = project_manager.add_scan(project_name, camera_controller, scan_settings, scan_description) - task = await scans.start_scan(project_manager, scan, camera_controller) - return task - - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to start scan: {e}") - - - -# Cloud uploads -------------------------------------------------------------- - - -@router.post("/{project_name}/upload", response_model=Task) -async def upload_project_to_cloud(project_name: str, token_override: Optional[str] = None) -> Task: - """Schedule an asynchronous cloud upload for a project. - - Args: - project_name: The name of the project - token_override: Optional token override - - Returns: - Task: The TaskManager model describing the scheduled upload - """ - try: - task = await cloud.upload_project(project_name, token=token_override) - except cloud.CloudServiceError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return task - - -@router.post("/{project_name}/download", response_model=Task) -async def download_project_from_cloud( - project_name: str, - token_override: Optional[str] = None, - remote_project: Optional[str] = None, -) -> Task: - """Schedule an asynchronous cloud download for a project's reconstruction. - - Args: - project_name: The name of the project - token_override: Optional token override - remote_project: Optional explicit remote project name, defaults to the stored cloud name - - Returns: - Task: The TaskManager model describing the scheduled download - """ - try: - task = await cloud.download_project( - project_name, - token=token_override, - remote_project=remote_project, - ) - except cloud.CloudServiceError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return task - - -@router.delete("/{project_name}/{scan_index}/photos", response_model=DeleteResponse) -async def delete_photos(project_name: str, scan_index: int, photo_filenames: list[str]): - """Delete photos from a scan in a project - - Args: - project_name: The name of the project - scan_index: The index of the scan - photo_filenames: A list of photo filenames to delete - - Returns: - True if the photos were deleted successfully, False otherwise - """ - project_manager = get_project_manager() - try: - scan = project_manager.get_scan_by_index(project_name, scan_index) - project_manager.delete_photos(scan, photo_filenames) - return DeleteResponse( - success=True, - message="Photos deleted successfully", - deleted=photo_filenames - ) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/{project_name}", response_model=DeleteResponse) -async def delete_project(project_name: str): - """Delete a project - - Args: - project_name: The name of the project to delete - - Returns: - DeleteResponse: A response object containing the result of the deletion - """ - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - try: - project_manager.delete_project(project) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - return DeleteResponse( - success=True, - message="Project deleted successfully", - deleted=[project_name] - ) - - -@router.delete("/{project_name}/scans/{scan_index}", response_model=DeleteResponse) -async def delete_scan(project_name: str, scan_index: int): - """Delete a scan from a project - - Args: - project_name: The name of the project - scan_index: The index of the scan to delete - - Returns: - DeleteResponse: Result of the deletion operation - """ - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - try: - project_manager.delete_scan(scan) - return DeleteResponse( - success=True, - message="Scan deleted successfully", - deleted=[f"{project_name}:scan{scan_index:02d}"] - ) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - -@router.get("/{project_name}/scans/{scan_index:int}/status", response_model=Task) -async def get_scan_status(project_name: str, scan_index: int): - """Get the current task for a scan - - Args: - project_name: The name of the project - scan_index: The index of the scan to get the status of - - Returns: - Task: The task representing the scan execution - """ - try: - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - if not scan.task_id: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} has no associated task") - - task_manager_instance = get_task_manager() - task = task_manager_instance.get_task_info(scan.task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {scan.task_id} not found for scan {scan_index}") - - return task - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.patch("/{project_name}/scans/{scan_index:int}/pause", response_model=Task) -async def pause_scan(project_name: str, scan_index: int) -> Task: - """Pause a running scan and return the updated Task - - Args: - project_name: The name of the project - scan_index: The index of the scan to pause - - Returns: - Task: The updated task state - """ - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - - task = await scans.pause_scan(scan) - if task is None: - raise HTTPException(status_code=409, detail="Scan is not running or cannot be paused.") - - return task - - -@router.patch("/{project_name}/scans/{scan_index:int}/resume", response_model=Task) -async def resume_scan(project_name: str, scan_index: int, camera_name: str) -> Task: - """Resume a paused, cancelled or failed scan and return the resulting Task - - Args: - project_name: The name of the project - scan_index: The index of the scan to resume - camera_name: The name of the camera to use for the scan - - Returns: - Task: The resumed or restarted task - """ - try: - - camera_controller = get_camera_controller(camera_name) - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - - task_manager_instance = get_task_manager() - existing_task = task_manager_instance.get_task_info(scan.task_id) if scan.task_id else None - - if existing_task and existing_task.status == TaskStatus.PAUSED: - task = await scans.resume_scan(scan) - elif not existing_task or existing_task.status in [ - TaskStatus.COMPLETED, - TaskStatus.CANCELLED, - TaskStatus.ERROR, - TaskStatus.INTERRUPTED, - ]: - task = await scans.start_scan( - project_manager, - scan, - camera_controller, - start_from_step=scan.current_step - ) - else: - raise HTTPException(status_code=409, detail=f"Scan cannot be resumed from its current state: {existing_task.status.value}") - - if task is None: - raise HTTPException(status_code=409, detail="Failed to resume scan task.") - - return task - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.patch("/{project_name}/scans/{scan_index:int}/cancel", response_model=Task) -async def cancel_scan(project_name: str, scan_index: int) -> Task: - """Cancel a running scan and return the resulting Task - - Args: - project_name: The name of the project - scan_index: The index of the scan to cancel - - Returns: - Task: The updated task state - """ - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - - try: - task = await scans.cancel_scan(scan) - except HTTPException: - raise - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc - - if task is None: - raise HTTPException(status_code=409, detail="Scan is not running or cannot be cancelled.") - - return task - - -def _serialize_project_for_zip(project: Project) -> str: - """Serialize a project to JSON for inclusion in a ZIP file - - Args: - project: Project to serialize - - Returns: - str: JSON string representation of the project - """ - # Use jsonable_encoder to convert the project to a dict - project_dict = jsonable_encoder(project) - - # Convert to JSON string - return json.dumps(project_dict, indent=2) - - -@router.get("/{project_name}/zip") -async def download_project(project_name: str): - """Download a project as a ZIP file stream - - This endpoint streams the entire project directory as a ZIP file, - including all scans, photos, and metadata. - - Args: - project_name: Name of the project to download - - Returns: - StreamingResponse: ZIP file stream - """ - try: - # Import zipstream-ng - from zipstream import ZipStream - project_manager = get_project_manager() - # Get project - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - # Create ZipStream from project path - zs = ZipStream.from_path(project.path) - - # Add project metadata - zs.add(_serialize_project_for_zip(project), "project_metadata.json") - - # Return streaming response - headers = { - "Content-Disposition": f"attachment; filename={project_name}.zip", - } - if getattr(zs, "last_modified", None): - headers["Last-Modified"] = str(zs.last_modified) - - response = StreamingResponse( - zs, - media_type="application/zip", - headers=headers, - ) - - return response - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{project_name}/scans/zip") -async def download_scans(project_name: str, scan_indices: List[int] = Query(None)): - """Download selected scans from a project as a ZIP file stream - - This endpoint streams selected scans from a project as a ZIP file. - If no scan indices are provided, all scans will be included. - - Args: - project_name: Name of the project - scan_indices: List of scan indices to include in the ZIP file - - Returns: - StreamingResponse: ZIP file stream - """ - try: - from zipstream import ZipStream - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - zs = ZipStream(sized=True) - zs.comment = f"OpenScan3 Project: {project_name} - Generated on {datetime.now().isoformat()}" - - # Build filename based on what's being downloaded - if scan_indices: - if len(scan_indices) == 1: - filename = f"{project_name}_scan{scan_indices[0]:02d}.zip" - else: - scan_nums = "_".join(str(i) for i in sorted(scan_indices)) - filename = f"{project_name}_scans_{scan_nums}.zip" - - for scan_index in scan_indices: - try: - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - logger.error(f"Scan with index {scan_index} not found") - continue - scan_dir = os.path.join(project.path, f"scan{scan_index:02d}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan{scan_index:02d}") - except Exception as e: - logger.error(f"Failed to add scan {scan_index} to zip: {e}") - continue - else: - filename = f"{project_name}_all_scans.zip" - for scan_id, scan in project.scans.items(): - scan_dir = os.path.join(project.path, f"scan_{scan.index}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan_{scan.index}") - - zs.add(_serialize_project_for_zip(project), "project_metadata.json") - - headers = { - "Content-Disposition": f"attachment; filename={filename}", - } - if getattr(zs, "last_modified", None): - headers["Last-Modified"] = str(zs.last_modified) - - response = StreamingResponse( - zs, - media_type="application/zip", - headers=headers, - ) - return response - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"") - except Exception as e: - print(e) - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{project_name}/scans/{scan_index:int}", response_model=Scan) -async def get_scan(project_name: str, scan_index: int): - """Get Scan by project and index - - Args: - project_name: The name of the project - scan_index: The index of the scan - - Returns: - Scan: The scan object - """ - try: - project_manager = get_project_manager() - return project_manager.get_scan_by_index(project_name, scan_index) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) diff --git a/openscan_firmware/routers/v0_6/settings_utils.py b/openscan_firmware/routers/v0_6/settings_utils.py deleted file mode 100644 index 9d8f10f..0000000 --- a/openscan_firmware/routers/v0_6/settings_utils.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Any, Callable, Dict, Type, TypeVar -from fastapi import APIRouter, Body, HTTPException -from pydantic import BaseModel - -T = TypeVar('T', bound=BaseModel) - - -def create_settings_endpoints( - router: APIRouter, - resource_name: str, - get_controller: Callable[[str], Any], - settings_model: Type[T] -) -> Dict[str, Callable[..., Any]]: - """ - Create standardized settings endpoints for a resource. - - Args: - router: The FastAPI router to add endpoints to - resource_name: Name of the resource (e.g., 'camera', 'motor') - get_controller: Function to get the controller by name - settings_model: Pydantic model for the settings - """ - - path = "/{name}/settings" - - @router.get( - path, - response_model=settings_model, - name=f"get_{resource_name}_settings", - ) - async def get_settings(name: str) -> T: - """Get settings for a specific resource""" - controller = get_controller(name) - return controller.settings.model - - @router.put( - path, - response_model=settings_model, - name=f"replace_{resource_name}_settings", - ) - async def replace_settings(name: str, settings: settings_model) -> T: - """Replace all settings for a specific resource""" - controller = get_controller(name) - try: - controller.settings.replace(settings) - return controller.settings.model - except Exception as e: - raise HTTPException(status_code=422, detail=str(e)) - - - @router.patch( - path, - response_model=settings_model, - name=f"update_{resource_name}_settings", - ) - async def update_settings( - name: str, - settings: Dict[str, Any] = Body(..., examples=[{"some_setting": 123}]) - ) -> T: - """Update one or more specific settings for a resource - - Args: - name: The name of the resource to update settings for - settings: A dictionary of settings to update - - Returns: - The updated settings for the resource - """ - controller = get_controller(name) - try: - controller.settings.update(**settings) - return controller.settings.model - except Exception as e: - raise HTTPException(status_code=422, detail=str(e)) - - return { - "get_settings": get_settings, - "replace_settings": replace_settings, - "update_settings": update_settings - } \ No newline at end of file diff --git a/openscan_firmware/routers/v0_6/tasks.py b/openscan_firmware/routers/v0_6/tasks.py deleted file mode 100644 index 0c193a0..0000000 --- a/openscan_firmware/routers/v0_6/tasks.py +++ /dev/null @@ -1,152 +0,0 @@ -from typing import List, Any, Dict - -from fastapi import APIRouter, HTTPException, status, Body - -from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager -from openscan_firmware.models.task import Task, TaskStatus - - -router = APIRouter( - prefix="/tasks", - tags=["tasks"], - responses={404: {"description": "Not found"}}, -) - - -@router.get("/", response_model=List[Task]) -async def get_all_tasks(): - """ - Retrieve a list of all tasks known to the task manager. - - Returns: - List[Task]: A list of all tasks known to the task manager. - """ - task_manager = get_task_manager() - return task_manager.get_all_tasks_info() - - -@router.get("/{task_id}", response_model=Task) -async def get_task_status(task_id: str): - """ - Retrieve the status and details of a specific task. - - Args: - task_id: The ID of the task to retrieve. - - Returns: - Task: The task object with its status and details. - """ - task_manager = get_task_manager() - task = task_manager.get_task_info(task_id) - if not task: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") - return task - - -@router.delete("/{task_id}", response_model=Task) -async def cancel_task(task_id: str): - """ - Request cancellation of a running task. - - Args: - task_id: The ID of the task to cancel. - - Returns: - Task: The task object with its status and details. - """ - task_manager = get_task_manager() - task = await task_manager.cancel_task(task_id) - if not task: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") - return task - - -@router.post("/{task_id}/pause", response_model=Task, summary="Pause a Task") -async def pause_task(task_id: str): - """ - Pauses a running task. - - Args: - task_id: The ID of the task to pause. - - Returns: - Task: The task object with its status and details. - """ - task_manager = get_task_manager() - task = await task_manager.pause_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found or cannot be paused.") - if task.status not in [TaskStatus.PAUSED, TaskStatus.RUNNING]: - pass - return task - - -@router.post("/{task_id}/resume", response_model=Task, summary="Resume a Task") -async def resume_task(task_id: str): - """ - Resumes a paused task. - - Args: - task_id: The ID of the task to resume. - - Returns: - Task: The task object with its status and details. - """ - task_manager = get_task_manager() - task = await task_manager.resume_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found or cannot be resumed.") - if task.status not in [TaskStatus.RUNNING, TaskStatus.PAUSED]: - pass - return task - - -@router.post("/{task_name}", response_model=Task, status_code=status.HTTP_202_ACCEPTED) -async def create_task( - task_name: str, - args: List[Any] = Body(default=[], description="Positional arguments for the task"), - kwargs: Dict[str, Any] = Body(default={}, description="Keyword arguments for the task") -): - """ - Create and start a new background task with optional parameters. - - The request body accepts: - - **args**: List of positional arguments (e.g., `["project_name", 0]`) - - **kwargs**: Dictionary of keyword arguments (e.g., `{"num_batches": 5}`) - - Args: - task_name: The name of the task to create, as registered in the TaskManager. - args: Positional arguments to pass to the task's run method. - kwargs: Keyword arguments to pass to the task's run method. - - Returns: - The created task object. - - Examples: - ```json - // No parameters - {} - - // With positional args - { - "args": ["MyProject", 0] - } - - // With keyword args - { - "kwargs": {"num_calibration_batches": 5} - } - - // With both - { - "args": ["MyProject", 0], - "kwargs": {"num_calibration_batches": 5} - } - ``` - """ - try: - task_manager = get_task_manager() - task = await task_manager.create_and_run_task(task_name, *args, **kwargs) - return task - except ValueError as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_7/__init__.py b/openscan_firmware/routers/v0_7/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/openscan_firmware/routers/v0_7/cameras.py b/openscan_firmware/routers/v0_7/cameras.py deleted file mode 100644 index 6f54760..0000000 --- a/openscan_firmware/routers/v0_7/cameras.py +++ /dev/null @@ -1,138 +0,0 @@ -import asyncio - -from fastapi import APIRouter, Body, HTTPException, Query -from fastapi.responses import StreamingResponse, Response -from fastapi.encoders import jsonable_encoder -from pydantic import BaseModel - -from openscan_firmware.config.camera import CameraSettings -from openscan_firmware.models.camera import Camera, CameraType -from openscan_firmware.controllers.hardware.cameras.camera import get_all_camera_controllers, get_camera_controller - -from .settings_utils import create_settings_endpoints - -router = APIRouter( - prefix="/cameras", - tags=["cameras"], - responses={404: {"description": "Not found"}}, -) - - -class CameraStatusResponse(BaseModel): - name: str - type: CameraType - busy: bool - settings: CameraSettings - - -@router.get("/", response_model=dict[str, CameraStatusResponse]) -async def get_cameras(): - """Get all cameras with their current status - - Returns: - dict[str, CameraStatusResponse]: A dictionary of camera name to a camera status object - """ - return { - name: controller.get_status() - for name, controller in get_all_camera_controllers().items() - } - - -@router.get("/{camera_name}", response_model=CameraStatusResponse) -async def get_camera(camera_name: str): - """Get a camera with its current status - - Args: - camera_name: The name of the camera to get the status of - - Returns: - CameraStatusResponse: A response object containing the status of the camera - """ - try: - return get_camera_controller(camera_name).get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.get("/{camera_name}/preview") -async def get_preview( - camera_name: str, - mode: str = Query(default="stream", pattern="^(stream|snapshot)$"), - fps: int = Query(default=50, ge=1, le=50), -): - """Get a camera preview stream in lower resolution - - Note: The preview is not rotated by orientation_flag and has to be rotated by client. - - Args: - camera_name: The name of the camera to get the preview stream from - mode: Either ``stream`` for the MJPEG stream or ``snapshot`` for a single JPEG frame - fps: Target frames per second for the stream, clamped between 1 and 50 (only used in stream mode) - - Returns: - StreamingResponse: A streaming response containing the preview stream - """ - controller = get_camera_controller(camera_name) - - if mode == "snapshot": - if controller.is_busy(): - raise HTTPException(status_code=409, detail="Camera is busy. If this is a bug, please restart the camera.") - try: - frame = controller.preview() - except RuntimeError as exc: - raise HTTPException(status_code=503, detail=str(exc)) from exc - return Response(content=frame, media_type="image/jpeg") - - frame_delay = 1 / fps - - async def generate(): - while True: - try: - frame = await controller.preview_async() - except RuntimeError: - break - if frame is not None: - yield (b'--frame\r\n' - b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') - await asyncio.sleep(frame_delay) - - return StreamingResponse(generate(), media_type="multipart/x-mixed-replace;boundary=frame") - - -@router.get("/{camera_name}/photo") -async def get_photo(camera_name: str): - """Get a camera photo - - Args: - camera_name: The name of the camera to get the photo from - - Returns: - Response: A response containing the photo - """ - controller = get_camera_controller(camera_name) - try: - photo = await controller.photo_async() - return Response(content=photo.data.getvalue(), media_type="image/jpeg") - except Exception as e: - return Response(status_code=500, content=str(e)) - -@router.post("/{camera_name}/restart") -async def restart_camera(camera_name: str): - """Restart a camera - - Args: - camera_name: The name of the camera to restart - - Returns: - Response: A response containing the status code - """ - controller = get_camera_controller(camera_name) - controller.restart_camera() - return Response(status_code=200) - -create_settings_endpoints( - router=router, - resource_name="camera_name", - get_controller=get_camera_controller, - settings_model=CameraSettings -) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_7/cloud.py b/openscan_firmware/routers/v0_7/cloud.py deleted file mode 100644 index be09d07..0000000 --- a/openscan_firmware/routers/v0_7/cloud.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Cloud-specific API endpoints exposing status, configuration and project helpers.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -import re - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from openscan_firmware.config.cloud import CloudSettings, mask_secret, set_cloud_settings -from openscan_firmware.controllers.services import cloud as cloud_service -from openscan_firmware.controllers.services.cloud import CloudServiceError -from openscan_firmware.controllers.services.cloud_settings import ( - get_active_source, - get_masked_active_settings, - save_persistent_cloud_settings, - set_active_source, - settings_file_exists, -) -from openscan_firmware.controllers.services.projects import ProjectManager, get_project_manager -from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager -from openscan_firmware.models.project import Project -from openscan_firmware.models.task import Task - -router = APIRouter( - prefix="/cloud", - tags=["cloud"], - responses={404: {"description": "Not found"}}, -) - -logger = logging.getLogger(__name__) -_TOKEN_PARAM_PATTERN = re.compile(r"(token=)([^&\s]+)") - - -class CloudSettingsResponse(BaseModel): - """Masked cloud settings including metadata.""" - - settings: dict[str, Any] | None = None - source: str | None = None - persisted: bool = False - - -class CloudStatusResponse(BaseModel): - """Aggregated view of the cloud backend status.""" - - status: dict[str, Any] | None = None - token_info: dict[str, Any] | None = None - queue_estimate: dict[str, Any] | None = None - settings: CloudSettingsResponse - message: str | None = None - - -class CloudProjectStatus(BaseModel): - """Local project enriched with cloud metadata and related tasks.""" - - project: Project - remote_project_name: str | None = None - remote_info: dict[str, Any] | None = None - tasks: list[Task] = Field(default_factory=list) - message: str | None = None - - -async def _fetch_remote_info(remote_name: str) -> tuple[dict[str, Any] | None, str | None]: - try: - data = await asyncio.to_thread(cloud_service.get_project_info, remote_name) - return data, None - except CloudServiceError as exc: # pragma: no cover - exercised in error test - return None, str(exc) - except Exception as exc: # pragma: no cover - defensive logging - logger.exception("Failed to fetch remote project info for %s", remote_name) - return None, str(exc) - - -def _collect_tasks_by_project() -> dict[str, list[Task]]: - task_manager = get_task_manager() - mapping: dict[str, list[Task]] = {} - for task in task_manager.get_all_tasks_info(): - if task.task_type != "cloud_upload_task" or not task.run_args: - continue - project_name = task.run_args[0] - mapping.setdefault(project_name, []).append(task) - return mapping - - -def _extract_remote_name_from_tasks(tasks: list[Task]) -> str | None: - for task in tasks: - result = task.result - if isinstance(result, dict) and "project" in result: - return str(result["project"]) - if hasattr(result, "project"): - return str(getattr(result, "project")) - return None - - -async def _build_project_status( - project: Project, - tasks_by_project: dict[str, list[Task]], - project_manager: ProjectManager, -) -> CloudProjectStatus: - remote_info = None - message = None - remote_name = project.cloud_project_name - tasks = tasks_by_project.get(project.name, []) - - if not remote_name: - remote_name = _extract_remote_name_from_tasks(tasks) - if remote_name: - try: - project_manager.mark_uploaded(project.name, True, remote_name) - refreshed = project_manager.get_project_by_name(project.name) - if refreshed is not None: - project = refreshed - except ValueError: - logger.warning( - "Failed to persist derived remote project name '%s' for '%s'", - remote_name, - project.name, - ) - elif tasks: - message = "Remote project name not available yet. Upload still running?" - - if remote_name: - fetched_info, fetch_message = await _fetch_remote_info(remote_name) - remote_info = fetched_info - if fetch_message: - message = f"{message} | {fetch_message}".strip(" |") if message else fetch_message - - return CloudProjectStatus( - project=project.model_copy(), - remote_project_name=remote_name, - remote_info=remote_info, - tasks=[task.model_copy() for task in tasks], - message=message, - ) - - -def _build_settings_response() -> CloudSettingsResponse: - return CloudSettingsResponse( - settings=get_masked_active_settings(), - source=get_active_source(), - persisted=settings_file_exists(), - ) - - -def _mask_tokens(text: str | None) -> str | None: - if not text: - return text - - return _TOKEN_PARAM_PATTERN.sub( - lambda match: f"{match.group(1)}{mask_secret(match.group(2))}", - text, - ) - - -@router.get("/status", response_model=CloudStatusResponse) -async def get_cloud_status() -> CloudStatusResponse: - """Return aggregated status information for the cloud backend. - - Returns: - CloudStatusResponse: A response object containing the status of the cloud backend - """ - - status = token_info = queue_estimate = None - messages: list[str] = [] - - try: - status = await asyncio.to_thread(cloud_service.get_status) - except CloudServiceError as exc: - messages.append(f"Status unavailable: {_mask_tokens(str(exc))}") - except Exception as exc: # pragma: no cover - defensive logging - logger.exception("Cloud status request failed") - messages.append(f"Status request failed: {_mask_tokens(str(exc))}") - - try: - token_info = await asyncio.to_thread(cloud_service.get_token_info) - except CloudServiceError as exc: - messages.append(f"Token info unavailable: {_mask_tokens(str(exc))}") - except Exception as exc: # pragma: no cover - defensive logging - logger.exception("Token info request failed") - messages.append(f"Token info request failed: {_mask_tokens(str(exc))}") - - try: - queue_estimate = await asyncio.to_thread(cloud_service.get_queue_estimate) - except CloudServiceError as exc: - messages.append(f"Queue estimate unavailable: {_mask_tokens(str(exc))}") - except Exception as exc: # pragma: no cover - defensive logging - logger.exception("Queue estimate request failed") - messages.append(f"Queue estimate request failed: {_mask_tokens(str(exc))}") - - return CloudStatusResponse( - status=status, - token_info=token_info, - queue_estimate=queue_estimate, - settings=_build_settings_response(), - message=_mask_tokens(" | ".join(messages)) if messages else None, - ) - - -@router.get("/settings", response_model=CloudSettingsResponse) -async def get_cloud_settings() -> CloudSettingsResponse: - """Return the masked active cloud configuration. - - Returns: - CloudSettingsResponse: A response object containing the masked active cloud configuration - """ - - return _build_settings_response() - - -@router.post("/settings", response_model=CloudSettingsResponse) -async def update_cloud_settings(new_settings: CloudSettings) -> CloudSettingsResponse: - """Persist and activate new cloud settings. - - Args: - new_settings: The new cloud settings to persist and activate - - Returns: - CloudSettingsResponse: A response object containing the masked active cloud configuration - """ - - set_cloud_settings(new_settings) - await asyncio.to_thread(save_persistent_cloud_settings, new_settings) - set_active_source("persistent") - return _build_settings_response() - - -@router.get("/projects", response_model=list[CloudProjectStatus]) -async def list_cloud_projects() -> list[CloudProjectStatus]: - """Return all local projects enriched with cloud metadata. - - Returns: - list[CloudProjectStatus]: A list of cloud project status objects - """ - - project_manager = get_project_manager() - tasks_by_project = _collect_tasks_by_project() - - statuses: list[CloudProjectStatus] = [] - for project in project_manager.get_all_projects().values(): - statuses.append(await _build_project_status(project, tasks_by_project, project_manager)) - return statuses - - -@router.get("/projects/{project_name}", response_model=CloudProjectStatus) -async def get_cloud_project(project_name: str) -> CloudProjectStatus: - """Return cloud details for a single local project. - - Args: - project_name: The name of the project to get the cloud details for - - Returns: - CloudProjectStatus: A response object containing the cloud project status - """ - - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if project is None: - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") - - tasks_by_project = _collect_tasks_by_project() - return await _build_project_status(project, tasks_by_project, project_manager) - - -@router.delete("/projects/{project_name}") -async def reset_cloud_project(project_name: str) -> dict[str, Any]: - """Reset the remote project and clear the local linkage. - - Invokes the cloud backend's `resetProject` action, which removes the - current reconstruction job (queue progress, generated models and downloads) - and frees the remote project name for another upload. - Locally the project is marked as not uploaded anymore, the cached - `cloud_project_name` is cleared, and the `downloaded` flag is reset to - False so a subsequent download reflects the new state. The on-disk files - stay untouched. - - Args: - project_name: The name of the project to reset the remote project for - - Returns: - dict[str, Any]: A response object containing the result of the reset operation - """ - - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if project is None: - raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") - - remote_name = project.cloud_project_name - if not remote_name: - raise HTTPException(status_code=404, detail=f"Project '{project_name}' has no recorded remote counterpart") - - try: - response = await asyncio.to_thread(cloud_service.reset_project, remote_name) - except CloudServiceError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - project_manager.mark_uploaded(project_name, False) - project_manager.mark_downloaded(project_name, False) - return {"project": project_name, "remote_project": remote_name, "response": response} - - -@router.post("/projects/{project_name}/download", response_model=Task) -async def download_project_from_cloud( - project_name: str, - token_override: str | None = None, - remote_project: str | None = None, -) -> Task: - """Schedule an asynchronous cloud download for a project's reconstruction. - - Args: - project_name: Local project name whose reconstruction should be downloaded. - token_override: Optional token override forwarded to the download task. - remote_project: Optional explicit remote project identifier; defaults to stored linkage. - - Returns: - Task: The TaskManager model describing the scheduled download. - """ - - try: - task = await cloud_service.download_project( - project_name, - token=token_override, - remote_project=remote_project, - ) - except CloudServiceError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return task diff --git a/openscan_firmware/routers/v0_7/develop.py b/openscan_firmware/routers/v0_7/develop.py deleted file mode 100644 index 384ebc8..0000000 --- a/openscan_firmware/routers/v0_7/develop.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Developer endpoints - -These may be removed or changed at any time. -""" - -import base64 -import time - -from fastapi import APIRouter, HTTPException, status, Response, Query - -from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager -from openscan_firmware.models.task import TaskStatus, Task - -from openscan_firmware.models.paths import PolarPoint3D -from openscan_firmware.controllers.hardware.motors import move_to_point - -from openscan_firmware.utils.paths import paths -from openscan_firmware.cli import DEFAULT_RELOAD_TRIGGER - - -router = APIRouter( - prefix="/develop", - tags=["develop"], - responses={404: {"description": "Not found"}}, -) - -@router.put("/scanner-position") -async def move_to_position(point: PolarPoint3D): - """Move Rotor and Turntable to a polar point""" - await move_to_point(point) - - -@router.post("/restart", status_code=status.HTTP_202_ACCEPTED) -async def restart_application() -> dict[str, str]: - """Trigger a Firmware reload by touching the reload sentinel file. - - Note: The application has to be started with the --reload-trigger option to enable this endpoint.""" - DEFAULT_RELOAD_TRIGGER.parent.mkdir(parents=True, exist_ok=True) - DEFAULT_RELOAD_TRIGGER.write_text(str(time.time()), encoding="utf-8") - # Ensure mtime changes even on file systems with coarse-grained timestamps - DEFAULT_RELOAD_TRIGGER.touch() - return {"detail": "Reload triggered"} - - -@router.get("/crop_image", summary="Run crop task and return visualization image", response_class=Response) -async def crop_image(camera_name: str, threshold: int | None = Query(default=None, ge=0, le=255)) -> Response: - """Run the crop task and return the visualization image with bounding boxes. - - Args: - camera_name: Name of the camera controller to use. - threshold: Optional Canny threshold passed to the analysis (tutorial uses a trackbar). If not set, defaults inside the task. - - Returns: - Response: JPEG image showing contours, rectangles and circles as detected by the task. - """ - task_manager = get_task_manager() - - # Start task - task = await task_manager.create_and_run_task("crop_task", camera_name, threshold=threshold) - - # Wait for completion (default TaskManager timeout is fine for demo; can be adjusted if needed) - try: - final_task = await task_manager.wait_for_task(task.id, timeout=120.0) - except Exception as e: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Waiting for task failed: {e}") - - if final_task.status != TaskStatus.COMPLETED: - detail = final_task.error or f"Task did not complete successfully (status={final_task.status})." - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail) - - result = final_task.result or {} - if not isinstance(result, dict) or "image_base64" not in result: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Task result does not contain an image.") - - try: - img_bytes = base64.b64decode(result["image_base64"]) - except Exception: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to decode image from task result.") - - return Response(content=img_bytes, media_type=result.get("mime", "image/jpeg")) - - - -@router.post("/hello-world-async", response_model=Task) -async def hello_world_async(total_steps: int, delay: float): - """Start the async hello world demo task.""" - - task_manager = get_task_manager() - - # Updated to explicit task_name with required _task suffix - task = await task_manager.create_and_run_task("hello_world_async_task", total_steps=total_steps, delay=delay) - return task - - -@router.get("/{method}", response_model=list[paths.CartesianPoint3D]) -async def get_path(method: paths.PathMethod, points: int): - """Get a list of coordinates by path method and number of points""" - return paths.get_path(method, points) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_7/device.py b/openscan_firmware/routers/v0_7/device.py deleted file mode 100644 index b085795..0000000 --- a/openscan_firmware/routers/v0_7/device.py +++ /dev/null @@ -1,230 +0,0 @@ -from fastapi import APIRouter, HTTPException, UploadFile, File -from pydantic import BaseModel, ValidationError -import os -import json -import tempfile -import shutil - -from openscan_firmware.models.scanner import ScannerDevice -from openscan_firmware.controllers import device - -from openscan_firmware.utils.dir_paths import resolve_settings_dir -from .cameras import CameraStatusResponse -from .motors import MotorStatusResponse -from .lights import LightStatusResponse - -router = APIRouter( - prefix="/device", - tags=["device"], - responses={404: {"description": "Not found"}}, -) - - -class DeviceConfigRequest(BaseModel): - config_file: str - -class DeviceStatusResponse(BaseModel): - name: str - model: str - shield: str - cameras: dict[str, CameraStatusResponse] - motors: dict[str, MotorStatusResponse] - lights: dict[str, LightStatusResponse] - initialized: bool - -class DeviceControlResponse(BaseModel): - success: bool - message: str - status: DeviceStatusResponse - - -@router.get("/info", response_model=DeviceStatusResponse) -async def get_device_info(): - """Get information about the device - - Returns: - dict: A dictionary containing information about the device - """ - try: - info = device.get_device_info() - return DeviceStatusResponse.model_validate(info) - except ValidationError as exc: - raise HTTPException( - status_code=503, - detail={ - "message": "Device configuration is not loaded.", - "errors": exc.errors(), - }, - ) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error getting device info: {str(e)}") - - -@router.get("/configurations") -async def list_config_files(): - """List all available device configuration files""" - try: - configs = device.get_available_configs() - return {"status": "success", "configs": configs} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error listing configuration files: {str(e)}") - - -@router.post("/configurations/", response_model=DeviceControlResponse) -async def add_config_json(config_data: ScannerDevice, filename: DeviceConfigRequest): - """Add a device configuration from a JSON object - - This endpoint accepts a JSON object with the device configuration, - validates it and saves it to a file. - - Args: - config_data: The device configuration to add - filename: The filename to save the configuration as - - Returns: - dict: A dictionary containing the status of the operation - """ - try: - # Create a temporary file to save the configuration - with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as temp_file: - # Convert the model to a dictionary and save it as JSON - config_dict = config_data.dict() - json.dump(config_dict, temp_file, indent=4) - temp_path = temp_file.name - - # Save to settings directory with a meaningful name - settings_dir = resolve_settings_dir("device") - os.makedirs(settings_dir, exist_ok=True) - - filename = f"{filename.config_file}.json" - target_path = os.path.join(settings_dir, filename) - - # Move the temporary file to the target path - shutil.move(temp_path, target_path) - - return DeviceControlResponse( - success=True, - message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) - ) - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error setting device configuration: {str(e)}") - - -@router.patch("/configurations/current", response_model=DeviceControlResponse) -async def save_device_config(): - """Save the current device configuration to a file - - This endpoint saves the current device configuration to device_config.json. - - Returns: - dict: A dictionary containing the status of the operation - """ - if device.save_device_config(): - return DeviceControlResponse( - success=True, - message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) - ) - else: - raise HTTPException(status_code=500, detail="Failed to save device configuration") - -@router.put("/configurations/current", response_model=DeviceControlResponse) -async def set_config_file(config_data: DeviceConfigRequest): - """Set the device configuration from a file and initialize hardware - - Args: - config_data: The device configuration to set - - Returns: - dict: A dictionary containing the status of the operation - """ - try: - # Get available configs - available_configs = device.get_available_configs() - - # Check if the config file exists in available configs - config_file = config_data.config_file - config_found = False - - # If it's just a filename (no path), try to find it in available configs - if not os.path.dirname(config_file): - for config in available_configs: - if config["filename"] == config_file: - config_file = config["path"] - config_found = True - break - else: - # Check if the full path exists - config_found = os.path.exists(config_file) - - if not config_found: - raise HTTPException( - status_code=404, - detail={ - "message": f"Config file not found: {config_data.config_file}", - "available_configs": available_configs - } - ) - - # Set device config - if await device.set_device_config(config_file): - return DeviceControlResponse( - success=True, - message="Configuration loaded successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) - ) - else: - raise HTTPException(status_code=500, detail="Failed to load device configuration") - - except HTTPException: - # Re-raise HTTP exceptions to preserve status code and detail - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error setting device configuration: {str(e)}") - - -@router.post("/configurations/current/initialize", response_model=DeviceControlResponse) -async def reinitialize_hardware(detect_cameras: bool = False): - """Reinitialize hardware components - - This can be used in case of a hardware failure or to reload the hardware components. - - Args: - detect_cameras: Whether to detect cameras - - Returns: - dict: A dictionary containing the status of the operation - """ - try: - await device.initialize(detect_cameras=detect_cameras) - return DeviceControlResponse( - success=True, - message="Hardware reinitialized successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) - ) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Error reloading hardware: {str(e)}") - - -@router.post("/reboot", response_model=bool) -def reboot(save_config: bool = False): - """Reboot system and optionally save config. - - Args: - save_config: Whether to save the current configuration before rebooting - """ - device.reboot(save_config) - return True - - -@router.post("/shutdown", response_model=bool) -def shutdown(save_config: bool = False) -> None: - """Shutdown system and optionally save config. - - Args: - save_config: Whether to save the current configuration before shutting down - """ - device.shutdown(save_config) - return True \ No newline at end of file diff --git a/openscan_firmware/routers/v0_7/focus_stacking.py b/openscan_firmware/routers/v0_7/focus_stacking.py deleted file mode 100644 index 1370521..0000000 --- a/openscan_firmware/routers/v0_7/focus_stacking.py +++ /dev/null @@ -1,68 +0,0 @@ -"""API endpoints for managing focus stacking tasks.""" -from __future__ import annotations - -from fastapi import APIRouter, HTTPException - -from openscan_firmware.controllers.services import focus_stacking as focus_service -from openscan_firmware.models.task import Task - -router = APIRouter(prefix="/projects", tags=["focus_stacking"]) - - -@router.post("/{project_name}/scans/{scan_index:int}/focus-stacking/start", response_model=Task) -async def start_focus_stacking(project_name: str, scan_index: int) -> Task: - """Start focus stacking for a scan.""" - try: - return await focus_service.start_focus_stacking(project_name, scan_index) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 - raise HTTPException(status_code=500, detail=str(exc)) from exc - - -@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/pause", response_model=Task) -async def pause_focus_stacking(project_name: str, scan_index: int) -> Task: - """Pause an active focus stacking task.""" - try: - task = await focus_service.pause_focus_stacking(project_name, scan_index) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 - raise HTTPException(status_code=500, detail=str(exc)) from exc - - if task is None: - raise HTTPException(status_code=409, detail="Focus stacking is not running") - - return task - - -@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/resume", response_model=Task) -async def resume_focus_stacking(project_name: str, scan_index: int) -> Task: - """Resume a paused focus stacking task.""" - try: - task = await focus_service.resume_focus_stacking(project_name, scan_index) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 - raise HTTPException(status_code=500, detail=str(exc)) from exc - - if task is None: - raise HTTPException(status_code=409, detail="Focus stacking is not paused") - - return task - - -@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/cancel", response_model=Task) -async def cancel_focus_stacking(project_name: str, scan_index: int) -> Task: - """Cancel an active focus stacking task.""" - try: - task = await focus_service.cancel_focus_stacking(project_name, scan_index) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 - raise HTTPException(status_code=500, detail=str(exc)) from exc - - if task is None: - raise HTTPException(status_code=409, detail="Focus stacking is not running") - - return task diff --git a/openscan_firmware/routers/v0_7/gpio.py b/openscan_firmware/routers/v0_7/gpio.py deleted file mode 100644 index a4d5a0c..0000000 --- a/openscan_firmware/routers/v0_7/gpio.py +++ /dev/null @@ -1,53 +0,0 @@ -from fastapi import APIRouter - -from openscan_firmware.controllers.hardware import gpio - -router = APIRouter( - prefix="/gpio", - tags=["gpio"], - responses={404: {"description": "Not found"}}, -) - - -@router.get("/") -async def get_pins() -> dict[str, list[int]]: - """Get all initialized GPIO pins - - Returns: - dict[str, list[int]]: A dictionary of initialized output pins and buttons - """ - return gpio.get_initialized_pins() - - -@router.get("/{pin_id}", response_model=bool) -async def get_pin(pin_id: int): - """Get output value of a specific GPIO pin - - Args: - pin_id: The ID (int) of the GPIO pin to get the value of - - Returns: - bool: The output value of the GPIO pin - """ - return gpio.get_output_pin(pin_id) - - -@router.patch("/{pin_id}") -async def set_pin(pin_id: int, status: bool): - """Set GPIO pin output value - - Args: - 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) - - -@router.patch("/{pin_id}/toggle") -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) diff --git a/openscan_firmware/routers/v0_7/lights.py b/openscan_firmware/routers/v0_7/lights.py deleted file mode 100644 index 836dcbe..0000000 --- a/openscan_firmware/routers/v0_7/lights.py +++ /dev/null @@ -1,111 +0,0 @@ -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from openscan_firmware.controllers.hardware.lights import get_light_controller, get_all_light_controllers -from openscan_firmware.config.light import LightConfig -from .settings_utils import create_settings_endpoints - -router = APIRouter( - prefix="/lights", - tags=["lights"], - responses={404: {"description": "Not found"}}, -) - -class LightStatusResponse(BaseModel): - name: str - is_on: bool - settings: LightConfig - - -@router.get("/", response_model=dict[str, LightStatusResponse]) -async def get_lights(): - """Get all lights with their current status - - Returns: - dict[str, LightStatusResponse]: A dictionary of light name to a light status object - """ - return { - name: controller.get_status() - for name, controller in get_all_light_controllers().items() - } - - -@router.get("/{light_name}", response_model=LightStatusResponse) -async def get_light(light_name: str): - """Get light status - - Args: - light_name: The name of the light to get the status of - - Returns: - LightStatusResponse: A response object containing the status of the light - """ - try: - return get_light_controller(light_name).get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.patch("/{light_name}/turn_on", response_model=LightStatusResponse) -async def turn_on_light(light_name: str): - """Turn on light - - Args: - light_name: The name of the light to turn on - - Returns: - LightStatusResponse: A response object containing the status of the light after the turn on operation - """ - try: - controller = get_light_controller(light_name) - controller.turn_on() - return controller.get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.patch("/{light_name}/turn_off", response_model=LightStatusResponse) -async def turn_off_light(light_name: str): - """Turn of light - - Args: - light_name: The name of the light to turn off - - Returns: - LightStatusResponse: A response object containing the status of the light after the turn off operation - """ - try: - controller = get_light_controller(light_name) - controller.turn_off() - return controller.get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.patch("/{light_name}/toggle", response_model=LightStatusResponse) -async def toggle_light(light_name: str): - """Toggle light on or off - - Args: - light_name: The name of the light to toggle - - Returns: - LightStatusResponse: A response object containing the status of the light after the toggle operation - """ - try: - controller = get_light_controller(light_name) - if controller.is_on: - controller.turn_off() - else: - controller.turn_on() - return controller.get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -create_settings_endpoints( - router=router, - resource_name="light_name", - get_controller=get_light_controller, - settings_model=LightConfig -) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_7/motors.py b/openscan_firmware/routers/v0_7/motors.py deleted file mode 100644 index 66e03b2..0000000 --- a/openscan_firmware/routers/v0_7/motors.py +++ /dev/null @@ -1,120 +0,0 @@ -import asyncio - -from fastapi import APIRouter, Body, HTTPException -from pydantic import BaseModel -from typing import Optional - -from openscan_firmware.config.motor import MotorConfig -from openscan_firmware.controllers.hardware.motors import get_motor_controller, get_all_motor_controllers -from openscan_firmware.models.paths import PolarPoint3D -from .settings_utils import create_settings_endpoints - -router = APIRouter( - prefix="/motors", - tags=["motors"], - responses={404: {"description": "Not found"}}, -) - -class MotorStatusResponse(BaseModel): - name: str - angle: float - busy: bool - target_angle: Optional[float] - settings: MotorConfig - calibrated: bool - endstop: Optional[dict] - - -@router.get("/", response_model=dict[str, MotorStatusResponse]) -async def get_motors(): - """Get all motors with their current status - - Returns: - dict[str, MotorStatusResponse]: A dictionary of motor name to a motor status object - """ - return { - name: controller.get_status() - for name, controller in get_all_motor_controllers().items() - } - - -@router.get("/{motor_name}", response_model=MotorStatusResponse) -async def get_motor(motor_name: str): - """Get motor status - - Args: - motor_name: The name of the motor to get the status of - - Returns: - MotorStatusResponse: A response object containing the status of the motor - """ - try: - return get_motor_controller(motor_name).get_status() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.put("/{motor_name}/angle", response_model=MotorStatusResponse) -async def move_motor_to_angle(motor_name: str, degrees: float): - """Move motor to absolute position - - Args: - motor_name: The name of the motor to move - degrees: Number of degrees to move - - Returns: - MotorStatusResponse: A response object containing the status of the motor after the move - """ - - controller = get_motor_controller(motor_name) - await controller.move_to(degrees) - return controller.get_status() - - -@router.patch("/{motor_name}/angle", response_model=MotorStatusResponse) -async def move_motor_by_degree(motor_name: str, degrees: float = Body(embed=True)): - """Move motor by degrees - - Args: - motor_name: The name of the motor to move - degrees: Number of degrees to move - - Returns: - MotorStatusResponse: A response object containing the status of the motor after the move - """ - controller = get_motor_controller(motor_name) - await controller.move_degrees(degrees) - return controller.get_status() - - -@router.put("/{motor_name}/endstop-calibration", response_model=MotorStatusResponse) -async def move_motor_to_home_position(motor_name: str): - """Move motor to home position - - This endpoint moves the motor to the home position using the endstop calibration. - - Args: - motor_name: The name of the motor to move to the home position - - Returns: - MotorStatusResponse: A response object containing the status of the motor after the move - """ - controller = get_motor_controller(motor_name) - if controller.endstop and not controller.is_busy(): - # Trigger Endstop - controller.model.angle = 0 - await controller.move_degrees(140) - # Wait for Endstop and move motor to home position - await asyncio.sleep(3) - await controller.move_to(90) - return controller.get_status() - else: - raise HTTPException(status_code=422, detail="No endstop configured or motor is busy!") - - -create_settings_endpoints( - router=router, - resource_name="motor_name", - get_controller=get_motor_controller, - settings_model=MotorConfig -) \ No newline at end of file diff --git a/openscan_firmware/routers/v0_7/openscan.py b/openscan_firmware/routers/v0_7/openscan.py deleted file mode 100644 index 213e861..0000000 --- a/openscan_firmware/routers/v0_7/openscan.py +++ /dev/null @@ -1,226 +0,0 @@ -import asyncio -from fastapi import APIRouter, Body, HTTPException -from fastapi.responses import StreamingResponse - -from typing import Tuple - -from openscan_firmware import __version__ -from openscan_firmware.controllers.device import get_scanner_model -from typing import AsyncGenerator -from starlette.responses import FileResponse -from starlette.background import BackgroundTask -from openscan_firmware.config.logger import DEFAULT_LOGS_PATH, flush_memory_handlers -import os -import zipfile -import glob -from tempfile import NamedTemporaryFile -from datetime import datetime -from collections import deque - -router = APIRouter( - prefix="", - tags=["openscan"], - responses={404: {"description": "Not found"}}, -) - -@router.get("/") -async def get_software_info(): - """Get information about the scanner software""" - return {"model": get_scanner_model(), - "firmware_version": __version__} - - - -# ------------------------- -# Log utilities and endpoints -# ------------------------- - -def _read_last_lines(file_path: str, max_lines: int) -> str: - """Return the last max_lines from file as a single string. - - Args: - file_path: Path to the file to read. - max_lines: Maximum number of lines to return. - - Returns: - The tail content joined by newlines. - """ - if max_lines <= 0: - return "" - - lines = deque(maxlen=max_lines) - try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - for line in f: - lines.append(line.rstrip("\n")) - except FileNotFoundError: - raise HTTPException(status_code=404, detail="Log file not found") - return "\n".join(lines) + ("\n" if lines else "") - - -async def _follow_file(file_path: str, poll_interval: float = 1) -> AsyncGenerator[bytes, None]: - """Async generator that tails a file and yields new lines as bytes. - - Args: - file_path: Path to the file to follow. - poll_interval: Sleep interval between checks for new data. - - Yields: - Bytes chunks representing new lines appended to the file. - """ - f = None - last_inode = None - try: - while True: - # Open file if not open yet (or after rotation) - if f is None: - try: - f = open(file_path, "r", encoding="utf-8", errors="ignore") - f.seek(0, os.SEEK_END) - last_inode = os.fstat(f.fileno()).st_ino - except FileNotFoundError: - # File might not exist yet or just rotated; retry shortly - await asyncio.sleep(poll_interval) - continue - - line = f.readline() - if line: - yield line.encode("utf-8", errors="ignore") - continue - - # No new line yet: flush buffered handlers to force write-through - try: - flush_memory_handlers() - except Exception: - # Non-fatal; keep streaming - pass - - # Detect rotation by inode change or missing file - try: - current_inode = os.stat(file_path).st_ino - if current_inode != last_inode: - try: - f.close() - finally: - f = None - continue - except FileNotFoundError: - try: - f.close() - finally: - f = None - await asyncio.sleep(poll_interval) - continue - - await asyncio.sleep(poll_interval) - except asyncio.CancelledError: - if f is not None: - try: - f.close() - except Exception: - pass - return - except FileNotFoundError: - raise HTTPException(status_code=404, detail="Log file not found") - - -@router.get("/logs/tail") -async def tail_logs(format: str = "text", lines: int = 200, follow: bool = False, poll_interval: float = 1): - """Show or follow current logs. - - When follow=false (default), returns the last N lines of the selected log. - When follow=true (text mode only!), streams new lines as they are written (like `tail -f`). - - Args: - format: "text" for openscan_firmware.log, "json" for openscan_detailed_log.json. - lines: Number of last lines to return initially. - follow: If true, stream appended log lines in text mode. - poll_interval: Poll interval (seconds) when following in text mode. - - Returns: - A response with the requested log content. - """ - flush_memory_handlers() # Ensure buffered records are flushed to disk - - if format.lower() == "json": - log_file = os.path.join(DEFAULT_LOGS_PATH, "openscan_detailed_log.json") - media_type = "application/json" - else: - log_file = os.path.join(DEFAULT_LOGS_PATH, "openscan_firmware.log") - media_type = "text/plain" - - if not os.path.exists(log_file): - raise HTTPException(status_code=404, detail="Log file not found") - - if follow and format.lower() == "text": - # Send last N lines first, then follow new lines - async def stream() -> AsyncGenerator[bytes, None]: - head = _read_last_lines(log_file, lines).encode("utf-8") - if head: - yield head - async for chunk in _follow_file(log_file, poll_interval=poll_interval): - yield chunk - headers = { - "Cache-Control": "no-cache", - "Pragma": "no-cache", - "X-Accel-Buffering": "no", # disable nginx buffering if present - "Connection": "keep-alive", - } - return StreamingResponse(stream(), media_type=media_type, headers=headers) - - # One-shot tail of last N lines - content = _read_last_lines(log_file, lines) - return StreamingResponse(iter([content.encode("utf-8")]), media_type=media_type) - - -@router.get("/logs/archive") -async def download_logs_archive(): - """Create and download a ZIP archive containing all log files. - - The archive includes rotated files for both text and JSON logs, using - deflate compression for reasonable size to share e.g. via email. - - Returns: - FileResponse serving the generated ZIP. The temp file is deleted after send. - """ - flush_memory_handlers() # Flush buffered logs before archiving - - patterns = [ - os.path.join(DEFAULT_LOGS_PATH, "openscan_firmware.log*"), - os.path.join(DEFAULT_LOGS_PATH, "openscan_detailed_log.json*"), - ] - files = [] - for pat in patterns: - files.extend(glob.glob(pat)) - files = [f for f in files if os.path.isfile(f)] - - if not files: - raise HTTPException(status_code=404, detail="No log files found to archive") - - # Create a temporary zip file and return it; delete after response is sent - tmp = NamedTemporaryFile(delete=False, suffix=".zip") - tmp_path = tmp.name - tmp.close() - - # Use maximum compression level for smaller email-friendly files - compression = zipfile.ZIP_DEFLATED - compresslevel = 9 # Python 3.7+ supports compresslevel for ZipFile - with zipfile.ZipFile(tmp_path, mode="w", compression=compression, compresslevel=compresslevel) as zf: - for fpath in files: - arcname = os.path.basename(fpath) - zf.write(fpath, arcname=arcname) - - filename = f"openscan_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip" - - def _cleanup(path: str) -> None: - try: - os.remove(path) - except OSError: - pass - - return FileResponse( - tmp_path, - media_type="application/zip", - filename=filename, - background=BackgroundTask(_cleanup, tmp_path), - ) diff --git a/openscan_firmware/routers/v0_7/projects.py b/openscan_firmware/routers/v0_7/projects.py deleted file mode 100644 index 305fcbe..0000000 --- a/openscan_firmware/routers/v0_7/projects.py +++ /dev/null @@ -1,618 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query -from fastapi.encoders import jsonable_encoder -from fastapi.responses import FileResponse, StreamingResponse -from pydantic import BaseModel -import pathlib -from typing import Optional, List, Any -import asyncio -import os -import json -import mimetypes -from datetime import datetime -import logging - - -from openscan_firmware.controllers.hardware.cameras.camera import get_all_camera_controllers, get_camera_controller -from openscan_firmware.controllers.services import projects, cloud -import openscan_firmware.controllers.services.scans as scans #import start_scan, cancel_scan, pause_scan, resume_scan -from openscan_firmware.models.project import Project -from openscan_firmware.config.scan import ScanSetting -from openscan_firmware.models.scan import Scan -from openscan_firmware.models.task import Task, TaskStatus - -from openscan_firmware.controllers.services.projects import get_project_manager -from openscan_firmware.controllers.services.tasks.task_manager import task_manager, get_task_manager - -router = APIRouter( - prefix="/projects", - tags=["projects"], - responses={404: {"description": "Not found"}}, -) - -logger = logging.getLogger(__name__) - -class DeleteResponse(BaseModel): - success: bool - message: str - deleted: list[str] - - -class PhotoResponse(BaseModel): - project_name: str - scan_index: int - filename: str - content_type: str - size_bytes: int - metadata: Optional[dict[str, Any]] = None - photo_data: bytes - - -@router.get("/", response_model=dict[str, Project]) -async def get_projects(): - """Get all projects with serialized data - - Returns: - dict[str, Project]: A dictionary of project name to a project object - """ - project_manager = get_project_manager() - projects_dict = project_manager.get_all_projects() - return projects_dict - -@router.get("/{project_name}", response_model=Project) -async def get_project(project_name: str): - """Get a project - - Args: - project_name: The name of the project to get - - Returns: - Project: The project object if found, None if not - """ - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - return project - - -@router.get("/{project_name}/thumbnail") -async def get_project_thumbnail(project_name: str): - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - thumbnail_path = os.path.join(project.path, "thumbnail.jpg") - if not os.path.exists(thumbnail_path): - raise HTTPException(status_code=404, detail="thumbnail.jpg not found") - - return FileResponse(thumbnail_path, media_type="image/jpeg", filename="thumbnail.jpg") - - -@router.post("/{project_name}", response_model=Project) -async def new_project(project_name: str, project_description: Optional[str] = ""): - """Create a new project - - Args: - project_name: The name of the project to create - project_description: Optional description for the project - - Returns: - Project: The newly created project if successful, None if not - """ - try: - project_manager = get_project_manager() - return project_manager.add_project(project_name, project_description) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@router.post("/{project_name}/scan", response_model=Task) -async def add_scan_with_description(project_name: str, - camera_name: str, - scan_settings: ScanSetting, - scan_description: Optional[str] = "") -> Task: - """Add a new scan to a project and return the created Task - - Args: - project_name: The name of the project to add the scan to - camera_name: The name of the camera to use for the scan - scan_settings: The settings for the scan - scan_description: Optional description for the scan - - Returns: - Task: The Task representing the started scan - """ - camera_controller = get_camera_controller(camera_name) - project_manager = get_project_manager() - - try: - scan = project_manager.add_scan(project_name, camera_controller, scan_settings, scan_description) - task = await scans.start_scan(project_manager, scan, camera_controller) - return task - - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to start scan: {e}") - - - -# Cloud uploads -------------------------------------------------------------- - - -@router.post("/{project_name}/upload", response_model=Task) -async def upload_project_to_cloud(project_name: str, token_override: Optional[str] = None) -> Task: - """Schedule an asynchronous cloud upload for a project. - - Args: - project_name: The name of the project - token_override: Optional token override - - Returns: - Task: The TaskManager model describing the scheduled upload - """ - try: - task = await cloud.upload_project(project_name, token=token_override) - except cloud.CloudServiceError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return task - - -@router.delete("/{project_name}/{scan_index}/photos", response_model=DeleteResponse) -async def delete_photos(project_name: str, scan_index: int, photo_filenames: list[str]): - """Delete photos from a scan in a project - - Args: - project_name: The name of the project - scan_index: The index of the scan - photo_filenames: A list of photo filenames to delete - - Returns: - True if the photos were deleted successfully, False otherwise - """ - project_manager = get_project_manager() - try: - scan = project_manager.get_scan_by_index(project_name, scan_index) - project_manager.delete_photos(scan, photo_filenames) - return DeleteResponse( - success=True, - message="Photos deleted successfully", - deleted=photo_filenames - ) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.delete("/{project_name}", response_model=DeleteResponse) -async def delete_project(project_name: str): - """Delete a project - - Args: - project_name: The name of the project to delete - - Returns: - DeleteResponse: A response object containing the result of the deletion - """ - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - try: - project_manager.delete_project(project) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - return DeleteResponse( - success=True, - message="Project deleted successfully", - deleted=[project_name] - ) - - -@router.get("/{project_name}/{scan_index:int}/photo", response_model=PhotoResponse) -async def get_scan_photo( - project_name: str, - scan_index: int, - filename: str = Query(..., description="Photo filename including extension, e.g. scan01_001.jpg"), - file_only: bool = Query(False, description="Return only the raw file instead of JSON payload"), -): - """Fetch a stored scan photo either as JSON payload or direct file download.""" - project_manager = get_project_manager() - try: - scan, photo_path, metadata = project_manager.get_photo_file(project_name, scan_index, filename) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except FileNotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - - content_type, _ = mimetypes.guess_type(photo_path) - media_type = content_type or "application/octet-stream" - - if file_only: - return FileResponse(photo_path, media_type=media_type, filename=filename) - - def _read_file_bytes(path: str) -> bytes: - with open(path, "rb") as handle: - return handle.read() - - photo_bytes = await asyncio.to_thread(_read_file_bytes, photo_path) - - return PhotoResponse( - project_name=scan.project_name, - scan_index=scan.index, - filename=filename, - content_type=media_type, - size_bytes=len(photo_bytes), - metadata=metadata, - photo_data=photo_bytes, - ) - - -@router.get("/{project_name}/scans/{scan_index:int}/path") -async def get_scan_path(project_name: str, scan_index: int): - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - - scan_dir = os.path.join(project.path, f"scan{scan_index:02d}") - path_file = os.path.join(scan_dir, "path.json") - if not os.path.exists(path_file): - raise HTTPException(status_code=404, detail="path.json not found") - - def _read_json(path: str) -> dict: - with open(path, "r", encoding="utf-8") as handle: - return json.load(handle) - - return await asyncio.to_thread(_read_json, path_file) - - -@router.delete("/{project_name}/scans/{scan_index}", response_model=DeleteResponse) -async def delete_scan(project_name: str, scan_index: int): - """Delete a scan from a project - - Args: - project_name: The name of the project - scan_index: The index of the scan to delete - - Returns: - DeleteResponse: Result of the deletion operation - """ - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - try: - project_manager.delete_scan(scan) - return DeleteResponse( - success=True, - message="Scan deleted successfully", - deleted=[f"{project_name}:scan{scan_index:02d}"] - ) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - -@router.get("/{project_name}/scans/{scan_index:int}/status", response_model=Task) -async def get_scan_status(project_name: str, scan_index: int): - """Get the current task for a scan - - Args: - project_name: The name of the project - scan_index: The index of the scan to get the status of - - Returns: - Task: The task representing the scan execution - """ - try: - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - if not scan.task_id: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} has no associated task") - - task_manager_instance = get_task_manager() - task = task_manager_instance.get_task_info(scan.task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {scan.task_id} not found for scan {scan_index}") - - return task - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.patch("/{project_name}/scans/{scan_index:int}/pause", response_model=Task) -async def pause_scan(project_name: str, scan_index: int) -> Task: - """Pause a running scan and return the updated Task - - Args: - project_name: The name of the project - scan_index: The index of the scan to pause - - Returns: - Task: The updated task state - """ - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - - task = await scans.pause_scan(scan) - if task is None: - raise HTTPException(status_code=409, detail="Scan is not running or cannot be paused.") - - return task - - -@router.patch("/{project_name}/scans/{scan_index:int}/resume", response_model=Task) -async def resume_scan(project_name: str, scan_index: int, camera_name: str) -> Task: - """Resume a paused, cancelled or failed scan and return the resulting Task - - Args: - project_name: The name of the project - scan_index: The index of the scan to resume - camera_name: The name of the camera to use for the scan - - Returns: - Task: The resumed or restarted task - """ - try: - - camera_controller = get_camera_controller(camera_name) - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - - task_manager_instance = get_task_manager() - existing_task = task_manager_instance.get_task_info(scan.task_id) if scan.task_id else None - - if existing_task and existing_task.status == TaskStatus.PAUSED: - task = await scans.resume_scan(scan) - elif not existing_task or existing_task.status in [ - TaskStatus.COMPLETED, - TaskStatus.CANCELLED, - TaskStatus.ERROR, - TaskStatus.INTERRUPTED, - ]: - task = await scans.start_scan( - project_manager, - scan, - camera_controller, - start_from_step=scan.current_step - ) - else: - raise HTTPException(status_code=409, detail=f"Scan cannot be resumed from its current state: {existing_task.status.value}") - - if task is None: - raise HTTPException(status_code=409, detail="Failed to resume scan task.") - - return task - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.patch("/{project_name}/scans/{scan_index:int}/cancel", response_model=Task) -async def cancel_scan(project_name: str, scan_index: int) -> Task: - """Cancel a running scan and return the resulting Task - - Args: - project_name: The name of the project - scan_index: The index of the scan to cancel - - Returns: - Task: The updated task state - """ - project_manager = get_project_manager() - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") - - try: - task = await scans.cancel_scan(scan) - except HTTPException: - raise - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc - - if task is None: - raise HTTPException(status_code=409, detail="Scan is not running or cannot be cancelled.") - - return task - - -def _serialize_project_for_zip(project: Project) -> str: - """Serialize a project to JSON for inclusion in a ZIP file - - Args: - project: Project to serialize - - Returns: - str: JSON string representation of the project - """ - # Use jsonable_encoder to convert the project to a dict - project_dict = jsonable_encoder(project) - - # Convert to JSON string - return json.dumps(project_dict, indent=2) - - -@router.get("/{project_name}/zip") -async def download_project(project_name: str): - """Download a project as a ZIP file stream - - This endpoint streams the entire project directory as a ZIP file, - including all scans, photos, and metadata. - - Args: - project_name: Name of the project to download - - Returns: - StreamingResponse: ZIP file stream - """ - try: - # Import zipstream-ng - from zipstream import ZipStream - project_manager = get_project_manager() - # Get project - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - # Create ZipStream from project path - zs = ZipStream.from_path(project.path) - - # Add project metadata - zs.add(_serialize_project_for_zip(project), "project_metadata.json") - - # Return streaming response - headers = { - "Content-Disposition": f"attachment; filename={project_name}.zip", - } - if getattr(zs, "last_modified", None): - headers["Last-Modified"] = str(zs.last_modified) - - response = StreamingResponse( - zs, - media_type="application/zip", - headers=headers, - ) - - return response - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{project_name}/model/zip") -async def download_project_model(project_name: str): - """Download the reconstructed model directory of a project as a ZIP file.""" - - try: - from zipstream import ZipStream - except ModuleNotFoundError as exc: # pragma: no cover - dependency issue - raise HTTPException(status_code=500, detail="zipstream is not installed") from exc - - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - model_dir = pathlib.Path(project.path) / "model" - if not model_dir.exists() or not model_dir.is_dir(): - raise HTTPException( - status_code=404, - detail=f"No reconstructed model present for project {project_name}", - ) - - zs = ZipStream(sized=True) - zs.comment = f"OpenScan3 Project Model: {project_name}" - zs.add_path(str(model_dir), "model") - zs.add(_serialize_project_for_zip(project), "project_metadata.json") - - headers = { - "Content-Disposition": f"attachment; filename={project_name}_model.zip", - } - if getattr(zs, "last_modified", None): - headers["Last-Modified"] = str(zs.last_modified) - - return StreamingResponse( - zs, - media_type="application/zip", - headers=headers, - ) - - -@router.get("/{project_name}/scans/zip") -async def download_scans(project_name: str, scan_indices: List[int] = Query(None)): - """Download selected scans from a project as a ZIP file stream - - This endpoint streams selected scans from a project as a ZIP file. - If no scan indices are provided, all scans will be included. - - Args: - project_name: Name of the project - scan_indices: List of scan indices to include in the ZIP file - - Returns: - StreamingResponse: ZIP file stream - """ - try: - from zipstream import ZipStream - project_manager = get_project_manager() - project = project_manager.get_project_by_name(project_name) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") - - zs = ZipStream(sized=True) - zs.comment = f"OpenScan3 Project: {project_name} - Generated on {datetime.now().isoformat()}" - - # Build filename based on what's being downloaded - if scan_indices: - if len(scan_indices) == 1: - filename = f"{project_name}_scan{scan_indices[0]:02d}.zip" - else: - scan_nums = "_".join(str(i) for i in sorted(scan_indices)) - filename = f"{project_name}_scans_{scan_nums}.zip" - - for scan_index in scan_indices: - try: - scan = project_manager.get_scan_by_index(project_name, scan_index) - if not scan: - logger.error(f"Scan with index {scan_index} not found") - continue - scan_dir = os.path.join(project.path, f"scan{scan_index:02d}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan{scan_index:02d}") - except Exception as e: - logger.error(f"Failed to add scan {scan_index} to zip: {e}") - continue - else: - filename = f"{project_name}_all_scans.zip" - for scan_id, scan in project.scans.items(): - scan_dir = os.path.join(project.path, f"scan_{scan.index}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan_{scan.index}") - - zs.add(_serialize_project_for_zip(project), "project_metadata.json") - - headers = { - "Content-Disposition": f"attachment; filename={filename}", - } - if getattr(zs, "last_modified", None): - headers["Last-Modified"] = str(zs.last_modified) - - response = StreamingResponse( - zs, - media_type="application/zip", - headers=headers, - ) - return response - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"") - except Exception as e: - print(e) - raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{project_name}/scans/{scan_index:int}", response_model=Scan) -async def get_scan(project_name: str, scan_index: int): - """Get Scan by project and index - - Args: - project_name: The name of the project - scan_index: The index of the scan - - Returns: - Scan: The scan object - """ - try: - project_manager = get_project_manager() - return project_manager.get_scan_by_index(project_name, scan_index) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) diff --git a/openscan_firmware/routers/v0_7/settings_utils.py b/openscan_firmware/routers/v0_7/settings_utils.py deleted file mode 100644 index 9d8f10f..0000000 --- a/openscan_firmware/routers/v0_7/settings_utils.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Any, Callable, Dict, Type, TypeVar -from fastapi import APIRouter, Body, HTTPException -from pydantic import BaseModel - -T = TypeVar('T', bound=BaseModel) - - -def create_settings_endpoints( - router: APIRouter, - resource_name: str, - get_controller: Callable[[str], Any], - settings_model: Type[T] -) -> Dict[str, Callable[..., Any]]: - """ - Create standardized settings endpoints for a resource. - - Args: - router: The FastAPI router to add endpoints to - resource_name: Name of the resource (e.g., 'camera', 'motor') - get_controller: Function to get the controller by name - settings_model: Pydantic model for the settings - """ - - path = "/{name}/settings" - - @router.get( - path, - response_model=settings_model, - name=f"get_{resource_name}_settings", - ) - async def get_settings(name: str) -> T: - """Get settings for a specific resource""" - controller = get_controller(name) - return controller.settings.model - - @router.put( - path, - response_model=settings_model, - name=f"replace_{resource_name}_settings", - ) - async def replace_settings(name: str, settings: settings_model) -> T: - """Replace all settings for a specific resource""" - controller = get_controller(name) - try: - controller.settings.replace(settings) - return controller.settings.model - except Exception as e: - raise HTTPException(status_code=422, detail=str(e)) - - - @router.patch( - path, - response_model=settings_model, - name=f"update_{resource_name}_settings", - ) - async def update_settings( - name: str, - settings: Dict[str, Any] = Body(..., examples=[{"some_setting": 123}]) - ) -> T: - """Update one or more specific settings for a resource - - Args: - name: The name of the resource to update settings for - settings: A dictionary of settings to update - - Returns: - The updated settings for the resource - """ - controller = get_controller(name) - try: - controller.settings.update(**settings) - return controller.settings.model - except Exception as e: - raise HTTPException(status_code=422, detail=str(e)) - - return { - "get_settings": get_settings, - "replace_settings": replace_settings, - "update_settings": update_settings - } \ No newline at end of file diff --git a/openscan_firmware/routers/v0_7/tasks.py b/openscan_firmware/routers/v0_7/tasks.py deleted file mode 100644 index 0c193a0..0000000 --- a/openscan_firmware/routers/v0_7/tasks.py +++ /dev/null @@ -1,152 +0,0 @@ -from typing import List, Any, Dict - -from fastapi import APIRouter, HTTPException, status, Body - -from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager -from openscan_firmware.models.task import Task, TaskStatus - - -router = APIRouter( - prefix="/tasks", - tags=["tasks"], - responses={404: {"description": "Not found"}}, -) - - -@router.get("/", response_model=List[Task]) -async def get_all_tasks(): - """ - Retrieve a list of all tasks known to the task manager. - - Returns: - List[Task]: A list of all tasks known to the task manager. - """ - task_manager = get_task_manager() - return task_manager.get_all_tasks_info() - - -@router.get("/{task_id}", response_model=Task) -async def get_task_status(task_id: str): - """ - Retrieve the status and details of a specific task. - - Args: - task_id: The ID of the task to retrieve. - - Returns: - Task: The task object with its status and details. - """ - task_manager = get_task_manager() - task = task_manager.get_task_info(task_id) - if not task: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") - return task - - -@router.delete("/{task_id}", response_model=Task) -async def cancel_task(task_id: str): - """ - Request cancellation of a running task. - - Args: - task_id: The ID of the task to cancel. - - Returns: - Task: The task object with its status and details. - """ - task_manager = get_task_manager() - task = await task_manager.cancel_task(task_id) - if not task: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") - return task - - -@router.post("/{task_id}/pause", response_model=Task, summary="Pause a Task") -async def pause_task(task_id: str): - """ - Pauses a running task. - - Args: - task_id: The ID of the task to pause. - - Returns: - Task: The task object with its status and details. - """ - task_manager = get_task_manager() - task = await task_manager.pause_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found or cannot be paused.") - if task.status not in [TaskStatus.PAUSED, TaskStatus.RUNNING]: - pass - return task - - -@router.post("/{task_id}/resume", response_model=Task, summary="Resume a Task") -async def resume_task(task_id: str): - """ - Resumes a paused task. - - Args: - task_id: The ID of the task to resume. - - Returns: - Task: The task object with its status and details. - """ - task_manager = get_task_manager() - task = await task_manager.resume_task(task_id) - if not task: - raise HTTPException(status_code=404, detail=f"Task {task_id} not found or cannot be resumed.") - if task.status not in [TaskStatus.RUNNING, TaskStatus.PAUSED]: - pass - return task - - -@router.post("/{task_name}", response_model=Task, status_code=status.HTTP_202_ACCEPTED) -async def create_task( - task_name: str, - args: List[Any] = Body(default=[], description="Positional arguments for the task"), - kwargs: Dict[str, Any] = Body(default={}, description="Keyword arguments for the task") -): - """ - Create and start a new background task with optional parameters. - - The request body accepts: - - **args**: List of positional arguments (e.g., `["project_name", 0]`) - - **kwargs**: Dictionary of keyword arguments (e.g., `{"num_batches": 5}`) - - Args: - task_name: The name of the task to create, as registered in the TaskManager. - args: Positional arguments to pass to the task's run method. - kwargs: Keyword arguments to pass to the task's run method. - - Returns: - The created task object. - - Examples: - ```json - // No parameters - {} - - // With positional args - { - "args": ["MyProject", 0] - } - - // With keyword args - { - "kwargs": {"num_calibration_batches": 5} - } - - // With both - { - "args": ["MyProject", 0], - "kwargs": {"num_calibration_batches": 5} - } - ``` - """ - try: - task_manager = get_task_manager() - task = await task_manager.create_and_run_task(task_name, *args, **kwargs) - return task - except ValueError as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) \ No newline at end of file From 1676623cfbd5b0c0fc9dd44851fa7c7493e5486e Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 31 Mar 2026 11:22:31 +0200 Subject: [PATCH 26/75] refactor(device): improve configuration handling and logging - Made `model` and `shield` fields in `DeviceStatusResponse` optional. - Enhanced logging for configuration actions, including payload previews and file resolution details. - Added `resolve_settings_path` for consistent path resolution in configuration management. - Improved error handling and messaging for invalid or missing configuration files. --- openscan_firmware/controllers/device.py | 31 +++++++++++++++++++++--- openscan_firmware/routers/next/device.py | 30 +++++++++++++++++------ openscan_firmware/utils/dir_paths.py | 15 ++++++++++++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index f05ba90..e60f6da 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -65,7 +65,11 @@ from openscan_firmware.controllers.services.projects import get_project_manager from openscan_firmware.controllers.services.device_events import schedule_device_status_broadcast -from openscan_firmware.utils.dir_paths import resolve_settings_dir, resolve_settings_file +from openscan_firmware.utils.dir_paths import ( + resolve_settings_dir, + resolve_settings_file, + resolve_settings_path, +) from openscan_firmware.utils.firmware_state import mark_clean_shutdown from openscan_firmware.utils.inactivity_timer import inactivity_timer, inactivity_timer_paused @@ -203,19 +207,38 @@ async def set_device_config(config_file) -> bool: """Set the device configuration from a file and initialize hardware. Args: - config_file: Path to the configuration file + config_file: Path or filename to the configuration file Returns: bool: True if successful, False otherwise """ - config = load_device_config(config_file) + resolved_path = resolve_settings_path("device", config_file) + if not resolved_path.exists(): + logger.error( + "Requested device configuration file does not exist", + extra={"requested": str(config_file), "resolved_path": str(resolved_path)}, + ) + return False + + logger.info( + "Loading device configuration from file", + extra={"requested": str(config_file), "resolved_path": str(resolved_path)}, + ) + + config = load_device_config(str(resolved_path)) await initialize(config) if not save_device_config(): - logger.error("Failed to persist device configuration after loading %s", config_file) + logger.error( + "Failed to persist device configuration after loading %s", resolved_path + ) return False + logger.info( + "Device configuration applied", + extra={"requested": str(config_file), "resolved_path": str(resolved_path)}, + ) return True diff --git a/openscan_firmware/routers/next/device.py b/openscan_firmware/routers/next/device.py index ce4c5c6..f558ea5 100644 --- a/openscan_firmware/routers/next/device.py +++ b/openscan_firmware/routers/next/device.py @@ -29,8 +29,8 @@ class DeviceConfigRequest(BaseModel): class DeviceStatusResponse(BaseModel): name: str - model: str - shield: str + model: str | None = None + shield: str | None = None cameras: dict[str, CameraStatusResponse] motors: dict[str, MotorStatusResponse] lights: dict[str, LightStatusResponse] @@ -53,7 +53,9 @@ class DeviceConfigResponse(BaseModel): def _runtime_status_response() -> DeviceStatusResponse: - return DeviceStatusResponse.model_validate(device.get_device_info()) + raw_info = device.get_device_info() + logger.debug("Device info payload before validation: %s", raw_info) + return DeviceStatusResponse.model_validate(raw_info) @router.get("/info", response_model=DeviceStatusResponse) @@ -109,7 +111,7 @@ async def get_current_config(): async def get_config_file(filename: str): """Return a specific configuration JSON file by filename.""" try: - logger.debug("Reading configuration file request", extra={"filename": filename}) + logger.debug("Reading configuration file request", extra={"config_filename": filename}) normalized = filename if filename.endswith(".json") else f"{filename}.json" safe_name = Path(normalized).name config_path = resolve_settings_dir("device") / safe_name @@ -158,11 +160,20 @@ async def add_config_json(config_data: ScannerDeviceConfig, filename: DeviceConf dict: A dictionary containing the status of the operation """ try: - logger.info("Persisting uploaded configuration", extra={"filename": filename.config_file}) + logger.info("Persisting uploaded configuration", extra={"config_filename": filename.config_file}) # Create a temporary file to save the configuration with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as temp_file: # Convert the model to a dictionary and save it as JSON config_dict = config_data.model_dump(mode="json") + payload_preview = json.dumps(config_dict, ensure_ascii=False) + max_payload_chars = 2000 + if len(payload_preview) > max_payload_chars: + payload_preview = f"{payload_preview[:max_payload_chars]}... [truncated]" + logger.info( + "Incoming configuration payload for %s: %s", + filename.config_file, + payload_preview, + ) json.dump(config_dict, temp_file, indent=4) temp_path = temp_file.name @@ -170,7 +181,9 @@ async def add_config_json(config_data: ScannerDeviceConfig, filename: DeviceConf settings_dir = resolve_settings_dir("device") os.makedirs(settings_dir, exist_ok=True) - target_filename = f"{filename.config_file}.json" + target_filename = filename.config_file + if not target_filename.endswith(".json"): + target_filename = f"{target_filename}.json" target_path = os.path.join(settings_dir, target_filename) # Move the temporary file to the target path @@ -180,7 +193,8 @@ async def add_config_json(config_data: ScannerDeviceConfig, filename: DeviceConf logger.info( "Configuration saved", extra={ - "filename": target_filename, + "config_filename": target_filename, + "config_path": target_path, "motors": list(status.motors.keys()), }, ) @@ -192,7 +206,7 @@ async def add_config_json(config_data: ScannerDeviceConfig, filename: DeviceConf ) except Exception as e: - logger.exception("Error while saving configuration", extra={"filename": filename.config_file}) + logger.exception("Error while saving configuration", extra={"config_filename": filename.config_file}) raise HTTPException(status_code=500, detail=f"Error setting device configuration: {str(e)}") diff --git a/openscan_firmware/utils/dir_paths.py b/openscan_firmware/utils/dir_paths.py index 957f0f8..c4dc5e4 100644 --- a/openscan_firmware/utils/dir_paths.py +++ b/openscan_firmware/utils/dir_paths.py @@ -81,6 +81,21 @@ def resolve_settings_file(subdirectory: str, filename: str) -> Path: return resolve_settings_dir(subdirectory) / filename +def resolve_settings_path(subdirectory: str, path_or_filename: str | os.PathLike | None) -> Path: + """Resolve a settings-relative path, supporting absolute overrides.""" + + if path_or_filename is None: + return resolve_settings_dir(subdirectory) + + candidate = Path(path_or_filename) + if candidate.exists() or candidate.is_absolute(): + return candidate + + base_dir = resolve_settings_dir(subdirectory) + resolved = base_dir / candidate + return resolved + + def load_settings_json(filename: str, subdirectory: str | None = None) -> dict[str, Any] | None: """Load a JSON settings file from the resolved settings directory.""" settings_dir = resolve_settings_dir(subdirectory) From 93b6790993e3347249422b604a9f725cde505b07 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 31 Mar 2026 11:49:32 +0200 Subject: [PATCH 27/75] chore(device): update device names in example configuration files --- settings/device/default_mini_blackshield.json | 2 +- settings/device/example_custom.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/settings/device/default_mini_blackshield.json b/settings/device/default_mini_blackshield.json index c3eee69..4790def 100644 --- a/settings/device/default_mini_blackshield.json +++ b/settings/device/default_mini_blackshield.json @@ -1,5 +1,5 @@ { - "name": "Mini v2.1", + "name": "Mini v2 (Blackshield)", "model": "mini", "shield": "blackshield", "cameras": {}, diff --git a/settings/device/example_custom.json b/settings/device/example_custom.json index 6236f8c..9c3225a 100644 --- a/settings/device/example_custom.json +++ b/settings/device/example_custom.json @@ -1,5 +1,5 @@ { - "name": "Custom device by MicioMax", + "name": "Custom device example by MicioMax", "model": "custom", "shield": "custom", "cameras": {}, From 1527cfc33aa6e3ace8eb7b06481b715f31e9a17c Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 31 Mar 2026 12:20:02 +0200 Subject: [PATCH 28/75] fix(device): validate `model` and `shield` fields to ensure proper device configuration and compatibility with frontend --- openscan_firmware/routers/next/device.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openscan_firmware/routers/next/device.py b/openscan_firmware/routers/next/device.py index f558ea5..d1dc1eb 100644 --- a/openscan_firmware/routers/next/device.py +++ b/openscan_firmware/routers/next/device.py @@ -67,6 +67,25 @@ async def get_device_info(): """ try: info = device.get_device_info() + if info.get("model") is None or info.get("shield") is None: + raise HTTPException( + status_code=503, + detail={ + "message": "Device configuration is not loaded.", + "errors": [ + { + "loc": ["model"], + "msg": "Input should be a valid string", + "input": info.get("model"), + }, + { + "loc": ["shield"], + "msg": "Input should be a valid string", + "input": info.get("shield"), + }, + ], + }, + ) return DeviceStatusResponse.model_validate(info) except ValidationError as exc: raise HTTPException( From e031c1e7acb60eaaacea296729f30f3c0b28125d Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 31 Mar 2026 15:35:43 +0200 Subject: [PATCH 29/75] feat(firmware): extend endstop status with additional fields and update API schema - Added `pull_up`, `active_high`, and `bounce_time` fields to `EndstopStatusResponse`. - Adjusted `get_status` in `endstops.py` to include new configuration details. - Updated motor routers and OpenAPI schema to reflect extended endstop status. --- .../controllers/hardware/endstops.py | 19 ++-- openscan_firmware/routers/next/motors.py | 13 ++- scripts/openapi/openapi_next.json | 87 +++++++++++++++++-- 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/openscan_firmware/controllers/hardware/endstops.py b/openscan_firmware/controllers/hardware/endstops.py index 75aa4ea..e44446c 100644 --- a/openscan_firmware/controllers/hardware/endstops.py +++ b/openscan_firmware/controllers/hardware/endstops.py @@ -82,16 +82,17 @@ def get_config(self) -> EndstopConfig: def get_status(self) -> dict: - """ Returns the current status of the endstop. - - Returns: - dict: A dictionary containing the status of the endstop. - """ + """Returns the current status and config snapshot of the endstop.""" pressed = is_button_pressed(self.settings.pin) - return {"assigned_motor": self.settings.motor_name, - "position": self.settings.angular_position, - "pin": self.settings.pin, - "is_pressed": pressed if self.settings.active_high else (not pressed)} + return { + "assigned_motor": self.settings.motor_name, + "position": self.settings.angular_position, + "pin": self.settings.pin, + "is_pressed": pressed if self.settings.active_high else (not pressed), + "pull_up": self.settings.pull_up, + "active_high": self.settings.active_high, + "bounce_time": self.settings.bounce_time, + } async def _move_back_task(self): diff --git a/openscan_firmware/routers/next/motors.py b/openscan_firmware/routers/next/motors.py index 0001807..dd8e1e8 100644 --- a/openscan_firmware/routers/next/motors.py +++ b/openscan_firmware/routers/next/motors.py @@ -5,6 +5,7 @@ from typing import Optional from openscan_firmware.config.motor import MotorConfig +from openscan_firmware.config.endstop import EndstopConfig from openscan_firmware.controllers.hardware.motors import get_motor_controller, get_all_motor_controllers from .settings_utils import create_settings_endpoints @@ -24,6 +25,16 @@ def _get_motor_controller_or_404(motor_name: str): raise HTTPException(status_code=404, detail=str(exc)) from exc +class EndstopStatusResponse(BaseModel): + assigned_motor: str + position: float + pin: int + is_pressed: bool + pull_up: bool | None = None + active_high: bool | None = None + bounce_time: float | None = None + + class MotorStatusResponse(BaseModel): name: str angle: float @@ -31,7 +42,7 @@ class MotorStatusResponse(BaseModel): target_angle: Optional[float] settings: MotorConfig calibrated: bool - endstop: Optional[dict] + endstop: Optional[EndstopStatusResponse] @router.get("/", response_model=dict[str, MotorStatusResponse]) diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index fe21f39..3da5fe7 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -4996,11 +4996,25 @@ "title": "Name" }, "model": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Model" }, "shield": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Shield" }, "cameras": { @@ -5042,8 +5056,6 @@ "type": "object", "required": [ "name", - "model", - "shield", "cameras", "motors", "lights", @@ -5147,6 +5159,67 @@ "title": "EndstopConfig", "description": "Configuration for a motor endstop.\n\nArgs:\n pin (int): GPIO pin number used for the endstop.\n angular_position (float): Angle at which the endstop is triggered (degrees).\n motor_name (str): Name of the assigned motor.\n pull_up (Optional[bool]): Whether to use a pull-up resistor (default: True).\n bounce_time (Optional[float]): Debounce time for the button in seconds (default: 0.005)." }, + "EndstopStatusResponse": { + "properties": { + "assigned_motor": { + "type": "string", + "title": "Assigned Motor" + }, + "position": { + "type": "number", + "title": "Position" + }, + "pin": { + "type": "integer", + "title": "Pin" + }, + "is_pressed": { + "type": "boolean", + "title": "Is Pressed" + }, + "pull_up": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pull Up" + }, + "active_high": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active High" + }, + "bounce_time": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Bounce Time" + } + }, + "type": "object", + "required": [ + "assigned_motor", + "position", + "pin", + "is_pressed" + ], + "title": "EndstopStatusResponse" + }, "FirmwareSettingPatchRequest": { "properties": { "value": { @@ -5369,14 +5442,12 @@ "endstop": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/EndstopStatusResponse" }, { "type": "null" } - ], - "title": "Endstop" + ] } }, "type": "object", From d64c69d88ece77d6bdd17f063f2d90c4409e8c10 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 31 Mar 2026 16:16:33 +0200 Subject: [PATCH 30/75] feat(cloud): add endpoint to delete cloud settings and disable cloud features - Introduced `DELETE /cloud/settings` endpoint to remove persisted cloud settings. - Added logic to disable firmware cloud features upon settings deletion. - Updated OpenAPI schema to document the new endpoint. - Enhanced tests to ensure proper flag handling and functionality. --- .../controllers/services/cloud_settings.py | 12 +++++++ openscan_firmware/routers/next/cloud.py | 26 +++++++++++++++ scripts/openapi/openapi_next.json | 23 +++++++++++++ tests/routers/test_cloud_router.py | 33 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/openscan_firmware/controllers/services/cloud_settings.py b/openscan_firmware/controllers/services/cloud_settings.py index dde685e..37a5f3a 100644 --- a/openscan_firmware/controllers/services/cloud_settings.py +++ b/openscan_firmware/controllers/services/cloud_settings.py @@ -50,6 +50,18 @@ def save_persistent_cloud_settings(settings: CloudSettings) -> Path: return target +def delete_persistent_cloud_settings() -> bool: + """Remove persisted cloud settings if they exist.""" + + target = get_settings_path() + if not target.exists(): + return False + + target.unlink() + logger.debug("Deleted cloud settings at %s", target) + return True + + def load_persistent_cloud_settings() -> CloudSettings | None: """Load cloud settings from disk if available.""" diff --git a/openscan_firmware/routers/next/cloud.py b/openscan_firmware/routers/next/cloud.py index be09d07..e84723d 100644 --- a/openscan_firmware/routers/next/cloud.py +++ b/openscan_firmware/routers/next/cloud.py @@ -12,6 +12,10 @@ from pydantic import BaseModel, Field from openscan_firmware.config.cloud import CloudSettings, mask_secret, set_cloud_settings +from openscan_firmware.config.firmware import ( + get_firmware_settings, + save_firmware_settings, +) from openscan_firmware.controllers.services import cloud as cloud_service from openscan_firmware.controllers.services.cloud import CloudServiceError from openscan_firmware.controllers.services.cloud_settings import ( @@ -19,6 +23,7 @@ get_masked_active_settings, save_persistent_cloud_settings, set_active_source, + delete_persistent_cloud_settings, settings_file_exists, ) from openscan_firmware.controllers.services.projects import ProjectManager, get_project_manager @@ -156,6 +161,16 @@ def _mask_tokens(text: str | None) -> str | None: ) +def _disable_cloud_features() -> None: + settings = get_firmware_settings() + if not settings.enable_cloud: + return + + updated_settings = settings.model_copy(update={"enable_cloud": False}) + save_firmware_settings(updated_settings) + logger.info("Disabled firmware cloud features after cloud settings deletion.") + + @router.get("/status", response_model=CloudStatusResponse) async def get_cloud_status() -> CloudStatusResponse: """Return aggregated status information for the cloud backend. @@ -228,6 +243,17 @@ async def update_cloud_settings(new_settings: CloudSettings) -> CloudSettingsRes return _build_settings_response() +@router.delete("/settings", response_model=CloudSettingsResponse) +async def delete_cloud_settings() -> CloudSettingsResponse: + """Delete persisted cloud settings and disable cloud features.""" + + set_cloud_settings(None) + set_active_source(None) + await asyncio.to_thread(delete_persistent_cloud_settings) + _disable_cloud_features() + return _build_settings_response() + + @router.get("/projects", response_model=list[CloudProjectStatus]) async def list_cloud_projects() -> list[CloudProjectStatus]: """Return all local projects enriched with cloud metadata. diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index 3da5fe7..3362223 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -3877,6 +3877,29 @@ } } } + }, + "delete": { + "tags": [ + "cloud" + ], + "summary": "Delete Cloud Settings", + "description": "Delete persisted cloud settings and disable cloud features.", + "operationId": "delete_cloud_settings", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudSettingsResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } } }, "/cloud/projects": { diff --git a/tests/routers/test_cloud_router.py b/tests/routers/test_cloud_router.py index 90e5ada..56d1b76 100644 --- a/tests/routers/test_cloud_router.py +++ b/tests/routers/test_cloud_router.py @@ -6,6 +6,7 @@ from fastapi.testclient import TestClient from openscan_firmware.config.cloud import CloudSettings, set_cloud_settings +from openscan_firmware.config.firmware import FirmwareSettings from openscan_firmware.controllers.services.cloud_settings import set_active_source from openscan_firmware.models.project import Project from openscan_firmware.models.task import Task @@ -216,3 +217,35 @@ def mark_downloaded(self, name: str, downloaded: bool): assert response.json()["remote_project"] == "demo-remote.zip" assert stub_pm.calls == [("demo", False, None)] assert project.cloud_project_name is None + + +def test_delete_cloud_settings_disables_firmware_flag(client, monkeypatch, latest_router_path): + module_path = latest_router_path("cloud") + + delete_calls = {"delete": False} + monkeypatch.setattr(f"{module_path}.delete_persistent_cloud_settings", lambda: delete_calls.__setitem__("delete", True) or True) + monkeypatch.setattr(f"{module_path}.set_cloud_settings", lambda value: delete_calls.__setitem__("cloud", value)) + monkeypatch.setattr(f"{module_path}.set_active_source", lambda source: delete_calls.__setitem__("source", source)) + + firmware_settings = FirmwareSettings(qr_wifi_scan_enabled=True, enable_cloud=True) + monkeypatch.setattr(f"{module_path}.get_firmware_settings", lambda: firmware_settings) + + saved_settings: dict[str, FirmwareSettings] = {} + + def fake_save(settings: FirmwareSettings): + saved_settings["settings"] = settings + + monkeypatch.setattr(f"{module_path}.save_firmware_settings", fake_save) + monkeypatch.setattr(f"{module_path}.get_masked_active_settings", lambda: None) + monkeypatch.setattr(f"{module_path}.get_active_source", lambda: None) + monkeypatch.setattr(f"{module_path}.settings_file_exists", lambda: False) + + response = client.delete("/cloud/settings") + + assert response.status_code == 200 + payload = response.json() + assert payload == {"settings": None, "source": None, "persisted": False} + assert delete_calls["delete"] is True + assert delete_calls["cloud"] is None + assert delete_calls["source"] is None + assert saved_settings["settings"].enable_cloud is False From d9002316fbd331ca8a40c79a4dd8060c7b8e9556 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 31 Mar 2026 17:42:52 +0200 Subject: [PATCH 31/75] chore(tests): update to v0_8 in focus stacking and remove legacy v0_6/v0_7 references - Updated focus stacking test cases to use `v0_8` endpoints. - Removed references to legacy `v0_6` and `v0_7` routers in tests. - Adjusted monkeypatching to align with the latest versions. --- openscan_firmware/routers/v0_8/cloud.py | 26 +++++++++++++++++++ tests/controllers/test_device_controller.py | 20 +++++++++++--- tests/routers/test_device_router.py | 9 ++----- tests/routers/test_focus_stacking_router.py | 10 +++---- tests/routers/test_motors_router.py | 3 ++- tests/routers/test_projects_api.py | 6 ++--- tests/routers/test_projects_model_download.py | 5 ---- 7 files changed, 53 insertions(+), 26 deletions(-) diff --git a/openscan_firmware/routers/v0_8/cloud.py b/openscan_firmware/routers/v0_8/cloud.py index be09d07..e84723d 100644 --- a/openscan_firmware/routers/v0_8/cloud.py +++ b/openscan_firmware/routers/v0_8/cloud.py @@ -12,6 +12,10 @@ from pydantic import BaseModel, Field from openscan_firmware.config.cloud import CloudSettings, mask_secret, set_cloud_settings +from openscan_firmware.config.firmware import ( + get_firmware_settings, + save_firmware_settings, +) from openscan_firmware.controllers.services import cloud as cloud_service from openscan_firmware.controllers.services.cloud import CloudServiceError from openscan_firmware.controllers.services.cloud_settings import ( @@ -19,6 +23,7 @@ get_masked_active_settings, save_persistent_cloud_settings, set_active_source, + delete_persistent_cloud_settings, settings_file_exists, ) from openscan_firmware.controllers.services.projects import ProjectManager, get_project_manager @@ -156,6 +161,16 @@ def _mask_tokens(text: str | None) -> str | None: ) +def _disable_cloud_features() -> None: + settings = get_firmware_settings() + if not settings.enable_cloud: + return + + updated_settings = settings.model_copy(update={"enable_cloud": False}) + save_firmware_settings(updated_settings) + logger.info("Disabled firmware cloud features after cloud settings deletion.") + + @router.get("/status", response_model=CloudStatusResponse) async def get_cloud_status() -> CloudStatusResponse: """Return aggregated status information for the cloud backend. @@ -228,6 +243,17 @@ async def update_cloud_settings(new_settings: CloudSettings) -> CloudSettingsRes return _build_settings_response() +@router.delete("/settings", response_model=CloudSettingsResponse) +async def delete_cloud_settings() -> CloudSettingsResponse: + """Delete persisted cloud settings and disable cloud features.""" + + set_cloud_settings(None) + set_active_source(None) + await asyncio.to_thread(delete_persistent_cloud_settings) + _disable_cloud_features() + return _build_settings_response() + + @router.get("/projects", response_model=list[CloudProjectStatus]) async def list_cloud_projects() -> list[CloudProjectStatus]: """Return all local projects enriched with cloud metadata. diff --git a/tests/controllers/test_device_controller.py b/tests/controllers/test_device_controller.py index ce74a9f..58a632e 100644 --- a/tests/controllers/test_device_controller.py +++ b/tests/controllers/test_device_controller.py @@ -152,9 +152,20 @@ def get_status(self): @pytest.mark.asyncio -async def test_set_device_config_calls_initialize(monkeypatch): +async def test_set_device_config_calls_initialize(monkeypatch, tmp_path): device = _import_device(monkeypatch) + preset = tmp_path / "some.json" + preset.write_text(json.dumps({ + "name": "Y", + "model": None, + "shield": None, + "cameras": {}, + "motors": {}, + "lights": {}, + "endstops": {}, + })) + called = {} def fake_load(path=None): @@ -167,9 +178,9 @@ async def fake_init(cfg, detect_cameras=False): monkeypatch.setattr(device, "load_device_config", fake_load) monkeypatch.setattr(device, "initialize", fake_init) - ok = await device.set_device_config("/tmp/some.json") + ok = await device.set_device_config(str(preset)) assert ok is True - assert called.get("load") == "/tmp/some.json" + assert called.get("load") == str(preset) assert isinstance(called.get("init"), dict) @@ -470,12 +481,13 @@ def fake_system(cmd): monkeypatch.setattr(device.os, "system", fake_system) monkeypatch.setattr(device, "save_device_config", lambda: True) + monkeypatch.setattr(device, "cleanup_and_exit", lambda: None) device.reboot(with_saving=True) device.shutdown(with_saving=True) assert any("reboot" in c for c in sys_calls) - assert any("shutdown" in c for c in sys_calls) + assert any(("shutdown" in c) or ("poweroff" in c) for c in sys_calls) def test_get_available_configs_lists_jsons(monkeypatch, tmp_path): diff --git a/tests/routers/test_device_router.py b/tests/routers/test_device_router.py index b26e8bf..0c59a79 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": {}, + "endstops": None, "motors_timeout": 3.5, "startup_mode": "startup_enabled", "calibrate_mode": "calibrate_manual", @@ -145,6 +146,7 @@ def test_get_named_config_reads_disk(monkeypatch, tmp_path, device_client, devic "cameras": {}, "motors": {}, "lights": {}, + "endstops": None, "motors_timeout": 1.0, "startup_mode": "startup_enabled", "calibrate_mode": "calibrate_manual", @@ -287,13 +289,6 @@ def test_reinitialize_endpoint_calls_controller(monkeypatch, device_client, devi } monkeypatch.setattr(f"{module_path}.device.get_device_info", lambda: status_payload, raising=False) - class _PassthroughStatus: - @staticmethod - def model_validate(payload): - return payload - - monkeypatch.setattr(f"{module_path}.DeviceStatusResponse", _PassthroughStatus, raising=False) - detected_args: list[bool] = [] async def fake_initialize(*, detect_cameras: bool = False): diff --git a/tests/routers/test_focus_stacking_router.py b/tests/routers/test_focus_stacking_router.py index 8f4efdf..8903947 100644 --- a/tests/routers/test_focus_stacking_router.py +++ b/tests/routers/test_focus_stacking_router.py @@ -22,7 +22,7 @@ def _make_task(status: TaskStatus = TaskStatus.RUNNING) -> Task: ("resume", "patch"), ("cancel", "patch"), ]) -def test_focus_stacking_endpoints_available_only_in_latest( +def test_focus_stacking_endpoints_available_only_in_v0_8( monkeypatch, client: TestClient, endpoint: tuple[str, str], @@ -39,13 +39,13 @@ async def _stub(*args, **kwargs): _stub, ) - url = f"/v0.6/projects/demo/scans/1/focus-stacking/{action}" + url = f"/v0.8/projects/demo/scans/1/focus-stacking/{action}" response = getattr(client, method)(url) assert response.status_code == 200 assert response.json()["status"] == TaskStatus.RUNNING - legacy_url = f"/v0.5/projects/demo/scans/1/focus-stacking/{action}" + legacy_url = f"/v0.7/projects/demo/scans/1/focus-stacking/{action}" legacy_response = getattr(client, method)(legacy_url) assert legacy_response.status_code == 404 @@ -72,7 +72,7 @@ async def _stub(*args, **kwargs): _stub, ) - response = client.patch(f"/v0.6/projects/demo/scans/1/focus-stacking/{action}") + response = client.patch(f"/v0.8/projects/demo/scans/1/focus-stacking/{action}") assert response.status_code == 409 assert response.json()["detail"] == message @@ -100,6 +100,6 @@ async def _stub(*args, **kwargs): _stub, ) - response = getattr(client, method)(f"/v0.6/projects/demo/scans/1/focus-stacking/{action}") + response = getattr(client, method)(f"/v0.8/projects/demo/scans/1/focus-stacking/{action}") assert response.status_code == 404 assert response.json()["detail"] == "Scan not found" diff --git a/tests/routers/test_motors_router.py b/tests/routers/test_motors_router.py index 61e8f85..7dd1b21 100644 --- a/tests/routers/test_motors_router.py +++ b/tests/routers/test_motors_router.py @@ -50,6 +50,7 @@ def get_status(self) -> dict: "busy": self._busy, "target_angle": None, "settings": self._config, + "calibrated": True, "endstop": None, } @@ -329,7 +330,7 @@ def test_endstop_calibration_endpoint_success(monkeypatch: pytest.MonkeyPatch, c response = client.put("/next/motors/rotor/endstop-calibration") assert response.status_code == 200 - controller.calibrate.assert_awaited_once_with() + controller.calibrate.assert_awaited_once_with(force=False) def test_endstop_calibration_endpoint_no_endstop(monkeypatch: pytest.MonkeyPatch, client: TestClient): diff --git a/tests/routers/test_projects_api.py b/tests/routers/test_projects_api.py index 28309dd..1253dc2 100644 --- a/tests/routers/test_projects_api.py +++ b/tests/routers/test_projects_api.py @@ -38,8 +38,6 @@ def project_manager(monkeypatch: pytest.MonkeyPatch, tmp_path_factory) -> Genera temp_dir = tmp_path_factory.mktemp("projects_api") pm = ProjectManager(path=temp_dir) - module_path_v0_6 = "openscan_firmware.routers.v0_6.projects" - module_path_v0_7 = "openscan_firmware.routers.v0_7.projects" module_path_v0_8 = "openscan_firmware.routers.v0_8.projects" next_module_path = "openscan_firmware.routers.next.projects" @@ -48,7 +46,7 @@ def project_manager(monkeypatch: pytest.MonkeyPatch, tmp_path_factory) -> Genera lambda path=None: pm, raising=False, ) - for module_path in (module_path_v0_6, module_path_v0_7, module_path_v0_8, next_module_path): + for module_path in (module_path_v0_8, next_module_path): monkeypatch.setattr( module_path + ".get_project_manager", lambda: pm, @@ -403,4 +401,4 @@ def __len__(self) -> int: # pragma: no cover - should not be invoked assert response.status_code == 200 assert response.headers["Content-Disposition"].startswith(f"attachment; filename={project_name}") assert response.headers["Last-Modified"] == str(FakeLargeZipStream.last_modified) - assert "Content-Length" not in response.headers \ No newline at end of file + assert "Content-Length" not in response.headers diff --git a/tests/routers/test_projects_model_download.py b/tests/routers/test_projects_model_download.py index f3d2ed5..05844e5 100644 --- a/tests/routers/test_projects_model_download.py +++ b/tests/routers/test_projects_model_download.py @@ -27,11 +27,6 @@ def project_manager( lambda path=None: manager, raising=False, ) - monkeypatch.setattr( - "openscan_firmware.routers.v0_6.projects.get_project_manager", - lambda: manager, - raising=False, - ) monkeypatch.setattr( "openscan_firmware.routers.next.projects.get_project_manager", lambda: manager, From ae8cea1986fb1bac63678f08e6270b2b5488f766 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 31 Mar 2026 18:41:40 +0200 Subject: [PATCH 32/75] Add metadata response to `next` camera endpoints and support different photo formats --- openscan_firmware/routers/next/cameras.py | 198 +++++- tests/routers/test_next_cameras_router.py | 746 ++++++++++++++++++++++ 2 files changed, 935 insertions(+), 9 deletions(-) create mode 100644 tests/routers/test_next_cameras_router.py diff --git a/openscan_firmware/routers/next/cameras.py b/openscan_firmware/routers/next/cameras.py index f5db70a..51ffebf 100644 --- a/openscan_firmware/routers/next/cameras.py +++ b/openscan_firmware/routers/next/cameras.py @@ -1,12 +1,20 @@ import asyncio - -from fastapi import APIRouter, Body, HTTPException, Query +import io +import time +from dataclasses import dataclass +from threading import Lock +from typing import Literal, Optional +from uuid import uuid4 + +import numpy as np +from fastapi import APIRouter, Body, HTTPException, Query, Request from fastapi.responses import StreamingResponse, Response from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field from openscan_firmware.config.camera import CameraSettings -from openscan_firmware.models.camera import Camera, CameraType +from openscan_firmware.models.camera import Camera, CameraMetadata, CameraType, PhotoData +from openscan_firmware.models.scan import ScanMetadata from openscan_firmware.controllers.hardware.cameras.camera import ( get_all_camera_controllers, get_camera_controller, @@ -20,6 +28,129 @@ responses={404: {"description": "Not found"}}, ) +PhotoFormat = Literal["jpeg", "dng", "rgb_array", "yuv_array"] +_PAYLOAD_TTL_SECONDS = 90 +_MAX_PAYLOAD_CACHE_ENTRIES = 8 +_MAX_PAYLOAD_CACHE_BYTES = 256 * 1024 * 1024 + + +@dataclass +class _CachedPhotoPayload: + camera_name: str + content: bytes + media_type: str + filename: str + size_bytes: int + expires_at_monotonic: float + + +_photo_payload_cache: dict[str, _CachedPhotoPayload] = {} +_photo_payload_cache_lock = Lock() + + +class PhotoMetadataResponse(BaseModel): + format: PhotoFormat + media_type: str + filename: str + camera_metadata: CameraMetadata + scan_metadata: Optional[ScanMetadata] = None + payload_url: str + expires_in_s: int + + +def _prune_expired_payloads(now_monotonic: float) -> None: + expired_ids = [ + payload_id + for payload_id, payload in _photo_payload_cache.items() + if payload.expires_at_monotonic <= now_monotonic + ] + for payload_id in expired_ids: + _photo_payload_cache.pop(payload_id, None) + + +def _enforce_payload_cache_size_limit() -> None: + # Evict entries that expire first to keep newer payloads available. + sorted_ids = sorted( + _photo_payload_cache, + key=lambda payload_id: _photo_payload_cache[payload_id].expires_at_monotonic, + ) + + while len(_photo_payload_cache) > _MAX_PAYLOAD_CACHE_ENTRIES and sorted_ids: + _photo_payload_cache.pop(sorted_ids.pop(0), None) + + total_size_bytes = sum(payload.size_bytes for payload in _photo_payload_cache.values()) + while total_size_bytes > _MAX_PAYLOAD_CACHE_BYTES and sorted_ids: + payload_id = sorted_ids.pop(0) + removed = _photo_payload_cache.pop(payload_id, None) + if removed is not None: + total_size_bytes -= removed.size_bytes + + +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 ("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 isinstance(photo.data, io.BytesIO): + content = photo.data.getvalue() + elif isinstance(photo.data, (bytes, bytearray)): + content = bytes(photo.data) + elif hasattr(photo.data, "seek") and hasattr(photo.data, "read"): + photo.data.seek(0) + content = photo.data.read() + else: + raise TypeError(f"Expected byte stream for {photo.format}, got {type(photo.data).__name__}") + else: + if not isinstance(photo.data, np.ndarray): + raise TypeError(f"Expected numpy array for {photo.format}, got {type(photo.data).__name__}") + buffer = io.BytesIO() + np.save(buffer, photo.data) + content = buffer.getvalue() + + return content, media_type, filename + + +def _store_photo_payload( + camera_name: str, + content: bytes, + media_type: str, + filename: str, +) -> tuple[str, int]: + now_monotonic = time.monotonic() + payload_id = uuid4().hex + expires_at_monotonic = now_monotonic + _PAYLOAD_TTL_SECONDS + with _photo_payload_cache_lock: + _prune_expired_payloads(now_monotonic) + _photo_payload_cache[payload_id] = _CachedPhotoPayload( + camera_name=camera_name, + content=content, + media_type=media_type, + filename=filename, + size_bytes=len(content), + expires_at_monotonic=expires_at_monotonic, + ) + _enforce_payload_cache_size_limit() + return payload_id, _PAYLOAD_TTL_SECONDS + + +def _get_cached_photo_payload(camera_name: str, payload_id: str) -> _CachedPhotoPayload: + now_monotonic = time.monotonic() + with _photo_payload_cache_lock: + _prune_expired_payloads(now_monotonic) + payload = _photo_payload_cache.get(payload_id) + if payload is None or payload.camera_name != camera_name: + raise HTTPException(status_code=404, detail="Photo payload not found or expired.") + return payload + class CameraStatusResponse(BaseModel): name: str @@ -131,7 +262,12 @@ async def generate(): @router.get("/{camera_name}/photo") -async def get_photo(camera_name: str): +async def get_photo( + camera_name: str, + request: Request, + image_format: PhotoFormat = Query(default="jpeg"), + with_metadata: bool = Query(default=False), +): """Get a camera photo Args: @@ -142,10 +278,54 @@ async def get_photo(camera_name: str): """ controller = get_camera_controller(camera_name) try: - photo = await controller.photo_async() - return Response(content=photo.data.getvalue(), media_type="image/jpeg") - except Exception as e: - return Response(status_code=500, content=str(e)) + photo = await controller.photo_async(image_format=image_format) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + try: + content, media_type, filename = _serialize_photo_payload(photo) + except (ValueError, TypeError) as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if not with_metadata: + return Response(content=content, media_type=media_type) + + payload_id, expires_in_s = _store_photo_payload( + camera_name=camera_name, + content=content, + media_type=media_type, + filename=filename, + ) + payload_url = str( + request.url_for( + "get_photo_payload", + camera_name=camera_name, + payload_id=payload_id, + ) + ) + return PhotoMetadataResponse( + format=photo.format, + media_type=media_type, + filename=filename, + camera_metadata=photo.camera_metadata, + scan_metadata=photo.scan_metadata, + payload_url=payload_url, + expires_in_s=expires_in_s, + ) + + +@router.get("/{camera_name}/photo/payload/{payload_id}", name="get_photo_payload") +async def get_photo_payload(camera_name: str, payload_id: str): + payload = _get_cached_photo_payload(camera_name=camera_name, payload_id=payload_id) + return Response( + content=payload.content, + media_type=payload.media_type, + headers={"Content-Disposition": f'inline; filename="{payload.filename}"'}, + ) @router.post("/{camera_name}/restart") async def restart_camera(camera_name: str): @@ -208,4 +388,4 @@ async def auto_calibrate_awb( resource_name="camera_name", get_controller=get_camera_controller, settings_model=CameraSettings -) \ No newline at end of file +) diff --git a/tests/routers/test_next_cameras_router.py b/tests/routers/test_next_cameras_router.py new file mode 100644 index 0000000..9bbfe4c --- /dev/null +++ b/tests/routers/test_next_cameras_router.py @@ -0,0 +1,746 @@ +"""Tests for the next cameras photo endpoints.""" + +from __future__ import annotations + +import asyncio +import io +import time +from importlib import import_module +from typing import Callable + +import httpx +import numpy as np +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from openscan_firmware.config.camera import CameraSettings +from openscan_firmware.models.camera import CameraMetadata, PhotoData + + +def _next_router_module_path(name: str) -> str: + return f"openscan_firmware.routers.next.{name}" + + +class _FakeCameraController: + def __init__(self, photo_data: PhotoData): + self._photo_data = photo_data + self.requested_formats: list[str] = [] + + async def photo_async(self, image_format: str = "jpeg") -> PhotoData: + self.requested_formats.append(image_format) + return self._photo_data + + +class _ConcurrentFakeCameraController: + def __init__(self): + self.preview_calls = 0 + self.photo_calls = 0 + metadata = CameraMetadata( + camera_name="cam0", + camera_settings=CameraSettings(), + raw_metadata={"driver": "test"}, + ) + self._photo_data = PhotoData( + data=io.BytesIO(b"parallel-jpeg"), + format="jpeg", + camera_metadata=metadata, + ) + + async def preview_async(self): + self.preview_calls += 1 + if self.preview_calls > 40: + raise RuntimeError("stop stream") + return b"frame-jpeg" + + async def photo_async(self, image_format: str = "jpeg") -> PhotoData: + self.photo_calls += 1 + await asyncio.sleep(0) + return self._photo_data + + +class _SnapshotBusyController: + def is_busy(self) -> bool: + return True + + def preview(self): + raise AssertionError("preview() must not be called when controller is busy") + + +class _SlowPhotoConcurrentController: + def __init__(self, delay_s: float = 2.0): + self.delay_s = delay_s + self.preview_calls = 0 + self.photo_calls = 0 + self.photo_in_flight = False + metadata = CameraMetadata( + camera_name="cam0", + camera_settings=CameraSettings(), + raw_metadata={"driver": "test"}, + ) + self._photo_data = PhotoData( + data=io.BytesIO(b"slow-photo-jpeg"), + format="jpeg", + camera_metadata=metadata, + ) + + async def preview_async(self): + self.preview_calls += 1 + if self.preview_calls > 60: + raise RuntimeError("stop stream") + return b"frame-jpeg" + + async def photo_async(self, image_format: str = "jpeg") -> PhotoData: + self.photo_calls += 1 + self.photo_in_flight = True + try: + await asyncio.sleep(self.delay_s) + return self._photo_data + finally: + self.photo_in_flight = False + + +class _SlowMetadataController: + def __init__(self, delay_s: float = 2.0): + self.delay_s = delay_s + self.photo_calls = 0 + metadata = CameraMetadata( + camera_name="cam0", + camera_settings=CameraSettings(), + raw_metadata={"driver": "slow-meta-test"}, + ) + self._photo_data = PhotoData( + data=io.BytesIO(b"slow-metadata-dng"), + format="dng", + camera_metadata=metadata, + ) + + async def photo_async(self, image_format: str = "jpeg") -> PhotoData: + self.photo_calls += 1 + await asyncio.sleep(self.delay_s) + return self._photo_data + + +class _UnsupportedFormatController: + def __init__(self, unsupported_formats: set[str]): + self.unsupported_formats = unsupported_formats + metadata = CameraMetadata( + camera_name="cam0", + camera_settings=CameraSettings(), + raw_metadata={"driver": "unsupported-test"}, + ) + self._fallback_photo = PhotoData( + data=io.BytesIO(b"jpeg-bytes"), + format="jpeg", + camera_metadata=metadata, + ) + + async def photo_async(self, image_format: str = "jpeg") -> PhotoData: + if image_format in self.unsupported_formats: + raise ValueError(f"Unsupported image format: {image_format}") + return self._fallback_photo + + +class _ConcurrentPhotoController: + def __init__(self, delay_s: float = 0.2, fail_calls: set[int] | None = None): + self.delay_s = delay_s + self.fail_calls = fail_calls or set() + self._lock = asyncio.Lock() + self.call_count = 0 + self.in_flight = 0 + self.max_in_flight = 0 + + async def photo_async(self, image_format: str = "jpeg") -> PhotoData: + self.call_count += 1 + call_index = self.call_count + async with self._lock: + self.in_flight += 1 + self.max_in_flight = max(self.max_in_flight, self.in_flight) + try: + await asyncio.sleep(self.delay_s) + if call_index in self.fail_calls: + raise RuntimeError("simulated capture failure") + metadata = CameraMetadata( + camera_name="cam0", + camera_settings=CameraSettings(), + raw_metadata={"call_index": call_index}, + ) + return PhotoData( + data=io.BytesIO(f"jpeg-{call_index}".encode("ascii")), + format="jpeg", + camera_metadata=metadata, + ) + finally: + self.in_flight -= 1 + + +@pytest.fixture +def cameras_router_path() -> Callable[[str], str]: + return _next_router_module_path + + +@pytest.fixture +def cameras_app(monkeypatch: pytest.MonkeyPatch, cameras_router_path) -> FastAPI: + module_path = cameras_router_path("cameras") + cameras_router = import_module(module_path) + cameras_router._photo_payload_cache.clear() + + app = FastAPI() + app.include_router(cameras_router.router, prefix="/next") + return app + + +@pytest_asyncio.fixture +async def cameras_client(cameras_app: FastAPI, cameras_router_path) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=cameras_app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + cameras_router = import_module(cameras_router_path("cameras")) + cameras_router._photo_payload_cache.clear() + + +def _make_photo_data(data, data_format: str) -> PhotoData: + metadata = CameraMetadata( + camera_name="cam0", + camera_settings=CameraSettings(), + raw_metadata={"driver": "test"}, + ) + return PhotoData(data=data, format=data_format, camera_metadata=metadata) + + +@pytest.mark.asyncio +async def test_get_photo_legacy_returns_raw_jpeg(monkeypatch, cameras_client, cameras_router_path): + module_path = cameras_router_path("cameras") + controller = _FakeCameraController( + _make_photo_data(io.BytesIO(b"jpeg-bytes"), "jpeg") + ) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get("/next/cameras/cam0/photo") + + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + assert response.content == b"jpeg-bytes" + assert controller.requested_formats == ["jpeg"] + + +@pytest.mark.asyncio +async def test_get_photo_with_metadata_returns_payload_url_for_dng( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _FakeCameraController( + _make_photo_data(io.BytesIO(b"dng-bytes"), "dng") + ) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "dng", "with_metadata": "true"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["format"] == "dng" + assert payload["media_type"] == "image/x-adobe-dng" + assert payload["filename"] == "photo.dng" + assert payload["camera_metadata"]["camera_name"] == "cam0" + assert payload["camera_metadata"]["raw_metadata"] == {"driver": "test"} + assert payload["scan_metadata"] is None + assert payload["expires_in_s"] == 90 + assert "/next/cameras/cam0/photo/payload/" in payload["payload_url"] + assert controller.requested_formats == ["dng"] + + payload_response = await cameras_client.get(payload["payload_url"]) + + assert payload_response.status_code == 200 + assert payload_response.headers["content-type"] == "image/x-adobe-dng" + assert payload_response.content == b"dng-bytes" + + +@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") + array = np.array([[1, 2], [3, 4]], dtype=np.uint8) + controller = _FakeCameraController( + _make_photo_data(array, "rgb_array") + ) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "rgb_array"}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/x-npy" + restored = np.load(io.BytesIO(response.content)) + np.testing.assert_array_equal(restored, array) + assert controller.requested_formats == ["rgb_array"] + + +@pytest.mark.asyncio +async def test_payload_endpoint_returns_404_after_cache_miss(monkeypatch, cameras_client, cameras_router_path): + module_path = cameras_router_path("cameras") + controller = _FakeCameraController( + _make_photo_data(io.BytesIO(b"jpeg-bytes"), "jpeg") + ) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get("/next/cameras/cam0/photo/payload/missing") + + assert response.status_code == 404 + assert response.json()["detail"] == "Photo payload not found or expired." + + +@pytest.mark.asyncio +async def test_preview_stream_allows_parallel_photo_requests( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _ConcurrentFakeCameraController() + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + async with cameras_client.stream( + "GET", + "/next/cameras/cam0/preview", + params={"mode": "stream", "fps": 10}, + ) as preview_response: + assert preview_response.status_code == 200 + assert preview_response.headers["content-type"].startswith("multipart/x-mixed-replace") + + async def _consume_one_chunk(): + async for chunk in preview_response.aiter_bytes(): + if chunk: + return chunk + return b"" + + preview_task = asyncio.create_task(_consume_one_chunk()) + await asyncio.sleep(0.05) + + photo_response_1 = await cameras_client.get("/next/cameras/cam0/photo") + photo_response_2 = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "jpeg"}, + ) + + assert photo_response_1.status_code == 200 + assert photo_response_1.content == b"parallel-jpeg" + assert photo_response_2.status_code == 200 + assert photo_response_2.content == b"parallel-jpeg" + + first_preview_chunk = await preview_task + assert b"Content-Type: image/jpeg" in first_preview_chunk + + assert controller.preview_calls >= 1 + assert controller.photo_calls == 2 + + +@pytest.mark.asyncio +async def test_preview_snapshot_returns_409_when_busy(monkeypatch, cameras_client, cameras_router_path): + module_path = cameras_router_path("cameras") + controller = _SnapshotBusyController() + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get( + "/next/cameras/cam0/preview", + params={"mode": "snapshot"}, + ) + + assert response.status_code == 409 + assert "Camera is busy" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_preview_stream_continues_while_photo_capture_is_slow( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _SlowPhotoConcurrentController(delay_s=2.0) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + async with cameras_client.stream( + "GET", + "/next/cameras/cam0/preview", + params={"mode": "stream", "fps": 10}, + ) as preview_response: + assert preview_response.status_code == 200 + + photo_task = asyncio.create_task( + cameras_client.get("/next/cameras/cam0/photo", params={"image_format": "jpeg"}) + ) + await asyncio.sleep(0) + + preview_chunks_with_jpeg_header = 0 + async for chunk in preview_response.aiter_bytes(): + if b"Content-Type: image/jpeg" in chunk: + preview_chunks_with_jpeg_header += 1 + if preview_chunks_with_jpeg_header >= 3: + break + + assert preview_chunks_with_jpeg_header >= 1 + # Slow capture should still be running while preview keeps producing frames. + assert photo_task.done() is False + + photo_response = await asyncio.wait_for(photo_task, timeout=5) + assert photo_response.status_code == 200 + assert photo_response.content == b"slow-photo-jpeg" + + assert controller.preview_calls >= 1 + assert controller.photo_calls == 1 + + +@pytest.mark.asyncio +async def test_with_metadata_slow_capture_returns_payload_url_and_cached_payload( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _SlowMetadataController(delay_s=2.0) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + metadata_task = asyncio.create_task( + cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "dng", "with_metadata": "true"}, + ) + ) + await asyncio.sleep(0.1) + assert metadata_task.done() is False + + metadata_response = await asyncio.wait_for(metadata_task, timeout=6) + assert metadata_response.status_code == 200 + metadata_payload = metadata_response.json() + assert metadata_payload["format"] == "dng" + assert metadata_payload["media_type"] == "image/x-adobe-dng" + assert metadata_payload["camera_metadata"]["raw_metadata"] == {"driver": "slow-meta-test"} + assert "/next/cameras/cam0/photo/payload/" in metadata_payload["payload_url"] + + payload_response = await cameras_client.get(metadata_payload["payload_url"]) + assert payload_response.status_code == 200 + assert payload_response.headers["content-type"] == "image/x-adobe-dng" + assert payload_response.content == b"slow-metadata-dng" + + assert controller.photo_calls == 1 + + +@pytest.mark.asyncio +async def test_payload_url_returns_404_after_expiry( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + cameras_router = import_module(module_path) + controller = _FakeCameraController( + _make_photo_data(io.BytesIO(b"jpeg-bytes"), "jpeg") + ) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + metadata_response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "jpeg", "with_metadata": "true"}, + ) + assert metadata_response.status_code == 200 + payload_url = metadata_response.json()["payload_url"] + payload_id = payload_url.rsplit("/", 1)[-1] + + assert payload_id in cameras_router._photo_payload_cache + cameras_router._photo_payload_cache[payload_id].expires_at_monotonic = time.monotonic() - 1 + + expired_payload_response = await cameras_client.get(payload_url) + assert expired_payload_response.status_code == 404 + assert expired_payload_response.json()["detail"] == "Photo payload not found or expired." + assert payload_id not in cameras_router._photo_payload_cache + + +@pytest.mark.asyncio +async def test_payload_url_returns_404_for_camera_mismatch( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _FakeCameraController( + _make_photo_data(io.BytesIO(b"jpeg-bytes"), "jpeg") + ) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + metadata_response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "jpeg", "with_metadata": "true"}, + ) + assert metadata_response.status_code == 200 + payload_url = metadata_response.json()["payload_url"] + payload_id = payload_url.rsplit("/", 1)[-1] + + wrong_camera_response = await cameras_client.get( + f"/next/cameras/not-cam0/photo/payload/{payload_id}" + ) + assert wrong_camera_response.status_code == 404 + assert wrong_camera_response.json()["detail"] == "Photo payload not found or expired." + + +@pytest.mark.asyncio +async def test_photo_returns_400_when_controller_rejects_requested_format( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _UnsupportedFormatController(unsupported_formats={"dng"}) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "dng"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Unsupported image format: dng" + + +@pytest.mark.asyncio +async def test_photo_with_metadata_returns_400_when_format_unsupported( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _UnsupportedFormatController(unsupported_formats={"rgb_array"}) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"image_format": "rgb_array", "with_metadata": "true"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Unsupported image format: rgb_array" + + +@pytest.mark.asyncio +async def test_concurrent_photo_requests_are_serialized_by_controller( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _ConcurrentPhotoController(delay_s=0.2) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response_1_task = asyncio.create_task(cameras_client.get("/next/cameras/cam0/photo")) + response_2_task = asyncio.create_task(cameras_client.get("/next/cameras/cam0/photo")) + + response_1, response_2 = await asyncio.gather(response_1_task, response_2_task) + + assert response_1.status_code == 200 + assert response_2.status_code == 200 + assert response_1.content in (b"jpeg-1", b"jpeg-2") + assert response_2.content in (b"jpeg-1", b"jpeg-2") + assert response_1.content != response_2.content + assert controller.call_count == 2 + assert controller.max_in_flight == 1 + + +@pytest.mark.asyncio +async def test_concurrent_photo_requests_mixed_metadata_and_raw( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _ConcurrentPhotoController(delay_s=0.2) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + metadata_task = asyncio.create_task( + cameras_client.get( + "/next/cameras/cam0/photo", + params={"with_metadata": "true", "image_format": "jpeg"}, + ) + ) + raw_task = asyncio.create_task(cameras_client.get("/next/cameras/cam0/photo")) + + metadata_response, raw_response = await asyncio.gather(metadata_task, raw_task) + + assert metadata_response.status_code == 200 + assert raw_response.status_code == 200 + metadata_payload = metadata_response.json() + assert "/next/cameras/cam0/photo/payload/" in metadata_payload["payload_url"] + payload_response = await cameras_client.get(metadata_payload["payload_url"]) + assert payload_response.status_code == 200 + assert payload_response.content in (b"jpeg-1", b"jpeg-2") + assert raw_response.content in (b"jpeg-1", b"jpeg-2") + assert controller.call_count == 2 + assert controller.max_in_flight == 1 + + +@pytest.mark.asyncio +async def test_concurrent_with_metadata_requests_have_distinct_payload_urls( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _ConcurrentPhotoController(delay_s=0.2) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response_1_task = asyncio.create_task( + cameras_client.get("/next/cameras/cam0/photo", params={"with_metadata": "true"}) + ) + response_2_task = asyncio.create_task( + cameras_client.get("/next/cameras/cam0/photo", params={"with_metadata": "true"}) + ) + + response_1, response_2 = await asyncio.gather(response_1_task, response_2_task) + assert response_1.status_code == 200 + assert response_2.status_code == 200 + + payload_url_1 = response_1.json()["payload_url"] + payload_url_2 = response_2.json()["payload_url"] + assert payload_url_1 != payload_url_2 + + payload_1, payload_2 = await asyncio.gather( + cameras_client.get(payload_url_1), + cameras_client.get(payload_url_2), + ) + assert payload_1.status_code == 200 + assert payload_2.status_code == 200 + assert payload_1.content in (b"jpeg-1", b"jpeg-2") + assert payload_2.content in (b"jpeg-1", b"jpeg-2") + assert payload_1.content != payload_2.content + assert controller.call_count == 2 + assert controller.max_in_flight == 1 + + +@pytest.mark.asyncio +async def test_concurrent_photo_requests_one_fails_one_succeeds( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _ConcurrentPhotoController(delay_s=0.1, fail_calls={1}) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + response_1_task = asyncio.create_task(cameras_client.get("/next/cameras/cam0/photo")) + response_2_task = asyncio.create_task(cameras_client.get("/next/cameras/cam0/photo")) + + response_1, response_2 = await asyncio.gather(response_1_task, response_2_task) + + status_codes = sorted([response_1.status_code, response_2.status_code]) + assert status_codes == [200, 503] + success_response = response_1 if response_1.status_code == 200 else response_2 + failure_response = response_1 if response_1.status_code == 503 else response_2 + assert success_response.content == b"jpeg-2" + assert failure_response.json()["detail"] == "simulated capture failure" + assert controller.call_count == 2 + assert controller.max_in_flight == 1 + + +@pytest.mark.asyncio +async def test_payload_url_can_be_reused_before_expiry( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + controller = _FakeCameraController(_make_photo_data(io.BytesIO(b"jpeg-bytes"), "jpeg")) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: controller) + + metadata_response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"with_metadata": "true", "image_format": "jpeg"}, + ) + assert metadata_response.status_code == 200 + payload_url = metadata_response.json()["payload_url"] + + first_payload_response = await cameras_client.get(payload_url) + second_payload_response = await cameras_client.get(payload_url) + + assert first_payload_response.status_code == 200 + assert second_payload_response.status_code == 200 + assert first_payload_response.content == b"jpeg-bytes" + assert second_payload_response.content == b"jpeg-bytes" + + +@pytest.mark.asyncio +async def test_payload_cache_is_capped_to_prevent_unbounded_growth( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + cameras_router = import_module(module_path) + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: _FakeCameraController( + _make_photo_data(io.BytesIO(b"jpeg-bytes"), "jpeg") + )) + monkeypatch.setattr(cameras_router, "_MAX_PAYLOAD_CACHE_ENTRIES", 3) + + created_payload_ids: list[str] = [] + for _ in range(6): + metadata_response = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"with_metadata": "true", "image_format": "jpeg"}, + ) + assert metadata_response.status_code == 200 + payload_url = metadata_response.json()["payload_url"] + created_payload_ids.append(payload_url.rsplit("/", 1)[-1]) + + assert len(cameras_router._photo_payload_cache) == 3 + remaining_ids = set(cameras_router._photo_payload_cache.keys()) + assert remaining_ids.issubset(set(created_payload_ids)) + assert set(created_payload_ids[-3:]).issubset(remaining_ids) + + +@pytest.mark.asyncio +async def test_payload_cache_byte_limit_evicts_old_entries( + monkeypatch, + cameras_client, + cameras_router_path, +): + module_path = cameras_router_path("cameras") + cameras_router = import_module(module_path) + monkeypatch.setattr(cameras_router, "_MAX_PAYLOAD_CACHE_ENTRIES", 10) + monkeypatch.setattr(cameras_router, "_MAX_PAYLOAD_CACHE_BYTES", 10) + + class _LargePayloadController: + async def photo_async(self, image_format: str = "jpeg") -> PhotoData: + metadata = CameraMetadata( + camera_name="cam0", + camera_settings=CameraSettings(), + raw_metadata={"driver": "test"}, + ) + return PhotoData( + data=io.BytesIO(b"123456"), + format="jpeg", + camera_metadata=metadata, + ) + + monkeypatch.setattr(f"{module_path}.get_camera_controller", lambda _name: _LargePayloadController()) + + response_1 = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"with_metadata": "true", "image_format": "jpeg"}, + ) + response_2 = await cameras_client.get( + "/next/cameras/cam0/photo", + params={"with_metadata": "true", "image_format": "jpeg"}, + ) + assert response_1.status_code == 200 + assert response_2.status_code == 200 + + payload_1 = response_1.json()["payload_url"] + payload_2 = response_2.json()["payload_url"] + + # Byte cap is 10, each payload is 6 bytes -> only the newer payload survives. + first_payload_response = await cameras_client.get(payload_1) + second_payload_response = await cameras_client.get(payload_2) + assert first_payload_response.status_code == 404 + assert second_payload_response.status_code == 200 From 683d5183e4f06004052e4f0a5434ca7474f76785 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 31 Mar 2026 18:42:18 +0200 Subject: [PATCH 33/75] chore(openapi): regenerate types and add `delete_cloud_settings` endpoint and enhance camera query parameters --- scripts/openapi/openapi_latest.json | 23 +++++++++ scripts/openapi/openapi_next.json | 78 +++++++++++++++++++++++++++++ scripts/openapi/openapi_v0.8.json | 23 +++++++++ 3 files changed, 124 insertions(+) diff --git a/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index 7857e2f..28bf910 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -3636,6 +3636,29 @@ } } } + }, + "delete": { + "tags": [ + "cloud" + ], + "summary": "Delete Cloud Settings", + "description": "Delete persisted cloud settings and disable cloud features.", + "operationId": "delete_cloud_settings", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudSettingsResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } } }, "/cloud/projects": { diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index 3362223..6644206 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -164,6 +164,84 @@ "type": "string", "title": "Camera Name" } + }, + { + "name": "image_format", + "in": "query", + "required": false, + "schema": { + "enum": [ + "jpeg", + "dng", + "rgb_array", + "yuv_array" + ], + "type": "string", + "default": "jpeg", + "title": "Image Format" + } + }, + { + "name": "with_metadata", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "With Metadata" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/cameras/{camera_name}/photo/payload/{payload_id}": { + "get": { + "tags": [ + "cameras" + ], + "summary": "Get Photo Payload", + "operationId": "get_photo_payload", + "parameters": [ + { + "name": "camera_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Camera Name" + } + }, + { + "name": "payload_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Payload Id" + } } ], "responses": { diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index 7857e2f..28bf910 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -3636,6 +3636,29 @@ } } } + }, + "delete": { + "tags": [ + "cloud" + ], + "summary": "Delete Cloud Settings", + "description": "Delete persisted cloud settings and disable cloud features.", + "operationId": "delete_cloud_settings", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudSettingsResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } } }, "/cloud/projects": { From fd6b5c8bcc8c98319f6d0b0bbe6d960dd45d302c Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 1 Apr 2026 09:04:31 +0200 Subject: [PATCH 34/75] feat(diagnostics): add camera diagnostics script and API endpoint - Introduced `camera_report.sh` script for generating camera diagnostics reports. - Added `/next/develop/camera-report` endpoint to execute the script and return results in JSON or plain text. - Implemented tests to validate the new endpoint's functionality and error handling. --- openscan_firmware/routers/next/develop.py | 48 +++++++++++++++++- scripts/camera_report.sh | 61 +++++++++++++++++++++++ tests/routers/test_next_develop_router.py | 56 +++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100755 scripts/camera_report.sh create mode 100644 tests/routers/test_next_develop_router.py diff --git a/openscan_firmware/routers/next/develop.py b/openscan_firmware/routers/next/develop.py index 0b05272..5ba338a 100644 --- a/openscan_firmware/routers/next/develop.py +++ b/openscan_firmware/routers/next/develop.py @@ -5,9 +5,13 @@ """ import base64 +import subprocess import time +from pathlib import Path +from typing import Literal from fastapi import APIRouter, HTTPException, status, Response, Query +from fastapi.responses import PlainTextResponse from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager from openscan_firmware.models.task import TaskStatus, Task @@ -18,6 +22,8 @@ from openscan_firmware.utils.paths import paths from openscan_firmware.cli import DEFAULT_RELOAD_TRIGGER +CAMERA_REPORT_SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "camera_report.sh" + router = APIRouter( prefix="/develop", @@ -43,6 +49,46 @@ async def restart_application() -> dict[str, str]: return {"detail": "Reload triggered"} +@router.get("/camera-report") +async def get_camera_report( + format: Literal["json", "text"] = Query(default="json"), +): + """Run the camera diagnostics script and return a bundled report.""" + if not CAMERA_REPORT_SCRIPT.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Camera report script not found: {CAMERA_REPORT_SCRIPT}", + ) + + result = subprocess.run( + ["bash", str(CAMERA_REPORT_SCRIPT)], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + report = result.stdout.strip() + stderr = result.stderr.strip() + + if format == "text": + text_output = report or stderr or "No output produced." + status_code = status.HTTP_200_OK if result.returncode == 0 else status.HTTP_500_INTERNAL_SERVER_ERROR + return PlainTextResponse(content=text_output, status_code=status_code) + + payload = { + "ok": result.returncode == 0, + "return_code": result.returncode, + "script": str(CAMERA_REPORT_SCRIPT), + "report": report, + "stderr": stderr, + } + + if result.returncode != 0: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=payload) + + return payload + + @router.get("/crop_image", summary="Run crop task and return visualization image", response_class=Response) async def crop_image(camera_name: str, threshold: int | None = Query(default=None, ge=0, le=255)) -> Response: """Run the crop task and return the visualization image with bounding boxes. @@ -120,4 +166,4 @@ async def start_qr_scan( @router.get("/{method}", response_model=list[paths.CartesianPoint3D]) async def get_path(method: paths.PathMethod, points: int): """Get a list of coordinates by path method and number of points""" - return paths.get_path(method, points) \ No newline at end of file + return paths.get_path(method, points) diff --git a/scripts/camera_report.sh b/scripts/camera_report.sh new file mode 100755 index 0000000..0d247d4 --- /dev/null +++ b/scripts/camera_report.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -u + +print_section() { + local title="$1" + printf "\n===== %s =====\n" "$title" +} + +run_command() { + local description="$1" + shift + print_section "$description" + printf "+ %s\n" "$*" + "$@" 2>&1 + local rc=$? + if [ "$rc" -ne 0 ]; then + printf "[exit-code] %s\n" "$rc" + fi +} + +run_if_available() { + local binary="$1" + shift + local description="$1" + shift + if command -v "$binary" >/dev/null 2>&1; then + run_command "$description" "$@" + else + print_section "$description" + printf "%s not found in PATH\n" "$binary" + fi +} + +printf "OpenScan Camera Report\n" +printf "Generated: %s\n" "$(date --iso-8601=seconds)" +printf "Host: %s\n" "$(hostname)" +printf "Kernel: %s\n" "$(uname -srmo)" + +run_if_available "v4l2-ctl" "V4L2 device overview" v4l2-ctl --list-devices +run_command "Video and media device nodes" bash -lc 'ls -l /dev/video* /dev/media* 2>/dev/null || echo "No /dev/video* or /dev/media* nodes found"' +run_if_available "lsusb" "USB device tree" lsusb -t +run_if_available "lsusb" "USB device list" lsusb +run_command "Kernel camera/video log excerpts" bash -lc 'dmesg | egrep -i "camera|video|uvc|bcm2835|unicam" | tail -n 200' +run_command "Boot firmware config (/boot/firmware/config.txt)" bash -lc 'if [ -f /boot/firmware/config.txt ]; then sed -n "1,240p" /boot/firmware/config.txt; else echo "/boot/firmware/config.txt not found"; fi' + +if command -v v4l2-ctl >/dev/null 2>&1; then + print_section "Per-device V4L2 details" + shopt -s nullglob + video_devices=(/dev/video*) + shopt -u nullglob + + if [ "${#video_devices[@]}" -eq 0 ]; then + echo "No /dev/video* devices found" + else + for dev in "${video_devices[@]}"; do + printf "\n--- %s ---\n" "$dev" + v4l2-ctl -d "$dev" --all 2>&1 | head -n 80 + done + fi +fi diff --git a/tests/routers/test_next_develop_router.py b/tests/routers/test_next_develop_router.py new file mode 100644 index 0000000..7482cf9 --- /dev/null +++ b/tests/routers/test_next_develop_router.py @@ -0,0 +1,56 @@ +"""Tests for next develop router endpoints.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from openscan_firmware.routers.next import develop as develop_router + + +def _create_app() -> FastAPI: + app = FastAPI() + app.include_router(develop_router.router, prefix="/next") + return app + + +def test_camera_report_returns_json(monkeypatch, tmp_path: Path): + script = tmp_path / "camera_report.sh" + script.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + monkeypatch.setattr(develop_router, "CAMERA_REPORT_SCRIPT", script) + + def fake_run(cmd, capture_output, text, timeout, check): # noqa: ANN001 + assert cmd == ["bash", str(script)] + assert capture_output is True + assert text is True + assert timeout == 60 + assert check is False + return subprocess.CompletedProcess(cmd, 0, stdout="camera report\n", stderr="") + + monkeypatch.setattr(develop_router.subprocess, "run", fake_run) + + with TestClient(_create_app()) as client: + response = client.get("/next/develop/camera-report") + + assert response.status_code == 200 + assert response.json() == { + "ok": True, + "return_code": 0, + "script": str(script), + "report": "camera report", + "stderr": "", + } + + +def test_camera_report_missing_script_returns_404(monkeypatch, tmp_path: Path): + missing_script = tmp_path / "missing_camera_report.sh" + monkeypatch.setattr(develop_router, "CAMERA_REPORT_SCRIPT", missing_script) + + with TestClient(_create_app()) as client: + response = client.get("/next/develop/camera-report") + + assert response.status_code == 404 + assert "Camera report script not found" in response.json()["detail"] From 2b0b812e47409e4953a1c557d4725fc409613ff9 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 1 Apr 2026 09:41:33 +0200 Subject: [PATCH 35/75] feat(camera): replace GPhoto2 implementation with modular architecture - Removed the monolithic `Gphoto2Camera` class. - Introduced a modular design with `GPhoto2Session`, `GPhoto2Controller`, and camera profiles. - Added support for model-specific profiles, including a generic profile and Canon EOS 700D profile. - Enhanced startup configuration and settings application for targeted camera models. - Updated device initialization to include default camera settings. --- openscan_firmware/controllers/device.py | 2 +- .../controllers/hardware/cameras/gphoto2.py | 42 ------ .../hardware/cameras/gphoto2/__init__.py | 15 +++ .../hardware/cameras/gphoto2/controller.py | 107 ++++++++++++++++ .../hardware/cameras/gphoto2/profile.py | 53 ++++++++ .../cameras/gphoto2/profile_registry.py | 19 +++ .../cameras/gphoto2/profiles/__init__.py | 6 + .../gphoto2/profiles/canon_eos_700d.py | 39 ++++++ .../cameras/gphoto2/profiles/generic.py | 46 +++++++ .../hardware/cameras/gphoto2/session.py | 121 ++++++++++++++++++ 10 files changed, 407 insertions(+), 43 deletions(-) delete mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/__init__.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/canon_eos_700d.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py create mode 100644 openscan_firmware/controllers/hardware/cameras/gphoto2/session.py diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index e60f6da..60b660b 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -342,7 +342,7 @@ def _detect_cameras() -> Dict[str, Camera]: type=CameraType.GPHOTO2, name=camera_name, path=camera_path, - settings=None, + settings=CameraSettings(), ) except Exception as e: logger.error(f"Error loading GPhoto2 cameras: {e}") diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2.py b/openscan_firmware/controllers/hardware/cameras/gphoto2.py deleted file mode 100644 index 7c12d5f..0000000 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2.py +++ /dev/null @@ -1,42 +0,0 @@ -from tempfile import TemporaryFile -from typing import IO -import gphoto2 as gp - -from .camera import CameraController -from openscan_firmware.models.camera import Camera - - -class Gphoto2Camera(CameraController): - @classmethod - def _get_camera(cls, camera: Camera) -> gp.Camera: - if cls._camera is None: - port_info_list = gp.PortInfoList() - port_info_list.load() - abilities_list = gp.CameraAbilitiesList() - abilities_list.load() - camera_list = abilities_list.detect(port_info_list) - cls._camera = gp.Camera() - idx = port_info_list.lookup_path(camera.path) - cls._camera.set_port_info(port_info_list[idx]) - idx = abilities_list.lookup_model(camera_list[0][0]) - cls._camera.set_abilities(abilities_list[idx]) - return cls._camera - - @staticmethod - def photo(camera: Camera) -> IO[bytes]: - gp_camera = Gphoto2Camera._get_camera(camera) - file_path = gp_camera.capture(gp.GP_CAPTURE_IMAGE) - camera_file = gp_camera.file_get( - file_path.folder, file_path.name, gp.GP_FILE_TYPE_NORMAL - ) - file = TemporaryFile() - file.write(camera_file.get_data_and_size()) - return file - - @staticmethod - def preview(camera: Camera) -> IO[bytes]: - gp_camera = Gphoto2Camera._get_camera(camera) - camera_file = gp.gp_camera_capture_preview(gp_camera)[1] - file = TemporaryFile() - file.write(camera_file.get_data_and_size()) - return file diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/__init__.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/__init__.py new file mode 100644 index 0000000..7f4a670 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/__init__.py @@ -0,0 +1,15 @@ +"""GPhoto2 camera controller package.""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["GPhoto2Controller", "Gphoto2Camera"] + + +def __getattr__(name: str) -> Any: + if name in {"GPhoto2Controller", "Gphoto2Camera"}: + from .controller import GPhoto2Controller, Gphoto2Camera + + return {"GPhoto2Controller": GPhoto2Controller, "Gphoto2Camera": Gphoto2Camera}[name] + raise AttributeError(name) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py new file mode 100644 index 0000000..a9f6833 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py @@ -0,0 +1,107 @@ +"""High-level GPhoto2 camera controller.""" + +from __future__ import annotations + +import io +import logging +from typing import IO + +import cv2 # type: ignore[import] +import numpy as np + +from openscan_firmware.config.camera import CameraSettings +from openscan_firmware.models.camera import Camera, CameraMetadata, PhotoData + +from ..camera import CameraController +from .profile_registry import get_profile_for_identity +from .session import GPhoto2Session + +logger = logging.getLogger(__name__) + + +class GPhoto2Controller(CameraController): + """CameraController implementation for USB DSLR cameras via gphoto2.""" + + def __init__(self, camera: Camera): + if camera.settings is None: + camera.settings = CameraSettings() + super().__init__(camera) + self._session = GPhoto2Session(camera_path=camera.path, model_hint=camera.name) + self._session.ensure_connected() + self._profile = get_profile_for_identity(self._session.identity) + logger.info( + "Initialized gphoto2 controller for '%s' with profile '%s'.", + camera.name, + self._profile.profile_id, + ) + self._profile.apply_startup_config(self._session, self.settings.model) + + def cleanup(self): + self._session.close() + + def _apply_settings_to_hardware(self, settings: CameraSettings): + self._set_busy(True) + try: + self._profile.apply_settings(self._session, settings) + finally: + self._set_busy(False) + + def preview(self) -> IO[bytes]: + self._set_busy(True) + try: + return self._session.capture_preview() + finally: + self._set_busy(False) + + def capture_jpeg(self) -> PhotoData: + self._set_busy(True) + try: + content, extra = self._session.capture_image() + return self._create_photo_data(io.BytesIO(content), "jpeg", extra) + finally: + self._set_busy(False) + + 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) + finally: + self._set_busy(False) + + def capture_rgb_array(self) -> PhotoData: + rgb_array = self._capture_rgb_array() + return self._create_photo_data(rgb_array, "rgb_array") + + def capture_yuv_array(self) -> PhotoData: + rgb_array = self._capture_rgb_array() + yuv_array = cv2.cvtColor(rgb_array, cv2.COLOR_RGB2YUV) + return self._create_photo_data(yuv_array, "yuv_array") + + def _capture_rgb_array(self) -> np.ndarray: + self._set_busy(True) + try: + content, _ = self._session.capture_image() + np_buffer = np.frombuffer(content, dtype=np.uint8) + bgr_image = cv2.imdecode(np_buffer, cv2.IMREAD_COLOR) + if bgr_image is None: + raise RuntimeError("Failed to decode JPEG payload from gphoto2 capture.") + return cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB) + finally: + self._set_busy(False) + + def _create_photo_data(self, data, data_format: str, extra: dict | None = None) -> PhotoData: + metadata = CameraMetadata( + camera_name=self.camera.name, + camera_settings=self.settings.model, + raw_metadata=self._profile.build_metadata(self._session.identity, extra=extra), + ) + return PhotoData( + data=data, + format=data_format, + camera_metadata=metadata, + ) + + +# Backward-compatible class name used in existing imports. +Gphoto2Camera = GPhoto2Controller diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py new file mode 100644 index 0000000..dc97fe4 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py @@ -0,0 +1,53 @@ +"""Profile abstraction for camera model-specific GPhoto2 behavior.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from openscan_firmware.config.camera import CameraSettings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CameraIdentity: + model: str | None + port: str | None + + +class GPhoto2Profile: + """Base profile for model-specific GPhoto2 tuning.""" + + profile_id = "generic" + + def matches(self, identity: CameraIdentity) -> bool: + return True + + def apply_startup_config(self, session: Any, settings: CameraSettings) -> None: + """Apply one-time defaults when the controller starts.""" + + def apply_settings(self, session: Any, settings: CameraSettings) -> None: + """Apply runtime settings updates.""" + + def supports_dng(self) -> bool: + return False + + def capture_dng(self, session: Any) -> tuple[bytes, dict[str, Any]]: + raise ValueError(f"Profile '{self.profile_id}' does not support DNG capture.") + + def build_metadata( + self, + identity: CameraIdentity, + extra: dict[str, Any] | None = None, + ) -> dict[str, Any]: + metadata: dict[str, Any] = { + "driver": "gphoto2", + "profile": self.profile_id, + "model": identity.model, + "port": identity.port, + } + if extra: + metadata.update(extra) + return metadata diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py new file mode 100644 index 0000000..83cfd8e --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py @@ -0,0 +1,19 @@ +"""Registry for selecting a GPhoto2 profile based on camera identity.""" + +from __future__ import annotations + +from .profile import CameraIdentity, GPhoto2Profile +from .profiles import CanonEOS700DProfile, GenericGPhoto2Profile + +_PROFILE_CLASSES: list[type[GPhoto2Profile]] = [ + CanonEOS700DProfile, + GenericGPhoto2Profile, +] + + +def get_profile_for_identity(identity: CameraIdentity) -> GPhoto2Profile: + for profile_cls in _PROFILE_CLASSES: + profile = profile_cls() + if profile.matches(identity): + return profile + return GenericGPhoto2Profile() diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py new file mode 100644 index 0000000..8a20eb6 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py @@ -0,0 +1,6 @@ +"""Built-in GPhoto2 camera profiles.""" + +from .canon_eos_700d import CanonEOS700DProfile +from .generic import GenericGPhoto2Profile + +__all__ = ["CanonEOS700DProfile", "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 new file mode 100644 index 0000000..f441a25 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/canon_eos_700d.py @@ -0,0 +1,39 @@ +"""Canon EOS 700D specific GPhoto2 profile.""" + +from __future__ import annotations + +from openscan_firmware.config.camera import CameraSettings + +from ..profile import CameraIdentity +from .generic import GenericGPhoto2Profile + + +class CanonEOS700DProfile(GenericGPhoto2Profile): + """Canon EOS 700D tuning on top of the generic DSLR behavior.""" + + profile_id = "canon_eos_700d" + + _MODEL_MARKERS = ("canon eos 700d", "canon eos rebel t5i") + _CAPTURE_TARGET_KEYS = ["capturetarget"] + _SHUTTER_KEYS = ["shutterspeed"] + _JPEG_QUALITY_KEYS = ["imageformat", "imagequality"] + _DNG_KEYS = ["imageformat", "imagequality"] + + 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: + # Canon DSLRs usually need explicit card target for stable capture. + session.set_first_config_value(self._CAPTURE_TARGET_KEYS, "Memory card") + self.apply_settings(session, settings) + + def supports_dng(self) -> bool: + return True + + def capture_dng(self, session): + # Try RAW+JPEG first, fall back to RAW only if the setting is unsupported. + session.set_first_config_value(self._DNG_KEYS, "RAW + Large Fine JPEG") + if not session.set_first_config_value(self._DNG_KEYS, "RAW"): + pass + return session.capture_image() diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py new file mode 100644 index 0000000..11a755a --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py @@ -0,0 +1,46 @@ +"""Generic fallback profile for unknown GPhoto2 DSLR cameras.""" + +from __future__ import annotations + +import logging + +from openscan_firmware.config.camera import CameraSettings + +from ..profile import CameraIdentity, GPhoto2Profile + +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)}" + + +class GenericGPhoto2Profile(GPhoto2Profile): + """Best-effort profile that targets common DSLR config keys.""" + + profile_id = "generic" + + _CAPTURE_TARGET_KEYS = ["capturetarget", "capture", "recordingmedia"] + _SHUTTER_KEYS = ["shutterspeed", "shutter_speed"] + _JPEG_QUALITY_KEYS = ["imagequality", "imageformat", "imgquality"] + + 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.apply_settings(session, settings) + + def apply_settings(self, session, settings: CameraSettings) -> None: + if settings.shutter is not None: + shutter_str = _format_shutter_value_ms(settings.shutter) + applied = session.set_first_config_value(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") diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py new file mode 100644 index 0000000..36345c4 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py @@ -0,0 +1,121 @@ +"""Low-level session wrapper around python-gphoto2.""" + +from __future__ import annotations + +import logging +from typing import Any + +import gphoto2 as gp + +from .profile import CameraIdentity + +logger = logging.getLogger(__name__) + + +class GPhoto2Session: + """Manage a gphoto2 camera session for one physical device.""" + + def __init__(self, camera_path: str, model_hint: str | None = None): + self._camera_path = camera_path + self._model_hint = model_hint + self._camera: Any | None = None + self._identity = CameraIdentity(model=model_hint, port=camera_path) + + @property + def identity(self) -> CameraIdentity: + return self._identity + + def ensure_connected(self) -> Any: + if self._camera is not None: + return self._camera + + port_info_list = gp.PortInfoList() + port_info_list.load() + abilities_list = gp.CameraAbilitiesList() + abilities_list.load() + + detected_model = self._model_hint + try: + camera_list = abilities_list.detect(port_info_list) + for idx in range(camera_list.count()): + model_name = camera_list.get_name(idx) + detected_path = camera_list.get_value(idx) + if detected_path == self._camera_path: + detected_model = model_name + break + except Exception: + logger.debug("GPhoto2 autodetect lookup failed.", exc_info=True) + + camera = gp.Camera() + if self._camera_path: + port_idx = port_info_list.lookup_path(self._camera_path) + if port_idx >= 0: + camera.set_port_info(port_info_list[port_idx]) + + if detected_model: + try: + abilities_idx = abilities_list.lookup_model(detected_model) + if abilities_idx >= 0: + camera.set_abilities(abilities_list[abilities_idx]) + except Exception: + logger.debug("Failed setting camera abilities for '%s'.", detected_model, exc_info=True) + + camera.init() + self._camera = camera + self._identity = CameraIdentity(model=detected_model or self._model_hint, port=self._camera_path) + return camera + + def close(self) -> None: + if self._camera is None: + return + try: + self._camera.exit() + except Exception: + logger.debug("Failed to close gphoto2 camera session cleanly.", exc_info=True) + finally: + self._camera = None + + def capture_preview(self) -> bytes: + camera = self.ensure_connected() + camera_file = gp.gp_camera_capture_preview(camera)[1] + return bytes(camera_file.get_data_and_size()) + + 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) + camera_file = camera.file_get(file_path.folder, file_path.name, gp_file_type) + payload = bytes(camera_file.get_data_and_size()) + metadata = { + "capture_folder": file_path.folder, + "capture_name": file_path.name, + "gp_file_type": gp_file_type, + } + return payload, metadata + + def set_config_value(self, key: str, value: Any) -> bool: + camera = self.ensure_connected() + config = camera.get_config() + child = self._find_widget(config, key) + if child is None: + return False + child.set_value(value) + camera.set_config(config) + return True + + def set_first_config_value(self, keys: list[str], value: Any) -> bool: + for key in keys: + try: + if self.set_config_value(key, value): + return True + except Exception: + logger.debug("Setting config '%s' failed.", key, exc_info=True) + return False + + @staticmethod + def _find_widget(config_root: Any, key: str) -> Any | None: + if hasattr(config_root, "get_child_by_name"): + try: + return config_root.get_child_by_name(key) + except Exception: + return None + return None From cecee958be9d3c9ae17256b200661d35d0b8666a Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 1 Apr 2026 10:54:54 +0200 Subject: [PATCH 36/75] feat(camera): enhance camera configuration and diagnostics handling - Added hardware setting synchronization using thread-safe locking. - Improved gphoto2 session with retry logic for configuration I/O. - Introduced utility methods for widget discovery and choice matching. - Extended Canon EOS 700D profile with advanced default and specific settings (e.g., ISO mapping, RAW capture). - Enhanced camera detection to merge new and configured cameras dynamically. - Updated diagnostics to include GPhoto2-related data in `/next/develop/camera-report`. - Expanded `camera_report.sh` with additional USB and udev details. --- openscan_firmware/controllers/device.py | 61 +++++- .../controllers/hardware/cameras/camera.py | 4 +- .../gphoto2/profiles/canon_eos_700d.py | 58 +++++- .../cameras/gphoto2/profiles/generic.py | 53 ++++- .../hardware/cameras/gphoto2/session.py | 169 ++++++++++++++- openscan_firmware/routers/next/develop.py | 195 +++++++++++++++++- scripts/camera_report.sh | 20 ++ tests/routers/test_next_develop_router.py | 32 ++- 8 files changed, 561 insertions(+), 31 deletions(-) diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index 60b660b..ea89379 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -373,6 +373,46 @@ def _detect_cameras() -> Dict[str, Camera]: logger.info("Skipping Picamera2 detection: module not available on this system.") return cameras + +def _build_configured_camera_objects(config_cameras: dict) -> Dict[str, Camera]: + camera_objects: Dict[str, Camera] = {} + for cam_name, cam_conf in config_cameras.items(): + camera = Camera( + name=cam_name, + type=CameraType(cam_conf["type"]), + path=cam_conf["path"], + settings=_load_camera_config(cam_conf["settings"]), + ) + camera_objects[cam_name] = camera + return camera_objects + + +def _merge_detected_with_configured( + configured: Dict[str, Camera], + detected: Dict[str, Camera], +) -> Dict[str, Camera]: + """Merge freshly detected cameras into configured cameras. + + - Keep configured cameras and settings as baseline. + - Add newly detected cameras that are not configured yet. + - For existing names, keep configured settings but refresh type/path from detection. + """ + merged = dict(configured) + + for name, detected_camera in detected.items(): + if name in merged: + configured_camera = merged[name] + merged[name] = Camera( + name=name, + type=detected_camera.type, + path=detected_camera.path, + settings=configured_camera.settings, + ) + continue + merged[name] = detected_camera + + return merged + """ Inactivity code -- allow to send device (parts) to sleep when idle for some time """ # check if device is idle @@ -489,18 +529,19 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam logger.debug("Cleaned up old controllers.") # Detect hardware - if detect_cameras or config_dict["cameras"] == {}: + configured_cameras = _build_configured_camera_objects(config_dict["cameras"]) + + if detect_cameras or not configured_cameras: camera_objects = _detect_cameras() else: - camera_objects = {} - for cam_name in config_dict["cameras"]: - camera = Camera( - name=cam_name, - type=CameraType(config_dict["cameras"][cam_name]["type"]), - path=config_dict["cameras"][cam_name]["path"], - settings=_load_camera_config(config_dict["cameras"][cam_name]["settings"]) - ) - camera_objects[cam_name] = camera + camera_objects = configured_cameras + # Always attempt best-effort augmentation so newly attached USB cameras + # appear without requiring a full config reset. + detected_cameras = _detect_cameras() + camera_objects = _merge_detected_with_configured(camera_objects, detected_cameras) + newly_added = [name for name in camera_objects.keys() if name not in configured_cameras] + if newly_added: + logger.info("Detected additional cameras not in config: %s", ", ".join(newly_added)) # Create motor objects motor_objects = {} diff --git a/openscan_firmware/controllers/hardware/cameras/camera.py b/openscan_firmware/controllers/hardware/cameras/camera.py index 74d1941..37bed3a 100644 --- a/openscan_firmware/controllers/hardware/cameras/camera.py +++ b/openscan_firmware/controllers/hardware/cameras/camera.py @@ -88,7 +88,9 @@ def get_config(self) -> CameraSettings: def _on_settings_change(self, settings: CameraSettings): self.camera.settings = settings - self._apply_settings_to_hardware(settings) + # Serialize settings updates with preview/photo hardware operations. + with self._hw_lock: + self._apply_settings_to_hardware(settings) schedule_device_status_broadcast([f"cameras.{self.camera.name}.settings"]) def _apply_settings_to_hardware(self, settings: CameraSettings): 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 f441a25..72ae186 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 @@ -2,11 +2,15 @@ from __future__ import annotations +import logging + from openscan_firmware.config.camera import CameraSettings from ..profile import CameraIdentity from .generic import GenericGPhoto2Profile +logger = logging.getLogger(__name__) + class CanonEOS700DProfile(GenericGPhoto2Profile): """Canon EOS 700D tuning on top of the generic DSLR behavior.""" @@ -14,26 +18,58 @@ class CanonEOS700DProfile(GenericGPhoto2Profile): profile_id = "canon_eos_700d" _MODEL_MARKERS = ("canon eos 700d", "canon eos rebel t5i") - _CAPTURE_TARGET_KEYS = ["capturetarget"] - _SHUTTER_KEYS = ["shutterspeed"] - _JPEG_QUALITY_KEYS = ["imageformat", "imagequality"] - _DNG_KEYS = ["imageformat", "imagequality"] + _CAPTURE_TARGET_KEYS = ["/main/settings/capturetarget", "capturetarget"] + _SHUTTER_KEYS = ["/main/capturesettings/shutterspeed", "shutterspeed"] + _JPEG_QUALITY_KEYS: list[str] = [] + _DNG_KEYS = ["/main/imgsettings/imageformat", "imageformat"] + _EXPOSURE_MODE_KEYS = ["/main/capturesettings/autoexposuremode", "autoexposuremode"] + _FOCUS_MODE_KEYS = ["/main/capturesettings/focusmode", "focusmode"] + _ISO_KEYS = ["/main/imgsettings/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: - # Canon DSLRs usually need explicit card target for stable capture. - session.set_first_config_value(self._CAPTURE_TARGET_KEYS, "Memory card") + # 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.apply_settings(session, settings) + 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 = session.set_first_config_value(self._ISO_KEYS, iso_value) + if not applied: + logger.debug("ISO mapping unsupported on this EOS 700D config tree.") + def supports_dng(self) -> bool: return True def capture_dng(self, session): - # Try RAW+JPEG first, fall back to RAW only if the setting is unsupported. - session.set_first_config_value(self._DNG_KEYS, "RAW + Large Fine JPEG") - if not session.set_first_config_value(self._DNG_KEYS, "RAW"): - pass - return session.capture_image() + previous = session.get_first_config_details(self._DNG_KEYS) + previous_value = None if previous is None else previous.get("value") + session.set_first_config_value(self._DNG_KEYS, "RAW") + 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() + finally: + if previous_value: + session.set_first_config_value(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) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py index 11a755a..4cb853c 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from fractions import Fraction from openscan_firmware.config.camera import CameraSettings @@ -24,9 +25,19 @@ class GenericGPhoto2Profile(GPhoto2Profile): profile_id = "generic" - _CAPTURE_TARGET_KEYS = ["capturetarget", "capture", "recordingmedia"] - _SHUTTER_KEYS = ["shutterspeed", "shutter_speed"] - _JPEG_QUALITY_KEYS = ["imagequality", "imageformat", "imgquality"] + _CAPTURE_TARGET_KEYS = [ + "/main/settings/capturetarget", + "capturetarget", + "capture", + "recordingmedia", + ] + _SHUTTER_KEYS = [ + "/main/capturesettings/shutterspeed", + "/main/settings/shutterspeed", + "shutterspeed", + "shutter_speed", + ] + _JPEG_QUALITY_KEYS = ["/main/imgsettings/imagequality", "imagequality", "imageformat", "imgquality"] def matches(self, identity: CameraIdentity) -> bool: return True @@ -37,10 +48,44 @@ def apply_startup_config(self, session, settings: CameraSettings) -> None: def apply_settings(self, session, settings: CameraSettings) -> None: if settings.shutter is not None: - shutter_str = _format_shutter_value_ms(settings.shutter) + shutter_str = self._select_best_shutter_choice(session, settings.shutter) applied = session.set_first_config_value(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") + + 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) + + +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 diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py index 36345c4..cfbeea8 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from typing import Any import gphoto2 as gp @@ -20,6 +21,8 @@ def __init__(self, camera_path: str, model_hint: str | None = None): self._model_hint = model_hint self._camera: Any | None = None self._identity = CameraIdentity(model=model_hint, port=camera_path) + self._io_retry_attempts = 3 + self._io_retry_delay_s = 0.08 @property def identity(self) -> CameraIdentity: @@ -94,12 +97,39 @@ def capture_image(self, gp_file_type: int = gp.GP_FILE_TYPE_NORMAL) -> tuple[byt def set_config_value(self, key: str, value: Any) -> bool: camera = self.ensure_connected() - config = camera.get_config() + config = self._get_config_with_retry(camera, key_context=key) + if config is None: + return False child = self._find_widget(config, key) if child is None: return False - child.set_value(value) - camera.set_config(config) + # 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 + + current = self._safe_call(child, "get_value") + if current is not None and str(current) == str(selected): + return True + + 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 + + verified = self._safe_call(child, "get_value") + if verified is not None and str(verified) != str(selected): + logger.debug( + "Config '%s' write did not persist expected value (wanted=%s got=%s).", + key, + selected, + verified, + ) + return False return True def set_first_config_value(self, keys: list[str], value: Any) -> bool: @@ -111,11 +141,144 @@ def set_first_config_value(self, keys: list[str], value: Any) -> bool: logger.debug("Setting config '%s' failed.", key, exc_info=True) return False + def get_config_details(self, key: str) -> dict[str, Any] | None: + camera = self.ensure_connected() + config = self._get_config_with_retry(camera, key_context=key) + if config is None: + return None + child = self._find_widget(config, key) + if child is None: + return None + + details: dict[str, Any] = { + "key": key, + "name": self._safe_call(child, "get_name"), + "label": self._safe_call(child, "get_label"), + "type": self._safe_call(child, "get_type"), + "readonly": self._safe_call(child, "get_readonly"), + "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: + for key in keys: + try: + details = self.get_config_details(key) + except Exception: + 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: + for attempt in range(self._io_retry_attempts): + try: + return camera.get_config() + 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 + @staticmethod def _find_widget(config_root: Any, key: str) -> Any | None: + if key.startswith("/"): + return GPhoto2Session._find_widget_by_path(config_root, key) + by_name = GPhoto2Session._find_widget_by_name(config_root, key) + if by_name is not None: + return by_name if hasattr(config_root, "get_child_by_name"): try: return config_root.get_child_by_name(key) except Exception: return None return None + + @staticmethod + def _find_widget_by_path(config_root: Any, key_path: str) -> Any | None: + parts = [part for part in key_path.split("/") if part] + if not parts: + return config_root + + current = config_root + root_name = GPhoto2Session._safe_call(current, "get_name") + if parts and root_name and parts[0] == str(root_name): + parts = parts[1:] + + for part in parts: + next_widget = None + if hasattr(current, "get_child_by_name"): + try: + next_widget = current.get_child_by_name(part) + except Exception: + next_widget = None + if next_widget is None: + return None + current = next_widget + return current + + @staticmethod + def _find_widget_by_name(config_root: Any, key_name: str) -> Any | None: + if hasattr(config_root, "get_name"): + try: + if str(config_root.get_name()) == key_name: + return config_root + except Exception: + pass + + try: + child_count = config_root.count_children() + except Exception: + child_count = 0 + + for child_idx in range(child_count): + try: + child = config_root.get_child(child_idx) + except Exception: + continue + found = GPhoto2Session._find_widget_by_name(child, key_name) + if found is not None: + return found + return None + + @staticmethod + def _extract_choices(widget: Any) -> list[Any]: + try: + count = widget.count_choices() + except Exception: + return [] + choices: list[Any] = [] + for idx in range(count): + try: + choices.append(widget.get_choice(idx)) + except Exception: + continue + return choices + + @staticmethod + def _match_choice(choices: list[Any], value: Any) -> Any: + value_str = str(value) + for choice in choices: + if str(choice) == value_str: + return choice + lowered = value_str.lower() + for choice in choices: + if str(choice).lower() == lowered: + return choice + return value + + @staticmethod + def _safe_call(widget: Any, method_name: str) -> Any: + method = getattr(widget, method_name, None) + if method is None: + return None + try: + return method() + except Exception: + return None diff --git a/openscan_firmware/routers/next/develop.py b/openscan_firmware/routers/next/develop.py index 5ba338a..4997038 100644 --- a/openscan_firmware/routers/next/develop.py +++ b/openscan_firmware/routers/next/develop.py @@ -5,6 +5,7 @@ """ import base64 +import json import subprocess import time from pathlib import Path @@ -31,6 +32,194 @@ responses={404: {"description": "Not found"}}, ) + +def _gp_text(value) -> str: # noqa: ANN001 + if value is None: + return "" + return str(getattr(value, "text", value)) + + +def _extract_widget_choices(widget) -> list[str]: # noqa: ANN001 + try: + count = widget.count_choices() + except Exception: + return [] + choices: list[str] = [] + for idx in range(count): + try: + choices.append(str(widget.get_choice(idx))) + except Exception: + continue + return choices + + +def _walk_config_widgets(widget, prefix: str = "") -> list[dict]: # noqa: ANN001 + entries: list[dict] = [] + try: + name = str(widget.get_name()) + except Exception: + name = "unknown" + path = f"{prefix}/{name}" if prefix else f"/{name}" + + try: + label = str(widget.get_label()) + except Exception: + label = "" + + try: + value = str(widget.get_value()) + except Exception: + value = None + + try: + readonly = bool(widget.get_readonly()) + except Exception: + readonly = None + + try: + widget_type = str(widget.get_type()) + except Exception: + widget_type = None + + entries.append( + { + "name": name, + "label": label, + "path": path, + "type": widget_type, + "readonly": readonly, + "value": value, + "choices": _extract_widget_choices(widget), + } + ) + + try: + child_count = widget.count_children() + except Exception: + child_count = 0 + + for child_idx in range(child_count): + try: + child = widget.get_child(child_idx) + except Exception: + continue + entries.extend(_walk_config_widgets(child, path)) + return entries + + +def _collect_gphoto2_diagnostics() -> dict: + """Collect gphoto2 diagnostics via Python API with lazy import.""" + try: + import gphoto2 as gp + except Exception as exc: + return { + "available": False, + "error": f"python gphoto2 module unavailable: {exc}", + "detected": [], + "cameras": [], + } + + try: + detected = gp.Camera.autodetect() + except Exception as exc: + return { + "available": True, + "error": f"autodetect failed: {exc}", + "detected": [], + "cameras": [], + } + + rows: list[dict[str, str]] = [] + try: + count = detected.count() + for idx in range(count): + rows.append({"model": detected.get_name(idx), "path": detected.get_value(idx)}) + except Exception: + try: + rows = [{"model": item[0], "path": item[1]} for item in detected] + except Exception as exc: + return { + "available": True, + "error": f"Failed to parse autodetect result: {exc}", + "detected": [], + "cameras": [], + } + + cameras: list[dict] = [] + for row in rows: + model = row.get("model") + path = row.get("path") + camera_diag = { + "model": model, + "path": path, + "summary": None, + "about": None, + "config_groups": [], + "relevant_config": [], + "error": None, + } + camera = None + try: + camera = gp.Camera() + camera.init() + try: + camera_diag["summary"] = _gp_text(camera.get_summary()).strip() + except Exception: + camera_diag["summary"] = None + try: + camera_diag["about"] = _gp_text(camera.get_about()).strip() + except Exception: + camera_diag["about"] = None + try: + config = camera.get_config() + child_count = config.count_children() + groups: list[str] = [] + for child_idx in range(child_count): + child = config.get_child(child_idx) + groups.append(f"{child.get_name()}: {child.get_label()}") + camera_diag["config_groups"] = groups + all_widgets = _walk_config_widgets(config) + key_candidates = { + "capturetarget", + "capture", + "recordingmedia", + "shutterspeed", + "shutter_speed", + "aperture", + "f-number", + "iso", + "imageformat", + "imagequality", + "imgquality", + "eosremoterelease", + "viewfinder", + "focusmode", + "autoexposuremode", + } + camera_diag["relevant_config"] = [ + item for item in all_widgets if item["name"].lower() in key_candidates + ] + except Exception: + camera_diag["config_groups"] = [] + camera_diag["relevant_config"] = [] + except Exception as exc: + camera_diag["error"] = str(exc) + finally: + if camera is not None: + try: + camera.exit() + except Exception: + pass + cameras.append(camera_diag) + + return { + "available": True, + "error": None, + "detected": rows, + "cameras": cameras, + } + + @router.put("/scanner-position") async def move_to_position(point: PolarPoint3D): """Move Rotor and Turntable to a polar point""" @@ -64,14 +253,17 @@ async def get_camera_report( ["bash", str(CAMERA_REPORT_SCRIPT)], capture_output=True, text=True, - timeout=60, + timeout=180, check=False, ) report = result.stdout.strip() stderr = result.stderr.strip() + gphoto2_diag = _collect_gphoto2_diagnostics() if format == "text": text_output = report or stderr or "No output produced." + gphoto2_section = "===== GPhoto2 python diagnostics =====\n" + json.dumps(gphoto2_diag, indent=2) + text_output = f"{text_output}\n\n{gphoto2_section}" status_code = status.HTTP_200_OK if result.returncode == 0 else status.HTTP_500_INTERNAL_SERVER_ERROR return PlainTextResponse(content=text_output, status_code=status_code) @@ -81,6 +273,7 @@ async def get_camera_report( "script": str(CAMERA_REPORT_SCRIPT), "report": report, "stderr": stderr, + "gphoto2": gphoto2_diag, } if result.returncode != 0: diff --git a/scripts/camera_report.sh b/scripts/camera_report.sh index 0d247d4..997ceb5 100755 --- a/scripts/camera_report.sh +++ b/scripts/camera_report.sh @@ -41,7 +41,9 @@ run_if_available "v4l2-ctl" "V4L2 device overview" v4l2-ctl --list-devices run_command "Video and media device nodes" bash -lc 'ls -l /dev/video* /dev/media* 2>/dev/null || echo "No /dev/video* or /dev/media* nodes found"' run_if_available "lsusb" "USB device tree" lsusb -t run_if_available "lsusb" "USB device list" lsusb +run_if_available "usb-devices" "USB devices (kernel view)" usb-devices run_command "Kernel camera/video log excerpts" bash -lc 'dmesg | egrep -i "camera|video|uvc|bcm2835|unicam" | tail -n 200' +run_command "Kernel USB log excerpts" bash -lc 'dmesg | egrep -i "usb|xhci|dwc2|dwc_otg|hub|mtp|ptp" | tail -n 200' run_command "Boot firmware config (/boot/firmware/config.txt)" bash -lc 'if [ -f /boot/firmware/config.txt ]; then sed -n "1,240p" /boot/firmware/config.txt; else echo "/boot/firmware/config.txt not found"; fi' if command -v v4l2-ctl >/dev/null 2>&1; then @@ -59,3 +61,21 @@ if command -v v4l2-ctl >/dev/null 2>&1; then done fi fi + +if command -v udevadm >/dev/null 2>&1; then + print_section "udev info for /dev/video*" + shopt -s nullglob + video_devices=(/dev/video*) + shopt -u nullglob + if [ "${#video_devices[@]}" -eq 0 ]; then + echo "No /dev/video* devices found" + else + for dev in "${video_devices[@]}"; do + printf "\n--- %s ---\n" "$dev" + udevadm info --query=all --name="$dev" 2>&1 | head -n 120 + done + fi +else + print_section "udev info for /dev/video*" + echo "udevadm not found in PATH" +fi diff --git a/tests/routers/test_next_develop_router.py b/tests/routers/test_next_develop_router.py index 7482cf9..2630cf3 100644 --- a/tests/routers/test_next_develop_router.py +++ b/tests/routers/test_next_develop_router.py @@ -26,11 +26,16 @@ def fake_run(cmd, capture_output, text, timeout, check): # noqa: ANN001 assert cmd == ["bash", str(script)] assert capture_output is True assert text is True - assert timeout == 60 + assert timeout == 180 assert check is False return subprocess.CompletedProcess(cmd, 0, stdout="camera report\n", stderr="") monkeypatch.setattr(develop_router.subprocess, "run", fake_run) + monkeypatch.setattr( + develop_router, + "_collect_gphoto2_diagnostics", + lambda: {"available": True, "error": None, "detected": [], "cameras": []}, + ) with TestClient(_create_app()) as client: response = client.get("/next/develop/camera-report") @@ -42,6 +47,7 @@ def fake_run(cmd, capture_output, text, timeout, check): # noqa: ANN001 "script": str(script), "report": "camera report", "stderr": "", + "gphoto2": {"available": True, "error": None, "detected": [], "cameras": []}, } @@ -54,3 +60,27 @@ def test_camera_report_missing_script_returns_404(monkeypatch, tmp_path: Path): assert response.status_code == 404 assert "Camera report script not found" in response.json()["detail"] + + +def test_camera_report_text_includes_gphoto2_section(monkeypatch, tmp_path: Path): + script = tmp_path / "camera_report.sh" + script.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + monkeypatch.setattr(develop_router, "CAMERA_REPORT_SCRIPT", script) + monkeypatch.setattr( + develop_router.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess(args[0], 0, stdout="report body\n", stderr=""), + ) + monkeypatch.setattr( + develop_router, + "_collect_gphoto2_diagnostics", + lambda: {"available": False, "error": "missing", "detected": [], "cameras": []}, + ) + + with TestClient(_create_app()) as client: + response = client.get("/next/develop/camera-report?format=text") + + assert response.status_code == 200 + assert "report body" in response.text + assert "===== GPhoto2 python diagnostics =====" in response.text + assert "\"available\": false" in response.text From 60ebc932511844b6f1fc71a97b20a3e084e4d7a0 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 1 Apr 2026 11:00:31 +0200 Subject: [PATCH 37/75] fix(camera): correct metadata variable name in debug log --- openscan_firmware/controllers/hardware/cameras/picamera2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openscan_firmware/controllers/hardware/cameras/picamera2.py b/openscan_firmware/controllers/hardware/cameras/picamera2.py index 9224191..8c50670 100644 --- a/openscan_firmware/controllers/hardware/cameras/picamera2.py +++ b/openscan_firmware/controllers/hardware/cameras/picamera2.py @@ -551,7 +551,7 @@ def capture_dng(self) -> PhotoData: self._configure_focus(camera_mode="preview") - logger.debug(f"Captured dng with metadata: {metadata}") + logger.debug(f"Captured dng with metadata: {camera_metadata}") self._set_busy(False) From b5f94b59763cfc40ce7d215f6b24824b45948a6c Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 1 Apr 2026 11:12:18 +0200 Subject: [PATCH 38/75] feat(camera): embed orientation flag in captured JPEGs using EXIF metadata - Added `_embed_orientation_flag` method to inject orientation data into JPEG files. - Integrated the orientation embedding process in `capture_image`. - Logged warnings for errors during EXIF modification. --- .../hardware/cameras/gphoto2/controller.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py b/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py index a9f6833..3b9f248 100644 --- a/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py @@ -8,6 +8,7 @@ import cv2 # type: ignore[import] import numpy as np +import piexif # type: ignore[import] from openscan_firmware.config.camera import CameraSettings from openscan_firmware.models.camera import Camera, CameraMetadata, PhotoData @@ -57,6 +58,7 @@ def capture_jpeg(self) -> PhotoData: self._set_busy(True) try: content, extra = self._session.capture_image() + content = self._embed_orientation_flag(content) return self._create_photo_data(io.BytesIO(content), "jpeg", extra) finally: self._set_busy(False) @@ -102,6 +104,22 @@ def _create_photo_data(self, data, data_format: str, extra: dict | None = None) camera_metadata=metadata, ) + def _embed_orientation_flag(self, jpeg_bytes: bytes) -> bytes: + orientation = self.settings.orientation_flag + if orientation is None: + return jpeg_bytes + try: + flag = int(orientation) + exif_bytes = piexif.dump({"0th": {piexif.ImageIFD.Orientation: flag}}) + return piexif.insert(exif_bytes, jpeg_bytes) + except Exception as exc: + logger.warning( + "Failed to embed orientation flag (%s) into gphoto2 JPEG: %s", + orientation, + exc, + ) + return jpeg_bytes + # Backward-compatible class name used in existing imports. Gphoto2Camera = GPhoto2Controller From 6a38c823ce82b8a2387acf4c7006dca441e6475f Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 1 Apr 2026 11:45:22 +0200 Subject: [PATCH 39/75] chore(openapi): persist `next` routers to new API version `0.9` - regenerate openapi files per version - remove deprecated `openapi_v0.6.json` file --- openscan_firmware/main.py | 35 +- openscan_firmware/routers/v0_9/__init__.py | 0 openscan_firmware/routers/v0_9/cameras.py | 391 ++ openscan_firmware/routers/v0_9/cloud.py | 357 ++ openscan_firmware/routers/v0_9/develop.py | 362 ++ openscan_firmware/routers/v0_9/device.py | 364 ++ openscan_firmware/routers/v0_9/firmware.py | 53 + .../routers/v0_9/focus_stacking.py | 68 + openscan_firmware/routers/v0_9/gpio.py | 53 + openscan_firmware/routers/v0_9/lights.py | 111 + openscan_firmware/routers/v0_9/motors.py | 198 + openscan_firmware/routers/v0_9/openscan.py | 283 + openscan_firmware/routers/v0_9/projects.py | 654 ++ .../routers/v0_9/settings_utils.py | 80 + openscan_firmware/routers/v0_9/tasks.py | 171 + scripts/openapi/openapi_latest.json | 956 ++- scripts/openapi/openapi_next.json | 49 + scripts/openapi/openapi_v0.6.json | 5273 ----------------- .../{openapi_v0.7.json => openapi_v0.9.json} | 1370 ++++- 19 files changed, 5136 insertions(+), 5692 deletions(-) create mode 100644 openscan_firmware/routers/v0_9/__init__.py create mode 100644 openscan_firmware/routers/v0_9/cameras.py create mode 100644 openscan_firmware/routers/v0_9/cloud.py create mode 100644 openscan_firmware/routers/v0_9/develop.py create mode 100644 openscan_firmware/routers/v0_9/device.py create mode 100644 openscan_firmware/routers/v0_9/firmware.py create mode 100644 openscan_firmware/routers/v0_9/focus_stacking.py create mode 100644 openscan_firmware/routers/v0_9/gpio.py create mode 100644 openscan_firmware/routers/v0_9/lights.py create mode 100644 openscan_firmware/routers/v0_9/motors.py create mode 100644 openscan_firmware/routers/v0_9/openscan.py create mode 100644 openscan_firmware/routers/v0_9/projects.py create mode 100644 openscan_firmware/routers/v0_9/settings_utils.py create mode 100644 openscan_firmware/routers/v0_9/tasks.py delete mode 100644 scripts/openapi/openapi_v0.6.json rename scripts/openapi/{openapi_v0.7.json => openapi_v0.9.json} (83%) diff --git a/openscan_firmware/main.py b/openscan_firmware/main.py index 8b6f30a..d23d21d 100644 --- a/openscan_firmware/main.py +++ b/openscan_firmware/main.py @@ -24,6 +24,21 @@ cloud as cloud_v0_8, focus_stacking as focus_stacking_v0_8, ) +# v0.9 routers +from openscan_firmware.routers.v0_9 import ( + cameras as cameras_v0_9, + motors as motors_v0_9, + lights as lights_v0_9, + firmware as firmware_v0_9, + projects as projects_v0_9, + gpio as gpio_v0_9, + openscan as openscan_v0_9, + device as device_v0_9, + tasks as tasks_v0_9, + develop as develop_v0_9, + cloud as cloud_v0_9, + focus_stacking as focus_stacking_v0_9, +) # next routers from openscan_firmware.routers.next import ( cameras as cameras_next, @@ -188,9 +203,26 @@ async def lifespan(app: FastAPI): focus_stacking_next.router, ] +v0_9_ROUTERS = [ + cameras_v0_9.router, + motors_v0_9.router, + lights_v0_9.router, + firmware_v0_9.router, + projects_v0_9.router, + gpio_v0_9.router, + openscan_v0_9.router, + device_v0_9.router, + tasks_v0_9.router, + develop_v0_9.router, + cloud_v0_9.router, + websocket_router.router, + focus_stacking_v0_9.router, +] + ROUTERS_BY_VERSION: dict[str, list] = { "0.8": v0_8_ROUTERS, + "0.9": v0_9_ROUTERS, "next": next_ROUTERS, } @@ -255,8 +287,9 @@ def _use_route_names_as_operation_ids(app: FastAPI) -> None: # Define the supported API versions and explicitly set the latest alias. SUPPORTED_VERSIONS = [ "0.8", + "0.9", ] -LATEST = "0.8" +LATEST = "0.9" for v in SUPPORTED_VERSIONS: app.mount(f"/v{v}", make_version_app(v)) diff --git a/openscan_firmware/routers/v0_9/__init__.py b/openscan_firmware/routers/v0_9/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openscan_firmware/routers/v0_9/cameras.py b/openscan_firmware/routers/v0_9/cameras.py new file mode 100644 index 0000000..51ffebf --- /dev/null +++ b/openscan_firmware/routers/v0_9/cameras.py @@ -0,0 +1,391 @@ +import asyncio +import io +import time +from dataclasses import dataclass +from threading import Lock +from typing import Literal, Optional +from uuid import uuid4 + +import numpy as np +from fastapi import APIRouter, Body, HTTPException, Query, Request +from fastapi.responses import StreamingResponse, Response +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, Field + +from openscan_firmware.config.camera import CameraSettings +from openscan_firmware.models.camera import Camera, CameraMetadata, CameraType, PhotoData +from openscan_firmware.models.scan import ScanMetadata +from openscan_firmware.controllers.hardware.cameras.camera import ( + get_all_camera_controllers, + get_camera_controller, +) + +from .settings_utils import create_settings_endpoints + +router = APIRouter( + prefix="/cameras", + tags=["cameras"], + responses={404: {"description": "Not found"}}, +) + +PhotoFormat = Literal["jpeg", "dng", "rgb_array", "yuv_array"] +_PAYLOAD_TTL_SECONDS = 90 +_MAX_PAYLOAD_CACHE_ENTRIES = 8 +_MAX_PAYLOAD_CACHE_BYTES = 256 * 1024 * 1024 + + +@dataclass +class _CachedPhotoPayload: + camera_name: str + content: bytes + media_type: str + filename: str + size_bytes: int + expires_at_monotonic: float + + +_photo_payload_cache: dict[str, _CachedPhotoPayload] = {} +_photo_payload_cache_lock = Lock() + + +class PhotoMetadataResponse(BaseModel): + format: PhotoFormat + media_type: str + filename: str + camera_metadata: CameraMetadata + scan_metadata: Optional[ScanMetadata] = None + payload_url: str + expires_in_s: int + + +def _prune_expired_payloads(now_monotonic: float) -> None: + expired_ids = [ + payload_id + for payload_id, payload in _photo_payload_cache.items() + if payload.expires_at_monotonic <= now_monotonic + ] + for payload_id in expired_ids: + _photo_payload_cache.pop(payload_id, None) + + +def _enforce_payload_cache_size_limit() -> None: + # Evict entries that expire first to keep newer payloads available. + sorted_ids = sorted( + _photo_payload_cache, + key=lambda payload_id: _photo_payload_cache[payload_id].expires_at_monotonic, + ) + + while len(_photo_payload_cache) > _MAX_PAYLOAD_CACHE_ENTRIES and sorted_ids: + _photo_payload_cache.pop(sorted_ids.pop(0), None) + + total_size_bytes = sum(payload.size_bytes for payload in _photo_payload_cache.values()) + while total_size_bytes > _MAX_PAYLOAD_CACHE_BYTES and sorted_ids: + payload_id = sorted_ids.pop(0) + removed = _photo_payload_cache.pop(payload_id, None) + if removed is not None: + total_size_bytes -= removed.size_bytes + + +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 ("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 isinstance(photo.data, io.BytesIO): + content = photo.data.getvalue() + elif isinstance(photo.data, (bytes, bytearray)): + content = bytes(photo.data) + elif hasattr(photo.data, "seek") and hasattr(photo.data, "read"): + photo.data.seek(0) + content = photo.data.read() + else: + raise TypeError(f"Expected byte stream for {photo.format}, got {type(photo.data).__name__}") + else: + if not isinstance(photo.data, np.ndarray): + raise TypeError(f"Expected numpy array for {photo.format}, got {type(photo.data).__name__}") + buffer = io.BytesIO() + np.save(buffer, photo.data) + content = buffer.getvalue() + + return content, media_type, filename + + +def _store_photo_payload( + camera_name: str, + content: bytes, + media_type: str, + filename: str, +) -> tuple[str, int]: + now_monotonic = time.monotonic() + payload_id = uuid4().hex + expires_at_monotonic = now_monotonic + _PAYLOAD_TTL_SECONDS + with _photo_payload_cache_lock: + _prune_expired_payloads(now_monotonic) + _photo_payload_cache[payload_id] = _CachedPhotoPayload( + camera_name=camera_name, + content=content, + media_type=media_type, + filename=filename, + size_bytes=len(content), + expires_at_monotonic=expires_at_monotonic, + ) + _enforce_payload_cache_size_limit() + return payload_id, _PAYLOAD_TTL_SECONDS + + +def _get_cached_photo_payload(camera_name: str, payload_id: str) -> _CachedPhotoPayload: + now_monotonic = time.monotonic() + with _photo_payload_cache_lock: + _prune_expired_payloads(now_monotonic) + payload = _photo_payload_cache.get(payload_id) + if payload is None or payload.camera_name != camera_name: + raise HTTPException(status_code=404, detail="Photo payload not found or expired.") + return payload + + +class CameraStatusResponse(BaseModel): + name: str + type: CameraType + busy: bool + settings: CameraSettings + + +class AutoCalibrateAwbRequest(BaseModel): + warmup_frames: int = Field( + default=12, + description="Number of frames to discard before reading AWB metadata.", + ge=0, + ) + stable_frames: int = Field( + default=4, + description="Consecutive frames that must meet the stability tolerance.", + ge=1, + ) + eps: float = Field( + default=0.01, + description="Maximum delta between gain values to consider them stable.", + gt=0, + ) + timeout_s: float = Field( + default=2.0, + description="Maximum time budget for the calibration loop in seconds.", + gt=0, + ) + + +class AutoCalibrateAwbResponse(BaseModel): + red_gain: float + blue_gain: float + + +@router.get("/", response_model=dict[str, CameraStatusResponse]) +async def get_cameras(): + """Get all cameras with their current status + + Returns: + dict[str, CameraStatusResponse]: A dictionary of camera name to a camera status object + """ + return { + name: controller.get_status() + for name, controller in get_all_camera_controllers().items() + } + + +@router.get("/{camera_name}", response_model=CameraStatusResponse) +async def get_camera(camera_name: str): + """Get a camera with its current status + + Args: + camera_name: The name of the camera to get the status of + + Returns: + CameraStatusResponse: A response object containing the status of the camera + """ + try: + return get_camera_controller(camera_name).get_status() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.get("/{camera_name}/preview") +async def get_preview( + camera_name: str, + mode: str = Query(default="stream", pattern="^(stream|snapshot)$"), + fps: int = Query(default=50, ge=1, le=50), +): + """Get a camera preview stream in lower resolution + + Note: The preview is not rotated by orientation_flag and has to be rotated by client. + + Args: + camera_name: The name of the camera to get the preview stream from + mode: Either ``stream`` for the MJPEG stream or ``snapshot`` for a single JPEG frame + fps: Target frames per second for the stream, clamped between 1 and 50 (only used in stream mode) + + Returns: + StreamingResponse: A streaming response containing the preview stream + """ + controller = get_camera_controller(camera_name) + + if mode == "snapshot": + if controller.is_busy(): + raise HTTPException(status_code=409, detail="Camera is busy. If this is a bug, please restart the camera.") + try: + frame = controller.preview() + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + return Response(content=frame, media_type="image/jpeg") + + frame_delay = 1 / fps + + async def generate(): + while True: + try: + frame = await controller.preview_async() + except RuntimeError: + break + if frame is not None: + yield (b'--frame\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') + await asyncio.sleep(frame_delay) + + return StreamingResponse(generate(), media_type="multipart/x-mixed-replace;boundary=frame") + + +@router.get("/{camera_name}/photo") +async def get_photo( + camera_name: str, + request: Request, + image_format: PhotoFormat = Query(default="jpeg"), + with_metadata: bool = Query(default=False), +): + """Get a camera photo + + Args: + camera_name: The name of the camera to get the photo from + + Returns: + Response: A response containing the photo + """ + controller = get_camera_controller(camera_name) + try: + photo = await controller.photo_async(image_format=image_format) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + try: + content, media_type, filename = _serialize_photo_payload(photo) + except (ValueError, TypeError) as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if not with_metadata: + return Response(content=content, media_type=media_type) + + payload_id, expires_in_s = _store_photo_payload( + camera_name=camera_name, + content=content, + media_type=media_type, + filename=filename, + ) + payload_url = str( + request.url_for( + "get_photo_payload", + camera_name=camera_name, + payload_id=payload_id, + ) + ) + return PhotoMetadataResponse( + format=photo.format, + media_type=media_type, + filename=filename, + camera_metadata=photo.camera_metadata, + scan_metadata=photo.scan_metadata, + payload_url=payload_url, + expires_in_s=expires_in_s, + ) + + +@router.get("/{camera_name}/photo/payload/{payload_id}", name="get_photo_payload") +async def get_photo_payload(camera_name: str, payload_id: str): + payload = _get_cached_photo_payload(camera_name=camera_name, payload_id=payload_id) + return Response( + content=payload.content, + media_type=payload.media_type, + headers={"Content-Disposition": f'inline; filename="{payload.filename}"'}, + ) + +@router.post("/{camera_name}/restart") +async def restart_camera(camera_name: str): + """Restart a camera + + Args: + camera_name: The name of the camera to restart + + Returns: + Response: A response containing the status code + """ + controller = get_camera_controller(camera_name) + controller.restart_camera() + return Response(status_code=200) + + +@router.post( + "/{camera_name}/awb-calibration", + response_model=AutoCalibrateAwbResponse, + summary="Run automatic white balance calibration and lock the gains.", +) +async def auto_calibrate_awb( + camera_name: str, + params: AutoCalibrateAwbRequest = Body(default=AutoCalibrateAwbRequest()), +): + """Expose the camera controller's automatic white balance calibration if available. + + Args: + camera_name: Target camera identifier. + params: Optional tuning parameters forwarded to the controller implementation. + + Returns: + AutoCalibrateAwbResponse: Locked gains after the calibration. + + Raises: + HTTPException: When the controller is busy, unsupported, or calibration fails. + """ + + controller = get_camera_controller(camera_name) + + if controller.is_busy(): + raise HTTPException(status_code=409, detail="Camera is busy. Retry once it is idle.") + + calibrate_fn = getattr(controller, "calibrate_awb_and_lock", None) + if not callable(calibrate_fn): + raise HTTPException( + status_code=501, + detail="This camera does not support automatic white balance calibration.", + ) + + try: + red_gain, blue_gain = calibrate_fn(**params.model_dump()) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return AutoCalibrateAwbResponse(red_gain=red_gain, blue_gain=blue_gain) + +create_settings_endpoints( + router=router, + resource_name="camera_name", + get_controller=get_camera_controller, + settings_model=CameraSettings +) diff --git a/openscan_firmware/routers/v0_9/cloud.py b/openscan_firmware/routers/v0_9/cloud.py new file mode 100644 index 0000000..e84723d --- /dev/null +++ b/openscan_firmware/routers/v0_9/cloud.py @@ -0,0 +1,357 @@ +"""Cloud-specific API endpoints exposing status, configuration and project helpers.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import re + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from openscan_firmware.config.cloud import CloudSettings, mask_secret, set_cloud_settings +from openscan_firmware.config.firmware import ( + get_firmware_settings, + save_firmware_settings, +) +from openscan_firmware.controllers.services import cloud as cloud_service +from openscan_firmware.controllers.services.cloud import CloudServiceError +from openscan_firmware.controllers.services.cloud_settings import ( + get_active_source, + get_masked_active_settings, + save_persistent_cloud_settings, + set_active_source, + delete_persistent_cloud_settings, + settings_file_exists, +) +from openscan_firmware.controllers.services.projects import ProjectManager, get_project_manager +from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager +from openscan_firmware.models.project import Project +from openscan_firmware.models.task import Task + +router = APIRouter( + prefix="/cloud", + tags=["cloud"], + responses={404: {"description": "Not found"}}, +) + +logger = logging.getLogger(__name__) +_TOKEN_PARAM_PATTERN = re.compile(r"(token=)([^&\s]+)") + + +class CloudSettingsResponse(BaseModel): + """Masked cloud settings including metadata.""" + + settings: dict[str, Any] | None = None + source: str | None = None + persisted: bool = False + + +class CloudStatusResponse(BaseModel): + """Aggregated view of the cloud backend status.""" + + status: dict[str, Any] | None = None + token_info: dict[str, Any] | None = None + queue_estimate: dict[str, Any] | None = None + settings: CloudSettingsResponse + message: str | None = None + + +class CloudProjectStatus(BaseModel): + """Local project enriched with cloud metadata and related tasks.""" + + project: Project + remote_project_name: str | None = None + remote_info: dict[str, Any] | None = None + tasks: list[Task] = Field(default_factory=list) + message: str | None = None + + +async def _fetch_remote_info(remote_name: str) -> tuple[dict[str, Any] | None, str | None]: + try: + data = await asyncio.to_thread(cloud_service.get_project_info, remote_name) + return data, None + except CloudServiceError as exc: # pragma: no cover - exercised in error test + return None, str(exc) + except Exception as exc: # pragma: no cover - defensive logging + logger.exception("Failed to fetch remote project info for %s", remote_name) + return None, str(exc) + + +def _collect_tasks_by_project() -> dict[str, list[Task]]: + task_manager = get_task_manager() + mapping: dict[str, list[Task]] = {} + for task in task_manager.get_all_tasks_info(): + if task.task_type != "cloud_upload_task" or not task.run_args: + continue + project_name = task.run_args[0] + mapping.setdefault(project_name, []).append(task) + return mapping + + +def _extract_remote_name_from_tasks(tasks: list[Task]) -> str | None: + for task in tasks: + result = task.result + if isinstance(result, dict) and "project" in result: + return str(result["project"]) + if hasattr(result, "project"): + return str(getattr(result, "project")) + return None + + +async def _build_project_status( + project: Project, + tasks_by_project: dict[str, list[Task]], + project_manager: ProjectManager, +) -> CloudProjectStatus: + remote_info = None + message = None + remote_name = project.cloud_project_name + tasks = tasks_by_project.get(project.name, []) + + if not remote_name: + remote_name = _extract_remote_name_from_tasks(tasks) + if remote_name: + try: + project_manager.mark_uploaded(project.name, True, remote_name) + refreshed = project_manager.get_project_by_name(project.name) + if refreshed is not None: + project = refreshed + except ValueError: + logger.warning( + "Failed to persist derived remote project name '%s' for '%s'", + remote_name, + project.name, + ) + elif tasks: + message = "Remote project name not available yet. Upload still running?" + + if remote_name: + fetched_info, fetch_message = await _fetch_remote_info(remote_name) + remote_info = fetched_info + if fetch_message: + message = f"{message} | {fetch_message}".strip(" |") if message else fetch_message + + return CloudProjectStatus( + project=project.model_copy(), + remote_project_name=remote_name, + remote_info=remote_info, + tasks=[task.model_copy() for task in tasks], + message=message, + ) + + +def _build_settings_response() -> CloudSettingsResponse: + return CloudSettingsResponse( + settings=get_masked_active_settings(), + source=get_active_source(), + persisted=settings_file_exists(), + ) + + +def _mask_tokens(text: str | None) -> str | None: + if not text: + return text + + return _TOKEN_PARAM_PATTERN.sub( + lambda match: f"{match.group(1)}{mask_secret(match.group(2))}", + text, + ) + + +def _disable_cloud_features() -> None: + settings = get_firmware_settings() + if not settings.enable_cloud: + return + + updated_settings = settings.model_copy(update={"enable_cloud": False}) + save_firmware_settings(updated_settings) + logger.info("Disabled firmware cloud features after cloud settings deletion.") + + +@router.get("/status", response_model=CloudStatusResponse) +async def get_cloud_status() -> CloudStatusResponse: + """Return aggregated status information for the cloud backend. + + Returns: + CloudStatusResponse: A response object containing the status of the cloud backend + """ + + status = token_info = queue_estimate = None + messages: list[str] = [] + + try: + status = await asyncio.to_thread(cloud_service.get_status) + except CloudServiceError as exc: + messages.append(f"Status unavailable: {_mask_tokens(str(exc))}") + except Exception as exc: # pragma: no cover - defensive logging + logger.exception("Cloud status request failed") + messages.append(f"Status request failed: {_mask_tokens(str(exc))}") + + try: + token_info = await asyncio.to_thread(cloud_service.get_token_info) + except CloudServiceError as exc: + messages.append(f"Token info unavailable: {_mask_tokens(str(exc))}") + except Exception as exc: # pragma: no cover - defensive logging + logger.exception("Token info request failed") + messages.append(f"Token info request failed: {_mask_tokens(str(exc))}") + + try: + queue_estimate = await asyncio.to_thread(cloud_service.get_queue_estimate) + except CloudServiceError as exc: + messages.append(f"Queue estimate unavailable: {_mask_tokens(str(exc))}") + except Exception as exc: # pragma: no cover - defensive logging + logger.exception("Queue estimate request failed") + messages.append(f"Queue estimate request failed: {_mask_tokens(str(exc))}") + + return CloudStatusResponse( + status=status, + token_info=token_info, + queue_estimate=queue_estimate, + settings=_build_settings_response(), + message=_mask_tokens(" | ".join(messages)) if messages else None, + ) + + +@router.get("/settings", response_model=CloudSettingsResponse) +async def get_cloud_settings() -> CloudSettingsResponse: + """Return the masked active cloud configuration. + + Returns: + CloudSettingsResponse: A response object containing the masked active cloud configuration + """ + + return _build_settings_response() + + +@router.post("/settings", response_model=CloudSettingsResponse) +async def update_cloud_settings(new_settings: CloudSettings) -> CloudSettingsResponse: + """Persist and activate new cloud settings. + + Args: + new_settings: The new cloud settings to persist and activate + + Returns: + CloudSettingsResponse: A response object containing the masked active cloud configuration + """ + + set_cloud_settings(new_settings) + await asyncio.to_thread(save_persistent_cloud_settings, new_settings) + set_active_source("persistent") + return _build_settings_response() + + +@router.delete("/settings", response_model=CloudSettingsResponse) +async def delete_cloud_settings() -> CloudSettingsResponse: + """Delete persisted cloud settings and disable cloud features.""" + + set_cloud_settings(None) + set_active_source(None) + await asyncio.to_thread(delete_persistent_cloud_settings) + _disable_cloud_features() + return _build_settings_response() + + +@router.get("/projects", response_model=list[CloudProjectStatus]) +async def list_cloud_projects() -> list[CloudProjectStatus]: + """Return all local projects enriched with cloud metadata. + + Returns: + list[CloudProjectStatus]: A list of cloud project status objects + """ + + project_manager = get_project_manager() + tasks_by_project = _collect_tasks_by_project() + + statuses: list[CloudProjectStatus] = [] + for project in project_manager.get_all_projects().values(): + statuses.append(await _build_project_status(project, tasks_by_project, project_manager)) + return statuses + + +@router.get("/projects/{project_name}", response_model=CloudProjectStatus) +async def get_cloud_project(project_name: str) -> CloudProjectStatus: + """Return cloud details for a single local project. + + Args: + project_name: The name of the project to get the cloud details for + + Returns: + CloudProjectStatus: A response object containing the cloud project status + """ + + project_manager = get_project_manager() + project = project_manager.get_project_by_name(project_name) + if project is None: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + + tasks_by_project = _collect_tasks_by_project() + return await _build_project_status(project, tasks_by_project, project_manager) + + +@router.delete("/projects/{project_name}") +async def reset_cloud_project(project_name: str) -> dict[str, Any]: + """Reset the remote project and clear the local linkage. + + Invokes the cloud backend's `resetProject` action, which removes the + current reconstruction job (queue progress, generated models and downloads) + and frees the remote project name for another upload. + Locally the project is marked as not uploaded anymore, the cached + `cloud_project_name` is cleared, and the `downloaded` flag is reset to + False so a subsequent download reflects the new state. The on-disk files + stay untouched. + + Args: + project_name: The name of the project to reset the remote project for + + Returns: + dict[str, Any]: A response object containing the result of the reset operation + """ + + project_manager = get_project_manager() + project = project_manager.get_project_by_name(project_name) + if project is None: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + + remote_name = project.cloud_project_name + if not remote_name: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' has no recorded remote counterpart") + + try: + response = await asyncio.to_thread(cloud_service.reset_project, remote_name) + except CloudServiceError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + project_manager.mark_uploaded(project_name, False) + project_manager.mark_downloaded(project_name, False) + return {"project": project_name, "remote_project": remote_name, "response": response} + + +@router.post("/projects/{project_name}/download", response_model=Task) +async def download_project_from_cloud( + project_name: str, + token_override: str | None = None, + remote_project: str | None = None, +) -> Task: + """Schedule an asynchronous cloud download for a project's reconstruction. + + Args: + project_name: Local project name whose reconstruction should be downloaded. + token_override: Optional token override forwarded to the download task. + remote_project: Optional explicit remote project identifier; defaults to stored linkage. + + Returns: + Task: The TaskManager model describing the scheduled download. + """ + + try: + task = await cloud_service.download_project( + project_name, + token=token_override, + remote_project=remote_project, + ) + except CloudServiceError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return task diff --git a/openscan_firmware/routers/v0_9/develop.py b/openscan_firmware/routers/v0_9/develop.py new file mode 100644 index 0000000..4997038 --- /dev/null +++ b/openscan_firmware/routers/v0_9/develop.py @@ -0,0 +1,362 @@ +""" +Developer endpoints + +These may be removed or changed at any time. +""" + +import base64 +import json +import subprocess +import time +from pathlib import Path +from typing import Literal + +from fastapi import APIRouter, HTTPException, status, Response, Query +from fastapi.responses import PlainTextResponse + +from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager +from openscan_firmware.models.task import TaskStatus, Task + +from openscan_firmware.models.paths import PolarPoint3D +from openscan_firmware.controllers.hardware.motors import move_to_point + +from openscan_firmware.utils.paths import paths +from openscan_firmware.cli import DEFAULT_RELOAD_TRIGGER + +CAMERA_REPORT_SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "camera_report.sh" + + +router = APIRouter( + prefix="/develop", + tags=["develop"], + responses={404: {"description": "Not found"}}, +) + + +def _gp_text(value) -> str: # noqa: ANN001 + if value is None: + return "" + return str(getattr(value, "text", value)) + + +def _extract_widget_choices(widget) -> list[str]: # noqa: ANN001 + try: + count = widget.count_choices() + except Exception: + return [] + choices: list[str] = [] + for idx in range(count): + try: + choices.append(str(widget.get_choice(idx))) + except Exception: + continue + return choices + + +def _walk_config_widgets(widget, prefix: str = "") -> list[dict]: # noqa: ANN001 + entries: list[dict] = [] + try: + name = str(widget.get_name()) + except Exception: + name = "unknown" + path = f"{prefix}/{name}" if prefix else f"/{name}" + + try: + label = str(widget.get_label()) + except Exception: + label = "" + + try: + value = str(widget.get_value()) + except Exception: + value = None + + try: + readonly = bool(widget.get_readonly()) + except Exception: + readonly = None + + try: + widget_type = str(widget.get_type()) + except Exception: + widget_type = None + + entries.append( + { + "name": name, + "label": label, + "path": path, + "type": widget_type, + "readonly": readonly, + "value": value, + "choices": _extract_widget_choices(widget), + } + ) + + try: + child_count = widget.count_children() + except Exception: + child_count = 0 + + for child_idx in range(child_count): + try: + child = widget.get_child(child_idx) + except Exception: + continue + entries.extend(_walk_config_widgets(child, path)) + return entries + + +def _collect_gphoto2_diagnostics() -> dict: + """Collect gphoto2 diagnostics via Python API with lazy import.""" + try: + import gphoto2 as gp + except Exception as exc: + return { + "available": False, + "error": f"python gphoto2 module unavailable: {exc}", + "detected": [], + "cameras": [], + } + + try: + detected = gp.Camera.autodetect() + except Exception as exc: + return { + "available": True, + "error": f"autodetect failed: {exc}", + "detected": [], + "cameras": [], + } + + rows: list[dict[str, str]] = [] + try: + count = detected.count() + for idx in range(count): + rows.append({"model": detected.get_name(idx), "path": detected.get_value(idx)}) + except Exception: + try: + rows = [{"model": item[0], "path": item[1]} for item in detected] + except Exception as exc: + return { + "available": True, + "error": f"Failed to parse autodetect result: {exc}", + "detected": [], + "cameras": [], + } + + cameras: list[dict] = [] + for row in rows: + model = row.get("model") + path = row.get("path") + camera_diag = { + "model": model, + "path": path, + "summary": None, + "about": None, + "config_groups": [], + "relevant_config": [], + "error": None, + } + camera = None + try: + camera = gp.Camera() + camera.init() + try: + camera_diag["summary"] = _gp_text(camera.get_summary()).strip() + except Exception: + camera_diag["summary"] = None + try: + camera_diag["about"] = _gp_text(camera.get_about()).strip() + except Exception: + camera_diag["about"] = None + try: + config = camera.get_config() + child_count = config.count_children() + groups: list[str] = [] + for child_idx in range(child_count): + child = config.get_child(child_idx) + groups.append(f"{child.get_name()}: {child.get_label()}") + camera_diag["config_groups"] = groups + all_widgets = _walk_config_widgets(config) + key_candidates = { + "capturetarget", + "capture", + "recordingmedia", + "shutterspeed", + "shutter_speed", + "aperture", + "f-number", + "iso", + "imageformat", + "imagequality", + "imgquality", + "eosremoterelease", + "viewfinder", + "focusmode", + "autoexposuremode", + } + camera_diag["relevant_config"] = [ + item for item in all_widgets if item["name"].lower() in key_candidates + ] + except Exception: + camera_diag["config_groups"] = [] + camera_diag["relevant_config"] = [] + except Exception as exc: + camera_diag["error"] = str(exc) + finally: + if camera is not None: + try: + camera.exit() + except Exception: + pass + cameras.append(camera_diag) + + return { + "available": True, + "error": None, + "detected": rows, + "cameras": cameras, + } + + +@router.put("/scanner-position") +async def move_to_position(point: PolarPoint3D): + """Move Rotor and Turntable to a polar point""" + await move_to_point(point) + + +@router.post("/restart", status_code=status.HTTP_202_ACCEPTED) +async def restart_application() -> dict[str, str]: + """Trigger a Firmware reload by touching the reload sentinel file. + + Note: The application has to be started with the --reload-trigger option to enable this endpoint.""" + DEFAULT_RELOAD_TRIGGER.parent.mkdir(parents=True, exist_ok=True) + DEFAULT_RELOAD_TRIGGER.write_text(str(time.time()), encoding="utf-8") + # Ensure mtime changes even on file systems with coarse-grained timestamps + DEFAULT_RELOAD_TRIGGER.touch() + return {"detail": "Reload triggered"} + + +@router.get("/camera-report") +async def get_camera_report( + format: Literal["json", "text"] = Query(default="json"), +): + """Run the camera diagnostics script and return a bundled report.""" + if not CAMERA_REPORT_SCRIPT.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Camera report script not found: {CAMERA_REPORT_SCRIPT}", + ) + + result = subprocess.run( + ["bash", str(CAMERA_REPORT_SCRIPT)], + capture_output=True, + text=True, + timeout=180, + check=False, + ) + report = result.stdout.strip() + stderr = result.stderr.strip() + gphoto2_diag = _collect_gphoto2_diagnostics() + + if format == "text": + text_output = report or stderr or "No output produced." + gphoto2_section = "===== GPhoto2 python diagnostics =====\n" + json.dumps(gphoto2_diag, indent=2) + text_output = f"{text_output}\n\n{gphoto2_section}" + status_code = status.HTTP_200_OK if result.returncode == 0 else status.HTTP_500_INTERNAL_SERVER_ERROR + return PlainTextResponse(content=text_output, status_code=status_code) + + payload = { + "ok": result.returncode == 0, + "return_code": result.returncode, + "script": str(CAMERA_REPORT_SCRIPT), + "report": report, + "stderr": stderr, + "gphoto2": gphoto2_diag, + } + + if result.returncode != 0: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=payload) + + return payload + + +@router.get("/crop_image", summary="Run crop task and return visualization image", response_class=Response) +async def crop_image(camera_name: str, threshold: int | None = Query(default=None, ge=0, le=255)) -> Response: + """Run the crop task and return the visualization image with bounding boxes. + + Args: + camera_name: Name of the camera controller to use. + threshold: Optional Canny threshold passed to the analysis (tutorial uses a trackbar). If not set, defaults inside the task. + + Returns: + Response: JPEG image showing contours, rectangles and circles as detected by the task. + """ + task_manager = get_task_manager() + + # Start task + task = await task_manager.create_and_run_task("crop_task", camera_name, threshold=threshold) + + # Wait for completion (default TaskManager timeout is fine for demo; can be adjusted if needed) + try: + final_task = await task_manager.wait_for_task(task.id, timeout=120.0) + except Exception as e: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Waiting for task failed: {e}") + + if final_task.status != TaskStatus.COMPLETED: + detail = final_task.error or f"Task did not complete successfully (status={final_task.status})." + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail) + + result = final_task.result or {} + if not isinstance(result, dict) or "image_base64" not in result: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Task result does not contain an image.") + + try: + img_bytes = base64.b64decode(result["image_base64"]) + except Exception: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to decode image from task result.") + + return Response(content=img_bytes, media_type=result.get("mime", "image/jpeg")) + + + +@router.post("/hello-world-async", response_model=Task) +async def hello_world_async(total_steps: int, delay: float): + """Start the async hello world demo task.""" + + task_manager = get_task_manager() + + # Updated to explicit task_name with required _task suffix + task = await task_manager.create_and_run_task("hello_world_async_task", total_steps=total_steps, delay=delay) + return task + + +@router.post("/qr-scan", response_model=Task, status_code=status.HTTP_202_ACCEPTED) +async def start_qr_scan( + camera_name: str = Query(description="Name of the camera controller to use"), +): + """Start a background task that scans for WiFi QR codes via the camera. + + The task runs indefinitely, capturing frames and looking for QR codes. + When it finds an Android/iOS WiFi share QR code it connects to the + network via nmcli and completes. Cancel the task to stop scanning. + + Args: + camera_name: Name of the camera controller to use for captures. + + Returns: + Task: The created task model (poll via /tasks/{id} for progress). + """ + task_manager = get_task_manager() + task = await task_manager.create_and_run_task( + "qr_scan_task", + camera_name=camera_name, + ) + return task + + +@router.get("/{method}", response_model=list[paths.CartesianPoint3D]) +async def get_path(method: paths.PathMethod, points: int): + """Get a list of coordinates by path method and number of points""" + return paths.get_path(method, points) diff --git a/openscan_firmware/routers/v0_9/device.py b/openscan_firmware/routers/v0_9/device.py new file mode 100644 index 0000000..d1dc1eb --- /dev/null +++ b/openscan_firmware/routers/v0_9/device.py @@ -0,0 +1,364 @@ +from fastapi import APIRouter, HTTPException, UploadFile, File +from pydantic import BaseModel, ValidationError +from pathlib import Path +import os +import json +import tempfile +import shutil +import logging + +from openscan_firmware.models.scanner import ScannerDeviceConfig, ScannerStartupMode, ScannerCalibrateMode +from openscan_firmware.controllers import device + +from openscan_firmware.utils.dir_paths import resolve_settings_dir +from .cameras import CameraStatusResponse +from .motors import MotorStatusResponse +from .lights import LightStatusResponse + +router = APIRouter( + prefix="/device", + tags=["device"], + responses={404: {"description": "Not found"}}, +) + +logger = logging.getLogger(__name__) + + +class DeviceConfigRequest(BaseModel): + config_file: str + +class DeviceStatusResponse(BaseModel): + name: str + model: str | None = None + shield: str | None = None + cameras: dict[str, CameraStatusResponse] + motors: dict[str, MotorStatusResponse] + lights: dict[str, LightStatusResponse] + motors_timeout: float + startup_mode: ScannerStartupMode + calibrate_mode: ScannerCalibrateMode + initialized: bool + +class DeviceControlResponse(BaseModel): + success: bool + message: str + status: DeviceStatusResponse + + +class DeviceConfigResponse(BaseModel): + status: str + filename: str + path: str + config: ScannerDeviceConfig + + +def _runtime_status_response() -> DeviceStatusResponse: + raw_info = device.get_device_info() + logger.debug("Device info payload before validation: %s", raw_info) + return DeviceStatusResponse.model_validate(raw_info) + + +@router.get("/info", response_model=DeviceStatusResponse) +async def get_device_info(): + """Get information about the device + + Returns: + dict: A dictionary containing information about the device + """ + try: + info = device.get_device_info() + if info.get("model") is None or info.get("shield") is None: + raise HTTPException( + status_code=503, + detail={ + "message": "Device configuration is not loaded.", + "errors": [ + { + "loc": ["model"], + "msg": "Input should be a valid string", + "input": info.get("model"), + }, + { + "loc": ["shield"], + "msg": "Input should be a valid string", + "input": info.get("shield"), + }, + ], + }, + ) + return DeviceStatusResponse.model_validate(info) + except ValidationError as exc: + raise HTTPException( + status_code=503, + detail={ + "message": "Device configuration is not loaded.", + "errors": exc.errors(), + }, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error getting device info: {str(e)}") + + +@router.get("/configurations") +async def list_config_files(): + """List all available device configuration files""" + try: + configs = device.get_available_configs() + return {"status": "success", "configs": configs} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error listing configuration files: {str(e)}") + + +@router.get("/configurations/current", response_model=DeviceConfigResponse) +async def get_current_config(): + """Return the currently active device configuration file.""" + try: + logger.debug("Reading current device configuration from %s", device.DEVICE_CONFIG_FILE) + config_path = Path(device.DEVICE_CONFIG_FILE) + config_payload = device.load_device_config() + return { + "status": "success", + "filename": config_path.name, + "path": str(config_path), + "config": config_payload, + } + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Error loading current configuration: {exc}") + + +@router.get("/configurations/{filename}", response_model=DeviceConfigResponse) +async def get_config_file(filename: str): + """Return a specific configuration JSON file by filename.""" + try: + logger.debug("Reading configuration file request", extra={"config_filename": filename}) + normalized = filename if filename.endswith(".json") else f"{filename}.json" + safe_name = Path(normalized).name + config_path = resolve_settings_dir("device") / safe_name + + if not config_path.exists(): + raise HTTPException( + status_code=404, + detail={ + "message": f"Config file not found: {safe_name}", + "available_configs": device.get_available_configs(), + }, + ) + + try: + config_payload = json.loads(config_path.read_text()) + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=400, + detail=f"Failed to parse configuration file '{safe_name}': {exc.msg}", + ) + + return { + "status": "success", + "filename": config_path.name, + "path": str(config_path), + "config": config_payload, + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Error loading configuration file: {exc}") + + +@router.post("/configurations/", response_model=DeviceControlResponse) +async def add_config_json(config_data: ScannerDeviceConfig, filename: DeviceConfigRequest): + """Add a device configuration from a JSON object + + This endpoint accepts a JSON object with the device configuration, + validates it and saves it to a file. + + Args: + config_data: The device configuration to add + filename: The filename to save the configuration as + + Returns: + dict: A dictionary containing the status of the operation + """ + try: + logger.info("Persisting uploaded configuration", extra={"config_filename": filename.config_file}) + # Create a temporary file to save the configuration + with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as temp_file: + # Convert the model to a dictionary and save it as JSON + config_dict = config_data.model_dump(mode="json") + payload_preview = json.dumps(config_dict, ensure_ascii=False) + max_payload_chars = 2000 + if len(payload_preview) > max_payload_chars: + payload_preview = f"{payload_preview[:max_payload_chars]}... [truncated]" + logger.info( + "Incoming configuration payload for %s: %s", + filename.config_file, + payload_preview, + ) + json.dump(config_dict, temp_file, indent=4) + temp_path = temp_file.name + + # Save to settings directory with a meaningful name + settings_dir = resolve_settings_dir("device") + os.makedirs(settings_dir, exist_ok=True) + + target_filename = filename.config_file + if not target_filename.endswith(".json"): + target_filename = f"{target_filename}.json" + target_path = os.path.join(settings_dir, target_filename) + + # Move the temporary file to the target path + shutil.move(temp_path, target_path) + + status = _runtime_status_response() + logger.info( + "Configuration saved", + extra={ + "config_filename": target_filename, + "config_path": target_path, + "motors": list(status.motors.keys()), + }, + ) + + return DeviceControlResponse( + success=True, + message="Configuration saved successfully", + status=status + ) + + except Exception as e: + logger.exception("Error while saving configuration", extra={"config_filename": filename.config_file}) + raise HTTPException(status_code=500, detail=f"Error setting device configuration: {str(e)}") + + +@router.patch("/configurations/current", response_model=DeviceControlResponse) +async def save_device_config(): + """Save the current device configuration to a file + + This endpoint saves the current device configuration to device_config.json. + + Returns: + dict: A dictionary containing the status of the operation + """ + logger.info("Saving current runtime configuration to disk") + if device.save_device_config(): + return DeviceControlResponse( + success=True, + message="Configuration saved successfully", + status=_runtime_status_response() + ) + else: + logger.error("save_device_config returned False") + raise HTTPException(status_code=500, detail="Failed to save device configuration") + +@router.put("/configurations/current", response_model=DeviceControlResponse) +async def set_config_file(config_data: DeviceConfigRequest): + """Set the device configuration from a file and initialize hardware + + Args: + config_data: The device configuration to set + + Returns: + dict: A dictionary containing the status of the operation + """ + try: + logger.info("Setting active configuration", extra={"requested": config_data.config_file}) + # Get available configs + available_configs = device.get_available_configs() + + # Check if the config file exists in available configs + config_file = config_data.config_file + config_found = False + + # If it's just a filename (no path), try to find it in available configs + if not os.path.dirname(config_file): + for config in available_configs: + if config["filename"] == config_file: + config_file = config["path"] + config_found = True + break + else: + # Check if the full path exists + config_found = os.path.exists(config_file) + + if not config_found: + raise HTTPException( + status_code=404, + detail={ + "message": f"Config file not found: {config_data.config_file}", + "available_configs": available_configs + } + ) + + # Set device config + if await device.set_device_config(config_file): + status = _runtime_status_response() + logger.info("Configuration loaded", extra={"active": config_file}) + return DeviceControlResponse( + success=True, + message="Configuration loaded successfully", + status=status + ) + else: + logger.error("set_device_config returned False", extra={"active": config_file}) + raise HTTPException(status_code=500, detail="Failed to load device configuration") + + except HTTPException: + # Re-raise HTTP exceptions to preserve status code and detail + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error setting device configuration: {str(e)}") + + +@router.post("/configurations/current/initialize", response_model=DeviceControlResponse) +async def reinitialize_hardware(detect_cameras: bool = False): + """Reinitialize hardware components + + This can be used in case of a hardware failure or to reload the hardware components. + + Args: + detect_cameras: Whether to detect cameras + + Returns: + dict: A dictionary containing the status of the operation + """ + logger.info("Reinitializing hardware", extra={"detect_cameras": detect_cameras}) + try: + await device.initialize(detect_cameras=detect_cameras) + status = _runtime_status_response() + logger.info( + "Hardware reinitialized", + extra={ + "detect_cameras": detect_cameras, + "motors": list(status.motors.keys()), + "lights": list(status.lights.keys()), + }, + ) + return DeviceControlResponse( + success=True, + message="Hardware reinitialized successfully", + status=status + ) + except Exception as e: + logger.exception("Error reloading hardware", extra={"detect_cameras": detect_cameras}) + raise HTTPException(status_code=500, detail=f"Error reloading hardware: {str(e)}") + + +@router.post("/reboot", response_model=bool) +def reboot(save_config: bool = False): + """Reboot system and optionally save config. + + Args: + save_config: Whether to save the current configuration before rebooting + """ + device.reboot(save_config) + return True + + +@router.post("/shutdown", response_model=bool) +def shutdown(save_config: bool = False) -> None: + """Shutdown system and optionally save config. + + Args: + save_config: Whether to save the current configuration before shutting down + """ + device.shutdown(save_config) + return True diff --git a/openscan_firmware/routers/v0_9/firmware.py b/openscan_firmware/routers/v0_9/firmware.py new file mode 100644 index 0000000..61c298e --- /dev/null +++ b/openscan_firmware/routers/v0_9/firmware.py @@ -0,0 +1,53 @@ +"""Firmware settings API endpoints.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from openscan_firmware.config.firmware import ( + FirmwareSettings, + get_firmware_settings, + save_firmware_settings, +) + +router = APIRouter( + prefix="/firmware", + tags=["firmware"], + responses={404: {"description": "Not found"}}, +) + + +class FirmwareSettingPatchRequest(BaseModel): + value: Any + + +@router.get("/settings", response_model=FirmwareSettings) +async def get_settings() -> FirmwareSettings: + """Return persisted firmware settings.""" + return get_firmware_settings() + + +@router.put("/settings", response_model=FirmwareSettings) +async def replace_settings(settings: FirmwareSettings) -> FirmwareSettings: + """Replace the entire firmware settings payload.""" + save_firmware_settings(settings) + return settings + + +@router.patch("/settings/{key}", response_model=FirmwareSettings) +async def update_setting(key: str, payload: FirmwareSettingPatchRequest) -> FirmwareSettings: + """Update a single firmware settings key.""" + current_settings = get_firmware_settings() + + if key not in FirmwareSettings.model_fields: + raise HTTPException(status_code=404, detail=f"Unknown firmware setting key: {key}") + + updated_payload = current_settings.model_dump() + updated_payload[key] = payload.value + updated_settings = FirmwareSettings.model_validate(updated_payload) + + save_firmware_settings(updated_settings) + return updated_settings diff --git a/openscan_firmware/routers/v0_9/focus_stacking.py b/openscan_firmware/routers/v0_9/focus_stacking.py new file mode 100644 index 0000000..1370521 --- /dev/null +++ b/openscan_firmware/routers/v0_9/focus_stacking.py @@ -0,0 +1,68 @@ +"""API endpoints for managing focus stacking tasks.""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from openscan_firmware.controllers.services import focus_stacking as focus_service +from openscan_firmware.models.task import Task + +router = APIRouter(prefix="/projects", tags=["focus_stacking"]) + + +@router.post("/{project_name}/scans/{scan_index:int}/focus-stacking/start", response_model=Task) +async def start_focus_stacking(project_name: str, scan_index: int) -> Task: + """Start focus stacking for a scan.""" + try: + return await focus_service.start_focus_stacking(project_name, scan_index) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/pause", response_model=Task) +async def pause_focus_stacking(project_name: str, scan_index: int) -> Task: + """Pause an active focus stacking task.""" + try: + task = await focus_service.pause_focus_stacking(project_name, scan_index) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if task is None: + raise HTTPException(status_code=409, detail="Focus stacking is not running") + + return task + + +@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/resume", response_model=Task) +async def resume_focus_stacking(project_name: str, scan_index: int) -> Task: + """Resume a paused focus stacking task.""" + try: + task = await focus_service.resume_focus_stacking(project_name, scan_index) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if task is None: + raise HTTPException(status_code=409, detail="Focus stacking is not paused") + + return task + + +@router.patch("/{project_name}/scans/{scan_index:int}/focus-stacking/cancel", response_model=Task) +async def cancel_focus_stacking(project_name: str, scan_index: int) -> Task: + """Cancel an active focus stacking task.""" + try: + task = await focus_service.cancel_focus_stacking(project_name, scan_index) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: # pragma: no cover - unexpected errors bubble up as 500 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if task is None: + raise HTTPException(status_code=409, detail="Focus stacking is not running") + + return task diff --git a/openscan_firmware/routers/v0_9/gpio.py b/openscan_firmware/routers/v0_9/gpio.py new file mode 100644 index 0000000..a4d5a0c --- /dev/null +++ b/openscan_firmware/routers/v0_9/gpio.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter + +from openscan_firmware.controllers.hardware import gpio + +router = APIRouter( + prefix="/gpio", + tags=["gpio"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/") +async def get_pins() -> dict[str, list[int]]: + """Get all initialized GPIO pins + + Returns: + dict[str, list[int]]: A dictionary of initialized output pins and buttons + """ + return gpio.get_initialized_pins() + + +@router.get("/{pin_id}", response_model=bool) +async def get_pin(pin_id: int): + """Get output value of a specific GPIO pin + + Args: + pin_id: The ID (int) of the GPIO pin to get the value of + + Returns: + bool: The output value of the GPIO pin + """ + return gpio.get_output_pin(pin_id) + + +@router.patch("/{pin_id}") +async def set_pin(pin_id: int, status: bool): + """Set GPIO pin output value + + Args: + 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) + + +@router.patch("/{pin_id}/toggle") +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) diff --git a/openscan_firmware/routers/v0_9/lights.py b/openscan_firmware/routers/v0_9/lights.py new file mode 100644 index 0000000..8bc8a08 --- /dev/null +++ b/openscan_firmware/routers/v0_9/lights.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from openscan_firmware.controllers.hardware.lights import get_light_controller, get_all_light_controllers +from openscan_firmware.config.light import LightConfig +from .settings_utils import create_settings_endpoints + +router = APIRouter( + prefix="/lights", + tags=["lights"], + responses={404: {"description": "Not found"}}, +) + +class LightStatusResponse(BaseModel): + name: str + is_on: bool + settings: LightConfig + + +@router.get("/", response_model=dict[str, LightStatusResponse]) +async def get_lights(): + """Get all lights with their current status + + Returns: + dict[str, LightStatusResponse]: A dictionary of light name to a light status object + """ + return { + name: controller.get_status() + for name, controller in get_all_light_controllers().items() + } + + +@router.get("/{light_name}", response_model=LightStatusResponse) +async def get_light(light_name: str): + """Get light status + + Args: + light_name: The name of the light to get the status of + + Returns: + LightStatusResponse: A response object containing the status of the light + """ + try: + return get_light_controller(light_name).get_status() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.patch("/{light_name}/turn_on", response_model=LightStatusResponse) +async def turn_on_light(light_name: str): + """Turn on light + + Args: + light_name: The name of the light to turn on + + Returns: + LightStatusResponse: A response object containing the status of the light after the turn on operation + """ + try: + controller = get_light_controller(light_name) + await controller.turn_on() + return controller.get_status() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.patch("/{light_name}/turn_off", response_model=LightStatusResponse) +async def turn_off_light(light_name: str): + """Turn of light + + Args: + light_name: The name of the light to turn off + + Returns: + LightStatusResponse: A response object containing the status of the light after the turn off operation + """ + try: + controller = get_light_controller(light_name) + await controller.turn_off() + return controller.get_status() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.patch("/{light_name}/toggle", response_model=LightStatusResponse) +async def toggle_light(light_name: str): + """Toggle light on or off + + Args: + light_name: The name of the light to toggle + + Returns: + LightStatusResponse: A response object containing the status of the light after the toggle operation + """ + try: + controller = get_light_controller(light_name) + if controller.is_on: + await controller.turn_off() + else: + await controller.turn_on() + return controller.get_status() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +create_settings_endpoints( + router=router, + resource_name="light_name", + get_controller=get_light_controller, + settings_model=LightConfig +) diff --git a/openscan_firmware/routers/v0_9/motors.py b/openscan_firmware/routers/v0_9/motors.py new file mode 100644 index 0000000..dd8e1e8 --- /dev/null +++ b/openscan_firmware/routers/v0_9/motors.py @@ -0,0 +1,198 @@ +import asyncio + +from fastapi import APIRouter, Body, HTTPException, Query +from pydantic import BaseModel +from typing import Optional + +from openscan_firmware.config.motor import MotorConfig +from openscan_firmware.config.endstop import EndstopConfig +from openscan_firmware.controllers.hardware.motors import get_motor_controller, get_all_motor_controllers +from .settings_utils import create_settings_endpoints + +router = APIRouter( + prefix="/motors", + tags=["motors"], + responses={404: {"description": "Not found"}}, +) + + +def _get_motor_controller_or_404(motor_name: str): + """Return the motor controller or raise a FastAPI 404.""" + + try: + return get_motor_controller(motor_name) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +class EndstopStatusResponse(BaseModel): + assigned_motor: str + position: float + pin: int + is_pressed: bool + pull_up: bool | None = None + active_high: bool | None = None + bounce_time: float | None = None + + +class MotorStatusResponse(BaseModel): + name: str + angle: float + busy: bool + target_angle: Optional[float] + settings: MotorConfig + calibrated: bool + endstop: Optional[EndstopStatusResponse] + + +@router.get("/", response_model=dict[str, MotorStatusResponse]) +async def get_motors(): + """Get all motors with their current status + + Returns: + dict[str, MotorStatusResponse]: A dictionary of motor name to a motor status object + """ + return { + name: controller.get_status() + for name, controller in get_all_motor_controllers().items() + } + + +@router.get("/{motor_name}", response_model=MotorStatusResponse) +async def get_motor(motor_name: str): + """Get motor status + + Args: + motor_name: The name of the motor to get the status of + + Returns: + MotorStatusResponse: A response object containing the status of the motor + """ + return _get_motor_controller_or_404(motor_name).get_status() + + +@router.put("/{motor_name}/angle", response_model=MotorStatusResponse) +async def move_motor_to_angle(motor_name: str, degrees: float): + """Move motor to absolute position + + Args: + motor_name: The name of the motor to move + degrees: Number of degrees to move + + Returns: + MotorStatusResponse: A response object containing the status of the motor after the move + """ + + controller = _get_motor_controller_or_404(motor_name) + await controller.move_to(degrees) + return controller.get_status() + + +@router.patch("/{motor_name}/angle", response_model=MotorStatusResponse) +async def move_motor_by_degree(motor_name: str, degrees: float = Body(embed=True)): + """Move motor by degrees + + Args: + motor_name: The name of the motor to move + degrees: Number of degrees to move + + Returns: + MotorStatusResponse: A response object containing the status of the motor after the move + """ + controller = _get_motor_controller_or_404(motor_name) + await controller.move_degrees(degrees) + return controller.get_status() + + +@router.put("/{motor_name}/angle-override", response_model=MotorStatusResponse) +async def override_motor_angle( + motor_name: str, + angle: float = Query( + 90.0, + 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." + ), + ), +): + """Override the internal motor angle model. + + This endpoint forces the controller's model to a specific angle without moving hardware. The + default of 90° assumes the motor was manually aligned beforehand. Changing this value without + confirming the actual motor position can desynchronize the model from reality and cause motion + issues. The override is rejected while the controller reports a busy state to avoid writing an + inconsistent angle during movements. + + Args: + motor_name: Identifier of the motor whose model should be overwritten. + angle: The new angle to store in the model (defaults to 90°). + + Returns: + MotorStatusResponse: Updated status after overriding the model angle. + """ + + controller = _get_motor_controller_or_404(motor_name) + if controller.is_busy(): + raise HTTPException( + status_code=409, + detail=( + "Motor is currently moving. Stop the motion before overriding the internal angle " + "model to avoid desynchronization." + ), + ) + + controller.model.angle = angle + return controller.get_status() + + +@router.put("/{motor_name}/endstop-calibration", response_model=MotorStatusResponse) +async def motor_endstop_calibration( + motor_name: str, + force: bool = Query( + False, + description="Force recalibration even if the controller already considers the motor calibrated.", + ), +): + """Move motor to home through endstop sensing + + This endpoint moves the motor to the home position using the endstop calibration. + + Args: + motor_name: The name of the motor to move to the home position + + Returns: + MotorStatusResponse: A response object containing the status of the motor after the move + """ + controller = _get_motor_controller_or_404(motor_name) + if controller.endstop and not controller.is_busy(): + await controller.calibrate(force=force) + return controller.get_status() + else: + raise HTTPException(status_code=422, detail="No endstop configured or motor is busy!") + +@router.put("/{motor_name}/home", response_model=MotorStatusResponse) +async def motor_move_home(motor_name: str): + """Move motor to home + + This endpoint moves the motor to the home position uning method depending on config param + + Args: + motor_name: The name of the motor to move to the home position + + Returns: + MotorStatusResponse: A response object containing the status of the motor after the move + """ + controller = _get_motor_controller_or_404(motor_name) + if not controller.is_busy(): + await controller.move_to_home() + return controller.get_status() + else: + raise HTTPException(status_code=422, detail="Motor is busy!") + + +create_settings_endpoints( + router=router, + resource_name="motor_name", + get_controller=get_motor_controller, + settings_model=MotorConfig +) diff --git a/openscan_firmware/routers/v0_9/openscan.py b/openscan_firmware/routers/v0_9/openscan.py new file mode 100644 index 0000000..096fa6a --- /dev/null +++ b/openscan_firmware/routers/v0_9/openscan.py @@ -0,0 +1,283 @@ +import asyncio +import os +import zipfile +import glob +from collections import deque +from datetime import datetime +from shutil import disk_usage +from tempfile import NamedTemporaryFile +from typing import AsyncGenerator, Optional, Tuple + +from fastapi import APIRouter, Body, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +from starlette.background import BackgroundTask +from starlette.responses import FileResponse + +from openscan_firmware import __version__ +from openscan_firmware.config.logger import DEFAULT_LOGS_PATH, flush_memory_handlers +from openscan_firmware.controllers.device import get_scanner_model +from openscan_firmware.utils.dir_paths import resolve_projects_dir, resolve_runtime_dir +from openscan_firmware.utils.firmware_state import get_firmware_state + +class DiskUsage(BaseModel): + """Filesystem usage snapshot for a directory.""" + + total: int = Field(..., description="Total bytes available on the filesystem.") + used: int = Field(..., description="Bytes currently used (total - free).") + free: int = Field(..., description="Free bytes remaining on the filesystem.") + + +class SoftwareInfoResponse(BaseModel): + """Information block served by /next/openscan.""" + + model: Optional[str] = Field(None, description="Scanner model identifier, if configured.") + firmware_version: str = Field(..., description="Currently running firmware version string.") + last_shutdown_was_unclean: bool = Field( + ..., description="Indicates whether the previous shutdown finished cleanly." + ) + runtime_dir: str = Field(..., description="Absolute path used for runtime state files.") + runtime_disk: Optional[DiskUsage] = Field( + None, description="Disk usage snapshot for the runtime directory filesystem." + ) + projects_disk: Optional[DiskUsage] = Field( + None, description="Disk usage snapshot for the projects directory filesystem." + ) + uptime_seconds: Optional[float] = Field( + None, description="Current system uptime in seconds, if available." + ) + + +router = APIRouter( + prefix="", + tags=["openscan"], + responses={404: {"description": "Not found"}}, +) + +@router.get("/", response_model=SoftwareInfoResponse) +async def get_software_info() -> SoftwareInfoResponse: + """Get information about the scanner software""" + state = get_firmware_state() + runtime_dir = resolve_runtime_dir() + projects_dir = resolve_projects_dir() + return SoftwareInfoResponse( + model=get_scanner_model(), + firmware_version=__version__, + last_shutdown_was_unclean=state.get("last_shutdown_was_unclean", False), + runtime_dir=str(runtime_dir), + runtime_disk=_probe_disk_usage(runtime_dir), + projects_disk=_probe_disk_usage(projects_dir), + uptime_seconds=_read_uptime_seconds(), + ) + + +def _probe_disk_usage(path) -> Optional[DiskUsage]: + try: + usage = disk_usage(path) + except (OSError, FileNotFoundError): + return None + return DiskUsage(total=usage.total, used=usage.total - usage.free, free=usage.free) + + +def _read_uptime_seconds() -> Optional[float]: + try: + with open("/proc/uptime", "r", encoding="ascii") as handle: + return float(handle.read().split()[0]) + except (OSError, ValueError, IndexError): + return None + + + +# ------------------------- +# Log utilities and endpoints +# ------------------------- + +def _read_last_lines(file_path: str, max_lines: int) -> str: + """Return the last max_lines from file as a single string. + + Args: + file_path: Path to the file to read. + max_lines: Maximum number of lines to return. + + Returns: + The tail content joined by newlines. + """ + if max_lines <= 0: + return "" + + lines = deque(maxlen=max_lines) + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + lines.append(line.rstrip("\n")) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Log file not found") + return "\n".join(lines) + ("\n" if lines else "") + + +async def _follow_file(file_path: str, poll_interval: float = 1) -> AsyncGenerator[bytes, None]: + """Async generator that tails a file and yields new lines as bytes. + + Args: + file_path: Path to the file to follow. + poll_interval: Sleep interval between checks for new data. + + Yields: + Bytes chunks representing new lines appended to the file. + """ + f = None + last_inode = None + try: + while True: + # Open file if not open yet (or after rotation) + if f is None: + try: + f = open(file_path, "r", encoding="utf-8", errors="ignore") + f.seek(0, os.SEEK_END) + last_inode = os.fstat(f.fileno()).st_ino + except FileNotFoundError: + # File might not exist yet or just rotated; retry shortly + await asyncio.sleep(poll_interval) + continue + + line = f.readline() + if line: + yield line.encode("utf-8", errors="ignore") + continue + + # No new line yet: flush buffered handlers to force write-through + try: + flush_memory_handlers() + except Exception: + # Non-fatal; keep streaming + pass + + # Detect rotation by inode change or missing file + try: + current_inode = os.stat(file_path).st_ino + if current_inode != last_inode: + try: + f.close() + finally: + f = None + continue + except FileNotFoundError: + try: + f.close() + finally: + f = None + await asyncio.sleep(poll_interval) + continue + + await asyncio.sleep(poll_interval) + except asyncio.CancelledError: + if f is not None: + try: + f.close() + except Exception: + pass + return + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Log file not found") + + +@router.get("/logs/tail") +async def tail_logs(format: str = "text", lines: int = 200, follow: bool = False, poll_interval: float = 1): + """Show or follow current logs. + + When follow=false (default), returns the last N lines of the selected log. + When follow=true (text mode only!), streams new lines as they are written (like `tail -f`). + + Args: + format: "text" for openscan_firmware.log, "json" for openscan_detailed_log.json. + lines: Number of last lines to return initially. + follow: If true, stream appended log lines in text mode. + poll_interval: Poll interval (seconds) when following in text mode. + + Returns: + A response with the requested log content. + """ + flush_memory_handlers() # Ensure buffered records are flushed to disk + + if format.lower() == "json": + log_file = os.path.join(DEFAULT_LOGS_PATH, "openscan_detailed_log.json") + media_type = "application/json" + else: + log_file = os.path.join(DEFAULT_LOGS_PATH, "openscan_firmware.log") + media_type = "text/plain" + + if not os.path.exists(log_file): + raise HTTPException(status_code=404, detail="Log file not found") + + if follow and format.lower() == "text": + # Send last N lines first, then follow new lines + async def stream() -> AsyncGenerator[bytes, None]: + head = _read_last_lines(log_file, lines).encode("utf-8") + if head: + yield head + async for chunk in _follow_file(log_file, poll_interval=poll_interval): + yield chunk + headers = { + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "X-Accel-Buffering": "no", # disable nginx buffering if present + "Connection": "keep-alive", + } + return StreamingResponse(stream(), media_type=media_type, headers=headers) + + # One-shot tail of last N lines + content = _read_last_lines(log_file, lines) + return StreamingResponse(iter([content.encode("utf-8")]), media_type=media_type) + + +@router.get("/logs/archive") +async def download_logs_archive(): + """Create and download a ZIP archive containing all log files. + + The archive includes rotated files for both text and JSON logs, using + deflate compression for reasonable size to share e.g. via email. + + Returns: + FileResponse serving the generated ZIP. The temp file is deleted after send. + """ + flush_memory_handlers() # Flush buffered logs before archiving + + patterns = [ + os.path.join(DEFAULT_LOGS_PATH, "openscan_firmware.log*"), + os.path.join(DEFAULT_LOGS_PATH, "openscan_detailed_log.json*"), + ] + files = [] + for pat in patterns: + files.extend(glob.glob(pat)) + files = [f for f in files if os.path.isfile(f)] + + if not files: + raise HTTPException(status_code=404, detail="No log files found to archive") + + # Create a temporary zip file and return it; delete after response is sent + tmp = NamedTemporaryFile(delete=False, suffix=".zip") + tmp_path = tmp.name + tmp.close() + + # Use maximum compression level for smaller email-friendly files + compression = zipfile.ZIP_DEFLATED + compresslevel = 9 # Python 3.7+ supports compresslevel for ZipFile + with zipfile.ZipFile(tmp_path, mode="w", compression=compression, compresslevel=compresslevel) as zf: + for fpath in files: + arcname = os.path.basename(fpath) + zf.write(fpath, arcname=arcname) + + filename = f"openscan_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip" + + def _cleanup(path: str) -> None: + try: + os.remove(path) + except OSError: + pass + + return FileResponse( + tmp_path, + media_type="application/zip", + filename=filename, + background=BackgroundTask(_cleanup, tmp_path), + ) diff --git a/openscan_firmware/routers/v0_9/projects.py b/openscan_firmware/routers/v0_9/projects.py new file mode 100644 index 0000000..40fd9cf --- /dev/null +++ b/openscan_firmware/routers/v0_9/projects.py @@ -0,0 +1,654 @@ +from fastapi import APIRouter, HTTPException, Query +from fastapi.encoders import jsonable_encoder +from fastapi.responses import FileResponse, StreamingResponse +from pydantic import BaseModel +import pathlib +from typing import Optional, List, Any +import asyncio +import os +import json +import mimetypes +from datetime import datetime +import logging + + +from openscan_firmware.controllers.hardware.cameras.camera import get_all_camera_controllers, get_camera_controller +from openscan_firmware.controllers.services import projects, cloud +import openscan_firmware.controllers.services.scans as scans #import start_scan, cancel_scan, pause_scan, resume_scan +from openscan_firmware.models.project import Project +from openscan_firmware.config.scan import ScanSetting +from openscan_firmware.models.scan import Scan +from openscan_firmware.models.task import Task, TaskStatus + +from openscan_firmware.controllers.services.projects import get_project_manager +from openscan_firmware.controllers.services.tasks.task_manager import task_manager, get_task_manager + +router = APIRouter( + prefix="/projects", + tags=["projects"], + responses={404: {"description": "Not found"}}, +) + +logger = logging.getLogger(__name__) + +class DeleteResponse(BaseModel): + success: bool + message: str + deleted: list[str] + + +class PhotoResponse(BaseModel): + project_name: str + scan_index: int + filename: str + content_type: str + size_bytes: int + metadata: Optional[dict[str, Any]] = None + photo_data: bytes + + +@router.get("/", response_model=dict[str, Project]) +async def get_projects(): + """Get all projects with serialized data + + Returns: + dict[str, Project]: A dictionary of project name to a project object + """ + project_manager = get_project_manager() + projects_dict = project_manager.get_all_projects() + return projects_dict + +@router.get("/{project_name}", response_model=Project) +async def get_project(project_name: str): + """Get a project + + Args: + project_name: The name of the project to get + + Returns: + Project: The project object if found, None if not + """ + project_manager = get_project_manager() + project = project_manager.get_project_by_name(project_name) + if not project: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + return project + + +@router.get("/{project_name}/thumbnail") +async def get_project_thumbnail(project_name: str): + project_manager = get_project_manager() + project = project_manager.get_project_by_name(project_name) + if not project: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + + thumbnail_path = os.path.join(project.path, "thumbnail.jpg") + if not os.path.exists(thumbnail_path): + raise HTTPException(status_code=404, detail="thumbnail.jpg not found") + + return FileResponse(thumbnail_path, media_type="image/jpeg", filename="thumbnail.jpg") + + +@router.post("/{project_name}", response_model=Project) +async def new_project(project_name: str, project_description: Optional[str] = ""): + """Create a new project + + Args: + project_name: The name of the project to create + project_description: Optional description for the project + + Returns: + Project: The newly created project if successful, None if not + """ + try: + project_manager = get_project_manager() + return project_manager.add_project(project_name, project_description) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/{project_name}/scan", response_model=Task) +async def add_scan_with_description(project_name: str, + camera_name: str, + scan_settings: ScanSetting, + scan_description: Optional[str] = "") -> Task: + """Add a new scan to a project and return the created Task + + Args: + project_name: The name of the project to add the scan to + camera_name: The name of the camera to use for the scan + scan_settings: The settings for the scan + scan_description: Optional description for the scan + + Returns: + Task: The Task representing the started scan + """ + camera_controller = get_camera_controller(camera_name) + project_manager = get_project_manager() + + try: + scan = project_manager.add_scan(project_name, camera_controller, scan_settings, scan_description) + task = await scans.start_scan(project_manager, scan, camera_controller) + return task + + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to start scan: {e}") + + + +# Cloud uploads -------------------------------------------------------------- + + +@router.post("/{project_name}/upload", response_model=Task) +async def upload_project_to_cloud(project_name: str, token_override: Optional[str] = None) -> Task: + """Schedule an asynchronous cloud upload for a project. + + Args: + project_name: The name of the project + token_override: Optional token override + + Returns: + Task: The TaskManager model describing the scheduled upload + """ + try: + task = await cloud.upload_project(project_name, token=token_override) + except cloud.CloudServiceError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return task + + +@router.delete("/{project_name}/{scan_index}/photos", response_model=DeleteResponse) +async def delete_photos(project_name: str, scan_index: int, photo_filenames: list[str]): + """Delete photos from a scan in a project + + Args: + project_name: The name of the project + scan_index: The index of the scan + photo_filenames: A list of photo filenames to delete + + Returns: + True if the photos were deleted successfully, False otherwise + """ + project_manager = get_project_manager() + try: + scan = project_manager.get_scan_by_index(project_name, scan_index) + project_manager.delete_photos(scan, photo_filenames) + return DeleteResponse( + success=True, + message="Photos deleted successfully", + deleted=photo_filenames + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{project_name}", response_model=DeleteResponse) +async def delete_project(project_name: str): + """Delete a project + + Args: + project_name: The name of the project to delete + + Returns: + DeleteResponse: A response object containing the result of the deletion + """ + project_manager = get_project_manager() + project = project_manager.get_project_by_name(project_name) + if not project: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + try: + project_manager.delete_project(project) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + return DeleteResponse( + success=True, + message="Project deleted successfully", + deleted=[project_name] + ) + + +@router.get("/{project_name}/{scan_index:int}/photo", response_model=PhotoResponse) +async def get_scan_photo( + project_name: str, + scan_index: int, + filename: str = Query(..., description="Photo filename including extension, e.g. scan01_001.jpg"), + file_only: bool = Query(False, description="Return only the raw file instead of JSON payload"), +): + """Fetch a stored scan photo either as JSON payload or direct file download.""" + project_manager = get_project_manager() + try: + scan, photo_path, metadata = project_manager.get_photo_file(project_name, scan_index, filename) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + content_type, _ = mimetypes.guess_type(photo_path) + media_type = content_type or "application/octet-stream" + + if file_only: + return FileResponse(photo_path, media_type=media_type, filename=filename) + + def _read_file_bytes(path: str) -> bytes: + with open(path, "rb") as handle: + return handle.read() + + photo_bytes = await asyncio.to_thread(_read_file_bytes, photo_path) + + return PhotoResponse( + project_name=scan.project_name, + scan_index=scan.index, + filename=filename, + content_type=media_type, + size_bytes=len(photo_bytes), + metadata=metadata, + photo_data=photo_bytes, + ) + + +@router.get("/{project_name}/scans/{scan_index:int}/path") +async def get_scan_path(project_name: str, scan_index: int): + project_manager = get_project_manager() + project = project_manager.get_project_by_name(project_name) + if not project: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + + scan = project_manager.get_scan_by_index(project_name, scan_index) + if not scan: + raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") + + scan_dir = os.path.join(project.path, f"scan{scan_index:02d}") + path_file = os.path.join(scan_dir, "path.json") + if not os.path.exists(path_file): + raise HTTPException(status_code=404, detail="path.json not found") + + def _read_json(path: str) -> dict: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + return await asyncio.to_thread(_read_json, path_file) + + +@router.delete("/{project_name}/scans/{scan_index}", response_model=DeleteResponse) +async def delete_scan(project_name: str, scan_index: int): + """Delete a scan from a project + + Args: + project_name: The name of the project + scan_index: The index of the scan to delete + + Returns: + DeleteResponse: Result of the deletion operation + """ + project_manager = get_project_manager() + scan = project_manager.get_scan_by_index(project_name, scan_index) + try: + project_manager.delete_scan(scan) + return DeleteResponse( + success=True, + message="Scan deleted successfully", + deleted=[f"{project_name}:scan{scan_index:02d}"] + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + + +@router.get("/{project_name}/scans/{scan_index:int}/status", response_model=Task) +async def get_scan_status(project_name: str, scan_index: int): + """Get the current task for a scan + + Args: + project_name: The name of the project + scan_index: The index of the scan to get the status of + + Returns: + Task: The task representing the scan execution + """ + try: + project_manager = get_project_manager() + scan = project_manager.get_scan_by_index(project_name, scan_index) + if not scan: + raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") + if not scan.task_id: + raise HTTPException(status_code=404, detail=f"Scan {scan_index} has no associated task") + + task_manager_instance = get_task_manager() + task = task_manager_instance.get_task_info(scan.task_id) + if not task: + raise HTTPException(status_code=404, detail=f"Task {scan.task_id} not found for scan {scan_index}") + + return task + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch("/{project_name}/scans/{scan_index:int}/pause", response_model=Task) +async def pause_scan(project_name: str, scan_index: int) -> Task: + """Pause a running scan and return the updated Task + + Args: + project_name: The name of the project + scan_index: The index of the scan to pause + + Returns: + Task: The updated task state + """ + project_manager = get_project_manager() + scan = project_manager.get_scan_by_index(project_name, scan_index) + if not scan: + raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") + + task = await scans.pause_scan(scan) + if task is None: + raise HTTPException(status_code=409, detail="Scan is not running or cannot be paused.") + + return task + + +@router.patch("/{project_name}/scans/{scan_index:int}/resume", response_model=Task) +async def resume_scan(project_name: str, scan_index: int, camera_name: str) -> Task: + """Resume a paused, cancelled or failed scan and return the resulting Task + + Args: + project_name: The name of the project + scan_index: The index of the scan to resume + camera_name: The name of the camera to use for the scan + + Returns: + Task: The resumed or restarted task + """ + try: + + camera_controller = get_camera_controller(camera_name) + project_manager = get_project_manager() + scan = project_manager.get_scan_by_index(project_name, scan_index) + if not scan: + raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") + + task_manager_instance = get_task_manager() + existing_task = task_manager_instance.get_task_info(scan.task_id) if scan.task_id else None + + if existing_task and existing_task.status == TaskStatus.PAUSED: + task = await scans.resume_scan(scan) + elif not existing_task or existing_task.status in [ + TaskStatus.COMPLETED, + TaskStatus.CANCELLED, + TaskStatus.ERROR, + TaskStatus.INTERRUPTED, + ]: + task = await scans.start_scan( + project_manager, + scan, + camera_controller, + start_from_step=scan.current_step + ) + else: + raise HTTPException(status_code=409, detail=f"Scan cannot be resumed from its current state: {existing_task.status.value}") + + if task is None: + raise HTTPException(status_code=409, detail="Failed to resume scan task.") + + return task + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch("/{project_name}/scans/{scan_index:int}/cancel", response_model=Task) +async def cancel_scan(project_name: str, scan_index: int) -> Task: + """Cancel a running scan and return the resulting Task + + Args: + project_name: The name of the project + scan_index: The index of the scan to cancel + + Returns: + Task: The updated task state + """ + project_manager = get_project_manager() + scan = project_manager.get_scan_by_index(project_name, scan_index) + if not scan: + raise HTTPException(status_code=404, detail=f"Scan {scan_index} not found") + + try: + task = await scans.cancel_scan(scan) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if task is None: + raise HTTPException(status_code=409, detail="Scan is not running or cannot be cancelled.") + + return task + + +def _serialize_project_for_zip(project: Project) -> str: + """Serialize a project to JSON for inclusion in a ZIP file + + Args: + project: Project to serialize + + Returns: + str: JSON string representation of the project + """ + # Use jsonable_encoder to convert the project to a dict + project_dict = jsonable_encoder(project) + + # Convert to JSON string + return json.dumps(project_dict, indent=2) + + +def _add_project_photos_to_zip(zip_stream, project: Project) -> int: + """Add all recorded photo files of a project to a flat zip archive.""" + added = 0 + for scan in sorted(project.scans.values(), key=lambda scan_obj: scan_obj.index): + scan_dir = os.path.join(project.path, f"scan{scan.index:02d}") + for photo_filename in scan.photos: + photo_path = os.path.join(scan_dir, photo_filename) + if not os.path.exists(photo_path): + logger.warning( + "Photo %s missing on disk for project %s scan %s", + photo_filename, + project.name, + scan.index, + ) + continue + zip_stream.add_path(photo_path, arcname=photo_filename) + added += 1 + return added + + +@router.get("/{project_name}/zip") +async def download_project( + project_name: str, + photos_only: bool = Query( + False, + description="If true, stream only photo files without metadata or directory structure.", + ), +): + """Download a project as a ZIP file stream + + This endpoint streams the entire project directory as a ZIP file, + including all scans, photos, and metadata. When ``photos_only`` is true, + only the recorded photo files are included without metadata or subfolders. + + Args: + project_name: Name of the project to download + + Returns: + StreamingResponse: ZIP file stream + """ + try: + # Import zipstream-ng + from zipstream import ZipStream + project_manager = get_project_manager() + # Get project + project = project_manager.get_project_by_name(project_name) + if not project: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + + if photos_only: + zs = ZipStream(sized=True) + zs.comment = f"OpenScan3 Project Photos: {project_name}" + added_files = _add_project_photos_to_zip(zs, project) + if added_files == 0: + raise HTTPException(status_code=404, detail="No photos available for this project") + filename = f"{project_name}_photos.zip" + else: + # Create ZipStream from project path + zs = ZipStream.from_path(project.path) + + # Add project metadata + zs.add(_serialize_project_for_zip(project), "project_metadata.json") + filename = f"{project_name}.zip" + + # Return streaming response + headers = { + "Content-Disposition": f"attachment; filename={filename}", + } + if getattr(zs, "last_modified", None): + headers["Last-Modified"] = str(zs.last_modified) + + response = StreamingResponse( + zs, + media_type="application/zip", + headers=headers, + ) + + return response + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{project_name}/model/zip") +async def download_project_model(project_name: str): + """Download the reconstructed model directory of a project as a ZIP file.""" + + try: + from zipstream import ZipStream + except ModuleNotFoundError as exc: # pragma: no cover - dependency issue + raise HTTPException(status_code=500, detail="zipstream is not installed") from exc + + project_manager = get_project_manager() + project = project_manager.get_project_by_name(project_name) + if not project: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + + model_dir = pathlib.Path(project.path) / "model" + if not model_dir.exists() or not model_dir.is_dir(): + raise HTTPException( + status_code=404, + detail=f"No reconstructed model present for project {project_name}", + ) + + zs = ZipStream(sized=True) + zs.comment = f"OpenScan3 Project Model: {project_name}" + zs.add_path(str(model_dir), "model") + zs.add(_serialize_project_for_zip(project), "project_metadata.json") + + headers = { + "Content-Disposition": f"attachment; filename={project_name}_model.zip", + } + if getattr(zs, "last_modified", None): + headers["Last-Modified"] = str(zs.last_modified) + + return StreamingResponse( + zs, + media_type="application/zip", + headers=headers, + ) + + +@router.get("/{project_name}/scans/zip") +async def download_scans(project_name: str, scan_indices: List[int] = Query(None)): + """Download selected scans from a project as a ZIP file stream + + This endpoint streams selected scans from a project as a ZIP file. + If no scan indices are provided, all scans will be included. + + Args: + project_name: Name of the project + scan_indices: List of scan indices to include in the ZIP file + + Returns: + StreamingResponse: ZIP file stream + """ + try: + from zipstream import ZipStream + project_manager = get_project_manager() + project = project_manager.get_project_by_name(project_name) + if not project: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + + zs = ZipStream(sized=True) + zs.comment = f"OpenScan3 Project: {project_name} - Generated on {datetime.now().isoformat()}" + + # Build filename based on what's being downloaded + if scan_indices: + if len(scan_indices) == 1: + filename = f"{project_name}_scan{scan_indices[0]:02d}.zip" + else: + scan_nums = "_".join(str(i) for i in sorted(scan_indices)) + filename = f"{project_name}_scans_{scan_nums}.zip" + + for scan_index in scan_indices: + try: + scan = project_manager.get_scan_by_index(project_name, scan_index) + if not scan: + logger.error(f"Scan with index {scan_index} not found") + continue + scan_dir = os.path.join(project.path, f"scan{scan_index:02d}") + if os.path.exists(scan_dir): + zs.add_path(scan_dir, f"scan{scan_index:02d}") + except Exception as e: + logger.error(f"Failed to add scan {scan_index} to zip: {e}") + continue + else: + filename = f"{project_name}_all_scans.zip" + for scan_id, scan in project.scans.items(): + scan_dir = os.path.join(project.path, f"scan_{scan.index}") + if os.path.exists(scan_dir): + zs.add_path(scan_dir, f"scan_{scan.index}") + + zs.add(_serialize_project_for_zip(project), "project_metadata.json") + + headers = { + "Content-Disposition": f"attachment; filename={filename}", + } + if getattr(zs, "last_modified", None): + headers["Last-Modified"] = str(zs.last_modified) + + response = StreamingResponse( + zs, + media_type="application/zip", + headers=headers, + ) + return response + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"") + except Exception as e: + print(e) + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/{project_name}/scans/{scan_index:int}", response_model=Scan) +async def get_scan(project_name: str, scan_index: int): + """Get Scan by project and index + + Args: + project_name: The name of the project + scan_index: The index of the scan + + Returns: + Scan: The scan object + """ + try: + project_manager = get_project_manager() + return project_manager.get_scan_by_index(project_name, scan_index) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/openscan_firmware/routers/v0_9/settings_utils.py b/openscan_firmware/routers/v0_9/settings_utils.py new file mode 100644 index 0000000..9d8f10f --- /dev/null +++ b/openscan_firmware/routers/v0_9/settings_utils.py @@ -0,0 +1,80 @@ +from typing import Any, Callable, Dict, Type, TypeVar +from fastapi import APIRouter, Body, HTTPException +from pydantic import BaseModel + +T = TypeVar('T', bound=BaseModel) + + +def create_settings_endpoints( + router: APIRouter, + resource_name: str, + get_controller: Callable[[str], Any], + settings_model: Type[T] +) -> Dict[str, Callable[..., Any]]: + """ + Create standardized settings endpoints for a resource. + + Args: + router: The FastAPI router to add endpoints to + resource_name: Name of the resource (e.g., 'camera', 'motor') + get_controller: Function to get the controller by name + settings_model: Pydantic model for the settings + """ + + path = "/{name}/settings" + + @router.get( + path, + response_model=settings_model, + name=f"get_{resource_name}_settings", + ) + async def get_settings(name: str) -> T: + """Get settings for a specific resource""" + controller = get_controller(name) + return controller.settings.model + + @router.put( + path, + response_model=settings_model, + name=f"replace_{resource_name}_settings", + ) + async def replace_settings(name: str, settings: settings_model) -> T: + """Replace all settings for a specific resource""" + controller = get_controller(name) + try: + controller.settings.replace(settings) + return controller.settings.model + except Exception as e: + raise HTTPException(status_code=422, detail=str(e)) + + + @router.patch( + path, + response_model=settings_model, + name=f"update_{resource_name}_settings", + ) + async def update_settings( + name: str, + settings: Dict[str, Any] = Body(..., examples=[{"some_setting": 123}]) + ) -> T: + """Update one or more specific settings for a resource + + Args: + name: The name of the resource to update settings for + settings: A dictionary of settings to update + + Returns: + The updated settings for the resource + """ + controller = get_controller(name) + try: + controller.settings.update(**settings) + return controller.settings.model + except Exception as e: + raise HTTPException(status_code=422, detail=str(e)) + + return { + "get_settings": get_settings, + "replace_settings": replace_settings, + "update_settings": update_settings + } \ No newline at end of file diff --git a/openscan_firmware/routers/v0_9/tasks.py b/openscan_firmware/routers/v0_9/tasks.py new file mode 100644 index 0000000..81f181e --- /dev/null +++ b/openscan_firmware/routers/v0_9/tasks.py @@ -0,0 +1,171 @@ +from typing import List, Any, Dict + +from fastapi import APIRouter, HTTPException, Response, status, Body + +from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager +from openscan_firmware.models.task import Task, TaskStatus + + +router = APIRouter( + prefix="/tasks", + tags=["tasks"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/", response_model=List[Task]) +async def get_all_tasks(): + """ + Retrieve a list of all tasks known to the task manager. + + Returns: + List[Task]: A list of all tasks known to the task manager. + """ + task_manager = get_task_manager() + return task_manager.get_all_tasks_info() + + +@router.get("/{task_id}", response_model=Task) +async def get_task_status(task_id: str): + """ + Retrieve the status and details of a specific task. + + Args: + task_id: The ID of the task to retrieve. + + Returns: + Task: The task object with its status and details. + """ + task_manager = get_task_manager() + task = task_manager.get_task_info(task_id) + if not task: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") + return task + + +@router.delete("/{task_id}", response_model=Task) +async def cancel_task(task_id: str): + """ + Request cancellation of a running task. + + Args: + task_id: The ID of the task to cancel. + + Returns: + Task: The task object with its status and details. + """ + task_manager = get_task_manager() + task = await task_manager.cancel_task(task_id) + if not task: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") + return task + + +@router.delete( + "/{task_id}/cleanup", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a terminal task record", +) +async def delete_task(task_id: str) -> Response: + """Remove a terminal task from persistence and memory.""" + task_manager = get_task_manager() + try: + await task_manager.delete_task(task_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post("/{task_id}/pause", response_model=Task, summary="Pause a Task") +async def pause_task(task_id: str): + """ + Pauses a running task. + + Args: + task_id: The ID of the task to pause. + + Returns: + Task: The task object with its status and details. + """ + task_manager = get_task_manager() + task = await task_manager.pause_task(task_id) + if not task: + raise HTTPException(status_code=404, detail=f"Task {task_id} not found or cannot be paused.") + if task.status not in [TaskStatus.PAUSED, TaskStatus.RUNNING]: + pass + return task + + +@router.post("/{task_id}/resume", response_model=Task, summary="Resume a Task") +async def resume_task(task_id: str): + """ + Resumes a paused task. + + Args: + task_id: The ID of the task to resume. + + Returns: + Task: The task object with its status and details. + """ + task_manager = get_task_manager() + task = await task_manager.resume_task(task_id) + if not task: + raise HTTPException(status_code=404, detail=f"Task {task_id} not found or cannot be resumed.") + if task.status not in [TaskStatus.RUNNING, TaskStatus.PAUSED]: + pass + return task + + +@router.post("/{task_name}", response_model=Task, status_code=status.HTTP_202_ACCEPTED) +async def create_task( + task_name: str, + args: List[Any] = Body(default=[], description="Positional arguments for the task"), + kwargs: Dict[str, Any] = Body(default={}, description="Keyword arguments for the task") +): + """ + Create and start a new background task with optional parameters. + + The request body accepts: + - **args**: List of positional arguments (e.g., `["project_name", 0]`) + - **kwargs**: Dictionary of keyword arguments (e.g., `{"num_batches": 5}`) + + Args: + task_name: The name of the task to create, as registered in the TaskManager. + args: Positional arguments to pass to the task's run method. + kwargs: Keyword arguments to pass to the task's run method. + + Returns: + The created task object. + + Examples: + ```json + // No parameters + {} + + // With positional args + { + "args": ["MyProject", 0] + } + + // With keyword args + { + "kwargs": {"num_calibration_batches": 5} + } + + // With both + { + "args": ["MyProject", 0], + "kwargs": {"num_calibration_batches": 5} + } + ``` + """ + try: + task_manager = get_task_manager() + task = await task_manager.create_and_run_task(task_name, *args, **kwargs) + return task + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) \ No newline at end of file diff --git a/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index 28bf910..78054b0 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "OpenScan3 API v0.8", - "version": "0.8" + "title": "OpenScan3 API v0.9", + "version": "0.9" }, "paths": { "/cameras/": { @@ -164,6 +164,84 @@ "type": "string", "title": "Camera Name" } + }, + { + "name": "image_format", + "in": "query", + "required": false, + "schema": { + "enum": [ + "jpeg", + "dng", + "rgb_array", + "yuv_array" + ], + "type": "string", + "default": "jpeg", + "title": "Image Format" + } + }, + { + "name": "with_metadata", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "With Metadata" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/cameras/{camera_name}/photo/payload/{payload_id}": { + "get": { + "tags": [ + "cameras" + ], + "summary": "Get Photo Payload", + "operationId": "get_photo_payload", + "parameters": [ + { + "name": "camera_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Camera Name" + } + }, + { + "name": "payload_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Payload Id" + } } ], "responses": { @@ -1338,6 +1416,130 @@ } } }, + "/firmware/settings": { + "get": { + "tags": [ + "firmware" + ], + "summary": "Get Settings", + "description": "Return persisted firmware settings.", + "operationId": "get_settings", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + } + }, + "404": { + "description": "Not found" + } + } + }, + "put": { + "tags": [ + "firmware" + ], + "summary": "Replace Settings", + "description": "Replace the entire firmware settings payload.", + "operationId": "replace_settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/firmware/settings/{key}": { + "patch": { + "tags": [ + "firmware" + ], + "summary": "Update Setting", + "description": "Update a single firmware settings key.", + "operationId": "update_setting", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettingPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/projects/": { "get": { "tags": [ @@ -2742,52 +2944,30 @@ } } }, - "/device/configurations/": { - "post": { + "/device/configurations/current": { + "get": { "tags": [ "device" ], - "summary": "Add Config Json", - "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "add_config_json", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" - } - } - }, - "required": true - }, + "summary": "Get Current Config", + "description": "Return the currently active device configuration file.", + "operationId": "get_current_config", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "$ref": "#/components/schemas/DeviceConfigResponse" } } } }, "404": { "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } } } - } - }, - "/device/configurations/current": { + }, "put": { "tags": [ "device" @@ -2855,23 +3035,22 @@ } } }, - "/device/configurations/current/initialize": { - "post": { + "/device/configurations/{filename}": { + "get": { "tags": [ "device" ], - "summary": "Reinitialize Hardware", - "description": "Reinitialize hardware components\n\nThis can be used in case of a hardware failure or to reload the hardware components.\n\nArgs:\n detect_cameras: Whether to detect cameras\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "reinitialize_hardware", + "summary": "Get Config File", + "description": "Return a specific configuration JSON file by filename.", + "operationId": "get_config_file", "parameters": [ { - "name": "detect_cameras", - "in": "query", - "required": false, + "name": "filename", + "in": "path", + "required": true, "schema": { - "type": "boolean", - "default": false, - "title": "Detect Cameras" + "type": "string", + "title": "Filename" } } ], @@ -2881,7 +3060,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "$ref": "#/components/schemas/DeviceConfigResponse" } } } @@ -2902,34 +3081,31 @@ } } }, - "/device/reboot": { + "/device/configurations/": { "post": { "tags": [ "device" ], - "summary": "Reboot", - "description": "Reboot system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before rebooting", - "operationId": "reboot", - "parameters": [ - { - "name": "save_config", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Save Config" + "summary": "Add Config Json", + "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "add_config_json", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" + } } - } - ], + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "boolean", - "title": "Response Reboot Device Reboot Post" + "$ref": "#/components/schemas/DeviceControlResponse" } } } @@ -2950,17 +3126,112 @@ } } }, - "/device/shutdown": { + "/device/configurations/current/initialize": { "post": { "tags": [ "device" ], - "summary": "Shutdown", - "description": "Shutdown system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before shutting down", - "operationId": "shutdown", + "summary": "Reinitialize Hardware", + "description": "Reinitialize hardware components\n\nThis can be used in case of a hardware failure or to reload the hardware components.\n\nArgs:\n detect_cameras: Whether to detect cameras\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "reinitialize_hardware", "parameters": [ { - "name": "save_config", + "name": "detect_cameras", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Detect Cameras" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceControlResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/device/reboot": { + "post": { + "tags": [ + "device" + ], + "summary": "Reboot", + "description": "Reboot system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before rebooting", + "operationId": "reboot", + "parameters": [ + { + "name": "save_config", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Save Config" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Reboot Device Reboot Post" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/device/shutdown": { + "post": { + "tags": [ + "device" + ], + "summary": "Shutdown", + "description": "Shutdown system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before shutting down", + "operationId": "shutdown", + "parameters": [ + { + "name": "save_config", "in": "query", "required": false, "schema": { @@ -3375,6 +3646,55 @@ } } }, + "/develop/camera-report": { + "get": { + "tags": [ + "develop" + ], + "summary": "Get Camera Report", + "description": "Run the camera diagnostics script and return a bundled report.", + "operationId": "get_camera_report", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "enum": [ + "json", + "text" + ], + "type": "string", + "default": "json", + "title": "Format" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/develop/crop_image": { "get": { "tags": [ @@ -3487,6 +3807,54 @@ } } }, + "/develop/qr-scan": { + "post": { + "tags": [ + "develop" + ], + "summary": "Start Qr Scan", + "description": "Start a background task that scans for WiFi QR codes via the camera.\n\nThe task runs indefinitely, capturing frames and looking for QR codes.\nWhen it finds an Android/iOS WiFi share QR code it connects to the\nnetwork via nmcli and completes. Cancel the task to stop scanning.\n\nArgs:\n camera_name: Name of the camera controller to use for captures.\n\nReturns:\n Task: The created task model (poll via /tasks/{id} for progress).", + "operationId": "start_qr_scan", + "parameters": [ + { + "name": "camera_name", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Name of the camera controller to use", + "title": "Camera Name" + }, + "description": "Name of the camera controller to use" + } + ], + "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" + } + } + } + } + } + } + }, "/develop/{method}": { "get": { "tags": [ @@ -4126,7 +4494,7 @@ "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { - "$ref": "#/components/schemas/ScannerDevice" + "$ref": "#/components/schemas/ScannerDeviceConfig-Input" }, "filename": { "$ref": "#/components/schemas/DeviceConfigRequest" @@ -4172,32 +4540,6 @@ ], "title": "Body_move_motor_by_degree_motors__motor_name__angle_patch" }, - "Camera": { - "properties": { - "type": { - "$ref": "#/components/schemas/CameraType" - }, - "name": { - "type": "string", - "title": "Name" - }, - "path": { - "type": "string", - "title": "Path" - }, - "settings": { - "$ref": "#/components/schemas/CameraSettings" - } - }, - "type": "object", - "required": [ - "type", - "name", - "path", - "settings" - ], - "title": "Camera" - }, "CameraSettings": { "properties": { "shutter": { @@ -4748,6 +5090,33 @@ ], "title": "DeviceConfigRequest" }, + "DeviceConfigResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "path": { + "type": "string", + "title": "Path" + }, + "config": { + "$ref": "#/components/schemas/ScannerDeviceConfig-Output" + } + }, + "type": "object", + "required": [ + "status", + "filename", + "path", + "config" + ], + "title": "DeviceConfigResponse" + }, "DeviceControlResponse": { "properties": { "success": { @@ -4777,11 +5146,25 @@ "title": "Name" }, "model": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Model" }, "shield": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Shield" }, "cameras": { @@ -4823,8 +5206,6 @@ "type": "object", "required": [ "name", - "model", - "shield", "cameras", "motors", "lights", @@ -4862,36 +5243,12 @@ "title": "DiskUsage", "description": "Filesystem usage snapshot for a directory." }, - "Endstop": { + "EndstopConfig": { "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/EndstopConfig" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Endstop" - }, - "EndstopConfig": { - "properties": { - "pin": { - "type": "integer", - "title": "Pin", - "description": "GPIO pin number used for the endstop" + "pin": { + "type": "integer", + "title": "Pin", + "description": "GPIO pin number used for the endstop" }, "angular_position": { "type": "number", @@ -4952,6 +5309,98 @@ "title": "EndstopConfig", "description": "Configuration for a motor endstop.\n\nArgs:\n pin (int): GPIO pin number used for the endstop.\n angular_position (float): Angle at which the endstop is triggered (degrees).\n motor_name (str): Name of the assigned motor.\n pull_up (Optional[bool]): Whether to use a pull-up resistor (default: True).\n bounce_time (Optional[float]): Debounce time for the button in seconds (default: 0.005)." }, + "EndstopStatusResponse": { + "properties": { + "assigned_motor": { + "type": "string", + "title": "Assigned Motor" + }, + "position": { + "type": "number", + "title": "Position" + }, + "pin": { + "type": "integer", + "title": "Pin" + }, + "is_pressed": { + "type": "boolean", + "title": "Is Pressed" + }, + "pull_up": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pull Up" + }, + "active_high": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active High" + }, + "bounce_time": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Bounce Time" + } + }, + "type": "object", + "required": [ + "assigned_motor", + "position", + "pin", + "is_pressed" + ], + "title": "EndstopStatusResponse" + }, + "FirmwareSettingPatchRequest": { + "properties": { + "value": { + "title": "Value" + } + }, + "type": "object", + "required": [ + "value" + ], + "title": "FirmwareSettingPatchRequest" + }, + "FirmwareSettings": { + "properties": { + "qr_wifi_scan_enabled": { + "type": "boolean", + "title": "Qr Wifi Scan Enabled", + "description": "Automatically scan for WiFi QR codes on startup when no WiFi or Ethernet connection is active.", + "default": true + }, + "enable_cloud": { + "type": "boolean", + "title": "Enable Cloud", + "description": "Enable integrations with OpenScan Cloud services.", + "default": false + } + }, + "type": "object", + "title": "FirmwareSettings", + "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances." + }, "HTTPValidationError": { "properties": { "detail": { @@ -4965,23 +5414,6 @@ "type": "object", "title": "HTTPValidationError" }, - "Light": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "$ref": "#/components/schemas/LightConfig" - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Light" - }, "LightConfig": { "properties": { "pin": { @@ -5043,35 +5475,6 @@ ], "title": "LightStatusResponse" }, - "Motor": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/MotorConfig" - }, - { - "type": "null" - } - ] - }, - "angle": { - "type": "number", - "title": "Angle", - "default": 90.0 - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Motor" - }, "MotorConfig": { "properties": { "direction_pin": { @@ -5189,14 +5592,12 @@ "endstop": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/EndstopStatusResponse" }, { "type": "null" } - ], - "title": "Endstop" + ] } }, "type": "object", @@ -5218,6 +5619,46 @@ ], "title": "PathMethod" }, + "PersistedCameraConfig": { + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/CameraType" + }, + { + "type": "string" + } + ], + "title": "Type" + }, + "path": { + "type": "string", + "title": "Path" + }, + "settings": { + "$ref": "#/components/schemas/CameraSettings" + } + }, + "type": "object", + "required": [ + "type", + "path" + ], + "title": "PersistedCameraConfig" + }, + "PersistedEndstopConfig": { + "properties": { + "settings": { + "$ref": "#/components/schemas/EndstopConfig" + } + }, + "type": "object", + "required": [ + "settings" + ], + "title": "PersistedEndstopConfig" + }, "PhotoResponse": { "properties": { "project_name": { @@ -5590,7 +6031,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDevice": { + "ScannerDeviceConfig-Input": { "properties": { "name": { "type": "string", @@ -5599,40 +6040,42 @@ "model": { "anyOf": [ { - "$ref": "#/components/schemas/ScannerModel" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Model" }, "shield": { "anyOf": [ { - "$ref": "#/components/schemas/ScannerShield" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Shield" }, "cameras": { "additionalProperties": { - "$ref": "#/components/schemas/Camera" + "$ref": "#/components/schemas/PersistedCameraConfig" }, "type": "object", "title": "Cameras" }, "motors": { "additionalProperties": { - "$ref": "#/components/schemas/Motor" + "$ref": "#/components/schemas/MotorConfig" }, "type": "object", "title": "Motors" }, "lights": { "additionalProperties": { - "$ref": "#/components/schemas/Light" + "$ref": "#/components/schemas/LightConfig" }, "type": "object", "title": "Lights" @@ -5641,7 +6084,7 @@ "anyOf": [ { "additionalProperties": { - "$ref": "#/components/schemas/Endstop" + "$ref": "#/components/schemas/PersistedEndstopConfig" }, "type": "object" }, @@ -5657,43 +6100,136 @@ "default": 0.0 }, "startup_mode": { - "$ref": "#/components/schemas/ScannerStartupMode", + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerStartupMode" + }, + { + "type": "string" + } + ], + "title": "Startup Mode", "default": "startup_enabled" }, "calibrate_mode": { - "$ref": "#/components/schemas/ScannerCalibrateMode", + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerCalibrateMode" + }, + { + "type": "string" + } + ], + "title": "Calibrate Mode", "default": "calibrate_manual" } }, "type": "object", "required": [ - "name", - "model", - "shield", - "cameras", - "motors", - "lights", - "endstops" - ], - "title": "ScannerDevice" - }, - "ScannerModel": { - "type": "string", - "enum": [ - "classic", - "mini", - "custom" + "name" ], - "title": "ScannerModel" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, - "ScannerShield": { - "type": "string", - "enum": [ - "greenshield", - "blackshield", - "custom" + "ScannerDeviceConfig-Output": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "shield": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Shield" + }, + "cameras": { + "additionalProperties": { + "$ref": "#/components/schemas/PersistedCameraConfig" + }, + "type": "object", + "title": "Cameras" + }, + "motors": { + "additionalProperties": { + "$ref": "#/components/schemas/MotorConfig" + }, + "type": "object", + "title": "Motors" + }, + "lights": { + "additionalProperties": { + "$ref": "#/components/schemas/LightConfig" + }, + "type": "object", + "title": "Lights" + }, + "endstops": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/PersistedEndstopConfig" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Endstops" + }, + "motors_timeout": { + "type": "number", + "title": "Motors Timeout", + "default": 0.0 + }, + "startup_mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerStartupMode" + }, + { + "type": "string" + } + ], + "title": "Startup Mode", + "default": "startup_enabled" + }, + "calibrate_mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerCalibrateMode" + }, + { + "type": "string" + } + ], + "title": "Calibrate Mode", + "default": "calibrate_manual" + } + }, + "type": "object", + "required": [ + "name" ], - "title": "ScannerShield" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, "ScannerStartupMode": { "type": "string", diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index 6644206..1e1d28f 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -3646,6 +3646,55 @@ } } }, + "/develop/camera-report": { + "get": { + "tags": [ + "develop" + ], + "summary": "Get Camera Report", + "description": "Run the camera diagnostics script and return a bundled report.", + "operationId": "get_camera_report", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "enum": [ + "json", + "text" + ], + "type": "string", + "default": "json", + "title": "Format" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/develop/crop_image": { "get": { "tags": [ diff --git a/scripts/openapi/openapi_v0.6.json b/scripts/openapi/openapi_v0.6.json deleted file mode 100644 index ee57459..0000000 --- a/scripts/openapi/openapi_v0.6.json +++ /dev/null @@ -1,5273 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "OpenScan3 API v0.6", - "version": "0.6" - }, - "paths": { - "/cameras/": { - "get": { - "tags": [ - "cameras" - ], - "summary": "Get Cameras", - "description": "Get all cameras with their current status\n\nReturns:\n dict[str, CameraStatusResponse]: A dictionary of camera name to a camera status object", - "operationId": "get_cameras", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "$ref": "#/components/schemas/CameraStatusResponse" - }, - "type": "object", - "title": "Response Get Cameras Cameras Get" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/cameras/{camera_name}": { - "get": { - "tags": [ - "cameras" - ], - "summary": "Get Camera", - "description": "Get a camera with its current status\n\nArgs:\n camera_name: The name of the camera to get the status of\n\nReturns:\n CameraStatusResponse: A response object containing the status of the camera", - "operationId": "get_camera", - "parameters": [ - { - "name": "camera_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Camera Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CameraStatusResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/cameras/{camera_name}/preview": { - "get": { - "tags": [ - "cameras" - ], - "summary": "Get Preview", - "description": "Get a camera preview stream in lower resolution\n\nNote: The preview is not rotated by orientation_flag and has to be rotated by client.\n\nArgs:\n camera_name: The name of the camera to get the preview stream from\n\nReturns:\n StreamingResponse: A streaming response containing the preview stream", - "operationId": "get_preview", - "parameters": [ - { - "name": "camera_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Camera Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/cameras/{camera_name}/photo": { - "get": { - "tags": [ - "cameras" - ], - "summary": "Get Photo", - "description": "Get a camera photo\n\nArgs:\n camera_name: The name of the camera to get the photo from\n\nReturns:\n Response: A response containing the photo", - "operationId": "get_photo", - "parameters": [ - { - "name": "camera_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Camera Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/cameras/{camera_name}/restart": { - "post": { - "tags": [ - "cameras" - ], - "summary": "Restart Camera", - "description": "Restart a camera\n\nArgs:\n camera_name: The name of the camera to restart\n\nReturns:\n Response: A response containing the status code", - "operationId": "restart_camera", - "parameters": [ - { - "name": "camera_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Camera Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/cameras/{name}/settings": { - "get": { - "tags": [ - "cameras" - ], - "summary": "Get Camera Name Settings", - "description": "Get settings for a specific resource", - "operationId": "get_camera_name_settings", - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CameraSettings" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "put": { - "tags": [ - "cameras" - ], - "summary": "Replace Camera Name Settings", - "description": "Replace all settings for a specific resource", - "operationId": "replace_camera_name_settings", - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Name" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CameraSettings" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CameraSettings" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": [ - "cameras" - ], - "summary": "Update Camera 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_camera_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/CameraSettings" - } - } - } - }, - "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", - "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}/endstop-calibration": { - "put": { - "tags": [ - "motors" - ], - "summary": "Move Motor To Home Position", - "description": "Move motor to home position\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": "move_motor_to_home_position", - "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/{name}/settings": { - "get": { - "tags": [ - "motors" - ], - "summary": "Get Motor Name Settings", - "description": "Get settings for a specific resource", - "operationId": "get_motor_name_settings", - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Name" - } - } - ], - "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" - } - } - } - } - } - }, - "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" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": [ - "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": "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" - ], - "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": "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_off": { - "patch": { - "tags": [ - "lights" - ], - "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": "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}/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" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/lights/{name}/settings": { - "get": { - "tags": [ - "lights" - ], - "summary": "Get Light Name Settings", - "description": "Get settings for a specific resource", - "operationId": "get_light_name_settings", - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LightConfig" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "put": { - "tags": [ - "lights" - ], - "summary": "Replace Light Name Settings", - "description": "Replace all settings for a specific resource", - "operationId": "replace_light_name_settings", - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Name" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LightConfig" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LightConfig" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": [ - "lights" - ], - "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_light_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/LightConfig" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/": { - "get": { - "tags": [ - "projects" - ], - "summary": "Get Projects", - "description": "Get all projects with serialized data\n\nReturns:\n dict[str, Project]: A dictionary of project name to a project object", - "operationId": "get_projects", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "$ref": "#/components/schemas/Project" - }, - "type": "object", - "title": "Response Get Projects Projects Get" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/projects/{project_name}": { - "get": { - "tags": [ - "projects" - ], - "summary": "Get Project", - "description": "Get a project\n\nArgs:\n project_name: The name of the project to get\n\nReturns:\n Project: The project object if found, None if not", - "operationId": "get_project", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "projects" - ], - "summary": "New Project", - "description": "Create a new project\n\nArgs:\n project_name: The name of the project to create\n project_description: Optional description for the project\n\nReturns:\n Project: The newly created project if successful, None if not", - "operationId": "new_project", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "project_description", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": "", - "title": "Project Description" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "projects" - ], - "summary": "Delete Project", - "description": "Delete a project\n\nArgs:\n project_name: The name of the project to delete\n\nReturns:\n DeleteResponse: A response object containing the result of the deletion", - "operationId": "delete_project", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/{project_name}/scan": { - "post": { - "tags": [ - "projects" - ], - "summary": "Add Scan With Description", - "description": "Add a new scan to a project and return the created Task\n\nArgs:\n project_name: The name of the project to add the scan to\n camera_name: The name of the camera to use for the scan\n scan_settings: The settings for the scan\n scan_description: Optional description for the scan\n\nReturns:\n Task: The Task representing the started scan", - "operationId": "add_scan_with_description", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "camera_name", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Camera Name" - } - }, - { - "name": "scan_description", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": "", - "title": "Scan Description" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanSetting" - } - } - } - }, - "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" - } - } - } - } - } - } - }, - "/projects/{project_name}/upload": { - "post": { - "tags": [ - "projects" - ], - "summary": "Upload Project To Cloud", - "description": "Schedule an asynchronous cloud upload for a project.\n\nArgs:\n project_name: The name of the project\n token_override: Optional token override\n\nReturns:\n Task: The TaskManager model describing the scheduled upload", - "operationId": "upload_project_to_cloud", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "token_override", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Token Override" - } - } - ], - "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" - } - } - } - } - } - } - }, - "/projects/{project_name}/download": { - "post": { - "tags": [ - "projects" - ], - "summary": "Download Project From Cloud", - "description": "Schedule an asynchronous cloud download for a project's reconstruction.\n\nArgs:\n project_name: The name of the project\n token_override: Optional token override\n remote_project: Optional explicit remote project name, defaults to the stored cloud name\n\nReturns:\n Task: The TaskManager model describing the scheduled download", - "operationId": "download_project_from_cloud", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "token_override", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Token Override" - } - }, - { - "name": "remote_project", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Remote Project" - } - } - ], - "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" - } - } - } - } - } - } - }, - "/projects/{project_name}/{scan_index}/photos": { - "delete": { - "tags": [ - "projects" - ], - "summary": "Delete Photos", - "description": "Delete photos from a scan in a project\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan\n photo_filenames: A list of photo filenames to delete\n\nReturns:\n True if the photos were deleted successfully, False otherwise", - "operationId": "delete_photos", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Photo Filenames" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}": { - "delete": { - "tags": [ - "projects" - ], - "summary": "Delete Scan", - "description": "Delete a scan from a project\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to delete\n\nReturns:\n DeleteResponse: Result of the deletion operation", - "operationId": "delete_scan", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "get": { - "tags": [ - "projects" - ], - "summary": "Get Scan", - "description": "Get Scan by project and index\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan\n\nReturns:\n Scan: The scan object", - "operationId": "get_scan", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Scan" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}/status": { - "get": { - "tags": [ - "projects" - ], - "summary": "Get Scan Status", - "description": "Get the current task for a scan\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to get the status of\n\nReturns:\n Task: The task representing the scan execution", - "operationId": "get_scan_status", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "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" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}/pause": { - "patch": { - "tags": [ - "projects" - ], - "summary": "Pause Scan", - "description": "Pause a running scan and return the updated Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to pause\n\nReturns:\n Task: The updated task state", - "operationId": "pause_scan", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "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" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}/resume": { - "patch": { - "tags": [ - "projects" - ], - "summary": "Resume Scan", - "description": "Resume a paused, cancelled or failed scan and return the resulting Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to resume\n camera_name: The name of the camera to use for the scan\n\nReturns:\n Task: The resumed or restarted task", - "operationId": "resume_scan", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - }, - { - "name": "camera_name", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Camera Name" - } - } - ], - "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" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}/cancel": { - "patch": { - "tags": [ - "projects" - ], - "summary": "Cancel Scan", - "description": "Cancel a running scan and return the resulting Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to cancel\n\nReturns:\n Task: The updated task state", - "operationId": "cancel_scan", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "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" - } - } - } - } - } - } - }, - "/projects/{project_name}/zip": { - "get": { - "tags": [ - "projects" - ], - "summary": "Download Project", - "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", - "operationId": "download_project", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/zip": { - "get": { - "tags": [ - "projects" - ], - "summary": "Download Scans", - "description": "Download selected scans from a project as a ZIP file stream\n\nThis endpoint streams selected scans from a project as a ZIP file.\nIf no scan indices are provided, all scans will be included.\n\nArgs:\n project_name: Name of the project\n scan_indices: List of scan indices to include in the ZIP file\n\nReturns:\n StreamingResponse: ZIP file stream", - "operationId": "download_scans", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_indices", - "in": "query", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "integer" - }, - "title": "Scan Indices" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/gpio/": { - "get": { - "tags": [ - "gpio" - ], - "summary": "Get Pins", - "description": "Get all initialized GPIO pins\n\nReturns:\n dict[str, list[int]]: A dictionary of initialized output pins and buttons", - "operationId": "get_pins", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "items": { - "type": "integer" - }, - "type": "array" - }, - "type": "object", - "title": "Response Get Pins Gpio Get" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/gpio/{pin_id}": { - "get": { - "tags": [ - "gpio" - ], - "summary": "Get Pin", - "description": "Get output value of a specific GPIO pin\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to get the value of\n\nReturns:\n bool: The output value of the GPIO pin", - "operationId": "get_pin", - "parameters": [ - { - "name": "pin_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Pin Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "boolean", - "title": "Response Get Pin Gpio Pin Id Get" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": [ - "gpio" - ], - "summary": "Set Pin", - "description": "Set GPIO pin output value\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to set the value of\n status: The output value to set for the GPIO pin", - "operationId": "set_pin", - "parameters": [ - { - "name": "pin_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Pin Id" - } - }, - { - "name": "status", - "in": "query", - "required": true, - "schema": { - "type": "boolean", - "title": "Status" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/gpio/{pin_id}/toggle": { - "patch": { - "tags": [ - "gpio" - ], - "summary": "Toggle Pin", - "description": "Toggle GPIO pin output value\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to toggle", - "operationId": "toggle_pin", - "parameters": [ - { - "name": "pin_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Pin Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/": { - "get": { - "tags": [ - "openscan" - ], - "summary": "Get Software Info", - "description": "Get information about the scanner software", - "operationId": "get_software_info", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/logs/tail": { - "get": { - "tags": [ - "openscan" - ], - "summary": "Tail Logs", - "description": "Show or follow current logs.\n\nWhen follow=false (default), returns the last N lines of the selected log.\nWhen follow=true (text mode only!), streams new lines as they are written (like `tail -f`).\n\nArgs:\n format: \"text\" for openscan_firmware.log, \"json\" for openscan_detailed_log.json.\n lines: Number of last lines to return initially.\n follow: If true, stream appended log lines in text mode.\n poll_interval: Poll interval (seconds) when following in text mode.\n\nReturns:\n A response with the requested log content.", - "operationId": "tail_logs", - "parameters": [ - { - "name": "format", - "in": "query", - "required": false, - "schema": { - "type": "string", - "default": "text", - "title": "Format" - } - }, - { - "name": "lines", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "default": 200, - "title": "Lines" - } - }, - { - "name": "follow", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Follow" - } - }, - { - "name": "poll_interval", - "in": "query", - "required": false, - "schema": { - "type": "number", - "default": 1, - "title": "Poll Interval" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/logs/archive": { - "get": { - "tags": [ - "openscan" - ], - "summary": "Download Logs Archive", - "description": "Create and download a ZIP archive containing all log files.\n\nThe archive includes rotated files for both text and JSON logs, using\ndeflate compression for reasonable size to share e.g. via email.\n\nReturns:\n FileResponse serving the generated ZIP. The temp file is deleted after send.", - "operationId": "download_logs_archive", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/device/info": { - "get": { - "tags": [ - "device" - ], - "summary": "Get Device Info", - "description": "Get information about the device\n\nReturns:\n dict: A dictionary containing information about the device", - "operationId": "get_device_info", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceStatusResponse" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/device/configurations": { - "get": { - "tags": [ - "device" - ], - "summary": "List Config Files", - "description": "List all available device configuration files", - "operationId": "list_config_files", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/device/configurations/": { - "post": { - "tags": [ - "device" - ], - "summary": "Add Config Json", - "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "add_config_json", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/device/configurations/current": { - "put": { - "tags": [ - "device" - ], - "summary": "Set Config File", - "description": "Set the device configuration from a file and initialize hardware\n\nArgs:\n config_data: The device configuration to set\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "set_config_file", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceConfigRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": [ - "device" - ], - "summary": "Save Device Config", - "description": "Save the current device configuration to a file\n\nThis endpoint saves the current device configuration to device_config.json.\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "save_device_config", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/device/configurations/current/initialize": { - "post": { - "tags": [ - "device" - ], - "summary": "Reinitialize Hardware", - "description": "Reinitialize hardware components\n\nThis can be used in case of a hardware failure or to reload the hardware components.\n\nArgs:\n detect_cameras: Whether to detect cameras\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "reinitialize_hardware", - "parameters": [ - { - "name": "detect_cameras", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Detect Cameras" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/device/reboot": { - "post": { - "tags": [ - "device" - ], - "summary": "Reboot", - "description": "Reboot system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before rebooting", - "operationId": "reboot", - "parameters": [ - { - "name": "save_config", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Save Config" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "boolean", - "title": "Response Reboot Device Reboot Post" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/device/shutdown": { - "post": { - "tags": [ - "device" - ], - "summary": "Shutdown", - "description": "Shutdown system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before shutting down", - "operationId": "shutdown", - "parameters": [ - { - "name": "save_config", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Save Config" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "boolean", - "title": "Response Shutdown Device Shutdown Post" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/tasks/": { - "get": { - "tags": [ - "tasks" - ], - "summary": "Get All Tasks", - "description": "Retrieve a list of all tasks known to the task manager.\n\nReturns:\n List[Task]: A list of all tasks known to the task manager.", - "operationId": "get_all_tasks", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/Task" - }, - "type": "array", - "title": "Response Get All Tasks Tasks Get" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/tasks/{task_id}": { - "get": { - "tags": [ - "tasks" - ], - "summary": "Get Task Status", - "description": "Retrieve the status and details of a specific task.\n\nArgs:\n task_id: The ID of the task to retrieve.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "get_task_status", - "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" - } - } - } - } - } - }, - "delete": { - "tags": [ - "tasks" - ], - "summary": "Cancel Task", - "description": "Request cancellation of a running task.\n\nArgs:\n task_id: The ID of the task to cancel.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "cancel_task", - "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" - } - } - } - } - } - } - }, - "/tasks/{task_id}/pause": { - "post": { - "tags": [ - "tasks" - ], - "summary": "Pause a Task", - "description": "Pauses a running task.\n\nArgs:\n task_id: The ID of the task to pause.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "pause_task", - "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" - } - } - } - } - } - } - }, - "/tasks/{task_id}/resume": { - "post": { - "tags": [ - "tasks" - ], - "summary": "Resume a Task", - "description": "Resumes a paused task.\n\nArgs:\n task_id: The ID of the task to resume.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "resume_task", - "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" - } - } - } - } - } - } - }, - "/tasks/{task_name}": { - "post": { - "tags": [ - "tasks" - ], - "summary": "Create Task", - "description": "Create and start a new background task with optional parameters.\n\nThe request body accepts:\n- **args**: List of positional arguments (e.g., `[\"project_name\", 0]`)\n- **kwargs**: Dictionary of keyword arguments (e.g., `{\"num_batches\": 5}`)\n\nArgs:\n task_name: The name of the task to create, as registered in the TaskManager.\n args: Positional arguments to pass to the task's run method.\n kwargs: Keyword arguments to pass to the task's run method.\n\nReturns:\n The created task object.\n\nExamples:\n ```json\n // No parameters\n {}\n\n // With positional args\n {\n \"args\": [\"MyProject\", 0]\n }\n\n // With keyword args\n {\n \"kwargs\": {\"num_calibration_batches\": 5}\n }\n\n // With both\n {\n \"args\": [\"MyProject\", 0],\n \"kwargs\": {\"num_calibration_batches\": 5}\n }\n ```", - "operationId": "create_task", - "parameters": [ - { - "name": "task_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Task Name" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_create_task_tasks__task_name__post" - } - } - } - }, - "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" - } - } - } - } - } - } - }, - "/develop/scanner-position": { - "put": { - "tags": [ - "develop" - ], - "summary": "Move To Position", - "description": "Move Rotor and Turntable to a polar point", - "operationId": "move_to_position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PolarPoint3D" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/develop/restart": { - "post": { - "tags": [ - "develop" - ], - "summary": "Restart Application", - "description": "Trigger a Firmware reload by touching the reload sentinel file.\n\nNote: The application has to be started with the --reload-trigger option to enable this endpoint.", - "operationId": "restart_application", - "responses": { - "202": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "title": "Response Restart Application Develop Restart Post" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/develop/crop_image": { - "get": { - "tags": [ - "develop" - ], - "summary": "Run crop task and return visualization image", - "description": "Run the crop task and return the visualization image with bounding boxes.\n\nArgs:\n camera_name: Name of the camera controller to use.\n threshold: Optional Canny threshold passed to the analysis (tutorial uses a trackbar). If not set, defaults inside the task.\n\nReturns:\n Response: JPEG image showing contours, rectangles and circles as detected by the task.", - "operationId": "crop_image", - "parameters": [ - { - "name": "camera_name", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Camera Name" - } - }, - { - "name": "threshold", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer", - "maximum": 255, - "minimum": 0 - }, - { - "type": "null" - } - ], - "title": "Threshold" - } - } - ], - "responses": { - "200": { - "description": "Successful Response" - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/develop/hello-world-async": { - "post": { - "tags": [ - "develop" - ], - "summary": "Hello World Async", - "description": "Start the async hello world demo task.", - "operationId": "hello_world_async", - "parameters": [ - { - "name": "total_steps", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Total Steps" - } - }, - { - "name": "delay", - "in": "query", - "required": true, - "schema": { - "type": "number", - "title": "Delay" - } - } - ], - "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" - } - } - } - } - } - } - }, - "/develop/{method}": { - "get": { - "tags": [ - "develop" - ], - "summary": "Get Path", - "description": "Get a list of coordinates by path method and number of points", - "operationId": "get_path", - "parameters": [ - { - "name": "method", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/PathMethod" - } - }, - { - "name": "points", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Points" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CartesianPoint3D" - }, - "title": "Response Get Path Develop Method Get" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/cloud/status": { - "get": { - "tags": [ - "cloud" - ], - "summary": "Get Cloud Status", - "description": "Return aggregated status information for the cloud backend.\n\nReturns:\n CloudStatusResponse: A response object containing the status of the cloud backend", - "operationId": "get_cloud_status", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloudStatusResponse" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/cloud/settings": { - "get": { - "tags": [ - "cloud" - ], - "summary": "Get Cloud Settings", - "description": "Return the masked active cloud configuration.\n\nReturns:\n CloudSettingsResponse: A response object containing the masked active cloud configuration", - "operationId": "get_cloud_settings", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloudSettingsResponse" - } - } - } - }, - "404": { - "description": "Not found" - } - } - }, - "post": { - "tags": [ - "cloud" - ], - "summary": "Update Cloud Settings", - "description": "Persist and activate new cloud settings.\n\nArgs:\n new_settings: The new cloud settings to persist and activate\n\nReturns:\n CloudSettingsResponse: A response object containing the masked active cloud configuration", - "operationId": "update_cloud_settings", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloudSettings" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloudSettingsResponse" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/cloud/projects": { - "get": { - "tags": [ - "cloud" - ], - "summary": "List Cloud Projects", - "description": "Return all local projects enriched with cloud metadata.\n\nReturns:\n list[CloudProjectStatus]: A list of cloud project status objects", - "operationId": "list_cloud_projects", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/CloudProjectStatus" - }, - "type": "array", - "title": "Response List Cloud Projects Cloud Projects Get" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/cloud/projects/{project_name}": { - "get": { - "tags": [ - "cloud" - ], - "summary": "Get Cloud Project", - "description": "Return cloud details for a single local project.\n\nArgs:\n project_name: The name of the project to get the cloud details for\n\nReturns:\n CloudProjectStatus: A response object containing the cloud project status", - "operationId": "get_cloud_project", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CloudProjectStatus" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "cloud" - ], - "summary": "Reset Cloud Project", - "description": "Reset the remote project and clear the local linkage.\n\nArgs:\n project_name: The name of the project to reset the remote project for\n\nReturns:\n dict[str, Any]: A response object containing the result of the reset operation", - "operationId": "reset_cloud_project", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Reset Cloud Project Cloud Projects Project Name Delete" - } - } - } - }, - "404": { - "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}/focus-stacking/start": { - "post": { - "tags": [ - "focus_stacking" - ], - "summary": "Start Focus Stacking", - "description": "Start focus stacking for a scan.", - "operationId": "start_focus_stacking", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}/focus-stacking/pause": { - "patch": { - "tags": [ - "focus_stacking" - ], - "summary": "Pause Focus Stacking", - "description": "Pause an active focus stacking task.", - "operationId": "pause_focus_stacking", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}/focus-stacking/resume": { - "patch": { - "tags": [ - "focus_stacking" - ], - "summary": "Resume Focus Stacking", - "description": "Resume a paused focus stacking task.", - "operationId": "resume_focus_stacking", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/projects/{project_name}/scans/{scan_index}/focus-stacking/cancel": { - "patch": { - "tags": [ - "focus_stacking" - ], - "summary": "Cancel Focus Stacking", - "description": "Cancel an active focus stacking task.", - "operationId": "cancel_focus_stacking", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Body_add_config_json_device_configurations__post": { - "properties": { - "config_data": { - "$ref": "#/components/schemas/ScannerDevice" - }, - "filename": { - "$ref": "#/components/schemas/DeviceConfigRequest" - } - }, - "type": "object", - "required": [ - "config_data", - "filename" - ], - "title": "Body_add_config_json_device_configurations__post" - }, - "Body_create_task_tasks__task_name__post": { - "properties": { - "args": { - "items": {}, - "type": "array", - "title": "Args", - "description": "Positional arguments for the task", - "default": [] - }, - "kwargs": { - "additionalProperties": true, - "type": "object", - "title": "Kwargs", - "description": "Keyword arguments for the task", - "default": {} - } - }, - "type": "object", - "title": "Body_create_task_tasks__task_name__post" - }, - "Body_move_motor_by_degree_motors__motor_name__angle_patch": { - "properties": { - "degrees": { - "type": "number", - "title": "Degrees" - } - }, - "type": "object", - "required": [ - "degrees" - ], - "title": "Body_move_motor_by_degree_motors__motor_name__angle_patch" - }, - "Camera": { - "properties": { - "type": { - "$ref": "#/components/schemas/CameraType" - }, - "name": { - "type": "string", - "title": "Name" - }, - "path": { - "type": "string", - "title": "Path" - }, - "settings": { - "$ref": "#/components/schemas/CameraSettings" - } - }, - "type": "object", - "required": [ - "type", - "name", - "path", - "settings" - ], - "title": "Camera" - }, - "CameraSettings": { - "properties": { - "shutter": { - "anyOf": [ - { - "type": "number", - "maximum": 435918.849, - "minimum": 0.001 - }, - { - "type": "null" - } - ], - "title": "Shutter", - "description": "Shutter speed in milliseconds.", - "default": 50.0 - }, - "saturation": { - "anyOf": [ - { - "type": "number", - "maximum": 32.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Saturation", - "description": "Image color saturation from 0 to 32", - "default": 1.0 - }, - "contrast": { - "anyOf": [ - { - "type": "number", - "maximum": 32.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Contrast", - "description": "Image contrast from 0 to 32.", - "default": 1.0 - }, - "awbg_red": { - "anyOf": [ - { - "type": "number", - "maximum": 32.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Awbg Red", - "description": "Red Gain from 0 to 32.", - "default": 1.8 - }, - "awbg_blue": { - "anyOf": [ - { - "type": "number", - "maximum": 32.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Awbg Blue", - "description": "Blue Gain from 0 to 32.", - "default": 1.8 - }, - "gain": { - "anyOf": [ - { - "type": "number", - "maximum": 32.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Gain", - "description": "Analogue Gain from 0 to 32.", - "default": 1.0 - }, - "jpeg_quality": { - "anyOf": [ - { - "type": "integer", - "maximum": 100.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Jpeg Quality", - "description": "JPEG image quality from 0 to 100", - "default": 90 - }, - "AF": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Af", - "description": "Enable Autofocus. This will ignore manual_focus settings.", - "default": true - }, - "AF_window": { - "anyOf": [ - { - "prefixItems": [ - { - "type": "integer", - "minimum": 0.0 - }, - { - "type": "integer", - "minimum": 0.0 - }, - { - "type": "integer", - "minimum": 0.0 - }, - { - "type": "integer", - "minimum": 0.0 - } - ], - "type": "array", - "maxItems": 4, - "minItems": 4 - }, - { - "type": "null" - } - ], - "title": "Af Window", - "description": "Autofocus window (x,y,w,h) in pixels. (x,y) specify the position of the upper left corner of the window. This will be ignored if AF is disabled." - }, - "manual_focus": { - "anyOf": [ - { - "type": "number", - "maximum": 15.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Manual Focus", - "description": "Manual focus position in diopters. This is only applied if autofocus is disabled.", - "default": 12.0 - }, - "crop_width": { - "anyOf": [ - { - "type": "integer", - "maximum": 100.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Crop Width", - "description": "Cropping width in percent.", - "default": 10 - }, - "crop_height": { - "anyOf": [ - { - "type": "integer", - "maximum": 100.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": "Crop Height", - "description": "Cropping on height in percent.", - "default": 10 - }, - "orientation_flag": { - "anyOf": [ - { - "type": "integer", - "maximum": 9.0, - "minimum": 1.0 - }, - { - "type": "null" - } - ], - "title": "Orientation Flag", - "description": "Orientation in exif flag format.For imx519 in Mini use 8.For Hawkeye in Mini use 6.For imx519 in Classic use 1.", - "default": 8 - }, - "preview_resolution": { - "anyOf": [ - { - "prefixItems": [ - { - "type": "integer" - }, - { - "type": "integer" - } - ], - "type": "array", - "maxItems": 2, - "minItems": 2 - }, - { - "type": "null" - } - ], - "title": "Preview Resolution", - "description": "Preview resolution (x,y). Changing resolution can break cropping." - }, - "photo_resolution": { - "anyOf": [ - { - "prefixItems": [ - { - "type": "integer" - }, - { - "type": "integer" - } - ], - "type": "array", - "maxItems": 2, - "minItems": 2 - }, - { - "type": "null" - } - ], - "title": "Photo Resolution", - "description": "Preview resolution (x,y). Changing resolution can break cropping." - } - }, - "type": "object", - "title": "CameraSettings" - }, - "CameraStatusResponse": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "type": { - "$ref": "#/components/schemas/CameraType" - }, - "busy": { - "type": "boolean", - "title": "Busy" - }, - "settings": { - "$ref": "#/components/schemas/CameraSettings" - } - }, - "type": "object", - "required": [ - "name", - "type", - "busy", - "settings" - ], - "title": "CameraStatusResponse" - }, - "CameraType": { - "type": "string", - "enum": [ - "gphoto2", - "linuxpy", - "picamera2", - "external" - ], - "title": "CameraType" - }, - "CartesianPoint3D": { - "properties": { - "x": { - "type": "number", - "title": "X" - }, - "y": { - "type": "number", - "title": "Y" - }, - "z": { - "type": "number", - "title": "Z" - } - }, - "type": "object", - "required": [ - "x", - "y", - "z" - ], - "title": "CartesianPoint3D" - }, - "CloudProjectStatus": { - "properties": { - "project": { - "$ref": "#/components/schemas/Project" - }, - "remote_project_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Remote Project Name" - }, - "remote_info": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Remote Info" - }, - "tasks": { - "items": { - "$ref": "#/components/schemas/Task" - }, - "type": "array", - "title": "Tasks" - }, - "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Message" - } - }, - "type": "object", - "required": [ - "project" - ], - "title": "CloudProjectStatus", - "description": "Local project enriched with cloud metadata and related tasks." - }, - "CloudSettings": { - "properties": { - "user": { - "type": "string", - "title": "User", - "description": "HTTP basic auth username for the cloud API.", - "default": "openscan" - }, - "password": { - "type": "string", - "title": "Password", - "description": "HTTP basic auth password for the cloud API.", - "default": "free" - }, - "token": { - "type": "string", - "title": "Token", - "description": "API token identifying the device or user." - }, - "host": { - "type": "string", - "maxLength": 2083, - "minLength": 1, - "format": "uri", - "title": "Host", - "description": "Base URL of the cloud service.", - "default": "http://openscanfeedback.dnsuser.de:1334" - }, - "split_size": { - "type": "integer", - "minimum": 1.0, - "title": "Split Size", - "description": "Maximum upload part size in bytes. The cloud currently accepts up to 200 MB per chunk.", - "default": 200000000 - } - }, - "type": "object", - "required": [ - "token" - ], - "title": "CloudSettings", - "description": "Settings that describe how to talk to the OpenScan cloud backend." - }, - "CloudSettingsResponse": { - "properties": { - "settings": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Settings" - }, - "source": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Source" - }, - "persisted": { - "type": "boolean", - "title": "Persisted", - "default": false - } - }, - "type": "object", - "title": "CloudSettingsResponse", - "description": "Masked cloud settings including metadata." - }, - "CloudStatusResponse": { - "properties": { - "status": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Status" - }, - "token_info": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Token Info" - }, - "queue_estimate": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Queue Estimate" - }, - "settings": { - "$ref": "#/components/schemas/CloudSettingsResponse" - }, - "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Message" - } - }, - "type": "object", - "required": [ - "settings" - ], - "title": "CloudStatusResponse", - "description": "Aggregated view of the cloud backend status." - }, - "DeleteResponse": { - "properties": { - "success": { - "type": "boolean", - "title": "Success" - }, - "message": { - "type": "string", - "title": "Message" - }, - "deleted": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Deleted" - } - }, - "type": "object", - "required": [ - "success", - "message", - "deleted" - ], - "title": "DeleteResponse" - }, - "DeviceConfigRequest": { - "properties": { - "config_file": { - "type": "string", - "title": "Config File" - } - }, - "type": "object", - "required": [ - "config_file" - ], - "title": "DeviceConfigRequest" - }, - "DeviceControlResponse": { - "properties": { - "success": { - "type": "boolean", - "title": "Success" - }, - "message": { - "type": "string", - "title": "Message" - }, - "status": { - "$ref": "#/components/schemas/DeviceStatusResponse" - } - }, - "type": "object", - "required": [ - "success", - "message", - "status" - ], - "title": "DeviceControlResponse" - }, - "DeviceStatusResponse": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "model": { - "type": "string", - "title": "Model" - }, - "shield": { - "type": "string", - "title": "Shield" - }, - "cameras": { - "additionalProperties": { - "$ref": "#/components/schemas/CameraStatusResponse" - }, - "type": "object", - "title": "Cameras" - }, - "motors": { - "additionalProperties": { - "$ref": "#/components/schemas/MotorStatusResponse" - }, - "type": "object", - "title": "Motors" - }, - "lights": { - "additionalProperties": { - "$ref": "#/components/schemas/LightStatusResponse" - }, - "type": "object", - "title": "Lights" - }, - "initialized": { - "type": "boolean", - "title": "Initialized" - } - }, - "type": "object", - "required": [ - "name", - "model", - "shield", - "cameras", - "motors", - "lights", - "initialized" - ], - "title": "DeviceStatusResponse" - }, - "Endstop": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/EndstopConfig" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Endstop" - }, - "EndstopConfig": { - "properties": { - "pin": { - "type": "integer", - "title": "Pin", - "description": "GPIO pin number used for the endstop" - }, - "angular_position": { - "type": "number", - "title": "Angular Position", - "description": "Angle at which the endstop is triggered (degrees)" - }, - "motor_name": { - "type": "string", - "title": "Motor Name", - "description": "Name of the assigned motor" - }, - "pull_up": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Pull Up", - "description": "Whether to use a pull-up resistor", - "default": true - }, - "bounce_time": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Bounce Time", - "description": "Debounce time for the button in seconds", - "default": 0.005 - }, - "active_high": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Active High", - "description": "Useful for normally closed switches", - "default": false - } - }, - "type": "object", - "required": [ - "pin", - "angular_position", - "motor_name" - ], - "title": "EndstopConfig", - "description": "Configuration for a motor endstop.\n\nArgs:\n pin (int): GPIO pin number used for the endstop.\n angular_position (float): Angle at which the endstop is triggered (degrees).\n motor_name (str): Name of the assigned motor.\n pull_up (Optional[bool]): Whether to use a pull-up resistor (default: True).\n bounce_time (Optional[float]): Debounce time for the button in seconds (default: 0.005)." - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "Light": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "$ref": "#/components/schemas/LightConfig" - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Light" - }, - "LightConfig": { - "properties": { - "pin": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Pin", - "description": "Single GPIO pin controlling the light output." - }, - "pins": { - "anyOf": [ - { - "items": { - "type": "integer" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Pins", - "description": "Multiple GPIO pins driving grouped light outputs." - }, - "pwm_support": { - "type": "boolean", - "title": "Pwm Support", - "description": "Indicates whether this light hardware can handle PWM (otherwise only on/off).", - "default": false - } - }, - "type": "object", - "title": "LightConfig" - }, - "LightStatusResponse": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "is_on": { - "type": "boolean", - "title": "Is On" - }, - "settings": { - "$ref": "#/components/schemas/LightConfig" - } - }, - "type": "object", - "required": [ - "name", - "is_on", - "settings" - ], - "title": "LightStatusResponse" - }, - "Motor": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/MotorConfig" - }, - { - "type": "null" - } - ] - }, - "angle": { - "type": "number", - "title": "Angle", - "default": 90.0 - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Motor" - }, - "MotorConfig": { - "properties": { - "direction_pin": { - "type": "integer", - "title": "Direction Pin", - "description": "GPIO pin controlling the motor direction signal." - }, - "enable_pin": { - "type": "integer", - "title": "Enable Pin", - "description": "GPIO pin toggling the motor driver enable line." - }, - "step_pin": { - "type": "integer", - "title": "Step Pin", - "description": "GPIO pin used to emit step pulses." - }, - "acceleration": { - "type": "integer", - "maximum": 80000.0, - "minimum": 1000.0, - "title": "Acceleration", - "description": "Acceleration in steps/s\u00b2, Limits tested on Rpi 4 2GB under full load --> time estimation within 0.5%", - "default": 20000 - }, - "max_speed": { - "type": "integer", - "maximum": 7500.0, - "minimum": 1.0, - "title": "Max Speed", - "description": "Steps per second, Limits tested on RPi 4 2GB under full load --> time estimation within 0.5%", - "default": 5000 - }, - "direction": { - "type": "integer", - "enum": [ - 1, - -1 - ], - "title": "Direction", - "description": "Motor direction (1 or -1).", - "default": 1 - }, - "steps_per_rotation": { - "type": "integer", - "title": "Steps Per Rotation", - "description": "Number of steps for a full motor rotation." - }, - "min_angle": { - "type": "number", - "maximum": 360.0, - "minimum": 0.0, - "title": "Min Angle", - "description": "Minimum allowed angle for the motor in degrees.", - "default": 0 - }, - "max_angle": { - "type": "number", - "maximum": 360.0, - "minimum": 0.0, - "title": "Max Angle", - "description": "Maximum allowed angle for the motor in degrees.", - "default": 360 - }, - "home_angle": { - "type": "number", - "maximum": 360.0, - "minimum": 0.0, - "title": "Home Angle", - "description": "Angle for home position in degrees.", - "default": 90 - } - }, - "type": "object", - "required": [ - "direction_pin", - "enable_pin", - "step_pin", - "steps_per_rotation" - ], - "title": "MotorConfig" - }, - "MotorStatusResponse": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "angle": { - "type": "number", - "title": "Angle" - }, - "busy": { - "type": "boolean", - "title": "Busy" - }, - "target_angle": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Target Angle" - }, - "settings": { - "$ref": "#/components/schemas/MotorConfig" - }, - "calibrated": { - "type": "boolean", - "title": "Calibrated" - }, - "endstop": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Endstop" - } - }, - "type": "object", - "required": [ - "name", - "angle", - "busy", - "target_angle", - "settings", - "calibrated", - "endstop" - ], - "title": "MotorStatusResponse" - }, - "PathMethod": { - "type": "string", - "enum": [ - "fibonacci" - ], - "title": "PathMethod" - }, - "PolarPoint3D": { - "properties": { - "theta": { - "type": "number", - "title": "Theta" - }, - "fi": { - "type": "number", - "title": "Fi" - }, - "r": { - "type": "number", - "title": "R", - "default": 1 - } - }, - "type": "object", - "required": [ - "theta", - "fi" - ], - "title": "PolarPoint3D" - }, - "Project": { - "properties": { - "name": { - "type": "string", - "title": "Name", - "description": "Name of the project." - }, - "path": { - "type": "string", - "title": "Path", - "description": "Path to the project directory." - }, - "created": { - "type": "string", - "format": "date-time", - "title": "Created", - "description": "Creation timestamp of the project." - }, - "scans": { - "additionalProperties": { - "$ref": "#/components/schemas/Scan" - }, - "type": "object", - "title": "Scans", - "description": "Scans associated with the project." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description", - "description": "Description of the project." - }, - "uploaded": { - "type": "boolean", - "title": "Uploaded", - "description": "Whether the model has been uploaded to the cloud.", - "default": false - }, - "cloud_project_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Cloud Project Name" - }, - "downloaded": { - "type": "boolean", - "title": "Downloaded", - "description": "Whether the model has been downloaded from the cloud.", - "default": false - } - }, - "type": "object", - "required": [ - "name", - "path", - "created", - "scans" - ], - "title": "Project", - "description": "Represents a scan project stored on disk and optionally processed in the cloud." - }, - "Scan": { - "properties": { - "project_name": { - "type": "string", - "title": "Project Name", - "description": "The name of the project this scan belongs to." - }, - "index": { - "type": "integer", - "title": "Index", - "description": "The sequential index of the scan within the project." - }, - "created": { - "type": "string", - "format": "date-time", - "title": "Created", - "description": "The timestamp when the scan was created." - }, - "status": { - "$ref": "#/components/schemas/TaskStatus", - "description": "The final, persistent status of the scan, mirroring the associated Task status.", - "default": "pending" - }, - "settings": { - "$ref": "#/components/schemas/ScanSetting", - "description": "The settings used for this scan." - }, - "camera_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Camera Name" - }, - "camera_settings": { - "$ref": "#/components/schemas/CameraSettings", - "description": "The camera settings used for this scan." - }, - "current_step": { - "type": "integer", - "title": "Current Step", - "default": 0 - }, - "system_message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "System Message" - }, - "last_updated": { - "type": "string", - "format": "date-time", - "title": "Last Updated" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "duration": { - "type": "number", - "title": "Duration", - "default": 0.0 - }, - "total_size_bytes": { - "type": "integer", - "minimum": 0.0, - "title": "Total Size Bytes", - "description": "Total size of all files belonging to the scan, in bytes.", - "default": 0 - }, - "photos": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Photos", - "description": "Relative filenames (with extension) of all photos captured for this scan." - }, - "task_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Task Id" - }, - "stacking_task_status": { - "anyOf": [ - { - "$ref": "#/components/schemas/StackingTaskStatus" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "project_name", - "index", - "settings", - "camera_settings" - ], - "title": "Scan", - "description": "Represents a single scan session within a project." - }, - "ScanSetting": { - "properties": { - "path_method": { - "$ref": "#/components/schemas/PathMethod", - "description": "Scanning path generator (e.g. fibonacci or spriral).", - "default": "fibonacci" - }, - "points": { - "type": "integer", - "maximum": 999.0, - "minimum": 1.0, - "title": "Points", - "description": "Number of points in scanning path.", - "default": 130 - }, - "image_format": { - "type": "string", - "enum": [ - "jpeg", - "dng", - "rgb_array", - "yuv_array" - ], - "title": "Image Format", - "description": "Output image format (JPEG, DNG, RGB array or YUV array).", - "default": "jpeg" - }, - "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 for faster scanning.", - "default": true - }, - "optimization_algorithm": { - "type": "string", - "title": "Optimization Algorithm", - "description": "Path optimization algorithm to use.", - "default": "nearest_neighbor" - }, - "focus_stacks": { - "type": "integer", - "maximum": 99.0, - "minimum": 1.0, - "title": "Focus Stacks", - "description": "Number of photos with different focus per position.This ignores AF and you need to set a focus range.Focus values will then be evenly spaced between min and max.", - "default": 1 - }, - "focus_range": { - "prefixItems": [ - { - "type": "number", - "maximum": 15.0, - "minimum": 0.0 - }, - { - "type": "number", - "maximum": 15.0, - "minimum": 0.0 - } - ], - "type": "array", - "maxItems": 2, - "minItems": 2, - "title": "Focus Range", - "description": "Minimum and maximum focus distance in diopters.", - "default": [ - 10.0, - 15.0 - ] - } - }, - "type": "object", - "title": "ScanSetting" - }, - "ScannerCalibrateMode": { - "type": "string", - "enum": [ - "calibrate_manual", - "calibrate_on_home", - "calibrate_on_scan", - "calibrate_on_wake" - ], - "title": "ScannerCalibrateMode" - }, - "ScannerDevice": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "model": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScannerModel" - }, - { - "type": "null" - } - ] - }, - "shield": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScannerShield" - }, - { - "type": "null" - } - ] - }, - "cameras": { - "additionalProperties": { - "$ref": "#/components/schemas/Camera" - }, - "type": "object", - "title": "Cameras" - }, - "motors": { - "additionalProperties": { - "$ref": "#/components/schemas/Motor" - }, - "type": "object", - "title": "Motors" - }, - "lights": { - "additionalProperties": { - "$ref": "#/components/schemas/Light" - }, - "type": "object", - "title": "Lights" - }, - "endstops": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/components/schemas/Endstop" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Endstops" - }, - "motors_timeout": { - "type": "number", - "title": "Motors Timeout", - "default": 0.0 - }, - "startup_mode": { - "$ref": "#/components/schemas/ScannerStartupMode", - "default": "startup_enabled" - }, - "calibrate_mode": { - "$ref": "#/components/schemas/ScannerCalibrateMode", - "default": "calibrate_manual" - } - }, - "type": "object", - "required": [ - "name", - "model", - "shield", - "cameras", - "motors", - "lights", - "endstops" - ], - "title": "ScannerDevice" - }, - "ScannerModel": { - "type": "string", - "enum": [ - "classic", - "mini", - "custom" - ], - "title": "ScannerModel" - }, - "ScannerShield": { - "type": "string", - "enum": [ - "greenshield", - "blackshield", - "custom" - ], - "title": "ScannerShield" - }, - "ScannerStartupMode": { - "type": "string", - "enum": [ - "startup_idle", - "startup_enabled" - ], - "title": "ScannerStartupMode" - }, - "StackingTaskStatus": { - "properties": { - "task_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Task Id" - }, - "status": { - "anyOf": [ - { - "$ref": "#/components/schemas/TaskStatus" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "title": "StackingTaskStatus" - }, - "Task": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "task_type": { - "type": "string", - "title": "Task Type" - }, - "is_exclusive": { - "type": "boolean", - "title": "Is Exclusive", - "description": "Whether this task is exclusive and should not run concurrently", - "default": false - }, - "is_blocking": { - "type": "boolean", - "title": "Is Blocking", - "description": "Whether this task is blocking and should run in a separate thread", - "default": false - }, - "status": { - "$ref": "#/components/schemas/TaskStatus", - "default": "pending" - }, - "progress": { - "$ref": "#/components/schemas/TaskProgress" - }, - "result": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "title": "Result" - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "started_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Started At" - }, - "completed_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Completed At" - }, - "run_args": { - "items": {}, - "type": "array", - "title": "Run Args", - "description": "Positional arguments the task was started with." - }, - "run_kwargs": { - "additionalProperties": true, - "type": "object", - "title": "Run Kwargs", - "description": "Keyword arguments the task was started with." - } - }, - "type": "object", - "required": [ - "name", - "task_type" - ], - "title": "Task", - "description": "Represents a background task." - }, - "TaskProgress": { - "properties": { - "current": { - "type": "number", - "title": "Current", - "description": "The current step or value of progress (e.g., files processed).", - "default": 0.0 - }, - "total": { - "type": "number", - "title": "Total", - "description": "The total number of steps or value for completion (e.g., total files).", - "default": 0.0 - }, - "message": { - "type": "string", - "title": "Message", - "default": "" - } - }, - "type": "object", - "title": "TaskProgress", - "description": "Model for task progress." - }, - "TaskStatus": { - "type": "string", - "enum": [ - "pending", - "running", - "paused", - "completed", - "cancelled", - "error", - "interrupted" - ], - "title": "TaskStatus", - "description": "Enum for task status" - }, - "ValidationError": { - "properties": { - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "type": "array", - "title": "Location" - }, - "msg": { - "type": "string", - "title": "Message" - }, - "type": { - "type": "string", - "title": "Error Type" - } - }, - "type": "object", - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError" - } - } - } -} \ No newline at end of file diff --git a/scripts/openapi/openapi_v0.7.json b/scripts/openapi/openapi_v0.9.json similarity index 83% rename from scripts/openapi/openapi_v0.7.json rename to scripts/openapi/openapi_v0.9.json index 2e2b909..78054b0 100644 --- a/scripts/openapi/openapi_v0.7.json +++ b/scripts/openapi/openapi_v0.9.json @@ -1,8 +1,8 @@ { "openapi": "3.1.0", "info": { - "title": "OpenScan3 API v0.7", - "version": "0.7" + "title": "OpenScan3 API v0.9", + "version": "0.9" }, "paths": { "/cameras/": { @@ -164,6 +164,84 @@ "type": "string", "title": "Camera Name" } + }, + { + "name": "image_format", + "in": "query", + "required": false, + "schema": { + "enum": [ + "jpeg", + "dng", + "rgb_array", + "yuv_array" + ], + "type": "string", + "default": "jpeg", + "title": "Image Format" + } + }, + { + "name": "with_metadata", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "With Metadata" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/cameras/{camera_name}/photo/payload/{payload_id}": { + "get": { + "tags": [ + "cameras" + ], + "summary": "Get Photo Payload", + "operationId": "get_photo_payload", + "parameters": [ + { + "name": "camera_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Camera Name" + } + }, + { + "name": "payload_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Payload Id" + } } ], "responses": { @@ -235,6 +313,67 @@ } } }, + "/cameras/{camera_name}/awb-calibration": { + "post": { + "tags": [ + "cameras" + ], + "summary": "Run automatic white balance calibration and lock the gains.", + "description": "Expose the camera controller's automatic white balance calibration if available.\n\nArgs:\n camera_name: Target camera identifier.\n params: Optional tuning parameters forwarded to the controller implementation.\n\nReturns:\n AutoCalibrateAwbResponse: Locked gains after the calibration.\n\nRaises:\n HTTPException: When the controller is busy, unsupported, or calibration fails.", + "operationId": "auto_calibrate_awb", + "parameters": [ + { + "name": "camera_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Camera Name" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoCalibrateAwbRequest", + "default": { + "warmup_frames": 12, + "stable_frames": 4, + "eps": 0.01, + "timeout_s": 2.0 + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoCalibrateAwbResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/cameras/{name}/settings": { "get": { "tags": [ @@ -580,14 +719,130 @@ } } }, + "/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": { + "$ref": "#/components/schemas/MotorStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/motors/{motor_name}/endstop-calibration": { "put": { "tags": [ "motors" ], - "summary": "Move Motor To Home Position", - "description": "Move motor to home position\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": "move_motor_to_home_position", + "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", + "in": "path", + "required": true, + "schema": { + "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": { + "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}/home": { + "put": { + "tags": [ + "motors" + ], + "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", @@ -1161,25 +1416,21 @@ } } }, - "/projects/": { + "/firmware/settings": { "get": { "tags": [ - "projects" + "firmware" ], - "summary": "Get Projects", - "description": "Get all projects with serialized data\n\nReturns:\n dict[str, Project]: A dictionary of project name to a project object", - "operationId": "get_projects", + "summary": "Get Settings", + "description": "Return persisted firmware settings.", + "operationId": "get_settings", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": { - "$ref": "#/components/schemas/Project" - }, - "type": "object", - "title": "Response Get Projects Projects Get" + "$ref": "#/components/schemas/FirmwareSettings" } } } @@ -1188,34 +1439,31 @@ "description": "Not found" } } - } - }, - "/projects/{project_name}": { - "get": { + }, + "put": { "tags": [ - "projects" + "firmware" ], - "summary": "Get Project", - "description": "Get a project\n\nArgs:\n project_name: The name of the project to get\n\nReturns:\n Project: The project object if found, None if not", - "operationId": "get_project", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" + "summary": "Replace Settings", + "description": "Replace the entire firmware settings payload.", + "operationId": "replace_settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } } - } - ], + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Project" + "$ref": "#/components/schemas/FirmwareSettings" } } } @@ -1234,8 +1482,139 @@ } } } - }, - "post": { + } + }, + "/firmware/settings/{key}": { + "patch": { + "tags": [ + "firmware" + ], + "summary": "Update Setting", + "description": "Update a single firmware settings key.", + "operationId": "update_setting", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettingPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/projects/": { + "get": { + "tags": [ + "projects" + ], + "summary": "Get Projects", + "description": "Get all projects with serialized data\n\nReturns:\n dict[str, Project]: A dictionary of project name to a project object", + "operationId": "get_projects", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "$ref": "#/components/schemas/Project" + }, + "type": "object", + "title": "Response Get Projects Projects Get" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/projects/{project_name}": { + "get": { + "tags": [ + "projects" + ], + "summary": "Get Project", + "description": "Get a project\n\nArgs:\n project_name: The name of the project to get\n\nReturns:\n Project: The project object if found, None if not", + "operationId": "get_project", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Project Name" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { "tags": [ "projects" ], @@ -2070,7 +2449,7 @@ "projects" ], "summary": "Download Project", - "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", + "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata. When ``photos_only`` is true,\nonly the recorded photo files are included without metadata or subfolders.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", "operationId": "download_project", "parameters": [ { @@ -2081,6 +2460,18 @@ "type": "string", "title": "Project Name" } + }, + { + "name": "photos_only", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "If true, stream only photo files without metadata or directory structure.", + "default": false, + "title": "Photos Only" + }, + "description": "If true, stream only photo files without metadata or directory structure." } ], "responses": { @@ -2395,7 +2786,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/SoftwareInfoResponse" + } } } }, @@ -2551,19 +2944,42 @@ } } }, - "/device/configurations/": { - "post": { + "/device/configurations/current": { + "get": { "tags": [ "device" ], - "summary": "Add Config Json", - "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "add_config_json", + "summary": "Get Current Config", + "description": "Return the currently active device configuration file.", + "operationId": "get_current_config", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceConfigResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + }, + "put": { + "tags": [ + "device" + ], + "summary": "Set Config File", + "description": "Set the device configuration from a file and initialize hardware\n\nArgs:\n config_data: The device configuration to set\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "set_config_file", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" + "$ref": "#/components/schemas/DeviceConfigRequest" } } }, @@ -2594,33 +3010,57 @@ } } } - } - }, - "/device/configurations/current": { - "put": { + }, + "patch": { "tags": [ "device" ], - "summary": "Set Config File", - "description": "Set the device configuration from a file and initialize hardware\n\nArgs:\n config_data: The device configuration to set\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "set_config_file", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceConfigRequest" + "summary": "Save Device Config", + "description": "Save the current device configuration to a file\n\nThis endpoint saves the current device configuration to device_config.json.\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "save_device_config", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceControlResponse" + } } } }, - "required": true - }, + "404": { + "description": "Not found" + } + } + } + }, + "/device/configurations/{filename}": { + "get": { + "tags": [ + "device" + ], + "summary": "Get Config File", + "description": "Return a specific configuration JSON file by filename.", + "operationId": "get_config_file", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Filename" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "$ref": "#/components/schemas/DeviceConfigResponse" } } } @@ -2639,14 +3079,26 @@ } } } - }, - "patch": { + } + }, + "/device/configurations/": { + "post": { "tags": [ "device" ], - "summary": "Save Device Config", - "description": "Save the current device configuration to a file\n\nThis endpoint saves the current device configuration to device_config.json.\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "save_device_config", + "summary": "Add Config Json", + "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "add_config_json", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", @@ -2660,6 +3112,16 @@ }, "404": { "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -2926,14 +3388,14 @@ } } }, - "/tasks/{task_id}/pause": { - "post": { + "/tasks/{task_id}/cleanup": { + "delete": { "tags": [ "tasks" ], - "summary": "Pause a Task", - "description": "Pauses a running task.\n\nArgs:\n task_id: The ID of the task to pause.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "pause_task", + "summary": "Delete a terminal task record", + "description": "Remove a terminal task from persistence and memory.", + "operationId": "delete_task", "parameters": [ { "name": "task_id", @@ -2946,12 +3408,51 @@ } ], "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" + "204": { + "description": "Successful Response" + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tasks/{task_id}/pause": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Pause a Task", + "description": "Pauses a running task.\n\nArgs:\n task_id: The ID of the task to pause.\n\nReturns:\n Task: The task object with its status and details.", + "operationId": "pause_task", + "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" } } } @@ -3145,6 +3646,55 @@ } } }, + "/develop/camera-report": { + "get": { + "tags": [ + "develop" + ], + "summary": "Get Camera Report", + "description": "Run the camera diagnostics script and return a bundled report.", + "operationId": "get_camera_report", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "enum": [ + "json", + "text" + ], + "type": "string", + "default": "json", + "title": "Format" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/develop/crop_image": { "get": { "tags": [ @@ -3257,6 +3807,54 @@ } } }, + "/develop/qr-scan": { + "post": { + "tags": [ + "develop" + ], + "summary": "Start Qr Scan", + "description": "Start a background task that scans for WiFi QR codes via the camera.\n\nThe task runs indefinitely, capturing frames and looking for QR codes.\nWhen it finds an Android/iOS WiFi share QR code it connects to the\nnetwork via nmcli and completes. Cancel the task to stop scanning.\n\nArgs:\n camera_name: Name of the camera controller to use for captures.\n\nReturns:\n Task: The created task model (poll via /tasks/{id} for progress).", + "operationId": "start_qr_scan", + "parameters": [ + { + "name": "camera_name", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Name of the camera controller to use", + "title": "Camera Name" + }, + "description": "Name of the camera controller to use" + } + ], + "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" + } + } + } + } + } + } + }, "/develop/{method}": { "get": { "tags": [ @@ -3406,6 +4004,29 @@ } } } + }, + "delete": { + "tags": [ + "cloud" + ], + "summary": "Delete Cloud Settings", + "description": "Delete persisted cloud settings and disable cloud features.", + "operationId": "delete_cloud_settings", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudSettingsResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } } }, "/cloud/projects": { @@ -3818,10 +4439,62 @@ }, "components": { "schemas": { + "AutoCalibrateAwbRequest": { + "properties": { + "warmup_frames": { + "type": "integer", + "minimum": 0.0, + "title": "Warmup Frames", + "description": "Number of frames to discard before reading AWB metadata.", + "default": 12 + }, + "stable_frames": { + "type": "integer", + "minimum": 1.0, + "title": "Stable Frames", + "description": "Consecutive frames that must meet the stability tolerance.", + "default": 4 + }, + "eps": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Eps", + "description": "Maximum delta between gain values to consider them stable.", + "default": 0.01 + }, + "timeout_s": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Timeout S", + "description": "Maximum time budget for the calibration loop in seconds.", + "default": 2.0 + } + }, + "type": "object", + "title": "AutoCalibrateAwbRequest" + }, + "AutoCalibrateAwbResponse": { + "properties": { + "red_gain": { + "type": "number", + "title": "Red Gain" + }, + "blue_gain": { + "type": "number", + "title": "Blue Gain" + } + }, + "type": "object", + "required": [ + "red_gain", + "blue_gain" + ], + "title": "AutoCalibrateAwbResponse" + }, "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { - "$ref": "#/components/schemas/ScannerDevice" + "$ref": "#/components/schemas/ScannerDeviceConfig-Input" }, "filename": { "$ref": "#/components/schemas/DeviceConfigRequest" @@ -3867,32 +4540,6 @@ ], "title": "Body_move_motor_by_degree_motors__motor_name__angle_patch" }, - "Camera": { - "properties": { - "type": { - "$ref": "#/components/schemas/CameraType" - }, - "name": { - "type": "string", - "title": "Name" - }, - "path": { - "type": "string", - "title": "Path" - }, - "settings": { - "$ref": "#/components/schemas/CameraSettings" - } - }, - "type": "object", - "required": [ - "type", - "name", - "path", - "settings" - ], - "title": "Camera" - }, "CameraSettings": { "properties": { "shutter": { @@ -4443,6 +5090,33 @@ ], "title": "DeviceConfigRequest" }, + "DeviceConfigResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "path": { + "type": "string", + "title": "Path" + }, + "config": { + "$ref": "#/components/schemas/ScannerDeviceConfig-Output" + } + }, + "type": "object", + "required": [ + "status", + "filename", + "path", + "config" + ], + "title": "DeviceConfigResponse" + }, "DeviceControlResponse": { "properties": { "success": { @@ -4472,11 +5146,25 @@ "title": "Name" }, "model": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Model" }, "shield": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Shield" }, "cameras": { @@ -4500,6 +5188,16 @@ "type": "object", "title": "Lights" }, + "motors_timeout": { + "type": "number", + "title": "Motors Timeout" + }, + "startup_mode": { + "$ref": "#/components/schemas/ScannerStartupMode" + }, + "calibrate_mode": { + "$ref": "#/components/schemas/ScannerCalibrateMode" + }, "initialized": { "type": "boolean", "title": "Initialized" @@ -4508,38 +5206,42 @@ "type": "object", "required": [ "name", - "model", - "shield", "cameras", "motors", "lights", + "motors_timeout", + "startup_mode", + "calibrate_mode", "initialized" ], "title": "DeviceStatusResponse" }, - "Endstop": { + "DiskUsage": { "properties": { - "name": { - "type": "string", - "title": "Name" + "total": { + "type": "integer", + "title": "Total", + "description": "Total bytes available on the filesystem." }, - "settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/EndstopConfig" - }, - { - "type": "null" - } - ] - } + "used": { + "type": "integer", + "title": "Used", + "description": "Bytes currently used (total - free)." + }, + "free": { + "type": "integer", + "title": "Free", + "description": "Free bytes remaining on the filesystem." + } }, "type": "object", "required": [ - "name", - "settings" + "total", + "used", + "free" ], - "title": "Endstop" + "title": "DiskUsage", + "description": "Filesystem usage snapshot for a directory." }, "EndstopConfig": { "properties": { @@ -4607,6 +5309,98 @@ "title": "EndstopConfig", "description": "Configuration for a motor endstop.\n\nArgs:\n pin (int): GPIO pin number used for the endstop.\n angular_position (float): Angle at which the endstop is triggered (degrees).\n motor_name (str): Name of the assigned motor.\n pull_up (Optional[bool]): Whether to use a pull-up resistor (default: True).\n bounce_time (Optional[float]): Debounce time for the button in seconds (default: 0.005)." }, + "EndstopStatusResponse": { + "properties": { + "assigned_motor": { + "type": "string", + "title": "Assigned Motor" + }, + "position": { + "type": "number", + "title": "Position" + }, + "pin": { + "type": "integer", + "title": "Pin" + }, + "is_pressed": { + "type": "boolean", + "title": "Is Pressed" + }, + "pull_up": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pull Up" + }, + "active_high": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active High" + }, + "bounce_time": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Bounce Time" + } + }, + "type": "object", + "required": [ + "assigned_motor", + "position", + "pin", + "is_pressed" + ], + "title": "EndstopStatusResponse" + }, + "FirmwareSettingPatchRequest": { + "properties": { + "value": { + "title": "Value" + } + }, + "type": "object", + "required": [ + "value" + ], + "title": "FirmwareSettingPatchRequest" + }, + "FirmwareSettings": { + "properties": { + "qr_wifi_scan_enabled": { + "type": "boolean", + "title": "Qr Wifi Scan Enabled", + "description": "Automatically scan for WiFi QR codes on startup when no WiFi or Ethernet connection is active.", + "default": true + }, + "enable_cloud": { + "type": "boolean", + "title": "Enable Cloud", + "description": "Enable integrations with OpenScan Cloud services.", + "default": false + } + }, + "type": "object", + "title": "FirmwareSettings", + "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances." + }, "HTTPValidationError": { "properties": { "detail": { @@ -4620,23 +5414,6 @@ "type": "object", "title": "HTTPValidationError" }, - "Light": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "$ref": "#/components/schemas/LightConfig" - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Light" - }, "LightConfig": { "properties": { "pin": { @@ -4698,35 +5475,6 @@ ], "title": "LightStatusResponse" }, - "Motor": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/MotorConfig" - }, - { - "type": "null" - } - ] - }, - "angle": { - "type": "number", - "title": "Angle", - "default": 90.0 - } - }, - "type": "object", - "required": [ - "name", - "settings" - ], - "title": "Motor" - }, "MotorConfig": { "properties": { "direction_pin": { @@ -4844,14 +5592,12 @@ "endstop": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/EndstopStatusResponse" }, { "type": "null" } - ], - "title": "Endstop" + ] } }, "type": "object", @@ -4873,6 +5619,46 @@ ], "title": "PathMethod" }, + "PersistedCameraConfig": { + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/components/schemas/CameraType" + }, + { + "type": "string" + } + ], + "title": "Type" + }, + "path": { + "type": "string", + "title": "Path" + }, + "settings": { + "$ref": "#/components/schemas/CameraSettings" + } + }, + "type": "object", + "required": [ + "type", + "path" + ], + "title": "PersistedCameraConfig" + }, + "PersistedEndstopConfig": { + "properties": { + "settings": { + "$ref": "#/components/schemas/EndstopConfig" + } + }, + "type": "object", + "required": [ + "settings" + ], + "title": "PersistedEndstopConfig" + }, "PhotoResponse": { "properties": { "project_name": { @@ -5245,7 +6031,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDevice": { + "ScannerDeviceConfig-Input": { "properties": { "name": { "type": "string", @@ -5254,40 +6040,42 @@ "model": { "anyOf": [ { - "$ref": "#/components/schemas/ScannerModel" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Model" }, "shield": { "anyOf": [ { - "$ref": "#/components/schemas/ScannerShield" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Shield" }, "cameras": { "additionalProperties": { - "$ref": "#/components/schemas/Camera" + "$ref": "#/components/schemas/PersistedCameraConfig" }, "type": "object", "title": "Cameras" }, "motors": { "additionalProperties": { - "$ref": "#/components/schemas/Motor" + "$ref": "#/components/schemas/MotorConfig" }, "type": "object", "title": "Motors" }, "lights": { "additionalProperties": { - "$ref": "#/components/schemas/Light" + "$ref": "#/components/schemas/LightConfig" }, "type": "object", "title": "Lights" @@ -5296,7 +6084,7 @@ "anyOf": [ { "additionalProperties": { - "$ref": "#/components/schemas/Endstop" + "$ref": "#/components/schemas/PersistedEndstopConfig" }, "type": "object" }, @@ -5312,43 +6100,136 @@ "default": 0.0 }, "startup_mode": { - "$ref": "#/components/schemas/ScannerStartupMode", + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerStartupMode" + }, + { + "type": "string" + } + ], + "title": "Startup Mode", "default": "startup_enabled" }, "calibrate_mode": { - "$ref": "#/components/schemas/ScannerCalibrateMode", + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerCalibrateMode" + }, + { + "type": "string" + } + ], + "title": "Calibrate Mode", "default": "calibrate_manual" } }, "type": "object", "required": [ - "name", - "model", - "shield", - "cameras", - "motors", - "lights", - "endstops" - ], - "title": "ScannerDevice" - }, - "ScannerModel": { - "type": "string", - "enum": [ - "classic", - "mini", - "custom" + "name" ], - "title": "ScannerModel" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, - "ScannerShield": { - "type": "string", - "enum": [ - "greenshield", - "blackshield", - "custom" + "ScannerDeviceConfig-Output": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "shield": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Shield" + }, + "cameras": { + "additionalProperties": { + "$ref": "#/components/schemas/PersistedCameraConfig" + }, + "type": "object", + "title": "Cameras" + }, + "motors": { + "additionalProperties": { + "$ref": "#/components/schemas/MotorConfig" + }, + "type": "object", + "title": "Motors" + }, + "lights": { + "additionalProperties": { + "$ref": "#/components/schemas/LightConfig" + }, + "type": "object", + "title": "Lights" + }, + "endstops": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/PersistedEndstopConfig" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Endstops" + }, + "motors_timeout": { + "type": "number", + "title": "Motors Timeout", + "default": 0.0 + }, + "startup_mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerStartupMode" + }, + { + "type": "string" + } + ], + "title": "Startup Mode", + "default": "startup_enabled" + }, + "calibrate_mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScannerCalibrateMode" + }, + { + "type": "string" + } + ], + "title": "Calibrate Mode", + "default": "calibrate_manual" + } + }, + "type": "object", + "required": [ + "name" ], - "title": "ScannerShield" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, "ScannerStartupMode": { "type": "string", @@ -5358,6 +6239,79 @@ ], "title": "ScannerStartupMode" }, + "SoftwareInfoResponse": { + "properties": { + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model", + "description": "Scanner model identifier, if configured." + }, + "firmware_version": { + "type": "string", + "title": "Firmware Version", + "description": "Currently running firmware version string." + }, + "last_shutdown_was_unclean": { + "type": "boolean", + "title": "Last Shutdown Was Unclean", + "description": "Indicates whether the previous shutdown finished cleanly." + }, + "runtime_dir": { + "type": "string", + "title": "Runtime Dir", + "description": "Absolute path used for runtime state files." + }, + "runtime_disk": { + "anyOf": [ + { + "$ref": "#/components/schemas/DiskUsage" + }, + { + "type": "null" + } + ], + "description": "Disk usage snapshot for the runtime directory filesystem." + }, + "projects_disk": { + "anyOf": [ + { + "$ref": "#/components/schemas/DiskUsage" + }, + { + "type": "null" + } + ], + "description": "Disk usage snapshot for the projects directory filesystem." + }, + "uptime_seconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Uptime Seconds", + "description": "Current system uptime in seconds, if available." + } + }, + "type": "object", + "required": [ + "firmware_version", + "last_shutdown_was_unclean", + "runtime_dir" + ], + "title": "SoftwareInfoResponse", + "description": "Information block served by /next/openscan." + }, "StackingTaskStatus": { "properties": { "task_id": { From 41d0ac90d38977c73bc5436d7bcfe0588714f015 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 1 Apr 2026 12:03:40 +0200 Subject: [PATCH 40/75] chore(pyproject): bump version to `0.11.0` --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2c8a0b5..eec3375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openscan-firmware" -version = "0.10.0" +version = "0.11.0" description = "OpenScan3 - Raspberry Pi based photogrammetry scanner (FastAPI-based application)" readme = "README.md" requires-python = ">=3.11" From a64ce0a861e4044ea35e021f79c8fa999e54c453 Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 2 Apr 2026 10:34:44 +0200 Subject: [PATCH 41/75] 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 42/75] 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 43/75] 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 44/75] 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 45/75] 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 46/75] 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" From 15050de878274f2b2746222b83110e0d0d837c60 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 14 Apr 2026 13:45:40 +0200 Subject: [PATCH 47/75] docs(camera): add external trigger documentation for supported shield variants - Documented GPIO pin mappings for `GreenShield` and `BlackShield`. - Included wiring guidelines, electrical notes, and configuration details. - Provided API examples for getting, updating, and triggering external camera pulses. --- docs/Camera/EXTERNAL_TRIGGER.md | 128 ++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/Camera/EXTERNAL_TRIGGER.md diff --git a/docs/Camera/EXTERNAL_TRIGGER.md b/docs/Camera/EXTERNAL_TRIGGER.md new file mode 100644 index 0000000..0a4a57a --- /dev/null +++ b/docs/Camera/EXTERNAL_TRIGGER.md @@ -0,0 +1,128 @@ +# Camera via External Trigger + +This note documents the default GPIO pin used for an external camera trigger on +the supported shield variants. + +## Pin Mapping + +- `GreenShield`: GPIO `10` +- `BlackShield`: GPIO `5` + +## Wiring Note + +If you connect an external trigger source, make sure the source is wired against +the correct shield variant: + +- use GPIO `10` on `GreenShield` +- use GPIO `5` on `BlackShield` + +If needed, document the corresponding physical header pins separately for the +specific hardware revision, because BCM numbering and board pin positions are +not interchangeable. + +## Electrical Note + +On both boards, the external trigger line is routed through an optocoupler. +That means the Raspberry Pi GPIO controls the trigger indirectly and the +electrical characteristics on the external side depend on the shield hardware +and the connected device. + +In practice, the external trigger circuit may require its own supply voltage on +the isolated side. Do not assume that the Raspberry Pi GPIO pin itself powers +the external trigger input. Verify the required voltage, polarity, and current +against the shield schematic and the camera or device you want to trigger. + +## Trigger Configuration + +The trigger API and configuration use the following fields: + +- `enabled`: Enables or disables the trigger. If set to `false`, the firmware + will refuse to fire it. +- `pin`: GPIO pin used for the trigger output. +- `active_level`: Defines which logic level is the active pulse: + `active_high` means the line goes high during the pulse, `active_low` means + the line goes low during the pulse. +- `pulse_width_ms`: Duration of the active pulse in milliseconds. + +## Trigger Usage + +When a trigger is fired, the firmware: + +1. sets the trigger line to its active level +2. keeps it active for `pulse_width_ms` +3. returns the line to its idle level + +The API also supports: + +- `pre_trigger_delay_ms`: wait time before the pulse +- `post_trigger_delay_ms`: wait time after the pulse + +This is useful if the external device needs setup time before the trigger pulse +or some settling time afterwards. + + +## API Example + +Get the current trigger status: + +```http +GET /triggers/camera +``` + +Example response: + +```json +{ + "name": "camera", + "busy": false, + "settings": { + "enabled": true, + "pin": 10, + "active_level": "active_high", + "pulse_width_ms": 100 + }, + "last_triggered_at": null, + "last_completed_at": null, + "last_duration_ms": null +} +``` + +Update the trigger settings: + +```http +PATCH /triggers/camera/settings +Content-Type: application/json +``` + +```json +{ + "pin": 10, + "active_level": "active_high", + "pulse_width_ms": 150 +} +``` + +Fire one trigger pulse with optional delays: + +```http +POST /triggers/camera/trigger +Content-Type: application/json +``` + +```json +{ + "pre_trigger_delay_ms": 50, + "post_trigger_delay_ms": 200 +} +``` + +Example response: + +```json +{ + "name": "camera", + "triggered_at": "2026-04-14T12:00:00.000000", + "completed_at": "2026-04-14T12:00:00.150000", + "duration_ms": 150 +} +``` From 9e0f3b113543bc4abeb0cd2fb93646bbe3516dce Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 14 Apr 2026 15:44:03 +0200 Subject: [PATCH 48/75] fix(picamera2): add unit tests for Picamera2 autofocus configuration methods - Added tests to validate autofocus behavior for preview and photo modes. - Verified default autofocus window handling when no window is provided. - Tested manual focus defaulting in the absence of autofocus settings. - Refactored `_configure_focus` to fix autofocus window transformation logic. --- .../controllers/hardware/cameras/picamera2.py | 6 +- .../picamera2/test_picamera2_focus_unit.py | 99 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py diff --git a/openscan_firmware/controllers/hardware/cameras/picamera2.py b/openscan_firmware/controllers/hardware/cameras/picamera2.py index 8c50670..4218416 100644 --- a/openscan_firmware/controllers/hardware/cameras/picamera2.py +++ b/openscan_firmware/controllers/hardware/cameras/picamera2.py @@ -287,9 +287,9 @@ def _configure_focus(self, camera_mode: str = None): if self.settings.AF_window is not None: x, y, w, h = self.settings.AF_window - af_window = _transform_settings_to_camera_coordinates(setting_coordinates=(x, y), - camera_resolution=(full_x, full_y), - setting_size=(w, h)) + af_window = [_transform_settings_to_camera_coordinates(setting_coordinates=(x, y), + camera_resolution=(full_x, full_y), + setting_size=(w, h))] else: # Default the focus window to the central 1% of the image win_width = int(full_x * 0.1) diff --git a/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py b/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py new file mode 100644 index 0000000..bc58c2a --- /dev/null +++ b/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py @@ -0,0 +1,99 @@ +import importlib +import sys +import types + +from openscan_firmware.config.camera import CameraSettings + + +def _import_picamera2_module(monkeypatch): + libcamera = types.ModuleType("libcamera") + + class _ColorSpace: + Sycc = "Sycc" + + class _AfMeteringEnum: + Windows = "windows" + + class _AfModeEnum: + Continuous = "continuous" + Auto = "auto" + Manual = "manual" + + libcamera.ColorSpace = _ColorSpace + libcamera.Transform = type("Transform", (), {}) + libcamera.controls = types.SimpleNamespace( + AfMeteringEnum=_AfMeteringEnum, + AfModeEnum=_AfModeEnum, + ) + + picamera2 = types.ModuleType("picamera2") + picamera2.Picamera2 = type("Picamera2", (), {}) + + monkeypatch.setitem(sys.modules, "libcamera", libcamera) + monkeypatch.setitem(sys.modules, "picamera2", picamera2) + sys.modules.pop("openscan_firmware.controllers.hardware.cameras.picamera2", None) + + return importlib.import_module("openscan_firmware.controllers.hardware.cameras.picamera2") + + +class _FakePicam: + def __init__(self, lens_position=1.0): + self.camera_properties = {"PixelArraySize": (200, 100)} + self.controls = [] + self._lens_position = lens_position + + def set_controls(self, values): + self.controls.append(values) + if "LensPosition" in values: + self._lens_position = values["LensPosition"] + + def capture_metadata(self): + return {"LensPosition": self._lens_position} + + +def test_configure_focus_sets_preview_autofocus_window(monkeypatch): + module = _import_picamera2_module(monkeypatch) + controller = object.__new__(module.Picamera2Controller) + controller.settings = CameraSettings(AF=True, AF_window=(10, 20, 30, 40)) + controller._picam = _FakePicam() + + controller._configure_focus(camera_mode="preview") + + assert controller._picam.controls == [ + { + "AfMetering": module.controls.AfMeteringEnum.Windows, + "AfWindows": [(20, 60, 40, 30)], + }, + {"AfMode": module.controls.AfModeEnum.Continuous}, + ] + + +def test_configure_focus_uses_default_af_window_when_none_is_set(monkeypatch): + module = _import_picamera2_module(monkeypatch) + controller = object.__new__(module.Picamera2Controller) + controller.settings = CameraSettings(AF=True, AF_window=None) + controller._picam = _FakePicam() + + controller._configure_focus(camera_mode="photo") + + assert controller._picam.controls == [ + { + "AfMetering": module.controls.AfMeteringEnum.Windows, + "AfWindows": [(90, 45, 20, 10)], + }, + {"AfMode": module.controls.AfModeEnum.Auto}, + ] + + +def test_configure_focus_sets_default_manual_focus(monkeypatch): + module = _import_picamera2_module(monkeypatch) + controller = object.__new__(module.Picamera2Controller) + controller.settings = CameraSettings(AF=False, manual_focus=None) + controller._picam = _FakePicam(lens_position=0.0) + + controller._configure_focus() + + assert controller.settings.manual_focus == 1.0 + assert controller._picam.controls == [ + {"AfMode": module.controls.AfModeEnum.Manual, "LensPosition": 1.0} + ] From 85e9bff4a88e430b91948db104b371648aefe694 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 14 Apr 2026 15:59:25 +0200 Subject: [PATCH 49/75] feat(paths): add support for phi angle constraints in scan path generation - Extended Fibonacci-based path generation to handle optional phi constraints. - Updated `get_constrained_path` to validate and apply `min_phi` and `max_phi` bounds. - Refactored `_generate_constrained_fibonacci` for customizable azimuth ranges. - Modified `ScanSetting` and related configuration models to include phi angles. - Added unit tests to verify behavior with and without phi constraints. --- .../config/external_trigger_run.py | 25 +++++- openscan_firmware/config/scan.py | 23 ++++- .../services/tasks/core/scan_task.py | 8 +- openscan_firmware/utils/paths/paths.py | 85 ++++++++++++++----- tests/config/test_scan_config.py | 33 +++++++ tests/controllers/services/test_scan_task.py | 61 ++++++++++++- tests/routers/test_scan_settings_openapi.py | 28 ++++++ 7 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 tests/config/test_scan_config.py create mode 100644 tests/routers/test_scan_settings_openapi.py diff --git a/openscan_firmware/config/external_trigger_run.py b/openscan_firmware/config/external_trigger_run.py index 3129a1a..f8ad907 100644 --- a/openscan_firmware/config/external_trigger_run.py +++ b/openscan_firmware/config/external_trigger_run.py @@ -1,6 +1,6 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, SerializerFunctionWrapHandler, model_serializer from openscan_firmware.config.scan import ScanSetting from openscan_firmware.models.paths import PathMethod @@ -24,6 +24,18 @@ class ExternalTriggerRunSettings(BaseModel): le=180.0, description="Maximum theta angle in degrees for constrained paths.", ) + min_phi: float | None = Field( + default=None, + ge=0.0, + le=360.0, + description="Optional minimum phi angle in degrees for constrained paths.", + ) + max_phi: float | None = Field( + default=None, + ge=0.0, + le=360.0, + description="Optional maximum phi angle in degrees for constrained paths.", + ) optimize_path: bool = Field( True, description="Enable path optimization based on the configured motor parameters.", @@ -57,9 +69,20 @@ def to_scan_settings(self) -> ScanSetting: points=self.points, min_theta=self.min_theta, max_theta=self.max_theta, + min_phi=self.min_phi, + max_phi=self.max_phi, optimize_path=self.optimize_path, optimization_algorithm=self.optimization_algorithm, focus_stacks=1, focus_range=(10.0, 15.0), image_format="jpeg", ) + + @model_serializer(mode="wrap") + def serialize_model(self, handler: SerializerFunctionWrapHandler): + data = handler(self) + if self.min_phi is None: + data.pop("min_phi", None) + if self.max_phi is None: + data.pop("max_phi", None) + return data diff --git a/openscan_firmware/config/scan.py b/openscan_firmware/config/scan.py index 7ac8e56..1f6a6e9 100644 --- a/openscan_firmware/config/scan.py +++ b/openscan_firmware/config/scan.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, confloat +from pydantic import BaseModel, Field, SerializerFunctionWrapHandler, confloat, model_serializer from typing import Tuple, Literal from openscan_firmware.models.paths import PathMethod @@ -21,6 +21,18 @@ class ScanSetting(BaseModel): 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.") + min_phi: float | None = Field( + default=None, + ge=0.0, + le=360.0, + description="Optional minimum phi angle in degrees for constrained paths.", + ) + max_phi: float | None = Field( + default=None, + ge=0.0, + le=360.0, + description="Optional maximum phi angle in degrees for constrained paths.", + ) # Path optimization settings optimize_path: bool = Field(True, description="Enable path optimization for faster scanning.") @@ -50,3 +62,12 @@ def focus_positions(self) -> list[float]: min_focus + i * (max_focus - min_focus) / (self.focus_stacks - 1) for i in range(self.focus_stacks) ] + + @model_serializer(mode="wrap") + def serialize_model(self, handler: SerializerFunctionWrapHandler): + data = handler(self) + if self.min_phi is None: + data.pop("min_phi", None) + if self.max_phi is None: + data.pop("max_phi", None) + return data diff --git a/openscan_firmware/controllers/services/tasks/core/scan_task.py b/openscan_firmware/controllers/services/tasks/core/scan_task.py index d7098a6..72c6878 100644 --- a/openscan_firmware/controllers/services/tasks/core/scan_task.py +++ b/openscan_firmware/controllers/services/tasks/core/scan_task.py @@ -42,12 +42,18 @@ def generate_scan_path(scan_settings: ScanSetting) -> dict[PolarPoint3D, int]: """ # Generate constrained path if scan_settings.path_method == PathMethod.FIBONACCI: - path = paths.get_constrained_path( + path_kwargs = dict( method=scan_settings.path_method, num_points=scan_settings.points, min_theta=scan_settings.min_theta, max_theta=scan_settings.max_theta, ) + if scan_settings.min_phi is not None: + path_kwargs["min_phi"] = scan_settings.min_phi + if scan_settings.max_phi is not None: + path_kwargs["max_phi"] = scan_settings.max_phi + + path = paths.get_constrained_path(**path_kwargs) logger.debug("Generated Fibonacci path with %d points", len(path)) else: logger.error("Unknown path method %s", scan_settings.path_method) diff --git a/openscan_firmware/utils/paths/paths.py b/openscan_firmware/utils/paths/paths.py index 32e102a..e12a4a9 100644 --- a/openscan_firmware/utils/paths/paths.py +++ b/openscan_firmware/utils/paths/paths.py @@ -2,8 +2,8 @@ Path generation utilities Provides functions and classes for generating scan paths in both cartesian and polar coordinates. -Supports Fibonacci-based point distributions with optional constraints on the theta angle, -which is typically limited by the degrees of freedom of the rotor motor (e.g., in the OpenScan Mini). +Supports Fibonacci-based point distributions with optional constraints on the theta and phi angles, +which are typically limited by the degrees of freedom of the scan rig motors. """ import abc @@ -79,12 +79,18 @@ def get_polar_path(method: PathMethod, num_points: int) -> list[PolarPoint3D]: return [cartesian_to_polar(point) for point in cartesian_points] -def get_constrained_path(method: PathMethod, num_points: int, min_theta: float = 0, - max_theta: float = 180) -> list[PolarPoint3D]: +def get_constrained_path( + method: PathMethod, + num_points: int, + min_theta: float = 0, + max_theta: float = 180, + min_phi: float = 0, + max_phi: float = 360, +) -> list[PolarPoint3D]: """ - Generate a path within specific theta angle constraints. + Generate a path within specific theta and phi angle constraints. - This function generates points specifically within the theta constraints + This function generates points specifically within the angle constraints rather than filtering from a full sphere, ensuring better distribution. Args: @@ -92,11 +98,20 @@ def get_constrained_path(method: PathMethod, num_points: int, min_theta: float = num_points: The target number of points to generate min_theta: Minimum theta angle in degrees (default: 0) max_theta: Maximum theta angle in degrees (default: 180) + min_phi: Minimum phi angle in degrees (default: 0) + max_phi: Maximum phi angle in degrees (default: 360) Returns: A list of PolarPoint3D objects within the specified constraints """ - logger.debug(f"Generating constrained path for {num_points} points, min theta: {min_theta}, max theta: {max_theta}") + logger.debug( + "Generating constrained path for %d points, min theta: %s, max theta: %s, min phi: %s, max phi: %s", + num_points, + min_theta, + max_theta, + min_phi, + max_phi, + ) # Validate input constraints if min_theta < 0 or max_theta > 180: logger.error("Theta angle must be between 0° and 180°") @@ -104,33 +119,68 @@ def get_constrained_path(method: PathMethod, num_points: int, min_theta: float = if min_theta >= max_theta: logger.error("Minimum theta angle must be less than maximum theta angle") raise ValueError("Minimum theta angle must be less than maximum theta angle") + if min_phi < 0 or min_phi > 360 or max_phi < 0 or max_phi > 360: + logger.error("Phi angle must be between 0° and 360°") + raise ValueError("Phi angle must be between 0° and 360°") + if min_phi == max_phi: + logger.error("Minimum phi angle must not be equal to maximum phi angle") + raise ValueError("Minimum phi angle must not be equal to maximum phi angle") if method == PathMethod.FIBONACCI: - return _generate_constrained_fibonacci(num_points, min_theta, max_theta) + return _generate_constrained_fibonacci( + num_points=num_points, + min_theta=min_theta, + max_theta=max_theta, + min_phi=min_phi, + max_phi=max_phi, + ) else: logger.error(f"Constrained path generation not implemented for method {method}") raise ValueError(f"Constrained path generation not implemented for method {method}") -def _generate_constrained_fibonacci(num_points: int, min_theta: float, max_theta: float) -> list[PolarPoint3D]: +def _phi_span(min_phi: float, max_phi: float) -> float: + """Return the positive span of a phi interval, supporting wrap-around at 360°.""" + span = (max_phi - min_phi) % 360 + return 360 if span == 0 else span + + +def _generate_constrained_fibonacci( + num_points: int, + min_theta: float, + max_theta: float, + min_phi: float, + max_phi: float, +) -> list[PolarPoint3D]: """ - Generate fibonacci points within theta constraints by directly controlling the Z range. + Generate fibonacci points within theta/phi constraints. The fibonacci sphere algorithm works by: 1. Distributing Z values linearly from -1 to 1 2. Converting Z to theta via theta = arccos(z) + 3. Distributing phi using a golden-ratio sequence within the allowed azimuth range - To constrain theta, we need to constrain the Z values accordingly. + To constrain theta, we limit the Z values accordingly. + To constrain phi, we map the golden-ratio sequence into the requested azimuth span. """ - logger.debug(f"Generating constrained fibonacci path for {num_points} points, min theta: {min_theta}, max theta: {max_theta}") + logger.debug( + "Generating constrained fibonacci path for %d points, min theta: %s, max theta: %s, min phi: %s, max phi: %s", + num_points, + min_theta, + max_theta, + min_phi, + max_phi, + ) # Convert theta constraints to Z constraints # theta = arccos(z), so z = cos(theta) # Note: theta increases as z decreases z_max = np.cos(np.radians(min_theta)) # z at min_theta z_min = np.cos(np.radians(max_theta)) # z at max_theta + phi_span = _phi_span(min_phi, max_phi) # Generate fibonacci points within the constrained Z range ga = (3 - np.sqrt(5)) * np.pi # golden angle + golden_ratio_conjugate = (np.sqrt(5) - 1) / 2 points = [] for i in range(num_points): @@ -149,12 +199,9 @@ def _generate_constrained_fibonacci(num_points: int, min_theta: float, max_theta # Convert to polar coordinates r = 1.0 # unit sphere - theta = np.degrees(np.arccos(z)) - fi = np.degrees(np.arctan2(y, x)) - - # Ensure fi is in 0-360 range - if fi < 0: - fi += 360 + theta = float(np.clip(np.degrees(np.arccos(z)), min_theta, max_theta)) + phi_fraction = (i * golden_ratio_conjugate) % 1 + fi = float((min_phi + phi_span * phi_fraction) % 360) points.append(PolarPoint3D(theta, fi, r)) @@ -188,4 +235,4 @@ def get_path(num_points: int) -> list[CartesianPoint3D]: y = radius * np.sin(theta) x = radius * np.cos(theta) - return [CartesianPoint3D(x[i], y[i], z[i]) for i in range(len(z))] \ No newline at end of file + return [CartesianPoint3D(x[i], y[i], z[i]) for i in range(len(z))] diff --git a/tests/config/test_scan_config.py b/tests/config/test_scan_config.py new file mode 100644 index 0000000..e01dad0 --- /dev/null +++ b/tests/config/test_scan_config.py @@ -0,0 +1,33 @@ +from openscan_firmware.config.external_trigger_run import ExternalTriggerRunSettings +from openscan_firmware.config.scan import ScanSetting + + +def test_scan_settings_omit_unset_phi_fields_from_json_dump() -> None: + settings = ScanSetting() + + payload = settings.model_dump(mode="json") + + assert "min_phi" not in payload + assert "max_phi" not in payload + + +def test_external_trigger_run_settings_omit_unset_phi_fields_from_json_dump() -> None: + settings = ExternalTriggerRunSettings(trigger_name="external-camera") + + payload = settings.model_dump(mode="json") + + assert "min_phi" not in payload + assert "max_phi" not in payload + + +def test_external_trigger_run_settings_transfer_optional_phi_values() -> None: + settings = ExternalTriggerRunSettings( + trigger_name="external-camera", + min_phi=45.0, + max_phi=135.0, + ) + + scan_settings = settings.to_scan_settings() + + assert scan_settings.min_phi == 45.0 + assert scan_settings.max_phi == 135.0 diff --git a/tests/controllers/services/test_scan_task.py b/tests/controllers/services/test_scan_task.py index 38b9b2f..c4ca363 100644 --- a/tests/controllers/services/test_scan_task.py +++ b/tests/controllers/services/test_scan_task.py @@ -21,7 +21,8 @@ from openscan_firmware.config.motor import MotorConfig from openscan_firmware.models.camera import PhotoData from openscan_firmware.models.camera import CameraMetadata -from openscan_firmware.controllers.services.tasks.core.scan_task import ScanTask, ScanRuntime +from openscan_firmware.controllers.services.tasks.core.scan_task import ScanTask, ScanRuntime, generate_scan_path +from openscan_firmware.models.paths import PathMethod class FocusTrackingSettings: @@ -194,6 +195,62 @@ async def delayed_add_photo(*args, **kwargs): mock_generate_scan_path.return_value, ) + +def test_generate_scan_path_omits_optional_phi_constraints_when_unset() -> None: + scan_settings = ScanSetting( + path_method=PathMethod.FIBONACCI, + points=10, + min_theta=10.0, + max_theta=120.0, + optimize_path=False, + focus_stacks=1, + focus_range=(10.0, 15.0), + image_format="jpeg", + ) + + with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task.paths.get_constrained_path", + return_value=[], + ) as get_constrained_path: + generate_scan_path(scan_settings) + + assert get_constrained_path.call_args.kwargs == { + "method": PathMethod.FIBONACCI, + "num_points": 10, + "min_theta": 10.0, + "max_theta": 120.0, + } + + +def test_generate_scan_path_passes_optional_phi_constraints_when_set() -> None: + scan_settings = ScanSetting( + path_method=PathMethod.FIBONACCI, + points=10, + min_theta=10.0, + max_theta=120.0, + min_phi=45.0, + max_phi=180.0, + optimize_path=False, + focus_stacks=1, + focus_range=(10.0, 15.0), + image_format="jpeg", + ) + + with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task.paths.get_constrained_path", + return_value=[], + ) as get_constrained_path: + generate_scan_path(scan_settings) + + assert get_constrained_path.call_args.kwargs == { + "method": PathMethod.FIBONACCI, + "num_points": 10, + "min_theta": 10.0, + "max_theta": 120.0, + "min_phi": 45.0, + "max_phi": 180.0, + } + @pytest.mark.asyncio @patch('openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller') @patch('openscan_firmware.controllers.services.tasks.core.scan_task.get_project_manager') @@ -1063,4 +1120,4 @@ async def test_scan_json_persistence_with_focus_stacking( assert mock_camera_controller.settings.AF is True # Should be restored # Verify save_scan_state was called - assert mock_save.call_count >= test_positions \ No newline at end of file + assert mock_save.call_count >= test_positions diff --git a/tests/routers/test_scan_settings_openapi.py b/tests/routers/test_scan_settings_openapi.py new file mode 100644 index 0000000..f46cc09 --- /dev/null +++ b/tests/routers/test_scan_settings_openapi.py @@ -0,0 +1,28 @@ +from openscan_firmware.main import make_version_app + + +def _schema_properties(schema: dict, schema_name: str) -> tuple[dict, list]: + component = schema["components"]["schemas"][schema_name] + return component["properties"], component.get("required", []) + + +def test_next_projects_openapi_exposes_optional_phi_scan_settings() -> None: + schema = make_version_app("next").openapi() + + properties, required = _schema_properties(schema, "ScanSetting") + + assert "min_phi" in properties + assert "max_phi" in properties + assert "min_phi" not in required + assert "max_phi" not in required + + +def test_next_external_trigger_openapi_exposes_optional_phi_settings() -> None: + schema = make_version_app("next").openapi() + + properties, required = _schema_properties(schema, "ExternalTriggerRunSettings") + + assert "min_phi" in properties + assert "max_phi" in properties + assert "min_phi" not in required + assert "max_phi" not in required From 6139c8c75672b78f814671ce75f96a0b62c3e490 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 14 Apr 2026 16:20:53 +0200 Subject: [PATCH 50/75] feat(scanner): introduce configurable scan radius support - Added `scan_radius_mm` parameter to scanner configuration models with a default value of 1.0mm. - Integrated scan radius to enforce radius constraints in path generation. - Updated device endpoints and status responses to include scan radius. - Added legacy config fallback to default scan radius. - Implemented new test cases validating scan radius behaviors and defaults. --- openscan_firmware/controllers/device.py | 9 +++++++ .../services/tasks/core/scan_task.py | 17 ++++++++++++ openscan_firmware/models/scanner.py | 9 +++++++ openscan_firmware/routers/next/device.py | 4 ++- openscan_firmware/routers/v0_8/device.py | 2 ++ openscan_firmware/routers/v0_9/device.py | 4 ++- tests/controllers/services/test_scan_task.py | 16 +++++++++--- tests/controllers/test_device_controller.py | 26 +++++++++++++++++++ 8 files changed, 81 insertions(+), 6 deletions(-) diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index 6e71744..2491697 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -115,6 +115,7 @@ def _create_default_scanner_device() -> ScannerDevice: lights={}, triggers={}, endstops={}, + scan_radius_mm=1.0, ).model_dump(mode="json") # Path to device configuration file (persisted) @@ -144,6 +145,7 @@ def _runtime_to_persisted_config() -> ScannerDeviceConfig: for name, endstop in _scanner_device.endstops.items() }, motors_timeout=_scanner_device.motors_timeout, + scan_radius_mm=_scanner_device.scan_radius_mm, startup_mode=_scanner_device.startup_mode.value if _scanner_device.startup_mode else None, calibrate_mode=_scanner_device.calibrate_mode.value if _scanner_device.calibrate_mode else None, ) @@ -269,6 +271,7 @@ def get_device_info(): "triggers": {name: controller.get_status() for name, controller in get_all_trigger_controllers().items()}, "motors_timeout": _scanner_device.motors_timeout, + "scan_radius_mm": _scanner_device.scan_radius_mm, "startup_mode": _scanner_device.startup_mode, "calibrate_mode": _scanner_device.calibrate_mode, @@ -695,6 +698,7 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam # motors timeout in seconds - 0 to disable motors_timeout=config_dict["motors_timeout"], + scan_radius_mm=config_dict["scan_radius_mm"], startup_mode=config_dict["startup_mode"], calibrate_mode=config_dict["calibrate_mode"], @@ -729,6 +733,11 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam schedule_device_status_broadcast() +def get_scan_radius_mm() -> float: + """Return the configured scan radius in millimeters.""" + return float(_scanner_device.scan_radius_mm) + + def get_available_configs(): """Get a list of all available device configuration files diff --git a/openscan_firmware/controllers/services/tasks/core/scan_task.py b/openscan_firmware/controllers/services/tasks/core/scan_task.py index 72c6878..ee1eb9b 100644 --- a/openscan_firmware/controllers/services/tasks/core/scan_task.py +++ b/openscan_firmware/controllers/services/tasks/core/scan_task.py @@ -31,6 +31,22 @@ logger = logging.getLogger(__name__) +def _get_scan_radius_mm() -> float: + """Return the active device scan radius, falling back to unit radius.""" + try: + from openscan_firmware.controllers import device as device_controller + + return device_controller.get_scan_radius_mm() + except Exception: + logger.debug("Falling back to default scan radius 1.0 mm", exc_info=True) + return 1.0 + + +def _apply_scan_radius(points: list[PolarPoint3D], radius_mm: float) -> list[PolarPoint3D]: + """Apply a shared radius to all generated polar path points.""" + return [PolarPoint3D(theta=point.theta, fi=point.fi, r=radius_mm) for point in points] + + def generate_scan_path(scan_settings: ScanSetting) -> dict[PolarPoint3D, int]: """Generate scan path based on settings with optional optimization. @@ -54,6 +70,7 @@ def generate_scan_path(scan_settings: ScanSetting) -> dict[PolarPoint3D, int]: path_kwargs["max_phi"] = scan_settings.max_phi path = paths.get_constrained_path(**path_kwargs) + path = _apply_scan_radius(path, _get_scan_radius_mm()) logger.debug("Generated Fibonacci path with %d points", len(path)) else: logger.error("Unknown path method %s", scan_settings.path_method) diff --git a/openscan_firmware/models/scanner.py b/openscan_firmware/models/scanner.py index 61baf49..15177bd 100644 --- a/openscan_firmware/models/scanner.py +++ b/openscan_firmware/models/scanner.py @@ -49,6 +49,10 @@ class ScannerDevice(BaseModel): # motors timeout in seconds - 0 to disable motors_timeout: float = 0.0 + scan_radius_mm: float = Field( + default=1.0, + description="Distance in millimeters between the camera lens and the turntable center point.", + ) startup_mode: ScannerStartupMode = ScannerStartupMode.STARTUP_ENABLED calibrate_mode: ScannerCalibrateMode = ScannerCalibrateMode.CALIBRATE_MANUAL @@ -81,5 +85,10 @@ class ScannerDeviceConfig(BaseModel): triggers: dict[str, TriggerConfig] = Field(default_factory=dict) endstops: dict[str, PersistedEndstopConfig] | None = None motors_timeout: float = 0.0 + scan_radius_mm: float = Field( + default=1.0, + gt=0.0, + description="Distance in millimeters between the camera lens and the turntable center point.", + ) startup_mode: ScannerStartupMode | str = ScannerStartupMode.STARTUP_ENABLED calibrate_mode: ScannerCalibrateMode | str = ScannerCalibrateMode.CALIBRATE_MANUAL diff --git a/openscan_firmware/routers/next/device.py b/openscan_firmware/routers/next/device.py index fe83d54..a61a50f 100644 --- a/openscan_firmware/routers/next/device.py +++ b/openscan_firmware/routers/next/device.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException, UploadFile, File from pydantic import BaseModel, Field, ValidationError +from typing import Any from pathlib import Path import os import json @@ -37,6 +38,7 @@ class DeviceStatusResponse(BaseModel): lights: dict[str, LightStatusResponse] triggers: dict[str, TriggerStatusResponse] = Field(default_factory=dict) motors_timeout: float + scan_radius_mm: float = 1.0 startup_mode: ScannerStartupMode calibrate_mode: ScannerCalibrateMode initialized: bool @@ -51,7 +53,7 @@ class DeviceConfigResponse(BaseModel): status: str filename: str path: str - config: ScannerDeviceConfig + config: dict[str, Any] def _runtime_status_response() -> DeviceStatusResponse: diff --git a/openscan_firmware/routers/v0_8/device.py b/openscan_firmware/routers/v0_8/device.py index d10b318..bd1dd74 100644 --- a/openscan_firmware/routers/v0_8/device.py +++ b/openscan_firmware/routers/v0_8/device.py @@ -38,6 +38,7 @@ class DeviceStatusResponse(BaseModel): motors: dict[str, MotorStatusResponse] lights: dict[str, LightStatusResponse] motors_timeout: float + scan_radius_mm: float = 1.0 startup_mode: ScannerStartupMode calibrate_mode: ScannerCalibrateMode initialized: bool @@ -78,6 +79,7 @@ def _v08_payload_to_persisted_config(config_data: ScannerDevice) -> ScannerDevic for name, endstop in (config_data.endstops or {}).items() }, motors_timeout=config_data.motors_timeout, + scan_radius_mm=config_data.scan_radius_mm, startup_mode=config_data.startup_mode.value if config_data.startup_mode else None, calibrate_mode=config_data.calibrate_mode.value if config_data.calibrate_mode else None, ) diff --git a/openscan_firmware/routers/v0_9/device.py b/openscan_firmware/routers/v0_9/device.py index d1dc1eb..3f14eb5 100644 --- a/openscan_firmware/routers/v0_9/device.py +++ b/openscan_firmware/routers/v0_9/device.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException, UploadFile, File from pydantic import BaseModel, ValidationError +from typing import Any from pathlib import Path import os import json @@ -35,6 +36,7 @@ class DeviceStatusResponse(BaseModel): motors: dict[str, MotorStatusResponse] lights: dict[str, LightStatusResponse] motors_timeout: float + scan_radius_mm: float = 1.0 startup_mode: ScannerStartupMode calibrate_mode: ScannerCalibrateMode initialized: bool @@ -49,7 +51,7 @@ class DeviceConfigResponse(BaseModel): status: str filename: str path: str - config: ScannerDeviceConfig + config: dict[str, Any] def _runtime_status_response() -> DeviceStatusResponse: diff --git a/tests/controllers/services/test_scan_task.py b/tests/controllers/services/test_scan_task.py index c4ca363..62a4b7a 100644 --- a/tests/controllers/services/test_scan_task.py +++ b/tests/controllers/services/test_scan_task.py @@ -209,10 +209,13 @@ def test_generate_scan_path_omits_optional_phi_constraints_when_unset() -> None: ) with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task._get_scan_radius_mm", + return_value=250.0, + ), patch( "openscan_firmware.controllers.services.tasks.core.scan_task.paths.get_constrained_path", - return_value=[], + return_value=[PolarPoint3D(theta=10.0, fi=20.0)], ) as get_constrained_path: - generate_scan_path(scan_settings) + path_dict = generate_scan_path(scan_settings) assert get_constrained_path.call_args.kwargs == { "method": PathMethod.FIBONACCI, @@ -220,6 +223,7 @@ def test_generate_scan_path_omits_optional_phi_constraints_when_unset() -> None: "min_theta": 10.0, "max_theta": 120.0, } + assert list(path_dict.keys()) == [PolarPoint3D(theta=10.0, fi=20.0, r=250.0)] def test_generate_scan_path_passes_optional_phi_constraints_when_set() -> None: @@ -237,10 +241,13 @@ def test_generate_scan_path_passes_optional_phi_constraints_when_set() -> None: ) with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task._get_scan_radius_mm", + return_value=250.0, + ), patch( "openscan_firmware.controllers.services.tasks.core.scan_task.paths.get_constrained_path", - return_value=[], + return_value=[PolarPoint3D(theta=10.0, fi=20.0)], ) as get_constrained_path: - generate_scan_path(scan_settings) + path_dict = generate_scan_path(scan_settings) assert get_constrained_path.call_args.kwargs == { "method": PathMethod.FIBONACCI, @@ -250,6 +257,7 @@ def test_generate_scan_path_passes_optional_phi_constraints_when_set() -> None: "min_phi": 45.0, "max_phi": 180.0, } + assert list(path_dict.keys()) == [PolarPoint3D(theta=10.0, fi=20.0, r=250.0)] @pytest.mark.asyncio @patch('openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller') diff --git a/tests/controllers/test_device_controller.py b/tests/controllers/test_device_controller.py index 58a632e..33b20f6 100644 --- a/tests/controllers/test_device_controller.py +++ b/tests/controllers/test_device_controller.py @@ -118,6 +118,7 @@ def test_save_device_config_writes_json(tmp_path, monkeypatch, data = json.loads(tmp_file.read_text()) assert data["name"] == "TestDevice" + assert data["scan_radius_mm"] == 1.0 assert "cam1" in data["cameras"] assert isinstance(data["cameras"]["cam1"], dict) assert data["cameras"]["cam1"]["settings"].get("shutter") == 123 @@ -146,6 +147,7 @@ def get_status(self): info = device.get_device_info() assert info["name"] == "X" + assert info["scan_radius_mm"] == 1.0 assert "cam" in info["cameras"] and info["cameras"]["cam"]["ok"] is True assert "rotor" in info["motors"] and info["motors"]["rotor"]["ok"] is True assert "ring" in info["lights"] and info["lights"]["ring"]["ok"] is True @@ -229,10 +231,34 @@ async def fake_initialize(config, detect_cameras=False): persisted = json.loads(config_file.read_text()) assert persisted["name"] == "Preset" assert persisted["motors_timeout"] == 5.0 + assert persisted["scan_radius_mm"] == 1.0 assert persisted["startup_mode"] == device.ScannerStartupMode.STARTUP_IDLE.value assert persisted["calibrate_mode"] == device.ScannerCalibrateMode.CALIBRATE_ON_WAKE.value +def test_load_device_config_uses_default_scan_radius_for_legacy_config(monkeypatch, tmp_path): + device = _import_device(monkeypatch) + + config_path = tmp_path / "device_config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(device, "DEVICE_CONFIG_FILE", config_path) + + preset = tmp_path / "legacy_preset.json" + preset.write_text(json.dumps({ + "name": "Legacy", + "model": "mini", + "shield": "greenshield", + "cameras": {}, + "motors": {}, + "lights": {}, + "endstops": {}, + })) + + loaded = device.load_device_config(str(preset)) + + assert loaded["scan_radius_mm"] == 1.0 + + def _write_minimal_preset(target: Path): content = { "name": "Preset", From cce7d0336c357fe119b432e824980bba22e93333 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 15 Apr 2026 11:59:27 +0200 Subject: [PATCH 51/75] docs: update URLs and setup instructions in DEVELOP.md and README.md - Replaced outdated hostname references (`openscan3-alpha`) with `openscan`. - Updated Raspberry Pi Image installation steps for improved clarity. - Simplified and corrected links to web frontend and API documentation. - Removed deprecated "Roadmap" section from README.md. --- README.md | 46 ++++++++++++---------------------------------- docs/DEVELOP.md | 4 ++-- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a600054..16b48ac 100644 --- a/README.md +++ b/README.md @@ -22,23 +22,22 @@ There are two ways to get started on a Raspberry Pi: flash a ready-made image or ## Install OpenScan Image (Recommended) -Download the image from here: https://openscan.eu/pages/resources-downloads +> **Note:** Advanced customization (hostname, user, Wi‑Fi, etc.) is confirmed to work with Raspberry Pi Imager > 2.0. +> Older versions may not apply the customizations properly. -Choose the image according to your camera variant: +1. Open Raspberry Pi Imager (>=2.0.6). +2. Click **ADD OPTIONS** -> Click **EDIT** Content Repository -> Use custom URL and paste `https://openscan.eu/rpi-repo.json` -> Click **Apply and restart** +3. Choose your Raspberry Pi device +4. Select the image according to your camera variant. **IMPORTANT**: Ensure the image matches your camera model. Choosing the wrong image may result in permanent hardware damage. +5. Select the storage device to write the image to. +6. Modify configuration options if needed (hostname, user, Wi‑Fi, etc.) via the Raspberry Pi Imager interface. +7. Write the image. Eject the card and insert it into the Pi. -- Arducam IMX519 -- Arducam Hawkeye -- Generic (Picamera Module 3) +**Default Hostname:** `openscan3` (or `openscan.local` if mDNS is enabled) -> Warning: Choosing the wrong image may result in permanent damage to your camera! +**UI (Webfrontend):** http://openscan/ or http://openscan.local/ -Flash the image with Raspberry Pi Imager or a similar tool. - -**Default Hostname:** `openscan3-alpha` (or `openscan3-alpha.local` if mDNS is enabled) - -**UI (Webfrontend):** http://openscan3-alpha/ or http://openscan3-alpha.local/ - -**API documentation:** http://openscan3-alpha/api/latest/docs. +**API documentation:** http://openscan/api/latest/docs. ## Build OpenScan Image from Source @@ -52,27 +51,6 @@ See [`docs/DEVELOP.md`](docs/DEVELOP.md) for development setup, first steps, and

(back to top)

- -## Roadmap - -### Beta (February 2026) -- [x] WebSockets for tasks, device state, and scan progress -- [ ] OS/device services: Samba, USB, disk monitoring; camera-assisted Wi‑Fi/setup -- [x] Reliability: improved handling for Arducam Hawkeye 64MP memory issues -- [x] Frontend improvements ([OpenScan3-client](https://github.com/OpenScan-org/OpenScan3-client)) - - -### Release (May 2026) -- Turntable Mode as a ScanTask -- Enhanced hardware support - - grblHAL - - More Hardware controllers: displays, fans, buttons - - Camera & capture: DSLR focus motor; broader camera support (PiCamera, DSLR via gphoto2, smartphones, external GPIO) -- Project export: Metashape, RealityCapture, 3DF Zephyr, Meshroom -- Automation: rsync-based project sync; new task features (auto-config via photo, background removal, drop detection) - -### Future -- Further extend hardware support and hackability to use as base for photogrammetry rigs For details and up-to-date status, see GitHub issues and check out the Discord channel. diff --git a/docs/DEVELOP.md b/docs/DEVELOP.md index bb246e2..6571283 100644 --- a/docs/DEVELOP.md +++ b/docs/DEVELOP.md @@ -70,13 +70,13 @@ There are three ways to load a configuration: **Method 1: Using the SPA client (Recommended)** -1. If you booted from the official OpenScan image, the bundled SPA client is available at `http://openscan3-alpha`. +1. If you booted from the official OpenScan image, the bundled SPA client is available at `http://openscan` (or `http://openscan.local`). 2. Open the page in a browser on the same network; the guided setup wizard walks you through selecting the correct hardware profile. 3. Confirm the suggested configuration; the SPA will push it to the firmware and trigger any required reloads automatically. **Method 2: Using the API docs** -1. Navigate to the API documentation at `http://openscan3-alpha:8000/latest/docs`. +1. Navigate to the API documentation at `http://openscan:8000/latest/docs`. 2. Find the **Device** Section and the **PUT** endpoint `/latest/device/configurations/current`. 3. Use the "Try it out" feature. 4. In the **Request body**, enter the name of the configuration file you want to load. For example, for an OpenScan Mini with a Greenshield, use: From ae991cda27eec875862bf409587f49e4cdc6ad04 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 15 Apr 2026 12:01:20 +0200 Subject: [PATCH 52/75] docs: remove redundant "back to top" link from README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 16b48ac..5f3f313 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,6 @@ You can also use [OpenScan3 Pi Image Builder](https://github.com/esto-openscan/O See [`docs/DEVELOP.md`](docs/DEVELOP.md) for development setup, first steps, and architectural overview. -

(back to top)

- For details and up-to-date status, see GitHub issues and check out the Discord channel. From 0cd75e399dcb708426b31fcad96e42ade2821293 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 15 Apr 2026 12:34:06 +0200 Subject: [PATCH 53/75] docs: update default hostname in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f3f313..33a55f2 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ There are two ways to get started on a Raspberry Pi: flash a ready-made image or 6. Modify configuration options if needed (hostname, user, Wi‑Fi, etc.) via the Raspberry Pi Imager interface. 7. Write the image. Eject the card and insert it into the Pi. -**Default Hostname:** `openscan3` (or `openscan.local` if mDNS is enabled) +**Default Hostname:** `openscan` (or `openscan.local` if mDNS is enabled) **UI (Webfrontend):** http://openscan/ or http://openscan.local/ From ae9813c2b11b318b6200c3d4f82c56d938c7826f Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 15 Apr 2026 17:20:10 +0200 Subject: [PATCH 54/75] chore: bump project version to 0.11.2 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ff4a7f6..86fa536 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openscan-firmware" -version = "0.11.1" +version = "0.11.2" description = "OpenScan3 - Raspberry Pi based photogrammetry scanner (FastAPI-based application)" readme = "README.md" requires-python = ">=3.11" From b1808d11fd4b1bc64cb366b4af159d9d4fea9471 Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 16 Apr 2026 11:52:51 +0200 Subject: [PATCH 55/75] feat(schema): update device config and trigger settings - Consolidated `ScannerDeviceConfig` schema by removing separate `Input` and `Output` definitions. - Added `camera_preview_enabled` toggle for non-live camera workflows. - Introduced `scan_radius_mm` and `min_phi`/`max_phi` properties in calibration models. - Replaced `TriggerPolarity` with `TriggerActiveLevel` standard enum. - Enhanced `FirmwareSettings` schema with additional attributes. --- scripts/openapi/openapi_latest.json | 183 +- scripts/openapi/openapi_next.json | 2769 +++++++++++++-------------- scripts/openapi/openapi_v0.8.json | 63 +- scripts/openapi/openapi_v0.9.json | 183 +- 4 files changed, 1544 insertions(+), 1654 deletions(-) diff --git a/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index ec722fd..1eb2e5e 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -4500,7 +4500,7 @@ "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { - "$ref": "#/components/schemas/ScannerDeviceConfig-Input" + "$ref": "#/components/schemas/ScannerDeviceConfig" }, "filename": { "$ref": "#/components/schemas/DeviceConfigRequest" @@ -5110,7 +5110,9 @@ "title": "Path" }, "config": { - "$ref": "#/components/schemas/ScannerDeviceConfig-Output" + "additionalProperties": true, + "type": "object", + "title": "Config" } }, "type": "object", @@ -5197,6 +5199,11 @@ "type": "number", "title": "Motors Timeout" }, + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "default": 1.0 + }, "startup_mode": { "$ref": "#/components/schemas/ScannerStartupMode" }, @@ -5400,11 +5407,17 @@ "title": "Enable Cloud", "description": "Enable integrations with OpenScan Cloud services.", "default": false + }, + "camera_preview_enabled": { + "type": "boolean", + "title": "Camera Preview Enabled", + "description": "Expose camera preview-oriented workflows. Disable for trigger-only systems without a live camera feed.", + "default": true } }, "type": "object", "title": "FirmwareSettings", - "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances." + "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances.\n camera_preview_enabled: When False the system is expected to operate\n without a live camera preview workflow, for example on trigger-only\n DSLR setups." }, "HTTPValidationError": { "properties": { @@ -5980,6 +5993,34 @@ "description": "Maximum theta angle in degrees for constrained paths.", "default": 125.0 }, + "min_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Min Phi", + "description": "Optional minimum phi angle in degrees for constrained paths." + }, + "max_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Phi", + "description": "Optional maximum phi angle in degrees for constrained paths." + }, "optimize_path": { "type": "boolean", "title": "Optimize Path", @@ -6037,7 +6078,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDeviceConfig-Input": { + "ScannerDeviceConfig": { "properties": { "name": { "type": "string", @@ -6112,112 +6153,12 @@ "title": "Motors Timeout", "default": 0.0 }, - "startup_mode": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScannerStartupMode" - }, - { - "type": "string" - } - ], - "title": "Startup Mode", - "default": "startup_enabled" - }, - "calibrate_mode": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScannerCalibrateMode" - }, - { - "type": "string" - } - ], - "title": "Calibrate Mode", - "default": "calibrate_manual" - } - }, - "type": "object", - "required": [ - "name" - ], - "title": "ScannerDeviceConfig", - "description": "Persisted scanner configuration payload stored as JSON." - }, - "ScannerDeviceConfig-Output": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Model" - }, - "shield": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Shield" - }, - "cameras": { - "additionalProperties": { - "$ref": "#/components/schemas/PersistedCameraConfig" - }, - "type": "object", - "title": "Cameras" - }, - "motors": { - "additionalProperties": { - "$ref": "#/components/schemas/MotorConfig" - }, - "type": "object", - "title": "Motors" - }, - "lights": { - "additionalProperties": { - "$ref": "#/components/schemas/LightConfig" - }, - "type": "object", - "title": "Lights" - }, - "triggers": { - "additionalProperties": { - "$ref": "#/components/schemas/TriggerConfig" - }, - "type": "object", - "title": "Triggers" - }, - "endstops": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/components/schemas/PersistedEndstopConfig" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Endstops" - }, - "motors_timeout": { + "scan_radius_mm": { "type": "number", - "title": "Motors Timeout", - "default": 0.0 + "exclusiveMinimum": 0.0, + "title": "Scan Radius Mm", + "description": "Distance in millimeters between the camera lens and the turntable center point.", + "default": 1.0 }, "startup_mode": { "anyOf": [ @@ -6500,6 +6441,14 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerActiveLevel": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerActiveLevel" + }, "TriggerConfig": { "properties": { "enabled": { @@ -6514,9 +6463,9 @@ "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.", + "active_level": { + "$ref": "#/components/schemas/TriggerActiveLevel", + "description": "Defines which logic level is considered active. The idle level is the inverse.", "default": "active_high" }, "pulse_width_ms": { @@ -6524,7 +6473,7 @@ "maximum": 5000.0, "minimum": 1.0, "title": "Pulse Width Ms", - "description": "How long the trigger line stays active for each trigger pulse.", + "description": "How long the trigger line stays active for each trigger pulse in ms.", "default": 100 } }, @@ -6534,14 +6483,6 @@ ], "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 d797a93..febb3f8 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -536,91 +536,51 @@ } } }, - "/external-trigger/runs/": { + "/motors/": { "get": { "tags": [ - "external-trigger" + "motors" ], - "summary": "List External Trigger Runs", - "operationId": "list_external_trigger_runs", + "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": { - "items": { - "$ref": "#/components/schemas/Task" + "additionalProperties": { + "$ref": "#/components/schemas/MotorStatusResponse" }, - "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" + "type": "object", + "title": "Response Get Motors Motors Get" } } } }, "404": { "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } } } } }, - "/external-trigger/runs/{task_id}": { + "/motors/{motor_name}": { "get": { "tags": [ - "external-trigger" + "motors" ], - "summary": "Get External Trigger Run", - "operationId": "get_external_trigger_run", + "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": "task_id", + "name": "motor_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Task Id" + "title": "Motor Name" } } ], @@ -630,7 +590,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "$ref": "#/components/schemas/MotorStatusResponse" } } } @@ -651,21 +611,31 @@ } } }, - "/external-trigger/runs/{task_id}/path": { - "get": { + "/motors/{motor_name}/angle": { + "put": { "tags": [ - "external-trigger" + "motors" ], - "summary": "Get External Trigger Run Path", - "operationId": "get_external_trigger_run_path", + "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": "task_id", + "name": "motor_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Task Id" + "title": "Motor Name" + } + }, + { + "name": "degrees", + "in": "query", + "required": true, + "schema": { + "type": "number", + "title": "Degrees" } } ], @@ -675,7 +645,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExternalTriggerRunPath" + "$ref": "#/components/schemas/MotorStatusResponse" } } } @@ -694,33 +664,42 @@ } } } - } - }, - "/external-trigger/runs/{task_id}/cancel": { + }, "patch": { "tags": [ - "external-trigger" + "motors" ], - "summary": "Cancel External Trigger Run Endpoint", - "operationId": "cancel_external_trigger_run_endpoint", + "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": "task_id", + "name": "motor_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Task Id" + "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/Task" + "$ref": "#/components/schemas/MotorStatusResponse" } } } @@ -741,22 +720,35 @@ } } }, - "/external-trigger/runs/{task_id}/pause": { - "patch": { + "/motors/{motor_name}/angle-override": { + "put": { "tags": [ - "external-trigger" + "motors" ], - "summary": "Pause External Trigger Run Endpoint", - "operationId": "pause_external_trigger_run_endpoint", + "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": "task_id", + "name": "motor_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Task Id" + "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": { @@ -765,7 +757,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "$ref": "#/components/schemas/MotorStatusResponse" } } } @@ -786,22 +778,35 @@ } } }, - "/external-trigger/runs/{task_id}/resume": { - "patch": { + "/motors/{motor_name}/endstop-calibration": { + "put": { "tags": [ - "external-trigger" + "motors" ], - "summary": "Resume External Trigger Run Endpoint", - "operationId": "resume_external_trigger_run_endpoint", + "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": "task_id", + "name": "motor_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Task Id" + "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": { @@ -810,7 +815,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "$ref": "#/components/schemas/MotorStatusResponse" } } } @@ -831,43 +836,14 @@ } } }, - "/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", - "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": { + "/motors/{motor_name}/home": { + "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 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", @@ -906,31 +882,22 @@ } } }, - "/motors/{motor_name}/angle": { - "put": { + "/motors/{name}/settings": { + "get": { "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": "Get Motor Name Settings", + "description": "Get settings for a specific resource", + "operationId": "get_motor_name_settings", "parameters": [ { - "name": "motor_name", + "name": "name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Motor Name" - } - }, - { - "name": "degrees", - "in": "query", - "required": true, - "schema": { - "type": "number", - "title": "Degrees" + "title": "Name" } } ], @@ -940,7 +907,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/MotorConfig" } } } @@ -960,21 +927,21 @@ } } }, - "patch": { + "put": { "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", + "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": "Motor Name" + "title": "Name" } } ], @@ -983,7 +950,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Body_move_motor_by_degree_motors__motor_name__angle_patch" + "$ref": "#/components/schemas/MotorConfig" } } } @@ -994,7 +961,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/MotorConfig" } } } @@ -1013,37 +980,115 @@ } } } - } - }, - "/motors/{motor_name}/angle-override": { - "put": { + }, + "patch": { "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", + "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": "motor_name", + "name": "name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Motor Name" + "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": "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": "angle", - "in": "query", - "required": false, + "name": "light_name", + "in": "path", + "required": true, "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." + "type": "string", + "title": "Light Name" + } } ], "responses": { @@ -1052,7 +1097,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/LightStatusResponse" } } } @@ -1073,35 +1118,69 @@ } } }, - "/motors/{motor_name}/endstop-calibration": { - "put": { + "/lights/{light_name}/turn_on": { + "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 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" + } + } + ], + "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_off": { + "patch": { + "tags": [ + "lights" + ], + "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": "force", - "in": "query", - "required": false, + "name": "light_name", + "in": "path", + "required": true, "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." + "type": "string", + "title": "Light Name" + } } ], "responses": { @@ -1110,7 +1189,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/LightStatusResponse" } } } @@ -1131,22 +1210,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" } } ], @@ -1156,7 +1235,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorStatusResponse" + "$ref": "#/components/schemas/LightStatusResponse" } } } @@ -1177,14 +1256,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", @@ -1202,7 +1281,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorConfig" + "$ref": "#/components/schemas/LightConfig" } } } @@ -1224,11 +1303,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", @@ -1245,7 +1324,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorConfig" + "$ref": "#/components/schemas/LightConfig" } } } @@ -1256,7 +1335,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorConfig" + "$ref": "#/components/schemas/LightConfig" } } } @@ -1278,11 +1357,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", @@ -1317,7 +1396,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MotorConfig" + "$ref": "#/components/schemas/LightConfig" } } } @@ -1338,25 +1417,21 @@ } } }, - "/lights/": { + "/firmware/settings": { "get": { "tags": [ - "lights" + "firmware" ], - "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 Settings", + "description": "Return persisted firmware settings.", + "operationId": "get_settings", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": { - "$ref": "#/components/schemas/LightStatusResponse" - }, - "type": "object", - "title": "Response Get Lights Lights Get" + "$ref": "#/components/schemas/FirmwareSettings" } } } @@ -1365,34 +1440,31 @@ "description": "Not found" } } - } - }, - "/lights/{light_name}": { - "get": { + }, + "put": { "tags": [ - "lights" + "firmware" ], - "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" + "summary": "Replace Settings", + "description": "Replace the entire firmware settings payload.", + "operationId": "replace_settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettings" + } } - } - ], + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightStatusResponse" + "$ref": "#/components/schemas/FirmwareSettings" } } } @@ -1413,32 +1485,42 @@ } } }, - "/lights/{light_name}/turn_on": { + "/firmware/settings/{key}": { "patch": { "tags": [ - "lights" + "firmware" ], - "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": "Update Setting", + "description": "Update a single firmware settings key.", + "operationId": "update_setting", "parameters": [ { - "name": "light_name", + "name": "key", "in": "path", "required": true, "schema": { "type": "string", - "title": "Light Name" + "title": "Key" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FirmwareSettingPatchRequest" + } + } + } + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightStatusResponse" + "$ref": "#/components/schemas/FirmwareSettings" } } } @@ -1459,22 +1541,51 @@ } } }, - "/lights/{light_name}/turn_off": { - "patch": { + "/projects/": { + "get": { "tags": [ - "lights" + "projects" ], - "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": "Get Projects", + "description": "Get all projects with serialized data\n\nReturns:\n dict[str, Project]: A dictionary of project name to a project object", + "operationId": "get_projects", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "$ref": "#/components/schemas/Project" + }, + "type": "object", + "title": "Response Get Projects Projects Get" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/projects/{project_name}": { + "get": { + "tags": [ + "projects" + ], + "summary": "Get Project", + "description": "Get a project\n\nArgs:\n project_name: The name of the project to get\n\nReturns:\n Project: The project object if found, None if not", + "operationId": "get_project", "parameters": [ { - "name": "light_name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Light Name" + "title": "Project Name" } } ], @@ -1484,7 +1595,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightStatusResponse" + "$ref": "#/components/schemas/Project" } } } @@ -1503,24 +1614,39 @@ } } } - } - }, - "/lights/{light_name}/toggle": { - "patch": { + }, + "post": { "tags": [ - "lights" + "projects" ], - "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", + "summary": "New Project", + "description": "Create a new project\n\nArgs:\n project_name: The name of the project to create\n project_description: Optional description for the project\n\nReturns:\n Project: The newly created project if successful, None if not", + "operationId": "new_project", "parameters": [ { - "name": "light_name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Light Name" + "title": "Project Name" + } + }, + { + "name": "project_description", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "title": "Project Description" } } ], @@ -1530,7 +1656,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightStatusResponse" + "$ref": "#/components/schemas/Project" } } } @@ -1549,24 +1675,22 @@ } } } - } - }, - "/lights/{name}/settings": { - "get": { + }, + "delete": { "tags": [ - "lights" + "projects" ], - "summary": "Get Light Name Settings", - "description": "Get settings for a specific resource", - "operationId": "get_light_name_settings", + "summary": "Delete Project", + "description": "Delete a project\n\nArgs:\n project_name: The name of the project to delete\n\nReturns:\n DeleteResponse: A response object containing the result of the deletion", + "operationId": "delete_project", "parameters": [ { - "name": "name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Name" + "title": "Project Name" } } ], @@ -1576,7 +1700,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightConfig" + "$ref": "#/components/schemas/DeleteResponse" } } } @@ -1595,43 +1719,32 @@ } } } - }, - "put": { + } + }, + "/projects/{project_name}/thumbnail": { + "get": { "tags": [ - "lights" + "projects" ], - "summary": "Replace Light Name Settings", - "description": "Replace all settings for a specific resource", - "operationId": "replace_light_name_settings", + "summary": "Get Project Thumbnail", + "operationId": "get_project_thumbnail", "parameters": [ { - "name": "name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Name" + "title": "Project Name" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LightConfig" - } - } - } - }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/LightConfig" - } + "schema": {} } } }, @@ -1649,22 +1762,50 @@ } } } - }, - "patch": { + } + }, + "/projects/{project_name}/scan": { + "post": { "tags": [ - "lights" + "projects" ], - "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_light_name_settings", + "summary": "Add Scan With Description", + "description": "Add a new scan to a project and return the created Task\n\nArgs:\n project_name: The name of the project to add the scan to\n camera_name: The name of the camera to use for the scan\n scan_settings: The settings for the scan\n scan_description: Optional description for the scan\n\nReturns:\n Task: The Task representing the started scan", + "operationId": "add_scan_with_description", "parameters": [ { - "name": "name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Name" + "title": "Project Name" + } + }, + { + "name": "camera_name", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Camera Name" + } + }, + { + "name": "scan_description", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "title": "Scan Description" } } ], @@ -1673,14 +1814,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "examples": [ - { - "some_setting": 123 - } - ], - "title": "Settings" + "$ref": "#/components/schemas/ScanSetting" } } } @@ -1691,7 +1825,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LightConfig" + "$ref": "#/components/schemas/Task" } } } @@ -1712,49 +1846,38 @@ } } }, - "/triggers/": { - "get": { - "tags": [ - "triggers" - ], - "summary": "Get Triggers", - "operationId": "get_triggers", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "$ref": "#/components/schemas/TriggerStatusResponse" - }, - "type": "object", - "title": "Response Get Triggers Triggers Get" - } - } - } - }, - "404": { - "description": "Not found" - } - } - } - }, - "/triggers/{trigger_name}": { - "get": { + "/projects/{project_name}/upload": { + "post": { "tags": [ - "triggers" + "projects" ], - "summary": "Get Trigger", - "operationId": "get_trigger", + "summary": "Upload Project To Cloud", + "description": "Schedule an asynchronous cloud upload for a project.\n\nArgs:\n project_name: The name of the project\n token_override: Optional token override\n\nReturns:\n Task: The TaskManager model describing the scheduled upload", + "operationId": "upload_project_to_cloud", "parameters": [ { - "name": "trigger_name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Trigger Name" + "title": "Project Name" + } + }, + { + "name": "token_override", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Token Override" } } ], @@ -1764,7 +1887,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerStatusResponse" + "$ref": "#/components/schemas/Task" } } } @@ -1785,37 +1908,44 @@ } } }, - "/triggers/{trigger_name}/trigger": { - "post": { + "/projects/{project_name}/{scan_index}/photos": { + "delete": { "tags": [ - "triggers" + "projects" ], - "summary": "Trigger Once", - "operationId": "trigger_once", + "summary": "Delete Photos", + "description": "Delete photos from a scan in a project\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan\n photo_filenames: A list of photo filenames to delete\n\nReturns:\n True if the photos were deleted successfully, False otherwise", + "operationId": "delete_photos", "parameters": [ { - "name": "trigger_name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Trigger Name" + "title": "Project Name" + } + }, + { + "name": "scan_index", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scan Index" } } ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/TriggerExecutionRequest" - }, - { - "type": "null" - } - ], - "title": "Request" + "type": "array", + "items": { + "type": "string" + }, + "title": "Photo Filenames" } } } @@ -1826,7 +1956,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerExecutionResponse" + "$ref": "#/components/schemas/DeleteResponse" } } } @@ -1847,23 +1977,55 @@ } } }, - "/triggers/{name}/settings": { + "/projects/{project_name}/{scan_index}/photo": { "get": { "tags": [ - "triggers" + "projects" ], - "summary": "Get Trigger Name Settings", - "description": "Get settings for a specific resource", - "operationId": "get_trigger_name_settings", + "summary": "Get Scan Photo", + "description": "Fetch a stored scan photo either as JSON payload or direct file download.", + "operationId": "get_scan_photo", "parameters": [ { - "name": "name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Name" + "title": "Project Name" + } + }, + { + "name": "scan_index", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scan Index" } + }, + { + "name": "filename", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Photo filename including extension, e.g. scan01_001.jpg", + "title": "Filename" + }, + "description": "Photo filename including extension, e.g. scan01_001.jpg" + }, + { + "name": "file_only", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Return only the raw file instead of JSON payload", + "default": false, + "title": "File Only" + }, + "description": "Return only the raw file instead of JSON payload" } ], "responses": { @@ -1872,7 +2034,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerConfig" + "$ref": "#/components/schemas/PhotoResponse" } } } @@ -1891,43 +2053,41 @@ } } } - }, - "put": { + } + }, + "/projects/{project_name}/scans/{scan_index}/path": { + "get": { "tags": [ - "triggers" + "projects" ], - "summary": "Replace Trigger Name Settings", - "description": "Replace all settings for a specific resource", - "operationId": "replace_trigger_name_settings", + "summary": "Get Scan Path", + "operationId": "get_scan_path", "parameters": [ { - "name": "name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Name" + "title": "Project Name" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TriggerConfig" - } + }, + { + "name": "scan_index", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scan Index" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/TriggerConfig" - } + "schema": {} } } }, @@ -1945,49 +2105,43 @@ } } } - }, - "patch": { + } + }, + "/projects/{project_name}/scans/{scan_index}": { + "delete": { "tags": [ - "triggers" + "projects" ], - "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_trigger_name_settings", + "summary": "Delete Scan", + "description": "Delete a scan from a project\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to delete\n\nReturns:\n DeleteResponse: Result of the deletion operation", + "operationId": "delete_scan", "parameters": [ { - "name": "name", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Name" + "title": "Project Name" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "examples": [ - { - "some_setting": 123 - } - ], - "title": "Settings" - } + }, + { + "name": "scan_index", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scan Index" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerConfig" + "$ref": "#/components/schemas/DeleteResponse" } } } @@ -2006,56 +2160,41 @@ } } } - } - }, - "/firmware/settings": { + }, "get": { "tags": [ - "firmware" + "projects" ], - "summary": "Get Settings", - "description": "Return persisted firmware settings.", - "operationId": "get_settings", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FirmwareSettings" - } - } - } - }, - "404": { - "description": "Not found" - } - } - }, - "put": { - "tags": [ - "firmware" - ], - "summary": "Replace Settings", - "description": "Replace the entire firmware settings payload.", - "operationId": "replace_settings", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FirmwareSettings" - } + "summary": "Get Scan", + "description": "Get Scan by project and index\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan\n\nReturns:\n Scan: The scan object", + "operationId": "get_scan", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Project Name" } }, - "required": true - }, + { + "name": "scan_index", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scan Index" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FirmwareSettings" + "$ref": "#/components/schemas/Scan" } } } @@ -2076,42 +2215,41 @@ } } }, - "/firmware/settings/{key}": { - "patch": { + "/projects/{project_name}/scans/{scan_index}/status": { + "get": { "tags": [ - "firmware" + "projects" ], - "summary": "Update Setting", - "description": "Update a single firmware settings key.", - "operationId": "update_setting", + "summary": "Get Scan Status", + "description": "Get the current task for a scan\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to get the status of\n\nReturns:\n Task: The task representing the scan execution", + "operationId": "get_scan_status", "parameters": [ { - "name": "key", + "name": "project_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Key" + "title": "Project Name" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FirmwareSettingPatchRequest" - } + }, + { + "name": "scan_index", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scan Index" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FirmwareSettings" + "$ref": "#/components/schemas/Task" } } } @@ -2132,43 +2270,69 @@ } } }, - "/projects/": { - "get": { + "/projects/{project_name}/scans/{scan_index}/pause": { + "patch": { "tags": [ "projects" ], - "summary": "Get Projects", - "description": "Get all projects with serialized data\n\nReturns:\n dict[str, Project]: A dictionary of project name to a project object", - "operationId": "get_projects", + "summary": "Pause Scan", + "description": "Pause a running scan and return the updated Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to pause\n\nReturns:\n Task: The updated task state", + "operationId": "pause_scan", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Project Name" + } + }, + { + "name": "scan_index", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scan Index" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": { - "$ref": "#/components/schemas/Project" - }, - "type": "object", - "title": "Response Get Projects Projects Get" + "$ref": "#/components/schemas/Task" } } } }, "404": { "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } }, - "/projects/{project_name}": { - "get": { + "/projects/{project_name}/scans/{scan_index}/resume": { + "patch": { "tags": [ "projects" ], - "summary": "Get Project", - "description": "Get a project\n\nArgs:\n project_name: The name of the project to get\n\nReturns:\n Project: The project object if found, None if not", - "operationId": "get_project", + "summary": "Resume Scan", + "description": "Resume a paused, cancelled or failed scan and return the resulting Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to resume\n camera_name: The name of the camera to use for the scan\n\nReturns:\n Task: The resumed or restarted task", + "operationId": "resume_scan", "parameters": [ { "name": "project_name", @@ -2178,6 +2342,24 @@ "type": "string", "title": "Project Name" } + }, + { + "name": "scan_index", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Scan Index" + } + }, + { + "name": "camera_name", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Camera Name" + } } ], "responses": { @@ -2186,7 +2368,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Project" + "$ref": "#/components/schemas/Task" } } } @@ -2205,14 +2387,16 @@ } } } - }, - "post": { + } + }, + "/projects/{project_name}/scans/{scan_index}/cancel": { + "patch": { "tags": [ "projects" ], - "summary": "New Project", - "description": "Create a new project\n\nArgs:\n project_name: The name of the project to create\n project_description: Optional description for the project\n\nReturns:\n Project: The newly created project if successful, None if not", - "operationId": "new_project", + "summary": "Cancel Scan", + "description": "Cancel a running scan and return the resulting Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to cancel\n\nReturns:\n Task: The updated task state", + "operationId": "cancel_scan", "parameters": [ { "name": "project_name", @@ -2224,20 +2408,12 @@ } }, { - "name": "project_description", - "in": "query", - "required": false, + "name": "scan_index", + "in": "path", + "required": true, "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": "", - "title": "Project Description" + "type": "integer", + "title": "Scan Index" } } ], @@ -2247,7 +2423,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Project" + "$ref": "#/components/schemas/Task" } } } @@ -2266,14 +2442,16 @@ } } } - }, - "delete": { + } + }, + "/projects/{project_name}/zip": { + "get": { "tags": [ "projects" ], - "summary": "Delete Project", - "description": "Delete a project\n\nArgs:\n project_name: The name of the project to delete\n\nReturns:\n DeleteResponse: A response object containing the result of the deletion", - "operationId": "delete_project", + "summary": "Download Project", + "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata. When ``photos_only`` is true,\nonly the recorded photo files are included without metadata or subfolders.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", + "operationId": "download_project", "parameters": [ { "name": "project_name", @@ -2283,6 +2461,18 @@ "type": "string", "title": "Project Name" } + }, + { + "name": "photos_only", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "If true, stream only photo files without metadata or directory structure.", + "default": false, + "title": "Photos Only" + }, + "description": "If true, stream only photo files without metadata or directory structure." } ], "responses": { @@ -2290,9 +2480,7 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteResponse" - } + "schema": {} } } }, @@ -2312,13 +2500,14 @@ } } }, - "/projects/{project_name}/thumbnail": { + "/projects/{project_name}/model/zip": { "get": { "tags": [ "projects" ], - "summary": "Get Project Thumbnail", - "operationId": "get_project_thumbnail", + "summary": "Download Project Model", + "description": "Download the reconstructed model directory of a project as a ZIP file.", + "operationId": "download_project_model", "parameters": [ { "name": "project_name", @@ -2355,14 +2544,14 @@ } } }, - "/projects/{project_name}/scan": { - "post": { + "/projects/{project_name}/scans/zip": { + "get": { "tags": [ "projects" ], - "summary": "Add Scan With Description", - "description": "Add a new scan to a project and return the created Task\n\nArgs:\n project_name: The name of the project to add the scan to\n camera_name: The name of the camera to use for the scan\n scan_settings: The settings for the scan\n scan_description: Optional description for the scan\n\nReturns:\n Task: The Task representing the started scan", - "operationId": "add_scan_with_description", + "summary": "Download Scans", + "description": "Download selected scans from a project as a ZIP file stream\n\nThis endpoint streams selected scans from a project as a ZIP file.\nIf no scan indices are provided, all scans will be included.\n\nArgs:\n project_name: Name of the project\n scan_indices: List of scan indices to include in the ZIP file\n\nReturns:\n StreamingResponse: ZIP file stream", + "operationId": "download_scans", "parameters": [ { "name": "project_name", @@ -2374,51 +2563,25 @@ } }, { - "name": "camera_name", + "name": "scan_indices", "in": "query", - "required": true, + "required": false, "schema": { - "type": "string", - "title": "Camera Name" - } - }, - { - "name": "scan_description", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": "", - "title": "Scan Description" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanSetting" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Task" - } - } + "type": "array", + "items": { + "type": "integer" + }, + "title": "Scan Indices" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } } }, "404": { @@ -2437,118 +2600,87 @@ } } }, - "/projects/{project_name}/upload": { - "post": { + "/": { + "get": { "tags": [ - "projects" - ], - "summary": "Upload Project To Cloud", - "description": "Schedule an asynchronous cloud upload for a project.\n\nArgs:\n project_name: The name of the project\n token_override: Optional token override\n\nReturns:\n Task: The TaskManager model describing the scheduled upload", - "operationId": "upload_project_to_cloud", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "token_override", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Token Override" - } - } + "openscan" ], + "summary": "Get Software Info", + "description": "Get information about the scanner software", + "operationId": "get_software_info", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "$ref": "#/components/schemas/SoftwareInfoResponse" } } } }, "404": { "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } } } } }, - "/projects/{project_name}/{scan_index}/photos": { - "delete": { + "/logs/tail": { + "get": { "tags": [ - "projects" + "openscan" ], - "summary": "Delete Photos", - "description": "Delete photos from a scan in a project\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan\n photo_filenames: A list of photo filenames to delete\n\nReturns:\n True if the photos were deleted successfully, False otherwise", - "operationId": "delete_photos", + "summary": "Tail Logs", + "description": "Show or follow current logs.\n\nWhen follow=false (default), returns the last N lines of the selected log.\nWhen follow=true (text mode only!), streams new lines as they are written (like `tail -f`).\n\nArgs:\n format: \"text\" for openscan_firmware.log, \"json\" for openscan_detailed_log.json.\n lines: Number of last lines to return initially.\n follow: If true, stream appended log lines in text mode.\n poll_interval: Poll interval (seconds) when following in text mode.\n\nReturns:\n A response with the requested log content.", + "operationId": "tail_logs", "parameters": [ { - "name": "project_name", - "in": "path", - "required": true, + "name": "format", + "in": "query", + "required": false, "schema": { "type": "string", - "title": "Project Name" + "default": "text", + "title": "Format" } }, { - "name": "scan_index", - "in": "path", - "required": true, + "name": "lines", + "in": "query", + "required": false, "schema": { "type": "integer", - "title": "Scan Index" + "default": 200, + "title": "Lines" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Photo Filenames" - } + }, + { + "name": "follow", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Follow" + } + }, + { + "name": "poll_interval", + "in": "query", + "required": false, + "schema": { + "type": "number", + "default": 1, + "title": "Poll Interval" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteResponse" - } + "schema": {} } } }, @@ -2568,111 +2700,62 @@ } } }, - "/projects/{project_name}/{scan_index}/photo": { + "/logs/archive": { "get": { "tags": [ - "projects" - ], - "summary": "Get Scan Photo", - "description": "Fetch a stored scan photo either as JSON payload or direct file download.", - "operationId": "get_scan_photo", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - }, - { - "name": "filename", - "in": "query", - "required": true, - "schema": { - "type": "string", - "description": "Photo filename including extension, e.g. scan01_001.jpg", - "title": "Filename" - }, - "description": "Photo filename including extension, e.g. scan01_001.jpg" - }, - { - "name": "file_only", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "description": "Return only the raw file instead of JSON payload", - "default": false, - "title": "File Only" - }, - "description": "Return only the raw file instead of JSON payload" - } + "openscan" ], + "summary": "Download Logs Archive", + "description": "Create and download a ZIP archive containing all log files.\n\nThe archive includes rotated files for both text and JSON logs, using\ndeflate compression for reasonable size to share e.g. via email.\n\nReturns:\n FileResponse serving the generated ZIP. The temp file is deleted after send.", + "operationId": "download_logs_archive", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/PhotoResponse" - } + "schema": {} } } }, "404": { "description": "Not found" - }, - "422": { - "description": "Validation Error", + } + } + } + }, + "/device/info": { + "get": { + "tags": [ + "device" + ], + "summary": "Get Device Info", + "description": "Get information about the device\n\nReturns:\n dict: A dictionary containing information about the device", + "operationId": "get_device_info", + "responses": { + "200": { + "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "$ref": "#/components/schemas/DeviceStatusResponse" } } } + }, + "404": { + "description": "Not found" } } } }, - "/projects/{project_name}/scans/{scan_index}/path": { + "/device/configurations": { "get": { "tags": [ - "projects" - ], - "summary": "Get Scan Path", - "operationId": "get_scan_path", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } + "device" ], + "summary": "List Config Files", + "description": "List all available device configuration files", + "operationId": "list_config_files", "responses": { "200": { "description": "Successful Response", @@ -2684,55 +2767,58 @@ }, "404": { "description": "Not found" - }, - "422": { - "description": "Validation Error", + } + } + } + }, + "/device/configurations/current": { + "get": { + "tags": [ + "device" + ], + "summary": "Get Current Config", + "description": "Return the currently active device configuration file.", + "operationId": "get_current_config", + "responses": { + "200": { + "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "$ref": "#/components/schemas/DeviceConfigResponse" } } } + }, + "404": { + "description": "Not found" } } - } - }, - "/projects/{project_name}/scans/{scan_index}": { - "delete": { + }, + "put": { "tags": [ - "projects" + "device" ], - "summary": "Delete Scan", - "description": "Delete a scan from a project\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to delete\n\nReturns:\n DeleteResponse: Result of the deletion operation", - "operationId": "delete_scan", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" + "summary": "Set Config File", + "description": "Set the device configuration from a file and initialize hardware\n\nArgs:\n config_data: The device configuration to set\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "set_config_file", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceConfigRequest" + } } }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], + "required": true + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteResponse" + "$ref": "#/components/schemas/DeviceControlResponse" } } } @@ -2752,30 +2838,46 @@ } } }, + "patch": { + "tags": [ + "device" + ], + "summary": "Save Device Config", + "description": "Save the current device configuration to a file\n\nThis endpoint saves the current device configuration to device_config.json.\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "save_device_config", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceControlResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/device/configurations/{filename}": { "get": { "tags": [ - "projects" + "device" ], - "summary": "Get Scan", - "description": "Get Scan by project and index\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan\n\nReturns:\n Scan: The scan object", - "operationId": "get_scan", + "summary": "Get Config File", + "description": "Return a specific configuration JSON file by filename.", + "operationId": "get_config_file", "parameters": [ { - "name": "project_name", + "name": "filename", "in": "path", "required": true, "schema": { "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" + "title": "Filename" } } ], @@ -2785,7 +2887,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Scan" + "$ref": "#/components/schemas/DeviceConfigResponse" } } } @@ -2806,41 +2908,31 @@ } } }, - "/projects/{project_name}/scans/{scan_index}/status": { - "get": { + "/device/configurations/": { + "post": { "tags": [ - "projects" + "device" ], - "summary": "Get Scan Status", - "description": "Get the current task for a scan\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to get the status of\n\nReturns:\n Task: The task representing the scan execution", - "operationId": "get_scan_status", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" + "summary": "Add Config Json", + "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "add_config_json", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" + } } }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - } - ], + "required": true + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "$ref": "#/components/schemas/DeviceControlResponse" } } } @@ -2861,31 +2953,23 @@ } } }, - "/projects/{project_name}/scans/{scan_index}/pause": { - "patch": { + "/device/configurations/current/initialize": { + "post": { "tags": [ - "projects" + "device" ], - "summary": "Pause Scan", - "description": "Pause a running scan and return the updated Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to pause\n\nReturns:\n Task: The updated task state", - "operationId": "pause_scan", + "summary": "Reinitialize Hardware", + "description": "Reinitialize hardware components\n\nThis can be used in case of a hardware failure or to reload the hardware components.\n\nArgs:\n detect_cameras: Whether to detect cameras\n\nReturns:\n dict: A dictionary containing the status of the operation", + "operationId": "reinitialize_hardware", "parameters": [ { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, + "name": "detect_cameras", + "in": "query", + "required": false, "schema": { - "type": "integer", - "title": "Scan Index" + "type": "boolean", + "default": false, + "title": "Detect Cameras" } } ], @@ -2895,7 +2979,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "$ref": "#/components/schemas/DeviceControlResponse" } } } @@ -2916,40 +3000,23 @@ } } }, - "/projects/{project_name}/scans/{scan_index}/resume": { - "patch": { + "/device/reboot": { + "post": { "tags": [ - "projects" + "device" ], - "summary": "Resume Scan", - "description": "Resume a paused, cancelled or failed scan and return the resulting Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to resume\n camera_name: The name of the camera to use for the scan\n\nReturns:\n Task: The resumed or restarted task", - "operationId": "resume_scan", + "summary": "Reboot", + "description": "Reboot system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before rebooting", + "operationId": "reboot", "parameters": [ { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" - } - }, - { - "name": "camera_name", + "name": "save_config", "in": "query", - "required": true, + "required": false, "schema": { - "type": "string", - "title": "Camera Name" + "type": "boolean", + "default": false, + "title": "Save Config" } } ], @@ -2959,7 +3026,8 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "type": "boolean", + "title": "Response Reboot Device Reboot Post" } } } @@ -2980,31 +3048,23 @@ } } }, - "/projects/{project_name}/scans/{scan_index}/cancel": { - "patch": { + "/device/shutdown": { + "post": { "tags": [ - "projects" + "device" ], - "summary": "Cancel Scan", - "description": "Cancel a running scan and return the resulting Task\n\nArgs:\n project_name: The name of the project\n scan_index: The index of the scan to cancel\n\nReturns:\n Task: The updated task state", - "operationId": "cancel_scan", + "summary": "Shutdown", + "description": "Shutdown system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before shutting down", + "operationId": "shutdown", "parameters": [ { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, + "name": "save_config", + "in": "query", + "required": false, "schema": { - "type": "integer", - "title": "Scan Index" + "type": "boolean", + "default": false, + "title": "Save Config" } } ], @@ -3014,7 +3074,8 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "type": "boolean", + "title": "Response Shutdown Device Shutdown Post" } } } @@ -3035,78 +3096,51 @@ } } }, - "/projects/{project_name}/zip": { + "/tasks/": { "get": { "tags": [ - "projects" - ], - "summary": "Download Project", - "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata. When ``photos_only`` is true,\nonly the recorded photo files are included without metadata or subfolders.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", - "operationId": "download_project", - "parameters": [ - { - "name": "project_name", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Project Name" - } - }, - { - "name": "photos_only", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "description": "If true, stream only photo files without metadata or directory structure.", - "default": false, - "title": "Photos Only" - }, - "description": "If true, stream only photo files without metadata or directory structure." - } + "tasks" ], + "summary": "Get All Tasks", + "description": "Retrieve a list of all tasks known to the task manager.\n\nReturns:\n List[Task]: A list of all tasks known to the task manager.", + "operationId": "get_all_tasks", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "$ref": "#/components/schemas/Task" + }, + "type": "array", + "title": "Response Get All Tasks Tasks Get" + } } } }, "404": { "description": "Not found" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } } } } }, - "/projects/{project_name}/model/zip": { + "/tasks/{task_id}": { "get": { "tags": [ - "projects" + "tasks" ], - "summary": "Download Project Model", - "description": "Download the reconstructed model directory of a project as a ZIP file.", - "operationId": "download_project_model", + "summary": "Get Task Status", + "description": "Retrieve the status and details of a specific task.\n\nArgs:\n task_id: The ID of the task to retrieve.\n\nReturns:\n Task: The task object with its status and details.", + "operationId": "get_task_status", "parameters": [ { - "name": "project_name", + "name": "task_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Project Name" + "title": "Task Id" } } ], @@ -3115,7 +3149,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/Task" + } } } }, @@ -3133,36 +3169,22 @@ } } } - } - }, - "/projects/{project_name}/scans/zip": { - "get": { + }, + "delete": { "tags": [ - "projects" + "tasks" ], - "summary": "Download Scans", - "description": "Download selected scans from a project as a ZIP file stream\n\nThis endpoint streams selected scans from a project as a ZIP file.\nIf no scan indices are provided, all scans will be included.\n\nArgs:\n project_name: Name of the project\n scan_indices: List of scan indices to include in the ZIP file\n\nReturns:\n StreamingResponse: ZIP file stream", - "operationId": "download_scans", + "summary": "Cancel Task", + "description": "Request cancellation of a running task.\n\nArgs:\n task_id: The ID of the task to cancel.\n\nReturns:\n Task: The task object with its status and details.", + "operationId": "cancel_task", "parameters": [ { - "name": "project_name", + "name": "task_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_indices", - "in": "query", - "required": false, - "schema": { - "type": "array", - "items": { - "type": "integer" - }, - "title": "Scan Indices" + "title": "Task Id" } } ], @@ -3171,7 +3193,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/Task" + } } } }, @@ -3191,54 +3215,61 @@ } } }, - "/gpio/": { - "get": { + "/tasks/{task_id}/cleanup": { + "delete": { "tags": [ - "gpio" + "tasks" + ], + "summary": "Delete a terminal task record", + "description": "Remove a terminal task from persistence and memory.", + "operationId": "delete_task", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } ], - "summary": "Get Pins", - "description": "Get all initialized GPIO pins\n\nReturns:\n dict[str, list[int]]: A dictionary of initialized output pins and buttons", - "operationId": "get_pins", "responses": { - "200": { - "description": "Successful Response", + "204": { + "description": "Successful Response" + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", "content": { "application/json": { - "schema": { - "additionalProperties": { - "items": { - "type": "integer" - }, - "type": "array" - }, - "type": "object", - "title": "Response Get Pins Gpio Get" + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } } } - }, - "404": { - "description": "Not found" } } } }, - "/gpio/{pin_id}": { - "get": { + "/tasks/{task_id}/pause": { + "post": { "tags": [ - "gpio" + "tasks" ], - "summary": "Get Pin", - "description": "Get output value of a specific GPIO pin\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to get the value of\n\nReturns:\n bool: The output value of the GPIO pin", - "operationId": "get_pin", + "summary": "Pause a Task", + "description": "Pauses a running task.\n\nArgs:\n task_id: The ID of the task to pause.\n\nReturns:\n Task: The task object with its status and details.", + "operationId": "pause_task", "parameters": [ { - "name": "pin_id", + "name": "task_id", "in": "path", "required": true, "schema": { - "type": "integer", - "title": "Pin Id" + "type": "string", + "title": "Task Id" } } ], @@ -3248,8 +3279,7 @@ "content": { "application/json": { "schema": { - "type": "boolean", - "title": "Response Get Pin Gpio Pin Id Get" + "$ref": "#/components/schemas/Task" } } } @@ -3268,31 +3298,24 @@ } } } - }, - "patch": { + } + }, + "/tasks/{task_id}/resume": { + "post": { "tags": [ - "gpio" + "tasks" ], - "summary": "Set Pin", - "description": "Set GPIO pin output value\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to set the value of\n status: The output value to set for the GPIO pin", - "operationId": "set_pin", + "summary": "Resume a Task", + "description": "Resumes a paused task.\n\nArgs:\n task_id: The ID of the task to resume.\n\nReturns:\n Task: The task object with its status and details.", + "operationId": "resume_task", "parameters": [ { - "name": "pin_id", + "name": "task_id", "in": "path", "required": true, "schema": { - "type": "integer", - "title": "Pin Id" - } - }, - { - "name": "status", - "in": "query", - "required": true, - "schema": { - "type": "boolean", - "title": "Status" + "type": "string", + "title": "Task Id" } } ], @@ -3302,8 +3325,7 @@ "content": { "application/json": { "schema": { - "type": "boolean", - "title": "Response Set Pin Gpio Pin Id Patch" + "$ref": "#/components/schemas/Task" } } } @@ -3324,33 +3346,41 @@ } } }, - "/gpio/{pin_id}/toggle": { - "patch": { + "/tasks/{task_name}": { + "post": { "tags": [ - "gpio" + "tasks" ], - "summary": "Toggle Pin", - "description": "Toggle GPIO pin output value\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to toggle", - "operationId": "toggle_pin", + "summary": "Create Task", + "description": "Create and start a new background task with optional parameters.\n\nThe request body accepts:\n- **args**: List of positional arguments (e.g., `[\"project_name\", 0]`)\n- **kwargs**: Dictionary of keyword arguments (e.g., `{\"num_batches\": 5}`)\n\nArgs:\n task_name: The name of the task to create, as registered in the TaskManager.\n args: Positional arguments to pass to the task's run method.\n kwargs: Keyword arguments to pass to the task's run method.\n\nReturns:\n The created task object.\n\nExamples:\n ```json\n // No parameters\n {}\n\n // With positional args\n {\n \"args\": [\"MyProject\", 0]\n }\n\n // With keyword args\n {\n \"kwargs\": {\"num_calibration_batches\": 5}\n }\n\n // With both\n {\n \"args\": [\"MyProject\", 0],\n \"kwargs\": {\"num_calibration_batches\": 5}\n }\n ```", + "operationId": "create_task", "parameters": [ { - "name": "pin_id", + "name": "task_name", "in": "path", "required": true, "schema": { - "type": "integer", - "title": "Pin Id" + "type": "string", + "title": "Task Name" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Body_create_task_tasks__task_name__post" + } + } + } + }, "responses": { - "200": { + "202": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "boolean", - "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" + "$ref": "#/components/schemas/Task" } } } @@ -3371,21 +3401,28 @@ } } }, - "/": { + "/gpio/": { "get": { "tags": [ - "openscan" + "gpio" ], - "summary": "Get Software Info", - "description": "Get information about the scanner software", - "operationId": "get_software_info", + "summary": "Get Pins", + "description": "Get all initialized GPIO pins\n\nReturns:\n dict[str, list[int]]: A dictionary of initialized output pins and buttons", + "operationId": "get_pins", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SoftwareInfoResponse" + "additionalProperties": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "type": "object", + "title": "Response Get Pins Gpio Get" } } } @@ -3396,53 +3433,22 @@ } } }, - "/logs/tail": { + "/gpio/{pin_id}": { "get": { "tags": [ - "openscan" + "gpio" ], - "summary": "Tail Logs", - "description": "Show or follow current logs.\n\nWhen follow=false (default), returns the last N lines of the selected log.\nWhen follow=true (text mode only!), streams new lines as they are written (like `tail -f`).\n\nArgs:\n format: \"text\" for openscan_firmware.log, \"json\" for openscan_detailed_log.json.\n lines: Number of last lines to return initially.\n follow: If true, stream appended log lines in text mode.\n poll_interval: Poll interval (seconds) when following in text mode.\n\nReturns:\n A response with the requested log content.", - "operationId": "tail_logs", + "summary": "Get Pin", + "description": "Get output value of a specific GPIO pin\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to get the value of\n\nReturns:\n bool: The output value of the GPIO pin", + "operationId": "get_pin", "parameters": [ { - "name": "format", - "in": "query", - "required": false, - "schema": { - "type": "string", - "default": "text", - "title": "Format" - } - }, - { - "name": "lines", - "in": "query", - "required": false, + "name": "pin_id", + "in": "path", + "required": true, "schema": { "type": "integer", - "default": 200, - "title": "Lines" - } - }, - { - "name": "follow", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Follow" - } - }, - { - "name": "poll_interval", - "in": "query", - "required": false, - "schema": { - "type": "number", - "default": 1, - "title": "Poll Interval" + "title": "Pin Id" } } ], @@ -3451,7 +3457,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Get Pin Gpio Pin Id Get" + } } } }, @@ -3469,127 +3478,89 @@ } } } - } - }, - "/logs/archive": { - "get": { + }, + "patch": { "tags": [ - "openscan" + "gpio" ], - "summary": "Download Logs Archive", - "description": "Create and download a ZIP archive containing all log files.\n\nThe archive includes rotated files for both text and JSON logs, using\ndeflate compression for reasonable size to share e.g. via email.\n\nReturns:\n FileResponse serving the generated ZIP. The temp file is deleted after send.", - "operationId": "download_logs_archive", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } + "summary": "Set Pin", + "description": "Set GPIO pin output value\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to set the value of\n status: The output value to set for the GPIO pin", + "operationId": "set_pin", + "parameters": [ + { + "name": "pin_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Pin Id" } }, - "404": { - "description": "Not found" + { + "name": "status", + "in": "query", + "required": true, + "schema": { + "type": "boolean", + "title": "Status" + } } - } - } - }, - "/device/info": { - "get": { - "tags": [ - "device" ], - "summary": "Get Device Info", - "description": "Get information about the device\n\nReturns:\n dict: A dictionary containing information about the device", - "operationId": "get_device_info", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceStatusResponse" + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" } } } }, "404": { "description": "Not found" - } - } - } - }, - "/device/configurations": { - "get": { - "tags": [ - "device" - ], - "summary": "List Config Files", - "description": "List all available device configuration files", - "operationId": "list_config_files", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } }, - "404": { - "description": "Not found" - } - } - } - }, - "/device/configurations/current": { - "get": { - "tags": [ - "device" - ], - "summary": "Get Current Config", - "description": "Return the currently active device configuration file.", - "operationId": "get_current_config", - "responses": { - "200": { - "description": "Successful Response", + "422": { + "description": "Validation Error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceConfigResponse" + "$ref": "#/components/schemas/HTTPValidationError" } } } - }, - "404": { - "description": "Not found" } } - }, - "put": { + } + }, + "/gpio/{pin_id}/toggle": { + "patch": { "tags": [ - "device" + "gpio" ], - "summary": "Set Config File", - "description": "Set the device configuration from a file and initialize hardware\n\nArgs:\n config_data: The device configuration to set\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "set_config_file", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceConfigRequest" - } + "summary": "Toggle Pin", + "description": "Toggle GPIO pin output value\n\nArgs:\n pin_id: The ID (int) of the GPIO pin to toggle", + "operationId": "toggle_pin", + "parameters": [ + { + "name": "pin_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Pin Id" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" } } } @@ -3608,21 +3579,26 @@ } } } - }, - "patch": { + } + }, + "/triggers/": { + "get": { "tags": [ - "device" + "triggers" ], - "summary": "Save Device Config", - "description": "Save the current device configuration to a file\n\nThis endpoint saves the current device configuration to device_config.json.\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "save_device_config", + "summary": "Get Triggers", + "operationId": "get_triggers", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "additionalProperties": { + "$ref": "#/components/schemas/TriggerStatusResponse" + }, + "type": "object", + "title": "Response Get Triggers Triggers Get" } } } @@ -3633,22 +3609,21 @@ } } }, - "/device/configurations/{filename}": { + "/triggers/{trigger_name}": { "get": { "tags": [ - "device" + "triggers" ], - "summary": "Get Config File", - "description": "Return a specific configuration JSON file by filename.", - "operationId": "get_config_file", + "summary": "Get Trigger", + "operationId": "get_trigger", "parameters": [ { - "name": "filename", + "name": "trigger_name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Filename" + "title": "Trigger Name" } } ], @@ -3658,7 +3633,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceConfigResponse" + "$ref": "#/components/schemas/TriggerStatusResponse" } } } @@ -3679,23 +3654,40 @@ } } }, - "/device/configurations/": { + "/triggers/{trigger_name}/trigger": { "post": { "tags": [ - "device" + "triggers" + ], + "summary": "Trigger Once", + "operationId": "trigger_once", + "parameters": [ + { + "name": "trigger_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Trigger Name" + } + } ], - "summary": "Add Config Json", - "description": "Add a device configuration from a JSON object\n\nThis endpoint accepts a JSON object with the device configuration,\nvalidates it and saves it to a file.\n\nArgs:\n config_data: The device configuration to add\n filename: The filename to save the configuration as\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "add_config_json", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Body_add_config_json_device_configurations__post" + "anyOf": [ + { + "$ref": "#/components/schemas/TriggerExecutionRequest" + }, + { + "type": "null" + } + ], + "title": "Request" } } - }, - "required": true + } }, "responses": { "200": { @@ -3703,7 +3695,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "$ref": "#/components/schemas/TriggerExecutionResponse" } } } @@ -3724,23 +3716,22 @@ } } }, - "/device/configurations/current/initialize": { - "post": { + "/triggers/{name}/settings": { + "get": { "tags": [ - "device" + "triggers" ], - "summary": "Reinitialize Hardware", - "description": "Reinitialize hardware components\n\nThis can be used in case of a hardware failure or to reload the hardware components.\n\nArgs:\n detect_cameras: Whether to detect cameras\n\nReturns:\n dict: A dictionary containing the status of the operation", - "operationId": "reinitialize_hardware", + "summary": "Get Trigger Name Settings", + "description": "Get settings for a specific resource", + "operationId": "get_trigger_name_settings", "parameters": [ { - "name": "detect_cameras", - "in": "query", - "required": false, + "name": "name", + "in": "path", + "required": true, "schema": { - "type": "boolean", - "default": false, - "title": "Detect Cameras" + "type": "string", + "title": "Name" } } ], @@ -3750,7 +3741,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -3769,36 +3760,42 @@ } } } - } - }, - "/device/reboot": { - "post": { + }, + "put": { "tags": [ - "device" + "triggers" ], - "summary": "Reboot", - "description": "Reboot system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before rebooting", - "operationId": "reboot", + "summary": "Replace Trigger Name Settings", + "description": "Replace all settings for a specific resource", + "operationId": "replace_trigger_name_settings", "parameters": [ { - "name": "save_config", - "in": "query", - "required": false, + "name": "name", + "in": "path", + "required": true, "schema": { - "type": "boolean", - "default": false, - "title": "Save Config" + "type": "string", + "title": "Name" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriggerConfig" + } + } + } + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "boolean", - "title": "Response Reboot Device Reboot Post" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -3817,36 +3814,49 @@ } } } - } - }, - "/device/shutdown": { - "post": { + }, + "patch": { "tags": [ - "device" + "triggers" ], - "summary": "Shutdown", - "description": "Shutdown system and optionally save config.\n\nArgs:\n save_config: Whether to save the current configuration before shutting down", - "operationId": "shutdown", + "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_trigger_name_settings", "parameters": [ { - "name": "save_config", - "in": "query", - "required": false, + "name": "name", + "in": "path", + "required": true, "schema": { - "type": "boolean", - "default": false, - "title": "Save Config" + "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": { - "type": "boolean", - "title": "Response Shutdown Device Shutdown Post" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -3867,14 +3877,13 @@ } } }, - "/tasks/": { + "/external-trigger/runs/": { "get": { "tags": [ - "tasks" + "external-trigger" ], - "summary": "Get All Tasks", - "description": "Retrieve a list of all tasks known to the task manager.\n\nReturns:\n List[Task]: A list of all tasks known to the task manager.", - "operationId": "get_all_tasks", + "summary": "List External Trigger Runs", + "operationId": "list_external_trigger_runs", "responses": { "200": { "description": "Successful Response", @@ -3885,7 +3894,7 @@ "$ref": "#/components/schemas/Task" }, "type": "array", - "title": "Response Get All Tasks Tasks Get" + "title": "Response List External Trigger Runs External Trigger Runs Get" } } } @@ -3894,29 +3903,25 @@ "description": "Not found" } } - } - }, - "/tasks/{task_id}": { - "get": { + }, + "post": { "tags": [ - "tasks" + "external-trigger" ], - "summary": "Get Task Status", - "description": "Retrieve the status and details of a specific task.\n\nArgs:\n task_id: The ID of the task to retrieve.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "get_task_status", - "parameters": [ - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Task Id" + "summary": "Create External Trigger Run", + "operationId": "create_external_trigger_run", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalTriggerRunCreateRequest" + } } - } - ], + }, + "required": true + }, "responses": { - "200": { + "202": { "description": "Successful Response", "content": { "application/json": { @@ -3940,14 +3945,15 @@ } } } - }, - "delete": { + } + }, + "/external-trigger/runs/{task_id}": { + "get": { "tags": [ - "tasks" + "external-trigger" ], - "summary": "Cancel Task", - "description": "Request cancellation of a running task.\n\nArgs:\n task_id: The ID of the task to cancel.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "cancel_task", + "summary": "Get External Trigger Run", + "operationId": "get_external_trigger_run", "parameters": [ { "name": "task_id", @@ -3986,14 +3992,13 @@ } } }, - "/tasks/{task_id}/cleanup": { - "delete": { + "/external-trigger/runs/{task_id}/path": { + "get": { "tags": [ - "tasks" + "external-trigger" ], - "summary": "Delete a terminal task record", - "description": "Remove a terminal task from persistence and memory.", - "operationId": "delete_task", + "summary": "Get External Trigger Run Path", + "operationId": "get_external_trigger_run_path", "parameters": [ { "name": "task_id", @@ -4006,8 +4011,15 @@ } ], "responses": { - "204": { - "description": "Successful Response" + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalTriggerRunPath" + } + } + } }, "404": { "description": "Not found" @@ -4025,14 +4037,13 @@ } } }, - "/tasks/{task_id}/pause": { - "post": { + "/external-trigger/runs/{task_id}/cancel": { + "patch": { "tags": [ - "tasks" + "external-trigger" ], - "summary": "Pause a Task", - "description": "Pauses a running task.\n\nArgs:\n task_id: The ID of the task to pause.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "pause_task", + "summary": "Cancel External Trigger Run Endpoint", + "operationId": "cancel_external_trigger_run_endpoint", "parameters": [ { "name": "task_id", @@ -4071,14 +4082,13 @@ } } }, - "/tasks/{task_id}/resume": { - "post": { + "/external-trigger/runs/{task_id}/pause": { + "patch": { "tags": [ - "tasks" + "external-trigger" ], - "summary": "Resume a Task", - "description": "Resumes a paused task.\n\nArgs:\n task_id: The ID of the task to resume.\n\nReturns:\n Task: The task object with its status and details.", - "operationId": "resume_task", + "summary": "Pause External Trigger Run Endpoint", + "operationId": "pause_external_trigger_run_endpoint", "parameters": [ { "name": "task_id", @@ -4117,36 +4127,26 @@ } } }, - "/tasks/{task_name}": { - "post": { + "/external-trigger/runs/{task_id}/resume": { + "patch": { "tags": [ - "tasks" + "external-trigger" ], - "summary": "Create Task", - "description": "Create and start a new background task with optional parameters.\n\nThe request body accepts:\n- **args**: List of positional arguments (e.g., `[\"project_name\", 0]`)\n- **kwargs**: Dictionary of keyword arguments (e.g., `{\"num_batches\": 5}`)\n\nArgs:\n task_name: The name of the task to create, as registered in the TaskManager.\n args: Positional arguments to pass to the task's run method.\n kwargs: Keyword arguments to pass to the task's run method.\n\nReturns:\n The created task object.\n\nExamples:\n ```json\n // No parameters\n {}\n\n // With positional args\n {\n \"args\": [\"MyProject\", 0]\n }\n\n // With keyword args\n {\n \"kwargs\": {\"num_calibration_batches\": 5}\n }\n\n // With both\n {\n \"args\": [\"MyProject\", 0],\n \"kwargs\": {\"num_calibration_batches\": 5}\n }\n ```", - "operationId": "create_task", + "summary": "Resume External Trigger Run Endpoint", + "operationId": "resume_external_trigger_run_endpoint", "parameters": [ { - "name": "task_name", + "name": "task_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Task Name" + "title": "Task Id" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_create_task_tasks__task_name__post" - } - } - } - }, "responses": { - "202": { + "200": { "description": "Successful Response", "content": { "application/json": { @@ -5092,7 +5092,7 @@ "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { - "$ref": "#/components/schemas/ScannerDeviceConfig-Input" + "$ref": "#/components/schemas/ScannerDeviceConfig" }, "filename": { "$ref": "#/components/schemas/DeviceConfigRequest" @@ -5702,7 +5702,9 @@ "title": "Path" }, "config": { - "$ref": "#/components/schemas/ScannerDeviceConfig-Output" + "additionalProperties": true, + "type": "object", + "title": "Config" } }, "type": "object", @@ -5796,6 +5798,11 @@ "type": "number", "title": "Motors Timeout" }, + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "default": 1.0 + }, "startup_mode": { "$ref": "#/components/schemas/ScannerStartupMode" }, @@ -6096,6 +6103,34 @@ "description": "Maximum theta angle in degrees for constrained paths.", "default": 125.0 }, + "min_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Min Phi", + "description": "Optional minimum phi angle in degrees for constrained paths." + }, + "max_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Phi", + "description": "Optional maximum phi angle in degrees for constrained paths." + }, "optimize_path": { "type": "boolean", "title": "Optimize Path", @@ -6116,7 +6151,7 @@ }, "pre_trigger_delay_ms": { "type": "integer", - "maximum": 30000.0, + "maximum": 600000.0, "minimum": 0.0, "title": "Pre Trigger Delay Ms", "description": "Delay after reaching the scan position and before asserting the trigger.", @@ -6124,7 +6159,7 @@ }, "post_trigger_delay_ms": { "type": "integer", - "maximum": 30000.0, + "maximum": 600000.0, "minimum": 0.0, "title": "Post Trigger Delay Ms", "description": "Delay after releasing the trigger before the next scan step starts.", @@ -6162,11 +6197,17 @@ "title": "Enable Cloud", "description": "Enable integrations with OpenScan Cloud services.", "default": false + }, + "camera_preview_enabled": { + "type": "boolean", + "title": "Camera Preview Enabled", + "description": "Expose camera preview-oriented workflows. Disable for trigger-only systems without a live camera feed.", + "default": true } }, "type": "object", "title": "FirmwareSettings", - "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances." + "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances.\n camera_preview_enabled: When False the system is expected to operate\n without a live camera preview workflow, for example on trigger-only\n DSLR setups." }, "HTTPValidationError": { "properties": { @@ -6742,6 +6783,34 @@ "description": "Maximum theta angle in degrees for constrained paths.", "default": 125.0 }, + "min_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Min Phi", + "description": "Optional minimum phi angle in degrees for constrained paths." + }, + "max_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Phi", + "description": "Optional maximum phi angle in degrees for constrained paths." + }, "optimize_path": { "type": "boolean", "title": "Optimize Path", @@ -6799,7 +6868,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDeviceConfig-Input": { + "ScannerDeviceConfig": { "properties": { "name": { "type": "string", @@ -6874,112 +6943,12 @@ "title": "Motors Timeout", "default": 0.0 }, - "startup_mode": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScannerStartupMode" - }, - { - "type": "string" - } - ], - "title": "Startup Mode", - "default": "startup_enabled" - }, - "calibrate_mode": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScannerCalibrateMode" - }, - { - "type": "string" - } - ], - "title": "Calibrate Mode", - "default": "calibrate_manual" - } - }, - "type": "object", - "required": [ - "name" - ], - "title": "ScannerDeviceConfig", - "description": "Persisted scanner configuration payload stored as JSON." - }, - "ScannerDeviceConfig-Output": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Model" - }, - "shield": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Shield" - }, - "cameras": { - "additionalProperties": { - "$ref": "#/components/schemas/PersistedCameraConfig" - }, - "type": "object", - "title": "Cameras" - }, - "motors": { - "additionalProperties": { - "$ref": "#/components/schemas/MotorConfig" - }, - "type": "object", - "title": "Motors" - }, - "lights": { - "additionalProperties": { - "$ref": "#/components/schemas/LightConfig" - }, - "type": "object", - "title": "Lights" - }, - "triggers": { - "additionalProperties": { - "$ref": "#/components/schemas/TriggerConfig" - }, - "type": "object", - "title": "Triggers" - }, - "endstops": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/components/schemas/PersistedEndstopConfig" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Endstops" - }, - "motors_timeout": { + "scan_radius_mm": { "type": "number", - "title": "Motors Timeout", - "default": 0.0 + "exclusiveMinimum": 0.0, + "title": "Scan Radius Mm", + "description": "Distance in millimeters between the camera lens and the turntable center point.", + "default": 1.0 }, "startup_mode": { "anyOf": [ @@ -7262,6 +7231,14 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerActiveLevel": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerActiveLevel" + }, "TriggerConfig": { "properties": { "enabled": { @@ -7276,9 +7253,9 @@ "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.", + "active_level": { + "$ref": "#/components/schemas/TriggerActiveLevel", + "description": "Defines which logic level is considered active. The idle level is the inverse.", "default": "active_high" }, "pulse_width_ms": { @@ -7286,7 +7263,7 @@ "maximum": 5000.0, "minimum": 1.0, "title": "Pulse Width Ms", - "description": "How long the trigger line stays active for each trigger pulse.", + "description": "How long the trigger line stays active for each trigger pulse in ms.", "default": 100 } }, @@ -7346,14 +7323,6 @@ ], "title": "TriggerExecutionResponse" }, - "TriggerPolarity": { - "type": "string", - "enum": [ - "active_high", - "active_low" - ], - "title": "TriggerPolarity" - }, "TriggerStatusResponse": { "properties": { "name": { diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index 117e83c..b3708c0 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -4814,6 +4814,11 @@ "type": "number", "title": "Motors Timeout" }, + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "default": 1.0 + }, "startup_mode": { "$ref": "#/components/schemas/ScannerStartupMode" }, @@ -5539,6 +5544,34 @@ "description": "Maximum theta angle in degrees for constrained paths.", "default": 125.0 }, + "min_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Min Phi", + "description": "Optional minimum phi angle in degrees for constrained paths." + }, + "max_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Phi", + "description": "Optional maximum phi angle in degrees for constrained paths." + }, "optimize_path": { "type": "boolean", "title": "Optimize Path", @@ -5669,6 +5702,12 @@ "title": "Motors Timeout", "default": 0.0 }, + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "description": "Distance in millimeters between the camera lens and the turntable center point.", + "default": 1.0 + }, "startup_mode": { "$ref": "#/components/schemas/ScannerStartupMode", "default": "startup_enabled" @@ -5975,6 +6014,14 @@ ], "title": "Trigger" }, + "TriggerActiveLevel": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerActiveLevel" + }, "TriggerConfig": { "properties": { "enabled": { @@ -5989,9 +6036,9 @@ "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.", + "active_level": { + "$ref": "#/components/schemas/TriggerActiveLevel", + "description": "Defines which logic level is considered active. The idle level is the inverse.", "default": "active_high" }, "pulse_width_ms": { @@ -5999,7 +6046,7 @@ "maximum": 5000.0, "minimum": 1.0, "title": "Pulse Width Ms", - "description": "How long the trigger line stays active for each trigger pulse.", + "description": "How long the trigger line stays active for each trigger pulse in ms.", "default": 100 } }, @@ -6009,14 +6056,6 @@ ], "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 ec722fd..1eb2e5e 100644 --- a/scripts/openapi/openapi_v0.9.json +++ b/scripts/openapi/openapi_v0.9.json @@ -4500,7 +4500,7 @@ "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { - "$ref": "#/components/schemas/ScannerDeviceConfig-Input" + "$ref": "#/components/schemas/ScannerDeviceConfig" }, "filename": { "$ref": "#/components/schemas/DeviceConfigRequest" @@ -5110,7 +5110,9 @@ "title": "Path" }, "config": { - "$ref": "#/components/schemas/ScannerDeviceConfig-Output" + "additionalProperties": true, + "type": "object", + "title": "Config" } }, "type": "object", @@ -5197,6 +5199,11 @@ "type": "number", "title": "Motors Timeout" }, + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "default": 1.0 + }, "startup_mode": { "$ref": "#/components/schemas/ScannerStartupMode" }, @@ -5400,11 +5407,17 @@ "title": "Enable Cloud", "description": "Enable integrations with OpenScan Cloud services.", "default": false + }, + "camera_preview_enabled": { + "type": "boolean", + "title": "Camera Preview Enabled", + "description": "Expose camera preview-oriented workflows. Disable for trigger-only systems without a live camera feed.", + "default": true } }, "type": "object", "title": "FirmwareSettings", - "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances." + "description": "Global firmware behaviour toggles.\n\nAttributes:\n qr_wifi_scan_enabled: When True the firmware automatically starts the\n QR WiFi scan task on startup if no usable network connection is\n detected.\n enable_cloud: When True the firmware enables cloud-facing features and\n UX affordances.\n camera_preview_enabled: When False the system is expected to operate\n without a live camera preview workflow, for example on trigger-only\n DSLR setups." }, "HTTPValidationError": { "properties": { @@ -5980,6 +5993,34 @@ "description": "Maximum theta angle in degrees for constrained paths.", "default": 125.0 }, + "min_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Min Phi", + "description": "Optional minimum phi angle in degrees for constrained paths." + }, + "max_phi": { + "anyOf": [ + { + "type": "number", + "maximum": 360.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Phi", + "description": "Optional maximum phi angle in degrees for constrained paths." + }, "optimize_path": { "type": "boolean", "title": "Optimize Path", @@ -6037,7 +6078,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDeviceConfig-Input": { + "ScannerDeviceConfig": { "properties": { "name": { "type": "string", @@ -6112,112 +6153,12 @@ "title": "Motors Timeout", "default": 0.0 }, - "startup_mode": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScannerStartupMode" - }, - { - "type": "string" - } - ], - "title": "Startup Mode", - "default": "startup_enabled" - }, - "calibrate_mode": { - "anyOf": [ - { - "$ref": "#/components/schemas/ScannerCalibrateMode" - }, - { - "type": "string" - } - ], - "title": "Calibrate Mode", - "default": "calibrate_manual" - } - }, - "type": "object", - "required": [ - "name" - ], - "title": "ScannerDeviceConfig", - "description": "Persisted scanner configuration payload stored as JSON." - }, - "ScannerDeviceConfig-Output": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Model" - }, - "shield": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Shield" - }, - "cameras": { - "additionalProperties": { - "$ref": "#/components/schemas/PersistedCameraConfig" - }, - "type": "object", - "title": "Cameras" - }, - "motors": { - "additionalProperties": { - "$ref": "#/components/schemas/MotorConfig" - }, - "type": "object", - "title": "Motors" - }, - "lights": { - "additionalProperties": { - "$ref": "#/components/schemas/LightConfig" - }, - "type": "object", - "title": "Lights" - }, - "triggers": { - "additionalProperties": { - "$ref": "#/components/schemas/TriggerConfig" - }, - "type": "object", - "title": "Triggers" - }, - "endstops": { - "anyOf": [ - { - "additionalProperties": { - "$ref": "#/components/schemas/PersistedEndstopConfig" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Endstops" - }, - "motors_timeout": { + "scan_radius_mm": { "type": "number", - "title": "Motors Timeout", - "default": 0.0 + "exclusiveMinimum": 0.0, + "title": "Scan Radius Mm", + "description": "Distance in millimeters between the camera lens and the turntable center point.", + "default": 1.0 }, "startup_mode": { "anyOf": [ @@ -6500,6 +6441,14 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerActiveLevel": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerActiveLevel" + }, "TriggerConfig": { "properties": { "enabled": { @@ -6514,9 +6463,9 @@ "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.", + "active_level": { + "$ref": "#/components/schemas/TriggerActiveLevel", + "description": "Defines which logic level is considered active. The idle level is the inverse.", "default": "active_high" }, "pulse_width_ms": { @@ -6524,7 +6473,7 @@ "maximum": 5000.0, "minimum": 1.0, "title": "Pulse Width Ms", - "description": "How long the trigger line stays active for each trigger pulse.", + "description": "How long the trigger line stays active for each trigger pulse in ms.", "default": 100 } }, @@ -6534,14 +6483,6 @@ ], "title": "TriggerConfig" }, - "TriggerPolarity": { - "type": "string", - "enum": [ - "active_high", - "active_low" - ], - "title": "TriggerPolarity" - }, "ValidationError": { "properties": { "loc": { From ebece36b29066b16c27e07bdf59c5fa16f4c0e33 Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 16 Apr 2026 12:59:27 +0200 Subject: [PATCH 56/75] chore: update firmware manifests and repository data for v0.11.2 release --- dist/local_json.rpi-imager-manifest | 74 ++++++++++++++--------------- dist/os-sublist-openscan.json | 74 ++++++++++++++--------------- dist/repo.json | 74 ++++++++++++++--------------- 3 files changed, 111 insertions(+), 111 deletions(-) diff --git a/dist/local_json.rpi-imager-manifest b/dist/local_json.rpi-imager-manifest index 7a595b4..c859ff6 100644 --- a/dist/local_json.rpi-imager-manifest +++ b/dist/local_json.rpi-imager-manifest @@ -52,8 +52,8 @@ { "name": "OpenScan3 (Arducam IMX519 16MP)", "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", - "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_imx519.zip", - "release_date": "2026-03-18", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.11.2_imx519.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -65,18 +65,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518263201, - "image_download_sha256": "cd2431458e8f0113dbfabaf0fe13f5a027891ab3e07c45a5560f7df025b9ccd3", + "image_download_size": 1539738003, + "image_download_sha256": "8a60af60e253135c57f40c472f76b560a5c10a848062adfce912c392f27beeea", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5318377472, - "extract_sha256": "30baa7bc46b73c89af51dee7c16e7ac412ca353c156de7cbeedfb5c404d6bfd3" + "extract_size": 5360320512, + "extract_sha256": "9f00a80020a91da4ae00c9672a50150446abeb1be395d58d7a00dd6fd3379f58" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP)", - "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", - "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_hawkeye-experimental.zip", - "release_date": "2026-03-18", + "description": "Optimised build for the Arducam HawkEye with drivers and tuning blobs pre-installed.", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.11.2_hawkeye.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -88,18 +88,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518239856, - "image_download_sha256": "0bcae0ae69d191195f9897f568acfe5c0f6b8d10c938133938044b60b9e11f1b", + "image_download_size": 1539770528, + "image_download_sha256": "18a0644c911312009ef6b3a393fe62d3be4df377f0fcaebf3e1a2a394a0fb55b", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5318377472, - "extract_sha256": "d0b3cd55f68f8c341c3e9e1dd0fd95b2a54dd434e219d73881b68d9263676f6a" + "extract_size": 5360320512, + "extract_sha256": "fbf9bc74c8ddd874809748c45a09eda3077ee24ec550f1e8cb3c3b01b20792df" }, { "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", - "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_generic.zip", - "release_date": "2026-03-18", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.11.2_generic.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -112,18 +112,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495921939, - "image_download_sha256": "ebfff63b6bfb600b58a6bba747cbe7198796e776226fc4583c1a1a9e433258c5", + "image_download_size": 1517385857, + "image_download_sha256": "3af598daba71dcc529dcfe91eb5e99abc4fe9f7dd6c482ee07463597c0a7a200", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5226102784, - "extract_sha256": "93a9c3e1c218dac0a725acf094950d6380e674cedf498ee39f00ff2d04d04a38" + "extract_size": 5268045824, + "extract_sha256": "bb85cc0da68f58690f711f7143f68bdb6d6e0d2e4a7c3a963430f4901b036171" }, { "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", - "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_imx519_DEVELOP.zip", - "release_date": "2026-03-18", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.11.2_imx519_DEVELOP.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -135,18 +135,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518249970, - "image_download_sha256": "5513828b5c7b457c963908fa6e6824a92e9e1e127a60952b2cf6426482776966", + "image_download_size": 1539771405, + "image_download_sha256": "df5882055de8221347981095bc040314bcd2b60e61b27a6044fc9ed5459e7735", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5318377472, - "extract_sha256": "7e5af5d67abfade308fb2d0829b9bdeff869c7618c9bb9370df102cc70862b3d" + "extract_size": 5360320512, + "extract_sha256": "7716d53a201ee411ba6e94fe98ced722ce73b640ab958e8ddb8ff6bf709ea1a3" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", "description": "Developer image for the Arducam Hawkeye, please read docs before use!", - "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", - "release_date": "2026-03-18", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.11.2_hawkeye_DEVELOP.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -158,18 +158,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518246422, - "image_download_sha256": "49f80c13f873fc4a6096439c752b41bd5a26f6bcf707cef7c1952e8a7eb542c3", + "image_download_size": 1539720340, + "image_download_sha256": "8cd94e9d3ba9b1d568ed75ac53d8746d1699c8bf0bf3a1e2fa963a119a16c668", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5318377472, - "extract_sha256": "d6bb1cfe35a43b7be0d83dcfdcfa8556c8152d9d83103900f79e4a5ecad1e267" + "extract_size": 5360320512, + "extract_sha256": "9ae9a22b65a0bcb20851a1bc672262fb41cea8e441dcacae7888a9c30ed52eea" }, { "name": "OpenScan3 (Generic camera) (Develop)", "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", - "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.10.0_generic_DEVELOP.zip", - "release_date": "2026-03-18", + "url": "file:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.11.2_generic_DEVELOP.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -182,12 +182,12 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495928041, - "image_download_sha256": "7c27dc67b87147c4ff521474a584c6316182beb0c4eaab319b020a19913eda7a", + "image_download_size": 1517446205, + "image_download_sha256": "7ce3d3058a076e9752d215bb406e9799c5cf74ed236c812906192d3ea7d157fc", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5226102784, - "extract_sha256": "2dba9e567c6efba02146715a64c876f330b52c0cd72c3253650e682395f80da5" + "extract_size": 5268045824, + "extract_sha256": "967cc0a5e98e09ca1646bde804311ad9500689bfb39c4533a018b6b2d2077b9b" } ], "description": "Firmware images for open 3d scanners.", diff --git a/dist/os-sublist-openscan.json b/dist/os-sublist-openscan.json index ce9a8d6..8023dcb 100644 --- a/dist/os-sublist-openscan.json +++ b/dist/os-sublist-openscan.json @@ -3,14 +3,14 @@ { "name": "OpenScan3 (Arducam IMX519 16MP)", "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519.zip", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_imx519.zip", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "release_date": "2026-03-18", - "extract_size": 5318377472, - "extract_sha256": "30baa7bc46b73c89af51dee7c16e7ac412ca353c156de7cbeedfb5c404d6bfd3", - "image_download_size": 1518263201, - "image_download_sha256": "cd2431458e8f0113dbfabaf0fe13f5a027891ab3e07c45a5560f7df025b9ccd3", + "release_date": "2026-04-16", + "extract_size": 5360320512, + "extract_sha256": "9f00a80020a91da4ae00c9672a50150446abeb1be395d58d7a00dd6fd3379f58", + "image_download_size": 1539738003, + "image_download_sha256": "8a60af60e253135c57f40c472f76b560a5c10a848062adfce912c392f27beeea", "devices": [ "pi5", "pi4", @@ -21,15 +21,15 @@ }, { "name": "OpenScan3 (Arducam Hawkeye 64MP)", - "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental.zip", + "description": "Optimised build for the Arducam HawkEye with drivers and tuning blobs pre-installed.", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_hawkeye.zip", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "release_date": "2026-03-18", - "extract_size": 5318377472, - "extract_sha256": "d0b3cd55f68f8c341c3e9e1dd0fd95b2a54dd434e219d73881b68d9263676f6a", - "image_download_size": 1518239856, - "image_download_sha256": "0bcae0ae69d191195f9897f568acfe5c0f6b8d10c938133938044b60b9e11f1b", + "release_date": "2026-04-16", + "extract_size": 5360320512, + "extract_sha256": "fbf9bc74c8ddd874809748c45a09eda3077ee24ec550f1e8cb3c3b01b20792df", + "image_download_size": 1539770528, + "image_download_sha256": "18a0644c911312009ef6b3a393fe62d3be4df377f0fcaebf3e1a2a394a0fb55b", "devices": [ "pi5", "pi4", @@ -41,14 +41,14 @@ { "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic.zip", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_generic.zip", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "release_date": "2026-03-18", - "extract_size": 5226102784, - "extract_sha256": "93a9c3e1c218dac0a725acf094950d6380e674cedf498ee39f00ff2d04d04a38", - "image_download_size": 1495921939, - "image_download_sha256": "ebfff63b6bfb600b58a6bba747cbe7198796e776226fc4583c1a1a9e433258c5", + "release_date": "2026-04-16", + "extract_size": 5268045824, + "extract_sha256": "bb85cc0da68f58690f711f7143f68bdb6d6e0d2e4a7c3a963430f4901b036171", + "image_download_size": 1517385857, + "image_download_sha256": "3af598daba71dcc529dcfe91eb5e99abc4fe9f7dd6c482ee07463597c0a7a200", "devices": [ "pi5", "pi4", @@ -61,14 +61,14 @@ { "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519_DEVELOP.zip", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_imx519_DEVELOP.zip", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "release_date": "2026-03-18", - "extract_size": 5318377472, - "extract_sha256": "7e5af5d67abfade308fb2d0829b9bdeff869c7618c9bb9370df102cc70862b3d", - "image_download_size": 1518249970, - "image_download_sha256": "5513828b5c7b457c963908fa6e6824a92e9e1e127a60952b2cf6426482776966", + "release_date": "2026-04-16", + "extract_size": 5360320512, + "extract_sha256": "7716d53a201ee411ba6e94fe98ced722ce73b640ab958e8ddb8ff6bf709ea1a3", + "image_download_size": 1539771405, + "image_download_sha256": "df5882055de8221347981095bc040314bcd2b60e61b27a6044fc9ed5459e7735", "devices": [ "pi5", "pi4", @@ -80,14 +80,14 @@ { "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", "description": "Developer image for the Arducam Hawkeye, please read docs before use!", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_hawkeye_DEVELOP.zip", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "release_date": "2026-03-18", - "extract_size": 5318377472, - "extract_sha256": "d6bb1cfe35a43b7be0d83dcfdcfa8556c8152d9d83103900f79e4a5ecad1e267", - "image_download_size": 1518246422, - "image_download_sha256": "49f80c13f873fc4a6096439c752b41bd5a26f6bcf707cef7c1952e8a7eb542c3", + "release_date": "2026-04-16", + "extract_size": 5360320512, + "extract_sha256": "9ae9a22b65a0bcb20851a1bc672262fb41cea8e441dcacae7888a9c30ed52eea", + "image_download_size": 1539720340, + "image_download_sha256": "8cd94e9d3ba9b1d568ed75ac53d8746d1699c8bf0bf3a1e2fa963a119a16c668", "devices": [ "pi5", "pi4", @@ -99,14 +99,14 @@ { "name": "OpenScan3 (Generic camera) (Develop)", "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic_DEVELOP.zip", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_generic_DEVELOP.zip", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "release_date": "2026-03-18", - "extract_size": 5226102784, - "extract_sha256": "2dba9e567c6efba02146715a64c876f330b52c0cd72c3253650e682395f80da5", - "image_download_size": 1495928041, - "image_download_sha256": "7c27dc67b87147c4ff521474a584c6316182beb0c4eaab319b020a19913eda7a", + "release_date": "2026-04-16", + "extract_size": 5268045824, + "extract_sha256": "967cc0a5e98e09ca1646bde804311ad9500689bfb39c4533a018b6b2d2077b9b", + "image_download_size": 1517446205, + "image_download_sha256": "7ce3d3058a076e9752d215bb406e9799c5cf74ed236c812906192d3ea7d157fc", "devices": [ "pi5", "pi4", diff --git a/dist/repo.json b/dist/repo.json index 0618ff0..ce0d314 100644 --- a/dist/repo.json +++ b/dist/repo.json @@ -52,8 +52,8 @@ { "name": "OpenScan3 (Arducam IMX519 16MP)", "description": "Optimised for the Arducam IMX519 camera with drivers and tuning blobs pre-installed.", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519.zip", - "release_date": "2026-03-18", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_imx519.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -65,18 +65,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518263201, - "image_download_sha256": "cd2431458e8f0113dbfabaf0fe13f5a027891ab3e07c45a5560f7df025b9ccd3", + "image_download_size": 1539738003, + "image_download_sha256": "8a60af60e253135c57f40c472f76b560a5c10a848062adfce912c392f27beeea", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5318377472, - "extract_sha256": "30baa7bc46b73c89af51dee7c16e7ac412ca353c156de7cbeedfb5c404d6bfd3" + "extract_size": 5360320512, + "extract_sha256": "9f00a80020a91da4ae00c9672a50150446abeb1be395d58d7a00dd6fd3379f58" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP)", - "description": "Experimental build that ships the Arducam HawkEye drivers and tuning blobs pre-installed.", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental.zip", - "release_date": "2026-03-18", + "description": "Optimised build for the Arducam HawkEye with drivers and tuning blobs pre-installed.", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_hawkeye.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -88,18 +88,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518239856, - "image_download_sha256": "0bcae0ae69d191195f9897f568acfe5c0f6b8d10c938133938044b60b9e11f1b", + "image_download_size": 1539770528, + "image_download_sha256": "18a0644c911312009ef6b3a393fe62d3be4df377f0fcaebf3e1a2a394a0fb55b", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5318377472, - "extract_sha256": "d0b3cd55f68f8c341c3e9e1dd0fd95b2a54dd434e219d73881b68d9263676f6a" + "extract_size": 5360320512, + "extract_sha256": "fbf9bc74c8ddd874809748c45a09eda3077ee24ec550f1e8cb3c3b01b20792df" }, { "name": "OpenScan3 (Generic Picamera/USB/HQ/DLSR)", "description": "Baseline OpenScan3 firmware for Raspberry Pi Camera Module 3 or other cameras.", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic.zip", - "release_date": "2026-03-18", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_generic.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -112,18 +112,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495921939, - "image_download_sha256": "ebfff63b6bfb600b58a6bba747cbe7198796e776226fc4583c1a1a9e433258c5", + "image_download_size": 1517385857, + "image_download_sha256": "3af598daba71dcc529dcfe91eb5e99abc4fe9f7dd6c482ee07463597c0a7a200", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5226102784, - "extract_sha256": "93a9c3e1c218dac0a725acf094950d6380e674cedf498ee39f00ff2d04d04a38" + "extract_size": 5268045824, + "extract_sha256": "bb85cc0da68f58690f711f7143f68bdb6d6e0d2e4a7c3a963430f4901b036171" }, { "name": "OpenScan3 (Arducam IMX519 16MP) (Develop)", "description": "Developer image for the Arducam IMX519 camera, please read docs before use!", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_imx519_DEVELOP.zip", - "release_date": "2026-03-18", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_imx519_DEVELOP.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -135,18 +135,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518249970, - "image_download_sha256": "5513828b5c7b457c963908fa6e6824a92e9e1e127a60952b2cf6426482776966", + "image_download_size": 1539771405, + "image_download_sha256": "df5882055de8221347981095bc040314bcd2b60e61b27a6044fc9ed5459e7735", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5318377472, - "extract_sha256": "7e5af5d67abfade308fb2d0829b9bdeff869c7618c9bb9370df102cc70862b3d" + "extract_size": 5360320512, + "extract_sha256": "7716d53a201ee411ba6e94fe98ced722ce73b640ab958e8ddb8ff6bf709ea1a3" }, { "name": "OpenScan3 (Arducam Hawkeye 64MP) (Develop)", "description": "Developer image for the Arducam Hawkeye, please read docs before use!", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_hawkeye-experimental_DEVELOP.zip", - "release_date": "2026-03-18", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_hawkeye_DEVELOP.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -158,18 +158,18 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1518246422, - "image_download_sha256": "49f80c13f873fc4a6096439c752b41bd5a26f6bcf707cef7c1952e8a7eb542c3", + "image_download_size": 1539720340, + "image_download_sha256": "8cd94e9d3ba9b1d568ed75ac53d8746d1699c8bf0bf3a1e2fa963a119a16c668", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5318377472, - "extract_sha256": "d6bb1cfe35a43b7be0d83dcfdcfa8556c8152d9d83103900f79e4a5ecad1e267" + "extract_size": 5360320512, + "extract_sha256": "9ae9a22b65a0bcb20851a1bc672262fb41cea8e441dcacae7888a9c30ed52eea" }, { "name": "OpenScan3 (Generic camera) (Develop)", "description": "Developer image for generic cameras (Picamera, DLSR, etc.), please read docs before use!", - "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.10.0/OpenScan3_v0.10.0_generic_DEVELOP.zip", - "release_date": "2026-03-18", + "url": "https://github.com/OpenScan-org/OpenScan3/releases/download/v0.11.2/OpenScan3_v0.11.2_generic_DEVELOP.zip", + "release_date": "2026-04-16", "devices": [ "pi5", "pi4", @@ -182,12 +182,12 @@ "rpi_connect" ], "init_format": "cloudinit-rpi", - "image_download_size": 1495928041, - "image_download_sha256": "7c27dc67b87147c4ff521474a584c6316182beb0c4eaab319b020a19913eda7a", + "image_download_size": 1517446205, + "image_download_sha256": "7ce3d3058a076e9752d215bb406e9799c5cf74ed236c812906192d3ea7d157fc", "icon": "https://raw.githubusercontent.com/OpenScan-org/OpenScan3/main/dist/assets/openscan_mini_icon.png", "website": "https://openscan.eu", - "extract_size": 5226102784, - "extract_sha256": "2dba9e567c6efba02146715a64c876f330b52c0cd72c3253650e682395f80da5" + "extract_size": 5268045824, + "extract_sha256": "967cc0a5e98e09ca1646bde804311ad9500689bfb39c4533a018b6b2d2077b9b" } ], "description": "Firmware images for open 3d scanners.", From 17d50d832024dc3ae05a3d348acb8796625a42a7 Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 20 Apr 2026 13:09:03 +0200 Subject: [PATCH 57/75] fix(cloud): handle temp storage exhaustion during cloud operations - Added `_get_cloud_temp_dir` utility to manage cloud temp directories. - Introduced `_temp_storage_error` to raise detailed errors for insufficient temp storage. - Updated `_build_project_archive` and cloud download logic to use specific temp directories and handle ENOSPC errors. - Added unit tests to verify behavior when temp storage is exhausted. --- .../controllers/services/cloud.py | 82 +++++++++++++++---- .../services/tasks/core/cloud_task.py | 21 ++++- .../tasks/test_cloud_download_task.py | 60 ++++++++++++++ .../services/tasks/test_cloud_upload_task.py | 71 ++++++++++++++++ 4 files changed, 214 insertions(+), 20 deletions(-) diff --git a/openscan_firmware/controllers/services/cloud.py b/openscan_firmware/controllers/services/cloud.py index fb3ed48..b9f88d4 100644 --- a/openscan_firmware/controllers/services/cloud.py +++ b/openscan_firmware/controllers/services/cloud.py @@ -1,11 +1,12 @@ """Cloud service helpers for OpenScan.""" import asyncio +import errno import io import logging -import math import pathlib from dataclasses import dataclass +from shutil import disk_usage from tempfile import TemporaryFile from typing import Any, BinaryIO, Callable, Iterator, Sequence from zipfile import ZIP_DEFLATED, ZipFile @@ -15,8 +16,9 @@ from openscan_firmware.config.cloud import CloudSettings, CloudConfigurationError, get_cloud_settings, mask_secret from openscan_firmware.controllers.services.projects import ProjectManager, get_project_manager from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager -from openscan_firmware.models.task import TaskStatus from openscan_firmware.models.project import Project +from openscan_firmware.models.task import TaskStatus +from openscan_firmware.utils.dir_paths import resolve_runtime_dir logger = logging.getLogger(__name__) @@ -24,6 +26,7 @@ REQUEST_TIMEOUT = 60 ALLOWED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} UNSUPPORTED_PHOTO_SUFFIXES = {".png", ".dng", ".raw", ".cr2", ".cr3", ".crw", ".npy"} +_CLOUD_TEMP_SUBDIR = "tmp/cloud" class CloudServiceError(RuntimeError): @@ -52,6 +55,41 @@ class CloudDownloadResult: download_info: dict[str, Any] +def _get_cloud_temp_dir() -> pathlib.Path: + """Resolve and create the temp directory used for large cloud artifacts.""" + + temp_dir = resolve_runtime_dir(_CLOUD_TEMP_SUBDIR) + temp_dir.mkdir(parents=True, exist_ok=True) + return temp_dir + + +def _format_storage_size(size_bytes: int) -> str: + """Format byte counts into a compact human-readable string.""" + + value = float(size_bytes) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if value < 1024 or unit == "TiB": + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024 + return f"{int(size_bytes)} B" + + +def _temp_storage_error(operation: str, temp_dir: pathlib.Path) -> CloudServiceError: + """Build a user-facing error for temporary storage exhaustion.""" + + free_space_suffix = "" + try: + free_space_suffix = f" Free space there: {_format_storage_size(disk_usage(temp_dir).free)}." + except OSError: + pass + + return CloudServiceError( + f"No space left in OpenScan temp storage '{temp_dir}' while {operation}.{free_space_suffix}" + ) + + def _require_cloud_settings() -> CloudSettings: """Retrieve configured cloud settings or raise a service error.""" @@ -458,23 +496,33 @@ def _iter_stream(): def _build_project_archive(project: Project) -> tuple[TemporaryFile, int]: - archive = TemporaryFile() + temp_dir = _get_cloud_temp_dir() + archive = None base_path = project.path_obj - seen_names: set[str] = set() - with ZipFile(archive, "w", compression=ZIP_DEFLATED) as zipf: - for file_path in _collect_project_photos(project): - arcname = file_path.name - if arcname in seen_names: - arcname = str(file_path.relative_to(base_path)).replace("/", "_") - seen_names.add(arcname) - - zipf.write(file_path, arcname) - - archive.seek(0, io.SEEK_END) - size = archive.tell() - archive.seek(0) - return archive, size + try: + archive = TemporaryFile(dir=temp_dir) + + seen_names: set[str] = set() + with ZipFile(archive, "w", compression=ZIP_DEFLATED) as zipf: + for file_path in _collect_project_photos(project): + arcname = file_path.name + if arcname in seen_names: + arcname = str(file_path.relative_to(base_path)).replace("/", "_") + seen_names.add(arcname) + + zipf.write(file_path, arcname) + + archive.seek(0, io.SEEK_END) + size = archive.tell() + archive.seek(0) + return archive, size + except OSError as exc: + if archive is not None: + archive.close() + if exc.errno == errno.ENOSPC: + raise _temp_storage_error("creating the cloud upload archive", temp_dir) from exc + raise def _count_project_photos(project: Project) -> int: diff --git a/openscan_firmware/controllers/services/tasks/core/cloud_task.py b/openscan_firmware/controllers/services/tasks/core/cloud_task.py index 52dfa40..37c6832 100644 --- a/openscan_firmware/controllers/services/tasks/core/cloud_task.py +++ b/openscan_firmware/controllers/services/tasks/core/cloud_task.py @@ -4,6 +4,7 @@ import asyncio import contextlib +import errno import io import logging import re @@ -23,9 +24,11 @@ _build_project_archive, _count_project_photos, _create_project, + _get_cloud_temp_dir, _iter_chunks, _require_cloud_settings, _start_project, + _temp_storage_error, get_project_info, _upload_file, ) @@ -408,9 +411,16 @@ async def _download_archive_stream( total_bytes = int(response.headers.get("Content-Length", "0") or 0) chunk_iter = response.iter_content(chunk_size=_DOWNLOAD_CHUNK_SIZE) + temp_dir = await asyncio.to_thread(_get_cloud_temp_dir) - with NamedTemporaryFile(delete=False, suffix=".zip") as temp_file: - temp_path = Path(temp_file.name) + try: + with NamedTemporaryFile(delete=False, suffix=".zip", dir=temp_dir) as temp_file: + temp_path = Path(temp_file.name) + except OSError as exc: + response.close() + if exc.errno == errno.ENOSPC: + raise _temp_storage_error("preparing the cloud download archive", temp_dir) from exc + raise downloaded = 0 try: @@ -426,7 +436,12 @@ async def _download_archive_stream( if not chunk: continue - await asyncio.to_thread(destination.write, chunk) + try: + await asyncio.to_thread(destination.write, chunk) + except OSError as exc: + if exc.errno == errno.ENOSPC: + raise _temp_storage_error("downloading the cloud archive", temp_dir) from exc + raise downloaded += len(chunk) total_for_progress = total_bytes or max(downloaded, 1) diff --git a/tests/controllers/services/tasks/test_cloud_download_task.py b/tests/controllers/services/tasks/test_cloud_download_task.py index b679f23..cae6d6d 100644 --- a/tests/controllers/services/tasks/test_cloud_download_task.py +++ b/tests/controllers/services/tasks/test_cloud_download_task.py @@ -1,4 +1,5 @@ import asyncio +import errno from types import SimpleNamespace from pathlib import Path @@ -194,3 +195,62 @@ async def _consume(): assert project.downloaded is False assert task_instance._task_model.result is None + + +@pytest.mark.asyncio +async def test_cloud_download_task_reports_temp_storage_exhaustion(monkeypatch, project_manager, tmp_path): + project = _prepare_environment(monkeypatch, project_manager) + temp_dir = tmp_path / "runtime" / "tmp" / "cloud" + temp_dir.mkdir(parents=True) + + def fake_get_project_info(name: str, token=None): + return {"dlink": "https://download/link", "status": "finished"} + + class FakeResponse: + headers = {"Content-Length": "9"} + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size: int): + yield b"zip-bytes" + + def close(self): + return None + + def fake_requests_get(url: str, stream: bool, timeout: int): + assert url == "https://download/link" + assert stream is True + return FakeResponse() + + def fake_named_temporary_file(*args, **kwargs): + raise OSError(errno.ENOSPC, "No space left on device", str(kwargs.get("dir"))) + + monkeypatch.setattr( + "openscan_firmware.controllers.services.tasks.core.cloud_task.get_project_info", + fake_get_project_info, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.tasks.core.cloud_task.requests.get", + fake_requests_get, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.tasks.core.cloud_task._get_cloud_temp_dir", + lambda: temp_dir, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.tasks.core.cloud_task.NamedTemporaryFile", + fake_named_temporary_file, + ) + + task_model = Task(name="cloud_download_task", task_type="cloud_download_task") + task_instance = CloudDownloadTask(task_model) + + async def _consume(): + async for _ in task_instance.run(project.name): + pass + + with pytest.raises(CloudServiceError, match="No space left in OpenScan temp storage") as exc_info: + await _consume() + + assert str(temp_dir) in str(exc_info.value) diff --git a/tests/controllers/services/tasks/test_cloud_upload_task.py b/tests/controllers/services/tasks/test_cloud_upload_task.py index 217950b..34d1186 100644 --- a/tests/controllers/services/tasks/test_cloud_upload_task.py +++ b/tests/controllers/services/tasks/test_cloud_upload_task.py @@ -1,4 +1,5 @@ import asyncio +import errno import io import logging import time @@ -371,3 +372,73 @@ def test_build_project_archive_prefers_stacked(tmp_path): archive.close() assert _count_project_photos(project) == 3 + + +def test_build_project_archive_uses_cloud_temp_dir(tmp_path, monkeypatch): + project_path = tmp_path / "project" + scan1 = project_path / "scan01" + scan1.mkdir(parents=True) + (scan1 / "img1.jpg").write_bytes(b"jpg1") + + project = Project( + name="demo", + path=str(project_path), + created=datetime.now(), + scans={}, + ) + + expected_temp_dir = tmp_path / "runtime" / "tmp" / "cloud" + captured: dict[str, object] = {} + + def fake_temporary_file(*, dir=None): + captured["dir"] = dir + return io.BytesIO() + + monkeypatch.setattr( + "openscan_firmware.controllers.services.cloud._get_cloud_temp_dir", + lambda: expected_temp_dir, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.cloud.TemporaryFile", + fake_temporary_file, + ) + + archive, size = _build_project_archive(project) + try: + assert captured["dir"] == expected_temp_dir + assert size > 0 + with ZipFile(archive, "r") as zipf: + assert zipf.namelist() == ["img1.jpg"] + finally: + archive.close() + + +def test_build_project_archive_reports_temp_storage_exhaustion(tmp_path, monkeypatch): + project_path = tmp_path / "project" + project_path.mkdir() + expected_temp_dir = tmp_path / "runtime" / "tmp" / "cloud" + expected_temp_dir.mkdir(parents=True) + + project = Project( + name="demo", + path=str(project_path), + created=datetime.now(), + scans={}, + ) + + def no_space_temp_file(*, dir=None): + raise OSError(errno.ENOSPC, "No space left on device", str(dir)) + + monkeypatch.setattr( + "openscan_firmware.controllers.services.cloud._get_cloud_temp_dir", + lambda: expected_temp_dir, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.cloud.TemporaryFile", + no_space_temp_file, + ) + + with pytest.raises(CloudServiceError, match="No space left in OpenScan temp storage") as exc_info: + _build_project_archive(project) + + assert str(expected_temp_dir) in str(exc_info.value) From 7bc3d78999a5d0e25230a2206e4983b977949f9d Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 20 Apr 2026 13:26:02 +0200 Subject: [PATCH 58/75] fix(cloud): improve temp storage handling and add cleanup utilities - Added `_create_cloud_download_temp_file` and `_register_active_cloud_temp_path` for better temp file management. - Introduced `_ensure_cloud_temp_space` to verify sufficient storage before cloud operations. - Implemented `_cleanup_cloud_temp_dir` to remove stale files securely. - Updated cloud upload and download logic to handle temporary storage limitations properly. - Added comprehensive unit tests to validate changes. --- .../controllers/services/cloud.py | 155 +++++++++++++++++- .../services/tasks/core/cloud_task.py | 24 ++- .../tasks/test_cloud_download_task.py | 73 ++++++++- .../services/tasks/test_cloud_upload_task.py | 69 +++++++- 4 files changed, 309 insertions(+), 12 deletions(-) diff --git a/openscan_firmware/controllers/services/cloud.py b/openscan_firmware/controllers/services/cloud.py index b9f88d4..a3bc4b3 100644 --- a/openscan_firmware/controllers/services/cloud.py +++ b/openscan_firmware/controllers/services/cloud.py @@ -5,9 +5,10 @@ import io import logging import pathlib +import threading from dataclasses import dataclass from shutil import disk_usage -from tempfile import TemporaryFile +from tempfile import NamedTemporaryFile, TemporaryFile from typing import Any, BinaryIO, Callable, Iterator, Sequence from zipfile import ZIP_DEFLATED, ZipFile @@ -27,6 +28,12 @@ ALLOWED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} UNSUPPORTED_PHOTO_SUFFIXES = {".png", ".dng", ".raw", ".cr2", ".cr3", ".crw", ".npy"} _CLOUD_TEMP_SUBDIR = "tmp/cloud" +_CLOUD_UPLOAD_TEMP_PREFIX = "cloud-upload-" +_CLOUD_DOWNLOAD_TEMP_PREFIX = "cloud-download-" +_ZIP_ENTRY_TEMP_OVERHEAD_BYTES = 1024 +_ZIP_FIXED_TEMP_OVERHEAD_BYTES = 64 * 1024 +_ACTIVE_CLOUD_TEMP_PATHS: set[pathlib.Path] = set() +_CLOUD_TEMP_STATE_LOCK = threading.Lock() class CloudServiceError(RuntimeError): @@ -90,6 +97,145 @@ def _temp_storage_error(operation: str, temp_dir: pathlib.Path) -> CloudServiceE ) +def _insufficient_temp_storage_error( + operation: str, + temp_dir: pathlib.Path, + required_bytes: int, + free_bytes: int, +) -> CloudServiceError: + """Build a user-facing error for a failed temp space preflight check.""" + + return CloudServiceError( + f"Insufficient free space in OpenScan temp storage '{temp_dir}' while {operation}. " + f"Required about {_format_storage_size(required_bytes)}, available {_format_storage_size(free_bytes)}." + ) + + +def _register_active_cloud_temp_path(path: str | pathlib.Path) -> pathlib.Path: + """Mark a temp file path as in use by the current process.""" + + normalized = pathlib.Path(path) + with _CLOUD_TEMP_STATE_LOCK: + _ACTIVE_CLOUD_TEMP_PATHS.add(normalized) + return normalized + + +def _release_cloud_temp_path(path: str | pathlib.Path | None) -> None: + """Remove a temp file path from the active set.""" + + if path is None: + return + + normalized = pathlib.Path(path) + with _CLOUD_TEMP_STATE_LOCK: + _ACTIVE_CLOUD_TEMP_PATHS.discard(normalized) + + +def _cleanup_cloud_temp_dir(temp_dir: pathlib.Path) -> tuple[int, int]: + """Delete stale files from the dedicated cloud temp directory.""" + + removed_count = 0 + removed_bytes = 0 + + try: + if not temp_dir.exists(): + return removed_count, removed_bytes + except OSError: + return removed_count, removed_bytes + + with _CLOUD_TEMP_STATE_LOCK: + active_paths = set(_ACTIVE_CLOUD_TEMP_PATHS) + for candidate in sorted(temp_dir.iterdir()): + if candidate in active_paths: + continue + + try: + if not candidate.is_file(): + continue + except OSError: + continue + + try: + candidate_size = candidate.stat().st_size + except OSError: + candidate_size = 0 + + try: + candidate.unlink() + except FileNotFoundError: + continue + except OSError as exc: + logger.warning("Failed to delete stale cloud temp file %s: %s", candidate, exc) + continue + + removed_count += 1 + removed_bytes += candidate_size + + if removed_count: + logger.info( + "Removed %s stale cloud temp file(s) from %s, reclaimed %s", + removed_count, + temp_dir, + _format_storage_size(removed_bytes), + ) + + return removed_count, removed_bytes + + +def _ensure_cloud_temp_space( + operation: str, + temp_dir: pathlib.Path, + required_bytes: int | None = None, +) -> None: + """Run cleanup and fail early when the temp directory lacks free space.""" + + _cleanup_cloud_temp_dir(temp_dir) + if required_bytes is None or required_bytes <= 0: + return + + try: + usage = disk_usage(temp_dir) + except OSError: + return + + if usage.free < required_bytes: + raise _insufficient_temp_storage_error(operation, temp_dir, required_bytes, usage.free) + + +def _estimate_upload_temp_space(photo_paths: Sequence[pathlib.Path]) -> int: + """Estimate the maximum temp space required for a ZIP archive upload.""" + + total_photo_bytes = 0 + for photo_path in photo_paths: + try: + total_photo_bytes += photo_path.stat().st_size + except OSError as exc: + raise CloudServiceError( + f"Failed to inspect photo '{photo_path}' before cloud upload." + ) from exc + + return ( + total_photo_bytes + + len(photo_paths) * _ZIP_ENTRY_TEMP_OVERHEAD_BYTES + + _ZIP_FIXED_TEMP_OVERHEAD_BYTES + ) + + +def _create_cloud_download_temp_file(temp_dir: pathlib.Path) -> pathlib.Path: + """Create and register a named temp file for cloud downloads.""" + + with _CLOUD_TEMP_STATE_LOCK: + with NamedTemporaryFile( + delete=False, + suffix=".zip", + prefix=_CLOUD_DOWNLOAD_TEMP_PREFIX, + dir=temp_dir, + ) as temp_file: + temp_path = pathlib.Path(temp_file.name) + _ACTIVE_CLOUD_TEMP_PATHS.add(temp_path) + return temp_path + + def _require_cloud_settings() -> CloudSettings: """Retrieve configured cloud settings or raise a service error.""" @@ -499,13 +645,16 @@ def _build_project_archive(project: Project) -> tuple[TemporaryFile, int]: temp_dir = _get_cloud_temp_dir() archive = None base_path = project.path_obj + photo_paths = _collect_project_photos(project) + required_bytes = _estimate_upload_temp_space(photo_paths) try: - archive = TemporaryFile(dir=temp_dir) + _ensure_cloud_temp_space("creating the cloud upload archive", temp_dir, required_bytes) + archive = TemporaryFile(dir=temp_dir, prefix=_CLOUD_UPLOAD_TEMP_PREFIX) seen_names: set[str] = set() with ZipFile(archive, "w", compression=ZIP_DEFLATED) as zipf: - for file_path in _collect_project_photos(project): + for file_path in photo_paths: arcname = file_path.name if arcname in seen_names: arcname = str(file_path.relative_to(base_path)).replace("/", "_") diff --git a/openscan_firmware/controllers/services/tasks/core/cloud_task.py b/openscan_firmware/controllers/services/tasks/core/cloud_task.py index 37c6832..a029b6a 100644 --- a/openscan_firmware/controllers/services/tasks/core/cloud_task.py +++ b/openscan_firmware/controllers/services/tasks/core/cloud_task.py @@ -10,7 +10,6 @@ import re import time from pathlib import Path -from tempfile import NamedTemporaryFile from typing import Any, AsyncGenerator import requests @@ -23,10 +22,13 @@ REQUEST_TIMEOUT, _build_project_archive, _count_project_photos, + _create_cloud_download_temp_file, _create_project, _get_cloud_temp_dir, _iter_chunks, + _ensure_cloud_temp_space, _require_cloud_settings, + _release_cloud_temp_path, _start_project, _temp_storage_error, get_project_info, @@ -381,6 +383,7 @@ async def run( archive_path, exc, ) + _release_cloud_temp_path(archive_path) async def _download_archive_stream( self, @@ -411,11 +414,22 @@ async def _download_archive_stream( total_bytes = int(response.headers.get("Content-Length", "0") or 0) chunk_iter = response.iter_content(chunk_size=_DOWNLOAD_CHUNK_SIZE) + required_bytes = total_bytes or None temp_dir = await asyncio.to_thread(_get_cloud_temp_dir) + try: + await asyncio.to_thread( + _ensure_cloud_temp_space, + "downloading the cloud archive", + temp_dir, + required_bytes, + ) + except Exception: + response.close() + raise + temp_path: Path | None = None try: - with NamedTemporaryFile(delete=False, suffix=".zip", dir=temp_dir) as temp_file: - temp_path = Path(temp_file.name) + temp_path = await asyncio.to_thread(_create_cloud_download_temp_file, temp_dir) except OSError as exc: response.close() if exc.errno == errno.ENOSPC: @@ -447,7 +461,9 @@ async def _download_archive_stream( total_for_progress = total_bytes or max(downloaded, 1) yield downloaded, total_for_progress except Exception: - temp_path.unlink(missing_ok=True) + if temp_path is not None: + temp_path.unlink(missing_ok=True) + _release_cloud_temp_path(temp_path) response.close() raise finally: diff --git a/tests/controllers/services/tasks/test_cloud_download_task.py b/tests/controllers/services/tasks/test_cloud_download_task.py index cae6d6d..3970d35 100644 --- a/tests/controllers/services/tasks/test_cloud_download_task.py +++ b/tests/controllers/services/tasks/test_cloud_download_task.py @@ -223,8 +223,8 @@ def fake_requests_get(url: str, stream: bool, timeout: int): assert stream is True return FakeResponse() - def fake_named_temporary_file(*args, **kwargs): - raise OSError(errno.ENOSPC, "No space left on device", str(kwargs.get("dir"))) + def fake_named_temporary_file(temp_dir_arg): + raise OSError(errno.ENOSPC, "No space left on device", str(temp_dir_arg)) monkeypatch.setattr( "openscan_firmware.controllers.services.tasks.core.cloud_task.get_project_info", @@ -239,7 +239,7 @@ def fake_named_temporary_file(*args, **kwargs): lambda: temp_dir, ) monkeypatch.setattr( - "openscan_firmware.controllers.services.tasks.core.cloud_task.NamedTemporaryFile", + "openscan_firmware.controllers.services.tasks.core.cloud_task._create_cloud_download_temp_file", fake_named_temporary_file, ) @@ -254,3 +254,70 @@ async def _consume(): await _consume() assert str(temp_dir) in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_cloud_download_task_fails_preflight_when_temp_space_is_insufficient( + monkeypatch, + project_manager, + tmp_path, +): + project = _prepare_environment(monkeypatch, project_manager) + temp_dir = tmp_path / "runtime" / "tmp" / "cloud" + temp_dir.mkdir(parents=True) + + def fake_get_project_info(name: str, token=None): + return {"dlink": "https://download/link", "status": "finished"} + + class FakeResponse: + headers = {"Content-Length": "4096"} + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size: int): + yield b"zip-bytes" + + def close(self): + return None + + def fake_requests_get(url: str, stream: bool, timeout: int): + assert url == "https://download/link" + assert stream is True + return FakeResponse() + + def should_not_create_temp_file(_temp_dir): + raise AssertionError("Download temp file should not be created when preflight fails") + + monkeypatch.setattr( + "openscan_firmware.controllers.services.tasks.core.cloud_task.get_project_info", + fake_get_project_info, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.tasks.core.cloud_task.requests.get", + fake_requests_get, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.tasks.core.cloud_task._get_cloud_temp_dir", + lambda: temp_dir, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.cloud.disk_usage", + lambda _path: SimpleNamespace(total=8192, free=512), + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.tasks.core.cloud_task._create_cloud_download_temp_file", + should_not_create_temp_file, + ) + + task_model = Task(name="cloud_download_task", task_type="cloud_download_task") + task_instance = CloudDownloadTask(task_model) + + async def _consume(): + async for _ in task_instance.run(project.name): + pass + + with pytest.raises(CloudServiceError, match="Insufficient free space in OpenScan temp storage") as exc_info: + await _consume() + + assert str(temp_dir) in str(exc_info.value) diff --git a/tests/controllers/services/tasks/test_cloud_upload_task.py b/tests/controllers/services/tasks/test_cloud_upload_task.py index 34d1186..ddedf03 100644 --- a/tests/controllers/services/tasks/test_cloud_upload_task.py +++ b/tests/controllers/services/tasks/test_cloud_upload_task.py @@ -14,7 +14,10 @@ from openscan_firmware.controllers.services.cloud import ( CloudServiceError, _build_project_archive, + _cleanup_cloud_temp_dir, _count_project_photos, + _register_active_cloud_temp_path, + _release_cloud_temp_path, ) from openscan_firmware.controllers.services.tasks.core.cloud_task import CloudUploadTask from openscan_firmware.models.project import Project @@ -390,8 +393,9 @@ def test_build_project_archive_uses_cloud_temp_dir(tmp_path, monkeypatch): expected_temp_dir = tmp_path / "runtime" / "tmp" / "cloud" captured: dict[str, object] = {} - def fake_temporary_file(*, dir=None): + def fake_temporary_file(*, dir=None, prefix=None): captured["dir"] = dir + captured["prefix"] = prefix return io.BytesIO() monkeypatch.setattr( @@ -406,6 +410,7 @@ def fake_temporary_file(*, dir=None): archive, size = _build_project_archive(project) try: assert captured["dir"] == expected_temp_dir + assert captured["prefix"] == "cloud-upload-" assert size > 0 with ZipFile(archive, "r") as zipf: assert zipf.namelist() == ["img1.jpg"] @@ -426,7 +431,7 @@ def test_build_project_archive_reports_temp_storage_exhaustion(tmp_path, monkeyp scans={}, ) - def no_space_temp_file(*, dir=None): + def no_space_temp_file(*, dir=None, prefix=None): raise OSError(errno.ENOSPC, "No space left on device", str(dir)) monkeypatch.setattr( @@ -442,3 +447,63 @@ def no_space_temp_file(*, dir=None): _build_project_archive(project) assert str(expected_temp_dir) in str(exc_info.value) + + +def test_cleanup_cloud_temp_dir_removes_stale_files_but_keeps_active_files(tmp_path): + temp_dir = tmp_path / "runtime" / "tmp" / "cloud" + temp_dir.mkdir(parents=True) + stale_file = temp_dir / "stale.zip" + stale_file.write_bytes(b"old") + stale_size = stale_file.stat().st_size + active_file = temp_dir / "active.zip" + active_file.write_bytes(b"current") + + _register_active_cloud_temp_path(active_file) + try: + removed_count, removed_bytes = _cleanup_cloud_temp_dir(temp_dir) + finally: + _release_cloud_temp_path(active_file) + + assert removed_count == 1 + assert removed_bytes == stale_size + assert not stale_file.exists() + assert active_file.exists() + + +def test_build_project_archive_fails_preflight_when_temp_space_is_insufficient(tmp_path, monkeypatch): + project_path = tmp_path / "project" + scan1 = project_path / "scan01" + scan1.mkdir(parents=True) + (scan1 / "img1.jpg").write_bytes(b"x" * 2048) + + project = Project( + name="demo", + path=str(project_path), + created=datetime.now(), + scans={}, + ) + + expected_temp_dir = tmp_path / "runtime" / "tmp" / "cloud" + expected_temp_dir.mkdir(parents=True) + + monkeypatch.setattr( + "openscan_firmware.controllers.services.cloud._get_cloud_temp_dir", + lambda: expected_temp_dir, + ) + monkeypatch.setattr( + "openscan_firmware.controllers.services.cloud.disk_usage", + lambda _path: SimpleNamespace(total=4096, free=512), + ) + + def should_not_create_temp_file(**kwargs): + raise AssertionError("TemporaryFile should not be created when the preflight check fails") + + monkeypatch.setattr( + "openscan_firmware.controllers.services.cloud.TemporaryFile", + should_not_create_temp_file, + ) + + with pytest.raises(CloudServiceError, match="Insufficient free space in OpenScan temp storage") as exc_info: + _build_project_archive(project) + + assert str(expected_temp_dir) in str(exc_info.value) From bbc8522b6841b937cb1052a5aeceb9b999caafea Mon Sep 17 00:00:00 2001 From: esto Date: Mon, 20 Apr 2026 16:05:52 +0200 Subject: [PATCH 59/75] feat(tasks): optimize progress persistence with throttling and selective updates - Introduced throttling for task progress persistence to reduce writes for minor updates. - Added configurable parameters for persistence intervals and delta thresholds. - Updated task state handling to selectively persist progress based on update significance. - Included additional tests to verify throttling behavior and ensure consistency. --- .../services/tasks/task_manager.py | 51 ++++++++++++++++++- .../controllers/services/test_task_manager.py | 44 +++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/openscan_firmware/controllers/services/tasks/task_manager.py b/openscan_firmware/controllers/services/tasks/task_manager.py index 34a5fef..8f68845 100644 --- a/openscan_firmware/controllers/services/tasks/task_manager.py +++ b/openscan_firmware/controllers/services/tasks/task_manager.py @@ -76,6 +76,9 @@ # Configuration for task concurrency MAX_CONCURRENT_NON_EXCLUSIVE_TASKS = 3 TASKS_STORAGE_PATH = pathlib.Path("data/tasks") +PROGRESS_PERSIST_INTERVAL_SECONDS = 2.0 +PROGRESS_PERSIST_MIN_DELTA_RATIO = 0.05 +PROGRESS_PERSIST_MIN_DELTA_ABSOLUTE = 1.0 class TaskManager: @@ -99,6 +102,8 @@ def __new__(cls) -> TaskManager: cls._instance._pending_tasks: asyncio.Queue[tuple[BaseTask, tuple, dict]] = asyncio.Queue() cls._instance._active_exclusive_task_id: str | None = None cls._instance._queue_processing_lock = asyncio.Lock() + cls._instance._last_persisted_progress: dict[str, tuple[float, float, str]] = {} + cls._instance._last_persisted_at: dict[str, float] = {} cls._instance.max_concurrent_non_exclusive_tasks = MAX_CONCURRENT_NON_EXCLUSIVE_TASKS @@ -236,10 +241,50 @@ def _save_task_state(self, task_model: Task): self._tasks_storage_path.mkdir(parents=True, exist_ok=True) with open(file_path, 'w') as f: f.write(json_string) - logger.debug(f"Persisted state for task {task_model.id} to {file_path}") + self._last_persisted_progress[task_model.id] = ( + float(task_model.progress.current), + float(task_model.progress.total), + task_model.progress.message, + ) + self._last_persisted_at[task_model.id] = time.monotonic() except IOError as e: logger.error(f"Failed to save task state for {task_model.id}: {e}", exc_info=True) + def _should_persist_progress_state(self, task_model: Task) -> bool: + """Persist streaming progress selectively to avoid writing on every micro-update.""" + last_progress = self._last_persisted_progress.get(task_model.id) + last_persisted_at = self._last_persisted_at.get(task_model.id) + if last_progress is None or last_persisted_at is None: + return True + + now = time.monotonic() + if now - last_persisted_at >= PROGRESS_PERSIST_INTERVAL_SECONDS: + return True + + current = float(task_model.progress.current) + total = float(task_model.progress.total) + last_current, last_total, last_message = last_progress + + if current < last_current or total != last_total: + return True + + if total <= 0: + return current != last_current or task_model.progress.message != last_message + + if current >= total: + return True + + min_delta = max( + PROGRESS_PERSIST_MIN_DELTA_ABSOLUTE, + total * PROGRESS_PERSIST_MIN_DELTA_RATIO, + ) + return (current - last_current) >= min_delta + + def _save_task_progress_state(self, task_model: Task) -> None: + """Persist progress updates at a reduced frequency while keeping lifecycle writes immediate.""" + if self._should_persist_progress_state(task_model): + self._save_task_state(task_model) + def _delete_task_state(self, task_id: str): """Deletes the JSON file for a given task.""" file_path = self._tasks_storage_path / f"{task_id}.json" @@ -247,6 +292,8 @@ def _delete_task_state(self, task_id: str): if os.path.exists(file_path): os.remove(file_path) logger.debug(f"Deleted persisted state for task {task_id}.") + self._last_persisted_progress.pop(task_id, None) + self._last_persisted_at.pop(task_id, None) except IOError as e: logger.error(f"Failed to delete task state for {task_id}: {e}", exc_info=True) @@ -488,7 +535,7 @@ async def _run_wrapper(self, task_instance: BaseTask, *args: Any, **kwargs: Any) # Update progress. It's now required that tasks yield `TaskProgress` objects. task_model.progress = progress_update - self._save_task_state(task_model) # Persist progress immediately + self._save_task_progress_state(task_model) await task_event_publisher.publish(task_model, TaskEventType.UPDATE) if task_model.status not in [TaskStatus.CANCELLED, TaskStatus.ERROR]: diff --git a/tests/controllers/services/test_task_manager.py b/tests/controllers/services/test_task_manager.py index f15ed06..b9eabc5 100644 --- a/tests/controllers/services/test_task_manager.py +++ b/tests/controllers/services/test_task_manager.py @@ -467,6 +467,48 @@ async def test_cancel_pending_task(task_manager_fixture: TaskManager): await wait_for_task_completion(tm, exclusive_task.id, timeout=4) +async def test_streaming_progress_persistence_is_throttled(task_manager_fixture: TaskManager, monkeypatch): + """Progress persistence should be reduced for noisy streaming updates.""" + tm = task_manager_fixture + task_model = Task(name="generator_task", task_type="generator_task") + persisted_currents = [] + fake_clock = {"now": 0.0} + + monkeypatch.setattr(task_manager_module, "PROGRESS_PERSIST_INTERVAL_SECONDS", 10.0) + monkeypatch.setattr(task_manager_module, "PROGRESS_PERSIST_MIN_DELTA_RATIO", 0.05) + monkeypatch.setattr(task_manager_module, "PROGRESS_PERSIST_MIN_DELTA_ABSOLUTE", 1.0) + monkeypatch.setattr(task_manager_module.time, "monotonic", lambda: fake_clock["now"]) + + original_save = tm._save_task_state + + def recording_save(model: Task): + persisted_currents.append(model.progress.current) + original_save(model) + + monkeypatch.setattr(tm, "_save_task_state", recording_save) + + task_model.progress = TaskProgress(current=0, total=100, message="starting") + tm._save_task_state(task_model) + + fake_clock["now"] = 0.1 + task_model.progress = TaskProgress(current=1, total=100, message="step 1") + tm._save_task_progress_state(task_model) + + fake_clock["now"] = 0.2 + task_model.progress = TaskProgress(current=5, total=100, message="step 5") + tm._save_task_progress_state(task_model) + + fake_clock["now"] = 0.3 + task_model.progress = TaskProgress(current=6, total=100, message="step 6") + tm._save_task_progress_state(task_model) + + fake_clock["now"] = 11.0 + task_model.progress = TaskProgress(current=7, total=100, message="step 7") + tm._save_task_progress_state(task_model) + + assert persisted_currents == [0, 5, 7] + + # --- Tests for Persistence --- async def test_task_state_is_persisted_across_lifecycle(task_manager_fixture: TaskManager): @@ -926,4 +968,4 @@ async def test_startup_with_unregistered_task_type(task_manager_fixture: TaskMan assert loaded_task_info is not None, "Task should have been loaded from the file." assert loaded_task_info.status == TaskStatus.ERROR, "Task status should be set to ERROR." assert loaded_task_info.error == f"Task type '{unregistered_task_type}' is not registered. Cannot restore." - assert loaded_task_info.task_type == unregistered_task_type \ No newline at end of file + assert loaded_task_info.task_type == unregistered_task_type From aa17afff7b30ae1d4786cb6e909e2d06abb3b655 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 22 Apr 2026 14:56:19 +0200 Subject: [PATCH 60/75] feat(api): add stacked photo preference for zip downloads - Introduced the `prefer_stacked_photos` query parameter to prioritize stacked JPEG outputs in project and scan ZIP downloads. - Updated backend logic to skip original photos when stacked results exist. - Enhanced OpenAPI schema to document the new parameter. - Added tests to verify preferred photo selection and proper ZIP stream behavior. --- openscan_firmware/routers/next/projects.py | 173 +++++++++++++++++++-- openscan_firmware/routers/v0_8/projects.py | 173 +++++++++++++++++++-- openscan_firmware/routers/v0_9/projects.py | 173 +++++++++++++++++++-- scripts/openapi/openapi_latest.json | 26 +++- scripts/openapi/openapi_next.json | 26 +++- scripts/openapi/openapi_v0.8.json | 26 +++- scripts/openapi/openapi_v0.9.json | 26 +++- tests/routers/test_projects_api.py | 128 +++++++++++++++ 8 files changed, 699 insertions(+), 52 deletions(-) diff --git a/openscan_firmware/routers/next/projects.py b/openscan_firmware/routers/next/projects.py index 40fd9cf..25c46ff 100644 --- a/openscan_firmware/routers/next/projects.py +++ b/openscan_firmware/routers/next/projects.py @@ -3,6 +3,7 @@ from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel import pathlib +import re from typing import Optional, List, Any import asyncio import os @@ -30,6 +31,7 @@ ) logger = logging.getLogger(__name__) +STACKED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} class DeleteResponse(BaseModel): success: bool @@ -444,12 +446,34 @@ def _serialize_project_for_zip(project: Project) -> str: def _add_project_photos_to_zip(zip_stream, project: Project) -> int: """Add all recorded photo files of a project to a flat zip archive.""" + return _add_project_photos_to_zip_with_strategy( + zip_stream, + project, + prefer_stacked_photos=False, + ) + + +def _add_project_photos_to_zip_with_strategy( + zip_stream, + project: Project, + *, + prefer_stacked_photos: bool, +) -> int: + """Add project photos with optional stacked-preferred selection.""" added = 0 for scan in sorted(project.scans.values(), key=lambda scan_obj: scan_obj.index): - scan_dir = os.path.join(project.path, f"scan{scan.index:02d}") + scan_dir = pathlib.Path(project.path) / f"scan{scan.index:02d}" + preferred_stacked = _get_stacked_photos(scan_dir) if prefer_stacked_photos else [] + + if preferred_stacked: + for stacked_path in preferred_stacked: + zip_stream.add_path(str(stacked_path), arcname=stacked_path.name) + added += 1 + continue + for photo_filename in scan.photos: - photo_path = os.path.join(scan_dir, photo_filename) - if not os.path.exists(photo_path): + photo_path = scan_dir / photo_filename + if not photo_path.exists(): logger.warning( "Photo %s missing on disk for project %s scan %s", photo_filename, @@ -457,11 +481,95 @@ def _add_project_photos_to_zip(zip_stream, project: Project) -> int: scan.index, ) continue - zip_stream.add_path(photo_path, arcname=photo_filename) + zip_stream.add_path(str(photo_path), arcname=photo_filename) added += 1 return added +def _get_stacked_photos(scan_dir: pathlib.Path) -> list[pathlib.Path]: + stacked_dir = scan_dir / "stacked" + if not stacked_dir.is_dir(): + return [] + return sorted( + path + for path in stacked_dir.rglob("*") + if path.is_file() and path.suffix.lower() in STACKED_PHOTO_SUFFIXES + ) + + +def _add_scan_directory_to_zip( + zip_stream, + project: Project, + scan: Scan, + *, + prefer_stacked_photos: bool, +) -> int: + scan_dir = pathlib.Path(project.path) / f"scan{scan.index:02d}" + if not scan_dir.is_dir(): + return 0 + + scan_arc_root = f"scan{scan.index:02d}" + if not prefer_stacked_photos: + zip_stream.add_path(str(scan_dir), scan_arc_root) + return 1 + + stacked_photos = _get_stacked_photos(scan_dir) + if not stacked_photos: + zip_stream.add_path(str(scan_dir), scan_arc_root) + return 1 + + originals_to_skip = set(scan.photos) + metadata_to_skip = {f"metadata/{pathlib.Path(name).stem}.json" for name in originals_to_skip} + added_files = 0 + + for file_path in sorted(scan_dir.rglob("*")): + if not file_path.is_file(): + continue + + rel = file_path.relative_to(scan_dir).as_posix() + if rel in originals_to_skip or rel in metadata_to_skip: + continue + if file_path.parent == scan_dir and file_path.name in originals_to_skip: + continue + + zip_stream.add_path(str(file_path), f"{scan_arc_root}/{rel}") + added_files += 1 + + return added_files + + +def _add_project_to_zip_with_strategy( + zip_stream, + project: Project, + *, + prefer_stacked_photos: bool, +) -> int: + project_root = pathlib.Path(project.path) + scans_by_name = { + f"scan{scan.index:02d}": scan + for scan in project.scans.values() + } + added = 0 + + for entry in sorted(project_root.iterdir(), key=lambda path: path.name): + if entry.is_dir(): + match = re.fullmatch(r"scan(\d+)", entry.name) + if match: + scan = scans_by_name.get(entry.name) + if scan is not None: + added += _add_scan_directory_to_zip( + zip_stream, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) + continue + zip_stream.add_path(str(entry), entry.name) + added += 1 + + return added + + @router.get("/{project_name}/zip") async def download_project( project_name: str, @@ -469,12 +577,18 @@ async def download_project( False, description="If true, stream only photo files without metadata or directory structure.", ), + prefer_stacked_photos: bool = Query( + False, + description="Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + ), ): """Download a project as a ZIP file stream This endpoint streams the entire project directory as a ZIP file, - including all scans, photos, and metadata. When ``photos_only`` is true, - only the recorded photo files are included without metadata or subfolders. + including all scans, photos, and metadata. When ``photos_only`` is true, + only the recorded photo files are included without metadata or subfolders. + When ``prefer_stacked_photos`` is true, stacked JPEG outputs are preferred + per scan and originals are skipped for scans with stacked results. Args: project_name: Name of the project to download @@ -494,10 +608,24 @@ async def download_project( if photos_only: zs = ZipStream(sized=True) zs.comment = f"OpenScan3 Project Photos: {project_name}" - added_files = _add_project_photos_to_zip(zs, project) + added_files = _add_project_photos_to_zip_with_strategy( + zs, + project, + prefer_stacked_photos=prefer_stacked_photos, + ) if added_files == 0: raise HTTPException(status_code=404, detail="No photos available for this project") filename = f"{project_name}_photos.zip" + elif prefer_stacked_photos: + zs = ZipStream(sized=True) + zs.comment = f"OpenScan3 Project: {project_name} (stacked photos preferred)" + _add_project_to_zip_with_strategy( + zs, + project, + prefer_stacked_photos=True, + ) + zs.add(_serialize_project_for_zip(project), "project_metadata.json") + filename = f"{project_name}_stacked_preferred.zip" else: # Create ZipStream from project path zs = ZipStream.from_path(project.path) @@ -566,7 +694,14 @@ async def download_project_model(project_name: str): @router.get("/{project_name}/scans/zip") -async def download_scans(project_name: str, scan_indices: List[int] = Query(None)): +async def download_scans( + project_name: str, + scan_indices: List[int] = Query(None), + prefer_stacked_photos: bool = Query( + False, + description="Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + ), +): """Download selected scans from a project as a ZIP file stream This endpoint streams selected scans from a project as a ZIP file. @@ -603,18 +738,24 @@ async def download_scans(project_name: str, scan_indices: List[int] = Query(None if not scan: logger.error(f"Scan with index {scan_index} not found") continue - scan_dir = os.path.join(project.path, f"scan{scan_index:02d}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan{scan_index:02d}") + _add_scan_directory_to_zip( + zs, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) except Exception as e: logger.error(f"Failed to add scan {scan_index} to zip: {e}") continue else: filename = f"{project_name}_all_scans.zip" - for scan_id, scan in project.scans.items(): - scan_dir = os.path.join(project.path, f"scan_{scan.index}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan_{scan.index}") + for scan in sorted(project.scans.values(), key=lambda scan_obj: scan_obj.index): + _add_scan_directory_to_zip( + zs, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) zs.add(_serialize_project_for_zip(project), "project_metadata.json") @@ -631,7 +772,7 @@ async def download_scans(project_name: str, scan_indices: List[int] = Query(None ) return response except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"") + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") except Exception as e: print(e) raise HTTPException(status_code=500, detail=str(e)) diff --git a/openscan_firmware/routers/v0_8/projects.py b/openscan_firmware/routers/v0_8/projects.py index 40fd9cf..25c46ff 100644 --- a/openscan_firmware/routers/v0_8/projects.py +++ b/openscan_firmware/routers/v0_8/projects.py @@ -3,6 +3,7 @@ from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel import pathlib +import re from typing import Optional, List, Any import asyncio import os @@ -30,6 +31,7 @@ ) logger = logging.getLogger(__name__) +STACKED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} class DeleteResponse(BaseModel): success: bool @@ -444,12 +446,34 @@ def _serialize_project_for_zip(project: Project) -> str: def _add_project_photos_to_zip(zip_stream, project: Project) -> int: """Add all recorded photo files of a project to a flat zip archive.""" + return _add_project_photos_to_zip_with_strategy( + zip_stream, + project, + prefer_stacked_photos=False, + ) + + +def _add_project_photos_to_zip_with_strategy( + zip_stream, + project: Project, + *, + prefer_stacked_photos: bool, +) -> int: + """Add project photos with optional stacked-preferred selection.""" added = 0 for scan in sorted(project.scans.values(), key=lambda scan_obj: scan_obj.index): - scan_dir = os.path.join(project.path, f"scan{scan.index:02d}") + scan_dir = pathlib.Path(project.path) / f"scan{scan.index:02d}" + preferred_stacked = _get_stacked_photos(scan_dir) if prefer_stacked_photos else [] + + if preferred_stacked: + for stacked_path in preferred_stacked: + zip_stream.add_path(str(stacked_path), arcname=stacked_path.name) + added += 1 + continue + for photo_filename in scan.photos: - photo_path = os.path.join(scan_dir, photo_filename) - if not os.path.exists(photo_path): + photo_path = scan_dir / photo_filename + if not photo_path.exists(): logger.warning( "Photo %s missing on disk for project %s scan %s", photo_filename, @@ -457,11 +481,95 @@ def _add_project_photos_to_zip(zip_stream, project: Project) -> int: scan.index, ) continue - zip_stream.add_path(photo_path, arcname=photo_filename) + zip_stream.add_path(str(photo_path), arcname=photo_filename) added += 1 return added +def _get_stacked_photos(scan_dir: pathlib.Path) -> list[pathlib.Path]: + stacked_dir = scan_dir / "stacked" + if not stacked_dir.is_dir(): + return [] + return sorted( + path + for path in stacked_dir.rglob("*") + if path.is_file() and path.suffix.lower() in STACKED_PHOTO_SUFFIXES + ) + + +def _add_scan_directory_to_zip( + zip_stream, + project: Project, + scan: Scan, + *, + prefer_stacked_photos: bool, +) -> int: + scan_dir = pathlib.Path(project.path) / f"scan{scan.index:02d}" + if not scan_dir.is_dir(): + return 0 + + scan_arc_root = f"scan{scan.index:02d}" + if not prefer_stacked_photos: + zip_stream.add_path(str(scan_dir), scan_arc_root) + return 1 + + stacked_photos = _get_stacked_photos(scan_dir) + if not stacked_photos: + zip_stream.add_path(str(scan_dir), scan_arc_root) + return 1 + + originals_to_skip = set(scan.photos) + metadata_to_skip = {f"metadata/{pathlib.Path(name).stem}.json" for name in originals_to_skip} + added_files = 0 + + for file_path in sorted(scan_dir.rglob("*")): + if not file_path.is_file(): + continue + + rel = file_path.relative_to(scan_dir).as_posix() + if rel in originals_to_skip or rel in metadata_to_skip: + continue + if file_path.parent == scan_dir and file_path.name in originals_to_skip: + continue + + zip_stream.add_path(str(file_path), f"{scan_arc_root}/{rel}") + added_files += 1 + + return added_files + + +def _add_project_to_zip_with_strategy( + zip_stream, + project: Project, + *, + prefer_stacked_photos: bool, +) -> int: + project_root = pathlib.Path(project.path) + scans_by_name = { + f"scan{scan.index:02d}": scan + for scan in project.scans.values() + } + added = 0 + + for entry in sorted(project_root.iterdir(), key=lambda path: path.name): + if entry.is_dir(): + match = re.fullmatch(r"scan(\d+)", entry.name) + if match: + scan = scans_by_name.get(entry.name) + if scan is not None: + added += _add_scan_directory_to_zip( + zip_stream, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) + continue + zip_stream.add_path(str(entry), entry.name) + added += 1 + + return added + + @router.get("/{project_name}/zip") async def download_project( project_name: str, @@ -469,12 +577,18 @@ async def download_project( False, description="If true, stream only photo files without metadata or directory structure.", ), + prefer_stacked_photos: bool = Query( + False, + description="Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + ), ): """Download a project as a ZIP file stream This endpoint streams the entire project directory as a ZIP file, - including all scans, photos, and metadata. When ``photos_only`` is true, - only the recorded photo files are included without metadata or subfolders. + including all scans, photos, and metadata. When ``photos_only`` is true, + only the recorded photo files are included without metadata or subfolders. + When ``prefer_stacked_photos`` is true, stacked JPEG outputs are preferred + per scan and originals are skipped for scans with stacked results. Args: project_name: Name of the project to download @@ -494,10 +608,24 @@ async def download_project( if photos_only: zs = ZipStream(sized=True) zs.comment = f"OpenScan3 Project Photos: {project_name}" - added_files = _add_project_photos_to_zip(zs, project) + added_files = _add_project_photos_to_zip_with_strategy( + zs, + project, + prefer_stacked_photos=prefer_stacked_photos, + ) if added_files == 0: raise HTTPException(status_code=404, detail="No photos available for this project") filename = f"{project_name}_photos.zip" + elif prefer_stacked_photos: + zs = ZipStream(sized=True) + zs.comment = f"OpenScan3 Project: {project_name} (stacked photos preferred)" + _add_project_to_zip_with_strategy( + zs, + project, + prefer_stacked_photos=True, + ) + zs.add(_serialize_project_for_zip(project), "project_metadata.json") + filename = f"{project_name}_stacked_preferred.zip" else: # Create ZipStream from project path zs = ZipStream.from_path(project.path) @@ -566,7 +694,14 @@ async def download_project_model(project_name: str): @router.get("/{project_name}/scans/zip") -async def download_scans(project_name: str, scan_indices: List[int] = Query(None)): +async def download_scans( + project_name: str, + scan_indices: List[int] = Query(None), + prefer_stacked_photos: bool = Query( + False, + description="Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + ), +): """Download selected scans from a project as a ZIP file stream This endpoint streams selected scans from a project as a ZIP file. @@ -603,18 +738,24 @@ async def download_scans(project_name: str, scan_indices: List[int] = Query(None if not scan: logger.error(f"Scan with index {scan_index} not found") continue - scan_dir = os.path.join(project.path, f"scan{scan_index:02d}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan{scan_index:02d}") + _add_scan_directory_to_zip( + zs, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) except Exception as e: logger.error(f"Failed to add scan {scan_index} to zip: {e}") continue else: filename = f"{project_name}_all_scans.zip" - for scan_id, scan in project.scans.items(): - scan_dir = os.path.join(project.path, f"scan_{scan.index}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan_{scan.index}") + for scan in sorted(project.scans.values(), key=lambda scan_obj: scan_obj.index): + _add_scan_directory_to_zip( + zs, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) zs.add(_serialize_project_for_zip(project), "project_metadata.json") @@ -631,7 +772,7 @@ async def download_scans(project_name: str, scan_indices: List[int] = Query(None ) return response except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"") + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") except Exception as e: print(e) raise HTTPException(status_code=500, detail=str(e)) diff --git a/openscan_firmware/routers/v0_9/projects.py b/openscan_firmware/routers/v0_9/projects.py index 40fd9cf..25c46ff 100644 --- a/openscan_firmware/routers/v0_9/projects.py +++ b/openscan_firmware/routers/v0_9/projects.py @@ -3,6 +3,7 @@ from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel import pathlib +import re from typing import Optional, List, Any import asyncio import os @@ -30,6 +31,7 @@ ) logger = logging.getLogger(__name__) +STACKED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} class DeleteResponse(BaseModel): success: bool @@ -444,12 +446,34 @@ def _serialize_project_for_zip(project: Project) -> str: def _add_project_photos_to_zip(zip_stream, project: Project) -> int: """Add all recorded photo files of a project to a flat zip archive.""" + return _add_project_photos_to_zip_with_strategy( + zip_stream, + project, + prefer_stacked_photos=False, + ) + + +def _add_project_photos_to_zip_with_strategy( + zip_stream, + project: Project, + *, + prefer_stacked_photos: bool, +) -> int: + """Add project photos with optional stacked-preferred selection.""" added = 0 for scan in sorted(project.scans.values(), key=lambda scan_obj: scan_obj.index): - scan_dir = os.path.join(project.path, f"scan{scan.index:02d}") + scan_dir = pathlib.Path(project.path) / f"scan{scan.index:02d}" + preferred_stacked = _get_stacked_photos(scan_dir) if prefer_stacked_photos else [] + + if preferred_stacked: + for stacked_path in preferred_stacked: + zip_stream.add_path(str(stacked_path), arcname=stacked_path.name) + added += 1 + continue + for photo_filename in scan.photos: - photo_path = os.path.join(scan_dir, photo_filename) - if not os.path.exists(photo_path): + photo_path = scan_dir / photo_filename + if not photo_path.exists(): logger.warning( "Photo %s missing on disk for project %s scan %s", photo_filename, @@ -457,11 +481,95 @@ def _add_project_photos_to_zip(zip_stream, project: Project) -> int: scan.index, ) continue - zip_stream.add_path(photo_path, arcname=photo_filename) + zip_stream.add_path(str(photo_path), arcname=photo_filename) added += 1 return added +def _get_stacked_photos(scan_dir: pathlib.Path) -> list[pathlib.Path]: + stacked_dir = scan_dir / "stacked" + if not stacked_dir.is_dir(): + return [] + return sorted( + path + for path in stacked_dir.rglob("*") + if path.is_file() and path.suffix.lower() in STACKED_PHOTO_SUFFIXES + ) + + +def _add_scan_directory_to_zip( + zip_stream, + project: Project, + scan: Scan, + *, + prefer_stacked_photos: bool, +) -> int: + scan_dir = pathlib.Path(project.path) / f"scan{scan.index:02d}" + if not scan_dir.is_dir(): + return 0 + + scan_arc_root = f"scan{scan.index:02d}" + if not prefer_stacked_photos: + zip_stream.add_path(str(scan_dir), scan_arc_root) + return 1 + + stacked_photos = _get_stacked_photos(scan_dir) + if not stacked_photos: + zip_stream.add_path(str(scan_dir), scan_arc_root) + return 1 + + originals_to_skip = set(scan.photos) + metadata_to_skip = {f"metadata/{pathlib.Path(name).stem}.json" for name in originals_to_skip} + added_files = 0 + + for file_path in sorted(scan_dir.rglob("*")): + if not file_path.is_file(): + continue + + rel = file_path.relative_to(scan_dir).as_posix() + if rel in originals_to_skip or rel in metadata_to_skip: + continue + if file_path.parent == scan_dir and file_path.name in originals_to_skip: + continue + + zip_stream.add_path(str(file_path), f"{scan_arc_root}/{rel}") + added_files += 1 + + return added_files + + +def _add_project_to_zip_with_strategy( + zip_stream, + project: Project, + *, + prefer_stacked_photos: bool, +) -> int: + project_root = pathlib.Path(project.path) + scans_by_name = { + f"scan{scan.index:02d}": scan + for scan in project.scans.values() + } + added = 0 + + for entry in sorted(project_root.iterdir(), key=lambda path: path.name): + if entry.is_dir(): + match = re.fullmatch(r"scan(\d+)", entry.name) + if match: + scan = scans_by_name.get(entry.name) + if scan is not None: + added += _add_scan_directory_to_zip( + zip_stream, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) + continue + zip_stream.add_path(str(entry), entry.name) + added += 1 + + return added + + @router.get("/{project_name}/zip") async def download_project( project_name: str, @@ -469,12 +577,18 @@ async def download_project( False, description="If true, stream only photo files without metadata or directory structure.", ), + prefer_stacked_photos: bool = Query( + False, + description="Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + ), ): """Download a project as a ZIP file stream This endpoint streams the entire project directory as a ZIP file, - including all scans, photos, and metadata. When ``photos_only`` is true, - only the recorded photo files are included without metadata or subfolders. + including all scans, photos, and metadata. When ``photos_only`` is true, + only the recorded photo files are included without metadata or subfolders. + When ``prefer_stacked_photos`` is true, stacked JPEG outputs are preferred + per scan and originals are skipped for scans with stacked results. Args: project_name: Name of the project to download @@ -494,10 +608,24 @@ async def download_project( if photos_only: zs = ZipStream(sized=True) zs.comment = f"OpenScan3 Project Photos: {project_name}" - added_files = _add_project_photos_to_zip(zs, project) + added_files = _add_project_photos_to_zip_with_strategy( + zs, + project, + prefer_stacked_photos=prefer_stacked_photos, + ) if added_files == 0: raise HTTPException(status_code=404, detail="No photos available for this project") filename = f"{project_name}_photos.zip" + elif prefer_stacked_photos: + zs = ZipStream(sized=True) + zs.comment = f"OpenScan3 Project: {project_name} (stacked photos preferred)" + _add_project_to_zip_with_strategy( + zs, + project, + prefer_stacked_photos=True, + ) + zs.add(_serialize_project_for_zip(project), "project_metadata.json") + filename = f"{project_name}_stacked_preferred.zip" else: # Create ZipStream from project path zs = ZipStream.from_path(project.path) @@ -566,7 +694,14 @@ async def download_project_model(project_name: str): @router.get("/{project_name}/scans/zip") -async def download_scans(project_name: str, scan_indices: List[int] = Query(None)): +async def download_scans( + project_name: str, + scan_indices: List[int] = Query(None), + prefer_stacked_photos: bool = Query( + False, + description="Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + ), +): """Download selected scans from a project as a ZIP file stream This endpoint streams selected scans from a project as a ZIP file. @@ -603,18 +738,24 @@ async def download_scans(project_name: str, scan_indices: List[int] = Query(None if not scan: logger.error(f"Scan with index {scan_index} not found") continue - scan_dir = os.path.join(project.path, f"scan{scan_index:02d}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan{scan_index:02d}") + _add_scan_directory_to_zip( + zs, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) except Exception as e: logger.error(f"Failed to add scan {scan_index} to zip: {e}") continue else: filename = f"{project_name}_all_scans.zip" - for scan_id, scan in project.scans.items(): - scan_dir = os.path.join(project.path, f"scan_{scan.index}") - if os.path.exists(scan_dir): - zs.add_path(scan_dir, f"scan_{scan.index}") + for scan in sorted(project.scans.values(), key=lambda scan_obj: scan_obj.index): + _add_scan_directory_to_zip( + zs, + project, + scan, + prefer_stacked_photos=prefer_stacked_photos, + ) zs.add(_serialize_project_for_zip(project), "project_metadata.json") @@ -631,7 +772,7 @@ async def download_scans(project_name: str, scan_indices: List[int] = Query(None ) return response except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"") + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") except Exception as e: print(e) raise HTTPException(status_code=500, detail=str(e)) diff --git a/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index 1eb2e5e..3971d9f 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -2449,7 +2449,7 @@ "projects" ], "summary": "Download Project", - "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata. When ``photos_only`` is true,\nonly the recorded photo files are included without metadata or subfolders.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", + "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\n including all scans, photos, and metadata. When ``photos_only`` is true,\n only the recorded photo files are included without metadata or subfolders.\n When ``prefer_stacked_photos`` is true, stacked JPEG outputs are preferred\n per scan and originals are skipped for scans with stacked results.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", "operationId": "download_project", "parameters": [ { @@ -2472,6 +2472,18 @@ "title": "Photos Only" }, "description": "If true, stream only photo files without metadata or directory structure." + }, + { + "name": "prefer_stacked_photos", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + "default": false, + "title": "Prefer Stacked Photos" + }, + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists." } ], "responses": { @@ -2572,6 +2584,18 @@ }, "title": "Scan Indices" } + }, + { + "name": "prefer_stacked_photos", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + "default": false, + "title": "Prefer Stacked Photos" + }, + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists." } ], "responses": { diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index febb3f8..c30bd4f 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -2450,7 +2450,7 @@ "projects" ], "summary": "Download Project", - "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata. When ``photos_only`` is true,\nonly the recorded photo files are included without metadata or subfolders.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", + "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\n including all scans, photos, and metadata. When ``photos_only`` is true,\n only the recorded photo files are included without metadata or subfolders.\n When ``prefer_stacked_photos`` is true, stacked JPEG outputs are preferred\n per scan and originals are skipped for scans with stacked results.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", "operationId": "download_project", "parameters": [ { @@ -2473,6 +2473,18 @@ "title": "Photos Only" }, "description": "If true, stream only photo files without metadata or directory structure." + }, + { + "name": "prefer_stacked_photos", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + "default": false, + "title": "Prefer Stacked Photos" + }, + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists." } ], "responses": { @@ -2573,6 +2585,18 @@ }, "title": "Scan Indices" } + }, + { + "name": "prefer_stacked_photos", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + "default": false, + "title": "Prefer Stacked Photos" + }, + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists." } ], "responses": { diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index b3708c0..4a9d14b 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -2247,7 +2247,7 @@ "projects" ], "summary": "Download Project", - "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata. When ``photos_only`` is true,\nonly the recorded photo files are included without metadata or subfolders.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", + "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\n including all scans, photos, and metadata. When ``photos_only`` is true,\n only the recorded photo files are included without metadata or subfolders.\n When ``prefer_stacked_photos`` is true, stacked JPEG outputs are preferred\n per scan and originals are skipped for scans with stacked results.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", "operationId": "download_project", "parameters": [ { @@ -2270,6 +2270,18 @@ "title": "Photos Only" }, "description": "If true, stream only photo files without metadata or directory structure." + }, + { + "name": "prefer_stacked_photos", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + "default": false, + "title": "Prefer Stacked Photos" + }, + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists." } ], "responses": { @@ -2370,6 +2382,18 @@ }, "title": "Scan Indices" } + }, + { + "name": "prefer_stacked_photos", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + "default": false, + "title": "Prefer Stacked Photos" + }, + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists." } ], "responses": { diff --git a/scripts/openapi/openapi_v0.9.json b/scripts/openapi/openapi_v0.9.json index 1eb2e5e..3971d9f 100644 --- a/scripts/openapi/openapi_v0.9.json +++ b/scripts/openapi/openapi_v0.9.json @@ -2449,7 +2449,7 @@ "projects" ], "summary": "Download Project", - "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\nincluding all scans, photos, and metadata. When ``photos_only`` is true,\nonly the recorded photo files are included without metadata or subfolders.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", + "description": "Download a project as a ZIP file stream\n\nThis endpoint streams the entire project directory as a ZIP file,\n including all scans, photos, and metadata. When ``photos_only`` is true,\n only the recorded photo files are included without metadata or subfolders.\n When ``prefer_stacked_photos`` is true, stacked JPEG outputs are preferred\n per scan and originals are skipped for scans with stacked results.\n\nArgs:\n project_name: Name of the project to download\n\nReturns:\n StreamingResponse: ZIP file stream", "operationId": "download_project", "parameters": [ { @@ -2472,6 +2472,18 @@ "title": "Photos Only" }, "description": "If true, stream only photo files without metadata or directory structure." + }, + { + "name": "prefer_stacked_photos", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + "default": false, + "title": "Prefer Stacked Photos" + }, + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists." } ], "responses": { @@ -2572,6 +2584,18 @@ }, "title": "Scan Indices" } + }, + { + "name": "prefer_stacked_photos", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists.", + "default": false, + "title": "Prefer Stacked Photos" + }, + "description": "Prefer scanXX/stacked JPEGs and skip original photos when stacked output exists." } ], "responses": { diff --git a/tests/routers/test_projects_api.py b/tests/routers/test_projects_api.py index 77d7f72..4c3f645 100644 --- a/tests/routers/test_projects_api.py +++ b/tests/routers/test_projects_api.py @@ -26,8 +26,10 @@ from openscan_firmware.controllers.services.tasks.task_manager import TaskManager from openscan_firmware.main import app, LATEST from openscan_firmware.models.project import Project +from openscan_firmware.models.scan import Scan from openscan_firmware.models.task import Task, TaskStatus from openscan_firmware.config.scan import ScanSetting +from openscan_firmware.config.camera import CameraSettings @pytest.fixture(scope="function") @@ -418,3 +420,129 @@ def __len__(self) -> int: # pragma: no cover - should not be invoked assert response.headers["Content-Disposition"].startswith(f"attachment; filename={project_name}") assert response.headers["Last-Modified"] == str(FakeLargeZipStream.last_modified) assert "Content-Length" not in response.headers + + +def test_download_project_zip_photos_only_prefers_stacked_outputs( + client: TestClient, + project_manager: ProjectManager, + monkeypatch: pytest.MonkeyPatch, +): + class FakeZipStream: + latest = None + last_modified = None + + def __init__(self, *_, **__): + self.added_paths: list[tuple[str, str]] = [] + self.added_metadata: list[tuple[str, str]] = [] + type(self).latest = self + + @classmethod + def from_path(cls, *_: str): + raise AssertionError("from_path should not be used for photos_only downloads") + + def add_path(self, path: str, arcname: str) -> None: + self.added_paths.append((path, arcname)) + + def add(self, data: str, arcname: str) -> None: + self.added_metadata.append((data, arcname)) + + def __iter__(self): + yield b"zip-data" + + monkeypatch.setitem(sys.modules, "zipstream", types.SimpleNamespace(ZipStream=FakeZipStream)) + + project_name = f"zip-pref-stack-{uuid.uuid4().hex[:8]}" + project = project_manager.add_project(project_name) + scan = Scan( + project_name=project.name, + index=1, + settings=ScanSetting(), + camera_settings=CameraSettings(), + ) + scan.photos = ["scan01_001.jpg"] + project.scans["scan01"] = scan + + scan_dir = Path(project.path) / "scan01" + stacked_dir = scan_dir / "stacked" + scan_dir.mkdir(parents=True, exist_ok=True) + stacked_dir.mkdir(parents=True, exist_ok=True) + raw_photo = scan_dir / "scan01_001.jpg" + stacked_photo = stacked_dir / "stacked_scan01_001.jpg" + raw_photo.write_bytes(b"raw") + stacked_photo.write_bytes(b"stacked") + + response = client.get( + f"/latest/projects/{project_name}/zip", + params={"photos_only": "true", "prefer_stacked_photos": "true"}, + ) + + assert response.status_code == 200 + stream = FakeZipStream.latest + assert stream is not None + added_paths = {path for path, _ in stream.added_paths} + assert str(stacked_photo) in added_paths + assert str(raw_photo) not in added_paths + + +def test_download_scans_zip_prefers_stacked_and_skips_original_photos( + client: TestClient, + project_manager: ProjectManager, + monkeypatch: pytest.MonkeyPatch, +): + class FakeZipStream: + latest = None + last_modified = None + + def __init__(self, *_, **__): + self.added_paths: list[tuple[str, str]] = [] + self.added_metadata: list[tuple[str, str]] = [] + self.comment: str | None = None + type(self).latest = self + + def add_path(self, path: str, arcname: str) -> None: + self.added_paths.append((path, arcname)) + + def add(self, data: str, arcname: str) -> None: + self.added_metadata.append((data, arcname)) + + def __iter__(self): + yield b"zip-data" + + monkeypatch.setitem(sys.modules, "zipstream", types.SimpleNamespace(ZipStream=FakeZipStream)) + + project_name = f"scan-zip-pref-stack-{uuid.uuid4().hex[:8]}" + project = project_manager.add_project(project_name) + scan = Scan( + project_name=project.name, + index=1, + settings=ScanSetting(), + camera_settings=CameraSettings(), + ) + scan.photos = ["scan01_001.jpg"] + project.scans["scan01"] = scan + + scan_dir = Path(project.path) / "scan01" + metadata_dir = scan_dir / "metadata" + stacked_dir = scan_dir / "stacked" + scan_dir.mkdir(parents=True, exist_ok=True) + metadata_dir.mkdir(parents=True, exist_ok=True) + stacked_dir.mkdir(parents=True, exist_ok=True) + raw_photo = scan_dir / "scan01_001.jpg" + raw_metadata = metadata_dir / "scan01_001.json" + stacked_photo = stacked_dir / "stacked_scan01_001.jpg" + raw_photo.write_bytes(b"raw") + raw_metadata.write_text("{}", encoding="utf-8") + stacked_photo.write_bytes(b"stacked") + + response = client.get( + f"/latest/projects/{project_name}/scans/zip", + params={"scan_indices": [1], "prefer_stacked_photos": "true"}, + ) + + assert response.status_code == 200 + stream = FakeZipStream.latest + assert stream is not None + added_paths = {path for path, _ in stream.added_paths} + assert str(stacked_photo) in added_paths + assert str(raw_photo) not in added_paths + assert str(raw_metadata) not in added_paths From 050ef2e075324095b1a07e71bdea13f032af7274 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 22 Apr 2026 16:00:25 +0200 Subject: [PATCH 61/75] feat(core): track and manage stacked photo metadata - Added functionality to register and manage stacked photo paths in scan metadata. - Introduced `stacked_size_bytes` to track the total size of stacked JPEG outputs. - Updated task cancellation and completion handlers to persist stacked photo updates. - Enhanced scan size recalculation to account for stacked photo metadata. - Adjusted APIs to handle stacked photo paths and avoid duplication. - Added tests to validate stacked photo handling, scan size updates, and path normalization. --- .../controllers/services/projects.py | 165 ++++++++++++++---- .../tasks/core/focus_stacking_task.py | 38 +++- openscan_firmware/models/scan.py | 9 +- openscan_firmware/routers/next/projects.py | 6 +- openscan_firmware/routers/v0_8/projects.py | 6 +- openscan_firmware/routers/v0_9/projects.py | 6 +- scripts/openapi/openapi_latest.json | 9 +- scripts/openapi/openapi_next.json | 9 +- scripts/openapi/openapi_v0.8.json | 9 +- scripts/openapi/openapi_v0.9.json | 9 +- .../tasks/test_focus_stacking_task.py | 9 + .../services/test_project_manager.py | 88 ++++++++++ tests/routers/test_projects_api.py | 30 ++++ 13 files changed, 349 insertions(+), 44 deletions(-) diff --git a/openscan_firmware/controllers/services/projects.py b/openscan_firmware/controllers/services/projects.py index 6c7937a..c47b652 100644 --- a/openscan_firmware/controllers/services/projects.py +++ b/openscan_firmware/controllers/services/projects.py @@ -46,6 +46,8 @@ logger = logging.getLogger(__name__) +STACKED_DIR_NAME = "stacked" +STACKED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} def _get_project_path(projects_path: str, project_name: str) -> str: @@ -325,9 +327,13 @@ def _ensure_scan_sizes(self, project: Project) -> None: """Recalculate scan sizes to keep metadata in sync with disk state.""" dirty = False for scan in project.scans.values(): - recalculated = self._calculate_scan_size_bytes(project, scan) - if recalculated != scan.total_size_bytes: - scan.total_size_bytes = recalculated + if self._sync_stacked_photos_into_scan_index(project, scan): + dirty = True + + total_size, stacked_size = self._calculate_scan_size_components(project, scan) + if total_size != scan.total_size_bytes or stacked_size != scan.stacked_size_bytes: + scan.total_size_bytes = total_size + scan.stacked_size_bytes = stacked_size scan.last_updated = datetime.now() dirty = True @@ -337,31 +343,67 @@ def _ensure_scan_sizes(self, project: Project) -> None: def _get_scan_directory(self, project: Project, scan: Scan) -> str: return os.path.join(project.path, f"scan{scan.index:02d}") - def _calculate_scan_size_bytes(self, project: Project, scan: Scan) -> int: + def _collect_stacked_photo_relpaths(self, scan_dir: pathlib.Path) -> list[str]: + stacked_dir = scan_dir / STACKED_DIR_NAME + if not stacked_dir.is_dir(): + return [] + + return sorted( + path.relative_to(scan_dir).as_posix() + for path in stacked_dir.rglob("*") + if path.is_file() and path.suffix.lower() in STACKED_PHOTO_SUFFIXES + ) + + def _sync_stacked_photos_into_scan_index(self, project: Project, scan: Scan) -> bool: + scan_dir = pathlib.Path(self._get_scan_directory(project, scan)) + stacked_relpaths = self._collect_stacked_photo_relpaths(scan_dir) + dirty = False + for relpath in stacked_relpaths: + if relpath not in scan.photos: + scan.photos.append(relpath) + dirty = True + return dirty + + def _calculate_scan_size_components(self, project: Project, scan: Scan) -> tuple[int, int]: scan_dir = self._get_scan_directory(project, scan) if not os.path.exists(scan_dir): - return 0 + return 0, 0 base_size = 0 + stacked_size = 0 + scan_dir_path = pathlib.Path(scan_dir) for root, _, files in os.walk(scan_dir): for filename in files: file_path = os.path.join(root, filename) if filename == "scan.json": continue try: - base_size += os.path.getsize(file_path) + file_size = os.path.getsize(file_path) except FileNotFoundError: continue + base_size += file_size + rel_path = pathlib.Path(file_path).relative_to(scan_dir_path) + if ( + rel_path.parts + and rel_path.parts[0] == STACKED_DIR_NAME + and rel_path.suffix.lower() in STACKED_PHOTO_SUFFIXES + ): + stacked_size += file_size def serialized_scan_size(total_size: int) -> int: - payload = scan.model_copy(update={"total_size_bytes": total_size}).model_dump_json(indent=2) + payload = scan.model_copy( + update={ + "total_size_bytes": total_size, + "stacked_size_bytes": stacked_size, + } + ).model_dump_json(indent=2) return base_size + len(payload.encode("utf-8")) target_size = base_size for _ in range(5): new_size = serialized_scan_size(target_size) if new_size == target_size: - return new_size + return new_size, stacked_size target_size = new_size logger.warning( @@ -369,7 +411,11 @@ def serialized_scan_size(total_size: int) -> int: project.name, scan.index, ) - return target_size + return target_size, stacked_size + + def _calculate_scan_size_bytes(self, project: Project, scan: Scan) -> int: + total_size, _ = self._calculate_scan_size_components(project, scan) + return total_size def _recalculate_and_save_scan_size(self, project_name: str, scan_index: int) -> None: project = self.get_project_by_name(project_name) @@ -383,10 +429,17 @@ def _recalculate_and_save_scan_size(self, project_name: str, scan_index: int) -> logger.error("Cannot recalculate size, scan %s missing in project %s", scan_id, project_name) return - scan.total_size_bytes = self._calculate_scan_size_bytes(project, scan) + self._sync_stacked_photos_into_scan_index(project, scan) + total_size, stacked_size = self._calculate_scan_size_components(project, scan) + scan.total_size_bytes = total_size + scan.stacked_size_bytes = stacked_size scan.last_updated = datetime.now() save_project(project) + def recalculate_scan_size(self, project_name: str, scan_index: int) -> None: + """Public wrapper to recalculate and persist scan size metadata.""" + self._recalculate_and_save_scan_size(project_name, scan_index) + def get_project_by_name(self, project_name: str) -> Optional[Project]: """Get a project by name. Returns None if the project does not exist.""" if project_name not in self._projects: @@ -751,22 +804,19 @@ def delete_photos(self, scan: Scan, photo_filenames: list[str]) -> bool: try: project = self._projects[scan.project_name] scan_id = f"scan{scan.index:02d}" - scan_dir = os.path.join(project.path, scan_id) + scan_dir_path = pathlib.Path(project.path, scan_id).resolve() for photo_filename in photo_filenames: - if photo_filename != os.path.basename(photo_filename): - raise ValueError("Photo filename must not contain directories") - - file_path = os.path.join(scan_dir, photo_filename) - if os.path.exists(file_path): + normalized_filename = self._normalize_relative_photo_path(photo_filename) + file_path = self._resolve_scan_relative_path(scan_dir_path, normalized_filename) + if file_path.exists(): os.remove(file_path) - metadata_name = os.path.splitext(photo_filename)[0] + ".json" - metadata_path = os.path.join(scan_dir, "metadata", metadata_name) - if os.path.exists(metadata_path): - os.remove(metadata_path) + for metadata_path in self._metadata_candidates_for_photo(scan_dir_path, file_path): + if metadata_path.exists(): + os.remove(metadata_path) - self._remove_photo_file_record(project, scan, photo_filename) + self._remove_photo_file_record(project, scan, normalized_filename) self._recalculate_and_save_scan_size(scan.project_name, scan.index) @@ -792,8 +842,31 @@ def _register_photo_file(self, project_name: str, scan_index: int, filename: str if scan is None: raise ValueError(f"Scan {scan_index} not found in project {project_name}") - if filename not in scan.photos: - scan.photos.append(filename) + normalized_filename = self._normalize_relative_photo_path(filename) + if normalized_filename not in scan.photos: + scan.photos.append(normalized_filename) + scan.last_updated = datetime.now() + _save_scan_json(project.path, scan) + + def register_photo_files(self, project_name: str, scan_index: int, filenames: list[str]) -> None: + """Register one or more relative photo paths in the scan photo index.""" + project = self.get_project_by_name(project_name) + if project is None: + raise ValueError(f"Project {project_name} does not exist") + + scan_id = f"scan{scan_index:02d}" + scan = project.scans.get(scan_id) + if scan is None: + raise ValueError(f"Scan {scan_index} not found in project {project_name}") + + dirty = False + for filename in filenames: + normalized_filename = self._normalize_relative_photo_path(filename) + if normalized_filename not in scan.photos: + scan.photos.append(normalized_filename) + dirty = True + + if dirty: scan.last_updated = datetime.now() _save_scan_json(project.path, scan) @@ -803,10 +876,34 @@ def _remove_photo_file_record(self, project: Project, scan: Scan, filename: str) scan.last_updated = datetime.now() _save_scan_json(project.path, scan) + def _normalize_relative_photo_path(self, filename: str) -> str: + if not filename: + raise ValueError("Invalid photo filename") + + normalized = pathlib.PurePosixPath(filename.replace("\\", "/")) + if normalized.is_absolute() or any(part in {"", ".", ".."} for part in normalized.parts): + raise ValueError("Invalid photo filename") + return normalized.as_posix() + + def _resolve_scan_relative_path(self, scan_dir: pathlib.Path, filename: str) -> pathlib.Path: + normalized = self._normalize_relative_photo_path(filename) + candidate = (scan_dir / normalized).resolve() + try: + candidate.relative_to(scan_dir) + except ValueError as exc: + raise ValueError("Invalid photo filename") from exc + return candidate + + def _metadata_candidates_for_photo(self, scan_dir: pathlib.Path, photo_path: pathlib.Path) -> list[pathlib.Path]: + metadata_filename = f"{photo_path.stem}.json" + candidates = [scan_dir / "metadata" / metadata_filename] + if photo_path.parent != scan_dir: + candidates.append(photo_path.parent / "metadata" / metadata_filename) + return candidates + def get_photo_file(self, project_name: str, scan_index: int, filename: str) -> tuple[Scan, str, dict | None]: """Return scan, absolute photo path, and optional metadata for a stored photo.""" - if not filename or filename != os.path.basename(filename): - raise ValueError("Invalid photo filename") + normalized_filename = self._normalize_relative_photo_path(filename) project = self.get_project_by_name(project_name) if project is None: @@ -817,19 +914,19 @@ def get_photo_file(self, project_name: str, scan_index: int, filename: str) -> t if scan is None: raise FileNotFoundError(f"Scan {scan_index} not found in project {project_name}") - scan_dir = self._get_scan_directory(project, scan) - photo_path = os.path.join(scan_dir, filename) - if not os.path.exists(photo_path): + scan_dir_path = pathlib.Path(self._get_scan_directory(project, scan)).resolve() + photo_path = self._resolve_scan_relative_path(scan_dir_path, normalized_filename) + if not photo_path.exists(): raise FileNotFoundError(f"Photo {filename} not found") metadata = None - metadata_name = os.path.splitext(filename)[0] + ".json" - metadata_path = os.path.join(scan_dir, "metadata", metadata_name) - if os.path.exists(metadata_path): - with open(metadata_path, "r", encoding="utf-8") as handle: - metadata = json.load(handle) + for metadata_path in self._metadata_candidates_for_photo(scan_dir_path, photo_path): + if metadata_path.exists(): + with open(metadata_path, "r", encoding="utf-8") as handle: + metadata = json.load(handle) + break - return scan, photo_path, metadata + return scan, str(photo_path), metadata def save_scan_path(self, scan: Scan, path_dict) -> None: project = self.get_project_by_name(scan.project_name) diff --git a/openscan_firmware/controllers/services/tasks/core/focus_stacking_task.py b/openscan_firmware/controllers/services/tasks/core/focus_stacking_task.py index 6e6d95e..940c3d1 100644 --- a/openscan_firmware/controllers/services/tasks/core/focus_stacking_task.py +++ b/openscan_firmware/controllers/services/tasks/core/focus_stacking_task.py @@ -114,6 +114,24 @@ async def run(self, project_name: str, scan_index: int) -> AsyncGenerator[TaskPr # Check for cancel if self.is_cancelled(): logger.info("Focus stacking cancelled by user") + if output_paths: + relative_output_paths = [ + Path(path).relative_to(scan_dir).as_posix() + for path in output_paths + ] + await loop.run_in_executor( + None, + project_manager.register_photo_files, + project_name, + scan.index, + relative_output_paths, + ) + await loop.run_in_executor( + None, + project_manager.recalculate_scan_size, + project_name, + scan.index, + ) scan.stacking_task_status.status = TaskStatus.CANCELLED await project_manager.save_scan_state(scan) yield TaskProgress(current=idx, total=total_batches, message="Cancelled by user") @@ -141,6 +159,24 @@ async def run(self, project_name: str, scan_index: int) -> AsyncGenerator[TaskPr logger.info(f"Focus stacking complete: {len(output_paths)} images created in {output_dir}") + relative_output_paths = [ + Path(path).relative_to(scan_dir).as_posix() + for path in output_paths + ] + await loop.run_in_executor( + None, + project_manager.register_photo_files, + project_name, + scan.index, + relative_output_paths, + ) + await loop.run_in_executor( + None, + project_manager.recalculate_scan_size, + project_name, + scan.index, + ) + scan.stacking_task_status.status = TaskStatus.COMPLETED await project_manager.save_scan_state(scan) @@ -176,4 +212,4 @@ def _calibrate_stacker(self, scan_dir: str, num_batches: int): def _stack_batch(self, stacker, image_paths: list, output_path: str): """Stack a single batch (blocking CPU work).""" - stacker.stack(image_paths, output_path) \ No newline at end of file + stacker.stack(image_paths, output_path) diff --git a/openscan_firmware/models/scan.py b/openscan_firmware/models/scan.py index 6e424f4..caec527 100644 --- a/openscan_firmware/models/scan.py +++ b/openscan_firmware/models/scan.py @@ -41,9 +41,14 @@ class Scan(BaseModel): ge=0, description="Total size of all files belonging to the scan, in bytes.", ) + stacked_size_bytes: int = Field( + default=0, + ge=0, + description="Total size of focus-stacked JPEG files in scanXX/stacked, in bytes.", + ) photos: list[str] = Field( default_factory=list, - description="Relative filenames (with extension) of all photos captured for this scan.", + description="Relative photo paths of all photos for this scan (e.g. scan01_001.jpg or stacked/stacked_scan01_001.jpg).", ) task_id: Optional[str] = None @@ -63,4 +68,4 @@ class ScanMetadata(BaseModel): @model_validator(mode="after") def set_cart_coordinates(self) -> "ScanMetadata": self.cart_coordinates = polar_to_cartesian(self.polar_coordinates) - return self \ No newline at end of file + return self diff --git a/openscan_firmware/routers/next/projects.py b/openscan_firmware/routers/next/projects.py index 25c46ff..06ec122 100644 --- a/openscan_firmware/routers/next/projects.py +++ b/openscan_firmware/routers/next/projects.py @@ -518,7 +518,11 @@ def _add_scan_directory_to_zip( zip_stream.add_path(str(scan_dir), scan_arc_root) return 1 - originals_to_skip = set(scan.photos) + originals_to_skip = { + relpath + for relpath in scan.photos + if not relpath.startswith("stacked/") + } metadata_to_skip = {f"metadata/{pathlib.Path(name).stem}.json" for name in originals_to_skip} added_files = 0 diff --git a/openscan_firmware/routers/v0_8/projects.py b/openscan_firmware/routers/v0_8/projects.py index 25c46ff..06ec122 100644 --- a/openscan_firmware/routers/v0_8/projects.py +++ b/openscan_firmware/routers/v0_8/projects.py @@ -518,7 +518,11 @@ def _add_scan_directory_to_zip( zip_stream.add_path(str(scan_dir), scan_arc_root) return 1 - originals_to_skip = set(scan.photos) + originals_to_skip = { + relpath + for relpath in scan.photos + if not relpath.startswith("stacked/") + } metadata_to_skip = {f"metadata/{pathlib.Path(name).stem}.json" for name in originals_to_skip} added_files = 0 diff --git a/openscan_firmware/routers/v0_9/projects.py b/openscan_firmware/routers/v0_9/projects.py index 25c46ff..06ec122 100644 --- a/openscan_firmware/routers/v0_9/projects.py +++ b/openscan_firmware/routers/v0_9/projects.py @@ -518,7 +518,11 @@ def _add_scan_directory_to_zip( zip_stream.add_path(str(scan_dir), scan_arc_root) return 1 - originals_to_skip = set(scan.photos) + originals_to_skip = { + relpath + for relpath in scan.photos + if not relpath.startswith("stacked/") + } metadata_to_skip = {f"metadata/{pathlib.Path(name).stem}.json" for name in originals_to_skip} added_files = 0 diff --git a/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index 3971d9f..57f5966 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -5933,13 +5933,20 @@ "description": "Total size of all files belonging to the scan, in bytes.", "default": 0 }, + "stacked_size_bytes": { + "type": "integer", + "minimum": 0.0, + "title": "Stacked Size Bytes", + "description": "Total size of focus-stacked JPEG files in scanXX/stacked, in bytes.", + "default": 0 + }, "photos": { "items": { "type": "string" }, "type": "array", "title": "Photos", - "description": "Relative filenames (with extension) of all photos captured for this scan." + "description": "Relative photo paths of all photos for this scan (e.g. scan01_001.jpg or stacked/stacked_scan01_001.jpg)." }, "task_id": { "anyOf": [ diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index c30bd4f..27c0ec8 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -6723,13 +6723,20 @@ "description": "Total size of all files belonging to the scan, in bytes.", "default": 0 }, + "stacked_size_bytes": { + "type": "integer", + "minimum": 0.0, + "title": "Stacked Size Bytes", + "description": "Total size of focus-stacked JPEG files in scanXX/stacked, in bytes.", + "default": 0 + }, "photos": { "items": { "type": "string" }, "type": "array", "title": "Photos", - "description": "Relative filenames (with extension) of all photos captured for this scan." + "description": "Relative photo paths of all photos for this scan (e.g. scan01_001.jpg or stacked/stacked_scan01_001.jpg)." }, "task_id": { "anyOf": [ diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index 4a9d14b..a19bdab 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -5484,13 +5484,20 @@ "description": "Total size of all files belonging to the scan, in bytes.", "default": 0 }, + "stacked_size_bytes": { + "type": "integer", + "minimum": 0.0, + "title": "Stacked Size Bytes", + "description": "Total size of focus-stacked JPEG files in scanXX/stacked, in bytes.", + "default": 0 + }, "photos": { "items": { "type": "string" }, "type": "array", "title": "Photos", - "description": "Relative filenames (with extension) of all photos captured for this scan." + "description": "Relative photo paths of all photos for this scan (e.g. scan01_001.jpg or stacked/stacked_scan01_001.jpg)." }, "task_id": { "anyOf": [ diff --git a/scripts/openapi/openapi_v0.9.json b/scripts/openapi/openapi_v0.9.json index 3971d9f..57f5966 100644 --- a/scripts/openapi/openapi_v0.9.json +++ b/scripts/openapi/openapi_v0.9.json @@ -5933,13 +5933,20 @@ "description": "Total size of all files belonging to the scan, in bytes.", "default": 0 }, + "stacked_size_bytes": { + "type": "integer", + "minimum": 0.0, + "title": "Stacked Size Bytes", + "description": "Total size of focus-stacked JPEG files in scanXX/stacked, in bytes.", + "default": 0 + }, "photos": { "items": { "type": "string" }, "type": "array", "title": "Photos", - "description": "Relative filenames (with extension) of all photos captured for this scan." + "description": "Relative photo paths of all photos for this scan (e.g. scan01_001.jpg or stacked/stacked_scan01_001.jpg)." }, "task_id": { "anyOf": [ diff --git a/tests/controllers/services/tasks/test_focus_stacking_task.py b/tests/controllers/services/tasks/test_focus_stacking_task.py index d39026a..f7fee63 100644 --- a/tests/controllers/services/tasks/test_focus_stacking_task.py +++ b/tests/controllers/services/tasks/test_focus_stacking_task.py @@ -99,6 +99,15 @@ async def test_focus_stacking_task_happy_path( assert stack_impl.call_counter["value"] == len(expected_outputs) assert all(path.read_bytes() == b"stacked" for path in expected_outputs) + updated_scan = focus_stacking_environment["project_manager"].get_scan_by_index(project.name, scan.index) + assert updated_scan is not None + expected_relpaths = { + f"stacked/stacked_scan{scan.index:02d}_{position:03d}.jpg" + for position in sorted(focus_stacking_batches) + } + assert expected_relpaths.issubset(set(updated_scan.photos)) + assert updated_scan.stacked_size_bytes > 0 + @pytest.mark.asyncio async def test_focus_stacking_task_pause_and_resume( diff --git a/tests/controllers/services/test_project_manager.py b/tests/controllers/services/test_project_manager.py index 17d6ed4..e150bfd 100644 --- a/tests/controllers/services/test_project_manager.py +++ b/tests/controllers/services/test_project_manager.py @@ -578,3 +578,91 @@ async def test_pm_get_photo_file_returns_metadata( assert os.path.basename(photo_path) == photo_filename assert metadata is not None assert metadata["scan_metadata"]["step"] == 2 + + +@pytest.mark.asyncio +async def test_pm_get_photo_file_supports_stacked_relative_path( + project_manager: ProjectManager, + mock_camera_controller: MagicMock, + sample_scan_settings: ScanSetting, +): + project_name = "PhotoFetchStacked" + project_manager.add_project(name=project_name) + scan = project_manager.add_scan( + project_name=project_name, + camera_controller=mock_camera_controller, + scan_settings=sample_scan_settings, + ) + + project = project_manager.get_project_by_name(project_name) + assert project is not None + scan_dir = Path(project.path) / f"scan{scan.index:02d}" + stacked_dir = scan_dir / "stacked" + stacked_dir.mkdir(parents=True, exist_ok=True) + + stacked_relpath = f"stacked/stacked_scan{scan.index:02d}_001.jpg" + stacked_path = scan_dir / stacked_relpath + stacked_path.write_bytes(b"stacked") + project_manager.register_photo_files(project_name, scan.index, [stacked_relpath]) + + stored_scan, photo_path, metadata = project_manager.get_photo_file( + project_name, + scan.index, + stacked_relpath, + ) + + assert stored_scan.index == scan.index + assert stacked_relpath in stored_scan.photos + assert Path(photo_path) == stacked_path + assert metadata is None + + +@pytest.mark.asyncio +async def test_pm_get_photo_file_rejects_path_traversal( + project_manager: ProjectManager, + mock_camera_controller: MagicMock, + sample_scan_settings: ScanSetting, +): + project_name = "PhotoFetchTraversal" + project_manager.add_project(name=project_name) + scan = project_manager.add_scan( + project_name=project_name, + camera_controller=mock_camera_controller, + scan_settings=sample_scan_settings, + ) + + with pytest.raises(ValueError, match="Invalid photo filename"): + project_manager.get_photo_file(project_name, scan.index, "../outside.jpg") + + +@pytest.mark.asyncio +async def test_pm_recalculate_scan_size_tracks_stacked_size( + project_manager: ProjectManager, + mock_camera_controller: MagicMock, + sample_scan_settings: ScanSetting, +): + project_name = "StackedSize" + project_manager.add_project(name=project_name) + scan = project_manager.add_scan( + project_name=project_name, + camera_controller=mock_camera_controller, + scan_settings=sample_scan_settings, + ) + + project = project_manager.get_project_by_name(project_name) + assert project is not None + scan_dir = Path(project.path) / f"scan{scan.index:02d}" + stacked_dir = scan_dir / "stacked" + stacked_dir.mkdir(parents=True, exist_ok=True) + stacked_relpath = f"stacked/stacked_scan{scan.index:02d}_001.jpg" + stacked_path = scan_dir / stacked_relpath + stacked_path.write_bytes(b"stacked-bytes") + + project_manager.register_photo_files(project_name, scan.index, [stacked_relpath]) + project_manager.recalculate_scan_size(project_name, scan.index) + + updated_scan = project_manager.get_scan_by_index(project_name, scan.index) + assert updated_scan is not None + assert stacked_relpath in updated_scan.photos + assert updated_scan.stacked_size_bytes == stacked_path.stat().st_size + assert updated_scan.total_size_bytes >= updated_scan.stacked_size_bytes diff --git a/tests/routers/test_projects_api.py b/tests/routers/test_projects_api.py index 4c3f645..e8cb9dc 100644 --- a/tests/routers/test_projects_api.py +++ b/tests/routers/test_projects_api.py @@ -546,3 +546,33 @@ def __iter__(self): assert str(stacked_photo) in added_paths assert str(raw_photo) not in added_paths assert str(raw_metadata) not in added_paths + + +def test_get_scan_photo_supports_stacked_relative_path( + client: TestClient, + project_manager: ProjectManager, +): + project_name = f"photo-stacked-{uuid.uuid4().hex[:8]}" + project = project_manager.add_project(project_name) + scan = Scan( + project_name=project.name, + index=1, + settings=ScanSetting(), + camera_settings=CameraSettings(), + ) + stacked_relpath = "stacked/stacked_scan01_001.jpg" + scan.photos = [stacked_relpath] + project.scans["scan01"] = scan + + scan_dir = Path(project.path) / "scan01" + stacked_path = scan_dir / stacked_relpath + stacked_path.parent.mkdir(parents=True, exist_ok=True) + stacked_path.write_bytes(b"stacked") + + response = client.get( + f"/latest/projects/{project_name}/1/photo", + params={"filename": stacked_relpath, "file_only": "true"}, + ) + + assert response.status_code == 200 + assert response.content == b"stacked" From 9a0364f29543788c89e4fde8e88130db00e85f83 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 22 Apr 2026 17:39:16 +0200 Subject: [PATCH 62/75] refactor(api): streamline photo deletion and improve error handling - Simplified the `delete_photos` implementation to remove redundant try-except blocks and improve readability. - Added detailed `Query` descriptions for better API documentation. - Enhanced error handling for missing scans, invalid paths, and unknown exceptions. - Updated and added tests to validate new behaviors for edge cases. --- .../controllers/services/projects.py | 44 +++++++++---------- openscan_firmware/routers/next/projects.py | 19 ++++++-- openscan_firmware/routers/v0_8/projects.py | 19 ++++++-- openscan_firmware/routers/v0_9/projects.py | 19 ++++++-- tests/routers/test_projects_api.py | 39 ++++++++++++++++ 5 files changed, 107 insertions(+), 33 deletions(-) diff --git a/openscan_firmware/controllers/services/projects.py b/openscan_firmware/controllers/services/projects.py index c47b652..610d170 100644 --- a/openscan_firmware/controllers/services/projects.py +++ b/openscan_firmware/controllers/services/projects.py @@ -801,36 +801,32 @@ def delete_scan(self, scan: Scan) -> bool: def delete_photos(self, scan: Scan, photo_filenames: list[str]) -> bool: """Delete one or more photos from a scan in a project""" - try: - project = self._projects[scan.project_name] - scan_id = f"scan{scan.index:02d}" - scan_dir_path = pathlib.Path(project.path, scan_id).resolve() + project = self._projects[scan.project_name] + scan_id = f"scan{scan.index:02d}" + scan_dir_path = pathlib.Path(project.path, scan_id).resolve() - for photo_filename in photo_filenames: - normalized_filename = self._normalize_relative_photo_path(photo_filename) - file_path = self._resolve_scan_relative_path(scan_dir_path, normalized_filename) - if file_path.exists(): - os.remove(file_path) + for photo_filename in photo_filenames: + normalized_filename = self._normalize_relative_photo_path(photo_filename) + file_path = self._resolve_scan_relative_path(scan_dir_path, normalized_filename) + if file_path.exists(): + os.remove(file_path) - for metadata_path in self._metadata_candidates_for_photo(scan_dir_path, file_path): - if metadata_path.exists(): - os.remove(metadata_path) + for metadata_path in self._metadata_candidates_for_photo(scan_dir_path, file_path): + if metadata_path.exists(): + os.remove(metadata_path) - self._remove_photo_file_record(project, scan, normalized_filename) + self._remove_photo_file_record(project, scan, normalized_filename) - self._recalculate_and_save_scan_size(scan.project_name, scan.index) + self._recalculate_and_save_scan_size(scan.project_name, scan.index) - logger.info( - "Deleted photos %s from scan %s in project %s", - photo_filenames, - scan_id, - scan.project_name, - ) + logger.info( + "Deleted photos %s from scan %s in project %s", + photo_filenames, + scan_id, + scan.project_name, + ) - return True - except Exception as e: - logger.error(f"Error deleting photo: {e}", exc_info=True) - return False + return True def _register_photo_file(self, project_name: str, scan_index: int, filename: str) -> None: project = self.get_project_by_name(project_name) diff --git a/openscan_firmware/routers/next/projects.py b/openscan_firmware/routers/next/projects.py index 06ec122..aabe66e 100644 --- a/openscan_firmware/routers/next/projects.py +++ b/openscan_firmware/routers/next/projects.py @@ -162,7 +162,11 @@ async def upload_project_to_cloud(project_name: str, token_override: Optional[st @router.delete("/{project_name}/{scan_index}/photos", response_model=DeleteResponse) -async def delete_photos(project_name: str, scan_index: int, photo_filenames: list[str]): +async def delete_photos( + project_name: str, + scan_index: int, + photo_filenames: list[str] = Query(..., description="Relative photo paths to delete."), +): """Delete photos from a scan in a project Args: @@ -176,14 +180,23 @@ async def delete_photos(project_name: str, scan_index: int, photo_filenames: lis project_manager = get_project_manager() try: scan = project_manager.get_scan_by_index(project_name, scan_index) + if scan is None: + raise HTTPException( + status_code=404, + detail=f"Scan {scan_index} not found in project {project_name}", + ) project_manager.delete_photos(scan, photo_filenames) return DeleteResponse( success=True, message="Photos deleted successfully", deleted=photo_filenames ) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/openscan_firmware/routers/v0_8/projects.py b/openscan_firmware/routers/v0_8/projects.py index 06ec122..aabe66e 100644 --- a/openscan_firmware/routers/v0_8/projects.py +++ b/openscan_firmware/routers/v0_8/projects.py @@ -162,7 +162,11 @@ async def upload_project_to_cloud(project_name: str, token_override: Optional[st @router.delete("/{project_name}/{scan_index}/photos", response_model=DeleteResponse) -async def delete_photos(project_name: str, scan_index: int, photo_filenames: list[str]): +async def delete_photos( + project_name: str, + scan_index: int, + photo_filenames: list[str] = Query(..., description="Relative photo paths to delete."), +): """Delete photos from a scan in a project Args: @@ -176,14 +180,23 @@ async def delete_photos(project_name: str, scan_index: int, photo_filenames: lis project_manager = get_project_manager() try: scan = project_manager.get_scan_by_index(project_name, scan_index) + if scan is None: + raise HTTPException( + status_code=404, + detail=f"Scan {scan_index} not found in project {project_name}", + ) project_manager.delete_photos(scan, photo_filenames) return DeleteResponse( success=True, message="Photos deleted successfully", deleted=photo_filenames ) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/openscan_firmware/routers/v0_9/projects.py b/openscan_firmware/routers/v0_9/projects.py index 06ec122..aabe66e 100644 --- a/openscan_firmware/routers/v0_9/projects.py +++ b/openscan_firmware/routers/v0_9/projects.py @@ -162,7 +162,11 @@ async def upload_project_to_cloud(project_name: str, token_override: Optional[st @router.delete("/{project_name}/{scan_index}/photos", response_model=DeleteResponse) -async def delete_photos(project_name: str, scan_index: int, photo_filenames: list[str]): +async def delete_photos( + project_name: str, + scan_index: int, + photo_filenames: list[str] = Query(..., description="Relative photo paths to delete."), +): """Delete photos from a scan in a project Args: @@ -176,14 +180,23 @@ async def delete_photos(project_name: str, scan_index: int, photo_filenames: lis project_manager = get_project_manager() try: scan = project_manager.get_scan_by_index(project_name, scan_index) + if scan is None: + raise HTTPException( + status_code=404, + detail=f"Scan {scan_index} not found in project {project_name}", + ) project_manager.delete_photos(scan, photo_filenames) return DeleteResponse( success=True, message="Photos deleted successfully", deleted=photo_filenames ) - except FileNotFoundError: - raise HTTPException(status_code=404, detail=f"Project {project_name} not found") + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Project {project_name} not found") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/tests/routers/test_projects_api.py b/tests/routers/test_projects_api.py index e8cb9dc..b407f0c 100644 --- a/tests/routers/test_projects_api.py +++ b/tests/routers/test_projects_api.py @@ -576,3 +576,42 @@ def test_get_scan_photo_supports_stacked_relative_path( assert response.status_code == 200 assert response.content == b"stacked" + + +def test_delete_photos_returns_404_for_missing_scan( + client: TestClient, + project_manager: ProjectManager, +): + project_name = f"delete-missing-scan-{uuid.uuid4().hex[:8]}" + project_manager.add_project(project_name) + + response = client.delete( + f"/latest/projects/{project_name}/99/photos", + params={"photo_filenames": ["scan99_001.jpg"]}, + ) + + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +def test_delete_photos_returns_400_for_invalid_relative_path( + client: TestClient, + project_manager: ProjectManager, +): + project_name = f"delete-invalid-path-{uuid.uuid4().hex[:8]}" + project = project_manager.add_project(project_name) + scan = Scan( + project_name=project.name, + index=1, + settings=ScanSetting(), + camera_settings=CameraSettings(), + ) + project.scans["scan01"] = scan + + response = client.delete( + f"/latest/projects/{project_name}/1/photos", + params={"photo_filenames": ["../escape.jpg"]}, + ) + + assert response.status_code == 400 + assert "invalid photo filename" in response.json()["detail"].lower() From b5a9a13b83f8f3f23b8f9464711f1d298feaf9cf Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 23 Apr 2026 14:38:10 +0200 Subject: [PATCH 63/75] feat(api): exclude stacked photos by default in ZIP downloads - Updated logic to filter out "stacked/" photos from ZIP downloads unless explicitly preferred. - Applied changes across multiple API versions for consistency. - Added tests to validate exclusion behavior of stacked photos in `photos_only` downloads. --- openscan_firmware/routers/next/projects.py | 8 ++- openscan_firmware/routers/v0_8/projects.py | 8 ++- openscan_firmware/routers/v0_9/projects.py | 8 ++- tests/routers/test_projects_api.py | 62 ++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/openscan_firmware/routers/next/projects.py b/openscan_firmware/routers/next/projects.py index aabe66e..0dcac38 100644 --- a/openscan_firmware/routers/next/projects.py +++ b/openscan_firmware/routers/next/projects.py @@ -484,7 +484,13 @@ def _add_project_photos_to_zip_with_strategy( added += 1 continue - for photo_filename in scan.photos: + original_photo_filenames = [ + photo_filename + for photo_filename in scan.photos + if not photo_filename.startswith("stacked/") + ] + + for photo_filename in original_photo_filenames: photo_path = scan_dir / photo_filename if not photo_path.exists(): logger.warning( diff --git a/openscan_firmware/routers/v0_8/projects.py b/openscan_firmware/routers/v0_8/projects.py index aabe66e..0dcac38 100644 --- a/openscan_firmware/routers/v0_8/projects.py +++ b/openscan_firmware/routers/v0_8/projects.py @@ -484,7 +484,13 @@ def _add_project_photos_to_zip_with_strategy( added += 1 continue - for photo_filename in scan.photos: + original_photo_filenames = [ + photo_filename + for photo_filename in scan.photos + if not photo_filename.startswith("stacked/") + ] + + for photo_filename in original_photo_filenames: photo_path = scan_dir / photo_filename if not photo_path.exists(): logger.warning( diff --git a/openscan_firmware/routers/v0_9/projects.py b/openscan_firmware/routers/v0_9/projects.py index aabe66e..0dcac38 100644 --- a/openscan_firmware/routers/v0_9/projects.py +++ b/openscan_firmware/routers/v0_9/projects.py @@ -484,7 +484,13 @@ def _add_project_photos_to_zip_with_strategy( added += 1 continue - for photo_filename in scan.photos: + original_photo_filenames = [ + photo_filename + for photo_filename in scan.photos + if not photo_filename.startswith("stacked/") + ] + + for photo_filename in original_photo_filenames: photo_path = scan_dir / photo_filename if not photo_path.exists(): logger.warning( diff --git a/tests/routers/test_projects_api.py b/tests/routers/test_projects_api.py index b407f0c..ea17e03 100644 --- a/tests/routers/test_projects_api.py +++ b/tests/routers/test_projects_api.py @@ -484,6 +484,68 @@ def __iter__(self): assert str(raw_photo) not in added_paths +def test_download_project_zip_photos_only_excludes_stacked_without_preference( + client: TestClient, + project_manager: ProjectManager, + monkeypatch: pytest.MonkeyPatch, +): + class FakeZipStream: + latest = None + last_modified = None + + def __init__(self, *_, **__): + self.added_paths: list[tuple[str, str]] = [] + self.added_metadata: list[tuple[str, str]] = [] + type(self).latest = self + + @classmethod + def from_path(cls, *_: str): + raise AssertionError("from_path should not be used for photos_only downloads") + + def add_path(self, path: str, arcname: str) -> None: + self.added_paths.append((path, arcname)) + + def add(self, data: str, arcname: str) -> None: + self.added_metadata.append((data, arcname)) + + def __iter__(self): + yield b"zip-data" + + monkeypatch.setitem(sys.modules, "zipstream", types.SimpleNamespace(ZipStream=FakeZipStream)) + + project_name = f"zip-photos-only-raw-{uuid.uuid4().hex[:8]}" + project = project_manager.add_project(project_name) + scan = Scan( + project_name=project.name, + index=1, + settings=ScanSetting(), + camera_settings=CameraSettings(), + ) + scan.photos = ["scan01_001.jpg", "stacked/stacked_scan01_001.jpg"] + project.scans["scan01"] = scan + + scan_dir = Path(project.path) / "scan01" + stacked_dir = scan_dir / "stacked" + scan_dir.mkdir(parents=True, exist_ok=True) + stacked_dir.mkdir(parents=True, exist_ok=True) + raw_photo = scan_dir / "scan01_001.jpg" + stacked_photo = stacked_dir / "stacked_scan01_001.jpg" + raw_photo.write_bytes(b"raw") + stacked_photo.write_bytes(b"stacked") + + response = client.get( + f"/latest/projects/{project_name}/zip", + params={"photos_only": "true"}, + ) + + assert response.status_code == 200 + stream = FakeZipStream.latest + assert stream is not None + added_paths = {path for path, _ in stream.added_paths} + assert str(raw_photo) in added_paths + assert str(stacked_photo) not in added_paths + + def test_download_scans_zip_prefers_stacked_and_skips_original_photos( client: TestClient, project_manager: ProjectManager, From 16973b202d09453d59cb9f8e3d5e71e4c3b1f31c Mon Sep 17 00:00:00 2001 From: esto Date: Thu, 23 Apr 2026 14:40:28 +0200 Subject: [PATCH 64/75] chore: bump project version to 0.11.3 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 86fa536..2dd09ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openscan-firmware" -version = "0.11.2" +version = "0.11.3" description = "OpenScan3 - Raspberry Pi based photogrammetry scanner (FastAPI-based application)" readme = "README.md" requires-python = ">=3.11" From 9e8c3271898824f7652443e6cc4d51700f414101 Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:51:27 +0200 Subject: [PATCH 65/75] Fix LightConfig PWM defaults and range validation --- openscan_firmware/config/light.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openscan_firmware/config/light.py b/openscan_firmware/config/light.py index 4ac77b8..90b6fa0 100644 --- a/openscan_firmware/config/light.py +++ b/openscan_firmware/config/light.py @@ -14,7 +14,7 @@ class LightConfig(BaseModel): ) pwm_frequency: float = Field(10000.0, ge=50.0, le=100000.0, description="PWM frequency for led driver.") pwm_min: float = Field(0.0, ge=0, le=3.3, description="Minimum pwm voltage for led driver.") - pwm_max: float = Field(0.0, ge=0, le=3.3, description="Maximum pwm voltage for led driver.") + pwm_max: float = Field(3.3, ge=0, le=3.3, description="Maximum pwm voltage for led driver.") @model_validator(mode="before") @classmethod @@ -45,11 +45,9 @@ def ensure_pins(cls, values): @model_validator(mode="after") def validate_pwm_range(self): """ - Ensures pwm_min <= pwm_max and consistency with pwm_support. + Ensures a valid range when PWM mode is enabled. """ - - if self.pwm_min >= self.pwm_max: + if self.pwm_support and self.pwm_min >= self.pwm_max: raise ValueError("pwm_min must be less than pwm_max") - return self From 4e80bf3c7de3714396089a902b4dd64861caefc8 Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:52:01 +0200 Subject: [PATCH 66/75] Fix PWM pin cleanup for hardware-backed outputs --- openscan_firmware/controllers/hardware/gpio.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openscan_firmware/controllers/hardware/gpio.py b/openscan_firmware/controllers/hardware/gpio.py index 08a9de2..4ff6b0d 100644 --- a/openscan_firmware/controllers/hardware/gpio.py +++ b/openscan_firmware/controllers/hardware/gpio.py @@ -232,11 +232,15 @@ def cleanup_all_pins(): pins_to_remove = list(_pwm_pins.keys()) # Create a copy of keys to iterate over for pin in pins_to_remove: try: - _pwm_pins[pin].close() + dev = _pwm_pins[pin] + if isinstance(dev, int): + hwpwm.release(dev) + else: + dev.close() del _pwm_pins[pin] # Remove from tracking dict after successful close - logger.debug(f"Output pin {pin} closed.") + logger.debug(f"PWM pin {pin} closed.") except Exception as e: - logger.error(f"Error closing output pin {pin}: {e}", exc_info=True) + logger.error(f"Error closing PWM pin {pin}: {e}", exc_info=True) # Close output pins pins_to_remove = list(_output_pins.keys()) # Create a copy of keys to iterate over @@ -259,7 +263,10 @@ def cleanup_all_pins(): logger.error(f"Error closing button on pin {pin}: {e}", exc_info=True) # Double check if dictionaries are empty - if not _output_pins and not _buttons: + if not _output_pins and not _pwm_pins and not _buttons: logger.info("GPIO cleanup successful. All tracked pins released.") else: - logger.warning(f"GPIO cleanup potentially incomplete. Remaining outputs: {list(_output_pins.keys())}, Remaining buttons: {list(_buttons.keys())}") + logger.warning( + f"GPIO cleanup potentially incomplete. Remaining outputs: {list(_output_pins.keys())}, " + f"Remaining PWM: {list(_pwm_pins.keys())}, Remaining buttons: {list(_buttons.keys())}" + ) From ec19a7234c06ad5dd79ef32a2a7038c4763661ef Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:52:19 +0200 Subject: [PATCH 67/75] Fix hardware PWM release write call --- openscan_firmware/utils/pwm_hardware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openscan_firmware/utils/pwm_hardware.py b/openscan_firmware/utils/pwm_hardware.py index fabb51a..719d140 100644 --- a/openscan_firmware/utils/pwm_hardware.py +++ b/openscan_firmware/utils/pwm_hardware.py @@ -116,7 +116,7 @@ def release(pin: int): if pwm.exists(): try: - _HwPWM.write(pwm / "enable", 0) + _HwPWM._write(pwm / "enable", 0) except: pass From 25bf91b459bf4daf597a1ef517728626bc69f724 Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:52:41 +0200 Subject: [PATCH 68/75] Guard light intensity wake-up via shared idle helper --- openscan_firmware/controllers/hardware/lights.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/openscan_firmware/controllers/hardware/lights.py b/openscan_firmware/controllers/hardware/lights.py index dd6fd41..6c4a508 100644 --- a/openscan_firmware/controllers/hardware/lights.py +++ b/openscan_firmware/controllers/hardware/lights.py @@ -120,12 +120,7 @@ async def set_value(self, value: float): self._value = 100 else: self._value = value - #resume from idle - if self.is_idle(): - logger.info("Device idle, must exit before") - await self.send_event(HardwareEvent.LIGHT_EVENT) - else: - self.refresh() + await self._wake_if_idle(HardwareEvent.LIGHT_EVENT) logger.info(f"Light '{self.model.name}' value set to {self._value}.") From 7740c71ad085f95277f34655a03ea7f187146b5b Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:54:04 +0200 Subject: [PATCH 69/75] Expose light intensity in API and stabilize value handling --- .../controllers/hardware/lights.py | 5 +++-- openscan_firmware/routers/next/lights.py | 1 + tests/controllers/hardware/test_light.py | 19 ++++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/openscan_firmware/controllers/hardware/lights.py b/openscan_firmware/controllers/hardware/lights.py index 6c4a508..0c8ecd9 100644 --- a/openscan_firmware/controllers/hardware/lights.py +++ b/openscan_firmware/controllers/hardware/lights.py @@ -28,7 +28,7 @@ def __init__(self, light: Light): on_change=self._apply_settings_to_hardware ) self._is_on = False - self._value = self.settings.pwm_max + self._value = 100.0 # no idle callbacks self.is_idle = lambda: True @@ -70,7 +70,7 @@ def refresh(self): logger.info(f"Light '{self.model.name}' idle.") for pin in self.settings.pins: if self.settings.pwm_support: - gpio.set_pwm_pin(pin, self.settings.pwm_min) + gpio.set_pwm_pin(pin, self.settings.pwm_min / 3.3) else: gpio.set_output_pin(pin, False) else: @@ -121,6 +121,7 @@ async def set_value(self, value: float): else: self._value = value await self._wake_if_idle(HardwareEvent.LIGHT_EVENT) + schedule_device_status_broadcast([f"lights.{self.model.name}.value"]) logger.info(f"Light '{self.model.name}' value set to {self._value}.") diff --git a/openscan_firmware/routers/next/lights.py b/openscan_firmware/routers/next/lights.py index 5e467f4..c363209 100644 --- a/openscan_firmware/routers/next/lights.py +++ b/openscan_firmware/routers/next/lights.py @@ -14,6 +14,7 @@ class LightStatusResponse(BaseModel): name: str is_on: bool + value: float settings: LightConfig diff --git a/tests/controllers/hardware/test_light.py b/tests/controllers/hardware/test_light.py index 5a93cd3..d08ec00 100644 --- a/tests/controllers/hardware/test_light.py +++ b/tests/controllers/hardware/test_light.py @@ -99,5 +99,22 @@ def test_lightcontroller_get_status(light_config_with_pins, idle_callbacks): assert isinstance(status, dict) assert status["name"] == "test_light" assert status["is_on"] is False # Light not turned on after initializing + assert status["value"] == 100.0 assert isinstance(status["settings"], dict) - assert status["settings"]["pins"] == light_config_with_pins.pins \ No newline at end of file + assert status["settings"]["pins"] == light_config_with_pins.pins + + +@pytest.mark.asyncio +async def test_set_value_clamps_and_updates_status(light_config_with_pins, idle_callbacks): + light = Light(name="test_light", settings=light_config_with_pins) + controller = LightController(light) + controller.set_idle_callbacks(*idle_callbacks) + + await controller.set_value(150) + assert controller.get_status()["value"] == 100 + + await controller.set_value(-2) + assert controller.get_status()["value"] == 0 + + await controller.set_value(42.5) + assert controller.get_status()["value"] == 42.5 From 645382d8efe7e18cbf2f69bd13b36734751f0097 Mon Sep 17 00:00:00 2001 From: esto-openscan <193278706+esto-openscan@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:23:07 +0200 Subject: [PATCH 70/75] Add developer notes for PWM abstraction in OpenScan3 --- docs/PWM.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/PWM.md diff --git a/docs/PWM.md b/docs/PWM.md new file mode 100644 index 0000000..8b4f7b4 --- /dev/null +++ b/docs/PWM.md @@ -0,0 +1,35 @@ +# PWM in OpenScan3 + +This is a short developer note about the PWM abstraction used in OpenScan3. + +## Summary + +OpenScan3 handles light intensity as a percentage (`0..100`) at controller/API level, then maps that value to a PWM duty cycle (`0..1`) based on configured voltage bounds (`pwm_min`, `pwm_max`). + +Main modules: + +- `openscan_firmware/controllers/hardware/lights.py` + - Owns brightness state (`value` in percent) and mapping logic. +- `openscan_firmware/controllers/hardware/gpio.py` + - Selects hardware PWM when available, otherwise software PWM fallback. +- `openscan_firmware/utils/pwm_hardware.py` + - Low-level hardware PWM implementation for Raspberry Pi (`/sys/class/pwm` + pinctrl). + +## Raspberry Pi Setup Requirement + +For hardware PWM support, add the following to `/boot/firmware/config.txt`: + +```txt +dtparam=audio=off +dtoverlay=pwm-2chan +``` + +Important: + +- PWM and onboard audio are mutually exclusive with this setup. +- If audio is required, use a separate external PWM chip on the board. + +## Practical Note + +`pwm_hardware.py` is the utility-layer solution for hardware PWM. +As long as the boot config above is applied, the rest is handled by the OpenScan3 abstraction. From 27c8457382959b6b294857aa5ac2e80925f558c2 Mon Sep 17 00:00:00 2001 From: esto Date: Tue, 28 Apr 2026 12:41:09 +0200 Subject: [PATCH 71/75] feat(scan_task): add pre-capture pause to reduce vibration impact - Introduced `pause_before_capture_ms` setting to allow configurable delays before photo capture. - Enhanced scan logic to detect cancellation before and during the pause. - Updated schema to include the new pause setting with validation. --- openscan_firmware/config/scan.py | 5 +++ .../services/tasks/core/scan_task.py | 16 +++++++++ scripts/openapi/openapi_latest.json | 35 +++++++++++-------- scripts/openapi/openapi_next.json | 35 +++++++++++-------- scripts/openapi/openapi_v0.8.json | 35 +++++++++++-------- scripts/openapi/openapi_v0.9.json | 35 +++++++++++-------- 6 files changed, 105 insertions(+), 56 deletions(-) diff --git a/openscan_firmware/config/scan.py b/openscan_firmware/config/scan.py index 1f6a6e9..458c044 100644 --- a/openscan_firmware/config/scan.py +++ b/openscan_firmware/config/scan.py @@ -41,6 +41,11 @@ class ScanSetting(BaseModel): focus_stacks: int = Field(1, ge=1, le=99, description="Number of photos with different focus per position." "This ignores AF and you need to set a focus range." "Focus values will then be evenly spaced between min and max.") + pause_before_capture_ms: int = Field( + 0, + ge=0, + description="Pause in milliseconds before capture to let vibrations settle.", + ) focus_range: Tuple[ confloat(ge=0.0, le=15.0), confloat(ge=0.0, le=15.0)] = Field(default=(10.0, 15.0), diff --git a/openscan_firmware/controllers/services/tasks/core/scan_task.py b/openscan_firmware/controllers/services/tasks/core/scan_task.py index ee1eb9b..b3acabb 100644 --- a/openscan_firmware/controllers/services/tasks/core/scan_task.py +++ b/openscan_firmware/controllers/services/tasks/core/scan_task.py @@ -435,6 +435,10 @@ async def _capture_photos_at_position(self, current_point: PolarPoint3D, index: """ try: logger.debug("Capturing photo at position %s", current_point) + await self._wait_before_capture() + if self.is_cancelled(): + logger.info("Cancellation detected before capture at position %s.", index) + return if not self._ctx.focus_context or not self._ctx.focus_context["enabled"]: # Single photo capture @@ -492,6 +496,18 @@ async def _capture_photos_at_position(self, current_point: PolarPoint3D, index: logger.error("Error taking photo at position %s: %s", index, e, exc_info=True) raise + async def _wait_before_capture(self) -> None: + """Pause before capture to allow motor-induced vibrations to settle.""" + delay_ms = int(self._ctx.scan.settings.pause_before_capture_ms or 0) + if delay_ms <= 0: + return + + logger.debug("Waiting %d ms before capture", delay_ms) + await self.wait_for_pause() + if self.is_cancelled(): + return + await asyncio.sleep(delay_ms / 1000) + async def _cleanup_scan(self) -> None: """Cleanup after scan completion or failure and reset focus settings if needed.""" # Lazy import to avoid hardware side effects on module import diff --git a/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index 57f5966..db1711d 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -1933,22 +1933,22 @@ "type": "integer", "title": "Scan Index" } + }, + { + "name": "photo_filenames", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Relative photo paths to delete.", + "title": "Photo Filenames" + }, + "description": "Relative photo paths to delete." } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Photo Filenames" - } - } - } - }, "responses": { "200": { "description": "Successful Response", @@ -6072,6 +6072,13 @@ "description": "Number of photos with different focus per position.This ignores AF and you need to set a focus range.Focus values will then be evenly spaced between min and max.", "default": 1 }, + "pause_before_capture_ms": { + "type": "integer", + "minimum": 0.0, + "title": "Pause Before Capture Ms", + "description": "Pause in milliseconds before capture to let vibrations settle.", + "default": 0 + }, "focus_range": { "prefixItems": [ { diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index 27c0ec8..4f70065 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -1934,22 +1934,22 @@ "type": "integer", "title": "Scan Index" } + }, + { + "name": "photo_filenames", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Relative photo paths to delete.", + "title": "Photo Filenames" + }, + "description": "Relative photo paths to delete." } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Photo Filenames" - } - } - } - }, "responses": { "200": { "description": "Successful Response", @@ -6862,6 +6862,13 @@ "description": "Number of photos with different focus per position.This ignores AF and you need to set a focus range.Focus values will then be evenly spaced between min and max.", "default": 1 }, + "pause_before_capture_ms": { + "type": "integer", + "minimum": 0.0, + "title": "Pause Before Capture Ms", + "description": "Pause in milliseconds before capture to let vibrations settle.", + "default": 0 + }, "focus_range": { "prefixItems": [ { diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index a19bdab..386e214 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -1731,22 +1731,22 @@ "type": "integer", "title": "Scan Index" } + }, + { + "name": "photo_filenames", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Relative photo paths to delete.", + "title": "Photo Filenames" + }, + "description": "Relative photo paths to delete." } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Photo Filenames" - } - } - } - }, "responses": { "200": { "description": "Successful Response", @@ -5623,6 +5623,13 @@ "description": "Number of photos with different focus per position.This ignores AF and you need to set a focus range.Focus values will then be evenly spaced between min and max.", "default": 1 }, + "pause_before_capture_ms": { + "type": "integer", + "minimum": 0.0, + "title": "Pause Before Capture Ms", + "description": "Pause in milliseconds before capture to let vibrations settle.", + "default": 0 + }, "focus_range": { "prefixItems": [ { diff --git a/scripts/openapi/openapi_v0.9.json b/scripts/openapi/openapi_v0.9.json index 57f5966..db1711d 100644 --- a/scripts/openapi/openapi_v0.9.json +++ b/scripts/openapi/openapi_v0.9.json @@ -1933,22 +1933,22 @@ "type": "integer", "title": "Scan Index" } + }, + { + "name": "photo_filenames", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Relative photo paths to delete.", + "title": "Photo Filenames" + }, + "description": "Relative photo paths to delete." } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Photo Filenames" - } - } - } - }, "responses": { "200": { "description": "Successful Response", @@ -6072,6 +6072,13 @@ "description": "Number of photos with different focus per position.This ignores AF and you need to set a focus range.Focus values will then be evenly spaced between min and max.", "default": 1 }, + "pause_before_capture_ms": { + "type": "integer", + "minimum": 0.0, + "title": "Pause Before Capture Ms", + "description": "Pause in milliseconds before capture to let vibrations settle.", + "default": 0 + }, "focus_range": { "prefixItems": [ { From 0abdd1735506d7f65cfe49f8da4a8fc34493f567 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 29 Apr 2026 12:56:22 +0200 Subject: [PATCH 72/75] refactor(scan_config): replace confloat with `Annotated` for focus range validation - Updated `focus_range` type in `ScanSetting` to use `Annotated` for clearer and more reusable validation constraints. - Replaced `confloat` with the `FocusValue` type alias for focus range fields. --- openscan_firmware/config/scan.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openscan_firmware/config/scan.py b/openscan_firmware/config/scan.py index 458c044..0b4db58 100644 --- a/openscan_firmware/config/scan.py +++ b/openscan_firmware/config/scan.py @@ -1,9 +1,12 @@ -from pydantic import BaseModel, Field, SerializerFunctionWrapHandler, confloat, model_serializer -from typing import Tuple, Literal +from pydantic import BaseModel, Field, SerializerFunctionWrapHandler, model_serializer +from typing import Annotated, Literal from openscan_firmware.models.paths import PathMethod +FocusValue = Annotated[float, Field(ge=0.0, le=15.0)] + + class ScanSetting(BaseModel): path_method: PathMethod = Field( default=PathMethod.FIBONACCI, @@ -46,10 +49,10 @@ class ScanSetting(BaseModel): ge=0, description="Pause in milliseconds before capture to let vibrations settle.", ) - focus_range: Tuple[ - confloat(ge=0.0, le=15.0), - confloat(ge=0.0, le=15.0)] = Field(default=(10.0, 15.0), - description="Minimum and maximum focus distance in diopters.") + focus_range: tuple[FocusValue, FocusValue] = Field( + default=(10.0, 15.0), + description="Minimum and maximum focus distance in diopters.", + ) @property def focus_positions(self) -> list[float]: From 72f737695bdf33f43881cc7016ea9733e52f31a9 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 29 Apr 2026 13:02:47 +0200 Subject: [PATCH 73/75] test(scan_task): add tests for pre-capture pause and cancellation scenarios - Added tests for `pause_before_capture_ms` behavior, including handling of delay, cancellation, and resume scenarios. - Improved test coverage for photo capture logic during cancellation. - Verified default pause setting for legacy scan configurations. --- tests/config/test_scan_config.py | 18 +++ tests/controllers/services/test_scan_task.py | 118 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/tests/config/test_scan_config.py b/tests/config/test_scan_config.py index e01dad0..90999ad 100644 --- a/tests/config/test_scan_config.py +++ b/tests/config/test_scan_config.py @@ -11,6 +11,24 @@ def test_scan_settings_omit_unset_phi_fields_from_json_dump() -> None: assert "max_phi" not in payload +def test_scan_settings_default_pause_before_capture_ms_for_legacy_payload() -> None: + settings = ScanSetting.model_validate( + { + "path_method": "fibonacci", + "points": 10, + "min_theta": 0.0, + "max_theta": 170.0, + "optimize_path": True, + "optimization_algorithm": "nearest_neighbor", + "focus_stacks": 1, + "focus_range": [10.0, 15.0], + "image_format": "jpeg", + } + ) + + assert settings.pause_before_capture_ms == 0 + + def test_external_trigger_run_settings_omit_unset_phi_fields_from_json_dump() -> None: settings = ExternalTriggerRunSettings(trigger_name="external-camera") diff --git a/tests/controllers/services/test_scan_task.py b/tests/controllers/services/test_scan_task.py index 62a4b7a..54c5ae7 100644 --- a/tests/controllers/services/test_scan_task.py +++ b/tests/controllers/services/test_scan_task.py @@ -259,6 +259,124 @@ def test_generate_scan_path_passes_optional_phi_constraints_when_set() -> None: } assert list(path_dict.keys()) == [PolarPoint3D(theta=10.0, fi=20.0, r=250.0)] + +@pytest.mark.asyncio +async def test_wait_before_capture_uses_configured_delay(sample_scan_model: Scan): + scan = sample_scan_model.model_copy(deep=True) + scan.settings.pause_before_capture_ms = 250 + + task_model = Task(name="scan_task", task_type="core") + scan_task = ScanTask(task_model) + scan_task._ctx = ScanRuntime( + scan=scan, + camera_controller=MagicMock(), + project_manager=MagicMock(), + path_dict={}, + focus_context=None, + ) + scan_task.wait_for_pause = AsyncMock(return_value=None) + + with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep_mock: + await scan_task._wait_before_capture() + + scan_task.wait_for_pause.assert_awaited_once() + sleep_mock.assert_awaited_once_with(0.25) + + +@pytest.mark.asyncio +async def test_wait_before_capture_skips_sleep_when_cancelled_after_pause(sample_scan_model: Scan): + scan = sample_scan_model.model_copy(deep=True) + scan.settings.pause_before_capture_ms = 250 + + task_model = Task(name="scan_task", task_type="core") + scan_task = ScanTask(task_model) + scan_task._ctx = ScanRuntime( + scan=scan, + camera_controller=MagicMock(), + project_manager=MagicMock(), + path_dict={}, + focus_context=None, + ) + + async def cancel_during_pause_wait() -> None: + scan_task.cancel() + + scan_task.wait_for_pause = AsyncMock(side_effect=cancel_during_pause_wait) + + with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep_mock: + await scan_task._wait_before_capture() + + scan_task.wait_for_pause.assert_awaited_once() + sleep_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_wait_before_capture_blocks_while_paused_and_resumes(sample_scan_model: Scan): + scan = sample_scan_model.model_copy(deep=True) + scan.settings.pause_before_capture_ms = 1 + + task_model = Task(name="scan_task", task_type="core") + scan_task = ScanTask(task_model) + scan_task._ctx = ScanRuntime( + scan=scan, + camera_controller=MagicMock(), + project_manager=MagicMock(), + path_dict={}, + focus_context=None, + ) + + scan_task.pause() + wait_task = asyncio.create_task(scan_task._wait_before_capture()) + await asyncio.sleep(0.01) + + assert not wait_task.done() + + scan_task.resume() + await asyncio.wait_for(wait_task, timeout=1.0) + + +@pytest.mark.asyncio +async def test_capture_skips_photo_when_cancelled_before_capture_delay(sample_scan_model: Scan, fake_photo_data: PhotoData): + scan = sample_scan_model.model_copy(deep=True) + scan.settings.pause_before_capture_ms = 250 + + camera_controller = MagicMock() + camera_controller.photo = MagicMock(return_value=fake_photo_data) + + project_manager = MagicMock() + project_manager.add_photo_async = AsyncMock(return_value=None) + + task_model = Task(name="scan_task", task_type="core") + scan_task = ScanTask(task_model) + scan_task._ctx = ScanRuntime( + scan=scan, + camera_controller=camera_controller, + project_manager=project_manager, + path_dict={}, + focus_context=None, + ) + + async def cancel_during_pause_wait() -> None: + scan_task.cancel() + + scan_task.wait_for_pause = AsyncMock(side_effect=cancel_during_pause_wait) + + with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep_mock: + await scan_task._capture_photos_at_position(PolarPoint3D(theta=0, fi=0), 0) + + sleep_mock.assert_not_awaited() + camera_controller.photo.assert_not_called() + project_manager.add_photo_async.assert_not_called() + @pytest.mark.asyncio @patch('openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller') @patch('openscan_firmware.controllers.services.tasks.core.scan_task.get_project_manager') From 7d7b8908c6d565aaa4832a88631451ab9bc49133 Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 29 Apr 2026 13:59:48 +0200 Subject: [PATCH 74/75] feat(tests): add unit tests for constrained path generation and default phi constraints - Added new tests for `get_constrained_path` to validate behavior for fixed theta and phi constraints, as well as fully fixed positions. - Updated existing tests to reflect default `min_phi` and `max_phi` values. - Adjusted validation logic to handle equal theta/phi bounds and default values. - Enhanced path generation to account for fully constrained positions and edge cases. --- openscan_firmware/config/scan.py | 4 +- openscan_firmware/utils/paths/paths.py | 15 +++--- tests/config/test_scan_config.py | 6 +-- tests/controllers/services/test_scan_task.py | 27 +++++++++- tests/utils/test_paths.py | 57 ++++++++++++++++++++ 5 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 tests/utils/test_paths.py diff --git a/openscan_firmware/config/scan.py b/openscan_firmware/config/scan.py index 1f6a6e9..4c5433e 100644 --- a/openscan_firmware/config/scan.py +++ b/openscan_firmware/config/scan.py @@ -22,13 +22,13 @@ class ScanSetting(BaseModel): max_theta: float = Field(125.0, ge=0.0, le=180.0, description="Maximum theta angle in degrees for constrained paths.") min_phi: float | None = Field( - default=None, + default=0, ge=0.0, le=360.0, description="Optional minimum phi angle in degrees for constrained paths.", ) max_phi: float | None = Field( - default=None, + default=360.0, ge=0.0, le=360.0, description="Optional maximum phi angle in degrees for constrained paths.", diff --git a/openscan_firmware/utils/paths/paths.py b/openscan_firmware/utils/paths/paths.py index e12a4a9..bf71275 100644 --- a/openscan_firmware/utils/paths/paths.py +++ b/openscan_firmware/utils/paths/paths.py @@ -116,15 +116,12 @@ def get_constrained_path( if min_theta < 0 or max_theta > 180: logger.error("Theta angle must be between 0° and 180°") raise ValueError("Theta angle must be between 0° and 180°") - if min_theta >= max_theta: - logger.error("Minimum theta angle must be less than maximum theta angle") - raise ValueError("Minimum theta angle must be less than maximum theta angle") + if min_theta > max_theta: + logger.error("Minimum theta angle must be less than or equal to maximum theta angle") + raise ValueError("Minimum theta angle must be less than or equal to maximum theta angle") if min_phi < 0 or min_phi > 360 or max_phi < 0 or max_phi > 360: logger.error("Phi angle must be between 0° and 360°") raise ValueError("Phi angle must be between 0° and 360°") - if min_phi == max_phi: - logger.error("Minimum phi angle must not be equal to maximum phi angle") - raise ValueError("Minimum phi angle must not be equal to maximum phi angle") if method == PathMethod.FIBONACCI: return _generate_constrained_fibonacci( @@ -141,6 +138,9 @@ def get_constrained_path( def _phi_span(min_phi: float, max_phi: float) -> float: """Return the positive span of a phi interval, supporting wrap-around at 360°.""" + if min_phi == max_phi: + return 0.0 + span = (max_phi - min_phi) % 360 return 360 if span == 0 else span @@ -171,6 +171,9 @@ def _generate_constrained_fibonacci( min_phi, max_phi, ) + if min_theta == max_theta and min_phi == max_phi: + return [PolarPoint3D(theta=min_theta, fi=min_phi % 360, r=1.0)] + # Convert theta constraints to Z constraints # theta = arccos(z), so z = cos(theta) # Note: theta increases as z decreases diff --git a/tests/config/test_scan_config.py b/tests/config/test_scan_config.py index e01dad0..a77bd14 100644 --- a/tests/config/test_scan_config.py +++ b/tests/config/test_scan_config.py @@ -2,13 +2,13 @@ from openscan_firmware.config.scan import ScanSetting -def test_scan_settings_omit_unset_phi_fields_from_json_dump() -> None: +def test_scan_settings_include_default_phi_fields_in_json_dump() -> None: settings = ScanSetting() payload = settings.model_dump(mode="json") - assert "min_phi" not in payload - assert "max_phi" not in payload + assert payload["min_phi"] == 0 + assert payload["max_phi"] == 360.0 def test_external_trigger_run_settings_omit_unset_phi_fields_from_json_dump() -> None: diff --git a/tests/controllers/services/test_scan_task.py b/tests/controllers/services/test_scan_task.py index 62a4b7a..e2bbf80 100644 --- a/tests/controllers/services/test_scan_task.py +++ b/tests/controllers/services/test_scan_task.py @@ -196,7 +196,7 @@ async def delayed_add_photo(*args, **kwargs): ) -def test_generate_scan_path_omits_optional_phi_constraints_when_unset() -> None: +def test_generate_scan_path_passes_default_phi_constraints() -> None: scan_settings = ScanSetting( path_method=PathMethod.FIBONACCI, points=10, @@ -222,6 +222,8 @@ def test_generate_scan_path_omits_optional_phi_constraints_when_unset() -> None: "num_points": 10, "min_theta": 10.0, "max_theta": 120.0, + "min_phi": 0, + "max_phi": 360.0, } assert list(path_dict.keys()) == [PolarPoint3D(theta=10.0, fi=20.0, r=250.0)] @@ -709,6 +711,29 @@ async def test_focus_stacking_uses_configured_image_format( assert awaited_call.args == ("rgb_array",) +def test_generate_scan_path_fully_fixed_position_has_single_zero_index_step() -> None: + scan_settings = ScanSetting( + path_method=PathMethod.FIBONACCI, + points=130, + min_theta=45.0, + max_theta=45.0, + min_phi=90.0, + max_phi=90.0, + optimize_path=False, + focus_stacks=1, + focus_range=(10.0, 15.0), + image_format="jpeg", + ) + + with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task._get_scan_radius_mm", + return_value=250.0, + ): + path_dict = generate_scan_path(scan_settings) + + assert path_dict == {PolarPoint3D(theta=45.0, fi=90.0, r=250.0): 0} + + class TestScanTaskIntegration: """Integration tests for ScanTask persistence behavior with real ProjectManager.""" diff --git a/tests/utils/test_paths.py b/tests/utils/test_paths.py new file mode 100644 index 0000000..f7b8faf --- /dev/null +++ b/tests/utils/test_paths.py @@ -0,0 +1,57 @@ +import pytest + +from openscan_firmware.models.paths import PathMethod, PolarPoint3D +from openscan_firmware.utils.paths.paths import get_constrained_path + + +def test_constrained_path_allows_fixed_theta() -> None: + path = get_constrained_path( + method=PathMethod.FIBONACCI, + num_points=5, + min_theta=45.0, + max_theta=45.0, + min_phi=0.0, + max_phi=180.0, + ) + + assert len(path) == 5 + assert {point.theta for point in path} == {45.0} + assert len({point.fi for point in path}) > 1 + + +def test_constrained_path_allows_fixed_phi() -> None: + path = get_constrained_path( + method=PathMethod.FIBONACCI, + num_points=5, + min_theta=10.0, + max_theta=120.0, + min_phi=90.0, + max_phi=90.0, + ) + + assert len(path) == 5 + assert {point.fi for point in path} == {90.0} + assert len({point.theta for point in path}) > 1 + + +def test_constrained_path_collapses_fully_fixed_position_to_one_point() -> None: + path = get_constrained_path( + method=PathMethod.FIBONACCI, + num_points=130, + min_theta=45.0, + max_theta=45.0, + min_phi=90.0, + max_phi=90.0, + ) + + assert path == [PolarPoint3D(theta=45.0, fi=90.0, r=1.0)] + + +def test_constrained_path_still_rejects_reversed_theta_range() -> None: + with pytest.raises(ValueError, match="less than or equal"): + get_constrained_path( + method=PathMethod.FIBONACCI, + num_points=5, + min_theta=120.0, + max_theta=10.0, + ) From 60ab87f7e5ed27a17ba610f47ebac809db4318fd Mon Sep 17 00:00:00 2001 From: esto Date: Wed, 29 Apr 2026 15:27:20 +0200 Subject: [PATCH 75/75] feat(tests): expand unit test coverage for GPIO, motor, and scan task handling - Added comprehensive tests for GPIO auto-initialization, error cases, and router patch handling. - Introduced new motor tests for movement execution, direction handling, and stop behavior. - Restored and updated scan task path generation tests to validate phi constraints and default values. - Enhanced mocking strategies to simplify setup for hardware and executor dependencies. --- .../picamera2/test_picamera2_focus_unit.py | 2 + tests/controllers/hardware/test_gpio.py | 52 +++++ tests/controllers/hardware/test_motor.py | 121 ++++++++++- tests/controllers/services/test_scan_task.py | 203 +++++++++--------- tests/routers/test_next_gpio_router.py | 46 ++++ 5 files changed, 313 insertions(+), 111 deletions(-) create mode 100644 tests/controllers/hardware/test_gpio.py create mode 100644 tests/routers/test_next_gpio_router.py diff --git a/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py b/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py index bc58c2a..8bbdfa6 100644 --- a/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py +++ b/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py @@ -28,9 +28,11 @@ class _AfModeEnum: picamera2 = types.ModuleType("picamera2") picamera2.Picamera2 = type("Picamera2", (), {}) + cv2 = types.ModuleType("cv2") monkeypatch.setitem(sys.modules, "libcamera", libcamera) monkeypatch.setitem(sys.modules, "picamera2", picamera2) + monkeypatch.setitem(sys.modules, "cv2", cv2) sys.modules.pop("openscan_firmware.controllers.hardware.cameras.picamera2", None) return importlib.import_module("openscan_firmware.controllers.hardware.cameras.picamera2") diff --git a/tests/controllers/hardware/test_gpio.py b/tests/controllers/hardware/test_gpio.py new file mode 100644 index 0000000..fd666bb --- /dev/null +++ b/tests/controllers/hardware/test_gpio.py @@ -0,0 +1,52 @@ +import pytest + +from openscan_firmware.controllers.hardware import gpio as gpio_module + + +class _FakeDigitalOutputDevice: + def __init__(self, pin: int, initial_value: bool = False): + self.pin = pin + self.value = bool(initial_value) + + def toggle(self): + self.value = not self.value + + def close(self): + return None + + +@pytest.fixture(autouse=True) +def reset_gpio_state(monkeypatch): + original_outputs = gpio_module._output_pins.copy() + original_buttons = gpio_module._buttons.copy() + + gpio_module._output_pins.clear() + gpio_module._buttons.clear() + monkeypatch.setattr(gpio_module, "DigitalOutputDevice", _FakeDigitalOutputDevice) + + yield + + gpio_module._output_pins.clear() + gpio_module._buttons.clear() + gpio_module._output_pins.update(original_outputs) + gpio_module._buttons.update(original_buttons) + + +def test_set_output_pin_auto_initializes_when_pin_is_free(): + result = gpio_module.set_output_pin(10, True, auto_initialize=True) + + assert result is True + assert 10 in gpio_module._output_pins + assert gpio_module.get_output_pin(10) is True + + +def test_set_output_pin_rejects_pin_initialized_as_button(): + gpio_module._buttons[10] = object() + + with pytest.raises(ValueError, match="initialized as button input"): + gpio_module.set_output_pin(10, True, auto_initialize=True) + + +def test_set_output_pin_requires_initialized_output_without_auto_init(): + with pytest.raises(ValueError, match="not initialized as output"): + gpio_module.set_output_pin(11, True) diff --git a/tests/controllers/hardware/test_motor.py b/tests/controllers/hardware/test_motor.py index 8f27b71..d8dbea4 100644 --- a/tests/controllers/hardware/test_motor.py +++ b/tests/controllers/hardware/test_motor.py @@ -1,5 +1,6 @@ import pytest import asyncio # Still needed for async functions +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock # Keep for specific mock types # Adjust paths if necessary for your project structure @@ -55,7 +56,7 @@ def motor_event_loop(): @pytest.fixture def mocked_dependencies(monkeypatch, motor_event_loop): - """Mocks GPIO, time.sleep, math.cos, and event_loop.run_in_executor.""" + """Mocks GPIO, time.sleep, math.cos, and the low-level movement executor.""" import openscan_firmware.controllers.hardware.motors as motors_module @@ -67,12 +68,12 @@ def mocked_dependencies(monkeypatch, motor_event_loop): mock_math_cos = MagicMock(return_value=0.0) monkeypatch.setattr(motors_module.math, 'cos', mock_math_cos) + async def fake_execute_movement(self, step_count: int, requested_degrees: float) -> int: + self.model.angle = requested_degrees % 360 + return abs(step_count) - def sync_run_in_executor_side_effect(executor, callback, *args): - return callback(*args) - - mock_run_in_executor = AsyncMock(side_effect=sync_run_in_executor_side_effect) - monkeypatch.setattr(motor_event_loop, 'run_in_executor', mock_run_in_executor, raising=True) + monkeypatch.setattr(MotorController, '_execute_movement', fake_execute_movement) + mock_run_in_executor = AsyncMock() return { "gpio": mock_gpio, @@ -83,6 +84,40 @@ def sync_run_in_executor_side_effect(executor, callback, *args): } +@pytest.fixture +def movement_dependencies(monkeypatch): + """Mocks hardware and executor boundaries while keeping _execute_movement real.""" + import openscan_firmware.controllers.hardware.motors as motors_module + + mock_gpio = MagicMock() + monkeypatch.setattr(motors_module, 'gpio', mock_gpio) + monkeypatch.setattr(motors_module.time, 'sleep', MagicMock()) + monkeypatch.setattr(motors_module, 'notify_busy_change', MagicMock()) + + class ImmediateExecutorLoop: + def run_in_executor(self, executor, callback, *args): + future = asyncio.Future() + try: + future.set_result(callback(*args)) + except Exception as exc: + future.set_exception(exc) + return future + + monkeypatch.setattr( + motors_module, + 'asyncio', + SimpleNamespace( + CancelledError=asyncio.CancelledError, + get_event_loop=MagicMock(return_value=ImmediateExecutorLoop()), + ), + ) + + return { + "gpio": mock_gpio, + "notify_busy_change": motors_module.notify_busy_change, + } + + @pytest.fixture def motor_controller_instance(motor_model_instance, motor_config_instance, mocked_dependencies): """Provides a MotorController instance with mocked dependencies.""" @@ -245,3 +280,77 @@ async def test_move_to_with_clamping(motor_controller_clamping_instance, motor_m assert motor_model.angle == pytest.approx(expected_angle, abs=1), \ f"Angle mismatch for move_to({target_val}) from {initial_angle}" + +@pytest.fixture +def movement_motor_controller(movement_dependencies): + settings = MotorConfig( + direction_pin=1, + enable_pin=2, + step_pin=3, + acceleration=20000, + max_speed=7500, + min_angle=0, + max_angle=360, + direction=1, + steps_per_rotation=3200, + ) + controller = MotorController(Motor(name="test_motor", settings=settings, angle=0.0)) + controller.set_idle_callbacks(lambda: False, AsyncMock()) + controller._pre_calculate_step_times = MagicMock(return_value=[0.0, 0.0, 0.0]) + return controller + + +@pytest.mark.asyncio +async def test_execute_movement_sets_direction_and_steps_forward(movement_motor_controller, movement_dependencies): + controller = movement_motor_controller + + await controller._execute_movement(3, 0.0) + + assert controller.model.angle == pytest.approx(3 / 3200 * 360) + movement_dependencies["gpio"].set_output_pin.assert_any_call(controller.settings.direction_pin, True) + assert movement_dependencies["gpio"].set_output_pin.call_args_list.count( + ((controller.settings.step_pin, True),) + ) == 3 + assert movement_dependencies["gpio"].set_output_pin.call_args_list.count( + ((controller.settings.step_pin, False),) + ) == 3 + assert controller._current_steps == 0 + + +@pytest.mark.asyncio +async def test_execute_movement_sets_direction_and_updates_angle_backward( + movement_motor_controller, + movement_dependencies, +): + controller = movement_motor_controller + controller.model.angle = 10.0 + + await controller._execute_movement(-3, 0.0) + + assert controller.model.angle == pytest.approx((10.0 - (3 / 3200 * 360)) % 360) + movement_dependencies["gpio"].set_output_pin.assert_any_call(controller.settings.direction_pin, False) + + +@pytest.mark.asyncio +async def test_execute_movement_stops_when_stop_requested( + movement_motor_controller, + movement_dependencies, +): + controller = movement_motor_controller + controller._pre_calculate_step_times = MagicMock(return_value=[0.0, 1.0, 2.0, 3.0]) + + step_high_calls = 0 + + def set_output_pin(pin, value): + nonlocal step_high_calls + if pin == controller.settings.step_pin and value is True: + step_high_calls += 1 + controller._stop_requested = True + + movement_dependencies["gpio"].set_output_pin.side_effect = set_output_pin + + await controller._execute_movement(4, 0.0) + + assert step_high_calls == 1 + assert controller.model.angle == pytest.approx(1 / 3200 * 360) + assert controller._current_steps == 0 diff --git a/tests/controllers/services/test_scan_task.py b/tests/controllers/services/test_scan_task.py index e2bbf80..a7e95df 100644 --- a/tests/controllers/services/test_scan_task.py +++ b/tests/controllers/services/test_scan_task.py @@ -196,71 +196,6 @@ async def delayed_add_photo(*args, **kwargs): ) -def test_generate_scan_path_passes_default_phi_constraints() -> None: - scan_settings = ScanSetting( - path_method=PathMethod.FIBONACCI, - points=10, - min_theta=10.0, - max_theta=120.0, - optimize_path=False, - focus_stacks=1, - focus_range=(10.0, 15.0), - image_format="jpeg", - ) - - with patch( - "openscan_firmware.controllers.services.tasks.core.scan_task._get_scan_radius_mm", - return_value=250.0, - ), patch( - "openscan_firmware.controllers.services.tasks.core.scan_task.paths.get_constrained_path", - return_value=[PolarPoint3D(theta=10.0, fi=20.0)], - ) as get_constrained_path: - path_dict = generate_scan_path(scan_settings) - - assert get_constrained_path.call_args.kwargs == { - "method": PathMethod.FIBONACCI, - "num_points": 10, - "min_theta": 10.0, - "max_theta": 120.0, - "min_phi": 0, - "max_phi": 360.0, - } - assert list(path_dict.keys()) == [PolarPoint3D(theta=10.0, fi=20.0, r=250.0)] - - -def test_generate_scan_path_passes_optional_phi_constraints_when_set() -> None: - scan_settings = ScanSetting( - path_method=PathMethod.FIBONACCI, - points=10, - min_theta=10.0, - max_theta=120.0, - min_phi=45.0, - max_phi=180.0, - optimize_path=False, - focus_stacks=1, - focus_range=(10.0, 15.0), - image_format="jpeg", - ) - - with patch( - "openscan_firmware.controllers.services.tasks.core.scan_task._get_scan_radius_mm", - return_value=250.0, - ), patch( - "openscan_firmware.controllers.services.tasks.core.scan_task.paths.get_constrained_path", - return_value=[PolarPoint3D(theta=10.0, fi=20.0)], - ) as get_constrained_path: - path_dict = generate_scan_path(scan_settings) - - assert get_constrained_path.call_args.kwargs == { - "method": PathMethod.FIBONACCI, - "num_points": 10, - "min_theta": 10.0, - "max_theta": 120.0, - "min_phi": 45.0, - "max_phi": 180.0, - } - assert list(path_dict.keys()) == [PolarPoint3D(theta=10.0, fi=20.0, r=250.0)] - @pytest.mark.asyncio @patch('openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller') @patch('openscan_firmware.controllers.services.tasks.core.scan_task.get_project_manager') @@ -427,27 +362,13 @@ async def slow_add_photo_pause(*args, **kwargs): assert final_task_model.status == TaskStatus.COMPLETED @pytest.mark.asyncio - @patch('openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller') - @patch('openscan_firmware.controllers.services.tasks.core.scan_task.get_project_manager') - @patch('openscan_firmware.controllers.services.tasks.core.scan_task.generate_scan_path') - @patch('openscan_firmware.controllers.hardware.motors', create=True) async def test_focus_stacking_pause_and_resume_mid_capture( self, - mock_motors: MagicMock, - mock_generate_scan_path: MagicMock, - mock_get_project_manager: MagicMock, - mock_get_camera_controller: MagicMock, - task_manager_fixture: TaskManager, mock_camera_controller: MagicMock, sample_scan_model: Scan, - mock_project_manager: MagicMock, fake_photo_data: PhotoData, ): """Ensure pausing during focus stacking resumes cleanly mid-stack.""" - - mock_get_camera_controller.return_value = mock_camera_controller - mock_get_project_manager.return_value = mock_project_manager - scan = sample_scan_model.model_copy(deep=True) scan.settings.focus_stacks = 12 scan.settings.focus_range = (0.1, 0.4) @@ -456,52 +377,58 @@ async def test_focus_stacking_pause_and_resume_mid_capture( focus_settings = FocusTrackingSettings(AF=True, manual_focus=0.05) mock_camera_controller.settings = focus_settings - path_points = { - PolarPoint3D(theta=0, fi=0): 0, - PolarPoint3D(theta=15, fi=15): 1, - } - mock_generate_scan_path.return_value = path_points - mock_motors.move_to_point = AsyncMock(return_value=None) - capture_event = asyncio.Event() photo_counter = {"count": 0} pause_trigger_index = 2 - def slow_focus_photo(*args, **kwargs): + async def slow_focus_photo(*args, **kwargs): photo_counter["count"] += 1 if photo_counter["count"] == pause_trigger_index: capture_event.set() - time.sleep(0.05) + await asyncio.sleep(0.05) return fake_photo_data async def slow_add_photo(*args, **kwargs): await asyncio.sleep(0.01) - mock_camera_controller.photo.side_effect = slow_focus_photo - mock_project_manager.add_photo_async.side_effect = slow_add_photo + mock_camera_controller.photo_async = AsyncMock(side_effect=slow_focus_photo) + project_manager = MagicMock() + project_manager.add_photo_async = AsyncMock(side_effect=slow_add_photo) - tm = task_manager_fixture - task_model = await tm.create_and_run_task("scan_task", scan, 0) + task_model = Task(name="scan_task", task_type="core") + scan_task = ScanTask(task_model) + scan_task._ctx = ScanRuntime( + scan=scan, + camera_controller=mock_camera_controller, + project_manager=project_manager, + path_dict={PolarPoint3D(theta=0, fi=0): 0}, + focus_context={ + "enabled": True, + "positions": focus_positions, + "previous_settings": (focus_settings.AF, focus_settings.manual_focus), + }, + ) + + capture_task = asyncio.create_task( + scan_task._capture_photos_at_position(PolarPoint3D(theta=0, fi=0), 0) + ) await asyncio.wait_for(capture_event.wait(), timeout=2.0) - paused_task = await tm.pause_task(task_model.id) - assert paused_task.status == TaskStatus.PAUSED - assert mock_camera_controller.photo.call_count < scan.settings.focus_stacks + scan_task.pause() + assert mock_camera_controller.photo_async.await_count < scan.settings.focus_stacks await asyncio.sleep(0.05) + assert scan_task.is_paused() - resumed_task = await tm.resume_task(task_model.id) - assert resumed_task.status == TaskStatus.RUNNING - - final_task_model = await tm.wait_for_task(task_model.id) - assert final_task_model.status == TaskStatus.COMPLETED + scan_task.resume() + await capture_task + await asyncio.sleep(0) - expected_photos = scan.settings.focus_stacks * len(path_points) - assert mock_camera_controller.photo.call_count == expected_photos - assert mock_project_manager.add_photo_async.await_count == expected_photos + expected_photos = scan.settings.focus_stacks + assert mock_camera_controller.photo_async.await_count == expected_photos + assert project_manager.add_photo_async.await_count == expected_photos - expected_history = focus_positions * len(path_points) + [0.05] - assert focus_settings.history == expected_history + assert focus_settings.history == focus_positions @pytest.mark.asyncio @patch('openscan_firmware.controllers.hardware.cameras.camera.get_camera_controller') @@ -711,6 +638,72 @@ async def test_focus_stacking_uses_configured_image_format( assert awaited_call.args == ("rgb_array",) +def test_generate_scan_path_passes_default_phi_constraints() -> None: + scan_settings = ScanSetting( + path_method=PathMethod.FIBONACCI, + points=10, + min_theta=10.0, + max_theta=120.0, + optimize_path=False, + focus_stacks=1, + focus_range=(10.0, 15.0), + image_format="jpeg", + ) + + with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task._get_scan_radius_mm", + return_value=250.0, + ), patch( + "openscan_firmware.controllers.services.tasks.core.scan_task.paths.get_constrained_path", + return_value=[PolarPoint3D(theta=10.0, fi=20.0)], + ) as get_constrained_path: + path_dict = generate_scan_path(scan_settings) + + assert get_constrained_path.call_args.kwargs == { + "method": PathMethod.FIBONACCI, + "num_points": 10, + "min_theta": 10.0, + "max_theta": 120.0, + "min_phi": 0, + "max_phi": 360.0, + } + assert list(path_dict.keys()) == [PolarPoint3D(theta=10.0, fi=20.0, r=250.0)] + + +def test_generate_scan_path_passes_optional_phi_constraints_when_set() -> None: + scan_settings = ScanSetting( + path_method=PathMethod.FIBONACCI, + points=10, + min_theta=10.0, + max_theta=120.0, + min_phi=45.0, + max_phi=180.0, + optimize_path=False, + focus_stacks=1, + focus_range=(10.0, 15.0), + image_format="jpeg", + ) + + with patch( + "openscan_firmware.controllers.services.tasks.core.scan_task._get_scan_radius_mm", + return_value=250.0, + ), patch( + "openscan_firmware.controllers.services.tasks.core.scan_task.paths.get_constrained_path", + return_value=[PolarPoint3D(theta=10.0, fi=20.0)], + ) as get_constrained_path: + path_dict = generate_scan_path(scan_settings) + + assert get_constrained_path.call_args.kwargs == { + "method": PathMethod.FIBONACCI, + "num_points": 10, + "min_theta": 10.0, + "max_theta": 120.0, + "min_phi": 45.0, + "max_phi": 180.0, + } + assert list(path_dict.keys()) == [PolarPoint3D(theta=10.0, fi=20.0, r=250.0)] + + def test_generate_scan_path_fully_fixed_position_has_single_zero_index_step() -> None: scan_settings = ScanSetting( path_method=PathMethod.FIBONACCI, diff --git a/tests/routers/test_next_gpio_router.py b/tests/routers/test_next_gpio_router.py new file mode 100644 index 0000000..174c4f8 --- /dev/null +++ b/tests/routers/test_next_gpio_router.py @@ -0,0 +1,46 @@ +from importlib import import_module + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def gpio_client_next() -> TestClient: + app = FastAPI() + gpio_router = import_module("openscan_firmware.routers.next.gpio") + app.include_router(gpio_router.router, prefix="/next") + with TestClient(app) as client: + yield client + + +def test_next_gpio_patch_sets_pin_with_auto_init(monkeypatch, gpio_client_next): + module_path = "openscan_firmware.routers.next.gpio" + captured: dict[str, tuple[int, bool, bool]] = {} + + def fake_set_output_pin(pin: int, status: bool, auto_initialize: bool = False): + captured["args"] = (pin, status, auto_initialize) + return status + + monkeypatch.setattr(f"{module_path}.gpio.set_output_pin", fake_set_output_pin, raising=False) + + response = gpio_client_next.patch("/next/gpio/10", params={"status": "true"}) + + assert response.status_code == 200 + assert response.json() is True + assert captured["args"] == (10, True, True) + + +def test_next_gpio_patch_returns_clear_conflict_for_busy_pin(monkeypatch, gpio_client_next): + module_path = "openscan_firmware.routers.next.gpio" + detail = "Cannot set pin 10. Pin is initialized as button input." + + def fake_set_output_pin(pin: int, status: bool, auto_initialize: bool = False): + raise ValueError(detail) + + monkeypatch.setattr(f"{module_path}.gpio.set_output_pin", fake_set_output_pin, raising=False) + + response = gpio_client_next.patch("/next/gpio/10", params={"status": "true"}) + + assert response.status_code == 409 + assert response.json()["detail"] == detail