diff --git a/README.md b/README.md index a600054..33a55f2 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:** `openscan` (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 @@ -50,29 +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)

- - -## 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/dist/assets/openscan_mini_icon.png b/dist/assets/openscan_mini_icon.png new file mode 100644 index 0000000..70332e9 Binary files /dev/null and b/dist/assets/openscan_mini_icon.png differ diff --git a/dist/local_json.rpi-imager-manifest b/dist/local_json.rpi-imager-manifest new file mode 100644 index 0000000..c859ff6 --- /dev/null +++ b/dist/local_json.rpi-imager-manifest @@ -0,0 +1,197 @@ +{ + "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:///home/esto/OpenScan3-pi-gen/deploy/OpenScan3_v0.11.2_imx519.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 5360320512, + "extract_sha256": "9f00a80020a91da4ae00c9672a50150446abeb1be395d58d7a00dd6fd3379f58" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP)", + "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", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 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.11.2_generic.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 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.11.2_imx519_DEVELOP.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 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.11.2_hawkeye_DEVELOP.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 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.11.2_generic_DEVELOP.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 5268045824, + "extract_sha256": "967cc0a5e98e09ca1646bde804311ad9500689bfb39c4533a018b6b2d2077b9b" + } + ], + "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/os-sublist-openscan.json b/dist/os-sublist-openscan.json new file mode 100644 index 0000000..8023dcb --- /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.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-04-16", + "extract_size": 5360320512, + "extract_sha256": "9f00a80020a91da4ae00c9672a50150446abeb1be395d58d7a00dd6fd3379f58", + "image_download_size": 1539738003, + "image_download_sha256": "8a60af60e253135c57f40c472f76b560a5c10a848062adfce912c392f27beeea", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "init_format": "cloudinit-rpi" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP)", + "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-04-16", + "extract_size": 5360320512, + "extract_sha256": "fbf9bc74c8ddd874809748c45a09eda3077ee24ec550f1e8cb3c3b01b20792df", + "image_download_size": 1539770528, + "image_download_sha256": "18a0644c911312009ef6b3a393fe62d3be4df377f0fcaebf3e1a2a394a0fb55b", + "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.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-04-16", + "extract_size": 5268045824, + "extract_sha256": "bb85cc0da68f58690f711f7143f68bdb6d6e0d2e4a7c3a963430f4901b036171", + "image_download_size": 1517385857, + "image_download_sha256": "3af598daba71dcc529dcfe91eb5e99abc4fe9f7dd6c482ee07463597c0a7a200", + "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.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-04-16", + "extract_size": 5360320512, + "extract_sha256": "7716d53a201ee411ba6e94fe98ced722ce73b640ab958e8ddb8ff6bf709ea1a3", + "image_download_size": 1539771405, + "image_download_sha256": "df5882055de8221347981095bc040314bcd2b60e61b27a6044fc9ed5459e7735", + "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.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-04-16", + "extract_size": 5360320512, + "extract_sha256": "9ae9a22b65a0bcb20851a1bc672262fb41cea8e441dcacae7888a9c30ed52eea", + "image_download_size": 1539720340, + "image_download_sha256": "8cd94e9d3ba9b1d568ed75ac53d8746d1699c8bf0bf3a1e2fa963a119a16c668", + "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.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-04-16", + "extract_size": 5268045824, + "extract_sha256": "967cc0a5e98e09ca1646bde804311ad9500689bfb39c4533a018b6b2d2077b9b", + "image_download_size": 1517446205, + "image_download_sha256": "7ce3d3058a076e9752d215bb406e9799c5cf74ed236c812906192d3ea7d157fc", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "init_format": "cloudinit-rpi" + } + ] +} diff --git a/dist/repo.json b/dist/repo.json new file mode 100644 index 0000000..ce0d314 --- /dev/null +++ b/dist/repo.json @@ -0,0 +1,197 @@ +{ + "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.11.2/OpenScan3_v0.11.2_imx519.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 5360320512, + "extract_sha256": "9f00a80020a91da4ae00c9672a50150446abeb1be395d58d7a00dd6fd3379f58" + }, + { + "name": "OpenScan3 (Arducam Hawkeye 64MP)", + "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", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 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.11.2/OpenScan3_v0.11.2_generic.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 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.11.2/OpenScan3_v0.11.2_imx519_DEVELOP.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 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.11.2/OpenScan3_v0.11.2_hawkeye_DEVELOP.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 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.11.2/OpenScan3_v0.11.2_generic_DEVELOP.zip", + "release_date": "2026-04-16", + "devices": [ + "pi5", + "pi4", + "pi400", + "pi3", + "all" + ], + "capabilities": [ + "usb_otg", + "rpi_connect" + ], + "init_format": "cloudinit-rpi", + "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": 5268045824, + "extract_sha256": "967cc0a5e98e09ca1646bde804311ad9500689bfb39c4533a018b6b2d2077b9b" + } + ], + "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/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 +} +``` 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/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: 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. diff --git a/openscan_firmware/config/external_trigger_run.py b/openscan_firmware/config/external_trigger_run.py new file mode 100644 index 0000000..f8ad907 --- /dev/null +++ b/openscan_firmware/config/external_trigger_run.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field, SerializerFunctionWrapHandler, model_serializer + +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.", + ) + 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.", + ) + 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, + 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/firmware.py b/openscan_firmware/config/firmware.py new file mode 100644 index 0000000..98005d1 --- /dev/null +++ b/openscan_firmware/config/firmware.py @@ -0,0 +1,105 @@ +"""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 usable network connection is + 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( + 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.", + ) + 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. +_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/config/light.py b/openscan_firmware/config/light.py index b8cd511..90b6fa0 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(3.3, ge=0, le=3.3, description="Maximum pwm voltage for led driver.") @model_validator(mode="before") @classmethod @@ -37,4 +40,14 @@ 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 a valid range when PWM mode is enabled. + """ + if self.pwm_support and self.pwm_min >= self.pwm_max: + raise ValueError("pwm_min must be less than pwm_max") + + return self diff --git a/openscan_firmware/config/scan.py b/openscan_firmware/config/scan.py index 09c5db0..2176cf1 100644 --- a/openscan_firmware/config/scan.py +++ b/openscan_firmware/config/scan.py @@ -1,9 +1,12 @@ -from pydantic import BaseModel, Field, confloat -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, @@ -11,7 +14,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).' ) @@ -21,6 +24,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=0, + ge=0.0, + le=360.0, + description="Optional minimum phi angle in degrees for constrained paths.", + ) + max_phi: float | None = Field( + default=360.0, + 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.") @@ -29,10 +44,15 @@ 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.") - 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.") + pause_before_capture_ms: int = Field( + 0, + ge=0, + description="Pause in milliseconds before capture to let vibrations settle.", + ) + 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]: @@ -49,4 +69,13 @@ 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 + ] + + @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/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 9afadc6..2491697 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 @@ -23,12 +24,23 @@ 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.trigger import Trigger +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 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, @@ -50,12 +62,21 @@ 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 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 @@ -65,20 +86,37 @@ logger = logging.getLogger(__name__) # Current scanner model -_scanner_device = ScannerDevice( + +def _create_default_scanner_device() -> ScannerDevice: + device = ScannerDevice( + name="Unknown device", + model=None, + shield=None, + cameras={}, + motors={}, + lights={}, + triggers={}, + 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 = ScannerDeviceConfig( name="Unknown device", model=None, shield=None, cameras={}, motors={}, lights={}, + triggers={}, endstops={}, -) - -# beware, PrivateAttr are NOT initialized in constructor -# nor an error message is shown... -_scanner_device._idle=False -_scanner_device._initialized=False + scan_radius_mm=1.0, +).model_dump(mode="json") # Path to device configuration file (persisted) BASE_DIR = pathlib.Path(__file__).parent.parent.parent @@ -86,6 +124,33 @@ 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()}, + 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() + }, + 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, + ) + + def load_device_config(config_file=None) -> dict: """Load device configuration from a file @@ -96,8 +161,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,7 +192,8 @@ def load_device_config(config_file=None) -> dict: except Exception as e: logger.error(f"Error loading device configuration: {e}") - return config_dict + persisted_config = ScannerDeviceConfig.model_validate(config_dict) + return persisted_config.model_dump(mode="json") def save_device_config() -> bool: @@ -137,18 +203,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) @@ -164,13 +219,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 """ - await initialize(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", resolved_path + ) + return False + + logger.info( + "Device configuration applied", + extra={"requested": str(config_file), "resolved_path": str(resolved_path)}, + ) return True @@ -188,8 +268,10 @@ 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, + "scan_radius_mm": _scanner_device.scan_radius_mm, "startup_mode": _scanner_device.startup_mode, "calibrate_mode": _scanner_device.calibrate_mode, @@ -227,6 +309,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: @@ -274,7 +365,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}") @@ -305,6 +396,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 @@ -386,9 +517,25 @@ 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 | ScannerDeviceConfig | 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 | 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() @@ -399,43 +546,56 @@ async def initialize(config: dict = _scanner_device.model_dump(mode='json'), det 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() logger.debug("Cleaned up old controllers.") # Detect hardware - if detect_cameras or config["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["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"]) - ) - 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 = {} - 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}") + # 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: @@ -487,10 +647,10 @@ async def initialize(config: dict = _scanner_device.model_dump(mode='json'), det # 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: @@ -510,6 +670,12 @@ async def initialize(config: dict = _scanner_device.model_dump(mode='json'), det 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() @@ -521,19 +687,21 @@ async def initialize(config: dict = _scanner_device.model_dump(mode='json'), det 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, + triggers=trigger_objects, endstops=endstop_objects, # motors timeout in seconds - 0 to disable - motors_timeout=config["motors_timeout"], + motors_timeout=config_dict["motors_timeout"], + scan_radius_mm=config_dict["scan_radius_mm"], - 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 @@ -565,6 +733,11 @@ async def initialize(config: dict = _scanner_device.model_dump(mode='json'), det 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 @@ -602,14 +775,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(): diff --git a/openscan_firmware/controllers/hardware/cameras/camera.py b/openscan_firmware/controllers/hardware/cameras/camera.py index 74d1941..31b8cbb 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): @@ -121,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.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..2b3b30d --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/controller.py @@ -0,0 +1,207 @@ +"""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 +import piexif # type: ignore[import] + +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 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: + 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() + content = self._embed_orientation_flag(content) + 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), "raw", 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, + ) + + 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 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..134126d --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile.py @@ -0,0 +1,69 @@ +"""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 + +from .profile_helpers import select_best_shutter_choice + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CameraIdentity: + model: str | None + port: str | None + + +class GPhoto2Profile: + """Base profile contract for camera model-specific GPhoto2 behavior.""" + + profile_id = "generic" + register_in_registry = True + + 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 + + # 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 new file mode 100644 index 0000000..0cdf409 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profile_registry.py @@ -0,0 +1,69 @@ +"""Registry for selecting a GPhoto2 profile based on camera identity.""" + +from __future__ import annotations + +import importlib +import inspect +import logging +import pkgutil + +from .profile import CameraIdentity, GPhoto2Profile +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]] = _iter_profile_classes() + + +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..4da7bcf --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/__init__.py @@ -0,0 +1,7 @@ +"""Built-in GPhoto2 camera profiles.""" + +from .canon_eos_700d import CanonEOS700DProfile +from .generic import GenericGPhoto2Profile +from .nikon_d7100 import NikonD7100Profile + +__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 new file mode 100644 index 0000000..b666c15 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/canon_eos_700d.py @@ -0,0 +1,86 @@ +"""Canon EOS 700D specific GPhoto2 profile.""" + +from __future__ import annotations + +import logging + +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__) + + +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 = ["/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: + # For tethered capture on EOS 700D we prefer Internal RAM. + 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: + 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 this EOS 700D 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") + 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: + # 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: + restore_previous_config_value(session, self._DNG_KEYS, previous_value) + + +def _map_gain_to_iso_choice(gain: float | None) -> str | None: + # CameraSettings.gain is generic analogue gain; for DSLR map to nearest ISO stop. + 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 new file mode 100644 index 0000000..7aece32 --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/profiles/generic.py @@ -0,0 +1,66 @@ +"""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 +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: + return format_shutter_value_ms(shutter_ms) + + +class GenericGPhoto2Profile(GPhoto2Profile): + """Best-effort profile that targets common DSLR config keys.""" + + profile_id = "generic" + + _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 + + def apply_startup_config(self, session, settings: CameraSettings) -> None: + 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 = 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: + self._set_first(session, self._JPEG_QUALITY_KEYS, "JPEG Fine") + + def _select_best_shutter_choice(self, session, shutter_ms: float) -> str: + 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: + 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 new file mode 100644 index 0000000..bdaa4ca --- /dev/null +++ b/openscan_firmware/controllers/hardware/cameras/gphoto2/session.py @@ -0,0 +1,475 @@ +"""Low-level session wrapper around python-gphoto2.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from typing import Any + +import gphoto2 as gp + +from .profile import CameraIdentity + +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.""" + + 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) + self._io_retry_attempts = 3 + self._io_retry_delay_s = 0.08 + + @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() + 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 = { + "capture_folder": file_path.folder, + "capture_name": file_path.name, + "gp_file_type": gp_file_type, + } + return payload, metadata + + 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, config_error = self._get_config_with_retry(camera, key_context=key) + if config is None: + 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 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 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 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): + logger.debug( + "Config '%s' write did not persist expected value (wanted=%s got=%s).", + key, + selected, + verified, + ) + 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], + ) + + 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: + result = self.write_config(key, value) + except Exception as exc: + logger.debug("Setting config '%s' failed.", key, exc_info=True) + 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, config_error = self._get_config_with_retry(camera, key_context=key) + if config is 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 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, + "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 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: + result = self.read_config(key) + except Exception as exc: + logger.debug("Reading config '%s' failed.", key) + 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(), 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 + 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: + 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/controllers/hardware/cameras/picamera2.py b/openscan_firmware/controllers/hardware/cameras/picamera2.py index 9224191..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) @@ -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) 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/controllers/hardware/gpio.py b/openscan_firmware/controllers/hardware/gpio.py index e2e0d55..40f700f 100644 --- a/openscan_firmware/controllers/hardware/gpio.py +++ b/openscan_firmware/controllers/hardware/gpio.py @@ -1,12 +1,16 @@ import logging -from gpiozero import DigitalOutputDevice, Button +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 _output_pins = {} +_pwm_pins = {} _buttons = {} @@ -15,8 +19,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,37 +33,107 @@ 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: _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) -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()) - } + 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_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: + message = f"Cannot read pin {pin}. Pin is not initialized as output." + logger.warning(f"Warning: {message}") + raise ValueError(message) + + +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: + 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 + 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: + 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: + 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 output.") + logger.warning(f"Warning: Pin {pin} not initialized as PWM.") return None @@ -76,6 +152,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 +238,32 @@ 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: + 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"PWM pin {pin} closed.") + except Exception as e: + 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 for pin in pins_to_remove: @@ -185,7 +285,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())}") \ No newline at end of file + 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())}" + ) 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/lights.py b/openscan_firmware/controllers/hardware/lights.py index 0c08dff..0c8ecd9 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 = 100.0 + + # 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 / 3.3) + 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,17 @@ 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 + 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}.") create_light_controller, get_light_controller, remove_light_controller, _light_registry = create_controller_registry(LightController) 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/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/cloud.py b/openscan_firmware/controllers/services/cloud.py index 28230b4..a3bc4b3 100644 --- a/openscan_firmware/controllers/services/cloud.py +++ b/openscan_firmware/controllers/services/cloud.py @@ -1,12 +1,14 @@ """Cloud service helpers for OpenScan.""" import asyncio +import errno import io import logging -import math import pathlib +import threading from dataclasses import dataclass -from tempfile import TemporaryFile +from shutil import disk_usage +from tempfile import NamedTemporaryFile, TemporaryFile from typing import Any, BinaryIO, Callable, Iterator, Sequence from zipfile import ZIP_DEFLATED, ZipFile @@ -15,15 +17,23 @@ 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__) REQUEST_TIMEOUT = 60 ALLOWED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} -UNSUPPORTED_PHOTO_SUFFIXES = {".png", ".dng", ".npy"} +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): @@ -52,6 +62,180 @@ 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 _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.""" @@ -458,23 +642,36 @@ 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 + photo_paths = _collect_project_photos(project) + required_bytes = _estimate_upload_temp_space(photo_paths) - 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: + _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 photo_paths: + 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: @@ -484,4 +681,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/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/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/projects.py b/openscan_firmware/controllers/services/projects.py index a7488ce..610d170 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: @@ -191,6 +193,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 +208,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. @@ -315,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 @@ -327,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( @@ -359,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) @@ -373,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: @@ -738,39 +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 = os.path.join(project.path, scan_id) - - for photo_filename in photo_filenames: - if photo_filename != os.path.basename(photo_filename): - raise ValueError("Photo filename must not contain directories") + project = self._projects[scan.project_name] + scan_id = f"scan{scan.index:02d}" + scan_dir_path = pathlib.Path(project.path, scan_id).resolve() - file_path = os.path.join(scan_dir, photo_filename) - if os.path.exists(file_path): - 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) - 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): + 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) + 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) @@ -782,8 +838,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) @@ -793,10 +872,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: @@ -807,19 +910,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) @@ -879,4 +982,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/controllers/services/tasks/core/cloud_task.py b/openscan_firmware/controllers/services/tasks/core/cloud_task.py index 52dfa40..a029b6a 100644 --- a/openscan_firmware/controllers/services/tasks/core/cloud_task.py +++ b/openscan_firmware/controllers/services/tasks/core/cloud_task.py @@ -4,12 +4,12 @@ import asyncio import contextlib +import errno import io import logging import re import time from pathlib import Path -from tempfile import NamedTemporaryFile from typing import Any, AsyncGenerator import requests @@ -22,10 +22,15 @@ 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, _upload_file, ) @@ -378,6 +383,7 @@ async def run( archive_path, exc, ) + _release_cloud_temp_path(archive_path) async def _download_archive_stream( self, @@ -408,9 +414,27 @@ 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 - with NamedTemporaryFile(delete=False, suffix=".zip") as temp_file: - temp_path = Path(temp_file.name) + temp_path: Path | None = None + try: + temp_path = await asyncio.to_thread(_create_cloud_download_temp_file, temp_dir) + 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,13 +450,20 @@ 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) 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/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/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/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..ee23e80 --- /dev/null +++ b/openscan_firmware/controllers/services/tasks/core/qr_scan_task.py @@ -0,0 +1,293 @@ +"""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.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 + +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 +# 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 +# Re-check whether QR setup is still needed at most every N seconds +_NETWORK_READY_CHECK_INTERVAL = 5.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, 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") + + 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 + last_credentials = None + last_connect_attempt = 0.0 + last_network_check = 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) + _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) + 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) + 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) + + 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 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} + yield TaskProgress( + current=attempt, + total=0, + message=f"{error_msg} – retrying shortly", + ) + await asyncio.sleep(_CONNECT_ERROR_BACKOFF) + raise RuntimeError(error_msg) + + 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) + + +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") diff --git a/openscan_firmware/controllers/services/tasks/core/scan_task.py b/openscan_firmware/controllers/services/tasks/core/scan_task.py index 9173527..b3acabb 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. @@ -42,12 +58,19 @@ 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) + 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) @@ -412,10 +435,14 @@ 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 - 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( @@ -469,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/openscan_firmware/controllers/services/tasks/task_manager.py b/openscan_firmware/controllers/services/tasks/task_manager.py index 1a404e0..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 @@ -128,6 +133,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, ) @@ -135,12 +143,17 @@ 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, + "external_trigger_run_task": CoreExternalTriggerRunTask, "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(): @@ -228,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" @@ -239,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) @@ -480,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/openscan_firmware/main.py b/openscan_firmware/main.py index b5bc804..98dd318 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, @@ -50,11 +24,29 @@ 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, + 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, openscan as openscan_next, @@ -68,13 +60,52 @@ 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_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 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. + """ + 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 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 + + # 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 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) + except Exception: + logger.exception("Failed to auto-start QR WiFi scan task.") + + REQUIRED_CORE_TASKS = [ "scan_task", + "external_trigger_run_task", "focus_stacking_task", "cloud_upload_task", "cloud_download_task", @@ -118,6 +149,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 network is connected + await _maybe_start_qr_wifi_scan(task_manager) + yield # application runs here # Code to run on shutdown @@ -145,36 +179,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, @@ -194,22 +198,40 @@ 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, 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, 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.6": v0_6_ROUTERS, - "0.7": v0_7_ROUTERS, "0.8": v0_8_ROUTERS, + "0.9": v0_9_ROUTERS, "next": next_ROUTERS, } @@ -272,13 +294,11 @@ 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", + "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/models/camera.py b/openscan_firmware/models/camera.py index 77b7d22..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): @@ -39,7 +38,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/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/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/models/scanner.py b/openscan_firmware/models/scanner.py index cf8ad72..15177bd 100644 --- a/openscan_firmware/models/scanner.py +++ b/openscan_firmware/models/scanner.py @@ -1,15 +1,22 @@ 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.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" MINI = "mini" + MIDI = "midi" CUSTOM = "custom" class ScannerShield(Enum): @@ -37,13 +44,51 @@ 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 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 _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) + 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/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/cameras.py b/openscan_firmware/routers/next/cameras.py index f5db70a..e68b59c 100644 --- a/openscan_firmware/routers/next/cameras.py +++ b/openscan_firmware/routers/next/cameras.py @@ -1,12 +1,22 @@ import asyncio - -from fastapi import APIRouter, Body, HTTPException, Query +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 +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 +30,158 @@ responses={404: {"description": "Not found"}}, ) +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 + + +@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 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", "raw", "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 _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, + 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 _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: + _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 +293,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 +309,59 @@ 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: + 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: + 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 = _encode_url_path( + 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 +424,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/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/openscan_firmware/routers/next/develop.py b/openscan_firmware/routers/next/develop.py index 384ebc8..57d9862 100644 --- a/openscan_firmware/routers/next/develop.py +++ b/openscan_firmware/routers/next/develop.py @@ -5,11 +5,18 @@ """ 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.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 @@ -18,6 +25,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", @@ -25,6 +34,237 @@ 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": [], + } + + 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, + "summary": None, + "about": None, + "config_groups": [], + "relevant_config": [], + "in_use_by_openscan": False, + "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""" @@ -43,6 +283,50 @@ 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=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. @@ -93,7 +377,31 @@ 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""" - return paths.get_path(method, points) \ No newline at end of file + return paths.get_path(method, points) diff --git a/openscan_firmware/routers/next/device.py b/openscan_firmware/routers/next/device.py index 2686918..a61a50f 100644 --- a/openscan_firmware/routers/next/device.py +++ b/openscan_firmware/routers/next/device.py @@ -1,17 +1,21 @@ from fastapi import APIRouter, HTTPException, UploadFile, File -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, Field, ValidationError +from typing import Any +from pathlib import Path import os import json import tempfile import shutil +import logging -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 from .cameras import CameraStatusResponse from .motors import MotorStatusResponse from .lights import LightStatusResponse +from .triggers import TriggerStatusResponse router = APIRouter( prefix="/device", @@ -19,18 +23,22 @@ responses={404: {"description": "Not found"}}, ) +logger = logging.getLogger(__name__) + class DeviceConfigRequest(BaseModel): config_file: str 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] + 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 @@ -41,6 +49,19 @@ class DeviceControlResponse(BaseModel): status: DeviceStatusResponse +class DeviceConfigResponse(BaseModel): + status: str + filename: str + path: str + config: dict[str, Any] + + +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 @@ -50,6 +71,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( @@ -73,8 +113,63 @@ async def list_config_files(): 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: ScannerDevice, 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, @@ -88,10 +183,20 @@ 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={"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.dict() + 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 @@ -99,19 +204,32 @@ 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 = 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=DeviceStatusResponse.model_validate(device.get_device_info()) + 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)}") @@ -124,13 +242,15 @@ 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(): return DeviceControlResponse( success=True, message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + 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) @@ -144,6 +264,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 +294,15 @@ async def set_config_file(config_data: DeviceConfigRequest): # 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=DeviceStatusResponse.model_validate(device.get_device_info()) + 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: @@ -200,14 +324,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 = _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=DeviceStatusResponse.model_validate(device.get_device_info()) + 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)}") 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/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/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/next/lights.py b/openscan_firmware/routers/next/lights.py index 8bc8a08..c363209 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 @@ -14,6 +14,7 @@ class LightStatusResponse(BaseModel): name: str is_on: bool + value: float settings: LightConfig @@ -102,6 +103,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, diff --git a/openscan_firmware/routers/next/motors.py b/openscan_firmware/routers/next/motors.py index d5fa4ab..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,13 +25,24 @@ 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 busy: bool target_angle: Optional[float] settings: MotorConfig - endstop: Optional[dict] + calibrated: bool + endstop: Optional[EndstopStatusResponse] @router.get("/", response_model=dict[str, MotorStatusResponse]) @@ -134,7 +146,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 +165,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/next/projects.py b/openscan_firmware/routers/next/projects.py index 40fd9cf..0dcac38 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 @@ -160,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: @@ -174,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)) @@ -444,12 +459,40 @@ 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}") - for photo_filename in scan.photos: - photo_path = os.path.join(scan_dir, photo_filename) - if not os.path.exists(photo_path): + 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 + + 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( "Photo %s missing on disk for project %s scan %s", photo_filename, @@ -457,11 +500,99 @@ 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 = { + 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 + + 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 +600,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 +631,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 +717,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 +761,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 +795,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/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/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/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/motors.py b/openscan_firmware/routers/v0_6/motors.py deleted file mode 100644 index f6e4590..0000000 --- a/openscan_firmware/routers/v0_6/motors.py +++ /dev/null @@ -1,119 +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 - 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_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/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/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 f6e4590..0000000 --- a/openscan_firmware/routers/v0_7/motors.py +++ /dev/null @@ -1,119 +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 - 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/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 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/openscan_firmware/routers/v0_8/device.py b/openscan_firmware/routers/v0_8/device.py index 2686918..bd1dd74 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 @@ -31,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 @@ -41,6 +49,42 @@ 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, + 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, + ) + + @router.get("/info", response_model=DeviceStatusResponse) async def get_device_info(): """Get information about the device @@ -91,7 +135,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 +152,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 +172,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 +220,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 +249,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/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_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/openscan_firmware/routers/v0_8/projects.py b/openscan_firmware/routers/v0_8/projects.py index 40fd9cf..0dcac38 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 @@ -160,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: @@ -174,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)) @@ -444,12 +459,40 @@ 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}") - for photo_filename in scan.photos: - photo_path = os.path.join(scan_dir, photo_filename) - if not os.path.exists(photo_path): + 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 + + 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( "Photo %s missing on disk for project %s scan %s", photo_filename, @@ -457,11 +500,99 @@ 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 = { + 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 + + 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 +600,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 +631,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 +717,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 +761,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 +795,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_6/__init__.py b/openscan_firmware/routers/v0_9/__init__.py similarity index 100% rename from openscan_firmware/routers/v0_6/__init__.py rename to openscan_firmware/routers/v0_9/__init__.py 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_7/cloud.py b/openscan_firmware/routers/v0_9/cloud.py similarity index 93% rename from openscan_firmware/routers/v0_7/cloud.py rename to openscan_firmware/routers/v0_9/cloud.py index be09d07..e84723d 100644 --- a/openscan_firmware/routers/v0_7/cloud.py +++ b/openscan_firmware/routers/v0_9/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/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_6/device.py b/openscan_firmware/routers/v0_9/device.py similarity index 54% rename from openscan_firmware/routers/v0_6/device.py rename to openscan_firmware/routers/v0_9/device.py index b085795..3f14eb5 100644 --- a/openscan_firmware/routers/v0_6/device.py +++ b/openscan_firmware/routers/v0_9/device.py @@ -1,11 +1,14 @@ from fastapi import APIRouter, HTTPException, UploadFile, File from pydantic import BaseModel, ValidationError +from typing import Any +from pathlib import Path import os import json import tempfile import shutil +import logging -from openscan_firmware.models.scanner import ScannerDevice +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 @@ -19,17 +22,23 @@ responses={404: {"description": "Not found"}}, ) +logger = logging.getLogger(__name__) + class DeviceConfigRequest(BaseModel): config_file: str 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] + motors_timeout: float + scan_radius_mm: float = 1.0 + startup_mode: ScannerStartupMode + calibrate_mode: ScannerCalibrateMode initialized: bool class DeviceControlResponse(BaseModel): @@ -38,6 +47,19 @@ class DeviceControlResponse(BaseModel): status: DeviceStatusResponse +class DeviceConfigResponse(BaseModel): + status: str + filename: str + path: str + config: dict[str, Any] + + +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 @@ -47,6 +69,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( @@ -70,8 +111,63 @@ async def list_config_files(): 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: ScannerDevice, 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, @@ -85,10 +181,20 @@ 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={"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.dict() + 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 @@ -96,19 +202,32 @@ 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 = 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=DeviceStatusResponse.model_validate(device.get_device_info()) + 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)}") @@ -121,13 +240,15 @@ 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(): return DeviceControlResponse( success=True, message="Configuration saved successfully", - status=DeviceStatusResponse.model_validate(device.get_device_info()) + 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) @@ -141,6 +262,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() @@ -170,12 +292,15 @@ async def set_config_file(config_data: DeviceConfigRequest): # 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=DeviceStatusResponse.model_validate(device.get_device_info()) + 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: @@ -197,14 +322,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 = _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=DeviceStatusResponse.model_validate(device.get_device_info()) + 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)}") @@ -227,4 +363,4 @@ def shutdown(save_config: bool = False) -> None: save_config: Whether to save the current configuration before shutting down """ device.shutdown(save_config) - return True \ No newline at end of file + 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_6/focus_stacking.py b/openscan_firmware/routers/v0_9/focus_stacking.py similarity index 100% rename from openscan_firmware/routers/v0_6/focus_stacking.py rename to openscan_firmware/routers/v0_9/focus_stacking.py diff --git a/openscan_firmware/routers/v0_7/gpio.py b/openscan_firmware/routers/v0_9/gpio.py similarity index 58% rename from openscan_firmware/routers/v0_7/gpio.py rename to openscan_firmware/routers/v0_9/gpio.py index a4d5a0c..8061799 100644 --- a/openscan_firmware/routers/v0_7/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 diff --git a/openscan_firmware/routers/v0_6/lights.py b/openscan_firmware/routers/v0_9/lights.py similarity index 95% rename from openscan_firmware/routers/v0_6/lights.py rename to openscan_firmware/routers/v0_9/lights.py index 836dcbe..8bc8a08 100644 --- a/openscan_firmware/routers/v0_6/lights.py +++ b/openscan_firmware/routers/v0_9/lights.py @@ -58,7 +58,7 @@ async def turn_on_light(light_name: str): """ try: controller = get_light_controller(light_name) - controller.turn_on() + await controller.turn_on() return controller.get_status() except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -76,7 +76,7 @@ async def turn_off_light(light_name: str): """ try: controller = get_light_controller(light_name) - controller.turn_off() + await controller.turn_off() return controller.get_status() except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -95,9 +95,9 @@ async def toggle_light(light_name: str): try: controller = get_light_controller(light_name) if controller.is_on: - controller.turn_off() + await controller.turn_off() else: - controller.turn_on() + await controller.turn_on() return controller.get_status() except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -108,4 +108,4 @@ async def toggle_light(light_name: str): 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_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_7/openscan.py b/openscan_firmware/routers/v0_9/openscan.py similarity index 74% rename from openscan_firmware/routers/v0_7/openscan.py rename to openscan_firmware/routers/v0_9/openscan.py index 213e861..096fa6a 100644 --- a/openscan_firmware/routers/v0_7/openscan.py +++ b/openscan_firmware/routers/v0_9/openscan.py @@ -1,21 +1,52 @@ 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 typing import Tuple +from pydantic import BaseModel, Field +from starlette.background import BackgroundTask +from starlette.responses import FileResponse 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 +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="", @@ -23,11 +54,37 @@ responses={404: {"description": "Not found"}}, ) -@router.get("/") -async def get_software_info(): +@router.get("/", response_model=SoftwareInfoResponse) +async def get_software_info() -> SoftwareInfoResponse: """Get information about the scanner software""" - return {"model": get_scanner_model(), - "firmware_version": __version__} + 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 diff --git a/openscan_firmware/routers/v0_7/projects.py b/openscan_firmware/routers/v0_9/projects.py similarity index 73% rename from openscan_firmware/routers/v0_7/projects.py rename to openscan_firmware/routers/v0_9/projects.py index 305fcbe..0dcac38 100644 --- a/openscan_firmware/routers/v0_7/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 @@ -160,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: @@ -174,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)) @@ -442,12 +457,161 @@ def _serialize_project_for_zip(project: Project) -> str: 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.""" + 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 = 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 + + 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( + "Photo %s missing on disk for project %s scan %s", + photo_filename, + project.name, + scan.index, + ) + continue + 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 = { + 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 + + 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): +async def download_project( + project_name: str, + photos_only: bool = Query( + 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. + 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 @@ -464,15 +628,38 @@ async def download_project(project_name: str): 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) + if photos_only: + zs = ZipStream(sized=True) + zs.comment = f"OpenScan3 Project Photos: {project_name}" + 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) - # Add project metadata - zs.add(_serialize_project_for_zip(project), "project_metadata.json") + # 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={project_name}.zip", + "Content-Disposition": f"attachment; filename={filename}", } if getattr(zs, "last_modified", None): headers["Last-Modified"] = str(zs.last_modified) @@ -530,7 +717,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. @@ -567,18 +761,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") @@ -595,7 +795,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_6/settings_utils.py b/openscan_firmware/routers/v0_9/settings_utils.py similarity index 100% rename from openscan_firmware/routers/v0_6/settings_utils.py rename to openscan_firmware/routers/v0_9/settings_utils.py diff --git a/openscan_firmware/routers/v0_6/tasks.py b/openscan_firmware/routers/v0_9/tasks.py similarity index 87% rename from openscan_firmware/routers/v0_6/tasks.py rename to openscan_firmware/routers/v0_9/tasks.py index 0c193a0..81f181e 100644 --- a/openscan_firmware/routers/v0_6/tasks.py +++ b/openscan_firmware/routers/v0_9/tasks.py @@ -1,6 +1,6 @@ from typing import List, Any, Dict -from fastapi import APIRouter, HTTPException, status, Body +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 @@ -61,6 +61,25 @@ async def cancel_task(task_id: str): 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): """ 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) diff --git a/openscan_firmware/utils/paths/paths.py b/openscan_firmware/utils/paths/paths.py index 32e102a..bf71275 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,45 +98,92 @@ 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°") 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 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°.""" + if min_phi == max_phi: + return 0.0 + + 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, + ) + 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 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 +202,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 +238,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/openscan_firmware/utils/pwm_hardware.py b/openscan_firmware/utils/pwm_hardware.py new file mode 100644 index 0000000..719d140 --- /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() 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..f53b79f --- /dev/null +++ b/openscan_firmware/utils/wifi.py @@ -0,0 +1,259 @@ +"""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 +import time +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, + *, + 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) + to modify network connections. + + 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. + + 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, + "password", credentials.password, + ] + + if credentials.hidden: + cmd.extend(["hidden", "yes"]) + + logger.info("Attempting to connect to WiFi network '%s'", credentials.ssid) + + 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() + + 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}") + + raise RuntimeError( + f"Failed to connect to '{credentials.ssid}' after {attempts} attempts: {error_msg}" + ) + + +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. + """ + 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"], + capture_output=True, + text=True, + timeout=5, + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as exc: + 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: + """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) + + +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) diff --git a/pyproject.toml b/pyproject.toml index 2339885..2dd09ad 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.11.3" description = "OpenScan3 - Raspberry Pi based photogrammetry scanner (FastAPI-based application)" readme = "README.md" requires-python = ">=3.11" @@ -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/scripts/camera_report.sh b/scripts/camera_report.sh new file mode 100755 index 0000000..997ceb5 --- /dev/null +++ b/scripts/camera_report.sh @@ -0,0 +1,81 @@ +#!/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_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 + 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 + +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/scripts/openapi/openapi_latest.json b/scripts/openapi/openapi_latest.json index ef6d856..db1711d 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": { @@ -716,6 +794,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": { @@ -1326,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": [ @@ -1719,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", @@ -2235,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": [ { @@ -2258,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": { @@ -2358,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": { @@ -2495,7 +2733,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" + } } } }, @@ -2539,7 +2780,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" + } } } }, @@ -2730,52 +2974,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" @@ -2843,8 +3065,99 @@ } } }, - "/device/configurations/current/initialize": { - "post": { + "/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/DeviceConfigResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/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/initialize": { + "post": { "tags": [ "device" ], @@ -3363,6 +3676,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": [ @@ -3475,6 +3837,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": [ @@ -3624,6 +4034,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": { @@ -4091,7 +4524,7 @@ "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { - "$ref": "#/components/schemas/ScannerDevice" + "$ref": "#/components/schemas/ScannerDeviceConfig" }, "filename": { "$ref": "#/components/schemas/DeviceConfigRequest" @@ -4137,32 +4570,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": { @@ -4454,8 +4861,7 @@ "enum": [ "gphoto2", "linuxpy", - "picamera2", - "external" + "picamera2" ], "title": "CameraType" }, @@ -4713,6 +5119,35 @@ ], "title": "DeviceConfigRequest" }, + "DeviceConfigResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "path": { + "type": "string", + "title": "Path" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + } + }, + "type": "object", + "required": [ + "status", + "filename", + "path", + "config" + ], + "title": "DeviceConfigResponse" + }, "DeviceControlResponse": { "properties": { "success": { @@ -4742,11 +5177,25 @@ "title": "Name" }, "model": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Model" }, "shield": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Shield" }, "cameras": { @@ -4774,7 +5223,12 @@ "type": "number", "title": "Motors Timeout" }, - "startup_mode": { + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "default": 1.0 + }, + "startup_mode": { "$ref": "#/components/schemas/ScannerStartupMode" }, "calibrate_mode": { @@ -4788,8 +5242,6 @@ "type": "object", "required": [ "name", - "model", - "shield", "cameras", "motors", "lights", @@ -4827,30 +5279,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": { @@ -4917,6 +5345,104 @@ "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 + }, + "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.\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": { "detail": { @@ -4930,23 +5456,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": { @@ -5008,35 +5517,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": { @@ -5147,17 +5627,19 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/EndstopStatusResponse" }, { "type": "null" } - ], - "title": "Endstop" + ] } }, "type": "object", @@ -5167,6 +5649,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" @@ -5178,6 +5661,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": { @@ -5410,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": [ @@ -5469,6 +5999,7 @@ "type": "string", "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -5493,6 +6024,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", @@ -5513,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": [ { @@ -5550,7 +6116,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDevice": { + "ScannerDeviceConfig": { "properties": { "name": { "type": "string", @@ -5559,49 +6125,58 @@ "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" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { "additionalProperties": { - "$ref": "#/components/schemas/Endstop" + "$ref": "#/components/schemas/PersistedEndstopConfig" }, "type": "object" }, @@ -5616,44 +6191,44 @@ "title": "Motors Timeout", "default": 0.0 }, + "scan_radius_mm": { + "type": "number", + "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": { - "$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" - ], - "title": "ScannerModel" - }, - "ScannerShield": { - "type": "string", - "enum": [ - "greenshield", - "blackshield", - "custom" + "name" ], - "title": "ScannerShield" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, "ScannerStartupMode": { "type": "string", @@ -5904,6 +6479,48 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerActiveLevel": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerActiveLevel" + }, + "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." + }, + "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": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Pulse Width Ms", + "description": "How long the trigger line stays active for each trigger pulse in ms.", + "default": 100 + } + }, + "type": "object", + "required": [ + "pin" + ], + "title": "TriggerConfig" + }, "ValidationError": { "properties": { "loc": { diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index 349ece7..4f70065 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -164,6 +164,85 @@ "type": "string", "title": "Camera Name" } + }, + { + "name": "image_format", + "in": "query", + "required": false, + "schema": { + "enum": [ + "jpeg", + "raw", + "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": { @@ -716,6 +795,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": { @@ -1326,6 +1417,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": [ @@ -1719,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", @@ -2235,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": [ { @@ -2258,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": { @@ -2358,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": { @@ -2385,28 +2624,21 @@ } } }, - "/gpio/": { + "/": { "get": { "tags": [ - "gpio" + "openscan" ], - "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", + "summary": "Get Software Info", + "description": "Get information about the scanner software", + "operationId": "get_software_info", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": { - "items": { - "type": "integer" - }, - "type": "array" - }, - "type": "object", - "title": "Response Get Pins Gpio Get" + "$ref": "#/components/schemas/SoftwareInfoResponse" } } } @@ -2417,190 +2649,23 @@ } } }, - "/gpio/{pin_id}": { + "/logs/tail": { "get": { "tags": [ - "gpio" + "openscan" ], - "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": "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": "pin_id", - "in": "path", - "required": true, + "name": "format", + "in": "query", + "required": false, "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": { - "$ref": "#/components/schemas/SoftwareInfoResponse" - } - } - } - }, - "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" + "type": "string", + "default": "text", + "title": "Format" } }, { @@ -2730,52 +2795,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" @@ -2843,23 +2886,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" } } ], @@ -2869,7 +2911,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "$ref": "#/components/schemas/DeviceConfigResponse" } } } @@ -2890,34 +2932,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" } } } @@ -2938,17 +2977,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": { @@ -3291,68 +3425,28 @@ } } }, - "/develop/scanner-position": { - "put": { + "/gpio/": { + "get": { "tags": [ - "develop" + "gpio" ], - "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 - }, + "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": {} - } - } - }, - "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" + "items": { + "type": "integer" + }, + "type": "array" }, "type": "object", - "title": "Response Restart Application Develop Restart Post" + "title": "Response Get Pins Gpio Get" } } } @@ -3363,46 +3457,36 @@ } } }, - "/develop/crop_image": { + "/gpio/{pin_id}": { "get": { "tags": [ - "develop" + "gpio" ], - "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", + "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": "camera_name", - "in": "query", + "name": "pin_id", + "in": "path", "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" + "type": "integer", + "title": "Pin Id" } } ], "responses": { "200": { - "description": "Successful Response" + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Get Pin Gpio Pin Id Get" + } + } + } }, "404": { "description": "Not found" @@ -3418,33 +3502,31 @@ } } } - } - }, - "/develop/hello-world-async": { - "post": { + }, + "patch": { "tags": [ - "develop" + "gpio" ], - "summary": "Hello World Async", - "description": "Start the async hello world demo task.", - "operationId": "hello_world_async", + "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": "total_steps", - "in": "query", + "name": "pin_id", + "in": "path", "required": true, "schema": { "type": "integer", - "title": "Total Steps" + "title": "Pin Id" } }, { - "name": "delay", + "name": "status", "in": "query", "required": true, "schema": { - "type": "number", - "title": "Delay" + "type": "boolean", + "title": "Status" } } ], @@ -3454,7 +3536,8 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Task" + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" } } } @@ -3475,30 +3558,22 @@ } } }, - "/develop/{method}": { - "get": { + "/gpio/{pin_id}/toggle": { + "patch": { "tags": [ - "develop" + "gpio" ], - "summary": "Get Path", - "description": "Get a list of coordinates by path method and number of points", - "operationId": "get_path", + "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": "method", + "name": "pin_id", "in": "path", "required": true, - "schema": { - "$ref": "#/components/schemas/PathMethod" - } - }, - { - "name": "points", - "in": "query", - "required": true, "schema": { "type": "integer", - "title": "Points" + "title": "Pin Id" } } ], @@ -3508,11 +3583,8 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CartesianPoint3D" - }, - "title": "Response Get Path Develop Method Get" + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" } } } @@ -3533,21 +3605,24 @@ } } }, - "/cloud/status": { + "/triggers/": { "get": { "tags": [ - "cloud" + "triggers" ], - "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", + "summary": "Get Triggers", + "operationId": "get_triggers", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CloudStatusResponse" + "additionalProperties": { + "$ref": "#/components/schemas/TriggerStatusResponse" + }, + "type": "object", + "title": "Response Get Triggers Triggers Get" } } } @@ -3558,46 +3633,85 @@ } } }, - "/cloud/settings": { + "/triggers/{trigger_name}": { "get": { "tags": [ - "cloud" + "triggers" + ], + "summary": "Get Trigger", + "operationId": "get_trigger", + "parameters": [ + { + "name": "trigger_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Trigger Name" + } + } ], - "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" + "$ref": "#/components/schemas/TriggerStatusResponse" } } } }, "404": { "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } - }, + } + }, + "/triggers/{trigger_name}/trigger": { "post": { "tags": [ - "cloud" + "triggers" + ], + "summary": "Trigger Once", + "operationId": "trigger_once", + "parameters": [ + { + "name": "trigger_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Trigger Name" + } + } ], - "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" + "anyOf": [ + { + "$ref": "#/components/schemas/TriggerExecutionRequest" + }, + { + "type": "null" + } + ], + "title": "Request" } } - }, - "required": true + } }, "responses": { "200": { @@ -3605,7 +3719,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CloudSettingsResponse" + "$ref": "#/components/schemas/TriggerExecutionResponse" } } } @@ -3626,61 +3740,86 @@ } } }, - "/cloud/projects": { + "/triggers/{name}/settings": { "get": { "tags": [ - "cloud" + "triggers" + ], + "summary": "Get Trigger Name Settings", + "description": "Get settings for a specific resource", + "operationId": "get_trigger_name_settings", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Name" + } + } ], - "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" + "$ref": "#/components/schemas/TriggerConfig" } } } }, "404": { "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } - } - }, - "/cloud/projects/{project_name}": { - "get": { + }, + "put": { "tags": [ - "cloud" + "triggers" ], - "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", + "summary": "Replace Trigger Name Settings", + "description": "Replace all settings for a specific resource", + "operationId": "replace_trigger_name_settings", "parameters": [ { - "name": "project_name", + "name": "name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Project Name" + "title": "Name" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriggerConfig" + } + } + } + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CloudProjectStatus" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -3700,33 +3839,48 @@ } } }, - "delete": { + "patch": { "tags": [ - "cloud" + "triggers" ], - "summary": "Reset Cloud Project", - "description": "Reset the remote project and clear the local linkage.\n\nInvokes the cloud backend's `resetProject` action, which removes the\ncurrent reconstruction job (queue progress, generated models and downloads)\nand frees the remote project name for another upload.\nLocally the project is marked as not uploaded anymore, the cached\n`cloud_project_name` is cleared, and the `downloaded` flag is reset to\nFalse so a subsequent download reflects the new state. The on-disk files\nstay untouched.\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", + "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": "project_name", + "name": "name", "in": "path", "required": true, "schema": { "type": "string", - "title": "Project 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": { - "type": "object", - "additionalProperties": true, - "title": "Response Reset Cloud Project Cloud Projects Project Name Delete" + "$ref": "#/components/schemas/TriggerConfig" } } } @@ -3747,59 +3901,51 @@ } } }, - "/cloud/projects/{project_name}/download": { - "post": { + "/external-trigger/runs/": { + "get": { "tags": [ - "cloud" + "external-trigger" ], - "summary": "Download Project From Cloud", - "description": "Schedule an asynchronous cloud download for a project's reconstruction.\n\nArgs:\n project_name: Local project name whose reconstruction should be downloaded.\n token_override: Optional token override forwarded to the download task.\n remote_project: Optional explicit remote project identifier; defaults to stored linkage.\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" + "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" } - ], - "title": "Token Override" + } } }, - { - "name": "remote_project", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Remote Project" - } + "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": { - "200": { + "202": { "description": "Successful Response", "content": { "application/json": { @@ -3825,31 +3971,21 @@ } } }, - "/projects/{project_name}/scans/{scan_index}/focus-stacking/start": { - "post": { + "/external-trigger/runs/{task_id}": { + "get": { "tags": [ - "focus_stacking" + "external-trigger" ], - "summary": "Start Focus Stacking", - "description": "Start focus stacking for a scan.", - "operationId": "start_focus_stacking", + "summary": "Get External Trigger Run", + "operationId": "get_external_trigger_run", "parameters": [ { - "name": "project_name", + "name": "task_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Project Name" - } - }, - { - "name": "scan_index", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Scan Index" + "title": "Task Id" } } ], @@ -3864,6 +4000,9 @@ } } }, + "404": { + "description": "Not found" + }, "422": { "description": "Validation Error", "content": { @@ -3877,14 +4016,900 @@ } } }, - "/projects/{project_name}/scans/{scan_index}/focus-stacking/pause": { - "patch": { + "/external-trigger/runs/{task_id}/path": { + "get": { "tags": [ - "focus_stacking" + "external-trigger" ], - "summary": "Pause Focus Stacking", - "description": "Pause an active focus stacking task.", - "operationId": "pause_focus_stacking", + "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" + } + } + } + } + } + } + }, + "/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/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": [ + "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/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": [ + "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" + } + } + } + } + } + }, + "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": { + "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\nInvokes the cloud backend's `resetProject` action, which removes the\ncurrent reconstruction job (queue progress, generated models and downloads)\nand frees the remote project name for another upload.\nLocally the project is marked as not uploaded anymore, the cached\n`cloud_project_name` is cleared, and the `downloaded` flag is reset to\nFalse so a subsequent download reflects the new state. The on-disk files\nstay untouched.\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" + } + } + } + } + } + } + }, + "/cloud/projects/{project_name}/download": { + "post": { + "tags": [ + "cloud" + ], + "summary": "Download Project From Cloud", + "description": "Schedule an asynchronous cloud download for a project's reconstruction.\n\nArgs:\n project_name: Local project name whose reconstruction should be downloaded.\n token_override: Optional token override forwarded to the download task.\n remote_project: Optional explicit remote project identifier; defaults to stored linkage.\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}/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", @@ -4091,7 +5116,7 @@ "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { - "$ref": "#/components/schemas/ScannerDevice" + "$ref": "#/components/schemas/ScannerDeviceConfig" }, "filename": { "$ref": "#/components/schemas/DeviceConfigRequest" @@ -4137,32 +5162,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": { @@ -4454,8 +5453,7 @@ "enum": [ "gphoto2", "linuxpy", - "picamera2", - "external" + "picamera2" ], "title": "CameraType" }, @@ -4704,14 +5702,43 @@ "properties": { "config_file": { "type": "string", - "title": "Config File" + "title": "Config File" + } + }, + "type": "object", + "required": [ + "config_file" + ], + "title": "DeviceConfigRequest" + }, + "DeviceConfigResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "path": { + "type": "string", + "title": "Path" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" } }, "type": "object", "required": [ - "config_file" + "status", + "filename", + "path", + "config" ], - "title": "DeviceConfigRequest" + "title": "DeviceConfigResponse" }, "DeviceControlResponse": { "properties": { @@ -4742,11 +5769,25 @@ "title": "Name" }, "model": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Model" }, "shield": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Shield" }, "cameras": { @@ -4770,10 +5811,22 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerStatusResponse" + }, + "type": "object", + "title": "Triggers" + }, "motors_timeout": { "type": "number", "title": "Motors Timeout" }, + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "default": 1.0 + }, "startup_mode": { "$ref": "#/components/schemas/ScannerStartupMode" }, @@ -4788,8 +5841,6 @@ "type": "object", "required": [ "name", - "model", - "shield", "cameras", "motors", "lights", @@ -4827,30 +5878,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": { @@ -4917,6 +5944,295 @@ "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" + }, + "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 + }, + "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", + "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": 600000.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": 600000.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": { + "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 + }, + "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.\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": { "detail": { @@ -4930,23 +6246,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": { @@ -5008,35 +6307,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": { @@ -5147,17 +6417,19 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/EndstopStatusResponse" }, { "type": "null" } - ], - "title": "Endstop" + ] } }, "type": "object", @@ -5167,6 +6439,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" @@ -5178,6 +6451,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": { @@ -5410,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": [ @@ -5469,6 +6789,7 @@ "type": "string", "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -5493,6 +6814,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", @@ -5513,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": [ { @@ -5550,7 +6906,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDevice": { + "ScannerDeviceConfig": { "properties": { "name": { "type": "string", @@ -5559,49 +6915,58 @@ "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" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { "additionalProperties": { - "$ref": "#/components/schemas/Endstop" + "$ref": "#/components/schemas/PersistedEndstopConfig" }, "type": "object" }, @@ -5616,44 +6981,44 @@ "title": "Motors Timeout", "default": 0.0 }, + "scan_radius_mm": { + "type": "number", + "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": { - "$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" - ], - "title": "ScannerModel" - }, - "ScannerShield": { - "type": "string", - "enum": [ - "greenshield", - "blackshield", - "custom" + "name" ], - "title": "ScannerShield" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, "ScannerStartupMode": { "type": "string", @@ -5904,6 +7269,155 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerActiveLevel": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerActiveLevel" + }, + "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." + }, + "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": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Pulse Width Ms", + "description": "How long the trigger line stays active for each trigger pulse in ms.", + "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" + }, + "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.6.json b/scripts/openapi/openapi_v0.6.json deleted file mode 100644 index 8cf2abe..0000000 --- a/scripts/openapi/openapi_v0.6.json +++ /dev/null @@ -1,5268 +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" - }, - "endstop": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Endstop" - } - }, - "type": "object", - "required": [ - "name", - "angle", - "busy", - "target_angle", - "settings", - "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.8.json b/scripts/openapi/openapi_v0.8.json index ef6d856..386e214 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": { @@ -1719,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", @@ -2235,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": [ { @@ -2258,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": { @@ -2358,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": { @@ -2495,7 +2531,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" + } } } }, @@ -2539,7 +2578,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" + } } } }, @@ -3624,6 +3666,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": { @@ -4454,8 +4519,7 @@ "enum": [ "gphoto2", "linuxpy", - "picamera2", - "external" + "picamera2" ], "title": "CameraType" }, @@ -4774,6 +4838,11 @@ "type": "number", "title": "Motors Timeout" }, + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "default": 1.0 + }, "startup_mode": { "$ref": "#/components/schemas/ScannerStartupMode" }, @@ -5147,6 +5216,10 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { @@ -5167,6 +5240,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" @@ -5410,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": [ @@ -5469,6 +5550,7 @@ "type": "string", "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -5493,6 +5575,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", @@ -5513,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": [ { @@ -5597,6 +5714,13 @@ "type": "object", "title": "Lights" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/Trigger" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { @@ -5616,6 +5740,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" @@ -5642,6 +5772,7 @@ "enum": [ "classic", "mini", + "midi", "custom" ], "title": "ScannerModel" @@ -5904,6 +6035,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" + }, + "TriggerActiveLevel": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerActiveLevel" + }, + "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." + }, + "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": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Pulse Width Ms", + "description": "How long the trigger line stays active for each trigger pulse in ms.", + "default": 100 + } + }, + "type": "object", + "required": [ + "pin" + ], + "title": "TriggerConfig" + }, "ValidationError": { "properties": { "loc": { diff --git a/scripts/openapi/openapi_v0.7.json b/scripts/openapi/openapi_v0.9.json similarity index 81% rename from scripts/openapi/openapi_v0.7.json rename to scripts/openapi/openapi_v0.9.json index 3c115aa..db1711d 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" ], @@ -1554,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", @@ -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,\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": [ { @@ -2081,6 +2460,30 @@ "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." + }, + { + "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": { @@ -2181,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": { @@ -2318,7 +2733,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Set Pin Gpio Pin Id Patch" + } } } }, @@ -2362,7 +2780,10 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "boolean", + "title": "Response Toggle Pin Gpio Pin Id Toggle Patch" + } } } }, @@ -2395,7 +2816,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/SoftwareInfoResponse" + } } } }, @@ -2551,52 +2974,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" @@ -2664,23 +3065,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" } } ], @@ -2690,7 +3090,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeviceControlResponse" + "$ref": "#/components/schemas/DeviceConfigResponse" } } } @@ -2711,28 +3111,120 @@ } } }, - "/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" + } } - } - ], - "responses": { - "200": { + }, + "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/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": { @@ -2926,6 +3418,45 @@ } } }, + "/tasks/{task_id}/cleanup": { + "delete": { + "tags": [ + "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" + } + } + ], + "responses": { + "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": [ @@ -3145,6 +3676,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 +3837,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 +4034,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,26 +4469,78 @@ }, "components": { "schemas": { - "Body_add_config_json_device_configurations__post": { + "AutoCalibrateAwbRequest": { "properties": { - "config_data": { - "$ref": "#/components/schemas/ScannerDevice" + "warmup_frames": { + "type": "integer", + "minimum": 0.0, + "title": "Warmup Frames", + "description": "Number of frames to discard before reading AWB metadata.", + "default": 12 }, - "filename": { - "$ref": "#/components/schemas/DeviceConfigRequest" + "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", - "required": [ - "config_data", - "filename" - ], - "title": "Body_add_config_json_device_configurations__post" + "title": "AutoCalibrateAwbRequest" }, - "Body_create_task_tasks__task_name__post": { + "AutoCalibrateAwbResponse": { "properties": { - "args": { - "items": {}, + "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/ScannerDeviceConfig" + }, + "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", @@ -3867,32 +4570,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": { @@ -4184,8 +4861,7 @@ "enum": [ "gphoto2", "linuxpy", - "picamera2", - "external" + "picamera2" ], "title": "CameraType" }, @@ -4443,6 +5119,35 @@ ], "title": "DeviceConfigRequest" }, + "DeviceConfigResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "path": { + "type": "string", + "title": "Path" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + } + }, + "type": "object", + "required": [ + "status", + "filename", + "path", + "config" + ], + "title": "DeviceConfigResponse" + }, "DeviceControlResponse": { "properties": { "success": { @@ -4472,11 +5177,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 +5219,21 @@ "type": "object", "title": "Lights" }, + "motors_timeout": { + "type": "number", + "title": "Motors Timeout" + }, + "scan_radius_mm": { + "type": "number", + "title": "Scan Radius Mm", + "default": 1.0 + }, + "startup_mode": { + "$ref": "#/components/schemas/ScannerStartupMode" + }, + "calibrate_mode": { + "$ref": "#/components/schemas/ScannerCalibrateMode" + }, "initialized": { "type": "boolean", "title": "Initialized" @@ -4508,38 +5242,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 +5345,104 @@ "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 + }, + "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.\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": { "detail": { @@ -4620,23 +5456,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 +5517,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": { @@ -4837,17 +5627,19 @@ "settings": { "$ref": "#/components/schemas/MotorConfig" }, + "calibrated": { + "type": "boolean", + "title": "Calibrated" + }, "endstop": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/EndstopStatusResponse" }, { "type": "null" } - ], - "title": "Endstop" + ] } }, "type": "object", @@ -4857,6 +5649,7 @@ "busy", "target_angle", "settings", + "calibrated", "endstop" ], "title": "MotorStatusResponse" @@ -4868,6 +5661,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": { @@ -5100,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": [ @@ -5159,6 +5999,7 @@ "type": "string", "enum": [ "jpeg", + "raw", "dng", "rgb_array", "yuv_array" @@ -5183,6 +6024,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", @@ -5203,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": [ { @@ -5240,7 +6116,7 @@ ], "title": "ScannerCalibrateMode" }, - "ScannerDevice": { + "ScannerDeviceConfig": { "properties": { "name": { "type": "string", @@ -5249,49 +6125,58 @@ "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" }, + "triggers": { + "additionalProperties": { + "$ref": "#/components/schemas/TriggerConfig" + }, + "type": "object", + "title": "Triggers" + }, "endstops": { "anyOf": [ { "additionalProperties": { - "$ref": "#/components/schemas/Endstop" + "$ref": "#/components/schemas/PersistedEndstopConfig" }, "type": "object" }, @@ -5306,44 +6191,44 @@ "title": "Motors Timeout", "default": 0.0 }, + "scan_radius_mm": { + "type": "number", + "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": { - "$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" - }, - "ScannerModel": { - "type": "string", - "enum": [ - "classic", - "mini", - "custom" - ], - "title": "ScannerModel" - }, - "ScannerShield": { - "type": "string", - "enum": [ - "greenshield", - "blackshield", - "custom" - ], - "title": "ScannerShield" + "title": "ScannerDeviceConfig", + "description": "Persisted scanner configuration payload stored as JSON." }, "ScannerStartupMode": { "type": "string", @@ -5353,6 +6238,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": { @@ -5521,6 +6479,48 @@ "title": "TaskStatus", "description": "Enum for task status" }, + "TriggerActiveLevel": { + "type": "string", + "enum": [ + "active_high", + "active_low" + ], + "title": "TriggerActiveLevel" + }, + "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." + }, + "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": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Pulse Width Ms", + "description": "How long the trigger line stays active for each trigger pulse in ms.", + "default": 100 + } + }, + "type": "object", + "required": [ + "pin" + ], + "title": "TriggerConfig" + }, "ValidationError": { "properties": { "loc": { 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 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..2e1e148 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": {}, @@ -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" } diff --git a/settings/firmware/firmware_settings.json b/settings/firmware/firmware_settings.json new file mode 100644 index 0000000..cbdb120 --- /dev/null +++ b/settings/firmware/firmware_settings.json @@ -0,0 +1,4 @@ +{ + "qr_wifi_scan_enabled": true, + "enable_cloud": false +} diff --git a/tests/config/test_scan_config.py b/tests/config/test_scan_config.py new file mode 100644 index 0000000..3378488 --- /dev/null +++ b/tests/config/test_scan_config.py @@ -0,0 +1,51 @@ +from openscan_firmware.config.external_trigger_run import ExternalTriggerRunSettings +from openscan_firmware.config.scan import ScanSetting + + +def test_scan_settings_include_default_phi_fields_in_json_dump() -> None: + settings = ScanSetting() + + payload = settings.model_dump(mode="json") + + assert payload["min_phi"] == 0 + assert payload["max_phi"] == 360.0 + + +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") + + 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/hardware/picamera2/test_picamera2_focus_unit.py b/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py new file mode 100644 index 0000000..8bbdfa6 --- /dev/null +++ b/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py @@ -0,0 +1,101 @@ +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", (), {}) + 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") + + +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} + ] 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_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 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/tasks/test_cloud_download_task.py b/tests/controllers/services/tasks/test_cloud_download_task.py index b679f23..3970d35 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,129 @@ 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(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", + 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._create_cloud_download_temp_file", + 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) + + +@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 217950b..ddedf03 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 @@ -13,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 @@ -371,3 +375,135 @@ 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, prefix=None): + captured["dir"] = dir + captured["prefix"] = prefix + 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 captured["prefix"] == "cloud-upload-" + 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, prefix=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) + + +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) 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/tasks/test_qr_scan_task.py b/tests/controllers/services/tasks/test_qr_scan_task.py new file mode 100644 index 0000000..6f13be7 --- /dev/null +++ b/tests/controllers/services/tasks/test_qr_scan_task.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import asyncio +from datetime import UTC, 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) + 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) + + 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) + 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) + + 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_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.""" + + monkeypatch.setattr(qr_module, "get_task_manager", lambda: qr_task_manager) + + now = datetime.now(UTC) + 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 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/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/controllers/services/test_scan_task.py b/tests/controllers/services/test_scan_task.py index 9323c8e..e73d120 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,188 @@ 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._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, + } + 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 +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') @@ -360,27 +543,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) @@ -389,52 +558,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 + scan_task.resume() + await capture_task + await asyncio.sleep(0) - final_task_model = await tm.wait_for_task(task_model.id) - assert final_task_model.status == TaskStatus.COMPLETED + 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_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_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') @@ -556,6 +731,183 @@ 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",) + + +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, + 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.""" @@ -975,4 +1327,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/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 diff --git a/tests/controllers/test_device_controller.py b/tests/controllers/test_device_controller.py index be91450..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,15 +147,27 @@ 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 @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,12 +180,322 @@ 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) +@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["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", + "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) @@ -184,12 +507,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_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 diff --git a/tests/routers/test_device_router.py b/tests/routers/test_device_router.py new file mode 100644 index 0000000..8498e92 --- /dev/null +++ b/tests/routers/test_device_router.py @@ -0,0 +1,311 @@ +"""Integration-style tests for the device router endpoints.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from importlib import import_module +from typing import Callable + +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 device_client() -> TestClient: + """Provide a FastAPI client with the next device router mounted.""" + + app = FastAPI() + 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() -> Callable[[str], str]: + """Shortcut to build module paths for the next router version.""" + + return _next_router_module_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) + + 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"}, + ) + + 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_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": {}, + "triggers": {}, + "endstops": None, + "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": {}, + "triggers": {}, + "endstops": None, + "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") + + 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, + "calibrated": True, + "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"} 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 diff --git a/tests/routers/test_firmware_router.py b/tests/routers/test_firmware_router.py new file mode 100644 index 0000000..08e6c8e --- /dev/null +++ b/tests/routers/test_firmware_router.py @@ -0,0 +1,175 @@ +"""Integration-style tests for the firmware router endpoints.""" + +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}" + + +@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, + "enable_cloud": False, + "camera_preview_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 + 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, "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): + 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, + "enable_cloud": False, + "camera_preview_enabled": True, + } + 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" + + +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_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_next_cameras_router.py b/tests/routers/test_next_cameras_router.py new file mode 100644 index 0000000..6516f74 --- /dev/null +++ b/tests/routers/test_next_cameras_router.py @@ -0,0 +1,798 @@ +"""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_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") + 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 diff --git a/tests/routers/test_next_develop_router.py b/tests/routers/test_next_develop_router.py new file mode 100644 index 0000000..2630cf3 --- /dev/null +++ b/tests/routers/test_next_develop_router.py @@ -0,0 +1,86 @@ +"""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 == 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") + + assert response.status_code == 200 + assert response.json() == { + "ok": True, + "return_code": 0, + "script": str(script), + "report": "camera report", + "stderr": "", + "gphoto2": {"available": True, "error": None, "detected": [], "cameras": []}, + } + + +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"] + + +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 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 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 diff --git a/tests/routers/test_projects_api.py b/tests/routers/test_projects_api.py index 28309dd..ea17e03 100644 --- a/tests/routers/test_projects_api.py +++ b/tests/routers/test_projects_api.py @@ -24,10 +24,12 @@ 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.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") @@ -38,9 +40,8 @@ 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" + latest_module_path = f"openscan_firmware.routers.v{LATEST.replace('.', '_')}.projects" next_module_path = "openscan_firmware.routers.next.projects" monkeypatch.setattr( @@ -48,7 +49,22 @@ 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): + 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, @@ -403,4 +419,261 @@ 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 + + +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_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, + 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 + + +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" + + +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() 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, 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 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, + )