diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..01b706d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: openscan +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..38385d2 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,10 @@ +openscan3-firmware (0.12.0) unstable; urgency=medium + + * Add Debian package installation and service integration for the firmware. + * Add API support for checking and installing OpenScan system updates. + * Add system health checks and repair actions. + * Preserve existing device settings during installation and upgrades. + * Schedule update installation outside the firmware service cgroup. + * Preserve the pre-upgrade service state across interrupted dpkg retries. + + -- OpenScan3 Contributors Thu, 23 Jul 2026 00:00:00 +0200 diff --git a/debian/clean b/debian/clean new file mode 100644 index 0000000..756c34f --- /dev/null +++ b/debian/clean @@ -0,0 +1,4 @@ +build/ +dist/ +*.egg-info/ +debian/build-wheelhouse/ diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..4b0ac6a --- /dev/null +++ b/debian/control @@ -0,0 +1,27 @@ +Source: openscan3-firmware +Section: misc +Priority: optional +Maintainer: OpenScan3 Contributors +Build-Depends: + debhelper-compat (= 13), + libcap-dev, + python3, + python3-pip, + python3-venv +Standards-Version: 4.7.0 +Homepage: https://github.com/OpenScan-org/OpenScan3 + +Package: openscan3-firmware +Architecture: any +Depends: + ${misc:Depends}, + adduser, + python3, + python3-pip, + python3-venv +Recommends: + openscan3-system-config +Description: OpenScan3 firmware runtime + FastAPI-based firmware runtime for OpenScan3 Raspberry Pi photogrammetry + scanners. The Debian package installs a bundled Python wheelhouse and creates + a runtime virtual environment under /opt/openscan3. diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..25b329b --- /dev/null +++ b/debian/install @@ -0,0 +1 @@ +debian/openscan3.service lib/systemd/system/ diff --git a/debian/openscan3.service b/debian/openscan3.service new file mode 100644 index 0000000..13ee569 --- /dev/null +++ b/debian/openscan3.service @@ -0,0 +1,21 @@ +[Unit] +Description=OpenScan3 firmware API +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=openscan +Group=openscan +WorkingDirectory=/var/openscan3 +Environment=OPENSCAN_SETTINGS_DIR=/etc/openscan3 +Environment=OPENSCAN_RUNTIME_DIR=/var/openscan3 +Environment=OPENSCAN_PROJECT_DIR=/var/openscan3/projects +Environment=OPENSCAN_COMMUNITY_TASKS_DIR=/var/openscan3/community-tasks +Environment=OPENSCAN_LOG_DIR=/var/log/openscan3 +ExecStart=/opt/openscan3/current/venv/bin/openscan-firmware serve --root-path /api +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 0000000..23f11ba --- /dev/null +++ b/debian/postinst @@ -0,0 +1,193 @@ +#!/bin/sh +set -e + +DEB_PACKAGE_NAME="openscan3-firmware" +PYTHON_PACKAGE_NAME="openscan-firmware" +RUNTIME_USER="openscan" +RUNTIME_GROUP="openscan" +RELEASES_DIR="/opt/openscan3/releases" +CURRENT_LINK="/opt/openscan3/current" +SERVICE_STATE_DIR="/run/openscan3-firmware" +SERVICE_WAS_ACTIVE_FILE="$SERVICE_STATE_DIR/openscan3.service-was-active" +RELEASE_METADATA="/usr/share/openscan3-firmware/release.json" + +package_version() { + dpkg-query -W -f='${Version}' "$DEB_PACKAGE_NAME" +} + +bundled_python_version() { + debian_version="$1" + python3 - "$RELEASE_METADATA" "$debian_version" <<'PY' +import json +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +installed_debian_version = sys.argv[2] +try: + metadata = json.loads(path.read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as error: + raise SystemExit(f"Invalid or missing firmware release metadata {path}: {error}") + +if metadata.get("schema") != 1: + raise SystemExit(f"Unsupported firmware release metadata schema in {path}") +if metadata.get("debian_version") != installed_debian_version: + raise SystemExit( + "Firmware release metadata does not match installed Debian version: " + f"{metadata.get('debian_version')!r} != {installed_debian_version!r}" + ) +python_version = metadata.get("python_version") +if not isinstance(python_version, str) or not python_version: + raise SystemExit(f"Missing Python version in firmware release metadata {path}") +print(python_version) +PY +} + +ensure_runtime_user() { + if ! getent group "$RUNTIME_GROUP" >/dev/null; then + addgroup --system "$RUNTIME_GROUP" >/dev/null + fi + + if ! getent passwd "$RUNTIME_USER" >/dev/null; then + adduser \ + --system \ + --ingroup "$RUNTIME_GROUP" \ + --home /var/openscan3 \ + --no-create-home \ + --disabled-login \ + "$RUNTIME_USER" >/dev/null + fi +} + +set_default_acl_if_available() { + dir="$1" + if command -v setfacl >/dev/null 2>&1; then + setfacl -m "g:${RUNTIME_GROUP}:rwx" -m "d:g:${RUNTIME_GROUP}:rwx" "$dir" || true + else + # TODO: Consider depending on acl once the image/package baseline is settled. + : + fi +} + +create_runtime_dir() { + dir="$1" + install -d -o "$RUNTIME_USER" -g "$RUNTIME_GROUP" -m 2775 "$dir" + chmod 2775 "$dir" + chown "$RUNTIME_USER:$RUNTIME_GROUP" "$dir" + set_default_acl_if_available "$dir" +} + +seed_settings_file() { + source_file="$1" + target_file="$2" + + if [ ! -f "$source_file" ]; then + return 0 + fi + + if [ -e "$target_file" ]; then + return 0 + fi + + install -o "$RUNTIME_USER" -g "$RUNTIME_GROUP" -m 0664 "$source_file" "$target_file" +} + +seed_default_settings() { + version="$1" + defaults_dir="$RELEASES_DIR/$version/default-settings" + + if [ ! -d "$defaults_dir" ]; then + echo "Missing bundled default settings: $defaults_dir" >&2 + return 0 + fi + + create_runtime_dir /etc/openscan3/device + create_runtime_dir /etc/openscan3/firmware + create_runtime_dir /etc/openscan3/logging + + for source_file in "$defaults_dir"/device/*.json; do + seed_settings_file "$source_file" "/etc/openscan3/device/$(basename "$source_file")" + done + for source_file in "$defaults_dir"/firmware/*.json; do + seed_settings_file "$source_file" "/etc/openscan3/firmware/$(basename "$source_file")" + done + for source_file in "$defaults_dir"/logging/*.json; do + seed_settings_file "$source_file" "/etc/openscan3/logging/$(basename "$source_file")" + done +} + +install_release_venv() { + version="$1" + release_dir="$RELEASES_DIR/$version" + wheelhouse="$release_dir/wheels" + venv="$release_dir/venv" + + if [ ! -d "$wheelhouse" ]; then + echo "Missing bundled wheelhouse: $wheelhouse" >&2 + exit 1 + fi + + rm -rf "$venv" + python3 -m venv --system-site-packages "$venv" + "$venv/bin/python" -m pip install \ + --no-index \ + --find-links="$wheelhouse" \ + "$PYTHON_PACKAGE_NAME==$version" + + chown -R root:root "$release_dir" + find "$release_dir" -type d -exec chmod 0755 {} + +} + +update_current_link() { + version="$1" + release_dir="$RELEASES_DIR/$version" + + if [ -L "$CURRENT_LINK" ] || [ ! -e "$CURRENT_LINK" ]; then + ln -sfn "$release_dir" "$CURRENT_LINK" + else + echo "$CURRENT_LINK exists and is not a symlink; leaving it unchanged" >&2 + fi +} + +reload_and_restart_service() { + previous_version="${1:-}" + + if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + systemctl daemon-reload + + if [ -f "$SERVICE_WAS_ACTIVE_FILE" ]; then + systemctl restart openscan3.service + rm -f "$SERVICE_WAS_ACTIVE_FILE" + elif [ -z "$previous_version" ]; then + systemctl start openscan3.service || true + else + systemctl try-restart openscan3.service || true + fi + fi +} + +case "$1" in + configure) + version="$(package_version)" + python_version="$(bundled_python_version "$version")" + + ensure_runtime_user + + install -d -o root -g root -m 0755 /opt/openscan3 "$RELEASES_DIR" + create_runtime_dir /etc/openscan3 + create_runtime_dir /var/openscan3 + create_runtime_dir /var/openscan3/projects + create_runtime_dir /var/openscan3/community-tasks + create_runtime_dir /var/log/openscan3 + + seed_default_settings "$python_version" + install_release_venv "$python_version" + update_current_link "$python_version" + # TODO: Prune older release directories after release-retention policy is defined. + reload_and_restart_service "${2:-}" + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/debian/preinst b/debian/preinst new file mode 100644 index 0000000..3f4b416 --- /dev/null +++ b/debian/preinst @@ -0,0 +1,24 @@ +#!/bin/sh +set -e + +SERVICE_STATE_DIR="/run/openscan3-firmware" +SERVICE_WAS_ACTIVE_FILE="$SERVICE_STATE_DIR/openscan3.service-was-active" + +record_service_state() { + if command -v systemctl >/dev/null 2>&1 \ + && [ -d /run/systemd/system ] \ + && systemctl is-active --quiet openscan3.service; then + install -d -o root -g root -m 0755 "$SERVICE_STATE_DIR" + : > "$SERVICE_WAS_ACTIVE_FILE" + fi +} + +case "$1" in + install|upgrade) + record_service_state + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..3ece794 --- /dev/null +++ b/debian/rules @@ -0,0 +1,47 @@ +#!/usr/bin/make -f + +export DH_VERBOSE=1 + +PYPROJECT_VERSION := $(shell python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])') +DEBIAN_VERSION := $(shell dpkg-parsechangelog -SVersion) +WHEELHOUSE := debian/build-wheelhouse +RELEASE_DIR := debian/openscan3-firmware/opt/openscan3/releases/$(PYPROJECT_VERSION) +RELEASE_METADATA := debian/openscan3-firmware/usr/share/openscan3-firmware/release.json +OPENSCAN_RELEASE_CHANNEL ?= stable + +%: + dh $@ + +override_dh_auto_clean: + dh_auto_clean + rm -rf build dist *.egg-info $(WHEELHOUSE) + +override_dh_auto_build: + mkdir -p $(WHEELHOUSE) + python3 -m pip wheel --wheel-dir $(WHEELHOUSE) . + +override_dh_auto_install: + mkdir -p $(RELEASE_DIR)/wheels + cp -a $(WHEELHOUSE)/*.whl $(RELEASE_DIR)/wheels/ + mkdir -p $(RELEASE_DIR)/default-settings/device + mkdir -p $(RELEASE_DIR)/default-settings/firmware + mkdir -p $(RELEASE_DIR)/default-settings/logging + cp -a settings/device/default_*.json $(RELEASE_DIR)/default-settings/device/ + cp -a settings/device/example_custom.json $(RELEASE_DIR)/default-settings/device/ + cp -a settings/firmware/*.json $(RELEASE_DIR)/default-settings/firmware/ + cp -a settings/logging/*.json $(RELEASE_DIR)/default-settings/logging/ + python3 scripts/write-release-metadata.py \ + --output $(RELEASE_METADATA) \ + --channel "$(OPENSCAN_RELEASE_CHANNEL)" \ + --debian-version "$(DEBIAN_VERSION)" \ + --python-version "$(PYPROJECT_VERSION)" \ + --build-timestamp "$(OPENSCAN_RELEASE_TIMESTAMP)" \ + --source-revision "$(OPENSCAN_SOURCE_REVISION)" \ + --expected-debian-version "$(OPENSCAN_DEBIAN_VERSION)" \ + --expected-python-version "$(OPENSCAN_PYTHON_VERSION)" + +override_dh_installsystemd: + # Let debhelper enable the unit using deb-systemd-helper. That works while + # pi-gen installs the package in a chroot, where systemd itself is not + # running. Do not start the service during package installation. + dh_installsystemd --no-start diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/dist/local_json.rpi-imager-manifest b/dist/local_json.rpi-imager-manifest index c859ff6..fd1e000 100644 --- a/dist/local_json.rpi-imager-manifest +++ b/dist/local_json.rpi-imager-manifest @@ -58,6 +58,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "capabilities": [ @@ -81,6 +82,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "capabilities": [ @@ -128,6 +130,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "capabilities": [ @@ -151,6 +154,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "capabilities": [ diff --git a/dist/os-sublist-openscan.json b/dist/os-sublist-openscan.json index 8023dcb..9a6a6d9 100644 --- a/dist/os-sublist-openscan.json +++ b/dist/os-sublist-openscan.json @@ -15,6 +15,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "init_format": "cloudinit-rpi" @@ -34,6 +35,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "init_format": "cloudinit-rpi" @@ -73,6 +75,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "init_format": "cloudinit-rpi" @@ -92,6 +95,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "init_format": "cloudinit-rpi" diff --git a/dist/repo.json b/dist/repo.json index ce0d314..fc7b5f7 100644 --- a/dist/repo.json +++ b/dist/repo.json @@ -58,6 +58,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "capabilities": [ @@ -81,6 +82,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "capabilities": [ @@ -128,6 +130,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "capabilities": [ @@ -151,6 +154,7 @@ "pi5", "pi4", "pi400", + "pi3", "all" ], "capabilities": [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1897d61..729ad40 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,7 +62,7 @@ OpenScan3 uses a centralized background task system to coordinate long-running a During application startup (see `openscan_firmware/main.py` in the FastAPI lifespan handler): 1. Logging and device initialization are performed. -2. `TaskManager.initialize_core_tasks()` enforces the core task set. With `OPENSCAN_TASK_AUTODISCOVERY=1`, it runs discovery inside the default namespaces; otherwise it registers the built-in core implementations manually. +2. `TaskManager.initialize_core_tasks()` loads the classes from the static core task registry. With `OPENSCAN_TASK_AUTODISCOVERY=1`, it runs discovery and validates every registry entry; otherwise it registers every built-in class explicitly. 3. After initialization, `TaskManager.restore_tasks_from_persistence()` recovers previously persisted tasks. ### Task Discovery and Structure @@ -101,4 +101,11 @@ Implementation details (see `openscan_firmware/main.py`): Client guidance: - Prefer `/latest/...` to always track the current stable API. -- Pin to `/vX.Y/...` if you need strict compatibility. \ No newline at end of file +- Pin to `/vX.Y/...` if you need strict compatibility. + +## System Update API + +The firmware backend exposes update-related routes through a narrow wrapper +around `sudo /usr/bin/openscan-updater` and keeps APT/package policy inside the +updater package. See [docs/SYSTEM_UPDATE_API.md](./SYSTEM_UPDATE_API.md) for the +endpoint list, safety model, and deployment notes. diff --git a/docs/DEBIAN_PACKAGING.md b/docs/DEBIAN_PACKAGING.md new file mode 100644 index 0000000..4f5d3df --- /dev/null +++ b/docs/DEBIAN_PACKAGING.md @@ -0,0 +1,189 @@ +# Debian Packaging + +This is the first pragmatic Debian packaging milestone for the OpenScan3 +firmware runtime. The package is intentionally small: it bundles a Python +wheelhouse at build time and creates the runtime virtual environment during +package installation. + +## Build + +From this directory, install the Debian build tooling and run: + +```sh +dpkg-buildpackage -us -uc -b +``` + +The build reads the firmware version from `pyproject.toml`, builds a wheel for +the local `openscan-firmware` package, and asks `pip wheel` to build or download +wheels for the runtime dependencies declared in `pyproject.toml`. + +Build-time network access may be required for this milestone because the +wheelhouse is assembled during the Debian package build. Package installation on +the target device must not need network access. + +Expected build dependencies include: + +```sh +debhelper +libcap-dev +python3 +python3-pip +python3-venv +``` + +Some runtime dependencies include native or Raspberry Pi specific packages, such +as `picamera2`, `gphoto2`, `rpi.gpio`, and `zxing-cpp`. Building the wheelhouse +can fail if compatible wheels, headers, or native build tools are unavailable for +the build architecture. `libcap-dev` is required to build the `python-prctl` +wheel pulled in by `picamera2`. + +## Installed Layout + +The package installs release artifacts under: + +```text +/opt/openscan3/releases// +/opt/openscan3/releases//wheels/ +/opt/openscan3/releases//venv/ +/opt/openscan3/current -> /opt/openscan3/releases/ +``` + +Application release directories under `/opt/openscan3/releases` are owned by +`root:root`. + +Writable runtime paths are: + +```text +/etc/openscan3 +/var/openscan3 +/var/openscan3/projects +/var/openscan3/community-tasks +/var/log/openscan3 +``` + +These directories are created as `openscan:openscan` with mode `2775`. If +`setfacl` is available, `postinst` also applies default group-writable ACLs. The +package creates the `openscan` system user and group when they do not already +exist. + +During `postinst`, bundled default settings are copied from the release +directory into `/etc/openscan3/{device,firmware,logging}` only when the target +file does not already exist. The mutable active device configuration +`device_config.json` is intentionally not shipped as a default preset; the +firmware creates or updates it as runtime state. + +## Wheelhouse And Venv Installation + +The generated `.deb` contains all wheels under: + +```text +/opt/openscan3/releases//wheels/ +``` + +It also contains package defaults under: + +```text +/opt/openscan3/releases//default-settings/ +``` + +During `postinst`, the package creates a fresh virtual environment with: + +```sh +python3 -m venv --system-site-packages /opt/openscan3/releases//venv +``` + +It then installs the firmware from the bundled wheelhouse only: + +```sh +/opt/openscan3/releases//venv/bin/python -m pip install \ + --no-index \ + --find-links=/opt/openscan3/releases//wheels \ + openscan-firmware== +``` + +No dependency download is performed during target package installation. + +## Systemd Service + +The package installs the service as: + +```text +/lib/systemd/system/openscan3.service +``` + +The service name intentionally remains `openscan3.service`. Its runtime command +is: + +```sh +/opt/openscan3/current/venv/bin/openscan-firmware serve --root-path /api +``` + +Debhelper enables the unit with `deb-systemd-helper`, which creates the +necessary enablement links even when pi-gen installs the package in a chroot +without a running systemd. The service therefore starts automatically on the +first boot of the image. `postinst` runs `systemctl daemon-reload` when systemd +is available; it starts a fresh installation only on a live system and uses +`systemctl try-restart openscan3.service` for upgrades that were not already +running. + +## Package Responsibilities + +```text +openscan3-firmware = backend application code and openscan3.service +openscan3-client = SPA assets under /usr/share/openscan3-client/ +openscan3-updater = /usr/bin/openscan-updater and updater Python code +openscan3-camera-stack = tested camera stack marker provided by variant packages +openscan3-system-config = nginx, APT source/key, policy, sudoers, tmpfiles, logrotate +``` + +`openscan3-firmware` owns the mechanical runtime installation: release +directory, bundled wheelhouse, virtual environment, runtime directories, current +symlink, and systemd unit. It does not own nginx configuration. + +`openscan3-system-config` owns the appliance integration layer that used to live +in pi-gen or temporarily in this firmware package. It installs the nginx site, +OpenScan APT public key/source, update policy defaults, sudoers bridge, +tmpfiles directories, and logrotate defaults. It intentionally does not ship +PHP, `/admin` updater routes, firmware backend code, webclient assets, updater +implementation code, or camera-stack artifacts. + +## pi-gen, openscan3-firmware.deb, And openscan3-updater + +The pi-gen image installs the signed APT packages for the OpenScan runtime and +system integration. It keeps a bootstrap copy of the public OpenScan APT key and +source file only so it can install `openscan3-system-config`; after installation, +those paths are package-owned by `openscan3-system-config`. + +`openscan3-updater` is not implemented here. It should later decide if and when +an upgrade is installed. The updater must not mutate the active virtual +environment with `pip install -U`; package installation owns the venv contents. + +## Known Limitations + +This milestone does not split dependencies between APT-managed and venv-managed +Python packages. `pyproject.toml` remains the source of truth for runtime Python +dependencies. + +The venv still uses `--system-site-packages` for compatibility with the current +Raspberry Pi image. + +On a bare Raspberry Pi OS Lite image, importing `picamera2` also requires system +bindings such as `python3-libcamera` and `python3-kms++` to be installed. The +current pi-gen image may already provide these; this first milestone does not +model those runtime system dependencies completely. + +Older release pruning is intentionally not implemented yet. Keeping only the +current and previous releases should wait until release-retention policy is +defined independently from repair. + +Legacy pi-gen paths are not removed: + +```text +/opt/openscan3-src +/usr/local/bin/openscan3 +/usr/local/sbin/openscan3-update +/opt/openscan3/venv +``` + +If `/opt/openscan3/current` already exists as a non-symlink, `postinst` leaves it +unchanged and reports the conflict instead of deleting it. diff --git a/docs/SYSTEM_UPDATE_API.md b/docs/SYSTEM_UPDATE_API.md new file mode 100644 index 0000000..e867c14 --- /dev/null +++ b/docs/SYSTEM_UPDATE_API.md @@ -0,0 +1,97 @@ +# System Update API + +The firmware backend exposes a narrow API wrapper around the local +`openscan-updater` command. The backend does not implement APT, dpkg, package +selection, or update policy logic itself. + +Intended call path: + +```text +webclient -> openscan3-firmware API -> sudo /usr/bin/openscan-updater --json +``` + +The webclient must call the firmware API only. It must not call +`openscan-updater` directly. + +## Endpoints + +These routes are mounted under the normal firmware API version prefixes. On the +appliance, nginx maps `/api/...` to the backend, so clients should use +`/api/latest/system/update/status` or pin to +`/api/v0.9/system/update/status`. Repair endpoints are mounted the same way, +for example `/api/latest/system/repair/openscan3`. + +| Method | Path | Backend action | +| --- | --- | --- | +| `GET` | `/system/update/status` | Runs `sudo /usr/bin/openscan-updater status --json` | +| `POST` | `/system/update/check` | Runs the combined update check: OpenScan dry-run, then system update check | +| `POST` | `/system/update/apply` | Schedules the fixed OpenScan-plus-system update flow in an independent transient systemd service | +| `POST` | `/system/update/openscan` | Runs `sudo /usr/bin/openscan-updater update --json` | +| `POST` | `/system/update/healthcheck` | Runs `sudo /usr/bin/openscan-updater healthcheck --json` | +| `GET` | `/system/update/logs` | Returns the last 200 lines from fixed updater log files | +| `POST` | `/system/repair/openscan3` | Runs `sudo /usr/bin/openscan-updater repair --json` | + +The user-facing update button should use `/system/update/check` for planning +and `/system/update/apply` for execution. Apply returns `status: installing` +after `openscan-update-apply.service` was scheduled. That transient service is +outside the firmware service cgroup, so upgrading `openscan3-firmware` may +restart the API without interrupting dpkg. The detached flow updates OpenScan +packages first and then applies the classified system update. + +`/system/update/openscan` remains available as a narrower OpenScan-only action +for recovery and compatibility. It does not apply Raspberry Pi OS or other +system package updates. + +OpenScan3 v1 does not expose full system rollback, Debian package rollback, +kernel/firmware rollback, arbitrary package downgrades, package-name request +parameters, or a version chooser. Recovery is repair/forward-fix based: +reinstall OpenScan components, restore the known-good camera stack according to +the manifest, reapply protections, restart services, and run healthcheck. + +## Safety Model + +- The backend uses a fixed command map and fixed argv lists. +- Request bodies and query strings cannot add package names, shell fragments, or + arbitrary command arguments. +- `shell=True` is not used. +- Commands run with a minimal environment and bounded timeouts. +- The firmware process does not need to run as root; system integration is + expected to provide narrow sudoers rules for the backend user. +- The logs endpoint reads only known updater log paths and returns a bounded tail. +- The updater remains responsible for APT safety classification and package + policy decisions. +- The combined apply endpoint does not merge OpenScan and system policy. It + schedules one fixed updater command with no request-controlled arguments. + +Scheduling and repair requests are protected by a process-local async lock. +The detached update and updater repair paths use the shared file lock at +`/var/lock/openscan-updater.lock`; the fixed transient unit name also prevents +two detached apply jobs from running at once. + +Before starting `update`, combined update apply, or `repair`, the backend checks +the task manager for active `scan_task` +entries in `pending`, `running`, or `paused` state. If such a task is found, the +endpoint returns `409 Conflict` and does not call the updater. + +## Response Shape + +Successful command execution returns structured JSON: + +```json +{ + "ok": true, + "command": "status", + "backend_api_version": "1", + "result": {} +} +``` + +Backend execution failures, command timeouts, and invalid updater JSON return +structured errors. Updater policy and repair results, including blocked updates +and partial repair failures, are propagated as updater results instead of +treated as backend crashes. + +## Future Work + +Normal webclient presentation, richer progress reporting, and channel switching +UI are intentionally separate frontend work. diff --git a/docs/TASKS.md b/docs/TASKS.md index 816d241..3696f53 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -15,7 +15,7 @@ This document explains how background tasks work in OpenScan3, how they are disc - `scan_task.py`: Exclusive async task (generator style) responsible for the scan workflow. - `crop_task.py`: Blocking non-exclusive task for simple crop detection. - Example tasks: `openscan_firmware/controllers/services/tasks/examples/` - - `demo_examples.py`: Contains multiple demo tasks such as `hello_world_async_task`, `hello_world_blocking_task`, `exclusive_demo_task`, `generator_task`, `failing_task`. + - `demo_examples.py`: Contains multiple demo tasks such as `hello_world_progress_task`, `hello_world_blocking_task`, `exclusive_demo_task`, `failing_task`. - Community tasks: `openscan_firmware/tasks/community/` External (system-wide) community tasks can also be provided outside of the repo: @@ -38,9 +38,9 @@ modules such as `base_task`, `task_manager`, and everything under `examples*`. N checks (`task_name` must be snake_case ending in `_task`) and missing-name failures are hardcoded policies. -The firmware enforces a fixed core set (`scan_task`, `focus_stacking_task`, -`cloud_upload_task`, `cloud_download_task`). Startup fails if any are missing after -discovery, so keep those names available even when overriding implementations. +The firmware's static core registry is the source of truth for all built-in +tasks. Startup fails if any built-in task is missing after discovery, so keep +those names available even when overriding implementations. A module can opt out of autodiscovery by declaring `__openscan_autodiscover__ = False` at the module level. @@ -48,6 +48,275 @@ A module can opt out of autodiscovery by declaring `__openscan_autodiscover__ = The firmware defaults to keeping the first task registered under a given `task_name` and will log a warning for duplicates. If you deliberately want to swap out a core task (e.g., custom `scan_task`), export `OPENSCAN_TASK_OVERRIDE_ON_CONFLICT=1` together mit `OPENSCAN_TASK_AUTODISCOVERY=1`. Nur einschalten, wenn du die Ersatz-Implementierung komplett kontrollierst – ein falsch überschriebenes Core-Task bricht den Scanner. +## Tinkerer Workflow: External Tasks with Autodiscovery + +This workflow is for experimenting with a trusted custom task on one +administered scanner. The task is installed directly on the device and remains +separate from the main firmware. It is convenient for prototypes and local +automation, but it is not the way to add a permanent supported feature. + +This workflow deliberately enables the execution of locally installed Python +code. Only install task files whose complete contents you trust. Do not expose +task installation or the autodiscovery switch through an unauthenticated API or +frontend. + +The following example assumes that you already provisioned an administrator +account on the Raspberry Pi and can connect over SSH. + +### 1. Create the task file + +Connect to the scanner and create the external task directory: + +```bash +ssh @openscan.local +sudo install -d -o openscan -g openscan -m 0750 \ + /var/openscan3/community-tasks +sudoedit /var/openscan3/community-tasks/hello_world_task.py +``` + +Add this minimal task: + +```python +from openscan_firmware.controllers.services.tasks.base_task import BaseTask +from openscan_firmware.models.task import TaskProgress + + +class HelloWorldTask(BaseTask): + task_name = "hello_world_task" + task_category = "community" + is_exclusive = False + is_blocking = False + + async def run(self, name: str = "world"): + self._task_model.progress = TaskProgress( + current=0, + total=1, + message="Starting", + ) + + message = f"Hello, {name}!" + + self._task_model.progress = TaskProgress( + current=1, + total=1, + message="Finished", + ) + return {"message": message} +``` + +Make the file readable by the `openscan` service account: + +```bash +sudo chown openscan:openscan \ + /var/openscan3/community-tasks/hello_world_task.py +sudo chmod 0640 \ + /var/openscan3/community-tasks/hello_world_task.py +``` + +External discovery reads plain top-level `*.py` files from this directory. +Files beginning with `__` are ignored. A package directory or `__init__.py` is +not required. + +### 2. Enable autodiscovery locally + +Create a systemd override: + +```bash +sudo systemctl edit openscan3.service +``` + +Enter: + +```ini +[Service] +Environment="OPENSCAN_TASK_AUTODISCOVERY=1" +``` + +Save the editor and restart the firmware: + +```bash +sudo systemctl restart openscan3.service +``` + +Autodiscovery only runs during firmware startup. Adding or changing a task file +therefore requires another restart. + +Do not enable `OPENSCAN_TASK_OVERRIDE_ON_CONFLICT` for an ordinary custom task. +It is unnecessary when the new `task_name` is unique and would allow external +files to replace core task implementations. + +### 3. Verify registration + +Inspect the service log: + +```bash +sudo journalctl -u openscan3.service -b -n 100 --no-pager +``` + +A successful startup contains a message similar to: + +```text +Task 'hello_world_task' (...) registered via autodiscovery. +``` + +If the task is missing, check that: + +- the file ends in `.py` and is directly inside the community task directory; +- the service account can read the file; +- the class inherits from `BaseTask`; +- `task_name` is explicit, uses snake_case, and ends in `_task`; +- importing the file does not raise an exception; and +- `OPENSCAN_TASK_AUTODISCOVERY=1` appears in the service environment. + +The effective environment can be inspected with: + +```bash +sudo systemctl show openscan3.service --property=Environment +``` + +### 4. Run the task + +Start the task through the task API: + +```bash +curl --request POST \ + http://openscan.local/api/latest/tasks/hello_world_task \ + --header 'Content-Type: application/json' \ + --data '{"kwargs":{"name":"OpenScan"}}' +``` + +The API returns a task model with an `id`. Use that ID to inspect its state: + +```bash +curl http://openscan.local/api/latest/tasks/ +``` + +Task parameters come from the request body's `args` and `kwargs` fields and are +passed to the task's `run()` method. Registration makes a task available to the +scheduler; it does not automatically start the task. + +### 5. Disable external task loading + +Open the same override again: + +```bash +sudo systemctl edit openscan3.service +``` + +Change the flag to: + +```ini +[Service] +Environment="OPENSCAN_TASK_AUTODISCOVERY=0" +``` + +Then restart the service: + +```bash +sudo systemctl restart openscan3.service +``` + +The Python file remains on disk but is no longer imported. Setting the flag +explicitly to `0` avoids accidentally removing unrelated local service +overrides. + +## Firmware Developer Workflow: Permanent Integration + +Use this workflow when a task should become a maintained part of OpenScan, +survive firmware updates, and be available to other users. + +### 1. Discuss the task with the maintainers + +Before adding a permanent task, discuss it with the OpenScan maintainers. This +helps decide: + +- whether it should be a background task at all; +- its stable `task_name`; +- whether it must run exclusively; +- how users or other firmware features will start it; and +- whether it introduces new dependencies or hardware requirements. + +This avoids committing to a public task name or behavior that will be difficult +to change later. + +### 2. Add the task class + +Put the implementation under: + +```text +openscan_firmware/controllers/services/tasks/core/ +``` + +Use the `BaseTask` contract described below, including an explicit stable +`task_name`. Importing the file must not initialize hardware, open network +connections, or start other work. Do that inside `run()` instead. + +### 3. Add it to the built-in registry + +Import the class in +`openscan_firmware/controllers/services/tasks/core/registry.py` and add one +class to `BUILTIN_TASKS`: + +```python +from openscan_firmware.controllers.services.tasks.core.hello_world_task import ( + HelloWorldTask, +) + +BUILTIN_TASKS = ( + # Existing built-in task classes... + HelloWorldTask, +) +``` + +The registry reads the name from `HelloWorldTask.task_name`, so it is declared +only once. No JSON entry or second list of task names is required. + +### 4. Make the task available where it is needed + +Every registered task can already be started through +`POST /tasks/{task_name}`. If another firmware feature needs to start it, add a +small function for that feature: + +```python +from openscan_firmware.controllers.services.tasks.task_manager import ( + get_task_manager, +) + + +async def start_hello_world(name: str): + return await get_task_manager().create_and_run_task( + "hello_world_task", + name=name, + ) +``` + +Call the function instead of creating the task class directly. A new dedicated +API endpoint is only needed if the task is part of a larger user-facing +workflow. + +### 5. Add tests + +A permanent task should have tests for: + +- its normal result and important error cases; +- arguments passed to `run()`; +- cancellation or pause behavior, if supported; and +- registration through `BUILTIN_TASKS`. + +Place task-specific tests under `tests/controllers/services/tasks/` where +possible. + +Run at least: + +```bash +.venv/bin/pytest -q \ + tests/controllers/services/test_task_autodiscovery.py \ + tests/controllers/services/test_task_manager.py +``` + +When moving a prototype into the firmware, remove its external copy from +`/var/openscan3/community-tasks` to avoid two tasks with the same name. + ## Task Class Requirements A minimal task class looks like this: diff --git a/openscan_firmware/controllers/device.py b/openscan_firmware/controllers/device.py index 80542cc..82272b8 100644 --- a/openscan_firmware/controllers/device.py +++ b/openscan_firmware/controllers/device.py @@ -85,6 +85,8 @@ logger = logging.getLogger(__name__) +_LINUXPY_INTERNAL_CAMERA_CARDS = {"unicam", "bcm2835-isp", "rp1-cfe", "pispbe"} + # Current scanner model def _create_default_scanner_device() -> ScannerDevice: @@ -346,13 +348,19 @@ def _detect_cameras() -> Dict[str, Camera]: linuxpycameras = iter_video_capture_devices() for cam in linuxpycameras: cam.open() - if cam.info.card not in ("unicam", "bcm2835-isp"): + card_name = str(cam.info.card) + if card_name.lower() not in _LINUXPY_INTERNAL_CAMERA_CARDS: cameras[cam.info.card] = Camera( type=CameraType.LINUXPY, name=cam.info.card, path=str(cam.filename), settings=CameraSettings() ) + else: + logger.debug( + "Skipping internal V4L2 pipeline device '%s' for LinuxPy camera detection.", + card_name, + ) cam.close() except Exception as e: logger.error(f"Error loading Linux cameras: {e}") diff --git a/openscan_firmware/controllers/hardware/cameras/picamera2.py b/openscan_firmware/controllers/hardware/cameras/picamera2.py index 4218416..daa2e68 100644 --- a/openscan_firmware/controllers/hardware/cameras/picamera2.py +++ b/openscan_firmware/controllers/hardware/cameras/picamera2.py @@ -225,6 +225,7 @@ def _configure_resolutions(self, additional_settings=None): if additional_settings is not None: photogrammetry_settings.update(additional_settings) + self._photogrammetry_settings = photogrammetry_settings.copy() self.preview_config = self._strategy.create_preview_config(self._picam, self.settings.preview_resolution, photogrammetry_settings) self.photo_config = self._strategy.create_photo_config(self._picam, self.settings.photo_resolution, photogrammetry_settings) self.raw_config = self._strategy.create_raw_config(self._picam, self.settings.photo_resolution, photogrammetry_settings) @@ -268,7 +269,10 @@ def _configure_cropping_for_scalercrop(self): x_start = (full_x - width) // 2 y_start = (full_y - height) // 2 - update_controls = {"ScalerCrop": (x_start, y_start, width, height)} + update_controls = { + **getattr(self, "_photogrammetry_settings", {}), + "ScalerCrop": (x_start, y_start, width, height), + } logger.debug("Updated ScalerCrop: ", update_controls) self.photo_config = self._strategy.create_photo_config(self._picam, (width, height), update_controls) self.raw_config = self._strategy.create_raw_config(self._picam, (width, height), update_controls) diff --git a/openscan_firmware/controllers/services/tasks/base_task.py b/openscan_firmware/controllers/services/tasks/base_task.py index 4982dc8..186ca63 100644 --- a/openscan_firmware/controllers/services/tasks/base_task.py +++ b/openscan_firmware/controllers/services/tasks/base_task.py @@ -3,11 +3,13 @@ import asyncio import logging from abc import ABC, abstractmethod -from typing import Any, Coroutine -from openscan_firmware.models.task import Task, TaskStatus, TaskProgress +from typing import Any, ClassVar + +from openscan_firmware.models.task import Task logger = logging.getLogger(__name__) + class BaseTask(ABC): """ Abstract base class for a background task. @@ -16,14 +18,19 @@ class BaseTask(ABC): Each task should inherit from this class and implement the `run` method. Attributes: - is_exclusive (bool): If True, this task cannot run concurrently with any other tasks. - is_blocking (bool): If True, the `run` method is a standard synchronous function + task_name: Optional registry name used by autodiscovery. Manually registered + tasks can still receive their name from TaskManager.register_task(). + task_category: Optional grouping label used for discovered task classes. + is_exclusive: If True, this task cannot run concurrently with any other tasks. + is_blocking: If True, the `run` method is a standard synchronous function that will be executed in a separate thread to avoid blocking the main asyncio event loop. If False (default), `run` must be an async method. """ - is_exclusive: bool = False - is_blocking: bool = False + task_name: ClassVar[str | None] = None + task_category: ClassVar[str] = "community" + is_exclusive: ClassVar[bool] = False + is_blocking: ClassVar[bool] = False def __init__(self, task_model: Task): """ @@ -138,4 +145,4 @@ def _update_progress(self, current: float, total: float, message: str = "") -> N """ self._task_model.progress.current = current self._task_model.progress.total = total - self._task_model.progress.message = message \ No newline at end of file + self._task_model.progress.message = message diff --git a/openscan_firmware/controllers/services/tasks/core/registry.py b/openscan_firmware/controllers/services/tasks/core/registry.py new file mode 100644 index 0000000..5a52298 --- /dev/null +++ b/openscan_firmware/controllers/services/tasks/core/registry.py @@ -0,0 +1,29 @@ +"""Static registry for tasks shipped with the firmware.""" + +from __future__ import annotations + +from openscan_firmware.controllers.services.tasks.base_task import BaseTask +from openscan_firmware.controllers.services.tasks.core.cloud_task import ( + CloudDownloadTask, + CloudUploadTask, +) +from openscan_firmware.controllers.services.tasks.core.external_trigger_run_task import ( + ExternalTriggerRunTask, +) +from openscan_firmware.controllers.services.tasks.core.focus_stacking_task import ( + FocusStackingTask, +) +from openscan_firmware.controllers.services.tasks.core.qr_scan_task import QrScanTask +from openscan_firmware.controllers.services.tasks.core.scan_task import ScanTask + + +# Add new firmware task classes here. This tuple is the single source of truth +# for built-in tasks in both normal and autodiscovery startup modes. +BUILTIN_TASKS: tuple[type[BaseTask], ...] = ( + ScanTask, + ExternalTriggerRunTask, + FocusStackingTask, + CloudUploadTask, + CloudDownloadTask, + QrScanTask, +) diff --git a/openscan_firmware/controllers/services/tasks/core/scan_task.py b/openscan_firmware/controllers/services/tasks/core/scan_task.py index b3acabb..dc807c3 100644 --- a/openscan_firmware/controllers/services/tasks/core/scan_task.py +++ b/openscan_firmware/controllers/services/tasks/core/scan_task.py @@ -442,7 +442,7 @@ async def _capture_photos_at_position(self, current_point: PolarPoint3D, index: if not self._ctx.focus_context or not self._ctx.focus_context["enabled"]: # Single photo capture - photo_data = self._ctx.camera_controller.photo( + photo_data = await self._ctx.camera_controller.photo_async( self._ctx.scan.settings.image_format ) photo_data.scan_metadata = ScanMetadata( @@ -452,9 +452,9 @@ async def _capture_photos_at_position(self, current_point: PolarPoint3D, index: scan_index=self._ctx.scan.index, ) - asyncio.create_task(self._ctx.project_manager.add_photo_async(photo_data)) - # Needed so the event loop can process pause/cancel signals between captures: - await asyncio.sleep(0) + # Await the save so executor-backed file/metadata work cannot overlap with the + # next software-timed motor move and disturb GPIO step timing. + await self._ctx.project_manager.add_photo_async(photo_data) else: # Focus stacking capture focus_positions = self._ctx.focus_context["positions"] @@ -488,9 +488,8 @@ async def _capture_photos_at_position(self, current_point: PolarPoint3D, index: stack_index=stack_index, ) - asyncio.create_task(self._ctx.project_manager.add_photo_async(photo_data)) - # Let the event loop handle pause/cancel requests between focus captures - await asyncio.sleep(0) + # Keep save work serialized with captures/moves; see single-photo path above. + await self._ctx.project_manager.add_photo_async(photo_data) except Exception as e: logger.error("Error taking photo at position %s: %s", index, e, exc_info=True) diff --git a/openscan_firmware/controllers/services/tasks/examples/demo_examples.py b/openscan_firmware/controllers/services/tasks/examples/demo_examples.py index 428a54d..42eb8ec 100644 --- a/openscan_firmware/controllers/services/tasks/examples/demo_examples.py +++ b/openscan_firmware/controllers/services/tasks/examples/demo_examples.py @@ -9,7 +9,7 @@ import asyncio import logging import time -from typing import Any, AsyncGenerator, Optional +from typing import Any, AsyncGenerator from openscan_firmware.controllers.services.tasks.base_task import BaseTask from openscan_firmware.models.task import TaskProgress @@ -25,8 +25,8 @@ class HelloWorldBlockingTask(BaseTask): task_name = "hello_world_blocking_task" task_category = "example" - is_exclusive: bool = False - is_blocking: bool = True + is_exclusive = False + is_blocking = True def run(self, *args: Any, **kwargs: Any) -> Any: """Run the blocking example task. @@ -45,60 +45,13 @@ def run(self, *args: Any, **kwargs: Any) -> Any: return "Blocking task complete." -class HelloWorldAsyncTask(BaseTask): - """Demonstrates an asynchronous non-blocking task with progress updates.""" - - task_name = "hello_world_async_task" - task_category = "example" - is_exclusive: bool = False - - async def run(self, *args: Any, **kwargs: Any) -> Any: - """Run the async example task. - - Args: - *args: Unused. - **kwargs: `wait_for_event`, `total_steps`, `delay`. - - Returns: - Final message string when finished. - """ - wait_for_event: Optional[asyncio.Event] = kwargs.get('wait_for_event') - if wait_for_event: - logger.info(f"[{self.id}] Waiting for event...") - await wait_for_event.wait() - logger.info(f"[{self.id}] Event received, finishing task.") - self._task_model.result = "Event-based task finished." - return "Event-based task finished." - - total_steps = kwargs.get('total_steps', 5) - delay = kwargs.get('delay', 0.1) - - self._task_model.progress = TaskProgress(current=0, total=total_steps, message="Starting Hello World Task...") - await asyncio.sleep(0.01) - - for i in range(1, total_steps + 1): - await self.wait_for_pause() - if self.is_cancelled(): - self._task_model.progress.message = "Hello World task cancelled." - return "Hello World task cancelled by request." - - self._task_model.progress = TaskProgress(current=i, total=total_steps, message=f"Hello World! Step {i} of {total_steps}") - logger.info(f"[{self.id}] Hello World! Step {i} of {total_steps}") - await asyncio.sleep(delay) - - logger.info(f"[{self.id}] HelloWorldTask finished.") - final_message = f"Hello World! Completed {total_steps} steps successfully." - self._task_model.progress = TaskProgress(current=total_steps, total=total_steps, message=final_message) - self._task_model.result = final_message - return final_message - - class ExclusiveDemoTask(BaseTask): """Demonstrates an exclusive async task.""" task_name = "exclusive_demo_task" task_category = "example" - is_exclusive: bool = True + is_exclusive = True + is_blocking = False async def run(self, duration: float = 1.0): """Sleep for a given duration to simulate exclusive work. @@ -114,16 +67,16 @@ async def run(self, duration: float = 1.0): return {"status": "completed", "duration": duration} -class ExampleTaskWithGenerator(BaseTask): - """Demonstrates streaming progress via async generator with resume support.""" +class HelloWorldProgressTask(BaseTask): + """Demonstrates the canonical async progress-reporting task pattern.""" - task_name = "generator_task" + task_name = "hello_world_progress_task" task_category = "example" is_exclusive = False is_blocking = False async def run(self, total_steps: int = 10, interval: float = 0.5) -> AsyncGenerator[TaskProgress, None]: - """Run the streaming generator task. + """Run the async progress example task. Args: total_steps: The number of steps to complete. @@ -132,30 +85,32 @@ async def run(self, total_steps: int = 10, interval: float = 0.5) -> AsyncGenera Yields: TaskProgress updates. """ - steps = total_steps - start_step = self._task_model.progress.current - - if start_step >= steps: - yield TaskProgress(current=steps, total=steps, message="Task already completed.") + if total_steps <= 0: + yield TaskProgress(current=0, total=0, message="No steps to run.") return - yield TaskProgress(current=start_step, total=steps, message=f"Starting/Resuming from step {start_step}.") + yield TaskProgress(current=0, total=total_steps, message="Starting Hello World progress task.") - for i in range(int(start_step), steps): + for i in range(1, total_steps + 1): await self.wait_for_pause() if self.is_cancelled(): logger.info(f"Task {self.name} ({self.id}) stopping due to cancellation.") + yield TaskProgress( + current=i - 1, + total=total_steps, + message="Hello World progress task cancelled.", + ) return await asyncio.sleep(interval) yield TaskProgress( - current=i + 1, - total=steps, - message=f"Step {i + 1} of {steps} complete." + current=i, + total=total_steps, + message=f"Hello World! Step {i} of {total_steps} complete.", ) - logger.info(f"[{self.id}] ExampleTaskWithGenerator finished.") - self._task_model.result = f"Generator task completed after {total_steps} steps." + logger.info(f"[{self.id}] HelloWorldProgressTask finished.") + self._task_model.result = f"Hello World progress task completed after {total_steps} steps." class FailingTask(BaseTask): diff --git a/openscan_firmware/controllers/services/tasks/task_manager.py b/openscan_firmware/controllers/services/tasks/task_manager.py index 8f68845..b20360e 100644 --- a/openscan_firmware/controllers/services/tasks/task_manager.py +++ b/openscan_firmware/controllers/services/tasks/task_manager.py @@ -117,47 +117,31 @@ def __new__(cls) -> TaskManager: def initialize_core_tasks( self, autodiscovery_enabled: bool, - required_core_tasks: set[str], override_on_conflict: bool = False, ) -> None: """Ensure the core task set is available according to startup mode.""" + from openscan_firmware.controllers.services.tasks.core.registry import BUILTIN_TASKS + + builtin_tasks: dict[str, type[BaseTask]] = {} + for task_class in BUILTIN_TASKS: + task_name = task_class.task_name + if not task_name: + raise RuntimeError( + f"Built-in task {task_class.__name__} has no explicit task_name." + ) + if task_name in builtin_tasks: + raise RuntimeError(f"Duplicate built-in task name: {task_name}") + builtin_tasks[task_name] = task_class + if autodiscovery_enabled: self.autodiscover_tasks(override_on_conflict=override_on_conflict) - missing = required_core_tasks - set(self._task_registry.keys()) + missing = set(builtin_tasks) - set(self._task_registry) if missing: - raise RuntimeError(f"Missing required core tasks: {sorted(missing)}") + raise RuntimeError(f"Missing built-in tasks: {sorted(missing)}") return - self._register_builtin_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, - ) - from openscan_firmware.controllers.services.tasks.core.cloud_task import ( - 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(): - self.register_task(task_name, task_cls) + for task_name, task_class in builtin_tasks.items(): + self.register_task(task_name, task_class) def restore_tasks_from_persistence(self): """Loads all persisted task JSON files from the storage directory. diff --git a/openscan_firmware/main.py b/openscan_firmware/main.py index 98dd318..ba53037 100644 --- a/openscan_firmware/main.py +++ b/openscan_firmware/main.py @@ -11,6 +11,8 @@ from openscan_firmware import __version__ from openscan_firmware.routers import websocket as websocket_router +from openscan_firmware.routers import system_update as system_update_router +from openscan_firmware.routers import system_repair as system_repair_router from openscan_firmware.routers.v0_8 import ( cameras as cameras_v0_8, motors as motors_v0_8, @@ -103,15 +105,6 @@ async def _maybe_start_qr_wifi_scan(task_manager) -> None: 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", -] - - def _env_flag(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: @@ -142,7 +135,6 @@ async def lifespan(app: FastAPI): task_manager.initialize_core_tasks( autodiscovery_enabled=autodiscovery_enabled, - required_core_tasks=set(REQUIRED_CORE_TASKS), override_on_conflict=override_on_conflict, ) @@ -152,11 +144,19 @@ async def lifespan(app: FastAPI): # 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 - device_controller.cleanup_and_exit() - logging.shutdown() + try: + yield # application runs here + finally: + logger.info("OpenScan3 service shutdown: starting hardware cleanup.") + try: + device_controller.cleanup_and_exit() + except Exception: + logger.exception("OpenScan3 service shutdown: hardware cleanup failed.") + raise + else: + logger.info("OpenScan3 service shutdown: hardware cleanup completed.") + finally: + logging.shutdown() app = FastAPI( @@ -199,6 +199,8 @@ async def lifespan(app: FastAPI): motors_next.router, lights_next.router, firmware_next.router, + system_update_router.router, + system_repair_router.router, projects_next.router, openscan_next.router, device_next.router, @@ -217,6 +219,8 @@ async def lifespan(app: FastAPI): motors_v0_9.router, lights_v0_9.router, firmware_v0_9.router, + system_update_router.router, + system_repair_router.router, projects_v0_9.router, gpio_v0_9.router, openscan_v0_9.router, diff --git a/openscan_firmware/routers/next/cameras.py b/openscan_firmware/routers/next/cameras.py index e68b59c..0f815c3 100644 --- a/openscan_firmware/routers/next/cameras.py +++ b/openscan_firmware/routers/next/cameras.py @@ -37,6 +37,27 @@ _MAX_PAYLOAD_CACHE_ENTRIES = 8 _MAX_PAYLOAD_CACHE_BYTES = 256 * 1024 * 1024 +_BINARY_SCHEMA = {"type": "string", "format": "binary"} +def _photo_binary_content() -> dict[str, dict[str, dict[str, str]]]: + """Return a fresh response-content mapping for photo payloads. + + FastAPI merges a response model into the supplied mapping while building + OpenAPI. Sharing this mapping between routes would leak that JSON model to + the raw-payload endpoint. + """ + return { + media_type: {"schema": dict(_BINARY_SCHEMA)} + for media_type in ( + "image/jpeg", + "image/x-adobe-dng", + "image/x-canon-cr2", + "image/x-canon-cr3", + "image/x-canon-crw", + "application/x-npy", + "application/octet-stream", + ) + } + @dataclass class _CachedPhotoPayload: @@ -247,7 +268,19 @@ async def get_camera(camera_name: str): raise HTTPException(status_code=404, detail=str(e)) -@router.get("/{camera_name}/preview") +@router.get( + "/{camera_name}/preview", + response_class=Response, + responses={ + 200: { + "description": "A JPEG snapshot or an MJPEG stream, depending on `mode`.", + "content": { + "image/jpeg": {"schema": _BINARY_SCHEMA}, + "multipart/x-mixed-replace": {"schema": _BINARY_SCHEMA}, + }, + } + }, +) async def get_preview( camera_name: str, mode: str = Query(default="stream", pattern="^(stream|snapshot)$"), @@ -292,7 +325,16 @@ async def generate(): return StreamingResponse(generate(), media_type="multipart/x-mixed-replace;boundary=frame") -@router.get("/{camera_name}/photo") +@router.get( + "/{camera_name}/photo", + responses={ + 200: { + "model": PhotoMetadataResponse, + "description": "The requested photo bytes, or metadata with a payload URL when `with_metadata=true`.", + "content": _photo_binary_content(), + } + }, +) async def get_photo( camera_name: str, request: Request, @@ -354,7 +396,17 @@ async def get_photo( ) -@router.get("/{camera_name}/photo/payload/{payload_id}", name="get_photo_payload") +@router.get( + "/{camera_name}/photo/payload/{payload_id}", + name="get_photo_payload", + response_class=Response, + responses={ + 200: { + "description": "The cached photo payload bytes.", + "content": _photo_binary_content(), + } + }, +) 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( diff --git a/openscan_firmware/routers/next/develop.py b/openscan_firmware/routers/next/develop.py index 57d9862..154b265 100644 --- a/openscan_firmware/routers/next/develop.py +++ b/openscan_firmware/routers/next/develop.py @@ -6,7 +6,9 @@ import base64 import json +import os import subprocess +import sys import time from pathlib import Path from typing import Literal @@ -14,7 +16,11 @@ 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.hardware.cameras.camera import ( + create_camera_controller, + get_all_camera_controllers, + remove_camera_controller, +) 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 @@ -25,7 +31,7 @@ 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" +CAMERA_REPORT_SCRIPT = Path(__file__).resolve().parents[2] / "utils" / "camera_report.sh" router = APIRouter( @@ -35,6 +41,51 @@ ) +def _release_camera_controllers_for_report() -> tuple[list, dict]: + """Release OpenScan-owned cameras so external rpicam/libcamera probes can acquire them.""" + controllers = get_all_camera_controllers() + busy = [ + name + for name, controller in controllers.items() + if callable(getattr(controller, "is_busy", None)) and controller.is_busy() + ] + if busy: + return [], { + "released": False, + "reason": "camera busy", + "busy_cameras": busy, + "cameras": list(controllers.keys()), + } + + camera_models = [controller.camera for controller in controllers.values()] + released: list[str] = [] + errors: list[dict] = [] + for name in list(controllers.keys()): + try: + if remove_camera_controller(name): + released.append(name) + except Exception as exc: + errors.append({"camera": name, "error": str(exc)}) + + return camera_models, { + "released": bool(released), + "cameras": released, + "errors": errors, + } + + +def _restore_camera_controllers_after_report(camera_models: list) -> dict: + restored: list[str] = [] + errors: list[dict] = [] + for camera in camera_models: + try: + create_camera_controller(camera) + restored.append(camera.name) + except Exception as exc: + errors.append({"camera": camera.name, "error": str(exc)}) + return {"restored": restored, "errors": errors} + + def _gp_text(value) -> str: # noqa: ANN001 if value is None: return "" @@ -286,6 +337,10 @@ async def restart_application() -> dict[str, str]: @router.get("/camera-report") async def get_camera_report( format: Literal["json", "text"] = Query(default="json"), + release_cameras: bool = Query( + default=True, + description="Temporarily release OpenScan camera controllers so rpicam/libcamera probes can acquire cameras.", + ), ): """Run the camera diagnostics script and return a bundled report.""" if not CAMERA_REPORT_SCRIPT.exists(): @@ -294,21 +349,36 @@ async def get_camera_report( 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, - ) + camera_models: list = [] + camera_release = {"released": False, "reason": "disabled"} + camera_restore = {"restored": [], "errors": []} + if release_cameras: + camera_models, camera_release = _release_camera_controllers_for_report() + + try: + result = subprocess.run( + ["bash", str(CAMERA_REPORT_SCRIPT)], + capture_output=True, + text=True, + timeout=180, + check=False, + env={**os.environ, "OPENSCAN_REPORT_PYTHON": sys.executable}, + ) + finally: + if camera_models: + camera_restore = _restore_camera_controllers_after_report(camera_models) 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." + camera_section = "===== OpenScan camera release for external probes =====\n" + json.dumps( + {"release": camera_release, "restore": camera_restore}, + indent=2, + ) gphoto2_section = "===== GPhoto2 python diagnostics =====\n" + json.dumps(gphoto2_diag, indent=2) - text_output = f"{text_output}\n\n{gphoto2_section}" + text_output = f"{text_output}\n\n{camera_section}\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) @@ -316,6 +386,8 @@ async def get_camera_report( "ok": result.returncode == 0, "return_code": result.returncode, "script": str(CAMERA_REPORT_SCRIPT), + "camera_release": camera_release, + "camera_restore": camera_restore, "report": report, "stderr": stderr, "gphoto2": gphoto2_diag, @@ -368,12 +440,11 @@ async def crop_image(camera_name: str, threshold: int | None = Query(default=Non @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.""" + """Start the async hello world progress 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) + task = await task_manager.create_and_run_task("hello_world_progress_task", total_steps=total_steps, interval=delay) return task diff --git a/openscan_firmware/routers/next/device.py b/openscan_firmware/routers/next/device.py index d87ec17..9110efe 100644 --- a/openscan_firmware/routers/next/device.py +++ b/openscan_firmware/routers/next/device.py @@ -57,6 +57,19 @@ class DeviceConfigResponse(BaseModel): config: dict[str, Any] +class AvailableConfigResponse(BaseModel): + filename: str + path: str + name: str | None = None + model: str | None = None + shield: str | None = None + + +class AvailableConfigsResponse(BaseModel): + status: str + configs: list[AvailableConfigResponse] + + def _runtime_status_response() -> DeviceStatusResponse: raw_info = device.get_device_info() logger.debug("Device info payload before validation: %s", raw_info) @@ -104,7 +117,7 @@ async def get_device_info(): raise HTTPException(status_code=500, detail=f"Error getting device info: {str(e)}") -@router.get("/configurations") +@router.get("/configurations", response_model=AvailableConfigsResponse) async def list_config_files(): """List all available device configuration files""" try: diff --git a/openscan_firmware/routers/next/openscan.py b/openscan_firmware/routers/next/openscan.py index 096fa6a..6394f65 100644 --- a/openscan_firmware/routers/next/openscan.py +++ b/openscan_firmware/routers/next/openscan.py @@ -181,7 +181,19 @@ async def _follow_file(file_path: str, poll_interval: float = 1) -> AsyncGenerat raise HTTPException(status_code=404, detail="Log file not found") -@router.get("/logs/tail") +@router.get( + "/logs/tail", + response_class=StreamingResponse, + responses={ + 200: { + "description": "Plain-text log output, or JSON Lines when `format=json`.", + "content": { + "text/plain": {"schema": {"type": "string"}}, + "application/x-ndjson": {"schema": {"type": "string"}}, + }, + } + }, +) async def tail_logs(format: str = "text", lines: int = 200, follow: bool = False, poll_interval: float = 1): """Show or follow current logs. @@ -189,7 +201,7 @@ async def tail_logs(format: str = "text", lines: int = 200, follow: bool = False 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. + format: "text" for openscan_firmware.log, "json" for JSON Lines from 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. @@ -201,7 +213,7 @@ async def tail_logs(format: str = "text", lines: int = 200, follow: bool = False if format.lower() == "json": log_file = os.path.join(DEFAULT_LOGS_PATH, "openscan_detailed_log.json") - media_type = "application/json" + media_type = "application/x-ndjson" else: log_file = os.path.join(DEFAULT_LOGS_PATH, "openscan_firmware.log") media_type = "text/plain" @@ -230,7 +242,18 @@ async def stream() -> AsyncGenerator[bytes, None]: return StreamingResponse(iter([content.encode("utf-8")]), media_type=media_type) -@router.get("/logs/archive") +@router.get( + "/logs/archive", + response_class=FileResponse, + responses={ + 200: { + "description": "ZIP archive containing the available log files.", + "content": { + "application/zip": {"schema": {"type": "string", "format": "binary"}}, + }, + } + }, +) async def download_logs_archive(): """Create and download a ZIP archive containing all log files. diff --git a/openscan_firmware/routers/next/projects.py b/openscan_firmware/routers/next/projects.py index 0dcac38..dc9f982 100644 --- a/openscan_firmware/routers/next/projects.py +++ b/openscan_firmware/routers/next/projects.py @@ -32,6 +32,7 @@ logger = logging.getLogger(__name__) STACKED_PHOTO_SUFFIXES = {".jpg", ".jpeg"} +_BINARY_SCHEMA = {"type": "string", "format": "binary"} class DeleteResponse(BaseModel): success: bool @@ -49,6 +50,20 @@ class PhotoResponse(BaseModel): photo_data: bytes +class ProjectCreateRequest(BaseModel): + """JSON payload for creating a project.""" + + project_description: str = "" + + +class ScanCreateRequest(BaseModel): + """JSON payload for adding and starting a scan.""" + + camera_name: str + scan_settings: ScanSetting + scan_description: str = "" + + @router.get("/", response_model=dict[str, Project]) async def get_projects(): """Get all projects with serialized data @@ -77,7 +92,16 @@ async def get_project(project_name: str): return project -@router.get("/{project_name}/thumbnail") +@router.get( + "/{project_name}/thumbnail", + response_class=FileResponse, + responses={ + 200: { + "description": "The project thumbnail as a JPEG file.", + "content": {"image/jpeg": {"schema": _BINARY_SCHEMA}}, + } + }, +) async def get_project_thumbnail(project_name: str): project_manager = get_project_manager() project = project_manager.get_project_by_name(project_name) @@ -92,44 +116,47 @@ async def get_project_thumbnail(project_name: str): @router.post("/{project_name}", response_model=Project) -async def new_project(project_name: str, project_description: Optional[str] = ""): +async def new_project(project_name: str, request: ProjectCreateRequest): """Create a new project Args: project_name: The name of the project to create - project_description: Optional description for the project + request: JSON payload containing the optional project description 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) + return project_manager.add_project(project_name, request.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: +async def add_scan( + project_name: str, + request: ScanCreateRequest, +) -> 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 + request: JSON payload containing the camera, scan settings, and optional description Returns: Task: The Task representing the started scan """ - camera_controller = get_camera_controller(camera_name) + camera_controller = get_camera_controller(request.camera_name) project_manager = get_project_manager() try: - scan = project_manager.add_scan(project_name, camera_controller, scan_settings, scan_description) + scan = project_manager.add_scan( + project_name, + camera_controller, + request.scan_settings, + request.scan_description, + ) task = await scans.start_scan(project_manager, scan, camera_controller) return task @@ -226,7 +253,16 @@ async def delete_project(project_name: str): ) -@router.get("/{project_name}/{scan_index:int}/photo", response_model=PhotoResponse) +@router.get( + "/{project_name}/{scan_index:int}/photo", + responses={ + 200: { + "model": PhotoResponse, + "description": "Photo metadata and data, or the raw file when `file_only=true`.", + "content": {"application/octet-stream": {"schema": _BINARY_SCHEMA}}, + } + }, +) async def get_scan_photo( project_name: str, scan_index: int, @@ -593,7 +629,16 @@ def _add_project_to_zip_with_strategy( return added -@router.get("/{project_name}/zip") +@router.get( + "/{project_name}/zip", + response_class=StreamingResponse, + responses={ + 200: { + "description": "A ZIP stream containing the requested project files.", + "content": {"application/zip": {"schema": _BINARY_SCHEMA}}, + } + }, +) async def download_project( project_name: str, photos_only: bool = Query( @@ -677,7 +722,16 @@ async def download_project( raise HTTPException(status_code=500, detail=str(e)) -@router.get("/{project_name}/model/zip") +@router.get( + "/{project_name}/model/zip", + response_class=StreamingResponse, + responses={ + 200: { + "description": "A ZIP stream containing the reconstructed project model.", + "content": {"application/zip": {"schema": _BINARY_SCHEMA}}, + } + }, +) async def download_project_model(project_name: str): """Download the reconstructed model directory of a project as a ZIP file.""" @@ -716,7 +770,16 @@ async def download_project_model(project_name: str): ) -@router.get("/{project_name}/scans/zip") +@router.get( + "/{project_name}/scans/zip", + response_class=StreamingResponse, + responses={ + 200: { + "description": "A ZIP stream containing the requested scans.", + "content": {"application/zip": {"schema": _BINARY_SCHEMA}}, + } + }, +) async def download_scans( project_name: str, scan_indices: List[int] = Query(None), diff --git a/openscan_firmware/routers/system_repair.py b/openscan_firmware/routers/system_repair.py new file mode 100644 index 0000000..bc6a7ca --- /dev/null +++ b/openscan_firmware/routers/system_repair.py @@ -0,0 +1,33 @@ +"""System repair API endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from openscan_firmware.system_update import UpdateConflictError, run_repair_openscan3 + + +router = APIRouter( + prefix="/system/repair", + tags=["system repair"], + responses={404: {"description": "Not found"}}, +) + + +def _json(status_code: int, payload: dict) -> JSONResponse: + return JSONResponse(status_code=status_code, content=payload) + + +@router.post("/openscan3") +async def repair_openscan3() -> JSONResponse: + try: + status_code, payload = await run_repair_openscan3() + except UpdateConflictError as exc: + payload = { + "ok": False, + "command": "repair_openscan3", + "error": {"type": exc.error_type, "message": exc.message}, + } + status_code = 409 + return _json(status_code, payload) diff --git a/openscan_firmware/routers/system_update.py b/openscan_firmware/routers/system_update.py new file mode 100644 index 0000000..06eeacd --- /dev/null +++ b/openscan_firmware/routers/system_update.py @@ -0,0 +1,92 @@ +"""System update API endpoints.""" + +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, Response +from pydantic import BaseModel + +from openscan_firmware.system_update import ( + UpdateConflictError, + read_user_update_status, + refresh_user_update_status, + run_user_update_apply, +) + +router = APIRouter( + prefix="/system/update", + tags=["system update"], + responses={404: {"description": "Not found"}}, +) + + +class OpenScanUpdatePackage(BaseModel): + """One optional OpenScan component update for the details view.""" + + id: Literal["firmware", "client", "updater", "system_config", "camera_stack"] + installed_version: str | None + available_version: str | None + update_available: bool + + +class OpenScanUpdateSummary(BaseModel): + updates_available: bool + packages: list[OpenScanUpdatePackage] + + +class SystemUpdateSummary(BaseModel): + updates_available: bool + count: int + reboot_required_after_install: bool + + +class UpdateStatusResponse(BaseModel): + """Cached, user-facing software update status.""" + + status: Literal[ + "unknown", + "up_to_date", + "updates_available", + "status_unavailable", + "check_failed", + ] + checked_at: str | None + stale: bool + release_channel: Literal["stable", "nightly", "unknown"] + openscan: OpenScanUpdateSummary + system: SystemUpdateSummary + reboot_required: bool + + +class UpdateInstallResponse(BaseModel): + """Acceptance or result of a user-requested update installation.""" + + status: Literal["installing", "completed", "install_failed", "install_blocked"] + reboot_required: bool + +@router.get("/status", response_model=UpdateStatusResponse) +async def get_update_status(response: Response) -> dict: + status_code, payload = await read_user_update_status() + response.status_code = status_code + return payload + +@router.post("/check", response_model=UpdateStatusResponse) +async def check_for_updates(response: Response) -> dict: + status_code, payload = await refresh_user_update_status() + response.status_code = status_code + return payload + + +@router.post("/apply", response_model=UpdateInstallResponse) +async def apply_updates(response: Response) -> dict: + try: + status_code, payload = await run_user_update_apply() + except UpdateConflictError as exc: + payload = { + "status": "install_blocked", + "reboot_required": False, + } + status_code = 409 + response.status_code = status_code + return payload diff --git a/openscan_firmware/routers/v0_8/develop.py b/openscan_firmware/routers/v0_8/develop.py index 384ebc8..2fa9e5f 100644 --- a/openscan_firmware/routers/v0_8/develop.py +++ b/openscan_firmware/routers/v0_8/develop.py @@ -84,16 +84,15 @@ async def crop_image(camera_name: str, threshold: int | None = Query(default=Non @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.""" + """Start the async hello world progress 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) + task = await task_manager.create_and_run_task("hello_world_progress_task", total_steps=total_steps, interval=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 + return paths.get_path(method, points) diff --git a/openscan_firmware/routers/v0_9/develop.py b/openscan_firmware/routers/v0_9/develop.py index 4997038..3c7d9d5 100644 --- a/openscan_firmware/routers/v0_9/develop.py +++ b/openscan_firmware/routers/v0_9/develop.py @@ -6,7 +6,9 @@ import base64 import json +import os import subprocess +import sys import time from pathlib import Path from typing import Literal @@ -14,6 +16,11 @@ from fastapi import APIRouter, HTTPException, status, Response, Query from fastapi.responses import PlainTextResponse +from openscan_firmware.controllers.hardware.cameras.camera import ( + create_camera_controller, + get_all_camera_controllers, + remove_camera_controller, +) from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager from openscan_firmware.models.task import TaskStatus, Task @@ -23,7 +30,7 @@ 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" +CAMERA_REPORT_SCRIPT = Path(__file__).resolve().parents[2] / "utils" / "camera_report.sh" router = APIRouter( @@ -33,6 +40,51 @@ ) +def _release_camera_controllers_for_report() -> tuple[list, dict]: + """Release OpenScan-owned cameras so external rpicam/libcamera probes can acquire them.""" + controllers = get_all_camera_controllers() + busy = [ + name + for name, controller in controllers.items() + if callable(getattr(controller, "is_busy", None)) and controller.is_busy() + ] + if busy: + return [], { + "released": False, + "reason": "camera busy", + "busy_cameras": busy, + "cameras": list(controllers.keys()), + } + + camera_models = [controller.camera for controller in controllers.values()] + released: list[str] = [] + errors: list[dict] = [] + for name in list(controllers.keys()): + try: + if remove_camera_controller(name): + released.append(name) + except Exception as exc: + errors.append({"camera": name, "error": str(exc)}) + + return camera_models, { + "released": bool(released), + "cameras": released, + "errors": errors, + } + + +def _restore_camera_controllers_after_report(camera_models: list) -> dict: + restored: list[str] = [] + errors: list[dict] = [] + for camera in camera_models: + try: + create_camera_controller(camera) + restored.append(camera.name) + except Exception as exc: + errors.append({"camera": camera.name, "error": str(exc)}) + return {"restored": restored, "errors": errors} + + def _gp_text(value) -> str: # noqa: ANN001 if value is None: return "" @@ -241,6 +293,10 @@ async def restart_application() -> dict[str, str]: @router.get("/camera-report") async def get_camera_report( format: Literal["json", "text"] = Query(default="json"), + release_cameras: bool = Query( + default=True, + description="Temporarily release OpenScan camera controllers so rpicam/libcamera probes can acquire cameras.", + ), ): """Run the camera diagnostics script and return a bundled report.""" if not CAMERA_REPORT_SCRIPT.exists(): @@ -249,21 +305,36 @@ async def get_camera_report( 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, - ) + camera_models: list = [] + camera_release = {"released": False, "reason": "disabled"} + camera_restore = {"restored": [], "errors": []} + if release_cameras: + camera_models, camera_release = _release_camera_controllers_for_report() + + try: + result = subprocess.run( + ["bash", str(CAMERA_REPORT_SCRIPT)], + capture_output=True, + text=True, + timeout=180, + check=False, + env={**os.environ, "OPENSCAN_REPORT_PYTHON": sys.executable}, + ) + finally: + if camera_models: + camera_restore = _restore_camera_controllers_after_report(camera_models) 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." + camera_section = "===== OpenScan camera release for external probes =====\n" + json.dumps( + {"release": camera_release, "restore": camera_restore}, + indent=2, + ) gphoto2_section = "===== GPhoto2 python diagnostics =====\n" + json.dumps(gphoto2_diag, indent=2) - text_output = f"{text_output}\n\n{gphoto2_section}" + text_output = f"{text_output}\n\n{camera_section}\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) @@ -271,6 +342,8 @@ async def get_camera_report( "ok": result.returncode == 0, "return_code": result.returncode, "script": str(CAMERA_REPORT_SCRIPT), + "camera_release": camera_release, + "camera_restore": camera_restore, "report": report, "stderr": stderr, "gphoto2": gphoto2_diag, @@ -323,12 +396,11 @@ async def crop_image(camera_name: str, threshold: int | None = Query(default=Non @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.""" + """Start the async hello world progress 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) + task = await task_manager.create_and_run_task("hello_world_progress_task", total_steps=total_steps, interval=delay) return task diff --git a/openscan_firmware/system_update.py b/openscan_firmware/system_update.py new file mode 100644 index 0000000..94bcbae --- /dev/null +++ b/openscan_firmware/system_update.py @@ -0,0 +1,487 @@ +"""Safe wrapper around the local OpenScan updater CLI.""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +from collections import deque +from pathlib import Path +from typing import Any, Final + +from openscan_firmware import __version__ +from openscan_firmware.controllers.services.tasks.task_manager import get_task_manager +from openscan_firmware.models.task import TaskStatus + +BACKEND_API_VERSION: Final = "1" +UPDATER_TIMEOUT_SECONDS: Final = { + "status": 30, + "update_status": 30, + "check": 90, + "check_openscan": 90, + "check_system": 180, + "update_openscan": 900, + "apply_updates": 30, + "update_system": 1800, + "repair_openscan3": 1200, + "healthcheck": 60, +} +MAX_ERROR_TEXT_CHARS: Final = 4096 +MAX_LOG_LINES: Final = 200 +MAX_LOG_BYTES: Final = 128 * 1024 +UPDATER_ENV: Final = { + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", +} + +UPDATER_COMMANDS: Final[dict[str, list[str]]] = { + "status": ["sudo", "/usr/bin/openscan-updater", "status", "--json"], + "update_status": ["sudo", "/usr/bin/openscan-updater", "system", "status", "--json"], + "check": ["sudo", "/usr/bin/openscan-updater", "update", "--dry-run", "--json"], + "check_openscan": ["sudo", "/usr/bin/openscan-updater", "update", "--dry-run", "--json"], + "check_system": ["sudo", "/usr/bin/openscan-updater", "system", "check", "--json"], + "update_openscan": ["sudo", "/usr/bin/openscan-updater", "update", "--json"], + "apply_updates": ["sudo", "/usr/bin/openscan-updater", "apply", "--detach", "--json"], + "update_system": ["sudo", "/usr/bin/openscan-updater", "system", "update", "--json"], + "repair_openscan3": ["sudo", "/usr/bin/openscan-updater", "repair", "--json"], + "healthcheck": ["sudo", "/usr/bin/openscan-updater", "healthcheck", "--json"], +} + +UPDATER_LOG_FILES: Final[tuple[Path, ...]] = ( + Path("/var/log/openscan-updater/updater.log"), + Path("/var/log/openscan-updater.log"), +) + +_update_lock = asyncio.Lock() + + +class UpdateConflictError(RuntimeError): + """Raised when an update cannot be started because another operation is active.""" + + def __init__(self, error_type: str, message: str) -> None: + super().__init__(message) + self.error_type = error_type + self.message = message + + +def _base_response(command: str) -> dict[str, Any]: + return { + "backend_api_version": BACKEND_API_VERSION, + "firmware_version": __version__, + "command": command, + } + + +def _error_response(command: str, error_type: str, message: str, **extra: Any) -> dict[str, Any]: + payload = _base_response(command) + payload["ok"] = False + payload["error"] = {"type": error_type, "message": message, **extra} + return payload + + +def _result_response(command: str, result: Any, *, ok: bool = True) -> dict[str, Any]: + payload = _base_response(command) + payload["ok"] = ok + payload["result"] = result + return payload + + +async def run_updater_command(command: str) -> tuple[int, dict[str, Any]]: + """Run one fixed updater command and return an HTTP status plus JSON payload.""" + try: + argv = UPDATER_COMMANDS[command] + except KeyError: + return 501, _error_response( + command, + "command_not_implemented", + f"Updater command is not implemented by this backend: {command}", + ) + + timeout = UPDATER_TIMEOUT_SECONDS[command] + try: + completed = await asyncio.to_thread( + subprocess.run, + argv, + capture_output=True, + text=True, + timeout=timeout, + check=False, + shell=False, + env=UPDATER_ENV, + ) + except subprocess.TimeoutExpired: + return 500, _error_response( + command, + "command_timeout", + f"openscan-updater command timed out after {timeout} seconds", + timeout_seconds=timeout, + ) + except OSError as exc: + return 500, _error_response( + command, + "command_execution_failed", + "openscan-updater could not be executed", + detail=str(exc), + ) + + stdout = completed.stdout.strip() + stderr = completed.stderr.strip() + + try: + result = json.loads(stdout) if stdout else None + except json.JSONDecodeError: + return 500, _error_response( + command, + "invalid_updater_json", + "openscan-updater did not return valid JSON", + returncode=completed.returncode, + stderr=_truncate_text(stderr), + ) + + if completed.returncode != 0: + payload = _result_response(command, result, ok=False) + payload["error"] = { + "type": "command_failed", + "message": "openscan-updater exited with a non-zero status", + "returncode": completed.returncode, + "stderr": _truncate_text(stderr), + } + nonzero_json_results = { + "check_openscan", + "check_system", + "update_openscan", + "update_system", + "repair_openscan3", + } + if command in nonzero_json_results and result is not None: + return 200, payload + return 500, payload + + return 200, _result_response(command, result, ok=True) + + +async def run_update_check() -> tuple[int, dict[str, Any]]: + """Return a combined OpenScan and system update plan for the UI.""" + openscan = await _run_update_stage("openscan", "check_openscan") + system = await _run_update_stage("system", "check_system") + ok = _stage_ok(openscan) and _stage_ok(system) + + return 200, _result_response( + "update_check", + { + "summary": _combined_summary(openscan, system), + "stages": { + "openscan": openscan, + "system": system, + }, + }, + ok=ok, + ) + + +async def read_user_update_status() -> tuple[int, dict[str, Any]]: + """Return the compact cached update status intended for the web client.""" + status_code, payload = await run_updater_command("update_status") + if status_code != 200 or not payload.get("ok"): + return status_code, _public_error("status_unavailable") + return 200, _public_update_status(payload.get("result")) + + +async def refresh_user_update_status() -> tuple[int, dict[str, Any]]: + """Synchronously refresh the cached update status and return its public form.""" + status_code, payload = await run_updater_command("check_system") + if status_code != 200 or not payload.get("ok"): + return status_code, _public_error("check_failed") + return 200, _public_update_status(payload.get("result")) + + +async def run_update_openscan() -> tuple[int, dict[str, Any]]: + """Run the OpenScan update command under a process-local lock.""" + if _update_lock.locked(): + return 409, _error_response( + "update_openscan", + "update_active", + "Another update command is already running.", + ) + + if is_scan_active(): + raise UpdateConflictError( + "scan_active", + "Updates cannot be started while a scan is running.", + ) + + await _update_lock.acquire() + try: + return await run_updater_command("update_openscan") + finally: + _update_lock.release() + + +async def run_update_apply() -> tuple[int, dict[str, Any]]: + """Run the user-facing update flow: OpenScan first, then system updates.""" + if _update_lock.locked(): + return 409, _error_response( + "update_apply", + "update_active", + "Another update command is already running.", + ) + + if is_scan_active(): + raise UpdateConflictError( + "scan_active", + "Updates cannot be started while a scan is running.", + ) + + await _update_lock.acquire() + try: + openscan = await _run_update_stage("openscan", "update_openscan") + stages: dict[str, Any] = {"openscan": openscan} + if not _stage_ok(openscan): + return 200, _result_response( + "update_apply", + { + "summary": "OpenScan update did not complete; system update was not started.", + "stages": stages, + }, + ok=False, + ) + + system_check = await _run_update_stage("system_check", "check_system") + stages["system_check"] = system_check + if not _stage_ok(system_check): + return 200, _result_response( + "update_apply", + { + "summary": ( + "System update check did not complete; " + "system update was not started." + ), + "stages": stages, + }, + ok=False, + ) + + system = await _run_update_stage("system", "update_system") + stages["system"] = system + ok = _stage_ok(system) + + return 200, _result_response( + "update_apply", + { + "summary": _apply_summary(ok), + "stages": stages, + }, + ok=ok, + ) + finally: + _update_lock.release() + + +async def run_user_update_apply() -> tuple[int, dict[str, Any]]: + """Schedule the update outside the firmware service cgroup.""" + if _update_lock.locked(): + return 409, {"status": "install_blocked", "reboot_required": False} + + if is_scan_active(): + raise UpdateConflictError( + "scan_active", + "Updates cannot be started while a scan is running.", + ) + + await _update_lock.acquire() + try: + status_code, payload = await run_updater_command("apply_updates") + finally: + _update_lock.release() + + if status_code != 200 or not payload.get("ok"): + return 200, {"status": "install_failed", "reboot_required": False} + result = payload.get("result") + if not isinstance(result, dict) or result.get("status") != "update_scheduled": + return 200, {"status": "install_failed", "reboot_required": False} + return 200, {"status": "installing", "reboot_required": False} + + +async def run_repair_openscan3() -> tuple[int, dict[str, Any]]: + """Run the OpenScan3 repair command under the shared update/repair lock.""" + if _update_lock.locked(): + return 409, _error_response( + "repair_openscan3", + "update_active", + "Another update or repair command is already running.", + ) + + if is_scan_active(): + raise UpdateConflictError( + "scan_active", + "Repair cannot be started while a scan is running.", + ) + + await _update_lock.acquire() + try: + return await run_updater_command("repair_openscan3") + finally: + _update_lock.release() + + +def is_scan_active() -> bool: + """Return true when the firmware task manager reports an active scan task.""" + active_statuses = {TaskStatus.PENDING, TaskStatus.RUNNING, TaskStatus.PAUSED} + try: + tasks = get_task_manager().get_all_tasks_info() + except Exception: + return False + + return any(task.task_type == "scan_task" and task.status in active_statuses for task in tasks) + + +async def _run_update_stage(stage: str, command: str) -> dict[str, Any]: + status_code, payload = await run_updater_command(command) + result: dict[str, Any] = { + "stage": stage, + "command": command, + "status_code": status_code, + "ok": _payload_ok(payload), + "payload": payload, + } + if status_code >= 400: + result["ok"] = False + return result + + +def _stage_ok(stage: dict[str, Any]) -> bool: + return bool(stage.get("ok")) and int(stage.get("status_code", 500)) < 400 + + +def _payload_ok(payload: dict[str, Any]) -> bool: + if payload.get("ok") is False: + return False + result = payload.get("result") + if isinstance(result, dict) and result.get("ok") is False: + return False + return True + + +def _combined_summary(openscan: dict[str, Any], system: dict[str, Any]) -> str: + if _stage_ok(openscan) and _stage_ok(system): + return "OpenScan and system update checks completed." + if not _stage_ok(openscan) and not _stage_ok(system): + return "OpenScan and system update checks did not complete." + if not _stage_ok(openscan): + return "OpenScan update check did not complete." + return "System update check did not complete." + + +def _apply_summary(ok: bool) -> str: + if ok: + return "OpenScan update and system update completed." + return "System update did not complete." + + +def _public_update_status(payload: Any) -> dict[str, Any]: + """Project the updater cache into the stable, user-facing API contract.""" + if not isinstance(payload, dict): + return _public_error("status_unavailable") + + openscan = payload.get("openscan", {}) + system = payload.get("system", {}) + packages = openscan.get("packages", []) if isinstance(openscan, dict) else [] + return { + "status": _public_status_value(payload.get("status")), + "checked_at": payload.get("checked_at") if isinstance(payload.get("checked_at"), str) else None, + "stale": bool(payload.get("stale")), + "release_channel": _release_channel(payload.get("release_channel")), + "openscan": { + "updates_available": bool(openscan.get("updates_available")) if isinstance(openscan, dict) else False, + "packages": [_public_package(item) for item in packages if isinstance(item, dict)], + }, + "system": { + "updates_available": bool(system.get("updates_available")) if isinstance(system, dict) else False, + "count": _nonnegative_int(system.get("count")) if isinstance(system, dict) else 0, + "reboot_required_after_install": bool(system.get("reboot_required_after_install")) if isinstance(system, dict) else False, + }, + "reboot_required": bool(payload.get("reboot_required")), + } + + +def _public_error(status: str) -> dict[str, Any]: + return { + "status": status, + "checked_at": None, + "stale": True, + "release_channel": "unknown", + "openscan": {"updates_available": False, "packages": []}, + "system": {"updates_available": False, "count": 0, "reboot_required_after_install": False}, + "reboot_required": False, + } + + +def _public_package(package: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(package.get("id", "unknown")), + "installed_version": package.get("installed_version") if isinstance(package.get("installed_version"), str) else None, + "available_version": package.get("available_version") if isinstance(package.get("available_version"), str) else None, + "update_available": bool(package.get("update_available")), + } + + +def _public_status_value(value: Any) -> str: + return value if value in {"unknown", "up_to_date", "updates_available"} else "unknown" + + +def _release_channel(value: Any) -> str: + return value if value in {"stable", "nightly", "unknown"} else "unknown" + + +def _nonnegative_int(value: Any) -> int: + return value if isinstance(value, int) and value >= 0 else 0 + + +def read_updater_logs(tail: int = MAX_LOG_LINES) -> tuple[int, dict[str, Any]]: + """Return recent updater logs from fixed known log files only.""" + tail = max(1, min(tail, MAX_LOG_LINES)) + log_path = next((path for path in UPDATER_LOG_FILES if path.is_file()), None) + if log_path is None: + return 404, _error_response( + "logs", + "log_file_not_found", + "No updater log file was found.", + ) + + try: + lines = _tail_text_file(log_path, tail) + except OSError as exc: + return 500, _error_response( + "logs", + "log_read_failed", + "Updater log file could not be read.", + detail=str(exc), + ) + + return 200, _result_response( + "logs", + { + "path": str(log_path), + "tail": tail, + "lines": lines, + "truncated_bytes": MAX_LOG_BYTES, + }, + ok=True, + ) + + +def _tail_text_file(path: Path, max_lines: int) -> list[str]: + with path.open("rb") as handle: + handle.seek(0, 2) + size = handle.tell() + handle.seek(max(0, size - MAX_LOG_BYTES)) + text = handle.read(MAX_LOG_BYTES).decode("utf-8", errors="replace") + + return list(deque(text.splitlines(), maxlen=max_lines)) + + +def _truncate_text(text: str) -> str: + if len(text) <= MAX_ERROR_TEXT_CHARS: + return text + return text[:MAX_ERROR_TEXT_CHARS] + "... [truncated]" diff --git a/openscan_firmware/utils/camera_report.sh b/openscan_firmware/utils/camera_report.sh new file mode 100644 index 0000000..262c57c --- /dev/null +++ b/openscan_firmware/utils/camera_report.sh @@ -0,0 +1,169 @@ +#!/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 +} + +run_python_probe() { + local description="$1" + shift + print_section "$description" + "${OPENSCAN_REPORT_PYTHON:-python3}" - "$@" 2>&1 + local rc=$? + if [ "$rc" -ne 0 ]; then + printf "[exit-code] %s\n" "$rc" + fi +} + +run_camera_hello_probe() { + local binary="$1" + local description="$2" + + print_section "$description" + if ! command -v "$binary" >/dev/null 2>&1; then + printf "%s not found in PATH\n" "$binary" + return + fi + + printf "+ %s --version\n" "$binary" + "$binary" --version 2>&1 || printf "[exit-code] %s\n" "$?" + + printf "\n+ %s --list-cameras\n" "$binary" + local list_output + list_output="$("$binary" --list-cameras 2>&1)" + local rc=$? + printf "%s\n" "$list_output" + if [ "$rc" -ne 0 ]; then + printf "[exit-code] %s\n" "$rc" + return + fi + + local camera_indices=() + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*([0-9]+)[[:space:]]*: ]]; then + camera_indices+=("${BASH_REMATCH[1]}") + fi + done <<< "$list_output" + + if [ "${#camera_indices[@]}" -eq 0 ]; then + echo "No cameras listed by $binary" + return + fi + + for idx in "${camera_indices[@]}"; do + printf "\n--- %s camera %s hello probe ---\n" "$binary" "$idx" + printf "+ timeout 12s %s --camera %s --timeout 2000 --nopreview\n" "$binary" "$idx" + timeout 12s "$binary" --camera "$idx" --timeout 2000 --nopreview 2>&1 + rc=$? + if [ "$rc" -ne 0 ]; then + printf "[exit-code] %s\n" "$rc" + fi + done +} + +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_python_probe "OpenScan firmware package info" <<'PY' +import importlib +from importlib.metadata import PackageNotFoundError, version + +packages = [ + ("openscan-firmware", "openscan_firmware"), + ("picamera2", "picamera2"), + ("linuxpy", "linuxpy"), + ("gphoto2", "gphoto2"), +] +for package, module_name in packages: + try: + package_version = version(package) + except PackageNotFoundError: + package_version = "distribution metadata not found" + + try: + module = importlib.import_module(module_name) + module_file = getattr(module, "__file__", "built-in") + import_status = f"import ok ({module_file})" + except Exception as exc: + import_status = f"import failed: {exc}" + + print(f"{package}: {package_version}; module {module_name}: {import_status}") +PY +run_command "Python runtime" "${OPENSCAN_REPORT_PYTHON:-python3}" --version +run_command "OpenScan service status" bash -lc 'systemctl status --no-pager -l openscan3.service 2>&1 || true' +run_command "OpenScan service journal excerpts" bash -lc 'journalctl -u openscan3.service -n 160 --no-pager 2>&1 || true' +run_command "Camera ownership diagnostics" bash -lc 'ps -eo pid,ppid,stat,comm,args | egrep "openscan3|python|CameraManager|IPAProxy|rpicam|libcamera" | egrep -v "egrep" || true; if command -v fuser >/dev/null 2>&1; then fuser -v /dev/video* /dev/media* 2>&1 || true; else echo "fuser not found in PATH"; fi' +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_camera_hello_probe "rpicam-hello" "rpicam-hello camera probes" +run_camera_hello_probe "libcamera-hello" "libcamera-hello legacy camera probes" +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/openscan_firmware/utils/pwm_hardware.py b/openscan_firmware/utils/pwm_hardware.py index 719d140..6cdd235 100644 --- a/openscan_firmware/utils/pwm_hardware.py +++ b/openscan_firmware/utils/pwm_hardware.py @@ -4,8 +4,6 @@ from dataclasses import dataclass import atexit -import signal -import sys @dataclass class _HwPWM: @@ -21,11 +19,10 @@ class _HwPWM: _pins = {} - # register cleanup at exit + # Register an atexit fallback only. Do not install process-global signal + # handlers here; uvicorn/systemd need SIGTERM/SIGINT for graceful shutdown. 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): @@ -191,9 +188,6 @@ def _cleanup(): for pin in to_clean: _HwPWM.release(pin) - def _signal_handler(signum, frame): - _HwPWM._cleanup() - # ========================================================== # SINGLETON diff --git a/pyproject.toml b/pyproject.toml index 498249e..aa5a704 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openscan-firmware" -version = "0.11.4" +version = "0.12.0" description = "OpenScan3 - Raspberry Pi based photogrammetry scanner (FastAPI-based application)" readme = "README.md" requires-python = ">=3.11" @@ -67,11 +67,15 @@ where = ["."] include = ["openscan_firmware*"] [tool.setuptools.package-data] -"openscan_firmware" = ["**/*.json"] +"openscan_firmware" = ["**/*.json", "utils/camera_report.sh"] [tool.setuptools.data-files] "openscan_firmware/settings/device" = [ - "settings/device/*.json", + "settings/device/default_classic_greenshield.json", + "settings/device/default_midi_blackshield.json", + "settings/device/default_mini_blackshield.json", + "settings/device/default_mini_greenshield.json", + "settings/device/example_custom.json", ] "openscan_firmware/settings/firmware" = [ "settings/firmware/*.json", diff --git a/scripts/camera_report.sh b/scripts/camera_report.sh deleted file mode 100755 index 997ceb5..0000000 --- a/scripts/camera_report.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/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 57e1166..93f4c11 100644 --- a/scripts/openapi/openapi_latest.json +++ b/scripts/openapi/openapi_latest.json @@ -1540,6 +1540,100 @@ } } }, + "/system/update/status": { + "get": { + "tags": [ + "system update" + ], + "summary": "Get Update Status", + "operationId": "get_update_status", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/update/check": { + "post": { + "tags": [ + "system update" + ], + "summary": "Check For Updates", + "operationId": "check_for_updates", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/update/apply": { + "post": { + "tags": [ + "system update" + ], + "summary": "Apply Updates", + "operationId": "apply_updates", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateInstallResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/repair/openscan3": { + "post": { + "tags": [ + "system repair" + ], + "summary": "Repair Openscan3", + "operationId": "repair_openscan3", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, "/projects/": { "get": { "tags": [ @@ -3723,6 +3817,18 @@ "default": "json", "title": "Format" } + }, + { + "name": "release_cameras", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Temporarily release OpenScan camera controllers so rpicam/libcamera probes can acquire cameras.", + "default": true, + "title": "Release Cameras" + }, + "description": "Temporarily release OpenScan camera controllers so rpicam/libcamera probes can acquire cameras." } ], "responses": { @@ -3813,7 +3919,7 @@ "develop" ], "summary": "Hello World Async", - "description": "Start the async hello world demo task.", + "description": "Start the async hello world progress demo task.", "operationId": "hello_world_async", "parameters": [ { @@ -5703,6 +5809,77 @@ ], "title": "MotorStatusResponse" }, + "OpenScanUpdatePackage": { + "properties": { + "id": { + "type": "string", + "enum": [ + "firmware", + "client", + "updater", + "system_config", + "camera_stack" + ], + "title": "Id" + }, + "installed_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Installed Version" + }, + "available_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Available Version" + }, + "update_available": { + "type": "boolean", + "title": "Update Available" + } + }, + "type": "object", + "required": [ + "id", + "installed_version", + "available_version", + "update_available" + ], + "title": "OpenScanUpdatePackage", + "description": "One optional OpenScan component update for the details view." + }, + "OpenScanUpdateSummary": { + "properties": { + "updates_available": { + "type": "boolean", + "title": "Updates Available" + }, + "packages": { + "items": { + "$ref": "#/components/schemas/OpenScanUpdatePackage" + }, + "type": "array", + "title": "Packages" + } + }, + "type": "object", + "required": [ + "updates_available", + "packages" + ], + "title": "OpenScanUpdateSummary" + }, "PathMethod": { "type": "string", "enum": [ @@ -6400,6 +6577,29 @@ "type": "object", "title": "StackingTaskStatus" }, + "SystemUpdateSummary": { + "properties": { + "updates_available": { + "type": "boolean", + "title": "Updates Available" + }, + "count": { + "type": "integer", + "title": "Count" + }, + "reboot_required_after_install": { + "type": "boolean", + "title": "Reboot Required After Install" + } + }, + "type": "object", + "required": [ + "updates_available", + "count", + "reboot_required_after_install" + ], + "title": "SystemUpdateSummary" + }, "Task": { "properties": { "id": { @@ -6583,6 +6783,92 @@ ], "title": "TriggerConfig" }, + "UpdateInstallResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "installing", + "completed", + "install_failed", + "install_blocked" + ], + "title": "Status" + }, + "reboot_required": { + "type": "boolean", + "title": "Reboot Required" + } + }, + "type": "object", + "required": [ + "status", + "reboot_required" + ], + "title": "UpdateInstallResponse", + "description": "Result of a synchronous user-requested update installation." + }, + "UpdateStatusResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "unknown", + "up_to_date", + "updates_available", + "status_unavailable", + "check_failed" + ], + "title": "Status" + }, + "checked_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Checked At" + }, + "stale": { + "type": "boolean", + "title": "Stale" + }, + "release_channel": { + "type": "string", + "enum": [ + "stable", + "nightly", + "unknown" + ], + "title": "Release Channel" + }, + "openscan": { + "$ref": "#/components/schemas/OpenScanUpdateSummary" + }, + "system": { + "$ref": "#/components/schemas/SystemUpdateSummary" + }, + "reboot_required": { + "type": "boolean", + "title": "Reboot Required" + } + }, + "type": "object", + "required": [ + "status", + "checked_at", + "stale", + "release_channel", + "openscan", + "system", + "reboot_required" + ], + "title": "UpdateStatusResponse", + "description": "Cached, user-facing software update status." + }, "ValidationError": { "properties": { "loc": { @@ -6618,4 +6904,4 @@ } } } -} \ No newline at end of file +} diff --git a/scripts/openapi/openapi_next.json b/scripts/openapi/openapi_next.json index 9a7c0a1..6522811 100644 --- a/scripts/openapi/openapi_next.json +++ b/scripts/openapi/openapi_next.json @@ -124,10 +124,19 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "A JPEG snapshot or an MJPEG stream, depending on `mode`.", "content": { - "application/json": { - "schema": {} + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "multipart/x-mixed-replace": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -195,10 +204,54 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "The requested photo bytes, or metadata with a payload URL when `with_metadata=true`.", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/PhotoMetadataResponse" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/x-adobe-dng": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/x-canon-cr2": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/x-canon-cr3": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/x-canon-crw": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/x-npy": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -247,10 +300,49 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "The cached photo payload bytes.", "content": { - "application/json": { - "schema": {} + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/x-adobe-dng": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/x-canon-cr2": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/x-canon-cr3": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/x-canon-crw": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/x-npy": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -1599,6 +1691,100 @@ } } }, + "/system/update/status": { + "get": { + "tags": [ + "system update" + ], + "summary": "Get Update Status", + "operationId": "get_update_status", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/update/check": { + "post": { + "tags": [ + "system update" + ], + "summary": "Check For Updates", + "operationId": "check_for_updates", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/update/apply": { + "post": { + "tags": [ + "system update" + ], + "summary": "Apply Updates", + "operationId": "apply_updates", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateInstallResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/repair/openscan3": { + "post": { + "tags": [ + "system repair" + ], + "summary": "Repair Openscan3", + "operationId": "repair_openscan3", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, "/projects/": { "get": { "tags": [ @@ -1678,7 +1864,7 @@ "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", + "description": "Create a new project\n\nArgs:\n project_name: The name of the project to create\n request: JSON payload containing the optional project description\n\nReturns:\n Project: The newly created project if successful, None if not", "operationId": "new_project", "parameters": [ { @@ -1689,25 +1875,18 @@ "type": "string", "title": "Project Name" } - }, - { - "name": "project_description", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": "", - "title": "Project Description" - } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreateRequest" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1799,10 +1978,13 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "The project thumbnail as a JPEG file.", "content": { - "application/json": { - "schema": {} + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -1827,9 +2009,9 @@ "tags": [ "projects" ], - "summary": "Add Scan With Description", - "description": "Add a new scan to a project and return the created Task\n\nArgs:\n project_name: The name of the project to add the scan to\n camera_name: The name of the camera to use for the scan\n scan_settings: The settings for the scan\n scan_description: Optional description for the scan\n\nReturns:\n Task: The Task representing the started scan", - "operationId": "add_scan_with_description", + "summary": "Add Scan", + "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 request: JSON payload containing the camera, scan settings, and optional description\n\nReturns:\n Task: The Task representing the started scan", + "operationId": "add_scan", "parameters": [ { "name": "project_name", @@ -1839,32 +2021,6 @@ "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": { @@ -1872,7 +2028,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanSetting" + "$ref": "#/components/schemas/ScanCreateRequest" } } } @@ -2088,12 +2244,18 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "Photo metadata and data, or the raw file when `file_only=true`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PhotoResponse" } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -2547,10 +2709,13 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "A ZIP stream containing the requested project files.", "content": { - "application/json": { - "schema": {} + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -2591,10 +2756,13 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "A ZIP stream containing the reconstructed project model.", "content": { - "application/json": { - "schema": {} + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -2659,10 +2827,13 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "A ZIP stream containing the requested scans.", "content": { - "application/json": { - "schema": {} + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -2713,7 +2884,7 @@ "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.", + "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 JSON Lines from 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": [ { @@ -2759,10 +2930,17 @@ ], "responses": { "200": { - "description": "Successful Response", + "description": "Plain-text log output, or JSON Lines when `format=json`.", "content": { - "application/json": { - "schema": {} + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/x-ndjson": { + "schema": { + "type": "string" + } } } }, @@ -2792,10 +2970,13 @@ "operationId": "download_logs_archive", "responses": { "200": { - "description": "Successful Response", + "description": "ZIP archive containing the available log files.", "content": { - "application/json": { - "schema": {} + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } } } }, @@ -2843,7 +3024,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/AvailableConfigsResponse" + } } } }, @@ -4373,6 +4556,18 @@ "default": "json", "title": "Format" } + }, + { + "name": "release_cameras", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Temporarily release OpenScan camera controllers so rpicam/libcamera probes can acquire cameras.", + "default": true, + "title": "Release Cameras" + }, + "description": "Temporarily release OpenScan camera controllers so rpicam/libcamera probes can acquire cameras." } ], "responses": { @@ -4463,7 +4658,7 @@ "develop" ], "summary": "Hello World Async", - "description": "Start the async hello world demo task.", + "description": "Start the async hello world progress demo task.", "operationId": "hello_world_async", "parameters": [ { @@ -5196,6 +5391,78 @@ ], "title": "AutoCalibrateAwbResponse" }, + "AvailableConfigResponse": { + "properties": { + "filename": { + "type": "string", + "title": "Filename" + }, + "path": { + "type": "string", + "title": "Path" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "shield": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Shield" + } + }, + "type": "object", + "required": [ + "filename", + "path" + ], + "title": "AvailableConfigResponse" + }, + "AvailableConfigsResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "configs": { + "items": { + "$ref": "#/components/schemas/AvailableConfigResponse" + }, + "type": "array", + "title": "Configs" + } + }, + "type": "object", + "required": [ + "status", + "configs" + ], + "title": "AvailableConfigsResponse" + }, "Body_add_config_json_device_configurations__post": { "properties": { "config_data": { @@ -5245,6 +5512,30 @@ ], "title": "Body_move_motor_by_degree_motors__motor_name__angle_patch" }, + "CameraMetadata": { + "properties": { + "camera_name": { + "type": "string", + "title": "Camera Name" + }, + "camera_settings": { + "$ref": "#/components/schemas/CameraSettings" + }, + "raw_metadata": { + "additionalProperties": true, + "type": "object", + "title": "Raw Metadata" + } + }, + "type": "object", + "required": [ + "camera_name", + "camera_settings", + "raw_metadata" + ], + "title": "CameraMetadata", + "description": "Represents metadata from a camera." + }, "CameraSettings": { "properties": { "shutter": { @@ -6561,6 +6852,77 @@ ], "title": "MotorStatusResponse" }, + "OpenScanUpdatePackage": { + "properties": { + "id": { + "type": "string", + "enum": [ + "firmware", + "client", + "updater", + "system_config", + "camera_stack" + ], + "title": "Id" + }, + "installed_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Installed Version" + }, + "available_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Available Version" + }, + "update_available": { + "type": "boolean", + "title": "Update Available" + } + }, + "type": "object", + "required": [ + "id", + "installed_version", + "available_version", + "update_available" + ], + "title": "OpenScanUpdatePackage", + "description": "One optional OpenScan component update for the details view." + }, + "OpenScanUpdateSummary": { + "properties": { + "updates_available": { + "type": "boolean", + "title": "Updates Available" + }, + "packages": { + "items": { + "$ref": "#/components/schemas/OpenScanUpdatePackage" + }, + "type": "array", + "title": "Packages" + } + }, + "type": "object", + "required": [ + "updates_available", + "packages" + ], + "title": "OpenScanUpdateSummary" + }, "PathMethod": { "type": "string", "enum": [ @@ -6608,6 +6970,60 @@ ], "title": "PersistedEndstopConfig" }, + "PhotoMetadataResponse": { + "properties": { + "format": { + "type": "string", + "enum": [ + "jpeg", + "raw", + "dng", + "rgb_array", + "yuv_array" + ], + "title": "Format" + }, + "media_type": { + "type": "string", + "title": "Media Type" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "camera_metadata": { + "$ref": "#/components/schemas/CameraMetadata" + }, + "scan_metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScanMetadata" + }, + { + "type": "null" + } + ] + }, + "payload_url": { + "type": "string", + "title": "Payload Url" + }, + "expires_in_s": { + "type": "integer", + "title": "Expires In S" + } + }, + "type": "object", + "required": [ + "format", + "media_type", + "filename", + "camera_metadata", + "payload_url", + "expires_in_s" + ], + "title": "PhotoMetadataResponse" + }, "PhotoResponse": { "properties": { "project_name": { @@ -6754,6 +7170,18 @@ "title": "Project", "description": "Represents a scan project stored on disk and optionally processed in the cloud." }, + "ProjectCreateRequest": { + "properties": { + "project_description": { + "type": "string", + "title": "Project Description", + "default": "" + } + }, + "type": "object", + "title": "ProjectCreateRequest", + "description": "JSON payload for creating a project." + }, "Scan": { "properties": { "project_name": { @@ -6887,6 +7315,84 @@ "title": "Scan", "description": "Represents a single scan session within a project." }, + "ScanCreateRequest": { + "properties": { + "camera_name": { + "type": "string", + "title": "Camera Name" + }, + "scan_settings": { + "$ref": "#/components/schemas/ScanSetting" + }, + "scan_description": { + "type": "string", + "title": "Scan Description", + "default": "" + } + }, + "type": "object", + "required": [ + "camera_name", + "scan_settings" + ], + "title": "ScanCreateRequest", + "description": "JSON payload for adding and starting a scan." + }, + "ScanMetadata": { + "properties": { + "step": { + "type": "integer", + "title": "Step", + "description": "The sequential index of the photo within the scan." + }, + "polar_coordinates": { + "$ref": "#/components/schemas/PolarPoint3D", + "description": "The polar coordinates of the camera when the photo was taken." + }, + "project_name": { + "type": "string", + "title": "Project Name", + "description": "The name of the project this scan belongs to." + }, + "scan_index": { + "type": "integer", + "title": "Scan Index", + "description": "The sequential index of the scan within the project." + }, + "stack_index": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Stack Index", + "description": "The sequential index of the photo within the focus stack." + }, + "cart_coordinates": { + "anyOf": [ + { + "$ref": "#/components/schemas/CartesianPoint3D" + }, + { + "type": "null" + } + ], + "description": "Cartesian coordinates, derived from polar_coordinates." + } + }, + "type": "object", + "required": [ + "step", + "polar_coordinates", + "project_name", + "scan_index" + ], + "title": "ScanMetadata", + "description": "Represents metadata from a scan for a photo." + }, "ScanSetting": { "properties": { "path_method": { @@ -7258,6 +7764,29 @@ "type": "object", "title": "StackingTaskStatus" }, + "SystemUpdateSummary": { + "properties": { + "updates_available": { + "type": "boolean", + "title": "Updates Available" + }, + "count": { + "type": "integer", + "title": "Count" + }, + "reboot_required_after_install": { + "type": "boolean", + "title": "Reboot Required After Install" + } + }, + "type": "object", + "required": [ + "updates_available", + "count", + "reboot_required_after_install" + ], + "title": "SystemUpdateSummary" + }, "Task": { "properties": { "id": { @@ -7548,6 +8077,92 @@ ], "title": "TriggerStatusResponse" }, + "UpdateInstallResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "installing", + "completed", + "install_failed", + "install_blocked" + ], + "title": "Status" + }, + "reboot_required": { + "type": "boolean", + "title": "Reboot Required" + } + }, + "type": "object", + "required": [ + "status", + "reboot_required" + ], + "title": "UpdateInstallResponse", + "description": "Result of a synchronous user-requested update installation." + }, + "UpdateStatusResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "unknown", + "up_to_date", + "updates_available", + "status_unavailable", + "check_failed" + ], + "title": "Status" + }, + "checked_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Checked At" + }, + "stale": { + "type": "boolean", + "title": "Stale" + }, + "release_channel": { + "type": "string", + "enum": [ + "stable", + "nightly", + "unknown" + ], + "title": "Release Channel" + }, + "openscan": { + "$ref": "#/components/schemas/OpenScanUpdateSummary" + }, + "system": { + "$ref": "#/components/schemas/SystemUpdateSummary" + }, + "reboot_required": { + "type": "boolean", + "title": "Reboot Required" + } + }, + "type": "object", + "required": [ + "status", + "checked_at", + "stale", + "release_channel", + "openscan", + "system", + "reboot_required" + ], + "title": "UpdateStatusResponse", + "description": "Cached, user-facing software update status." + }, "ValidationError": { "properties": { "loc": { @@ -7583,4 +8198,4 @@ } } } -} \ No newline at end of file +} diff --git a/scripts/openapi/openapi_v0.8.json b/scripts/openapi/openapi_v0.8.json index 8afa6ee..bae2118 100644 --- a/scripts/openapi/openapi_v0.8.json +++ b/scripts/openapi/openapi_v0.8.json @@ -3493,7 +3493,7 @@ "develop" ], "summary": "Hello World Async", - "description": "Start the async hello world demo task.", + "description": "Start the async hello world progress demo task.", "operationId": "hello_world_async", "parameters": [ { diff --git a/scripts/openapi/openapi_v0.9.json b/scripts/openapi/openapi_v0.9.json index 57e1166..93f4c11 100644 --- a/scripts/openapi/openapi_v0.9.json +++ b/scripts/openapi/openapi_v0.9.json @@ -1540,6 +1540,100 @@ } } }, + "/system/update/status": { + "get": { + "tags": [ + "system update" + ], + "summary": "Get Update Status", + "operationId": "get_update_status", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/update/check": { + "post": { + "tags": [ + "system update" + ], + "summary": "Check For Updates", + "operationId": "check_for_updates", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatusResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/update/apply": { + "post": { + "tags": [ + "system update" + ], + "summary": "Apply Updates", + "operationId": "apply_updates", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateInstallResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/system/repair/openscan3": { + "post": { + "tags": [ + "system repair" + ], + "summary": "Repair Openscan3", + "operationId": "repair_openscan3", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, "/projects/": { "get": { "tags": [ @@ -3723,6 +3817,18 @@ "default": "json", "title": "Format" } + }, + { + "name": "release_cameras", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Temporarily release OpenScan camera controllers so rpicam/libcamera probes can acquire cameras.", + "default": true, + "title": "Release Cameras" + }, + "description": "Temporarily release OpenScan camera controllers so rpicam/libcamera probes can acquire cameras." } ], "responses": { @@ -3813,7 +3919,7 @@ "develop" ], "summary": "Hello World Async", - "description": "Start the async hello world demo task.", + "description": "Start the async hello world progress demo task.", "operationId": "hello_world_async", "parameters": [ { @@ -5703,6 +5809,77 @@ ], "title": "MotorStatusResponse" }, + "OpenScanUpdatePackage": { + "properties": { + "id": { + "type": "string", + "enum": [ + "firmware", + "client", + "updater", + "system_config", + "camera_stack" + ], + "title": "Id" + }, + "installed_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Installed Version" + }, + "available_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Available Version" + }, + "update_available": { + "type": "boolean", + "title": "Update Available" + } + }, + "type": "object", + "required": [ + "id", + "installed_version", + "available_version", + "update_available" + ], + "title": "OpenScanUpdatePackage", + "description": "One optional OpenScan component update for the details view." + }, + "OpenScanUpdateSummary": { + "properties": { + "updates_available": { + "type": "boolean", + "title": "Updates Available" + }, + "packages": { + "items": { + "$ref": "#/components/schemas/OpenScanUpdatePackage" + }, + "type": "array", + "title": "Packages" + } + }, + "type": "object", + "required": [ + "updates_available", + "packages" + ], + "title": "OpenScanUpdateSummary" + }, "PathMethod": { "type": "string", "enum": [ @@ -6400,6 +6577,29 @@ "type": "object", "title": "StackingTaskStatus" }, + "SystemUpdateSummary": { + "properties": { + "updates_available": { + "type": "boolean", + "title": "Updates Available" + }, + "count": { + "type": "integer", + "title": "Count" + }, + "reboot_required_after_install": { + "type": "boolean", + "title": "Reboot Required After Install" + } + }, + "type": "object", + "required": [ + "updates_available", + "count", + "reboot_required_after_install" + ], + "title": "SystemUpdateSummary" + }, "Task": { "properties": { "id": { @@ -6583,6 +6783,92 @@ ], "title": "TriggerConfig" }, + "UpdateInstallResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "installing", + "completed", + "install_failed", + "install_blocked" + ], + "title": "Status" + }, + "reboot_required": { + "type": "boolean", + "title": "Reboot Required" + } + }, + "type": "object", + "required": [ + "status", + "reboot_required" + ], + "title": "UpdateInstallResponse", + "description": "Result of a synchronous user-requested update installation." + }, + "UpdateStatusResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "unknown", + "up_to_date", + "updates_available", + "status_unavailable", + "check_failed" + ], + "title": "Status" + }, + "checked_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Checked At" + }, + "stale": { + "type": "boolean", + "title": "Stale" + }, + "release_channel": { + "type": "string", + "enum": [ + "stable", + "nightly", + "unknown" + ], + "title": "Release Channel" + }, + "openscan": { + "$ref": "#/components/schemas/OpenScanUpdateSummary" + }, + "system": { + "$ref": "#/components/schemas/SystemUpdateSummary" + }, + "reboot_required": { + "type": "boolean", + "title": "Reboot Required" + } + }, + "type": "object", + "required": [ + "status", + "checked_at", + "stale", + "release_channel", + "openscan", + "system", + "reboot_required" + ], + "title": "UpdateStatusResponse", + "description": "Cached, user-facing software update status." + }, "ValidationError": { "properties": { "loc": { @@ -6618,4 +6904,4 @@ } } } -} \ No newline at end of file +} diff --git a/scripts/write-release-metadata.py b/scripts/write-release-metadata.py new file mode 100755 index 0000000..8ce2889 --- /dev/null +++ b/scripts/write-release-metadata.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Validate and write the release identity embedded in the Debian package.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +VERSION_RE = re.compile(r"^\d+(?:\.\d+)+$") +TIMESTAMP_RE = re.compile(r"^\d{14}$") +REVISION_RE = re.compile(r"^[0-9a-f]+$") + + +def validate_versions( + *, + channel: str, + debian_version: str, + python_version: str, + build_timestamp: str, + source_revision: str, +) -> None: + if channel == "stable": + if not VERSION_RE.fullmatch(debian_version) or python_version != debian_version: + raise ValueError("stable Debian and Python versions must be the same numeric dotted version") + return + + if not TIMESTAMP_RE.fullmatch(build_timestamp): + raise ValueError("nightly build timestamp must contain exactly 14 UTC digits") + if not REVISION_RE.fullmatch(source_revision): + raise ValueError("nightly source revision must be a lowercase hexadecimal Git revision") + + debian_match = re.fullmatch( + rf"(\d+(?:\.\d+)+)~nightly\.{build_timestamp}\.g{source_revision}", + debian_version, + ) + if not debian_match: + raise ValueError("Debian version does not match the declared nightly build identity") + expected_python = f"{debian_match.group(1)}.dev{build_timestamp}+g{source_revision}" + if python_version != expected_python: + raise ValueError("Python version does not match the declared nightly build identity") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--channel", choices=("nightly", "stable"), required=True) + parser.add_argument("--debian-version", required=True) + parser.add_argument("--python-version", required=True) + parser.add_argument("--build-timestamp", default="") + parser.add_argument("--source-revision", default="") + parser.add_argument("--expected-debian-version", default="") + parser.add_argument("--expected-python-version", default="") + args = parser.parse_args() + + if args.expected_debian_version and args.debian_version != args.expected_debian_version: + parser.error("Debian changelog version differs from the orchestrator release identity") + if args.expected_python_version and args.python_version != args.expected_python_version: + parser.error("pyproject version differs from the orchestrator release identity") + + try: + validate_versions( + channel=args.channel, + debian_version=args.debian_version, + python_version=args.python_version, + build_timestamp=args.build_timestamp, + source_revision=args.source_revision, + ) + except ValueError as error: + parser.error(str(error)) + + metadata = { + "schema": 1, + "channel": args.channel, + "debian_version": args.debian_version, + "python_version": args.python_version, + "build_timestamp": args.build_timestamp, + "source_revision": args.source_revision, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/settings/firmware/firmware_settings.json b/settings/firmware/firmware_settings.json index cbdb120..32274de 100644 --- a/settings/firmware/firmware_settings.json +++ b/settings/firmware/firmware_settings.json @@ -1,4 +1,5 @@ { "qr_wifi_scan_enabled": true, - "enable_cloud": false + "enable_cloud": false, + "camera_preview_enabled": true } diff --git a/tests/conftest.py b/tests/conftest.py index 763984e..7e5faf5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -240,10 +240,9 @@ async def focus_task_manager(): from openscan_firmware.controllers.services.tasks.examples import demo_examples - task_manager.register_task("hello_world_async_task", demo_examples.HelloWorldAsyncTask) + task_manager.register_task("hello_world_progress_task", demo_examples.HelloWorldProgressTask) task_manager.register_task("hello_world_blocking_task", demo_examples.HelloWorldBlockingTask) task_manager.register_task("exclusive_demo_task", demo_examples.ExclusiveDemoTask) - task_manager.register_task("generator_task", demo_examples.ExampleTaskWithGenerator) task_manager.register_task("failing_task", demo_examples.FailingTask) yield task_manager diff --git a/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py b/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py index 8bbdfa6..68aca93 100644 --- a/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py +++ b/tests/controllers/hardware/picamera2/test_picamera2_focus_unit.py @@ -99,3 +99,43 @@ def test_configure_focus_sets_default_manual_focus(monkeypatch): assert controller._picam.controls == [ {"AfMode": module.controls.AfModeEnum.Manual, "LensPosition": 1.0} ] + + +def test_configure_cropping_preserves_noise_reduction_controls(monkeypatch): + module = _import_picamera2_module(monkeypatch) + + class _FakeStrategy: + def __init__(self): + self.photo_controls = None + self.raw_controls = None + + def create_photo_config(self, _picam, _resolution, controls): + self.photo_controls = controls + return {"photo": controls} + + def create_raw_config(self, _picam, _resolution, controls): + self.raw_controls = controls + return {"raw": controls} + + strategy = _FakeStrategy() + controller = object.__new__(module.Picamera2Controller) + controller.settings = CameraSettings(crop_width=10, crop_height=20, orientation_flag=1) + controller.camera = types.SimpleNamespace(settings=controller.settings) + controller._picam = _FakePicam() + controller._strategy = strategy + controller._photogrammetry_settings = { + "AeEnable": False, + "NoiseReductionMode": 0, + "AwbEnable": False, + } + + crop = controller._configure_cropping_for_scalercrop() + + assert crop == (10, 10, 180, 80) + assert strategy.photo_controls == { + "AeEnable": False, + "NoiseReductionMode": 0, + "AwbEnable": False, + "ScalerCrop": crop, + } + assert strategy.raw_controls == strategy.photo_controls diff --git a/tests/controllers/services/test_scan_task.py b/tests/controllers/services/test_scan_task.py index ed02196..10a0faf 100644 --- a/tests/controllers/services/test_scan_task.py +++ b/tests/controllers/services/test_scan_task.py @@ -65,10 +65,9 @@ async def task_manager_fixture() -> TaskManager: from openscan_firmware.controllers.services.tasks.examples import demo_examples - tm.register_task("hello_world_async_task", demo_examples.HelloWorldAsyncTask) + tm.register_task("hello_world_progress_task", demo_examples.HelloWorldProgressTask) tm.register_task("hello_world_blocking_task", demo_examples.HelloWorldBlockingTask) tm.register_task("exclusive_demo_task", demo_examples.ExclusiveDemoTask) - tm.register_task("generator_task", demo_examples.ExampleTaskWithGenerator) tm.register_task("failing_task", demo_examples.FailingTask) yield tm @@ -181,7 +180,7 @@ async def delayed_add_photo(*args, **kwargs): assert final_task_model.progress.total == total_expected_steps # 3. Check calls - assert mock_camera_controller.photo.call_count == total_expected_steps + assert mock_camera_controller.photo_async.await_count == total_expected_steps assert mock_project_manager.add_photo_async.call_count == total_expected_steps # +1 for the final cleanup move assert mock_motors.move_to_point.call_count == total_expected_steps + 1 @@ -734,7 +733,7 @@ async def test_focus_stacking_sets_manual_focus_per_stack( @pytest.mark.asyncio - async def test_single_capture_uses_configured_image_format( + async def test_single_capture_uses_async_camera_with_configured_image_format( self, sample_scan_model: Scan, fake_photo_data: PhotoData, @@ -748,8 +747,8 @@ async def test_single_capture_uses_configured_image_format( photo_payload.format = "dng" camera_controller = MagicMock() - camera_controller.photo = MagicMock(return_value=photo_payload) - camera_controller.photo_async = AsyncMock() + camera_controller.photo = MagicMock() + camera_controller.photo_async = AsyncMock(return_value=photo_payload) camera_controller.settings = MagicMock() project_manager = MagicMock() @@ -768,8 +767,8 @@ async def test_single_capture_uses_configured_image_format( 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",) + camera_controller.photo.assert_not_called() + camera_controller.photo_async.assert_awaited_once_with("dng") @pytest.mark.asyncio diff --git a/tests/controllers/services/test_task_autodiscovery.py b/tests/controllers/services/test_task_autodiscovery.py index a314eb5..250f278 100644 --- a/tests/controllers/services/test_task_autodiscovery.py +++ b/tests/controllers/services/test_task_autodiscovery.py @@ -2,12 +2,19 @@ import shutil import pytest +from openscan_firmware.controllers.services.tasks.core.registry import BUILTIN_TASKS +from openscan_firmware.controllers.services.tasks import ( + task_manager as task_manager_module, +) from openscan_firmware.controllers.services.tasks.task_manager import TaskManager +BUILTIN_TASK_NAMES = {task_class.task_name for task_class in BUILTIN_TASKS} + + @pytest.mark.asyncio async def test_autodiscover_registers_core_tasks(): - """Autodiscovery should register at least the required core tasks. + """Autodiscovery should register every built-in task. We only assert the presence of core tasks here to avoid coupling to demo examples. """ @@ -27,19 +34,14 @@ async def test_autodiscover_registers_core_tasks(): override_on_conflict=False, ) - required_core = { - "scan_task", - "focus_stacking_task", - "cloud_upload_task", - "cloud_download_task", - } + builtin_names = BUILTIN_TASK_NAMES # Core tasks must be present - for task_name in required_core: + for task_name in builtin_names: assert task_name in tm._task_registry # The method should return the list of newly registered tasks - for task_name in required_core: + for task_name in builtin_names: assert task_name in registered @@ -63,15 +65,10 @@ async def test_autodiscover_safe_mode_handles_import_errors(): override_on_conflict=False, ) - required_core = { - "scan_task", - "focus_stacking_task", - "cloud_upload_task", - "cloud_download_task", - } + builtin_names = BUILTIN_TASK_NAMES # Core tasks should still be discovered - for task_name in required_core: + for task_name in builtin_names: assert task_name in tm._task_registry @@ -88,7 +85,7 @@ async def test_autodiscover_ignore_examples_package(): ) # Demo/example tasks should not be present (including crop_task, now an example) - assert "hello_world_async_task" not in tm._task_registry + assert "hello_world_progress_task" not in tm._task_registry assert "hello_world_blocking_task" not in tm._task_registry assert "exclusive_demo_task" not in tm._task_registry assert "crop_task" not in tm._task_registry @@ -104,14 +101,9 @@ async def test_autodiscover_defaults_register_core_tasks(): registered = tm.autodiscover_tasks() - required_core = { - "scan_task", - "focus_stacking_task", - "cloud_upload_task", - "cloud_download_task", - } + builtin_names = BUILTIN_TASK_NAMES - for task_name in required_core: + for task_name in builtin_names: assert task_name in tm._task_registry assert task_name in registered @@ -143,3 +135,55 @@ async def run(self): # Registry should still point to the original dummy task assert tm._task_registry["scan_task"] is original_cls + + +def test_external_task_overrides_builtin_when_enabled(tmp_path, monkeypatch): + override_file = tmp_path / "scan_override.py" + override_file.write_text( + "\n".join( + [ + "from openscan_firmware.controllers.services.tasks.base_task import BaseTask", + "", + "class ScanOverrideTask(BaseTask):", + ' task_name = "scan_task"', + ' task_category = "community"', + "", + " async def run(self):", + ' return "external override"', + ] + ) + ) + monkeypatch.setattr( + task_manager_module, + "resolve_community_tasks_dir", + lambda: tmp_path, + ) + + TaskManager._instance = None + tm = TaskManager() + tm.initialize_core_tasks( + autodiscovery_enabled=True, + override_on_conflict=True, + ) + + registered_class = tm._task_registry["scan_task"] + assert registered_class.__name__ == "ScanOverrideTask" + assert registered_class.__module__ == "openscan_external_tasks.scan_override" + + +def test_builtin_registry_has_unique_explicit_names(): + names = [task_class.task_name for task_class in BUILTIN_TASKS] + + assert all(names) + assert len(names) == len(set(names)) + + +def test_initialize_core_tasks_uses_builtin_registry_without_autodiscovery(): + TaskManager._instance = None + tm = TaskManager() + + tm.initialize_core_tasks(autodiscovery_enabled=False) + + assert tm._task_registry == { + task_class.task_name: task_class for task_class in BUILTIN_TASKS + } diff --git a/tests/controllers/services/test_task_manager.py b/tests/controllers/services/test_task_manager.py index b9eabc5..ccdfa5d 100644 --- a/tests/controllers/services/test_task_manager.py +++ b/tests/controllers/services/test_task_manager.py @@ -7,12 +7,36 @@ import pytest_asyncio import openscan_firmware.controllers.services.tasks.task_manager as task_manager_module +from openscan_firmware.controllers.services.tasks.base_task import BaseTask from openscan_firmware.controllers.services.tasks.task_manager import TaskManager TASKS_STORAGE_PATH = task_manager_module.TASKS_STORAGE_PATH from openscan_firmware.models.task import TaskStatus, Task, TaskProgress +class ControlledAsyncTask(BaseTask): + """Test-only async task that remains running until an external event is set.""" + + task_name = "controlled_async_task" + task_category = "test" + is_exclusive = False + is_blocking = False + + async def run(self, completion_event: asyncio.Event): + self._task_model.progress = TaskProgress( + current=0, + total=1, + message="Waiting for completion event.", + ) + + await completion_event.wait() + + final_message = "Controlled async task complete." + self._task_model.progress = TaskProgress(current=1, total=1, message=final_message) + self._task_model.result = final_message + return final_message + + @pytest.fixture def tasks_storage_dir(task_manager_storage_path): """Synchronize module-level TASKS_STORAGE_PATH with the isolated test directory.""" @@ -55,11 +79,11 @@ async def task_manager_fixture(tasks_storage_dir): # Register example/demo tasks explicitly (they are ignored by default autodiscovery) from openscan_firmware.controllers.services.tasks.examples import demo_examples - tm.register_task("hello_world_async_task", demo_examples.HelloWorldAsyncTask) + tm.register_task("hello_world_progress_task", demo_examples.HelloWorldProgressTask) tm.register_task("hello_world_blocking_task", demo_examples.HelloWorldBlockingTask) tm.register_task("exclusive_demo_task", demo_examples.ExclusiveDemoTask) - tm.register_task("generator_task", demo_examples.ExampleTaskWithGenerator) tm.register_task("failing_task", demo_examples.FailingTask) + tm.register_task("controlled_async_task", ControlledAsyncTask) yield tm # Provide the cleaned-up instance to the test @@ -123,14 +147,14 @@ async def test_create_and_run_task(task_manager_fixture: TaskManager): Tests that a simple async task can be created and run successfully. """ tm = task_manager_fixture - task = await tm.create_and_run_task("hello_world_async_task", total_steps=2) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=2) assert task is not None - assert task.name == "hello_world_async_task" + assert task.name == "hello_world_progress_task" final_task_state = await tm.wait_for_task(task.id) assert final_task_state.status == TaskStatus.COMPLETED - assert "Completed 2 steps" in final_task_state.result + assert "completed after 2 steps" in final_task_state.result async def test_non_exclusive_task_concurrency_limit(task_manager_fixture: TaskManager): @@ -144,7 +168,7 @@ async def test_non_exclusive_task_concurrency_limit(task_manager_fixture: TaskMa tasks = [] for _ in range(task_count): # Use a long-running task to ensure they don't finish too quickly - task = await tm.create_and_run_task("hello_world_async_task", total_steps=10) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=10) tasks.append(task) await asyncio.sleep(0.1) # Allow time for tasks to be processed and statuses updated @@ -177,7 +201,7 @@ async def test_exclusive_task_blocks_others(task_manager_fixture: TaskManager): await asyncio.sleep(0.1) # Give it time to start and occupy the runner # Try to start a non-exclusive task while the exclusive one is running - non_exclusive_task = await tm.create_and_run_task("hello_world_async_task", total_steps=1) + non_exclusive_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=1) await asyncio.sleep(0.1) # Give the manager time to process the new task # The exclusive task should be running, the new one should be pending @@ -202,7 +226,7 @@ async def test_exclusive_task_waits_for_others(task_manager_fixture: TaskManager tm = task_manager_fixture # Start a non-exclusive task - non_exclusive_task = await tm.create_and_run_task("hello_world_async_task", total_steps=2) + non_exclusive_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=2) await asyncio.sleep(0.1) # Give it time to start # Try to start an exclusive task @@ -259,7 +283,7 @@ def print_state(tag: str): # The limit is 3, so we start 3 tasks to fill the slots. # Use a task that takes a bit of time. running_tasks = [ - await tm.create_and_run_task("hello_world_async_task", total_steps=3) for _ in range(3) + await tm.create_and_run_task("hello_world_progress_task", total_steps=3) for _ in range(3) ] await asyncio.sleep(0.1) # Let them start @@ -270,7 +294,7 @@ def print_state(tag: str): # Queue another non-exclusive task. Because an exclusive task is pending, # this one should also be PENDING, not RUNNING. - another_non_exclusive_task = await tm.create_and_run_task("hello_world_async_task", total_steps=1) + another_non_exclusive_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=1) await asyncio.sleep(0.1) assert tm.get_task_info(another_non_exclusive_task.id).status == TaskStatus.PENDING @@ -299,7 +323,7 @@ async def test_pause_and_resume_task(task_manager_fixture: TaskManager): step_interval = 0.2 # Create a task that runs for a predictable amount of time - task = await tm.create_and_run_task("generator_task", total_steps=total_steps, interval=step_interval) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=total_steps, interval=step_interval) # Let the task run for a bit await asyncio.sleep(step_interval * 1.5) @@ -330,7 +354,7 @@ async def test_streaming_task_progress(task_manager_fixture: TaskManager): """ tm = task_manager_fixture total_steps = 5 - task = await tm.create_and_run_task("generator_task", total_steps=total_steps, interval=0.1) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=total_steps, interval=0.1) # Allow the task a moment to initialize and set its total. await asyncio.sleep(0.01) @@ -351,7 +375,7 @@ async def test_streaming_task_cancel_and_restart(task_manager_fixture: TaskManag """ tm = task_manager_fixture total_steps = 10 - task = await tm.create_and_run_task("generator_task", total_steps=total_steps, interval=0.1) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=total_steps, interval=0.1) # Let it run halfway await asyncio.sleep(total_steps * 0.1 / 2) @@ -394,7 +418,7 @@ async def test_blocking_task_does_not_block_event_loop(task_manager_fixture: Tas await asyncio.sleep(0.01) # Immediately start another quick, non-blocking task - non_blocking_task = await tm.create_and_run_task("hello_world_async_task", total_steps=1, interval=0.01) + non_blocking_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=1, interval=0.01) # The non-blocking task should complete very quickly, long before the blocking one await tm.wait_for_task(non_blocking_task.id, timeout=0.2) @@ -434,7 +458,7 @@ async def test_cancel_running_task(task_manager_fixture: TaskManager): Tests that a running task can be cancelled. """ tm = task_manager_fixture - task = await tm.create_and_run_task("hello_world_async_task", total_steps=10) # Long running + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=10) # Long running await asyncio.sleep(0.5) # Let it start assert tm.get_task_info(task.id).status == TaskStatus.RUNNING @@ -455,7 +479,7 @@ async def test_cancel_pending_task(task_manager_fixture: TaskManager): exclusive_task = await tm.create_and_run_task("exclusive_demo_task", duration=3) # Create a new task that will be pending - pending_task = await tm.create_and_run_task("hello_world_async_task", total_steps=1) + pending_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=1) await asyncio.sleep(0.1) # Let the queue process assert tm.get_task_info(pending_task.id).status == TaskStatus.PENDING @@ -470,7 +494,7 @@ async def test_cancel_pending_task(task_manager_fixture: TaskManager): 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") + task_model = Task(name="hello_world_progress_task", task_type="hello_world_progress_task") persisted_currents = [] fake_clock = {"now": 0.0} @@ -516,7 +540,7 @@ async def test_task_state_is_persisted_across_lifecycle(task_manager_fixture: Ta Tests that a task's state is correctly saved to a JSON file at each lifecycle stage. """ tm = task_manager_fixture - task = await tm.create_and_run_task("generator_task", total_steps=4, interval=0.2) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=4, interval=0.2) task_file_path = TASKS_STORAGE_PATH / f"{task.id}.json" # 1. Wait for the status to become 'running' in the persisted file. @@ -557,9 +581,9 @@ async def test_tasks_are_reloaded_on_startup(task_manager_fixture: TaskManager): # --- Simulate a previous application run --- # Manually create task models for REAL task types and save them to disk. - completed_task = Task(name="completed_task", task_type="hello_world_async_task", status=TaskStatus.COMPLETED) - running_task = Task(name="running_task", task_type="hello_world_async_task", status=TaskStatus.RUNNING) - paused_task = Task(name="paused_task", task_type="generator_task", status=TaskStatus.PAUSED) + completed_task = Task(name="completed_task", task_type="hello_world_progress_task", status=TaskStatus.COMPLETED) + running_task = Task(name="running_task", task_type="hello_world_progress_task", status=TaskStatus.RUNNING) + paused_task = Task(name="paused_task", task_type="hello_world_progress_task", status=TaskStatus.PAUSED) with open(TASKS_STORAGE_PATH / f"{completed_task.id}.json", 'w') as f: f.write(completed_task.model_dump_json()) @@ -595,7 +619,7 @@ async def test_tasks_are_reloaded_on_startup(task_manager_fixture: TaskManager): async def test_cancelled_task_state_is_persisted(task_manager_fixture: TaskManager): """Tests that a cancelled task's final state is saved to its JSON file.""" tm = task_manager_fixture - task = await tm.create_and_run_task("generator_task", total_steps=10, interval=0.1) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=10, interval=0.1) task_file_path = TASKS_STORAGE_PATH / f"{task.id}.json" await asyncio.sleep(0.3) # Let it run a bit @@ -639,7 +663,7 @@ async def test_startup_with_corrupt_task_files(task_manager_fixture: TaskManager # 1. Create a valid task file that is in a terminal but not 'COMPLETED' state. # This ensures it won't be cleaned up on restart. - valid_task_to_preserve = await tm.create_and_run_task("hello_world_async_task") + valid_task_to_preserve = await tm.create_and_run_task("hello_world_progress_task") await tm.cancel_task(valid_task_to_preserve.id) await wait_for_task_completion(tm, valid_task_to_preserve.id) # Wait for cancellation to finish assert tm.get_task_info(valid_task_to_preserve.id).status == TaskStatus.CANCELLED @@ -678,19 +702,19 @@ async def test_blocking_tasks_ignore_concurrency_limit(task_manager_fixture: Tas # Temporarily lower the limit for this specific test case tm.max_concurrent_non_exclusive_tasks = 2 - # Use an event to control when the async tasks finish + # Use a test-only task to control when the async slots are released. async_task_can_finish_event = asyncio.Event() # 1. Start two async tasks that will wait for our event. This fills up the concurrency slots. - async_task_1 = await tm.create_and_run_task("hello_world_async_task", wait_for_event=async_task_can_finish_event) - async_task_2 = await tm.create_and_run_task("hello_world_async_task", wait_for_event=async_task_can_finish_event) + async_task_1 = await tm.create_and_run_task("controlled_async_task", completion_event=async_task_can_finish_event) + async_task_2 = await tm.create_and_run_task("controlled_async_task", completion_event=async_task_can_finish_event) await asyncio.sleep(0.05) # Give scheduler time to start them assert tm.get_task_info(async_task_1.id).status == TaskStatus.RUNNING assert tm.get_task_info(async_task_2.id).status == TaskStatus.RUNNING # 2. Start a third async task, which should be queued because the slots are full. - async_task_3_queued = await tm.create_and_run_task("hello_world_async_task", delay=0.1) + async_task_3_queued = await tm.create_and_run_task("hello_world_progress_task", interval=0.1) await asyncio.sleep(0.05) # Give scheduler time to process assert tm.get_task_info(async_task_3_queued.id).status == TaskStatus.PENDING @@ -721,7 +745,7 @@ async def test_restart_interrupted_task_after_shutdown(task_manager_fixture: Tas total_steps = 10 # 1. Start a task that will be 'interrupted' - task = await tm.create_and_run_task("generator_task", total_steps=total_steps, interval=0.1) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=total_steps, interval=0.1) await asyncio.sleep(total_steps * 0.1 / 2) # Let it run halfway task_info = tm.get_task_info(task.id) @@ -765,13 +789,13 @@ async def test_cancel_pending_task_in_full_queue(task_manager_fixture: TaskManag # 1. Fill the concurrent task slots running_tasks = [] for _ in range(concurrency_limit): - task = await tm.create_and_run_task("hello_world_async_task", total_steps=5) + task = await tm.create_and_run_task("hello_world_progress_task", total_steps=5) running_tasks.append(task) await asyncio.sleep(0.1) # Allow tasks to start running # 2. Create one more task, which should be PENDING - pending_task = await tm.create_and_run_task("hello_world_async_task", total_steps=1) + pending_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=1) await asyncio.sleep(0.1) # Allow scheduler to process assert tm.get_task_info(pending_task.id).status == TaskStatus.PENDING @@ -805,11 +829,11 @@ async def test_exclusive_tasks_respect_fifo_order(task_manager_fixture: TaskMana tm = task_manager_fixture completion_log = [] - # 1. Start a controllable task to block the queue + # 1. Start a controllable test task to block the queue blocker_event = asyncio.Event() blocker_task = await tm.create_and_run_task( - "hello_world_async_task", - wait_for_event=blocker_event, + "controlled_async_task", + completion_event=blocker_event, ) for _ in range(50): @@ -865,7 +889,7 @@ async def test_delete_task_functionality(task_manager_fixture: TaskManager): tm = task_manager_fixture # 1. Create a task and cancel it to get it into a terminal state - cancelled_task = await tm.create_and_run_task("hello_world_async_task", total_steps=10) + cancelled_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=10) await asyncio.sleep(0.1) await tm.cancel_task(cancelled_task.id) await asyncio.sleep(0.1) @@ -882,7 +906,7 @@ async def test_delete_task_functionality(task_manager_fixture: TaskManager): assert not os.path.exists(task_file_path) # 4. Create a running task and verify it cannot be deleted - running_task = await tm.create_and_run_task("hello_world_async_task", total_steps=10) + running_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=10) await asyncio.sleep(0.1) assert tm.get_task_info(running_task.id).status == TaskStatus.RUNNING with pytest.raises(ValueError, match="Cannot delete task"): @@ -900,8 +924,8 @@ async def test_auto_cleanup_of_completed_tasks_on_startup(task_manager_fixture: tm = task_manager_fixture # 1. Create one task that will complete and one that will be cancelled - completed_task = await tm.create_and_run_task("hello_world_async_task", total_steps=1) - cancelled_task = await tm.create_and_run_task("hello_world_async_task", total_steps=5) + completed_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=1) + cancelled_task = await tm.create_and_run_task("hello_world_progress_task", total_steps=5) await tm.wait_for_task(completed_task.id) await tm.cancel_task(cancelled_task.id) diff --git a/tests/controllers/test_device_controller.py b/tests/controllers/test_device_controller.py index 33b20f6..6a15f15 100644 --- a/tests/controllers/test_device_controller.py +++ b/tests/controllers/test_device_controller.py @@ -75,6 +75,39 @@ def device_module(monkeypatch): return _import_device(monkeypatch) +def test_detect_cameras_skips_pi_internal_v4l2_pipeline_devices(monkeypatch): + device = _import_device(monkeypatch) + + class DummyVideoDevice: + def __init__(self, card, filename): + self.info = types.SimpleNamespace(card=card) + self.filename = filename + self.closed = False + + def open(self): + pass + + def close(self): + self.closed = True + + devices = [ + DummyVideoDevice("rp1-cfe", "/dev/video0"), + DummyVideoDevice("pispbe", "/dev/video20"), + DummyVideoDevice("USB Camera", "/dev/video42"), + ] + + monkeypatch.setattr(device, "iter_video_capture_devices", lambda: devices, raising=True) + monkeypatch.setattr(device, "is_camera_type_available", lambda camera_type: False, raising=True) + + detected = device._detect_cameras() + + assert "rp1-cfe" not in detected + assert "pispbe" not in detected + assert detected["USB Camera"].type == device.CameraType.LINUXPY + assert detected["USB Camera"].path == "/dev/video42" + assert all(video_device.closed for video_device in devices) + + def test_save_device_config_writes_json(tmp_path, monkeypatch, motor_model_instance, light_model_instance, diff --git a/tests/packaging/test_nginx_packaging.py b/tests/packaging/test_nginx_packaging.py new file mode 100644 index 0000000..63af252 --- /dev/null +++ b/tests/packaging/test_nginx_packaging.py @@ -0,0 +1,255 @@ +import json +import subprocess +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def package_dependency_names() -> set[str]: + control = (ROOT / "debian" / "control").read_text() + package = control.split("\nPackage: openscan3-firmware\n", maxsplit=1)[1] + depends = package.split("\nDescription:", maxsplit=1)[0] + + names: set[str] = set() + for line in depends.splitlines(): + stripped = line.strip() + if not stripped or stripped in {"Architecture: any", "Depends:"}: + continue + names.add(stripped.rstrip(",").split()[0]) + return names + + +def test_firmware_does_not_install_package_owned_nginx_config() -> None: + assert not (ROOT / "debian" / "openscan3.conf").exists() + assert ( + "debian/openscan3.conf etc/nginx/sites-available/" + not in (ROOT / "debian" / "install").read_text() + ) + + +def test_firmware_does_not_depend_on_nginx_or_php_fpm() -> None: + dependencies = package_dependency_names() + + assert "nginx" not in dependencies + assert "php-fpm" not in dependencies + + +def test_postinst_does_not_manage_nginx_site() -> None: + postinst = (ROOT / "debian" / "postinst").read_text() + + assert "NGINX_SITE_AVAILABLE" not in postinst + assert "nginx -t" not in postinst + assert "systemctl reload nginx.service" not in postinst + assert "systemctl restart nginx.service" not in postinst + + +def test_firmware_service_upgrade_restart_is_stateful() -> None: + preinst = (ROOT / "debian" / "preinst").read_text() + postinst = (ROOT / "debian" / "postinst").read_text() + rules = (ROOT / "debian" / "rules").read_text() + + assert "systemctl is-active --quiet openscan3.service" in preinst + assert "openscan3.service-was-active" in preinst + assert "#DEBHELPER#" in preinst + assert "dh_installsystemd --no-start" in rules + assert "dh_installsystemd --no-enable" not in rules + assert "systemctl enable openscan3.service" not in postinst + assert "systemctl start openscan3.service || true" in postinst + assert "systemctl restart openscan3.service" in postinst + assert "systemctl try-restart openscan3.service || true" in postinst + assert 'rm -f "$SERVICE_WAS_ACTIVE_FILE"' not in preinst + + +def test_packaged_service_uses_current_release_without_reload_supervisor() -> None: + service = (ROOT / "debian" / "openscan3.service").read_text() + + assert ( + "ExecStart=/opt/openscan3/current/venv/bin/openscan-firmware serve --root-path /api" + in service + ) + assert "--reload-trigger" not in service + assert "/opt/openscan3/venv" not in service + + +def test_debian_package_bundles_default_settings_without_runtime_device_config() -> None: + rules = (ROOT / "debian" / "rules").read_text() + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text()) + packaged_device_settings = pyproject["tool"]["setuptools"]["data-files"][ + "openscan_firmware/settings/device" + ] + + assert "$(RELEASE_DIR)/default-settings/device" in rules + assert "settings/device/default_*.json" in rules + assert "settings/device/example_custom.json" in rules + assert "settings/firmware/*.json" in rules + assert "settings/logging/*.json" in rules + assert "settings/device/device_config.json" not in rules + assert "settings/device/device_config.json" not in packaged_device_settings + + +def test_postinst_seeds_default_settings_without_overwriting_runtime_files() -> None: + postinst = (ROOT / "debian" / "postinst").read_text() + + assert "python_package_version" not in postinst + assert "sed -E" not in postinst + assert "RELEASE_METADATA=\"/usr/share/openscan3-firmware/release.json\"" in postinst + assert "python_version=\"$(bundled_python_version \"$version\")\"" in postinst + assert "seed_default_settings \"$python_version\"" in postinst + assert "install_release_venv \"$python_version\"" in postinst + assert "update_current_link \"$python_version\"" in postinst + assert "default-settings" in postinst + assert "create_runtime_dir /etc/openscan3/device" in postinst + assert "create_runtime_dir /etc/openscan3/firmware" in postinst + assert "create_runtime_dir /etc/openscan3/logging" in postinst + assert "if [ -e \"$target_file\" ]; then" in postinst + assert "install -o \"$RUNTIME_USER\" -g \"$RUNTIME_GROUP\" -m 0664" in postinst + + +def test_release_metadata_writer_records_one_validated_nightly_identity(tmp_path: Path) -> None: + output = tmp_path / "release.json" + command = [ + "python3", + str(ROOT / "scripts" / "write-release-metadata.py"), + "--output", + str(output), + "--channel", + "nightly", + "--debian-version", + "0.11.11~nightly.20260722152803.g2498e42", + "--python-version", + "0.11.11.dev20260722152803+g2498e42", + "--build-timestamp", + "20260722152803", + "--source-revision", + "2498e42", + "--expected-debian-version", + "0.11.11~nightly.20260722152803.g2498e42", + "--expected-python-version", + "0.11.11.dev20260722152803+g2498e42", + ] + + subprocess.run(command, check=True) + + assert json.loads(output.read_text()) == { + "schema": 1, + "channel": "nightly", + "debian_version": "0.11.11~nightly.20260722152803.g2498e42", + "python_version": "0.11.11.dev20260722152803+g2498e42", + "build_timestamp": "20260722152803", + "source_revision": "2498e42", + } + + +def test_release_metadata_writer_rejects_mixed_nightly_identity(tmp_path: Path) -> None: + result = subprocess.run( + [ + "python3", + str(ROOT / "scripts" / "write-release-metadata.py"), + "--output", + str(tmp_path / "release.json"), + "--channel", + "nightly", + "--debian-version", + "0.11.11~nightly.20260722152803.g2498e42", + "--python-version", + "0.11.11.dev20260722152931+g2498e42", + "--build-timestamp", + "20260722152803", + "--source-revision", + "2498e42", + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "Python version does not match" in result.stderr + + +def test_postinst_reads_exact_python_version_from_release_metadata(tmp_path: Path) -> None: + metadata = tmp_path / "release.json" + metadata.write_text( + json.dumps( + { + "schema": 1, + "channel": "nightly", + "debian_version": "0.11.11~nightly.20260722152803.g2498e42", + "python_version": "0.11.11.dev20260722152803+g2498e42", + "build_timestamp": "20260722152803", + "source_revision": "2498e42", + } + ) + ) + postinst = (ROOT / "debian" / "postinst").read_text() + function_start = postinst.index("bundled_python_version()") + function_end = postinst.index("\n}\n", function_start) + len("\n}\n") + function = postinst[function_start:function_end] + + result = subprocess.run( + [ + "sh", + "-c", + f'{function}\nRELEASE_METADATA="$1" bundled_python_version "$2"', + "sh", + str(metadata), + "0.11.11~nightly.20260722152803.g2498e42", + ], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == "0.11.11.dev20260722152803+g2498e42" + + +def test_postinst_rejects_release_metadata_for_another_debian_package(tmp_path: Path) -> None: + metadata = tmp_path / "release.json" + metadata.write_text( + json.dumps( + { + "schema": 1, + "debian_version": "0.11.11~nightly.20260722152803.g2498e42", + "python_version": "0.11.11.dev20260722152803+g2498e42", + } + ) + ) + postinst = (ROOT / "debian" / "postinst").read_text() + function_start = postinst.index("bundled_python_version()") + function_end = postinst.index("\n}\n", function_start) + len("\n}\n") + function = postinst[function_start:function_end] + + result = subprocess.run( + [ + "sh", + "-c", + f'{function}\nRELEASE_METADATA="$1" bundled_python_version "$2"', + "sh", + str(metadata), + "0.11.11~nightly.20260722152931.g2498e42", + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "does not match installed Debian version" in result.stderr + + +def test_pwm_hardware_does_not_override_process_signal_handlers() -> None: + pwm_hardware = (ROOT / "openscan_firmware" / "utils" / "pwm_hardware.py").read_text() + + assert "signal.signal" not in pwm_hardware + assert "_signal_handler" not in pwm_hardware + assert "atexit.register(_HwPWM._cleanup)" in pwm_hardware + + +def test_lifespan_logs_shutdown_cleanup() -> None: + main = (ROOT / "openscan_firmware" / "main.py").read_text() + + assert "OpenScan3 service shutdown: starting hardware cleanup." in main + assert "device_controller.cleanup_and_exit()" in main + assert "OpenScan3 service shutdown: hardware cleanup completed." in main diff --git a/tests/routers/test_device_router.py b/tests/routers/test_device_router.py index 6b8333c..ae0a2bb 100644 --- a/tests/routers/test_device_router.py +++ b/tests/routers/test_device_router.py @@ -134,6 +134,18 @@ def test_get_current_config_returns_payload(monkeypatch, tmp_path, device_client assert payload["config"] == config_payload +def test_list_config_files_has_typed_openapi_response(device_client): + schema = device_client.get("/openapi.json").json() + response_schema = schema["paths"]["/latest/device/configurations"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + + assert response_schema == {"$ref": "#/components/schemas/AvailableConfigsResponse"} + assert schema["components"]["schemas"]["AvailableConfigsResponse"]["properties"]["configs"] == { + "items": {"$ref": "#/components/schemas/AvailableConfigResponse"}, + "title": "Configs", + "type": "array", + } + + def test_get_named_config_reads_disk(monkeypatch, tmp_path, device_client, device_router_path): module_path = device_router_path("device") diff --git a/tests/routers/test_next_binary_response_openapi.py b/tests/routers/test_next_binary_response_openapi.py new file mode 100644 index 0000000..ab7cedf --- /dev/null +++ b/tests/routers/test_next_binary_response_openapi.py @@ -0,0 +1,53 @@ +from openscan_firmware.main import make_version_app + + +def _response_content(schema: dict, path: str) -> dict: + return schema["paths"][path]["get"]["responses"]["200"]["content"] + + +def _assert_binary(content: dict, media_type: str) -> None: + assert content[media_type]["schema"] == {"type": "string", "format": "binary"} + + +def test_next_openapi_describes_binary_and_stream_responses() -> None: + schema = make_version_app("next").openapi() + + preview = _response_content(schema, "/cameras/{camera_name}/preview") + _assert_binary(preview, "image/jpeg") + _assert_binary(preview, "multipart/x-mixed-replace") + + photo = _response_content(schema, "/cameras/{camera_name}/photo") + _assert_binary(photo, "image/jpeg") + _assert_binary(photo, "application/x-npy") + assert photo["application/json"]["schema"]["$ref"] == "#/components/schemas/PhotoMetadataResponse" + + photo_payload = _response_content(schema, "/cameras/{camera_name}/photo/payload/{payload_id}") + _assert_binary(photo_payload, "application/octet-stream") + + thumbnail = _response_content(schema, "/projects/{project_name}/thumbnail") + _assert_binary(thumbnail, "image/jpeg") + + scan_photo = _response_content(schema, "/projects/{project_name}/{scan_index}/photo") + _assert_binary(scan_photo, "application/octet-stream") + assert scan_photo["application/json"]["schema"]["$ref"] == "#/components/schemas/PhotoResponse" + + for path in ( + "/projects/{project_name}/zip", + "/projects/{project_name}/scans/zip", + "/projects/{project_name}/model/zip", + "/logs/archive", + ): + _assert_binary(_response_content(schema, path), "application/zip") + + logs = _response_content(schema, "/logs/tail") + assert logs == { + "text/plain": {"schema": {"type": "string"}}, + "application/x-ndjson": {"schema": {"type": "string"}}, + } + + +def test_legacy_openapi_contracts_remain_unchanged() -> None: + for version in ("0.8", "0.9"): + schema = make_version_app(version).openapi() + content = _response_content(schema, "/cameras/{camera_name}/preview") + assert content == {"application/json": {"schema": {}}} diff --git a/tests/routers/test_next_develop_router.py b/tests/routers/test_next_develop_router.py index 2630cf3..4e11358 100644 --- a/tests/routers/test_next_develop_router.py +++ b/tests/routers/test_next_develop_router.py @@ -22,12 +22,13 @@ def test_camera_report_returns_json(monkeypatch, tmp_path: Path): 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 + def fake_run(cmd, capture_output, text, timeout, check, env): # noqa: ANN001 assert cmd == ["bash", str(script)] assert capture_output is True assert text is True assert timeout == 180 assert check is False + assert "OPENSCAN_REPORT_PYTHON" in env return subprocess.CompletedProcess(cmd, 0, stdout="camera report\n", stderr="") monkeypatch.setattr(develop_router.subprocess, "run", fake_run) @@ -38,13 +39,15 @@ def fake_run(cmd, capture_output, text, timeout, check): # noqa: ANN001 ) with TestClient(_create_app()) as client: - response = client.get("/next/develop/camera-report") + response = client.get("/next/develop/camera-report?release_cameras=false") assert response.status_code == 200 assert response.json() == { "ok": True, "return_code": 0, "script": str(script), + "camera_release": {"released": False, "reason": "disabled"}, + "camera_restore": {"restored": [], "errors": []}, "report": "camera report", "stderr": "", "gphoto2": {"available": True, "error": None, "detected": [], "cameras": []}, @@ -78,9 +81,10 @@ def test_camera_report_text_includes_gphoto2_section(monkeypatch, tmp_path: Path ) with TestClient(_create_app()) as client: - response = client.get("/next/develop/camera-report?format=text") + response = client.get("/next/develop/camera-report?format=text&release_cameras=false") assert response.status_code == 200 + assert "===== OpenScan camera release for external probes =====" in response.text 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_projects_request_bodies.py b/tests/routers/test_next_projects_request_bodies.py new file mode 100644 index 0000000..8fad49d --- /dev/null +++ b/tests/routers/test_next_projects_request_bodies.py @@ -0,0 +1,94 @@ +"""Request-contract tests for the next projects router.""" + +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from openscan_firmware.config.scan import ScanSetting +from openscan_firmware.main import make_version_app +from openscan_firmware.models.task import Task +from openscan_firmware.routers.next import projects as projects_router + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(projects_router.router, prefix="/next") + return TestClient(app) + + +def test_new_project_accepts_description_in_json_body(monkeypatch, project_manager) -> None: + monkeypatch.setattr(projects_router, "get_project_manager", lambda: project_manager) + + with _client() as client: + response = client.post( + "/next/projects/json-project", + json={"project_description": "Created from a JSON request body."}, + ) + + assert response.status_code == 200 + assert response.json()["description"] == "Created from a JSON request body." + + +def test_add_scan_accepts_all_input_in_json_body(monkeypatch) -> None: + project_manager = MagicMock() + scan = MagicMock() + project_manager.add_scan.return_value = scan + camera_controller = MagicMock() + started_task = Task(name="scan", task_type="core") + + monkeypatch.setattr(projects_router, "get_project_manager", lambda: project_manager) + monkeypatch.setattr(projects_router, "get_camera_controller", lambda _name: camera_controller) + monkeypatch.setattr(projects_router.scans, "start_scan", AsyncMock(return_value=started_task)) + + with _client() as client: + response = client.post( + "/next/projects/json-project/scan", + json={ + "camera_name": "cam0", + "scan_settings": ScanSetting().model_dump(mode="json"), + "scan_description": "Created from a JSON request body.", + }, + ) + + assert response.status_code == 200 + project_manager.add_scan.assert_called_once_with( + "json-project", + camera_controller, + ScanSetting(), + "Created from a JSON request body.", + ) + + +def test_next_projects_openapi_uses_json_request_bodies() -> None: + schema = make_version_app("next").openapi() + project_post = schema["paths"]["/projects/{project_name}"]["post"] + scan_post = schema["paths"]["/projects/{project_name}/scan"]["post"] + + assert [parameter["name"] for parameter in project_post["parameters"]] == ["project_name"] + assert [parameter["name"] for parameter in scan_post["parameters"]] == ["project_name"] + assert "$ref" in project_post["requestBody"]["content"]["application/json"]["schema"] + assert "$ref" in scan_post["requestBody"]["content"]["application/json"]["schema"] + assert scan_post["operationId"] == "add_scan" + + +def test_versioned_projects_openapi_contracts_remain_unchanged() -> None: + for version in ("0.8", "0.9"): + schema = make_version_app(version).openapi() + project_post = schema["paths"]["/projects/{project_name}"]["post"] + scan_post = schema["paths"]["/projects/{project_name}/scan"]["post"] + + assert [parameter["name"] for parameter in project_post["parameters"]] == [ + "project_name", + "project_description", + ] + assert "requestBody" not in project_post + assert [parameter["name"] for parameter in scan_post["parameters"]] == [ + "project_name", + "camera_name", + "scan_description", + ] + assert scan_post["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ScanSetting" + } + assert scan_post["operationId"] == "add_scan_with_description" diff --git a/tests/routers/test_system_update_router.py b/tests/routers/test_system_update_router.py new file mode 100644 index 0000000..bcf1628 --- /dev/null +++ b/tests/routers/test_system_update_router.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import asyncio +import subprocess + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from openscan_firmware.models.task import Task, TaskStatus +from openscan_firmware.routers.system_update import router +from openscan_firmware.routers.system_repair import router as repair_router +from openscan_firmware import system_update + + +@pytest.fixture +def update_client() -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/latest") + app.include_router(repair_router, prefix="/latest") + with TestClient(app) as client: + yield client + + +def _completed(argv: list[str], stdout: str, stderr: str = "", returncode: int = 0): + return subprocess.CompletedProcess(argv, returncode, stdout=stdout, stderr=stderr) + + +def test_fixed_command_map_has_no_request_controlled_package_names(): + assert system_update.UPDATER_COMMANDS == { + "status": ["sudo", "/usr/bin/openscan-updater", "status", "--json"], + "update_status": ["sudo", "/usr/bin/openscan-updater", "system", "status", "--json"], + "check": ["sudo", "/usr/bin/openscan-updater", "update", "--dry-run", "--json"], + "check_openscan": ["sudo", "/usr/bin/openscan-updater", "update", "--dry-run", "--json"], + "check_system": ["sudo", "/usr/bin/openscan-updater", "system", "check", "--json"], + "update_openscan": ["sudo", "/usr/bin/openscan-updater", "update", "--json"], + "apply_updates": ["sudo", "/usr/bin/openscan-updater", "apply", "--detach", "--json"], + "update_system": ["sudo", "/usr/bin/openscan-updater", "system", "update", "--json"], + "repair_openscan3": ["sudo", "/usr/bin/openscan-updater", "repair", "--json"], + "healthcheck": ["sudo", "/usr/bin/openscan-updater", "healthcheck", "--json"], + } + assert all("apt" not in argv for command in system_update.UPDATER_COMMANDS.values() for argv in command) + + +def test_status_endpoint_returns_compact_cached_update_status(monkeypatch, update_client): + calls = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs)) + return _completed( + argv, + '{"status":"updates_available","checked_at":"2026-07-24T09:15:00Z",' + '"stale":false,"release_channel":"nightly",' + '"openscan":{"updates_available":true,"packages":[{"id":"updater",' + '"installed_version":"0.1.8","available_version":"0.1.9","update_available":true}]},' + '"system":{"updates_available":true,"count":2,"reboot_required_after_install":false},' + '"reboot_required":false}', + ) + + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.get("/latest/system/update/status") + + assert response.status_code == 200 + assert response.json() == { + "status": "updates_available", + "checked_at": "2026-07-24T09:15:00Z", + "stale": False, + "release_channel": "nightly", + "openscan": { + "updates_available": True, + "packages": [{"id": "updater", "installed_version": "0.1.8", "available_version": "0.1.9", "update_available": True}], + }, + "system": {"updates_available": True, "count": 2, "reboot_required_after_install": False}, + "reboot_required": False, + } + assert calls[0][0] == system_update.UPDATER_COMMANDS["update_status"] + assert calls[0][1]["shell"] is False + assert calls[0][1]["env"] == system_update.UPDATER_ENV + + +def test_check_endpoint_returns_compact_refreshed_status(monkeypatch, update_client): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + return _completed(argv, '{"status":"up_to_date","checked_at":"2026-07-24T09:15:00Z","stale":false,"release_channel":"stable","openscan":{"updates_available":false,"packages":[]},"system":{"updates_available":false,"count":0,"reboot_required_after_install":false},"reboot_required":false}') + + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.post("/latest/system/update/check", json={"ignored": "input"}) + + assert response.status_code == 200 + assert response.json()["status"] == "up_to_date" + assert response.json()["release_channel"] == "stable" + assert calls == [system_update.UPDATER_COMMANDS["check_system"]] + + +def test_router_exposes_only_compact_update_actions(update_client): + assert update_client.post("/latest/system/update/openscan").status_code == 404 + assert update_client.post("/latest/system/update/healthcheck").status_code == 404 + assert update_client.get("/latest/system/update/logs").status_code == 404 + + +def test_update_openapi_exposes_response_schemas(update_client): + schema = update_client.get("/openapi.json").json() + paths = schema["paths"] + + assert paths["/latest/system/update/status"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/UpdateStatusResponse" + } + assert paths["/latest/system/update/check"]["post"]["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/UpdateStatusResponse" + } + assert paths["/latest/system/update/apply"]["post"]["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/UpdateInstallResponse" + } + + +def test_updater_command_timeout_returns_structured_error(monkeypatch, update_client): + def fake_run(argv, **kwargs): + raise subprocess.TimeoutExpired(argv, timeout=kwargs["timeout"]) + + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.get("/latest/system/update/status") + + assert response.status_code == 500 + payload = response.json() + assert payload["status"] == "status_unavailable" + assert payload["stale"] is True + + +def test_invalid_json_returns_structured_error(monkeypatch, update_client): + def fake_run(argv, **kwargs): + return _completed(argv, "not json") + + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.get("/latest/system/update/status") + + assert response.status_code == 500 + assert response.json()["status"] == "status_unavailable" + + +def test_nonzero_exit_returns_structured_error(monkeypatch, update_client): + def fake_run(argv, **kwargs): + return _completed(argv, '{"ok": false}', "apt failed", returncode=1) + + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.post("/latest/system/update/check") + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "check_failed" + assert payload["stale"] is True + + +@pytest.mark.asyncio +async def test_concurrent_update_attempts_are_rejected(monkeypatch): + started = asyncio.Event() + release = asyncio.Event() + + async def fake_run(command: str): + started.set() + await release.wait() + return 200, {"ok": True, "command": command, "result": {}} + + monkeypatch.setattr(system_update, "is_scan_active", lambda: False) + monkeypatch.setattr(system_update, "run_updater_command", fake_run) + + first = asyncio.create_task(system_update.run_update_openscan()) + await started.wait() + second_status, second_payload = await system_update.run_update_openscan() + release.set() + first_status, _ = await first + + assert first_status == 200 + assert second_status == 409 + assert second_payload["error"]["type"] == "update_active" + + +def test_apply_endpoint_schedules_independent_update_job( + monkeypatch, + update_client, +): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + return _completed(argv, '{"ok": true, "status": "update_scheduled"}') + + monkeypatch.setattr(system_update, "is_scan_active", lambda: False) + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.post("/latest/system/update/apply") + + assert response.status_code == 200 + payload = response.json() + assert payload == {"status": "installing", "reboot_required": False} + assert calls == [system_update.UPDATER_COMMANDS["apply_updates"]] + + +def test_apply_endpoint_reports_schedule_failure(monkeypatch, update_client): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + return _completed(argv, '{"ok": false, "classification": "blocked"}', returncode=2) + + monkeypatch.setattr(system_update, "is_scan_active", lambda: False) + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.post("/latest/system/update/apply") + + assert response.status_code == 200 + payload = response.json() + assert payload == {"status": "install_failed", "reboot_required": False} + assert calls == [system_update.UPDATER_COMMANDS["apply_updates"]] + + +def test_apply_endpoint_blocks_when_scan_active(monkeypatch, update_client): + monkeypatch.setattr(system_update, "is_scan_active", lambda: True) + + response = update_client.post("/latest/system/update/apply") + + assert response.status_code == 409 + assert response.json()["status"] == "install_blocked" + + +def test_is_scan_active_uses_task_manager(monkeypatch): + manager = type( + "Manager", + (), + { + "get_all_tasks_info": lambda self: [ + Task(name="scan", task_type="scan_task", status=TaskStatus.RUNNING) + ] + }, + )() + monkeypatch.setattr(system_update, "get_task_manager", lambda: manager) + + assert system_update.is_scan_active() is True + + +def test_repair_endpoint_calls_fixed_command(monkeypatch, update_client): + calls = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs)) + return _completed(argv, '{"ok": true, "command": "repair", "steps": []}') + + monkeypatch.setattr(system_update, "is_scan_active", lambda: False) + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.post("/latest/system/repair/openscan3") + + assert response.status_code == 200 + assert response.json()["result"]["command"] == "repair" + assert calls[0][0] == system_update.UPDATER_COMMANDS["repair_openscan3"] + assert calls[0][1]["shell"] is False + + +def test_repair_command_failure_returns_parsed_json(monkeypatch, update_client): + def fake_run(argv, **kwargs): + return _completed( + argv, + '{"ok": false, "error": {"type": "camera_manifest_missing"}}', + returncode=1, + ) + + monkeypatch.setattr(system_update, "is_scan_active", lambda: False) + monkeypatch.setattr(system_update.subprocess, "run", fake_run) + + response = update_client.post("/latest/system/repair/openscan3") + + assert response.status_code == 200 + payload = response.json() + assert payload["ok"] is False + assert payload["result"]["error"]["type"] == "camera_manifest_missing" + + +@pytest.mark.asyncio +async def test_concurrent_repair_attempts_are_rejected(monkeypatch): + started = asyncio.Event() + release = asyncio.Event() + + async def fake_run(command: str): + started.set() + await release.wait() + return 200, {"ok": True, "command": command, "result": {}} + + monkeypatch.setattr(system_update, "is_scan_active", lambda: False) + monkeypatch.setattr(system_update, "run_updater_command", fake_run) + + first = asyncio.create_task(system_update.run_repair_openscan3()) + await started.wait() + second_status, second_payload = await system_update.run_repair_openscan3() + release.set() + first_status, _ = await first + + assert first_status == 200 + assert second_status == 409 + assert second_payload["error"]["type"] == "update_active" + + +def test_active_scan_guard_blocks_repair(monkeypatch, update_client): + monkeypatch.setattr(system_update, "is_scan_active", lambda: True) + + response = update_client.post("/latest/system/repair/openscan3") + + assert response.status_code == 409 + assert response.json()["error"]["type"] == "scan_active" + + +def test_repair_endpoint_not_exposed_by_update_router_alone(): + app = FastAPI() + app.include_router(router, prefix="/latest") + + with TestClient(app) as client: + response = client.post("/latest/system/repair/openscan3") + + assert response.status_code == 404