Skip to content

Commit 70f86c5

Browse files
Merge pull request #97 from OpenScan-org/develop
Release v0.11.1
2 parents 0badb1d + 0ff0232 commit 70f86c5

54 files changed

Lines changed: 3927 additions & 404 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/Camera/GPHOTO2_ADD_CAMERA.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Add a GPhoto2 Camera Profile
2+
3+
This guide shows how to add support for a new gphoto2-compatible camera using a
4+
Python profile class.
5+
6+
## 1. Detect your camera
7+
8+
Connect camera over USB and run:
9+
10+
```bash
11+
gphoto2 --auto-detect
12+
```
13+
14+
Copy the detected model string. You will use parts of this string in
15+
`_MODEL_MARKERS`.
16+
17+
## 2. Inspect config keys and choices
18+
19+
List available keys:
20+
21+
```bash
22+
gphoto2 --list-config
23+
```
24+
25+
Inspect important keys:
26+
27+
```bash
28+
gphoto2 --get-config /main/settings/capturetarget
29+
gphoto2 --get-config /main/capturesettings/shutterspeed
30+
gphoto2 --get-config /main/imgsettings/imageformat
31+
gphoto2 --get-config /main/imgsettings/iso
32+
```
33+
34+
## 3. Copy the template profile
35+
36+
Copy:
37+
38+
`openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/template_camera.py`
39+
40+
Create a new file with your camera name, for example:
41+
42+
`openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/my_camera.py`
43+
44+
Then update:
45+
46+
- `profile_id`
47+
- `_MODEL_MARKERS`
48+
- config key lists (`_SHUTTER_KEYS`, `_ISO_KEYS`, `_RAW_FORMAT_KEYS`, ...)
49+
- startup defaults in `apply_startup_config`
50+
- optional RAW behavior in `capture_dng`
51+
52+
## 4. Register the profile
53+
54+
No manual registry edit is needed.
55+
56+
All Python modules in:
57+
58+
`openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/`
59+
60+
are auto-discovered by the profile registry at startup.
61+
62+
Requirements:
63+
64+
- your class must inherit from `GPhoto2Profile` (or `GenericGPhoto2Profile`)
65+
- `register_in_registry` must be `True` (default)
66+
- `matches(identity)` should return `True` only for your target camera model
67+
68+
## 5. Run a JPEG test
69+
70+
Use the firmware API/flow to capture a JPEG and check:
71+
72+
- image is captured successfully
73+
- expected shutter and quality values are applied
74+
- diagnostics show expected config keys
75+
76+
## 6. Run a RAW test
77+
78+
Capture RAW/DNG via the firmware flow and verify:
79+
80+
- file extension is RAW-like for your camera (`.nef`, `.cr2`, `.raw`, ...)
81+
- profile can switch to RAW mode
82+
- profile restores previous image format after capture
83+
84+
## 7. Debug setting failures
85+
86+
`write_first_config(...)` returns explicit result details:
87+
88+
- attempted keys
89+
- requested value
90+
- success/failure
91+
- failure message
92+
93+
If a setting fails, inspect these values first, then compare with
94+
`gphoto2 --get-config <key>` output.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from __future__ import annotations
2+
3+
from pydantic import BaseModel, Field
4+
5+
from openscan_firmware.config.scan import ScanSetting
6+
from openscan_firmware.models.paths import PathMethod
7+
8+
9+
class ExternalTriggerRunSettings(BaseModel):
10+
path_method: PathMethod = Field(
11+
default=PathMethod.FIBONACCI,
12+
description="Scanning path generator for the external trigger run.",
13+
)
14+
points: int = Field(130, ge=1, le=999, description="Number of trigger positions.")
15+
min_theta: float = Field(
16+
12.0,
17+
ge=0.0,
18+
le=180.0,
19+
description="Minimum theta angle in degrees for constrained paths.",
20+
)
21+
max_theta: float = Field(
22+
125.0,
23+
ge=0.0,
24+
le=180.0,
25+
description="Maximum theta angle in degrees for constrained paths.",
26+
)
27+
optimize_path: bool = Field(
28+
True,
29+
description="Enable path optimization based on the configured motor parameters.",
30+
)
31+
optimization_algorithm: str = Field(
32+
"nearest_neighbor",
33+
description="Path optimization algorithm to use when optimize_path is enabled.",
34+
)
35+
trigger_name: str = Field(
36+
...,
37+
min_length=1,
38+
description="Name of the configured trigger device to fire at each scan point.",
39+
)
40+
pre_trigger_delay_ms: int = Field(
41+
default=0,
42+
ge=0,
43+
le=600_000,
44+
description="Delay after reaching the scan position and before asserting the trigger.",
45+
)
46+
post_trigger_delay_ms: int = Field(
47+
default=0,
48+
ge=0,
49+
le=600_000,
50+
description="Delay after releasing the trigger before the next scan step starts.",
51+
)
52+
53+
def to_scan_settings(self) -> ScanSetting:
54+
"""Adapt the path-related settings to the shared scan path generator."""
55+
return ScanSetting(
56+
path_method=self.path_method,
57+
points=self.points,
58+
min_theta=self.min_theta,
59+
max_theta=self.max_theta,
60+
optimize_path=self.optimize_path,
61+
optimization_algorithm=self.optimization_algorithm,
62+
focus_stacks=1,
63+
focus_range=(10.0, 15.0),
64+
image_format="jpeg",
65+
)

openscan_firmware/config/firmware.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ class FirmwareSettings(BaseModel):
3030
detected.
3131
enable_cloud: When True the firmware enables cloud-facing features and
3232
UX affordances.
33+
camera_preview_enabled: When False the system is expected to operate
34+
without a live camera preview workflow, for example on trigger-only
35+
DSLR setups.
3336
"""
3437

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

4451

4552
# Module-level singleton – loaded once, then reused.

openscan_firmware/config/scan.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class ScanSetting(BaseModel):
1111
)
1212
points: int = Field(130, ge=1, le=999, description="Number of points in scanning path.")
1313

14-
image_format: Literal['jpeg','dng','rgb_array', 'yuv_array'] = Field(
14+
image_format: Literal['jpeg', 'raw', 'dng', 'rgb_array', 'yuv_array'] = Field(
1515
default='jpeg',
1616
description='Output image format (JPEG, DNG, RGB array or YUV array).'
1717
)
@@ -49,4 +49,4 @@ def focus_positions(self) -> list[float]:
4949
return [
5050
min_focus + i * (max_focus - min_focus) / (self.focus_stacks - 1)
5151
for i in range(self.focus_stacks)
52-
]
52+
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
5+
from pydantic import AliasChoices, BaseModel, Field
6+
7+
8+
class TriggerActiveLevel(str, Enum):
9+
ACTIVE_HIGH = "active_high"
10+
ACTIVE_LOW = "active_low"
11+
12+
13+
class TriggerConfig(BaseModel):
14+
enabled: bool = Field(default=True, description="Whether this trigger can be fired.")
15+
pin: int = Field(..., ge=0, description="BCM GPIO pin used for the trigger line.")
16+
active_level: TriggerActiveLevel = Field(
17+
default=TriggerActiveLevel.ACTIVE_HIGH,
18+
validation_alias=AliasChoices("active_level", "polarity"),
19+
description="Defines which logic level is considered active. The idle level is the inverse.",
20+
)
21+
pulse_width_ms: int = Field(
22+
default=100,
23+
ge=1,
24+
le=5_000,
25+
description="How long the trigger line stays active for each trigger pulse in ms.",
26+
)
27+
28+
29+
# Backwards-compatible alias for older code/config payloads.
30+
TriggerPolarity = TriggerActiveLevel

openscan_firmware/controllers/device.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from openscan_firmware.models.camera import Camera, CameraType
2525
from openscan_firmware.models.motor import Motor, Endstop
2626
from openscan_firmware.models.light import Light
27+
from openscan_firmware.models.trigger import Trigger
2728
from openscan_firmware.models.scanner import (
2829
ScannerDevice,
2930
ScannerDeviceConfig,
@@ -39,6 +40,7 @@
3940
from openscan_firmware.config.motor import MotorConfig
4041
from openscan_firmware.config.light import LightConfig
4142
from openscan_firmware.config.endstop import EndstopConfig
43+
from openscan_firmware.config.trigger import TriggerConfig
4244
from openscan_firmware.config.cloud import (
4345
load_cloud_settings_from_env,
4446
set_cloud_settings,
@@ -60,6 +62,11 @@
6062
remove_motor_controller
6163
from openscan_firmware.controllers.hardware.lights import create_light_controller, get_all_light_controllers, remove_light_controller, \
6264
get_light_controller
65+
from openscan_firmware.controllers.hardware.triggers import (
66+
create_trigger_controller,
67+
get_all_trigger_controllers,
68+
remove_trigger_controller,
69+
)
6370
from openscan_firmware.controllers.hardware.endstops import EndstopController
6471
from openscan_firmware.controllers.hardware.gpio import cleanup_all_pins
6572

@@ -88,6 +95,7 @@ def _create_default_scanner_device() -> ScannerDevice:
8895
cameras={},
8996
motors={},
9097
lights={},
98+
triggers={},
9199
endstops={},
92100
)
93101
# beware, PrivateAttr are NOT initialized in constructor
@@ -105,6 +113,7 @@ def _create_default_scanner_device() -> ScannerDevice:
105113
cameras={},
106114
motors={},
107115
lights={},
116+
triggers={},
108117
endstops={},
109118
).model_dump(mode="json")
110119

@@ -129,6 +138,7 @@ def _runtime_to_persisted_config() -> ScannerDeviceConfig:
129138
},
130139
motors={name: motor.settings for name, motor in _scanner_device.motors.items()},
131140
lights={name: light.settings for name, light in _scanner_device.lights.items()},
141+
triggers={name: trigger.settings for name, trigger in _scanner_device.triggers.items()},
132142
endstops={
133143
name: PersistedEndstopConfig(settings=endstop.settings)
134144
for name, endstop in _scanner_device.endstops.items()
@@ -256,6 +266,7 @@ def get_device_info():
256266
"cameras": {name: controller.get_status() for name, controller in get_all_camera_controllers().items()},
257267
"motors": {name: controller.get_status() for name, controller in get_all_motor_controllers().items()},
258268
"lights": {name: controller.get_status() for name, controller in get_all_light_controllers().items()},
269+
"triggers": {name: controller.get_status() for name, controller in get_all_trigger_controllers().items()},
259270

260271
"motors_timeout": _scanner_device.motors_timeout,
261272
"startup_mode": _scanner_device.startup_mode,
@@ -295,6 +306,15 @@ def _load_light_config(settings: dict) -> LightConfig:
295306
return LightConfig()
296307

297308

309+
def _load_trigger_config(settings: dict) -> TriggerConfig:
310+
"""Load trigger configuration for the current model."""
311+
try:
312+
return TriggerConfig(**settings)
313+
except Exception as e:
314+
logger.error("Error loading trigger settings: ", e)
315+
raise
316+
317+
298318
def _load_endstop_config(settings: dict) -> EndstopConfig:
299319
"""Helper function to load and validate endstop settings from a dictionary."""
300320
try:
@@ -523,6 +543,8 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
523543
remove_motor_controller(controller)
524544
for controller in get_all_light_controllers():
525545
remove_light_controller(controller)
546+
for controller in get_all_trigger_controllers():
547+
remove_trigger_controller(controller)
526548
for controller in get_all_camera_controllers():
527549
remove_camera_controller(controller)
528550
cleanup_all_pins()
@@ -561,6 +583,16 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
561583
light_objects[light_name] = light
562584
logger.debug(f"Loaded light {light_name} with settings: {light.settings}")
563585

586+
# Create trigger objects
587+
trigger_objects = {}
588+
for trigger_name in config_dict["triggers"]:
589+
trigger = Trigger(
590+
name=trigger_name,
591+
settings=_load_trigger_config(config_dict["triggers"][trigger_name])
592+
)
593+
trigger_objects[trigger_name] = trigger
594+
logger.debug(f"Loaded trigger {trigger_name} with settings: {trigger.settings}")
595+
564596
# Cloud settings
565597
persistent_settings = load_persistent_cloud_settings()
566598
if persistent_settings:
@@ -635,6 +667,12 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
635667
except Exception as e:
636668
logger.error(f"Error initializing light controller for {name}: {e}")
637669

670+
for name, trigger in trigger_objects.items():
671+
try:
672+
create_trigger_controller(trigger)
673+
except Exception as e:
674+
logger.error(f"Error initializing trigger controller for {name}: {e}")
675+
638676
# initialize project manager
639677
try:
640678
project_manager = get_project_manager()
@@ -652,6 +690,7 @@ async def _initialize_with_config(config: dict | ScannerDeviceConfig, detect_cam
652690
cameras=camera_objects,
653691
motors=motor_objects,
654692
lights=light_objects,
693+
triggers=trigger_objects,
655694
endstops=endstop_objects,
656695

657696
# motors timeout in seconds - 0 to disable

openscan_firmware/controllers/hardware/cameras/camera.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ def photo(self, image_format: str = "jpeg") -> PhotoData:
123123
"""
124124
handler = {
125125
"jpeg": self.capture_jpeg,
126+
"raw": self.capture_dng, # legacy implementation hook kept as capture_dng
126127
"dng": self.capture_dng,
127128
"rgb_array": self.capture_rgb_array,
128129
"yuv_array": self.capture_yuv_array,

0 commit comments

Comments
 (0)