diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..c98b77264 --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +use nix +watch_file flake.nix flake.lock shell.nix +watch_file nixos/pkgs/uv-python.nix +watch_file python/pyproject.toml python/uv.lock diff --git a/.github/scripts/publish_manifest.sh b/.github/scripts/publish_manifest.sh old mode 100644 new mode 100755 diff --git a/.github/scripts/update_manifest.py b/.github/scripts/update_manifest.py index 97ff73929..0ad72e5ba 100644 --- a/.github/scripts/update_manifest.py +++ b/.github/scripts/update_manifest.py @@ -4,11 +4,19 @@ import argparse import json import re +import urllib.error +import urllib.request from datetime import datetime, timezone from pathlib import Path STORE_PATH_RE = re.compile(r"^/nix/store/[a-z0-9]+-[A-Za-z0-9._+=?,-]+$") + +# The binary caches devices resolve upgrades from. A build is only advertised +# as available once one of these actually serves its narinfo — see set_available. +DEV_CACHE = "https://cache.pifinder.eu/pifinder" +RELEASE_CACHE = "https://cache.pifinder.eu/pifinder-release" +DEFAULT_CACHES = (DEV_CACHE, RELEASE_CACHE) EMPTY_MANIFEST = { "schema": 1, "generated_at": None, @@ -49,19 +57,63 @@ def valid_store_path(value: str | None) -> bool: return isinstance(value, str) and STORE_PATH_RE.fullmatch(value) is not None -def set_available(entry: dict) -> dict: - if valid_store_path(entry.get("store_path")): - entry["available"] = True - entry.pop("reason", None) - else: +def _narinfo_url(store_path: str, cache: str) -> str: + digest = Path(store_path).name.split("-", 1)[0] + return f"{cache.rstrip('/')}/{digest}.narinfo" + + +def cache_serves(store_path: str, caches, timeout: int = 15) -> bool | None: + """Whether the closure is actually downloadable from a cache right now. + + Returns True if any cache serves the narinfo (200); False only if every + cache answered and none had it (a real 404 everywhere); None if a cache + could not be reached, so presence is unknown. Publishing treats anything + other than True as "do not advertise", so a failed push or a down cache can + never advertise a build the device cannot fetch. + """ + saw_404 = False + for cache in caches: + url = _narinfo_url(store_path, cache) + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + if resp.status == 200: + return True + except urllib.error.HTTPError as exc: + if exc.code == 404: + saw_404 = True + else: + return None + except (urllib.error.URLError, OSError, TimeoutError): + return None + return False if saw_404 else None + + +def set_available(entry: dict, caches=DEFAULT_CACHES, verify: bool = True) -> dict: + store_path = entry.get("store_path") + if not valid_store_path(store_path): entry["store_path"] = None entry["available"] = False entry.setdefault("reason", "no build") + return entry + + if verify: + present = cache_serves(store_path, caches) + if present is not True: + entry["available"] = False + entry["reason"] = "not in cache" if present is False else "cache unreachable" + return entry + + entry["available"] = True + entry.pop("reason", None) return entry def replace_entry(entries: list[dict], predicate, entry: dict) -> list[dict]: - return [item for item in entries if not predicate(item)] + [entry] + # Newest first: consumers (release channels in the update UI, and the + # migration target lookup) take the head of the list as the current entry. + # The unstable channel is re-sorted after this, so prepending is neutral + # there. + return [entry] + [item for item in entries if not predicate(item)] def sort_unstable(entries: list[dict]) -> list[dict]: @@ -75,11 +127,17 @@ def key(item: dict) -> tuple[int, int, str]: return sorted(entries, key=key) +def resolve_verify(args: argparse.Namespace) -> tuple[tuple[str, ...], bool]: + caches = tuple(args.verify_cache) if args.verify_cache else DEFAULT_CACHES + return caches, not args.skip_cache_check + + def update_build(args: argparse.Namespace) -> None: manifest = load_manifest(args.manifest) channels = manifest["channels"] store_path = args.store_path or None short_sha = (args.head_sha or args.sha)[:7] + caches, verify = resolve_verify(args) if args.pr_number: number = int(args.pr_number) @@ -94,8 +152,9 @@ def update_build(args: argparse.Namespace) -> None: "source_sha": args.head_sha, "version": args.version or f"PR#{number}-{short_sha}", "store_path": store_path, + "built_at": now_iso(), } - set_available(entry) + set_available(entry, caches, verify) channels["unstable"] = replace_entry( channels["unstable"], lambda item: item.get("kind") == "pr" @@ -113,8 +172,9 @@ def update_build(args: argparse.Namespace) -> None: "source_sha": args.sha, "version": args.version or f"{args.ref_name}-{short_sha}", "store_path": store_path, + "built_at": now_iso(), } - set_available(entry) + set_available(entry, caches, verify) channels["unstable"] = replace_entry( channels["unstable"], lambda item: item.get("kind") == "trunk" @@ -129,6 +189,7 @@ def update_build(args: argparse.Namespace) -> None: def update_release(args: argparse.Namespace) -> None: manifest = load_manifest(args.manifest) + caches, verify = resolve_verify(args) channel = "beta" if args.release_type == "beta" else "stable" entry = { "kind": "release", @@ -140,8 +201,11 @@ def update_release(args: argparse.Namespace) -> None: "source_sha": args.sha, "version": args.version, "store_path": args.store_path or None, + "migration_url": args.migration_url or None, + "migration_sha256_url": args.migration_sha256_url or None, + "built_at": now_iso(), } - set_available(entry) + set_available(entry, caches, verify) manifest["channels"][channel] = replace_entry( manifest["channels"][channel], lambda item: item.get("kind") == "release" and item.get("label") == args.tag, @@ -150,6 +214,17 @@ def update_release(args: argparse.Namespace) -> None: save_manifest(args.manifest, manifest) +def add_cache_args(sub: argparse.ArgumentParser) -> None: + # A build is advertised as available only if one of these caches actually + # serves its narinfo. Repeatable; defaults to the dev + release caches. + sub.add_argument("--verify-cache", action="append", metavar="URL") + sub.add_argument( + "--skip-cache-check", + action="store_true", + help="advertise on store-path syntax alone (tests / offline only)", + ) + + def parser() -> argparse.ArgumentParser: root = argparse.ArgumentParser() sub = root.add_subparsers(dest="command", required=True) @@ -167,6 +242,7 @@ def parser() -> argparse.ArgumentParser: build.add_argument("--head-repo") build.add_argument("--head-ref") build.add_argument("--head-sha") + add_cache_args(build) build.set_defaults(func=update_build) release = sub.add_parser("release") @@ -177,8 +253,11 @@ def parser() -> argparse.ArgumentParser: release.add_argument("--version", required=True) release.add_argument("--release-type", choices=("stable", "beta"), required=True) release.add_argument("--store-path", required=True) + release.add_argument("--migration-url") + release.add_argument("--migration-sha256-url") release.add_argument("--title") release.add_argument("--notes") + add_cache_args(release) release.set_defaults(func=update_release) return root diff --git a/.github/workflows/nixos-pr-build.yml b/.github/workflows/nixos-pr-build.yml index 19e74b444..09184ba41 100644 --- a/.github/workflows/nixos-pr-build.yml +++ b/.github/workflows/nixos-pr-build.yml @@ -55,18 +55,22 @@ jobs: attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" attic use pifinder:pifinder - - name: Build NixOS system closure + - name: Build NixOS system and on-device dev shell closures id: build run: | nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ -L --no-link + nix build .#devShells.aarch64-linux.default -L --no-link - name: Push to Attic id: push run: | STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ --json | jq -r '.[].outputs.out') + DEV_SHELL_PATH=$(nix build .#devShells.aarch64-linux.default \ + --json | jq -r '.[].outputs.out') attic push pifinder:pifinder "$STORE_PATH" + attic push pifinder:pifinder "$DEV_SHELL_PATH" echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" # Wait up to ~15 min for the native builder, then decide on the hosted fallback. @@ -135,10 +139,11 @@ jobs: attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" attic use pifinder:pifinder - - name: Build NixOS system closure + - name: Build NixOS system and on-device dev shell closures run: | nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ -L --no-link + nix build .#devShells.aarch64-linux.default -L --no-link - name: Push to Attic id: push @@ -147,9 +152,12 @@ jobs: run: | STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ --json | jq -r '.[].outputs.out') + DEV_SHELL_PATH=$(nix build .#devShells.aarch64-linux.default \ + --json | jq -r '.[].outputs.out') echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" if [ -n "$ATTIC_TOKEN" ]; then attic push pifinder:pifinder "$STORE_PATH" + attic push pifinder:pifinder "$DEV_SHELL_PATH" else echo "No ATTIC_TOKEN — skipping push; build is verify-only" fi diff --git a/.github/workflows/nox.yml b/.github/workflows/nox.yml index ab38d5621..0eb274e5b 100644 --- a/.github/workflows/nox.yml +++ b/.github/workflows/nox.yml @@ -1,4 +1,9 @@ name: nox +# The nixos branch manages the Python environment with uv (pyproject.toml + +# uv.lock) instead of nox + requirements*.txt, so this runs the same checks +# (ruff, mypy, pytest) through uv. The workflow keeps upstream's "nox" name so +# PR check wiring stays identical across branches. +# # Run on pushes to the long-lived branches and on pull requests. Restricting # `push` to main/release means a push to a feature branch with an open PR only # triggers the `pull_request` run (not also a `push` run), so a PR gets a single @@ -18,9 +23,21 @@ jobs: working-directory: ./python steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 with: - submodules: true - - uses: wntrblm/nox@2024.04.15 - with: - python-versions: "3.9" - - run: nox -s lint format type_hints smoke_tests unit_tests ui_tests + enable-cache: true + # Same ruff pin as upstream main's noxfile, so shared files keep one style. + - name: Lint + run: uvx ruff@0.4.8 check --config "builtins=['_']" . + - name: Format check + run: uvx ruff@0.4.8 format --check . + - name: Sync environment + run: uv sync --frozen + - name: Type check + run: uv run mypy PiFinder + - name: Smoke tests + run: uv run pytest -m smoke + - name: Unit tests + run: uv run pytest -m unit + - name: UI module tests + run: uv run pytest -m integration tests/test_ui_modules.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..d2afc1597 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,387 @@ +name: Release NixOS image + +# Builds the PiFinder NixOS SD image and migration tarball and publishes them as +# a GitHub (pre-)release, and records the closure in the nixos-manifest branch. +# +# Runners are native aarch64 (no QEMU): the Pi5 self-hosted runner is tried +# first, with the free GitHub-hosted `ubuntu-24.04-arm` runner as a fallback if +# the Pi is offline/slow — mirroring nixos-pr-build.yml. The arch-sensitive +# `nix build` runs there; the image is handed off as an artifact and the +# arch-independent packaging + publishing happens on ubuntu-latest. +on: + workflow_dispatch: + inputs: + version: + description: 'Version (e.g., 2.5.0)' + required: true + notes: + description: 'Release notes' + required: true + type: + description: 'Release type' + type: choice + options: + - stable + - beta + default: stable + source_branch: + description: 'Source branch (default: main, use release/X.Y for hotfixes)' + required: false + default: 'main' + source_repository: + description: 'Source repo to build (blank = this repo; e.g. mrosseel/PiFinder to build a fork)' + required: false + default: '' + +jobs: + # Native aarch64 build on the Pi5 self-hosted runner (preferred, fast). + build-native: + runs-on: [self-hosted, aarch64] + timeout-minutes: 90 + outputs: + store_path: ${{ steps.push.outputs.store_path }} + source_sha: ${{ steps.sha.outputs.source_sha }} + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.source_repository || github.repository }} + ref: ${{ inputs.source_branch }} + persist-credentials: false + + - name: Record source SHA + id: sha + run: echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Ensure nix is on PATH (self-hosted runner) + run: | + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + + - name: Setup Attic caches (cache.pifinder.eu) + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - name: Build and push release closure to Attic + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + -L --json | jq -r '.[].outputs.out') + DEV_SHELL_PATH=$(nix build .#devShells.aarch64-linux.default \ + -L --json | jq -r '.[].outputs.out') + # Stable → retained pifinder-release (GC off; devices resolve it months + # later). Beta → dev pifinder cache (finite retention), so prereleases + # stay transient. + if [[ "${{ inputs.type }}" == "beta" ]]; then + attic push pifinder:pifinder "$STORE_PATH" + attic push pifinder:pifinder "$DEV_SHELL_PATH" + else + attic push pifinder:pifinder-release "$STORE_PATH" + attic push pifinder:pifinder-release "$DEV_SHELL_PATH" + fi + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + - name: Build SD image + run: | + nix build .#images.pifinder -L -o result-sd + + - name: Upload SD image + uses: actions/upload-artifact@v4 + with: + name: sdimage-native + path: result-sd/sd-image/*.img.zst + retention-days: 1 + + # The migration tarball is cut from the MINIMAL migration system, not the + # full image — it resolves the full system from the update manifest at + # first boot (ADR 0004 / 0003). + - name: Build migration image + run: | + nix build .#images.pifinder-migration -L -o result-mig + + - name: Upload migration image + uses: actions/upload-artifact@v4 + with: + name: migimage-native + path: result-mig/sd-image/*.img.zst + retention-days: 1 + + # Poll the native builder for ~15 min, then decide whether to fall back. + native-wait: + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + need_fallback: ${{ steps.wait.outputs.need_fallback }} + steps: + - name: Wait for native build + id: wait + env: + GH_TOKEN: ${{ github.token }} + run: | + for i in $(seq 1 30); do + sleep 30 + RESULT=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \ + --jq '.jobs[] | select(.name == "build-native") | .conclusion // "pending"' 2>/dev/null || echo "pending") + echo "Check $i/30: build-native=$RESULT" + if [ "$RESULT" = "success" ]; then + echo "need_fallback=false" >> "$GITHUB_OUTPUT" + exit 0 + elif [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then + echo "need_fallback=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "Native build not done after 15 min, falling back to hosted" + echo "need_fallback=true" >> "$GITHUB_OUTPUT" + + # Fallback on a free GitHub-hosted arm64 runner (native aarch64, no QEMU). + build-hosted: + needs: native-wait + if: needs.native-wait.outputs.need_fallback == 'true' + runs-on: ubuntu-24.04-arm + timeout-minutes: 120 + outputs: + store_path: ${{ steps.push.outputs.store_path }} + source_sha: ${{ steps.sha.outputs.source_sha }} + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.source_repository || github.repository }} + ref: ${{ inputs.source_branch }} + persist-credentials: false + + - name: Record source SHA + id: sha + run: echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: false + extra-conf: | + extra-system-features = big-parallel + extra-substituters = https://cache.pifinder.eu/pifinder + extra-trusted-public-keys = pifinder:VkemNaMqXDcsYlpONItSvOOcBIa1vEfnpyqdetr3gck= + + - name: Setup Attic caches (cache.pifinder.eu) + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - name: Build and push release closure to Attic + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + -L --json | jq -r '.[].outputs.out') + DEV_SHELL_PATH=$(nix build .#devShells.aarch64-linux.default \ + -L --json | jq -r '.[].outputs.out') + if [[ "${{ inputs.type }}" == "beta" ]]; then + attic push pifinder:pifinder "$STORE_PATH" + attic push pifinder:pifinder "$DEV_SHELL_PATH" + else + attic push pifinder:pifinder-release "$STORE_PATH" + attic push pifinder:pifinder-release "$DEV_SHELL_PATH" + fi + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + - name: Build SD image + run: | + nix build .#images.pifinder -L -o result-sd + + - name: Upload SD image + uses: actions/upload-artifact@v4 + with: + name: sdimage-hosted + path: result-sd/sd-image/*.img.zst + retention-days: 1 + + # Minimal migration system — see the native job's comment (ADR 0004). + - name: Build migration image + run: | + nix build .#images.pifinder-migration -L -o result-mig + + - name: Upload migration image + uses: actions/upload-artifact@v4 + with: + name: migimage-hosted + path: result-mig/sd-image/*.img.zst + retention-days: 1 + + # Arch-independent: package the migration tarball + checksums and publish. Runs + # on whichever build succeeded (native preferred). + release: + needs: [build-native, native-wait, build-hosted] + if: | + always() && + (needs.build-native.result == 'success' || needs.build-hosted.result == 'success') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # Base repo (this repo, not the fork source): supplies the TRUSTED manifest + # scripts and the `origin` the manifest branch is pushed to. Keeps default + # credentials for that push. + - name: Checkout base repo + uses: actions/checkout@v4 + + # Download native and hosted images to separate dirs. If both builds ran + # (native slow → fallback fired) their images share a filename, so merging + # them would corrupt the archive — never merge; the assemble step prefers + # native. + - name: Download native SD image + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: sdimage-native + path: img-native + + - name: Download hosted SD image + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: sdimage-hosted + path: img-hosted + + - name: Download native migration image + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: migimage-native + path: mig-native + + - name: Download hosted migration image + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: migimage-hosted + path: mig-hosted + + - name: Compute tag + run: | + TAG="v${{ inputs.version }}" + [[ "${{ inputs.type }}" == "beta" ]] && TAG="${TAG}-beta" + echo "TAG=$TAG" >> "$GITHUB_ENV" + + - name: Assemble image + migration tarball + checksums + run: | + set -euo pipefail + # Only some dirs may exist (downloads are continue-on-error); create + # all so find can't fail under pipefail. Prefer the native builder's + # artifacts (listed first); fall back to hosted. + mkdir -p img-native img-hosted mig-native mig-hosted + IMAGE_SRC=$(find img-native img-hosted -type f -name '*.img.zst' -print -quit) + MIG_SRC=$(find mig-native mig-hosted -type f -name '*.img.zst' -print -quit) + if [ -z "$IMAGE_SRC" ] || [ -z "$MIG_SRC" ]; then + echo "Missing artifact: sd=[$IMAGE_SRC] migration=[$MIG_SRC]" >&2 + exit 1 + fi + mkdir -p release + cp "$IMAGE_SRC" "release/pifinder-${TAG}.img.zst" + + # The migration tarball is cut from the MINIMAL migration image — it + # resolves the full system from the update manifest at first boot, so + # its content never goes stale (ADR 0004 / 0003). + rm -f /tmp/pifinder-mig.img + zstd -d "$MIG_SRC" -o /tmp/pifinder-mig.img + + LOOP=$(sudo losetup --find --show --partscan /tmp/pifinder-mig.img) + sudo mkdir -p /mnt/boot /mnt/root + sudo mount "${LOOP}p1" /mnt/boot + sudo mount "${LOOP}p2" /mnt/root + + mkdir -p /tmp/tarball-staging + sudo cp -a /mnt/boot /tmp/tarball-staging/boot + sudo cp -a /mnt/root /tmp/tarball-staging/rootfs + + sudo umount /mnt/boot /mnt/root + sudo losetup -d "$LOOP" + rm -f /tmp/pifinder-mig.img + + sudo tar -C /tmp/tarball-staging -cf - \ + --exclude='*/lost+found' \ + boot rootfs | zstd -T0 -19 -o "release/pifinder-migration-${TAG}.tar.zst" + sudo rm -rf /tmp/tarball-staging + + # Never-again guard (2026-07-05 field failure): during migration the + # tarball is copied into RAM, so a 2GB board can only hold ~1.4GB. + # Refuse to publish a release that cannot migrate 2GB boards. + MIG_MB=$(( $(stat -c%s "release/pifinder-migration-${TAG}.tar.zst") / 1048576 )) + BUDGET_MB=800 + echo "migration tarball: ${MIG_MB}MB (budget ${BUDGET_MB}MB)" + if [ "${MIG_MB}" -gt "${BUDGET_MB}" ]; then + echo "::error::migration tarball ${MIG_MB}MB exceeds ${BUDGET_MB}MB budget — shrink the migration closure (check linux-firmware and friends)" + exit 1 + fi + + # Checksum sidecars. The in-app upgrade (PiFinder.sys_utils) fetches + # .sha256 and refuses to flash without a matching digest; + # it reads the first whitespace-delimited token, so sha256sum output is + # the expected format. + ( cd release + for f in pifinder-*.img.zst pifinder-migration-*.tar.zst; do + sha256sum "$f" > "$f.sha256" + done ) + + - name: Tag release source commit + # Only when building this repo. A fork source_repository has a different + # `origin` we can't push to with GITHUB_TOKEN; the GitHub Release below + # still publishes the artifacts to this repo. + if: (inputs.source_repository || github.repository) == github.repository + env: + SOURCE_SHA: ${{ needs.build-native.outputs.source_sha || needs.build-hosted.outputs.source_sha }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # The base checkout is at the workflow's ref, not the built source — + # fetch and tag the commit the build job actually checked out. + git fetch --depth=1 origin "$SOURCE_SHA" + git tag "$TAG" "$SOURCE_SHA" + git push origin "$TAG" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.TAG }} + name: PiFinder ${{ env.TAG }} + body: ${{ inputs.notes }} + prerelease: ${{ inputs.type == 'beta' }} + files: | + release/pifinder-*.zst + release/pifinder-*.sha256 + + - name: Update generated manifest branch + # Records this release in the metadata-only nixos-manifest branch so + # devices discover it (upgrade uses store_path; migration uses the tarball + # URL). Pushes to this repo's origin via the base checkout above, so it + # runs for fork-source builds too. source_sha is the built source commit; + # the tarball URL points at this repo's release assets. + env: + STORE_PATH: ${{ needs.build-native.outputs.store_path || needs.build-hosted.outputs.store_path }} + SOURCE_SHA: ${{ needs.build-native.outputs.source_sha || needs.build-hosted.outputs.source_sha }} + RELEASE_NOTES: ${{ inputs.notes }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + MIGRATION_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/pifinder-migration-${TAG}.tar.zst" + + bash .github/scripts/publish_manifest.sh \ + "chore: update release manifest [skip ci]" \ + python3 .github/scripts/update_manifest.py release \ + --manifest @MANIFEST@ \ + --repository "${{ inputs.source_repository || github.repository }}" \ + --sha "$SOURCE_SHA" \ + --tag "$TAG" \ + --version "${{ inputs.version }}" \ + --release-type "${{ inputs.type }}" \ + --store-path "$STORE_PATH" \ + --migration-url "$MIGRATION_URL" \ + --migration-sha256-url "${MIGRATION_URL}.sha256" \ + --title "PiFinder $TAG" \ + --notes "$RELEASE_NOTES" diff --git a/.gitignore b/.gitignore index 7e9319394..502146381 100644 --- a/.gitignore +++ b/.gitignore @@ -152,8 +152,11 @@ case/my_printer *.db-shm *.db-wal .devenv/ +.direnv/ **/.claude/* !**/.claude/skills/ +.agents/ +.codex/ .serena/ astro_data/comets.txt @@ -161,3 +164,7 @@ astro_data/comets.txt test_ubx test_ubx/* python/telemetry_analysis/ + +# nix build result symlinks +result +result-* diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index bbd1b94ab..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "python/PiFinder/tetra3"] - path = python/PiFinder/tetra3 - url = https://github.com/smroid/cedar-solve diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a538aa17..317fee59c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,27 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks repos: - - repo: https://github.com/saltstack/mirrors-nox - rev: 'v2022.11.21' # Use the sha / tag you want to point at + - repo: local hooks: - - id: nox - files: ^.*\.py$ - args: - - -f - - python/noxfile.py - - -s - - type_hints - - smoke_tests - - -- + - id: ruff-lint + name: ruff lint + entry: bash -c 'cd python && ruff check' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: ruff-format + name: ruff format check + entry: bash -c 'cd python && ruff format --check' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: mypy + name: mypy type check + entry: bash -c 'cd python && mypy .' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: smoke-tests + name: smoke tests + entry: bash -c 'cd python && pytest -m smoke' + language: system + files: ^python/.*\.py$ + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index b0b0d15d2..a28cfe0a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,65 +22,46 @@ Maintainers can make `fresh` root on `main` automatically per clone (without cha git remote set-head origin main ``` -**Initialise the `tetra3` submodule in every new worktree.** `python/PiFinder/tetra3` is a git submodule (the `cedar-solve`/Tetra3 solver); the importable package is its inner `tetra3/tetra3/` dir, surfaced through the tracked symlink `python/tetra3`. `git worktree add` / `EnterWorktree` does **not** populate submodules, so a fresh worktree starts with an empty submodule dir and a dangling symlink. Any test that imports the solver then fails with `ModuleNotFoundError: No module named 'tetra3'` (or `cedar_detect_pb2`) — this is a missing checkout, **not** a code problem, so don't reach for `PYTHONPATH` hacks. Fix it once per worktree: - -```bash -git submodule update --init python/PiFinder/tetra3 -``` - -mypy also needs this: its config points at `python/PiFinder/tetra3/tetra3`, so `nox -s type_hints` can't run in a worktree until the submodule is initialised. +**No tetra3 submodule on this branch.** Unlike upstream `main`, the solver +(`cedar-solve`/Tetra3) is an ordinary uv dependency (pinned git rev in +`python/pyproject.toml`), so worktrees need no `git submodule` step. ## Development Commands -**Running Python** -Developers may have created virtual environments in directories like ".venv" or "venv". Make sure these virtual -environments are activated before any of the python based tools below. - -**Development workflow uses Nox for task automation:** -```bash -nox -s lint # Code linting with Ruff (auto-fixes issues) -nox -s format # Code formatting with Ruff -nox -s type_hints # Type checking with MyPy -nox -s smoke_tests # Quick functionality validation -nox -s unit_tests # Full unit test suite -nox -s babel # I18n message extraction and compilation -nox -s web_tests # Testing the webserver, see below -``` - -**Direct testing with pytest:** -```bash -pytest -m smoke # Smoke tests for core functionality -pytest -m unit # Unit tests for isolated components -pytest -m integration # End-to-end integration tests -``` +**This branch uses uv (pyproject.toml + uv.lock), not nox/requirements*.txt.** +All Python tooling runs through uv from `python/`: -**Development setup:** ```bash cd python/ -python3.9 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -pip install -r requirements_dev.txt +uv sync --frozen # create/refresh .venv from uv.lock +uvx ruff@0.4.8 check --config "builtins=['_']" . # lint (same pin as CI / upstream noxfile) +uvx ruff@0.4.8 format . # format +uv run mypy PiFinder # type checking +uv run pytest -m smoke # smoke tests +uv run pytest -m unit # unit tests +uv run pytest -m integration tests/test_ui_modules.py # UI module harness ``` -If the .venv dir already exists, you can directly source it and run the app. +CI (`.github/workflows/nox.yml` — keeps the upstream "nox" check name) runs +exactly these commands. -Watch out for .venv directories containing virtual environments, that you need to activate first. +NixOS dev-box note: manylinux wheels (numpy etc.) need `libstdc++`; if imports +fail with `libstdc++.so.6: cannot open shared object file`, run tests with +`LD_LIBRARY_PATH=$(nix build --print-out-paths nixpkgs#stdenv.cc.cc.lib)/lib`. **Running the application:** First start the `cedar-detect-server` which is in `bin` (you need to use `-p 50551`, when invoking it). -Use the correct architecture suffix for cedar-detect-server according to the platform you're running on. +Use the correct architecture suffix for cedar-detect-server according to the platform you're running on. -Development setup has to have run and you should be in .venv virtual environment ```bash cd python/ -python -m PiFinder.main [options] +uv run python -m PiFinder.main [options] ``` Usual startup: ```bash -python3.9 -m PiFinder.main -fh --camera debug --keyboard local -x +uv run python -m PiFinder.main -fh --camera debug --keyboard local -x ``` ## Reference Documentation @@ -191,6 +172,39 @@ Tests use pytest with custom markers for different test types. The smoke tests p - **Linting:** Ruff with Python 3.9 target, Black-compatible formatting - **Type Checking:** MyPy with gradual typing adoption - **Code Style:** 88-character line length, double quotes, space indentation +- **Comments:** describe what the code does now, not how it changed. No "previously / no longer / used to / moved from" — history lives in git/jj, not in comments. - **I18n Support:** Babel integration for multi-language UI The codebase follows modern Python practices with type hints, comprehensive testing, and automated code quality checks integrated into the development workflow. + +## NixOS Development + +**CRITICAL: Never run `nix build` or `nix eval` on Pi 4 targets.** The Pi 4 lacks sufficient resources and will hang/crash. Always build on pi5.local (GitHub Actions runner), push to Attic, then trigger the upgrade service: +```bash +# Build on pi5 +ssh pi5.local 'nix build --no-link --print-out-paths github:mrosseel/PiFinder/nixos#nixosConfigurations.pifinder.config.system.build.toplevel' +# Push to Attic (so Pi can download signed paths) +ssh pi5.local 'attic push pifinder:pifinder ' +# Trigger upgrade on target Pi (downloads from Attic, activates, reboots) +ssh pifinder@ 'echo "" > /run/pifinder/upgrade-ref && sudo systemctl start --no-block pifinder-upgrade.service' +# Monitor progress +ssh pifinder@ 'cat /run/pifinder/upgrade-status' +``` + +**Netboot deployment (dev Pi on proxnix NFS):** +```bash +./deploy-image-to-nfs.sh # Build and deploy to NFS +``` + +**Power control (Shelly plug via Home Assistant):** +```bash +~/.local/bin/pifinder-power-off.sh # Turn off PiFinder +~/.local/bin/pifinder-power-on.sh # Turn on PiFinder +``` + +**Check Pi status:** +```bash +ssh pifinder@192.168.5.146 # SSH to netboot Pi +systemctl status pifinder # Check service status +journalctl -u pifinder -f # Follow service logs +``` diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 1f77e97b7..c29997468 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -12,6 +12,7 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - [Camera](./docs/ax/camera/CONTEXT.md) — captures frames and decides exposure: the three exposure regimes, the auto-exposure controllers, and zero-match recovery. - [Battery](./docs/ax/battery/CONTEXT.md) — reads battery voltage and charge state from the rev-4 BQ25895 charger and publishes `BatteryState`; read-only telemetry, gated on hardware presence. - [Sound](./docs/ax/sound/CONTEXT.md) — turns named events into short **earcons** on the rev-4 passive buzzer (hardware PWM ch0, GPIO12); best-effort, fire-and-forget feedback, gated on hardware presence. +- [NixOS](./docs/ax/nixos/CONTEXT.md) — how a NixOS PiFinder is built, published, and updated over the air: the Attic cache, the stable/beta/unstable channels, and the on-device upgrade flow. Cross-cutting infrastructure, not a runtime slice. ## Relationships @@ -29,6 +30,7 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - **Sound → shutdown**: the shutdown chokepoint (`callbacks.shutdown`) plays `SHUTDOWN` and waits its catalog duration + margin **before** triggering the GPIO14 power latch (see [ADR 0007](./docs/adr/0007-gpio-poweroff-latch.md)), so the cue isn't cut off by power-down. Companion architecture docs live next to each `CONTEXT.md`: +- [`docs/ax/nixos.md`](./docs/ax/nixos.md) - [`docs/ax/catalog.md`](./docs/ax/catalog.md) - [`docs/ax/positioning.md`](./docs/ax/positioning.md) - [`docs/ax/sqm.md`](./docs/ax/sqm.md) diff --git a/bin/cedar-detect-server-aarch64 b/bin/cedar-detect-server-aarch64 deleted file mode 100755 index 7b44b89b7..000000000 Binary files a/bin/cedar-detect-server-aarch64 and /dev/null differ diff --git a/bin/cedar-detect-server-arm64 b/bin/cedar-detect-server-arm64 deleted file mode 100755 index ea792437f..000000000 Binary files a/bin/cedar-detect-server-arm64 and /dev/null differ diff --git a/default_config.json b/default_config.json index 7684bff0f..6c8002d99 100644 --- a/default_config.json +++ b/default_config.json @@ -180,6 +180,7 @@ "active_eyepiece_index": 0 }, "imu_threshold_scale": 1, + "dev_mode": false, "telemetry_record": false, "telemetry_images": false, "telemetry_raw_imu": false diff --git a/deploy-image-to-nfs.sh b/deploy-image-to-nfs.sh new file mode 100755 index 000000000..1aec63990 --- /dev/null +++ b/deploy-image-to-nfs.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy PiFinder NixOS netboot configuration to proxnix +# +# Builds the pifinder-netboot closure (NFS root baked in), copies the nix store +# closure to NFS, and sets up TFTP with kernel/initrd/firmware for PXE boot. +# +# Boot sequence: Pi firmware → u-boot → extlinux/extlinux.conf (TFTP) → NFS root + +PROXNIX="mike@192.168.5.12" +NFS_ROOT="/srv/nfs/pifinder" +TFTP_ROOT="/srv/tftp" +PI_IP="192.168.5.150" +PI_MAC="e4-5f-01-b7-37-31" # For PXE boot speedup + +# SSH options to prevent timeout during long transfers +SSH_OPTS="-o ServerAliveInterval=30 -o ServerAliveCountMax=10" +export RSYNC_RSH="ssh ${SSH_OPTS}" + +SSH_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrPg9hSgxwg0EECxXSpYi7t3F/w/BgpymlD1uUDedRz mike@nixtop" + +# Password hash for "solveit" +SHADOW_HASH='$6$upbQ1/Jfh7zDiIYW$jPVQdYJCZn/Pe/OIGx89DZm9trIhEJp7Q4LNZsq/5x9csj6U08.P2avebrQIDJCEyD0xipsV6C19Sr5iAbCuv1' + +# ── Helpers ────────────────────────────────────────────────────────────────── + +run_proxnix() { + ssh ${SSH_OPTS} "${PROXNIX}" "bash -euo pipefail -c \"$1\"" +} + +# ── Build netboot closure ──────────────────────────────────────────────────── + +echo "=== Building pifinder-netboot closure ===" +nix build .#nixosConfigurations.pifinder-netboot.config.system.build.toplevel \ + -o result-netboot --system aarch64-linux + +CLOSURE=$(readlink -f result-netboot) +echo "Closure: $CLOSURE" + +# Extract paths from closure +KERNEL=$(readlink -f result-netboot/kernel) +INITRD=$(readlink -f result-netboot/initrd) +DTBS=$(readlink -f result-netboot/dtbs) +INIT_PATH="${CLOSURE}/init" + +KERNEL_NAME=$(basename "$(dirname "$KERNEL")")-Image +INITRD_NAME=$(basename "$(dirname "$INITRD")")-initrd + +echo "Kernel: $KERNEL" +echo "Initrd: $INITRD" +echo "DTBs: $DTBS" +echo "Init: $INIT_PATH" + +# ── Stop TFTP — prevent Pi from netbooting during deploy ───────────────────── + +echo "Stopping TFTP server..." +ssh "${PROXNIX}" "sudo systemctl stop atftpd.service" + +# ── Halt Pi if running — prevent NFS corruption ────────────────────────────── + +if ssh -o ConnectTimeout=3 -o BatchMode=yes "pifinder@${PI_IP}" "echo ok" 2>/dev/null; then + echo "Pi is running — halting..." + ssh "pifinder@${PI_IP}" "echo solveit | sudo -S poweroff" 2>/dev/null || true + echo "Waiting for Pi to go down..." + sleep 3 + while ping -c1 -W1 "${PI_IP}" &>/dev/null; do sleep 1; done + echo "Pi is down" +else + echo "Pi not reachable, proceeding" +fi + +# ── Backup SSH host keys ───────────────────────────────────────────────────── + +echo "Backing up SSH host keys..." +ssh "${PROXNIX}" "sudo cp -a ${NFS_ROOT}/etc/ssh/ssh_host_* /tmp/ 2>/dev/null || true" + +# ── Copy nix store closure to NFS ──────────────────────────────────────────── + +echo "Copying nix store closure to NFS..." +ssh "${PROXNIX}" "sudo mkdir -p ${NFS_ROOT}/nix/store" + +# Get list of store paths and stream via tar (fast, handles duplicates via overwrite) +STORE_PATHS=$(nix path-info -r "$CLOSURE") +TOTAL_PATHS=$(echo "$STORE_PATHS" | wc -l) +echo "Streaming ${TOTAL_PATHS} store paths via tar..." + +# Rsync store paths with -R to preserve directory structure +# shellcheck disable=SC2086 +rsync -avR --rsync-path="sudo rsync" $STORE_PATHS "${PROXNIX}:${NFS_ROOT}/" +echo "Transfer complete" + +# ── Set up NFS root directory structure ────────────────────────────────────── + +echo "Setting up NFS root directory structure..." +ssh "${PROXNIX}" "sudo bash -euo pipefail" << SETUP +# Create standard directories (bin/usr are symlinks, not dirs) +mkdir -p ${NFS_ROOT}/{etc/ssh,home/pifinder/.ssh,root/.ssh,var,tmp,proc,sys,dev,run,boot} +chmod 1777 ${NFS_ROOT}/tmp + +# Symlinks from NixOS system (remove existing dirs/symlinks first) +rm -rf ${NFS_ROOT}/bin ${NFS_ROOT}/usr +ln -sfT ${CLOSURE}/sw/bin ${NFS_ROOT}/bin +ln -sfT ${CLOSURE}/sw ${NFS_ROOT}/usr + +# /etc/static points to the NixOS etc derivation (required for PAM, etc.) +ln -sfT ${CLOSURE}/etc ${NFS_ROOT}/etc/static + +# Critical /etc symlinks that NixOS activation would normally create +rm -rf ${NFS_ROOT}/etc/pam.d 2>/dev/null || true +ln -sfT /etc/static/pam.d ${NFS_ROOT}/etc/pam.d +ln -sfT /etc/static/bashrc ${NFS_ROOT}/etc/bashrc +# passwd/shadow/group are created as real files later (need to be writable for netboot) +rm -f ${NFS_ROOT}/etc/passwd ${NFS_ROOT}/etc/shadow ${NFS_ROOT}/etc/group 2>/dev/null || true +ln -sfT /etc/static/sudoers ${NFS_ROOT}/etc/sudoers 2>/dev/null || true +ln -sfT /etc/static/sudoers.d ${NFS_ROOT}/etc/sudoers.d 2>/dev/null || true +ln -sfT /etc/static/nsswitch.conf ${NFS_ROOT}/etc/nsswitch.conf 2>/dev/null || true +ln -sfT /etc/static/systemd ${NFS_ROOT}/etc/systemd 2>/dev/null || true +ln -sfT /etc/static/polkit-1 ${NFS_ROOT}/etc/polkit-1 2>/dev/null || true + +# Create nix profile symlinks +mkdir -p ${NFS_ROOT}/nix/var/nix/profiles +ln -sfT ${CLOSURE} ${NFS_ROOT}/nix/var/nix/profiles/system +ln -sfT ${CLOSURE} ${NFS_ROOT}/run/current-system 2>/dev/null || true +SETUP + +# ── Restore SSH host keys ──────────────────────────────────────────────────── + +echo "Restoring/generating SSH host keys..." +ssh "${PROXNIX}" "bash -euo pipefail -c ' +if ls /tmp/ssh_host_* >/dev/null 2>&1; then + sudo cp -a /tmp/ssh_host_* ${NFS_ROOT}/etc/ssh/ + echo \"Restored existing host keys\" +else + sudo ssh-keygen -A -f ${NFS_ROOT} + echo \"Generated new host keys\" +fi +'" + +# ── Link NixOS /etc files ──────────────────────────────────────────────────── + +echo "Linking NixOS etc files..." +ssh "${PROXNIX}" "sudo bash -euo pipefail -c ' +ln -sf /etc/static/ssh/sshd_config ${NFS_ROOT}/etc/ssh/sshd_config +ln -sf /etc/static/ssh/ssh_config ${NFS_ROOT}/etc/ssh/ssh_config 2>/dev/null || true +ln -sf /etc/static/ssh/moduli ${NFS_ROOT}/etc/ssh/moduli 2>/dev/null || true +# pam.d already symlinked to /etc/static/pam.d in SETUP block +'" + +# ── Static user files ──────────────────────────────────────────────────────── + +echo "Creating static user files..." + +ssh "${PROXNIX}" "sudo tee ${NFS_ROOT}/etc/passwd > /dev/null" << 'PASSWD' +root:x:0:0:System administrator:/root:/run/current-system/sw/bin/bash +pifinder:x:1000:100::/home/pifinder:/run/current-system/sw/bin/bash +nobody:x:65534:65534:Unprivileged account:/var/empty:/run/current-system/sw/bin/nologin +sshd:x:993:993:SSH daemon user:/var/empty:/run/current-system/sw/bin/nologin +avahi:x:994:994:Avahi daemon user:/var/empty:/run/current-system/sw/bin/nologin +gpsd:x:992:992:GPSD daemon user:/var/empty:/run/current-system/sw/bin/nologin +PASSWD + +ssh "${PROXNIX}" "sudo tee ${NFS_ROOT}/etc/group > /dev/null" << 'GROUP' +root:x:0: +wheel:x:1:pifinder +users:x:100:pifinder +kmem:x:9:pifinder +input:x:174:pifinder +nobody:x:65534: +spi:x:996:pifinder +i2c:x:997:pifinder +gpio:x:998:pifinder +dialout:x:995:pifinder +video:x:994:pifinder +networkmanager:x:993:pifinder +sshd:x:993: +avahi:x:994: +gpsd:x:992: +GROUP + +ssh "${PROXNIX}" "echo 'root:${SHADOW_HASH}:1:::::: +pifinder:${SHADOW_HASH}:1:::::: +nobody:!:1:::::: +sshd:!:1:::::: +avahi:!:1:::::: +gpsd:!:1::::::' | sudo tee ${NFS_ROOT}/etc/shadow > /dev/null" + +run_proxnix "sudo chmod 644 ${NFS_ROOT}/etc/passwd ${NFS_ROOT}/etc/group" +run_proxnix "sudo chmod 640 ${NFS_ROOT}/etc/shadow" + +# ── SSH authorized_keys ────────────────────────────────────────────────────── + +echo "Setting up SSH authorized_keys..." +ssh "${PROXNIX}" "echo '${SSH_PUBKEY}' | sudo tee ${NFS_ROOT}/home/pifinder/.ssh/authorized_keys > /dev/null" +ssh "${PROXNIX}" "echo '${SSH_PUBKEY}' | sudo tee ${NFS_ROOT}/root/.ssh/authorized_keys > /dev/null" +run_proxnix "sudo chown -R 1000:100 ${NFS_ROOT}/home/pifinder" +run_proxnix "sudo chmod 700 ${NFS_ROOT}/home/pifinder/.ssh ${NFS_ROOT}/root/.ssh" +run_proxnix "sudo chmod 600 ${NFS_ROOT}/home/pifinder/.ssh/authorized_keys ${NFS_ROOT}/root/.ssh/authorized_keys" + +# ── PiFinder symlink ───────────────────────────────────────────────────────── + +echo "Setting up PiFinder directory..." +# Find pifinder-src from the current closure (not just any old one in the store) +PFSRC_REL=$(nix path-info -r "$CLOSURE" | grep pifinder-src | head -1) +echo "PiFinder source from closure: $PFSRC_REL" +ssh "${PROXNIX}" "sudo bash -euo pipefail -c ' +PFSRC=\"${NFS_ROOT}${PFSRC_REL}\" +if [ ! -d \"\$PFSRC\" ]; then + echo \"ERROR: pifinder-src not found: \$PFSRC\" + exit 1 +fi +PFHOME=${NFS_ROOT}/home/pifinder/PiFinder + +echo \"PiFinder source: ${PFSRC_REL}\" + +[ -L \"\$PFHOME\" ] && rm \"\$PFHOME\" +[ -d \"\$PFHOME\" ] && rm -rf \"\$PFHOME\" + +ln -sfT \"${PFSRC_REL}\" \"\$PFHOME\" + +mkdir -p ${NFS_ROOT}/home/pifinder/PiFinder_data +chown 1000:100 ${NFS_ROOT}/home/pifinder/PiFinder_data +'" + +# ── Copy firmware to TFTP (from raspberrypi firmware package) ──────────────── + +echo "Copying firmware to TFTP..." +FW_PKG=$(nix build nixpkgs#raspberrypifw --print-out-paths --system aarch64-linux 2>/dev/null) +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}" + +# Copy firmware files +rsync -avz "${FW_PKG}/share/raspberrypi/boot/"*.{elf,dat,bin,dtb} "${PROXNIX}:/tmp/fw/" +ssh "${PROXNIX}" "sudo cp /tmp/fw/* ${TFTP_ROOT}/ && rm -rf /tmp/fw" + +# Copy custom u-boot with network boot priority +UBOOT=$(nix build .#packages.aarch64-linux.uboot-netboot --print-out-paths --system aarch64-linux 2>/dev/null) +echo "Using custom u-boot: $UBOOT" +rsync -avz "${UBOOT}/u-boot.bin" "${PROXNIX}:/tmp/u-boot-rpi4.bin" +ssh "${PROXNIX}" "sudo mv /tmp/u-boot-rpi4.bin ${TFTP_ROOT}/" + +# ── Copy kernel, initrd, DTBs to TFTP ──────────────────────────────────────── + +echo "Copying kernel/initrd/DTBs to TFTP..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/nixos" +rsync -avz "${KERNEL}" "${PROXNIX}:/tmp/${KERNEL_NAME}" +rsync -avz "${INITRD}" "${PROXNIX}:/tmp/${INITRD_NAME}" +ssh "${PROXNIX}" "sudo mv /tmp/${KERNEL_NAME} /tmp/${INITRD_NAME} ${TFTP_ROOT}/nixos/" + +# Copy NixOS-built DTBs (with camera overlay baked in) to dtbs/ subdirectory +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/dtbs" +rsync -avz "${DTBS}/broadcom/" "${PROXNIX}:/tmp/dtbs/" +ssh "${PROXNIX}" "sudo cp /tmp/dtbs/*.dtb ${TFTP_ROOT}/dtbs/ && sudo rm -rf /tmp/dtbs" + +# Copy overlays from kernel package +KERNEL_DIR=$(dirname "$KERNEL") +rsync -avz "${KERNEL_DIR}/dtbs/overlays/" "${PROXNIX}:/tmp/overlays/" +ssh "${PROXNIX}" "sudo rm -rf ${TFTP_ROOT}/overlays && sudo mv /tmp/overlays ${TFTP_ROOT}/" + +# ── Write config.txt for u-boot ────────────────────────────────────────────── + +echo "Writing config.txt..." +ssh "${PROXNIX}" "sudo tee ${TFTP_ROOT}/config.txt > /dev/null" << CONFIG +[pi4] +kernel=u-boot-rpi4.bin +enable_gic=1 +armstub=armstub8-gic.bin + +disable_overscan=1 +arm_boost=1 + +[all] +arm_64bit=1 +enable_uart=1 +avoid_warnings=1 +CONFIG + +# ── Generate extlinux/extlinux.conf ──────────────────────────────────────────── + +echo "Generating extlinux/extlinux.conf..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/extlinux && sudo tee ${TFTP_ROOT}/extlinux/extlinux.conf > /dev/null" << EXTLINUX +TIMEOUT 10 +DEFAULT nixos-default + +LABEL nixos-default + MENU LABEL NixOS - Default + LINUX /nixos/${KERNEL_NAME} + INITRD /nixos/${INITRD_NAME} + FDTDIR /dtbs + APPEND init=${INIT_PATH} ip=dhcp console=ttyS0,115200n8 console=ttyAMA0,115200n8 console=tty0 loglevel=4 +EXTLINUX + +# ── Create pxelinux.cfg for faster MAC-based boot ───────────────────────────── + +echo "Creating pxelinux.cfg/01-${PI_MAC}..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/pxelinux.cfg && sudo ln -sf ../extlinux/extlinux.conf ${TFTP_ROOT}/pxelinux.cfg/01-${PI_MAC}" + +# ── Clean up old artifacts ─────────────────────────────────────────────────── + +echo "Cleaning up old artifacts..." +ssh "${PROXNIX}" "sudo rm -f ${TFTP_ROOT}/cmdline.txt ${TFTP_ROOT}/nixos/patched-initrd 2>/dev/null || true" +ssh "${PROXNIX}" "sudo rm -f /tmp/ssh_host_*" + +# ── Restart TFTP ───────────────────────────────────────────────────────────── + +echo "Restarting TFTP server..." +ssh "${PROXNIX}" "sudo systemctl start atftpd.service" + +# ── Verification ───────────────────────────────────────────────────────────── + +echo "" +echo "==========================================" +echo "VERIFYING DEPLOYMENT CONSISTENCY" +echo "==========================================" +VERIFY_FAILED=0 + +echo -n "Checking u-boot... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/u-boot-rpi4.bin"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking config.txt... " +if ssh "${PROXNIX}" "grep -q 'kernel=u-boot-rpi4.bin' ${TFTP_ROOT}/config.txt"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking extlinux/extlinux.conf... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/extlinux/extlinux.conf"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking kernel... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/nixos/${KERNEL_NAME}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking initrd... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/nixos/${INITRD_NAME}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking NFS closure... " +if ssh "${PROXNIX}" "test -f ${NFS_ROOT}${INIT_PATH}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking PiFinder symlink... " +PFSRC_TARGET=$(ssh "${PROXNIX}" "readlink ${NFS_ROOT}/home/pifinder/PiFinder 2>/dev/null || true") +if [ -n "$PFSRC_TARGET" ] && ssh "${PROXNIX}" "test -d ${NFS_ROOT}${PFSRC_TARGET}/python"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo "==========================================" + +if [ $VERIFY_FAILED -eq 1 ]; then + echo "=== DEPLOY FAILED VERIFICATION — DO NOT BOOT ===" + exit 1 +fi + +echo "=== Deploy complete and verified ===" +echo "" +echo "Boot chain: Pi firmware → u-boot → extlinux/extlinux.conf → NFS root" +echo "To boot the Pi: power cycle it" diff --git a/docs/adr/0005-focus-hfd-self-contained-in-ui.md b/docs/adr/0005-focus-hfd-self-contained-in-ui.md index a83625757..997420059 100644 --- a/docs/adr/0005-focus-hfd-self-contained-in-ui.md +++ b/docs/adr/0005-focus-hfd-self-contained-in-ui.md @@ -1,18 +1,20 @@ # Focus-quality indicator: self-contained HFD measured in the UI -The focus screen (`UIPreview`) measures focus quality with **Half-Flux Diameter (HFD)** — the diameter enclosing half a star's background-subtracted flux — computed by its **own lightweight star detector** running in the main process on the raw 512×512 frame, deliberately tuned to accept broad, defocused blobs (up to a ~50 px size cap). It does **not** reuse the solver's tetra3/Cedar centroids or SQM's photometry. The driving reason: a badly defocused frame **does not plate-solve**, so solver-derived stars (and SQM, which only runs on successful solves) vanish at exactly the moment focus help is needed most. A self-contained detector is the only source of focus feedback that works across the entire defocus range, including the donut phase at the start of a focus sweep. The math lives in a pure `focus.py` module (no UI/PIL deps) so it is unit-testable against synthetic blobs of known width; `UIPreview` owns only rolling-window state and rendering. +The focus screen (`UIPreview`) measures focus quality with **Half-Flux Diameter (HFD)** — the diameter enclosing half a star's background-subtracted flux — computed by its **own lightweight star detector** running in the main process on the raw 512×512 frame, deliberately tuned to accept broad, defocused blobs (up to a ~50 px HFD measurement cap). It does **not** reuse the solver's tetra3/Cedar centroids or SQM's photometry. The driving reason: a badly defocused frame **does not plate-solve**, so solver-derived stars (and SQM, which only runs on successful solves) vanish at exactly the moment focus help is needed most. A self-contained detector is the only source of focus feedback that works across the entire defocus range, including the donut phase at the start of a focus sweep. The math lives in a pure `focus.py` module (no UI/PIL deps) so it is unit-testable against synthetic blobs of known width; `UIPreview` owns only raw crop rendering. ## Considered Options - **Reuse the solver's matched centroids via `shared_state`.** Rejected: matched-centroid count goes to zero when defocused. Focus guidance would go blind precisely when the image is most out of focus — the worst possible failure mode for the feature. - **Open a second Cedar gRPC client from the UI.** Rejected: Cedar is tuned to *reject* fuzzy/large blobs (`max_size=10`, `sigma=8`) to find tight stars for solving, so it returns little or nothing on a defocused donut. Wrong tool for the defocused regime, and it adds cross-process coupling to the UI. - **Share/refactor SQM's `_measure_star_flux_with_local_background` patch geometry.** Rejected: SQM is a different bounded context, runs in the solver process, is gated to once per 5 s on successful solves, and is photometry-tuned. The genuine overlap is ~15 lines of generic numpy patch math carrying no domain meaning; duplicating it keeps the two contexts decoupled and lets each evolve independently. SQM does not detect stars at all, so there is no "two competing star-finders" concern. -- **FWHM instead of HFD.** Rejected: FWHM assumes a Gaussian-ish PSF and breaks on saturated cores and broad/donut defocus — the exact shapes seen across a focus sweep. HFD stays stable and monotonic over the full range, which is what makes the "stop at the minimum" workflow legible. +- **FWHM instead of HFD.** Rejected as the primary focus metric: FWHM assumes a Gaussian-ish PSF and breaks on saturated cores and broad/donut defocus — the exact shapes seen across a focus sweep. HFD stays stable and monotonic over the full range. The statistics display may show an area-equivalent FWHM estimate as supplementary information, clearly labelled and never substituted for HFD. - **Report HFD in arcseconds.** Rejected: the PiFinder's ~10.2° / 512 px optics give ~72 arcsec/px, so an in-focus star would read as ~70–215 arcsec — ~30× worse than the 2–4 arcsec users expect from imaging-rig FWHM, inviting false alarm and false comparison. Pixels are the honest native unit, and only the *relative* trend matters for finding best focus. ## Consequences - A second, intentional star-finder exists in the codebase, tuned opposite to Cedar (accept blobs rather than reject them). This is deliberate, not an oversight or a missed reuse opportunity. -- HFD is always computed on the **raw** frame, independent of the display contrast stretch — the reported number never depends on how the image looks. (This also lets the focus screen replace blind per-frame `autocontrast` with a background-anchored, EMA-smoothed stretch driven by the detector's background/peak estimates.) +- HFD is always computed on the **raw** frame. The four visual star tiles also preserve raw luminance: crop, nearest-neighbour enlargement, and the panel's red/grey channel mask are the only display operations. There is no contrast stretch, denoising, sharpening, blur, or interpolating resize. +- The four brightest detected blobs anywhere in the camera frame are repacked into the four display quadrants to use the small screen efficiently. Their relative image-space pattern is tracked under shared translation, preserving each surviving star's slot through global image motion and brightness-order changes; missing stars are replaced. HIP/catalog identity is deliberately not used because it requires a successful plate solve, while Focus must keep working before and during defocus. Compact stars use a nominal 10× enlargement relative to a full-frame preview; the crop expands around broad blobs so the effective zoom falls rather than clipping a defocused star. +- Short `SQUARE` follows the standard UI display-mode convention, cycling the four-star view, a full-screen view of the brightest star, an Image view, and focus statistics. The Image view uses the original per-frame autocontrast for display only; measurement, the star crops, and the Stats histogram remain raw. Single uses twice the apparent magnification of Stars while retaining adaptive crop expansion for broad blobs. In the four-star view the rolling 10-second HFD signal is split around the center readout; in the brightest-star view both sit on a translucent lower-third overlay so the raw star remains visible beneath them. The gap follows the actual glyph bounds so its visual padding is equal on both sides. Unavailable HFD is always `?.?`; no upper-limit sentinel is displayed. The recent signal range is centered on the readout with lower values below; this deliberately avoids claiming a universal good-focus HFD across different optics. No guide, marker, or under-stroke accompanies the signal. The statistics page includes HFD, supplementary FWHM, detected count, exposure mode/value, gain, and a log-scaled histogram of the raw frame. - The indicator works without any plate solve, so the focus screen is useful before the system has ever solved. - Companion glossary: [`docs/ax/ui/CONTEXT.md`](../ax/ui/CONTEXT.md) (Focus indicator section). diff --git a/docs/adr/0020-sqm-raw-green-photometry-redesign.md b/docs/adr/0020-sqm-raw-green-photometry-redesign.md new file mode 100644 index 000000000..eb51587a1 --- /dev/null +++ b/docs/adr/0020-sqm-raw-green-photometry-redesign.md @@ -0,0 +1,98 @@ +# SQM: raw-green photometry with self-calibrating black level, color term, and wing correction + +The shipped SQM was computed on the 8-bit processed display image with a static +pedestal constant. It wandered 0.7–2.4 mag within a night as auto-exposure moved, +and shifted with focus and sensor swaps. We redesigned it to be self-calibrating: +every correction is either measured from the frame stream itself or is a fixed +property of the sensor — no hand-tuned absolute constant. + +The plate-solving path is untouched: solving stays on the processed image +(bare sensors keep their NIR sensitivity for solving); only SQM photometry moves +to the raw frame. + +## The decision, in four parts + +1. **Photometry on the raw green channel** (`sqm_use_raw_green` in the camera + profile). The processed image's clipping, 8-bit quantisation and resize break + the flux linearity photometry needs. For Bayer sensors the green channel is + the mean of the two green sites; solve centroids are scaled from the 512-px + processed frame to green-frame pixels. If the raw frame is unavailable the + SQM cycle is **skipped**, never computed on the processed image with the raw + profile. + +2. **Black level from a per-frame joint fit** (`PedestalEstimator`). Auto-exposure + sweeps exposure continuously, so fitting `background = P0 + rate·exposure` + over a rolling window recovers the true black level `P0` (glossary: this is + the *bias offset*; the fitted slope is the dark-current term and is discarded, + never subtracted). A static constant that misses the true black level is what + made SQM exposure-dependent. The fit refuses to run until the exposure range + spans ≥2×; the profile `bias_offset` is the fallback. + +3. **Color term** `V − T·(B−V)` per matched star (B−V from Hipparcos by HIP id). + The catalog magnitude is Johnson V but the sensor passband is not: bare color + sensors leak NIR, over-fluxing red stars. `T` is per-sensor + (`color_coefficient`): 0.8 measured on-sky for bare imx462/imx290, 0.0 for + HQ (factory IR-cut; measured −0.05 ≈ 0), 0.0 *placeholder* for the imx296 + mono (unmeasured — no working unit available). This is an instrumental + passband match — the same thing a hardware SQM meter's fixed spectral + response does — and is distinct from the atmospheric correction that + ADR-0002 keeps out of the published value. + +4. **Wing correction** (`WingEstimator`). Curve-of-growth measurement on the + sweeps showed the r=5 px aperture misses 25–38% of each star's flux in the + lens-halo wings, biasing `mzero` low and SQM bright by 0.3–0.5 mag — the + dominant absolute error. The estimator finds each star's wing boundary by + growing rings until the profile flattens, measures the aperture's enclosed + fraction `f`, smooths `f` in a rolling window, and applies `−2.5·log10(f)` + to `mzero` on every frame. The **measure/smooth/apply split is load-bearing**: + wings sink below the noise at short exposures, so a per-frame correction + re-introduces the exposure dependence (measured: exposure slopes up to + −1.2 mag/dex and 3–6× worse scatter). + +5. **Robust mzero.** The zero point is the **median** of the per-star zero + points, not a flux-weighted mean, and stars peaking above 70% of full scale + are excluded (CMOS response bends well before hard clip). A flux-weighted + mean concentrates the vote in the brightest stars — exactly the ones prone + to nonlinearity and to colour-term extrapolation (B−V lookups are clamped + to ≤1.2 for the same reason). One near-saturated red giant dragged a + night's SQM by 0.5 mag under flux weighting; the median is unmoved + (validated: night-to-night spread 0.63 → 0.06 mag on the imx462 sweeps). + +## Considered and rejected + +- **IR-cut filter (hardware):** clobbers the NIR sensitivity plate-solving + depends on, and thousands of devices are already in the field. +- **Per-sensor zero-point constant** (additive mag offset calibrated against a + reference meter): implemented, then removed. It rested on 3 hand-read meter + points per sensor, and the residual it papered over turned out to be the wing + loss (correctable from data) plus night-to-night reference scatter (not a + sensor property). A constant cannot represent either. +- **Per-frame adaptive apertures** (radius ∝ HFD): wings extend far beyond any + HFD-scaled radius, and the correction becomes exposure-dependent. +- **Low-percentile / minimum annulus background:** order statistics of a noisy + sky sit below truth by a noise-dependent amount, and noise varies with + exposure — measured slopes to −0.6 mag/dex. Median (or a mode estimator on + large samples) is the only safe annulus statistic. +- **Faint-star cut in the mzero fit:** pooled per-star statistics show the + faintest flux quartile reading −0.11 mag (identically on imx462 and hq — + a fixed-ADU annulus error eats a bigger fraction of a small flux). Cutting + those stars was scanned across the 8-sweep ensemble and made every metric + worse: the median mzero already absorbs the tail, and shrinking a 10–20 + star sample toward 3–5 costs more in median noise than the bias removal + gains. Don't re-invent this. + +## Consequences + +- On the six validation sweeps, SQM is exposure-flat and focus-flat + (std 0.07–0.22 mag within a sweep) and lands within **±0.07 mag** of the + hand-held reference meter on 4 of 6 nights with no tuned constant. Two nights + read ~0.6 mag apart from the reference for reasons the star photometry cannot + see (reference pointing/haze suspected); settling the absolute anchor needs a + proper campaign (side-by-side meter on the camera field, or M67 standard + photometry). +- All published values change scale relative to prior firmware; prior SQM logs + are not comparable. +- The imx296 mono `color_coefficient=0.0` is an unmeasured placeholder; measure + it when a working unit exists. +- The estimators condition over the first minutes of operation; until then SQM + runs on the profile fallback (`bias_offset`, no wing correction). diff --git a/docs/ax/nixos.md b/docs/ax/nixos.md new file mode 100644 index 000000000..4c958217d --- /dev/null +++ b/docs/ax/nixos.md @@ -0,0 +1,20 @@ +# NixOS — architecture notes + +Companion to [`nixos/CONTEXT.md`](./nixos/CONTEXT.md): how the pieces named there actually move. Sections are added as they're worked through; today it covers the on-device download. + +## Installing a version (download, availability, progress) + +Installing any version works the same way whether it's a channel pick, a rollback, or the first-boot download during the move to NixOS: the device **downloads** the files that make up that version from the cache and switches to them. It never rebuilds anything — if a file is missing from the cache, the install stops rather than compiling it. + +### One up-front query, two jobs + +When a version is chosen, the device makes a single request to the cache for that version's complete set of files and each file's download size. From the answer it gets: + +- **The download size, up front.** Drop the files already on the device, add up the sizes of the rest — a fixed total in megabytes, known before the download starts. +- **Whether the version is still there.** If the cache can't return the full set, the version has been removed: stop immediately with "no longer available" instead of failing partway through. Only **unstable** versions can reach this state; **stable** and **beta** are kept forever (see [`nixos/CONTEXT.md`](./nixos/CONTEXT.md) and [ADR 0002](./nixos/adr/0002-update-channels-and-rollback.md)). The check happens at the moment a version is picked, so browsing the list costs nothing. + +### Progress + +The bar is **size-based**: megabytes downloaded out of the up-front total, advancing each time a file finishes (its known size is added to the running total). Because the total is fixed from the start, the bar is honest from the first moment. + +This replaces the earlier behaviour, which counted files against a total that itself grew as the download proceeded — so early percentages were meaningless, and even a correct count would misreport progress because file sizes vary by orders of magnitude. The first-boot download already fixed its total up front but still counted files; it moves to the same size-based approach so both downloads behave identically. diff --git a/docs/ax/nixos/CONTEXT.md b/docs/ax/nixos/CONTEXT.md new file mode 100644 index 000000000..25bb5bd02 --- /dev/null +++ b/docs/ax/nixos/CONTEXT.md @@ -0,0 +1,98 @@ +# NixOS + +How a NixOS PiFinder system is built, published, and updated over the air — the binary cache, the release/channel metadata, and the on-device upgrade flow. Distinct from the Raspbian→NixOS one-time **Migration**, which this context feeds but does not own. + +## Language + +### Repositories and their roles + +**Upstream**: +The canonical public PiFinder repo, `brickbots/PiFinder`. The to-be home of releases, update channels, and (eventually) build infrastructure. On-device update channels already read from here (the **update manifest** on the `nixos-manifest` branch). +_Avoid_: "the main repo", bare "brickbots" in prose, "official". + +**Fork**: +`mrosseel/PiFinder` (git remote `origin`). Where NixOS is developed (the `nixos` branch) and, through the transition, where every NixOS artifact is produced — CI builds, the Attic cache, the update manifest, release tags, and the migration tarball. +_Avoid_: "my repo", bare "mrosseel", "the staging repo" used interchangeably with branch names. + +**Trunk**: +The branch that holds the live NixOS development tip and feeds the "unstable" channel's non-PR entry. Today that is `nixos` **on the Fork**; in the steady state it is `main` on the Upstream. The branch is a single switch (`TRUNK_BRANCH`), not hard-coded to `main`. +_Avoid_: conflating "trunk" with the literal branch name `main`. + +### Transition + +**Phase 1**: +Release/channel metadata and the migration gate live on the **Upstream**; the **Attic cache** and the **pi5 runner** (build infrastructure) stay on the **Fork**. A Pi reads release metadata from the Upstream but substitutes store paths from the Fork's cache. + +**Phase 2**: +Everything — builds, cache, releases, channels — is hosted by the **Upstream**. The Fork reverts to an ordinary contributor fork. + +**In-between phase**: +The current state: no NixOS artifacts exist on the Upstream yet (PR #379 not merged), so all three channels are temporarily sourced from the **Fork** (its releases for stable/beta, its `nixos` trunk + testable PRs for unstable) via a single switch, purely so they can be exercised for testing. Reverts to Upstream/`main` at upstreaming. + +### Channels + +The on-device UI offers three update **channels**, each mapped to one stage of the branch promotion flow (testable PRs → `main` → `release`). Choosing a channel and a version resolves through the **update manifest** to a store path and installs it. + +**stable**: +The production channel — official release entries in the generated manifest. The default for ordinary users. +_Avoid_: "release channel" (the *branch* is `release`; the *channel* is "stable"). + +**beta**: +The integration channel — GitHub **prereleases** (the `prerelease` flag), cut deliberately from `main`. Curated like stable (notes, explicit semver `vX.Y.Z-beta`, the version gate) and pushed to the *retained* cache, so beta builds reinstall durably too. Ceremonial, not continuous. +_Avoid_: naming the channel "prerelease" — it is "beta"; prerelease is its mechanism. + +**unstable**: +The bleeding-edge channel — the live `main`/**trunk** head plus open PRs carrying the `testable` label, each installable at its own head. The `main` entry is rendered more prominently to set it apart from the per-PR rows. Hidden until unlocked (7× square). +_Avoid_: "preview", "nightly". + +**As-is vs to-be:** channel *sourcing* matches the current code (stable/beta = Releases split on the prerelease flag; unstable = `main` head + testable PRs). The only deltas are cosmetic and transitional: render the unstable `main` entry more prominently than PR rows, and — until upstreaming — read from the Fork's `nixos` trunk (see In-between phase). + +### Rollback + +**Rollback**: +Returning a device — or the fleet — to a known-good build after a bad one ships. Guaranteed for **stable** and **beta** — both are Releases whose closures live in the retained `pifinder-release` cache; only **unstable** (`main` head / PR) builds may be GC'd from the short-retention dev cache. + +**Watchdog**: +The on-device boot guardian. It health-checks every boot of a not-yet-**confirmed** generation (a **trial**) and performs a **generation rollback** when the trial fails — capturing evidence and telling the operator on screen. It never rolls back a confirmed generation, but it still *reports*: a confirmed generation whose app fails gets an on-screen advisory naming the **recovery hold** (see [NixOS ADR 0005](./adr/0005-self-arming-watchdog-confirmed-generations.md)). + +**Trial**: +The probation boot of a generation that has not yet proven itself on this device. Every boot of an unconfirmed generation is a trial, regardless of which build installed it. A passed trial **confirms** the generation. +_Avoid_: "first boot" as a synonym — a trial can recur (e.g. after a crash before the health check completed). + +**Health check**: +What a trial must pass: the app *itself* declares that its UI is live (readiness announced from the first drawn frame), and then stays up briefly. A merely-running process is not healthy — a build that starts but never turns the screen on must fail its trial. Outside supervised boots (development runs), the readiness announcement is a harmless no-op. +_Avoid_: equating "healthy" with "the service started". + +**Confirmed generation**: +A generation that has passed a trial on this device, recorded locally. Confirmed generations are never auto-rolled-back — later failures are for the user-driven recovery ladder (Rollback channel, **recovery hold**, SSH), not the watchdog. +_Avoid_: "committed" (overloaded with VCS meaning). + +**Recovery hold**: +The user gesture that enters **recovery mode**: hold the square-equivalent input while powering on, and keep holding until RECOVERY appears. Detected only during a short boot window (so it can never fire mid-observation). Deliberately a single input: v4's joystick makes multi-key chords physically impossible, and a square-equivalent will always exist. +_Avoid_: "recovery chord" (one input, not a chord). + +**Recovery mode**: +The interactive rescue environment the **recovery hold** boots into: the update screen alone (no camera/solver/positioning), offering the local generation overview — marking the generation that was about to boot and each **confirmed generation** — plus the normal internet channels. Choosing a generation is *sticky* (it becomes the boot default); an internet pick installs through the ordinary upgrade flow and faces a **trial** like any install. If recovery mode itself fails its **health check**, the device falls back to a blind **generation rollback** to the newest confirmed generation. Never touches user data. +_Avoid_: "safe mode" (nothing about the broken build is run "safely" — recovery either works or falls through), "factory reset" (nothing is wiped). + +**Generation rollback**: +The instance-local revert to an earlier NixOS generation. Triggered automatically by the **watchdog** when a **trial** fails, or manually. Bounded — local generations are pruned to three (the running one plus two rollback targets, surfaced in the Software screen's Rollback channel). + +**Reinstall an older build**: +The durable rollback path: pick a prior version and install it — the device substitutes its prebuilt closure (it never compiles; the upgrade is `nix build … --max-jobs 0`), so the only requirement is that the closure still lives in a reachable cache. For **stable** and **beta** that is guaranteed (their closures live in the never-GC'd `pifinder-release` cache), so any past release reinstalls forever; **unstable** closures may be GC'd from the short-retention cache, at which point that exact build is un-installable until CI rebuilds and re-pushes it. Survives generation pruning and covers boots-but-misbehaves bugs the watchdog cannot catch. + +**Yank**: +A release-level rollback — demoting a buggy official Release (to draft/prerelease, or superseding it) so it leaves the **stable** channel for *new* installs. There is no fleet-wide auto-revert: a device already on a yanked build surfaces an **advisory** (a status/notification that its version is withdrawn) prompting the user to choose the latest, who then recovers by **reinstalling an older build**. User-initiated, never automatic. + +### Build and cache + +**Attic cache**: +The binary cache at `cache.pifinder.eu`, with two namespaces: `pifinder` (dev builds, short retention) and `pifinder-release` (tagged releases, GC-disabled). Every Pi substitutes signed store paths from here. Hosted on the Fork's side through Phase 1. +_Avoid_: "cachix" (an earlier/alternative cache; the current one is Attic — see [NixOS ADR 0001](./adr/0001-attic-binary-cache.md)). + +**pi5 runner**: +The self-hosted aarch64 GitHub Actions runner that builds NixOS systems natively, with a hosted `ubuntu-*-arm` runner (also native aarch64 — no emulation) as fallback. Fork-side infrastructure through Phase 1. + +**Update manifest**: +`update-manifest.json` — the generated channel listing published on a metadata-only branch (`nixos-manifest` during the fork transition). It maps releases, trunk builds, and testable PR builds to signed Nix `store_path`s in the Attic cache (and, for releases, to the migration tarball). The device reads this raw JSON file instead of calling the GitHub API; it is the single mapping between versions and store paths. +_Avoid_: bare "manifest" (say *update* manifest), "build stamp" (a retired concept: `pifinder-build.json` is gone — a device's identity lives in one file, seeded at image build and rewritten by every upgrade). diff --git a/docs/ax/nixos/HANDOVER.md b/docs/ax/nixos/HANDOVER.md new file mode 100644 index 000000000..1d678b5c0 --- /dev/null +++ b/docs/ax/nixos/HANDOVER.md @@ -0,0 +1,56 @@ +# NixOS Handover + +Current state as of `2026-06-24`: + +- Latest pushed commit on `mrosseel/PiFinder:nixos` is `210f2c08` (`feat(nixos): drive update channels from manifest`). +- The branch is clean locally in the current worktree. +- GitHub Actions run `28119693140` is in progress for that push. + +## What changed + +- The PiFinder software UI no longer queries the GitHub REST API at runtime. +- Device channel data now comes from one generated raw JSON file: + - `https://raw.githubusercontent.com/mrosseel/PiFinder/nixos-manifest/update-manifest.json` +- The manifest contains: + - stable release entries + - beta prerelease entries + - unstable trunk + testable PR entries +- Old stamp-commit behavior on the source branch was removed. +- CI now updates the metadata-only `nixos-manifest` branch instead of committing `pifinder-build.json` back onto `nixos`. + +## Important files + +- [`python/PiFinder/ui/software.py`](../../python/PiFinder/ui/software.py) +- [`python/tests/test_software.py`](../../python/tests/test_software.py) +- [`.github/scripts/update_manifest.py`](../../.github/scripts/update_manifest.py) +- [`.github/workflows/build.yml`](../../.github/workflows/build.yml) +- [`.github/workflows/release.yml`](../../.github/workflows/release.yml) +- [`docs/ax/nixos/CONTEXT.md`](./CONTEXT.md) +- [`nixos/RELEASE.md`](../../nixos/RELEASE.md) + +## Verified locally + +- `nix develop path:. -c bash -lc 'cd python && pytest -m unit tests/test_software.py -q'` +- `nix develop path:. -c bash -lc 'cd python && mypy PiFinder/ui/software.py'` +- `python3 -m py_compile .github/scripts/update_manifest.py` + +The focused software tests pass in the Nix Python 3.13 environment. + +## Device state + +- On the real PiFinder, `/home/pifinder/PiFinder` is a root-owned symlink into `/nix/store`. +- The running `pifinder.service` uses the store-backed source tree, not writable local source. +- `/var/lib/pifinder/current-build.json` reflects the installed build after updates. + +## Current risk + +- The manifest workflow is new and needs CI confirmation. +- Build-native and update-manifest passed in CI on the last run before this handover. +- Release workflow still writes a temporary `pifinder-build.json` in the workspace for build stamping inside the job, but it no longer commits that file back to the source branch. + +## If you continue + +1. Watch run `28119693140` to completion. +2. Verify `nixos-manifest` exists and contains `update-manifest.json`. +3. If CI fails, look first at the `update-manifest` job and the release workflow step that pushes the metadata branch. +4. If the device still shows no PRs, inspect the manifest contents, not the GitHub API, because runtime no longer calls GitHub REST. diff --git a/docs/ax/nixos/adr/0001-attic-binary-cache.md b/docs/ax/nixos/adr/0001-attic-binary-cache.md new file mode 100644 index 000000000..3a26fd2ce --- /dev/null +++ b/docs/ax/nixos/adr/0001-attic-binary-cache.md @@ -0,0 +1,95 @@ +# Self-hosted Attic for NixOS binary distribution + +A NixOS PiFinder runs from pre-built binaries in `/nix/store/`; updates work by +atomically swapping the running system for a new closure of pre-built binaries. +Distributing those binaries requires a **binary cache** — a server that hands +them out on demand, since recompiling from source on a Pi is not viable (Rust +crates alone take hours). We will self-host the [Attic](https://github.com/zhaofengli/attic) +binary cache at `cache.pifinder.eu`, backed by SQLite and local disk initially, +with Cloudflare R2 as the eventual chunk store. Attic is a small Rust server +that adds **content-defined chunking (FastCDC)** on top of the standard Nix +substituter protocol: every NAR is sliced into variable-size chunks by byte +content and identical chunks are stored exactly once. This dedup is +**server-side** — it shrinks storage across releases, and because `attic push` +chunks on the runner it makes the **CI upload** proportional to actual changes. +It does **not** delta the device download: Attic serves whole NARs over the +standard binary-cache protocol, so a device fetches the full (compressed) NAR of +every store path whose hash changed — not a chunk-delta against the previous +version of that path. The saving devices get is **path-level**: the 90–95% of a +closure that is unchanged between releases (identical store hashes) is not +refetched at all, so an update pulls only the changed paths' NARs — on the order +of tens of MB for a 1.5 GB closure, but each of those in full. True client-side +chunk-delta downloads need a casync/desync-style client that keeps a local chunk +store; the standard Nix client — and therefore Attic, harmonia, and nix-casync +used as substituters — does not do this. (That client-delta property is exactly +why desync is used for the out-of-closure astro-data blobs; see the data-blob +distribution notes.) + +## Considered Options + +- **Stay on cachix.org indefinitely.** Rejected: SaaS quota caps (storage tier, + push throttling) become a planning concern as the system closure grows; ships + full NARs per closure (no chunking), so every update transfers the full + closure even when 5% of bytes changed; per-cache pricing scales linearly with + closure count. +- **Magic Nix Cache (DetSys) alone.** Rejected: backed by GitHub Actions Cache + (~10 GB per repo, ephemeral, HTTP-418 rate-limited under sustained traffic — + already broke a `type-check` job once). Useful for CI runner-local caching, + not for distributing binaries to end-user devices, which it cannot do at all. +- **nix-casync.** Same content-defined-chunking idea, predates Attic, but + distributed as a standalone tool rather than a hosted server; would need to + assemble the server side ourselves. Attic delivers the same dedup story as a + complete package. +- **harmonia.** Newer self-hosted alternative; simpler than Attic but no + chunking. Loses the headline bandwidth-and-storage saving. + +## Consequences + +- **Operational ownership:** PiFinder takes on a small piece of infrastructure + (one VPS, one Rust binary, one SQLite file, one Caddy reverse-proxy with + Let's Encrypt). Sized at Hetzner CX22 / €4 month for the foreseeable future; + SQLite handles millions of chunks before PostgreSQL becomes necessary. Backup + story is "snapshot the SQLite file and the chunk directory" — same pattern as + a typical small VPS service. +- **Egress economics:** Cloudflare R2 charges zero egress, which matters when + distributing updates to a globally-dispersed PiFinder fleet. Self-hosted on a + Hetzner VPS the egress is also effectively free at typical hobby volumes. + Either way, the bandwidth question stops being a recurring concern. +- **CI publish step:** `build.yml`'s `cachix-action` step is replaced by an + `attic push` step using a long-lived JWT minted by `atticadm make-token + --push pifinder`, stored as `secrets.ATTIC_TOKEN`. Chunking happens on the + runner; the server only ingests new chunks. Push payload is proportional to + actual changes, not to closure size. +- **Device pull side:** `services.nix` declares `cache.pifinder.eu` as a + substituter alongside `cache.nixos.org`, with the Attic public key in + `trusted-public-keys`. The existing on-device upgrade flow + (`pifinder-upgrade.service`, `nix build "$STORE_PATH" --max-jobs 0`) is + unchanged — the new substituter is transparent. Users see the same + "downloading N/M" progress in the menu, just with smaller N. +- **Failure model:** Nix tries substituters in order and falls through. If + `cache.pifinder.eu` is unreachable, the device falls through to + `cache.nixos.org` for any path that exists there. The "Attic outage = bricked + PiFinders" scenario does not exist for paths nixpkgs already publishes; only + locally-built paths (kernel with our overlays, `cedar-detect-server`, Python + wheels) are at risk during an outage, and those are cached locally on devices + that previously updated successfully. +- **Migration tarball stays self-contained.** The boot-from-tarball path + (`pifinder-nixos-v3.0.0.tar.zst` on the GitHub release) is independent of the + cache and remains the way a stock Debian PiFinder bootstraps into NixOS. A + later refinement could ship a smaller tarball that pulls the bulk of the + closure from Attic on first boot, but that is out of scope for this ADR. +- **No retirement of cachix.org is mandated here.** Whether to keep cachix.org + as a fallback or drop it after Attic is proven is a separate operational + decision; the substituter list can carry both indefinitely with no penalty + beyond the cachix subscription cost. +- **Two caches, split by retention.** The server hosts two Attic caches: + `pifinder` (dev/nightly builds from `build.yml`, short retention — these churn + on every push) and `pifinder-release` (tagged release closures from + `release.yml`, garbage collection disabled). The split exists because Attic + retention is per-cache, not per-path: a device may upgrade to a release months + after it was cut, so its closure must never be GC'd, while dev builds should + not accumulate forever. Chunk dedup is global across caches on the same + server, so storing a release closure separately costs only its genuinely-new + chunks. Each cache has its own signing key; devices trust both, plus + `cache.nixos.org`. (Originally a single `pifinder` cache; this followed once + releases started flowing through Attic instead of cachix.) diff --git a/docs/ax/nixos/adr/0002-update-channels-and-rollback.md b/docs/ax/nixos/adr/0002-update-channels-and-rollback.md new file mode 100644 index 000000000..f0b92a641 --- /dev/null +++ b/docs/ax/nixos/adr/0002-update-channels-and-rollback.md @@ -0,0 +1,16 @@ +# Update channels stay Release-based (stable/beta) over a live main+PR unstable; rollback via reinstall + passive yank + +The three on-device update **channels** map onto the git promotion flow (testable PRs → `main` → `release`) and resolve through a **build stamp** (`pifinder-build.json`) to a store path: **stable** = official GitHub Releases (non-prerelease, `≥ MIN_NIXOS_VERSION`); **beta** = GitHub **prereleases** cut from `main`; **unstable** = the live `main`/trunk head plus open `testable`-labeled PRs (the `main` entry rendered more prominently than the PR rows). stable and beta are ceremonial Releases — both curated (notes, explicit semver, the version gate) and pushed to the *retained* cache; unstable tracks ref heads continuously and is hidden until unlocked. + +A bad build is recovered three ways, never by fleet-wide auto-revert: the **watchdog** reverts to the previous NixOS generation on a boot failure (once); a user can **reinstall any older build** by selecting it (the device only substitutes the prebuilt closure — `nix build … --max-jobs 0`, it never compiles); and a bad official release is **yanked** (demoted/superseded so it leaves the channel for new installs) plus a device **advisory** prompting affected units to choose the latest. + +## Considered options + +- **beta = live `main` head (continuous), rejected.** Briefly chosen, then reverted: GitHub's prerelease flag is built in and keeps beta symmetric with stable (notes, semver, gate). Decisively, a prerelease lands in the *retained* `pifinder-release` cache, so beta gets durable rollback; a `main`-head beta would sit in the short-retention cache and lose it. Continuous "every merge" delivery is unstable's job — `main` head lives there — not beta's. +- **Fully uniform branch-head channels (stable = `release` head), rejected.** Drops release notes, explicit versioning, the gate, and the SD-image/migration assets, and would need `build.yml` to stamp `release`. +- **Active kill-switch for yank, rejected for now.** Passive yank + advisory avoids a server-side bad-builds list and device polling/auto-revert; revisit only if the field shows the "already-running unit that never opens the update screen" gap is real. + +## Consequences + +- **Rollback is guaranteed for stable and beta** — both are Releases whose closures live in the never-GC'd `pifinder-release` cache ([0001](./0001-attic-binary-cache.md)), and the device never builds. Only **unstable** (`main`-head / PR) closures may be GC'd from the short-retention cache and become un-installable until CI rebuilds and re-pushes them. +- Channel *sourcing* matches the current code; the only deltas are cosmetic (render the unstable `main` entry more prominently than PR rows) and transitional (read from the Fork's `nixos` trunk until the NixOS line is upstreamed). diff --git a/docs/ax/nixos/adr/0003-migration-tarball-rides-latest-stable.md b/docs/ax/nixos/adr/0003-migration-tarball-rides-latest-stable.md new file mode 100644 index 000000000..e71ad352e --- /dev/null +++ b/docs/ax/nixos/adr/0003-migration-tarball-rides-latest-stable.md @@ -0,0 +1,20 @@ +# Migration tarball resolves its full system from the update manifest at first boot (rides latest stable), instead of pinning a closure + +> **Amended by [0004](./0004-migration-tarball-published-per-release.md):** publication is a per-(pre)release asset, not a built-once file. The resolve-at-first-boot mechanism below is unchanged. + +The migration tarball is the **minimal** bootable system; on first boot it downloads the **full** PiFinder system from the binary cache and switches to it. We will have first-boot resolve that store path from the **same update manifest the on-device updater reads** (`brickbots/PiFinder@nixos-manifest:update-manifest.json`, see [0002](./0002-update-channels-and-rollback.md)) — taking the newest entry in the best available channel, **stable → beta → unstable trunk** — rather than baking a fixed store path into the tarball. Because **stable** holds only Releases, whose closures live in the never-GC'd `pifinder-release` cache ([0001](./0001-attic-binary-cache.md)), a resolved stable path can't be garbage-collected out from under a published tarball. The tarball therefore never goes stale, is built **once** (not per Release), and every new migrator gets the *current* system rather than the snapshot the tarball was cut from. + +The trap this avoids: a baked/pinned closure couples the tarball's lifetime (months — until the next cut) to a single cache entry's lifetime. Pin into the short-retention `pifinder` cache and the closure is GC'd while the tarball is still the published download → first-boot fails. Pin into the retained `pifinder-release` cache and every intermediate test build piles up there forever (it is never GC'd) → the retained cache fills with dead closures, the exact thing "persist only the last version" is meant to prevent. Resolving *latest stable* at boot escapes both horns: nothing per-tarball is pinned, test churn stays in the self-cleaning unstable cache, and the only durable closures are deliberate Releases. + +## Considered options + +- **Bake the full closure into the tarball (self-contained), rejected.** Drops the cache dependency entirely and can never rot, but bloats the download (full system vs minimal) and freezes the migrated version to the tarball's build date. Resolving latest-stable keeps the tarball minimal and current. +- **Bake a pinned store path + push it to `pifinder-release`, rejected.** Durable, but every iteration pushes another full closure into the never-GC'd cache, and the tarball must be rebuilt and re-uploaded every Release. Directly conflicts with "only the last version persists." +- **Bake a pinned store path served from the short-retention `pifinder` cache, rejected.** Small and self-cleaning, but the pinned closure is GC'd (~90 days) and the still-published tarball breaks. + +## Consequences + +- First-boot reads the **update manifest**, not `pifinder-build.json` (which it previously fetched from `mrosseel/PiFinder@nixos`). First-boot and the updater now share one source of truth. +- Resolution order is **stable → beta → unstable trunk → baked-in `first-boot-target`**. The baked-in path survives only as a last-ditch fallback when the manifest can't be fetched. +- **Transitional:** with `stable`/`beta` empty until the first Release is cut, migration currently resolves the **unstable trunk**, which is the **`nixos` branch** (`source_ref == "nixos"`), not `main` — the NixOS line isn't upstream yet ([0002](./0002-update-channels-and-rollback.md)). Drop the `source_ref == "nixos"` guard once `nixos` becomes the mainline trunk. +- The tarball is built **once** and only rebuilt when the minimal migration system itself changes — never per Release. A "cut stable release" flow (build full system → push closure to `pifinder-release` → stamp a `stable` entry) is what makes new closures reachable; the tarball just rides whatever that produces. diff --git a/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md b/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md new file mode 100644 index 000000000..d11706ff1 --- /dev/null +++ b/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md @@ -0,0 +1,14 @@ +# Migration tarball is published as a (pre)release asset, not a built-once file (amends 0003) + +The tarball keeps [0003](./0003-migration-tarball-rides-latest-stable.md)'s *mechanism* — minimal system (`images.pifinder-migration`), full system resolved from the update manifest at first boot, so the tarball's content never goes stale — but its *publication* moves from "built once at a fixed URL" to an asset (with `.sha256` sidecar) on every stable/beta Release, referenced per-entry as `migration_url` in the manifest. Releases are visible, versioned, documented, and in the canonical place; a replace-in-place file on a private file server is none of those, and un-auditable after an incident. + +## Considered options + +- **Built-once evergreen tarball at a fixed URL (0003's original), rejected.** Minimal asset churn, but the artifact lives outside the release record: no version, no notes, no checksum ceremony, silent replace-in-place. The Raspbian-side updater would also need a hardcoded URL instead of reading the manifest like everything else. +- **Per-release *full-system* tarball (shipped briefly in the first release.yml), rejected.** ~1.4 GB per release forever, and it freezes the migrated system to the tarball's build date — the exact staleness trap 0003 exists to avoid. + +## Consequences + +- `release.yml` must tar `images.pifinder-migration`, **not** `images.pifinder` (the full SD image remains a separate asset for fresh flashes). +- Two-stage resolution: the Raspbian device picks a *tarball* (first manifest entry with a `migration_url`, stable → beta → unstable); first boot then picks the *closure* from the manifest — the authoritative version decision. Any reasonably recent tarball yields the same end state. +- Asset cost per release drops to the minimal system's size; old tarballs remain downloadable alongside their releases. diff --git a/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md b/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md new file mode 100644 index 000000000..486c4f1b8 --- /dev/null +++ b/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md @@ -0,0 +1,17 @@ +# Boot watchdog is self-arming via a confirmed-generations ledger; confirmed generations are never auto-rolled-back + +A generation is a **trial** until it passes one boot health check, which **confirms** it permanently on that device (recorded in a device-local ledger). Any boot of an *unconfirmed* generation is a trial — regardless of which build installed it — so recovery never depends on the previous system's code: the version-skew hole where an older build (unaware of trial markers) installs a broken new one and leaves it unprotected, which is exactly how the first v3.0.0-beta crash-looped with no rollback on 2026-07-03. Confirmed generations are never auto-rolled-back: a transient failure in the field must not cause a surprise downgrade. + +## Considered options + +- **Marker-armed only (upgrade writes a trial marker; no marker → nothing to watch), rejected.** The original design. Protection depends on the *installing* build knowing to arm the marker — any device upgrading from an older build gets one unprotected hop, proven in the field. +- **Auto-rollback on any repeated boot failure (no confirmation concept), rejected.** Self-heals late-life breakage, but a confirmed build failing transiently (cold night, flaky SD read) would be silently downgraded — the failure mode the trial/confirm split exists to prevent. + +## Consequences + +- Persistent breakage of an already-confirmed generation does **not** self-heal — deliberate. It is covered by the explicit recovery ladder instead: the Software screen's Rollback channel, the power-on **recovery hold** into recovery mode (rollback without any working UI, see [0006](./0006-recovery-mode-reuses-update-screen.md)), and SSH. +- The upgrade-written trial marker survives as a *hint* (it names the exact pre-upgrade system, camera specialisation included); the ledger is what's load-bearing. Rollback targets are chosen newest-first from the profile, preferring confirmed generations. +- Ledger loss is benign: a healthy generation simply re-confirms on its next boot; an unhealthy one gets a rollback it genuinely needs. +- A failing trial with no rollback target at all (first-ever install) shows an on-screen failure message and stays up for rescue instead of boot-looping. +- "Never touches a confirmed generation" means never *acts on* — the watchdog still *reports*: a confirmed generation whose app fails gets an on-screen advisory naming the recovery hold, so the escape hatch reveals itself exactly when needed and never clutters a healthy boot. +- Known floor: a generation that dies before userspace (kernel/initrd) boot-loops — the watchdog never runs, and neither NixOS nor extlinux offers boot counting. Chosen future fix (backlogged): **U-Boot `bootcount`/`altbootcmd`** — the trial's confirm step resets the counter; exceeding the limit boots the previous generation's entry. Preferred over Raspberry Pi firmware `tryboot`, which works at the config.txt/partition level and would force an A/B boot-chain redesign; bootcount preserves the single-extlinux generation model. diff --git a/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md b/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md new file mode 100644 index 000000000..1bab15257 --- /dev/null +++ b/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md @@ -0,0 +1,14 @@ +# Recovery mode is the update screen in isolation, with a blind-rollback fallback — not a separate minimal tool + +The recovery hold boots a stripped app entry running only the update screen (display + keypad; no camera/solver/positioning), so rescue gets the generation overview and the internet channels from the same code, screen, and install path users already know — one machinery, one set of bugs. Because that screen runs inside the possibly-broken generation, it is a *rung*, not the ladder: if recovery mode fails its own health check, the device falls back to a blind generation rollback to the newest confirmed generation with an on-screen message ([0005](./0005-self-arming-watchdog-confirmed-generations.md)). + +## Considered options + +- **Standalone minimal recovery tool (own renderer, own generation lister), rejected.** Smaller failure domain, but duplicates the generation list, manifest fetch, install trigger, and keypad/display handling — a second UI that rots separately from the real one, for a rung the blind fallback already backstops. +- **Blind rollback only (no interactive mode), rejected.** Works when everything is broken, but forces the newest-confirmed choice on the user; the "it's broken" scenario often wants *a specific* known-good version or a fresh install from a channel. + +## Consequences + +- Selection semantics are **sticky**: choosing a generation sets the boot default (a telescope user in recovery means "go back until I say otherwise"), never a one-shot boot. Internet picks go through the ordinary upgrade flow and face a normal trial. +- Recovery mode inherits the update screen's offline behavior: no network degrades to "rollback only". +- The app's readiness signal (health check) pulls double duty: it is also what decides whether recovery mode itself is alive or the blind fallback fires. diff --git a/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md b/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md new file mode 100644 index 000000000..e509faf4c --- /dev/null +++ b/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md @@ -0,0 +1,13 @@ +# extlinux and kernels live on the ext4 root; the FAT partition is firmware-only + +A custom U-Boot (`CONFIG_CMD_SYSBOOT`) reads `extlinux.conf` and the per-generation kernels from `/boot` **on the ext4 root** (`mmc 0:2`); the FAT partition carries only what the GPU firmware itself must parse — `config.txt`, `start*.elf`, U-Boot, DTBs — written once at install and never touched at runtime. NixOS mutates `/boot` on every generation switch (upgrade, rollback, camera specialisation, watchdog auto-rollback, `set-extlinux-default`), and those rewrites must be crash-safe: ext4 renames are atomic, FAT's are not — a power cut mid-rewrite on FAT can corrupt the one file the boot chain needs, which is unacceptable for a bootloader the watchdog rewrites unattended. + +## Considered options + +- **Everything on FAT (Raspbian convention: firmware loads the kernel directly), rejected.** No atomic rename (crash-unsafe generation switches), no symlinks, and per-generation kernels+initrds (~50MB each, `configurationLimit` of them plus specialisation entries) outgrow a 256MB firmware partition. +- **extlinux on FAT, kernels on ext4, rejected.** Splits one logical unit (the boot menu and what it points at) across filesystems and still leaves the menu rewrite non-atomic. + +## Consequences + +- The partition most fragile to corruption (FAT, which the GPU bootloader must parse) is also the one never written after install. +- The tarball layout follows: its top-level `boot/` is the firmware payload, while `rootfs/boot` is a **populated** directory — anything consuming the tarball must not assume Raspbian's everything-in-FAT layout. The migration initramfs did exactly that (empty-`/boot` assumption + extlinux-on-FAT verifications) and failed the first real migration against this layout (2026-07-05); it now stages the firmware payload aside and verifies each partition for what its boot-chain stage actually needs. diff --git a/docs/ax/nixos/adr/0008-imx462-xclk-override.md b/docs/ax/nixos/adr/0008-imx462-xclk-override.md new file mode 100644 index 000000000..74f229346 --- /dev/null +++ b/docs/ax/nixos/adr/0008-imx462-xclk-override.md @@ -0,0 +1,23 @@ +# The imx462 camera gets an explicit 74.25 MHz xclk overlay because fdtoverlay drops overlay parameters + +**Status: proposed — pending a build on pi5 and a camera test on a device.** + +PiFinder's imx462 camera module has a 74.25 MHz oscillator. The kernel's `imx290`/`imx462` DT overlays default the sensor xclk to 37.125 MHz; on Raspbian, PiFinder corrects this with an overlay *parameter* (`switch_camera.py` writes `dtoverlay=imx290,clock-frequency=74250000` to `config.txt`, applied by the RPi firmware loader). The NixOS image applies overlays with `fdtoverlay`, which **cannot apply overlay parameters** (`__overrides__`) — so the 37.125 MHz default silently survived. The driver (`imx290.c`) reads the sensor node's `clock-frequency` property and programs a per-frequency INCKSEL/PLL register set; with the wrong xclk the sensor enumerates on I2C but never delivers frames, and libcamera reports `Camera frontend has timed out` with an empty kernel log. + +The image therefore compiles a small additional overlay, applied after the camera overlay, that sets `clock-frequency = 74250000` in both places the Raspbian parameter writes: the `cam1_clk` fixed-clock node (the driver `clk_set_rate`s it and errors on mismatch) and the sensor node property (selects the INCKSEL register set). + +This supersedes an earlier draft of this ADR that blamed the kernel: the hypothesis was that the mainline `imx290` driver's lack of an imx462 model caused the failure, to be fixed by switching to `pkgs.linuxPackages_rpi4`. That was wrong on both ends — the image already runs the Raspberry Pi vendor kernel (the `nixos-hardware` `raspberry-pi-4` module pins the downstream tree, `stable_20250916` / 6.12.47, whose `imx290` driver does carry imx462 support), and the mr2 diagnosis that "verified" the DT clock at 37.125 MHz was in fact confirming the bug: 37.125 MHz matches the overlay default, not the hardware. + +## Considered options + +- **Compiled xclk-override overlay applied after the camera overlay (chosen).** Reproduces Raspbian's field-proven configuration (`sony,imx290lqr` compatible + 74.25 MHz xclk) exactly, using the overlay machinery `hardware.nix` already has. Relies on `fdtoverlay` merging the camera overlay's `__symbols__` so `&cam_node` resolves — same mechanism the existing custom overlays use against base-DT labels. +- **Switch to the vendor kernel via `boot.kernelPackages = pkgs.linuxPackages_rpi4`, rejected.** The earlier draft's fix. Redundant — nixos-hardware already pins the same vendor tree at the same tag — and actively harmful: it swaps in an equivalent-but-different kernel derivation, forcing a full kernel rebuild on pi5 and re-opening the u-boot/extlinux boot-chain question for zero functional change. +- **Use the kernel's `imx462.dtbo` (compatible `sony,imx462lqr`, dedicated init registers), deferred.** Arguably the "more correct" driver model, but it is not what PiFinder runs on Raspbian, it still needs the same xclk override, and it changes the libcamera tuning file (sensor name `imx462` vs `imx290`). Not worth the untested variables while getting the camera working; worth revisiting once streaming is confirmed. +- **Patch the camera overlay source and compile it ourselves, rejected.** Duplicates kernel dtsi includes into the flake for something a two-property override achieves. + +## Consequences + +- imx296 and imx477 are unaffected: on Raspbian PiFinder configures them without a clock parameter, so the overlay defaults are already correct and the override is gated on `cameraType == "imx462"`. +- No kernel change, so no new boot-chain risk and no kernel rebuild on pi5; the DTB derivation is the only thing that changes. +- The override must stay ordered after the camera overlay in the `fdtoverlay` invocation — `&cam_node` only exists in the merged symbol table at that point. A future refactor that reorders the overlay list will break it at DTB build time (fdtoverlay fails to resolve the symbol), not silently. +- If the sensor still does not stream with the correct xclk, the next suspects are the `imx462.dtbo` model path above and the libcamera/IPA tuning — not the kernel. diff --git a/docs/ax/nixos/adr/README.md b/docs/ax/nixos/adr/README.md new file mode 100644 index 000000000..597fee9a2 --- /dev/null +++ b/docs/ax/nixos/adr/README.md @@ -0,0 +1,10 @@ +# NixOS ADRs + +Architecture-decision records for the **NixOS** context (NixOS build, binary cache, update channels, on-device upgrade/rollback). Numbered locally — `0001`, `0002`, … — independent of the repo-root `docs/adr/`. + +**Why a separate namespace.** These decisions are fork-only (`mrosseel/PiFinder`, the NixOS line) and have no counterpart upstream (`brickbots/PiFinder`). The root `docs/adr/` is shared with upstream and is merged on every sync; putting a fork ADR there means picking a number that will collide with the next upstream ADR, and a rename on the fork is undone/duplicated by the next merge. A context-local namespace keeps fork deploy decisions collision-proof until the NixOS line becomes upstream mainline, at which point these fold into the shared sequence. + +- [0001 — Self-hosted Attic for NixOS binary distribution](./0001-attic-binary-cache.md) +- [0002 — Update channels stay Release-based (stable/beta) over a live main+PR unstable; rollback via reinstall + passive yank](./0002-update-channels-and-rollback.md) +- [0003 — Migration tarball resolves its full system from the update manifest at first boot (rides latest stable), instead of pinning a closure](./0003-migration-tarball-rides-latest-stable.md) +- [0008 — The imx462 camera gets an explicit 74.25 MHz xclk overlay because fdtoverlay drops overlay parameters (proposed)](./0008-imx462-xclk-override.md) diff --git a/docs/ax/sqm/CONTEXT.md b/docs/ax/sqm/CONTEXT.md index 679d99ee4..56963aa23 100644 --- a/docs/ax/sqm/CONTEXT.md +++ b/docs/ax/sqm/CONTEXT.md @@ -1,6 +1,6 @@ # SQM (Sky Quality Meter) -The SQM context estimates sky brightness in magnitudes per square arcsecond from solved camera frames, and produces a "noise floor" ADU level that auto-exposure consumes. Lives entirely in the solver process at runtime; the UI is read-only. +The SQM context estimates sky brightness in magnitudes per square arcsecond from solved camera frames. Lives entirely in the solver process at runtime; the UI is read-only. > Companion architecture doc: [`../sqm.md`](../sqm.md). @@ -39,13 +39,45 @@ Ring around each star (default inner 6 px, outer 14 px) used to measure the *loc _Avoid_: background ring, sky ring. **Photometric zero point** (`mzero`): -Per-frame conversion constant between ADU flux and apparent magnitude: `mag = mzero − 2.5 · log10(flux_ADU)`. Flux-weighted mean over the matched stars. -_Avoid_: zero-point, calibration constant. +Per-frame conversion constant between ADU flux and apparent magnitude: `mag = mzero − 2.5 · log10(flux_ADU)`. **Median** over the matched stars' individual zero points — deliberately not a flux-weighted mean, which hands the vote to the 1–2 brightest stars, exactly the ones prone to near-saturation nonlinearity and colour-term extrapolation (one such star dragged a night's SQM by 0.5 mag). +_Avoid_: zero-point, calibration constant, flux-weighted mzero (it isn't anymore). **Saturation threshold**: -Pixel value above which a star's aperture is excluded from `mzero`. Default 250 ADU (assumes 8-bit processed frames). +Pixel value above which a star's aperture is excluded from `mzero` (and from wing measurement). Default 250 ADU for 8-bit processed frames; on the raw-green path it is `0.70 · (2^bit_depth − 1)` — deliberately far below hard clip, because the CMOS response bends well before full scale and stars peaking at 75–90% already read systematically low. _Avoid_: clipping threshold, max pixel. +**B−V**: +A star's Johnson blue-minus-visual color index, looked up per matched star from `hip_main.dat` keyed by HIP number (the `matched_catID` of a solve). Positive = red star, negative/near-zero = blue star. Missing values are `nan` and skip the color correction for that star. +_Avoid_: colour, B minus V without the star (be explicit it's per-star). + +**Color coefficient** (`T`, `CameraProfile.color_coefficient`): +Per-sensor slope mapping B−V to a magnitude shift, because the sensor's real passband is not Johnson V. `T = 0.8` for bare color sensors (imx462/imx290 — the near-IR leak over-fluxes red stars); `T = 0` for IR-cut sensors (hq) and the unmeasured imx296 placeholder. A property of the optics + sensor, not the sky. +_Avoid_: color term (that's the whole correction, not the coefficient), CI slope. + +**PSF wings** (wing loss): +The broad halo of a star image outside the photometry aperture — a property of the lens, not the sky. On PiFinder optics the r=5 px aperture misses ~25–38% of a star's total flux, biasing `mzero` low and SQM bright by 0.3–0.5 mag. The dominant absolute-scale error; corrected by the wing correction below. +_Avoid_: seeing halo (it's the lens, not the atmosphere), defocus (wings persist in focus). + +**Wing boundary**: +The radius where a star's radial ring profile stops declining — wings end, true sky begins. Found per star by growing rings outward and cutting at the first ring with no significant further drop (slope-cutoff). Sky is measured beyond it, total star flux inside it. +_Avoid_: aperture cutoff, star edge. + +**Enclosed fraction** (`f`): +Fraction of a star's total flux (out to the wing boundary) captured inside the photometry aperture. Per-frame sample = median over the bright, unsaturated, uncrowded matched stars. +_Avoid_: flux ratio, aperture efficiency. + +**Wing correction** (`mzero_correction`): +The additive `-2.5·log10(f)` applied to `mzero`, from `WingEstimator`. **Measure/smooth/apply split**: `f` is measured only on frames that can support it (bright stars, SNR-gated), smoothed in a rolling window, and the smoothed correction applied to every frame. Measuring per-frame is forbidden — wings sink below noise at short exposures, which re-introduces exposure dependence (validated on sweeps). +_Avoid_: aperture correction (fine informally, but the code term is mzero_correction), zero-point offset (that's the rejected per-sensor constant — see ADR). + +**`WingEstimator`**: +The rolling estimator in `sqm/wings.py` implementing the above; sibling of `PedestalEstimator` in design. Fed each SQM cycle with the photometry image + matched centroids; conditioned after `min_samples` frames; `correction()` returns 0 until then. +_Avoid_: halo estimator, PSF estimator. + +**Effective magnitude**: +A matched star's catalog V transformed into this sensor's passband: `V − T · (B−V)`. Fed into the `mzero` fit in place of raw V. **Sign**: red stars (B−V > 0) become *brighter* through a NIR-leaky passband, so their effective magnitude is *smaller* — hence the minus sign. This is the **color term**, the same passband matching a hardware SQM-L meter's fixed spectral response applies; it is an instrumental correction, distinct from the atmospheric `sqm_altitude_corrected` (see ADR-0002 — the "publish uncorrected" argument is about the atmosphere, not this). +_Avoid_: corrected magnitude (ambiguous), green magnitude (that's a channel, not a transform). + **Per-star arrays**: The bulky diagnostic arrays `star_centroids`, `star_mags`, `star_fluxes`, `star_local_backgrounds`, `star_mzeros` returned in `details` by `SQM.calculate()`. **Stripped** before `shared_state.set_sqm_details()` to keep proxy traffic small. _Avoid_: star data, debug arrays. @@ -62,15 +94,23 @@ _Avoid_: atmospheric loss. ### Noise model +**Black level**: +The static ADU floor a pixel reports at zero light — the sensor's quiescent output, applied so counts never clip at zero (Sony IMX sensors expose it as a "black level" register). Exposure-independent. This is the quantity **subtracted** from the measured sky background before the magnitude conversion. Matches standard imaging/photometry usage, where black level, bias, and (static) pedestal are the same floor. Sourced two ways: the per-frame joint-fit estimate `P0` (see `PedestalEstimator`), or the static `bias_offset` fallback. +_Avoid_: dark level, DC offset (overloaded), pedestal without qualifying that it means this static floor. + **Bias offset**: -The **static** component of the pedestal: sensor quiescent ADU value at zero exposure, no signal. Constant per sensor; doesn't scale with exposure time. From `CameraProfile.bias_offset`; refinable via calibration JSON or zero-second samples. -_Avoid_: bias, pedestal (pedestal includes more than just the bias — see below). +The **static** black level as a fixed per-sensor constant: `CameraProfile.bias_offset`. The fallback source of the black level when no per-frame estimate is available; refinable via calibration JSON or zero-second samples. Same physical quantity as black level, named for its config field. +_Avoid_: bias, pedestal. + +**`P0`** (joint-fit black level): +The intercept of `background = P0 + rate · exposure_sec` fit by `PedestalEstimator` over the auto-exposure stream. `P0` is the black level measured empirically per session; the fitted `rate` is the dark-current contribution and is **discarded**, not subtracted. Used as `pedestal_override` into `SQM.calculate()`. +_Avoid_: pedestal (P0 is the static floor, not an exposure-dependent total). **Pedestal**: -The **exposure-dependent total** subtracted from the measured sky background: `bias_offset + dark_current_contribution`. Grows with exposure because dark current does. Use this when you want "the floor under sky signal at this exposure"; use `bias_offset` when you want "the DC offset before any signal." -_Avoid_: offset (overloaded), bias offset (it's a *component* of pedestal, not a synonym). +In PiFinder code (`pedestal.py`, the `pedestal` / `pedestal_override` variables) this names the **static black level** that gets subtracted — i.e. `P0` or the `bias_offset` fallback. This follows mainstream imaging usage (pedestal = static floor). It does **not** include a dark-current term: dark current is fit as the slope and thrown away. When precision matters, prefer "black level". +_Avoid_: using "pedestal" for `bias_offset + dark_current` — that sum is never the subtracted quantity. -> Worked example: at `bias_offset = 20 ADU`, `dark_current_rate = 0.5 ADU/s`, `exposure_sec = 1`: pedestal = `20 + 0.5 · 1 = 20.5 ADU`. At `exposure_sec = 10`: pedestal = `20 + 0.5 · 10 = 25 ADU`. Bias offset stays 20 in both cases. +> Worked example: auto-exposure yields samples `(0.5 s → 240 ADU)`, `(2.0 s → 246 ADU)`, `(8.0 s → 270 ADU)`. The joint fit gives `rate ≈ 4 ADU/s` and intercept `P0 ≈ 238 ADU` — the black level. SQM subtracts `238` at **every** exposure; the `rate · exposure` sky/dark accumulation is exactly what SQM is measuring and must not be subtracted. **Read noise**: Random per-pixel noise from the ADC. Constant w.r.t. exposure. `CameraProfile.read_noise_adu`. @@ -81,7 +121,7 @@ Thermal electrons per second per pixel. `CameraProfile.dark_current_rate` (ADU/s _Avoid_: thermal noise (it isn't noise — it's signal). **Noise floor**: -The **published** ADU value (`shared_state.set_noise_floor()`) below which we treat pixel values as "empty sky + sensor noise" rather than real signal. Lower bound for sky background; the minimum acceptable background for the Camera context's background controller. When someone says "the noise floor" without qualifier, this is what they mean — never one of the intermediate quantities inside `NoiseFloorEstimator`. +The ADU value below which pixel values are treated as "empty sky + sensor noise" rather than real signal. `shared_state.noise_floor()` holds a static default (10.0) that the Camera context's SNR background controller reads as its minimum acceptable background; **nothing publishes an adaptive value into it** — the SQM path never did (the wiring was dead and has been removed). When someone says "the noise floor" without qualifier, this shared-state value is what they mean — never one of the intermediate quantities inside `NoiseFloorEstimator`. _Avoid_: dark level, baseline, raw noise floor (use a qualifier — see below). **Measured noise floor**: @@ -111,7 +151,7 @@ Persistent calibration file at `~/PiFinder_data/sqm_calibration_.js _Avoid_: cal file, sqm config. **Zero-second sample**: -A single 0-second exposure used to measure `bias_offset` and `read_noise_adu` directly (no sky signal, no dark current). `NoiseFloorEstimator` periodically sets `request_zero_sec_sample=True` in `details` so the camera process can capture one. Runtime concept. +A single 0-second exposure used to measure `bias_offset` and `read_noise_adu` directly (no sky signal, no dark current). `NoiseFloorEstimator` can set `request_zero_sec_sample=True` in its details, but **no camera-process handler exists** — the mechanism is aspirational. Wizard/diagnostic concept. _Avoid_: dark frame (astrophotography sense — see ambiguities), zero sample. **Dark sequence**: @@ -137,7 +177,7 @@ Publishes the diagnostic dict (with per-star arrays stripped). Consumed by the U _Avoid_: set details. **`shared_state.set_noise_floor(float)`**: -Publishes the latest noise floor. Read by the camera process and forwarded into the background controller (`ExposureSNRController.update(..., noise_floor=...)`) — see the [Camera context](../camera/CONTEXT.md). +Setter for the shared noise floor. **No production caller** — the camera process reads `noise_floor()` (the 10.0 default) into `ExposureSNRController.update(..., noise_floor=...)`; see the [Camera context](../camera/CONTEXT.md). Any future adaptive floor must be produced in processed-image units (the controller measures the 8-bit display frame), not raw-green units. _Avoid_: set floor. ### Timing @@ -158,10 +198,10 @@ _Avoid_: refresh calibration, reload sqm. ## Flagged ambiguities -- **"Noise floor"** (bare) — the *published* ADU value (`shared_state.set_noise_floor()`). For the intermediates inside `NoiseFloorEstimator`, force a qualifier: **measured**, **smoothed**, **theoretical**, **conservative**. Bare "noise floor" never means an intermediate. +- **"Noise floor"** (bare) — the shared-state value (`shared_state.noise_floor()`, currently always the 10.0 default). For the intermediates inside `NoiseFloorEstimator`, force a qualifier: **measured**, **smoothed**, **theoretical**, **conservative**. Bare "noise floor" never means an intermediate. - **"Dark frame" (astrophotography sense)** is NOT used here. In astrophotography, a "dark frame" is one lens-capped exposure (often combined into a master dark) — readers may import that mental model. PiFinder has two distinct concepts instead: **dark sequence** (multi-frame, multi-exposure, wizard-time, used to fit noise constants — never stacked into a master), and **zero-second sample** (single 0-s exposure, runtime, refreshes `bias_offset` / `read_noise_adu`). Don't say "dark frame". - **"SQM"** — without qualifier means the published `SQMState.value` (i.e. `sqm_final`, no extinction correction). The altitude-corrected number is `sqm_altitude_corrected` in details. -- **"Pedestal"** vs **"bias offset"** — pedestal is the exposure-dependent total (`bias_offset + dark_current_contribution`); bias offset is the static zero-exposure component. **Bias offset ⊆ pedestal**. They are not synonyms. +- **"Black level" / "pedestal" / "bias offset"** — all name the same **static** ADU floor that SQM subtracts, matching standard imaging usage. Prefer **black level** for the physical quantity, **bias offset** for the `CameraProfile` constant, and **`P0`** for the per-frame joint-fit estimate. "Pedestal" in the code means this static floor, **not** `bias_offset + dark_current`. Dark current is fit as the joint-fit slope and discarded, never added to the subtracted term. ## Example dialogue @@ -171,4 +211,4 @@ _Avoid_: refresh calibration, reload sqm. > > **Dev:** What does the camera process care about? > -> **Domain:** Just the noise floor. `set_noise_floor()` is the only line of communication. The background controller uses it as the minimum acceptable background — change SQM's interval gate and that auto-exposure signal changes cadence too. +> **Domain:** Nothing at runtime. The camera's SNR background controller reads `shared_state.noise_floor()`, but that has always held the static default — SQM never published into it (that wiring was dead code, now removed). `NoiseFloorEstimator` survives only as a sweep-wizard diagnostic. diff --git a/docs/ax/ui/CONTEXT.md b/docs/ax/ui/CONTEXT.md index be27e5d40..04b90b6c4 100644 --- a/docs/ax/ui/CONTEXT.md +++ b/docs/ax/ui/CONTEXT.md @@ -181,24 +181,28 @@ The focus-quality metric — the diameter (in pixels) of the circle enclosing ha _Avoid_: FWHM (a different, fit-based metric — not what we compute), star size, spot size, sharpness. **Detected star**: -A blob the focus screen's own lightweight detector finds in the raw 512×512 frame, deliberately tuned to accept broad/defocused blobs (up to a ~50 px size cap). The few brightest detected stars are what HFD is measured on. Distinct from a **matched star** (the solver's tetra3 catalog match), which goes to zero when defocused. +A blob the focus screen's own lightweight detector finds in the raw 512×512 frame, deliberately tuned to accept broad/defocused blobs. HFD is measured on blobs up to a ~50 px size cap; broader blobs remain available for visual magnification. The few brightest measurable stars are what HFD is measured on. Distinct from a **matched star** (the solver's tetra3 catalog match), which goes to zero when defocused. _Avoid_: centroid (reserve for the solver/SQM sense), matched star, blob (in prose; fine informally). **Focus HFD** (the reported value): -The **median** HFD over the few brightest detected stars in the current frame — steadier frame-to-frame than any single star. When someone says "the HFD" on the focus screen, this is it. +The **median** HFD over the four brightest measurable stars — steadier frame-to-frame than any single star and representative of the four focus tiles. When someone says "the HFD" on the focus screen, this is it. _Avoid_: best HFD (that's the marker), single-star HFD. -**Focus strip**: -The bottom-of-screen overlay (a fixed fraction of the screen height — ~38 px on the 128 panel, proportionally taller on a larger panel; see ADR 0009) that renders the focus indicator over the live image: a large right-justified **focus HFD** readout (filling the strip height), and in the freed left region the V-curve, best-focus marker, exposure, detected-star count, and the (kept) matched-star count. On by default; `square` hides the whole strip. Persists across all zoom levels (HFD is zoom-independent). -_Avoid_: HUD (loosely the same overlay; "focus strip" is the canonical name), info overlay (the prior exposure+matched-count overlay this replaces). +**Focus tiles**: +The 2×2 view made by repacking the four brightest detected stars from anywhere in the camera frame. Each tile is centered on the star's background-subtracted flux centroid and enlarged with nearest-neighbour sampling; no display stretch or filtering changes the star pixels. After initial selection, stars retain their quadrant through brightness changes. Tracking matches the relative 2--4-star pattern under one shared image translation, so moving the image while adjusting focus does not reshuffle the tiles; a missing star is replaced by the brightest unused candidate. Missing stars leave black tiles. The current **focus HFD** is shown at the intersection, with the rolling 10-second HFD signal split around it along the middle divider. The recent signal range is centered on the divider and lower HFD appears below it; a minimum 1.0-HFD display span avoids magnifying tiny measurement noise. Missing measurements add no points, so existing samples recede with wall time; the next numeric measurement starts a fresh signal. No absolute good-focus threshold, guide, marker, or under-stroke is drawn. +_Avoid_: processed preview, enhanced stars, focus strip. -**V-curve** (focus trend graph): -The scrolling sparkline of focus HFD over the **rolling 10-second window**, plotted in the focus strip. Cleared on screen entry. Named for the V shape traced as the user sweeps a focuser through best focus. -_Avoid_: focus graph, history graph, trend line. +**Focus display mode**: +One of the four Focus-screen views cycled with short `square`, following the normal **display mode** convention: **Stars** (the four focus tiles and HFD history), **Single** (the brightest tracked star at twice the Stars magnification, with HFD and history on a translucent lower-third overlay), **Image** (the complete frame with the original per-frame autocontrast applied for display only), and **Stats** (HFD, supplementary area-equivalent FWHM, detected-star count, exposure mode/value, gain, and a log-scaled raw histogram). HFD, centroids, and the Stats histogram always use the unstretched raw frame. Every unavailable HFD readout is shown as `?.?`; no upper-limit sentinel is displayed. +_Avoid_: tab, page, focus-strip mode. -**Best-focus marker**: -The minimum focus HFD within the rolling 10-second window — the bottom of the current V. Auto-rearms as old samples scroll out of the window; there is no manual reset. -_Avoid_: best focus (the state), minimum line, target HFD. +**Focus FWHM estimate**: +The median area-equivalent diameter of the pixels above half local maximum for the same four brightest measurable stars. It is supplementary diagnostics on the Stats display mode, not the focus-quality metric; HFD remains primary because it behaves better on saturated, broad, and donut-shaped stars. +_Avoid_: focus FWHM (when used as a replacement for HFD), fitted FWHM (there is no Gaussian fit). + +**Adaptive focus zoom**: +The magnification used by the Stars and Single views. A compact star defaults to 10× relative to the former full-frame preview in Stars (a 26×26 native crop on square displays); Single maps that crop across the full panel, giving twice the apparent magnification. For a broad star the crop grows to include its detected extent plus margin, lowering effective magnification instead of clipping it. In the Stars and Single display modes, `+` and `-` adjust the nominal zoom from 4× to 16×. Short `square` cycles display modes. +_Avoid_: optical zoom, solver zoom. ## Boundary terms diff --git a/docs/source/dev_arch.rst b/docs/source/dev_arch.rst index fe81f8dcc..3b150dcf0 100644 --- a/docs/source/dev_arch.rst +++ b/docs/source/dev_arch.rst @@ -312,9 +312,9 @@ Testing Unit Testing ............... -On commit or pull request to the repository the unit tests in ``python/tests`` are run using the -configuration in ``pyproject.toml`` using nox (also see its configuration in -``noxfile.py``). **Please provide unit tests with your pull requests.** +On commit or pull request to the repository the unit tests in ``python/tests`` are +run in CI inside ``nix develop`` with ``pytest -m unit``, configured in +``pyproject.toml``. **Please provide unit tests with your pull requests.** Fuzz Testing ............... diff --git a/docs/source/dev_guide.rst b/docs/source/dev_guide.rst index 3fe65b99b..813349b62 100644 --- a/docs/source/dev_guide.rst +++ b/docs/source/dev_guide.rst @@ -57,61 +57,36 @@ to discuss the issue on the to sort things out and prioritize. Beta Testing --------------- - -When you look at the `PiFinder GitHub repository `_ you will see, that there are different branches. -That is the way, how we develop the PiFinder. The main branch is the one, on which development is happening. If you want to test the latest changes, you can -check out the main branch and run its code. For this your PiFinder needs to be connected to the internet, i.e. your WiFi. -Once you have connected, log into your PiFinder via ssh and run the following commands in the terminal: - -.. code-block:: bash - - cd ~/PiFinder - git fetch --all - sudo systemctl stop pifinder - git checkout main - git pull - ./pifinder_post_update.sh - sudo systemctl start pifinder - -This will stop the PiFinder, update the code and dependencies to the latest development version and start it again. - -If you want to return to the stable version, you can run the following command: - -.. code-block:: bash - - ./pifinder_update.sh - -If you really, really would like to use bleeding edge code, you can check out a different branch, or checkout one of the forks of the repository. - -To list all branches, run the following command: - -.. code-block:: bash - - cd ~/PiFinder - git branch -a - -To checkout one of the forks of the repository, run the following commands: - -.. code-block:: bash - - cd ~/PiFinder - git remote add - git fetch --all - git checkout -b / - -You have to replace with the name of the remote you added, with the URL of the fork you want to check out (you can copy this from github, by pressing on the "code" button), and with the name of the branch you want to check out. This will create a new branch in your local repository, which follows the branch of the fork you checked out. - -To keep up to date with the latest changes in the fork, you can run the following commands: - -.. code-block:: bash - - cd ~/PiFinder - git pull - cd python - sudo pip install -r requirements.txt - -The last command will install the requirements and only needs to be run occasionally, depending on the changes in the branch. You need to restart the pifinder service to see the changes. +------------ + +PiFinder updates over the air, right from the device. Open the +:ref:`user_guide:tools` menu and choose Software Upd; the PiFinder downloads a +prebuilt image and switches to it. The update screen is arranged as three +**channels** that you move between on the device: + +- **stable** — where Software Upd opens. The production channel of official + releases, listing the versions you can switch to. The safe choice for ordinary + observing. +- **beta** — press **RIGHT** from the stable channel to reach it. Pre-release + builds cut from the development branch, curated with release notes before they + go stable. This is the channel for most beta testers. +- **unstable** — the bleeding edge: the live tip of development plus individual + open pull requests, each installable before it's merged. It stays hidden until + you unlock it by pressing **SQUARE** seven times on the update screen. + +Each version you pick resolves to a build the project's binary cache has already +compiled, so the device only downloads and activates it — it never compiles +anything itself, and the switch takes a couple of minutes. If a build misbehaves +you can switch back to an earlier stable or beta version the same way, since +those are kept in the cache. + +The PiFinder needs internet access to reach the cache, so put it in Client Mode +on a WiFi network with a connection. See :ref:`user_guide:update software` for a +full walkthrough of the update screen. + +When you hit a problem on a beta or unstable build, report it as described in +`Submitting issues, bugs and ideas`_ above, and say which channel and version +you were running. Fork me - getting or contributing to the sources with pull request ------------------------------------------------------------------ @@ -139,15 +114,19 @@ The files are located in PiFinders GitHub repository under ``docs/source`` and h the ending ``.rst``. The documentation is then published to `readthedocs.io `_, when the change is committed to the official GitHub repository (using readthedocs's infrastructure). -You can link your fork also to your account on readthedocs.io, but it is easier to build the documentation locally. -For this, install Sphinx and the Read the Docs theme from the pinned -requirements file (run this from the ``docs`` directory): +Read the Docs rebuilds and publishes the site automatically whenever a change +lands on the official GitHub repository, so you don't have to do anything to +publish. To preview your changes first, build the site locally with the pinned +requirements. The dev shell provides ``uv``, which can run Sphinx in a throwaway +environment without installing anything globally — run this from the ``docs`` +directory: .. code-block:: - pip install -r source/requirements.txt + uv run --no-project --with-requirements source/requirements.txt --python 3.11 \ + sphinx-build -b html source build/html -You can then use the supplied ``Makefile`` to build a html tree using ``make html`` and running a http server in the directory with the files: +Then serve the result and open it in your browser: .. code-block:: @@ -248,22 +227,22 @@ also contain the compiled ``.mo`` files, which are binary representations of the When you edit the files, check for each entry that has a ``msgstr ""`` line, which means the string is not translated yet. You also need to check the translations of strings marked as "fuzzy". You need to remove the "fuzzy" line, once you have checked the translation. -In order to run the PiFinder software with the latest translation, you need to run the following commands: +The Babel toolchain extracts the strings, updates the ``.po`` files, and compiles +them into the ``.mo`` files the PiFinder reads. Run it from ``python/`` inside the +dev shell (see `Install dependencies with Nix`_): .. code-block:: - cd ~/PiFinder/python - sudo pip install -r requirements_dev.txt - nox -s babel - -The ``pip`` command installs the dependencies for the translation, the second command runs the babel toolchain to extract the strings -to translate and update the .po files. This also compiles the .po files into .mo files, which are then used by the PiFinder software. + cd python + pybabel extract -F babel.cfg -c TRANSLATORS -o locale/messages.pot ./PiFinder ./views + pybabel update -i locale/messages.pot -d locale + pybabel compile -d locale -So if you want to test your translations, you need to run the ``nox`` command every time you change the .po files, then restart the PiFinder software: +Run these again every time you change a ``.po`` file, then restart the PiFinder +to pick up the new ``.mo`` files. On a running device that is: .. code-block:: - nox -s babel sudo systemctl restart pifinder Please post the changed po files in the Discord channel "translation" and we will include it in the next release. @@ -271,46 +250,56 @@ Please post the changed po files in the Discord channel "translation" and we wil Setup the development environment --------------------------------- -On the PiFinder -.................. +PiFinder is developed on a Linux machine with the `Nix package manager +`_, which provides the exact toolchain the project +builds and tests with. An x86_64 machine running Linux — including WSL2 on +Windows — is the primary platform, and the rest of this guide assumes it. -The best development platform for the PiFinder is the PiFinder itself via SSH or with a -monitor keyboard attached. This will let you develop and test any part of the code. +Most UI and catalog work can be done on that machine alone: the display is +emulated and the camera, IMU and GPS are faked with the flags described under +`Running/Debugging from the command line`_. Those physical features can only be +exercised on a real PiFinder. -See the :ref:`software:build from scratch` section of the Software Setup guide for -information on creating a base SD card and getting the base software running. +The device itself runs an immutable NixOS image, so its software sits read-only in +the Nix store. For a finished change you build an image and install it over the +air through the update channels (see `Beta Testing`_), or cut a release. For quick +iteration against the real camera, IMU and GPS, though, you can point the device +at an editable copy of your code and skip the image build entirely — see +`Developing on the PiFinder itself`_. -Other Options -................ +To get started, fork the repo and clone your fork, then set up the environment as +described next. -Second to this is a standalone Raspberry Pi hooked up to a keyboard and monitor. This -will make sure your code will run on the PiFinder, but you won't be able to test the -IMU, GPS or other physical hardware features. You can emulate these using the -`--fakehardware` and `--display` flags. See below for more details. - -You can also develop on any Posix compatible system (Linux / MacOS) in roughly the -same way you can on a Raspberry Pi. The emulated hardware and networking features -will work differently so this is mostly useful for UI/Catalog feature development. +Install dependencies with Nix +............................. -Note that you can develop on Windows by activating Windows Subsystem for Linux (WSL2) -and installing Ubuntu from the Microsoft Store. The window launched by PiFinder will -be fully integrated into your windows desktop. +PiFinder's development environment is described by the ``flake.nix`` at the +repository root, so you don't install Python or its libraries by hand. On NixOS +the Nix package manager is built in; on another Linux machine, install it and +enable flakes. If you use `direnv `_, let it manage the +shell automatically from the repository root: -To get started, fork the repo and set up your virtual environment system of choice -using Python 3.9. Then follow some of the steps below! +.. code-block:: -Install python dependencies -........................... + direnv allow -For running PiFinder, you need to install some python libraries in certain -versions. These lists can be installed via -`pip tool chain `_ and are separated in two -files: one for getting PiFinder to run, one for development purposes: +The shell then loads and unloads as you enter and leave the checkout. If you do +not use direnv, enter the identical shell explicitly instead: .. code-block:: - pip install -r requirements.txt - pip install -r requirements_dev.txt + nix develop + +Both routes give you everything PiFinder needs on your ``PATH``: a Python +interpreter with the project's dependencies, the ``ruff`` linter, the ``uv`` +package manager, and the ``cedar-detect-server`` plate-solving helper. The +repo's ``.envrc`` uses the classic ``shell.nix`` entry point, which selects the +same flake dev shell but filters large runtime data out of the source copied to +the Nix store. This keeps direnv reloads quick; manual ``nix develop`` and CI +still evaluate the flake directly. + +You still need to fetch the Tetra3 submodule once; see +`Install the Tetra3/Cedar solver`_ below. Hipparcos catalog @@ -341,76 +330,58 @@ command from with your checked out repo Code Quality Automation ----------------------- -The PiFinder codebase includes features for maintaining code quality, -adherence to style guide and for evaluation and testing. These will -be installed along with the dev dependencies and should be available -to run immediately. +PiFinder uses Ruff for linting and formatting, MyPy for type checking, and +PyTest for the test suite. They all come with the dev shell, so inside +``nix develop`` you run them directly from the ``python`` directory. Every push +and pull request runs the same commands in CI, so it's worth running them +locally before you open a PR. -NOX -.... +Linting and formatting +...................... -We use `Nox `_ as an entrypoint to all of -the code quality tools. Simply run ``nox`` to from the ``PiFinder/python`` -directory and it will run (almost) all of the code quality checks and tests. +`Ruff `_ handles both. From ``python/``: -The first time it runs Nox will set up suitable environments for each session -it manages and this might take a bit. Subsequent runs will be much faster. - -To see what sessions are available use ``nox -l`` +.. code-block:: -To run only a specific session use ``nox -s [session_name]`` + ruff check # report common issues (add --fix to repair them) + ruff format # reformat code in the Black style -The defined sessions are: +CI runs ``ruff check`` and ``ruff format --check`` and fails if either reports +anything, so run them before you push. -- lint -> Runs `RUFF `_ using ``ruff check --fix`` to - check/fix common code issues. It may produce warnings or fail completely if - there are issues with new code you are working on. See the documentation for - details on any errors it finds. +Type checking +............. -- format -> Runs ``ruff format`` to reformat code in the Black style. +`MyPy `_ does static type analysis. The +PiFinder code is not fully typed yet, but new contributions need to be +annotated. From ``python/``: -- type_hints -> Runs `my[py] `_ to do static - type analysis. The PiFinder code is not fully typed (yet!) but we are working on it - and any new contributions will need to be fully annotated. If you've not worked - with type-hinted Python before, we'll help you out, so feel free to put up PR's - for non-type-hinted code and we can collaborate. +.. code-block:: -- smoke_tests -> Runs `PyTest `_ and executes - all tests marked SMOKE. Smoke tests should be FAST and provide some basic - checking of sanity/syntax. + mypy . -- unit_tests -> Runs PyTest and executes all tests marked as UNIT. Unit tests - should exercise more functionality and make take a bit more time. This Nox - session is not run by default, but is executed on code check in to the PiFinder - repository. +If you've not worked with type hints before we'll help you out, so feel free to +open a PR for non-type-hinted code and we can collaborate. -- ui_tests -> Runs PyTest against the UI module smoke harness - (``tests/test_ui_modules.py``). It builds every UI screen through a real - ``MenuManager`` and exercises each screen's key handlers as a crash-only smoke - test. Because it builds the real catalogs and may download ``hip_main.dat`` on - first run, it is heavier and more network-dependent than the unit suite, so it - lives in its own session and is not run by default. +Tests +..... -- babel -> Runs the complete toolchain for internationalization (based on `pybabel`). - That means extracts strings to translate and updates the `.po`-files in `python/locale/**` - Then these are compiled into `.mo`-files. Unfortunately, this changes the `.mo`-files in any case, - even if the there have been no changes to strings or their translation. As this will show up - as changes to checked-in, this is not run by default. +`PyTest `_ runs the test suite. Tests carry markers so +you can run a slice of them. From ``python/``: -- web_tests -> Runs PyTest and executes all tests marked as WEB. Web tests use Selenium - to automate browser testing of the PiFinder web interface. These tests require a - running Selenium Grid server and a running PiFinder web server. You can test against a real PiFinder - or a locally running instance. See the sections below for setup instructions. - +.. code-block:: -CI/CD -....... + pytest -m smoke # fast sanity/syntax checks + pytest -m unit # broader unit coverage + pytest -m web # browser tests of the web interface (see below) -All pushes to the PiFinder repository will run all the defined Nox sessions. Automations -for PR's will need to be triggered by a maintainer, but you can (and should!) set up -your fork to run the existing automation to validate your code as you develop. +There is also a UI smoke harness that builds every screen through a real +``MenuManager`` and exercises its key handlers — run it with +``pytest tests/test_ui_modules.py``. It builds the real catalogs and may +download ``hip_main.dat`` on first run, so it's heavier than the unit suite. -If you need help, reach out via email or discord. We are happy to help :-) +Smoke and unit tests run in CI on every push. The web tests need extra setup — +a Selenium Grid and a running PiFinder web server — described next. Website Tests ............. @@ -455,20 +426,18 @@ Running against a locally running instance at localhost:8080: .. code-block:: bash - cd ~/PiFinder/python - . .venv/bin/activate # Optionally active your virtual environment + cd python export SELENIUM_GRID_URL= # Optional, default is http://localhost:4444/wd/hub - nox -s web_tests + pytest -m web --local If you want to test against a real PiFinder, set the ``PIFINDER_HOMEPAGE`` environment variable to the URL of your PiFinder instance: .. code-block:: bash - cd ~/PiFinder/python - . .venv/bin/activate # Optionally active your virtual environment + cd python export SELENIUM_GRID_URL= # Optional, default is http://localhost:4444/wd/hub export PIFINDER_HOMEPAGE=http://pifinder.local # Change to the URL of your PiFinder, which needs to be in the same WiFi - nox -s web_tests + pytest -m web If you run the tests with-out a working Selenium Grid instance, the tests will all be skipped. You can also run individual tests with PyTest directly, use ``SELENIUM_GRID_URL=... PIFINDER_HOMEPAGE=... pytest tests/website/test_file.py``. @@ -507,6 +476,13 @@ python program with the command line parameters you need for the certain use cas You simply stop the program with "Ctrl + C". +.. note:: + + On a Nix development machine, enter the dev shell first (``nix develop``, or + let direnv load it) and run these commands from the ``python`` folder of your + own checkout rather than ``/home/pifinder/PiFinder``. Everything you need, + including ``cedar-detect-server``, is already on your ``PATH``. + **Remember**: PiFinder is designed to automatically start after boot. So a PiFinder process is likely running. Before you can start a PiFinder process for testing purposes from the command line, you have to stop all currently running @@ -522,14 +498,16 @@ PiFinder: Running cedar-detect-server ............................. -You will need to start the ``cedar-detect`` process manually, if your development machine is not a PiFinder, -as it is started as a separate process on the PiFinder starting with v2.4.0. -You can do this by running the following command in another terminal window: +If your development machine isn't a PiFinder, you need to start the +``cedar-detect`` star-detection process yourself — since v2.4.0 it runs as a +separate process. The Nix dev shell puts ``cedar-detect-server`` on your +``PATH``, so in another terminal window run: .. code-block:: - cd /home/pifinder/PiFinder/bin - ./cedar-detect-server- -p 50551 + cedar-detect-server -p 50551 + +The ``-p 50551`` port is required — PiFinder looks for the server there. -h, --help | available command line arguments ............................................. @@ -609,6 +587,117 @@ be retired because the remote server is always started. python3 -m PiFinder.main -fh -k server --camera debug -x +Developing on the PiFinder itself +--------------------------------- + +Most development happens on your desktop, but the camera, IMU, GPS and the +physical keypad and screen only exist on the device. When a change needs testing +against that real hardware, you can run your own code on the PiFinder directly, +without building and flashing an image for every edit. + +The shipped software sits read-only in the Nix store, and +``/home/pifinder/PiFinder`` is a symlink pointing at it. Repoint that symlink at a +writable copy of your code and the app runs your files instead, using the Python +interpreter and libraries already installed on the device. The service follows +the symlink into your checkout, so the loop is just edit, restart, look — no +rebuild. + +Connect to the PiFinder over SSH, then: + +1. Stop the running app. It starts automatically at boot, and only one instance + can use the hardware at a time: + + .. code-block:: bash + + sudo systemctl stop pifinder + +2. Get a copy of your fork into the ``pifinder`` home directory, under any name + except ``PiFinder`` itself — that's the symlink you're about to move. The + device carries ``git`` and ``rsync``, so clone your fork directly: + + .. code-block:: bash + + git clone --depth 1 https://github.com//PiFinder.git PiFinder-dev + + The checkout includes the bundled catalog data, so it's a few hundred + megabytes; ``--depth 1`` keeps the Git history lean. If you'd rather edit on + your desktop, ``rsync`` the changed files over between runs: + + .. code-block:: bash + + rsync -a --exclude .git ./ pifinder@pifinder.local:PiFinder-dev/ + +3. Note where the symlink currently points, so you can get back to the shipped + code later, then aim it at your copy: + + .. code-block:: bash + + readlink /home/pifinder/PiFinder # save this store path + ln -sfT /home/pifinder/PiFinder-dev /home/pifinder/PiFinder + +4. Start the app again. It now runs your code: + + .. code-block:: bash + + sudo systemctl start pifinder + +From here the cycle is quick. Edit a file — ``vim`` is on the device, or re-copy +it from your desktop — then restart the app and follow its log: + +.. code-block:: bash + + sudo systemctl restart pifinder + journalctl -u pifinder -f + +For verbose, interactive output (the ``-x`` flag and the other switches above), +stop the service instead and run the app in the foreground from your copy's +``python`` folder, exactly as in `Running/Debugging from the command line`_. + +For work that changes Python dependencies or needs development tools, enter the +repository's complete Nix development environment first: + +.. code-block:: bash + + cd /home/pifinder/PiFinder-dev + nix develop + +The flake provides this shell for both desktop Linux and the PiFinder's +``aarch64-linux`` system. It uses the checked-in ``flake.lock``, +``python/pyproject.toml`` and ``python/uv.lock`` and supplies the same native +``libcamera`` and GObject bindings as the service. CI publishes the aarch64 +shell dependency closure for testable PRs and releases to the PiFinder binary +caches. + +Why ``nix develop`` here instead of the desktop's preferred ``direnv allow``? +The released device does not currently ship direnv. More importantly, the +current dev-shell output still includes the PiFinder project source: editing a +file changes that output's store hash even when none of its dependencies +changed. ``--option max-jobs 0`` would therefore reject an ordinary edited +checkout whose exact source-dependent output cannot already exist in a cache. +The intended end state is to make the shell dependency-only and ship direnv on +the device; at that point ``direnv allow`` can be the identical entry point on +desktop and PiFinder while the editable source remains outside the cached +environment. + +.. note:: + + This override is deliberately temporary. A reboot or an over-the-air software + update re-runs the device's activation step, which restores + ``/home/pifinder/PiFinder`` to the shipped store path. To return to the + released software at any time, reboot — or repoint the symlink at the path you + saved with ``readlink``. + +.. note:: + + Repointing the symlink runs your code against the image's existing Python + environment, which is the fastest path for pure-Python edits. Use ``nix + develop`` when changing the environment itself; do not create a separate + ``uv venv`` on the device, because that omits native bindings supplied by + Nix. A dependency you intend to keep belongs in ``python/pyproject.toml`` + with an updated ``uv.lock`` and ultimately in a new image (see `Beta + Testing`_) so every device runs the same tested environment. + + Troubleshooting --------------- @@ -682,6 +771,3 @@ Finally, you can start straight into this mode from the command line — see the .. image:: images/user_guide/DEMO_MODE_001_docs.png .. image:: images/user_guide/DEMO_MODE_002_docs.png - - - diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index 9103fa9b3..09cf50bf2 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -268,7 +268,7 @@ dimmest stars. .. note:: **Focus is the single most common reason a PiFinder won't solve.** Stars that look sharp by eye are often still too soft to solve, so rather than judging focus by sight, - use the **HFD** readout and its graph on the Focus screen (described below) to find the + use the **HFD** readout on the Focus screen (described below) to find the sharpest point precisely. Screw the lens in and out in the holder to adjust focus. Starting from scratch — a new @@ -279,53 +279,52 @@ Select the 'Focus' option under the 'Start' menu. .. image:: images/quick_start/start_menu.png -The Focus screen shows a live preview of what the camera is seeing, with special image -processing that highlights stars and removes background skyglow to make focusing easier. - -With no stars visible or the image well out of focus, the screen may look too bright, too -dark, or noisy — normal until the camera is near focus. Some examples: - -.. list-table:: - - * - .. figure:: images/quick_start/CAMERA_unfocused_hud.png - - Unfocused: bright, noisy background and a high HFD - - - .. figure:: images/quick_start/CAMERA_focused_hud.png - - At best focus: dark background, a tight star and a low HFD - - -Pan your scope until a bright object appears in the camera view. Screw the lens in and out -to focus; once something star-like is in the FOV and near focus, the preview's image -processing will work properly and start dimming the background and highlighting the stars. - -Along the bottom of the Focus screen is the **focus strip**, which turns focusing from a judgement -call into a number you can chase. A large **HFD** readout — the Half-Flux Diameter of the stars it -finds, in pixels — fills the right of the strip. This is simply how spread-out the stars are, so a -smaller number means tighter, sharper stars: as you adjust the lens, your goal is to make the HFD as -small as you can. - -.. image:: images/quick_start/focus_strip_docs.png - -To the left of the readout a graph plots the HFD over the last several seconds. As you slowly turn -the focuser the line traces a "V" — dropping as the stars sharpen, reaching a low point at best focus, -then climbing again as you go past it. Stop at the bottom of the V. The graph is scaled to the range -a real lens reaches — about 4 px at sharp focus up to 20 px when clearly soft — and a marker line shows -the best (lowest) HFD seen recently. Small labels show the current exposure time, **det** (the number -of stars the focus screen detected) and, once a solve succeeds, the matched-star count next to the star -icon — watch that jump from zero the moment your stars are sharp enough for the PiFinder to recognise -them. - -If the image is too far out of focus to measure, the readout shows ``keep going`` until a star comes -into range. The strip works at every zoom level, since the HFD doesn't depend on zoom, and you can -press the **SQUARE** button to hide or show it if you'd rather see the bare image. - -Good focus means the quickest solves. Close will work, but it's worth driving the HFD down to its -lowest point — use the **+/-** keys to zoom the view to 2x and 4x and get the stars as tight as you -reasonably can. With dark enough skies and good focus, the camera icon appears in the top right and -the current constellation shows in the title bar. Congratulations — the PiFinder knows where it's -pointing! +The Focus screen finds the four brightest stars anywhere in the camera frame and arranges +their magnified, centered views into four equal screen quadrants in the visible area below +the title bar. Once selected, the same stars stay in the same quadrants while they remain +detectable; tracking their relative pattern keeps them stable even if the whole camera image +shifts while you adjust the lens. A star that disappears is replaced by another bright star. +This does not depend on a plate solve or catalog identity, so it also works while badly out of +focus. These are raw camera pixels: the display does +not stretch the contrast, remove skyglow, sharpen, blur, or smooth the stars. It only crops and +enlarges them with nearest-neighbour sampling, then applies the panel's red night-vision colour. + +The default magnification is about 10× compared with a full-frame view. When a star is +well out of focus, its tile automatically shows a wider crop so the broad disc or donut is not +clipped. As focus improves the view tightens toward 10×. Use **+/-** to change the nominal +magnification between 4× and 16×. + +At the intersection of the four quadrants is the **HFD** readout — the Half-Flux Diameter of +the measurable stars, in camera pixels. This is how spread-out the stars are, so lower is +sharper. Slowly screw the lens in or out and chase the smallest stable number. ``?.?`` means +there is no usable focus measurement for the current frame; broad stars may still remain visible +in their enlarged tiles while you adjust toward focus. + +A rolling 10-second HFD trace runs along the middle divider on either side of the number. It +centres its recent value range on the divider, with lower HFD values below the line. This makes +it easier to see when you have passed through the focus minimum and should turn back, without +pretending that one absolute HFD value is equally good on every PiFinder. +When no star can be measured no new point is added, so the existing trace visibly recedes as it +becomes stale. A fresh signal starts on the first numeric measurement that follows. + +Good focus means the quickest solves. Close will work, but it is worth adjusting through the +minimum and then returning to the lowest HFD you saw. Judge the number and all four stars +together rather than relying on a single unusually bright or saturated star. + +Press **SQUARE** to cycle through four Focus views: + +* **Stars** — the four magnified raw star tiles described above. +* **Single** — the brightest detected star at twice the apparent magnification of Stars, + with the HFD number and rolling trace on a translucent overlay in the lower third. +* **Image** — the complete camera frame using the original display-only autocontrast. + Focus measurements still use raw pixels. +* **Stats** — HFD, an additional FWHM estimate, total detected stars, exposure mode and + actual exposure, gain, and a histogram of the raw pixel values. HFD is still the number + to minimize; FWHM is supplementary because it is less reliable on saturated or heavily + defocused stars. + +The **+/-** magnification keys apply in the Stars and Single views. Hold **SQUARE** from any view to +open the normal Quick Menu. .. note:: diff --git a/docs/source/troubleshooting.rst b/docs/source/troubleshooting.rst index 41152360a..b20214615 100644 --- a/docs/source/troubleshooting.rst +++ b/docs/source/troubleshooting.rst @@ -90,8 +90,8 @@ often not tight enough. Work through these in order: -- **Focus, properly.** On the Focus screen, use **+/-** to zoom to 2x and 4x and rotate - the lens until the stars are as small as you can make them. Tight focus matters *even +- **Focus, properly.** On the Focus screen, rotate the lens until the four magnified stars + are as small as possible and the central HFD reaches its lowest value. Tight focus matters *even more* under bright, light-polluted skies, where slightly soft dim stars vanish into the background. If you're starting from way off, set the lens so about 6 mm of thread is showing — roughly a pencil's width — which is close to in focus. diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..27b08dcbc --- /dev/null +++ b/flake.lock @@ -0,0 +1,115 @@ +{ + "nodes": { + "nixos-hardware": { + "locked": { + "lastModified": 1770631810, + "narHash": "sha256-b7iK/x+zOXbjhRqa+XBlYla4zFvPZyU5Ln2HJkiSnzc=", + "owner": "NixOS", + "repo": "nixos-hardware", + "rev": "2889685785848de940375bf7fea5e7c5a3c8d502", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixos-hardware", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1770617025, + "narHash": "sha256-1jZvgZoAagZZB6NwGRv2T2ezPy+X6EFDsJm+YSlsvEs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2db38e08fdadcc0ce3232f7279bab59a15b94482", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1781807804, + "narHash": "sha256-04KFQME8sE1LSywNiYS1B6Ucf5rEiUD7/vxwFMgooXU=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "b84e03a7870c66033d309e0e00abd513e2299627", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781812259, + "narHash": "sha256-uRqDouxg3b0EuOHQd1HhmFZouHebM7pz+H6EWAXd3FM=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "d9847acff422152a03764fd60c96ae0dd9f9fa73", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "nixos-hardware": "nixos-hardware", + "nixpkgs": "nixpkgs", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1781810314, + "narHash": "sha256-PQfvfKWaBvCysdHFUO5GewwvwIqI/WL6OcrJhDSUdbc=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "14aa44100859a44144878fe079f8089d3fa4dc4e", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..cf6986e8e --- /dev/null +++ b/flake.nix @@ -0,0 +1,447 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixos-hardware.url = "github:NixOS/nixos-hardware"; + + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, nixos-hardware, pyproject-nix, uv2nix, pyproject-build-systems, ... }: let + # Flake inputs the python-env module needs, passed via specialArgs. + pythonInputs = { inherit pyproject-nix uv2nix pyproject-build-systems; }; + # Headless config shared by all profiles + headlessModule = { lib, ... }: { + services.xserver.enable = false; + security.polkit.enable = true; + fonts.fontconfig.enable = false; + documentation.enable = false; + documentation.man.enable = false; + documentation.nixos.enable = false; + xdg.portal.enable = false; + services.pipewire.enable = false; + services.pulseaudio.enable = false; + boot.initrd.availableKernelModules = lib.mkForce [ "mmc_block" "usbhid" "usb_storage" "vc4" ]; + }; + + # Shared modules for all PiFinder configurations + commonModules = [ + nixos-hardware.nixosModules.raspberry-pi-4 + ./nixos/hardware.nix + ./nixos/networking.nix + ./nixos/services.nix + ./nixos/python-env.nix + headlessModule + ]; + + # Migration profile — minimal bootable system, full config fetched on first boot + migrationModules = [ + nixos-hardware.nixosModules.raspberry-pi-4 + ./nixos/hardware.nix + ./nixos/networking.nix + ./nixos/wifi-fallback-minimal.nix + ./nixos/device.nix + headlessModule + ]; + + mkPifinderSystem = { includeSDImage ? false }: nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + specialArgs = pythonInputs; + modules = commonModules ++ [ + { pifinder.devMode = false; } + # Camera specialisations — base is imx462 (default), specialisations for others + ({ ... }: { + specialisation = { + imx296.configuration = { pifinder.cameraType = "imx296"; }; + imx477.configuration = { pifinder.cameraType = "imx477"; }; + }; + }) + ({ lib, ... }: { + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" ]; + boot.loader.timeout = 0; + }) + ] ++ nixpkgs.lib.optionals includeSDImage [ + "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix" + ({ config, pkgs, lib, ... }: { + # Catalog images (~5GB compressed) are not baked into the SD image: the + # app fetches per-object images on demand from the CDN (get_images.py) + # and renders a placeholder when one is absent. Shipping only the empty + # data dir keeps the image slim and the build fast. + # + # current-build.json seeds the device's identity with its own store + # path; human version labels come from the update manifest (which maps + # store paths to versions), and every upgrade rewrites this file. + sdImage.populateRootCommands = '' + mkdir -p ./files/home/pifinder/PiFinder_data + mkdir -p ./files/var/lib/pifinder + printf '{"store_path": "%s"}\n' "${config.system.build.toplevel}" \ + > ./files/var/lib/pifinder/current-build.json + ''; + sdImage.populateFirmwareCommands = lib.mkForce '' + (cd ${pkgs.raspberrypifw}/share/raspberrypi/boot && cp bootcode.bin fixup*.dat start*.elf $NIX_BUILD_TOP/firmware/) + + cp ${configTxt} firmware/config.txt + + # Pi3 files + cp ${pkgs.ubootRaspberryPi3_64bit}/u-boot.bin firmware/u-boot-rpi3.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-2-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b-plus.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-cm3.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2-w.dtb firmware/ + + # Pi4 files + cp ${ubootSD}/u-boot.bin firmware/u-boot-rpi4.bin + cp ${pkgs.raspberrypi-armstubs}/armstub8-gic.bin firmware/armstub8-gic.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-4-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-400.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4s.dtb firmware/ + ''; + }) + ] ++ nixpkgs.lib.optionals (!includeSDImage) [ + # Minimal filesystem stub for closure builds (CI) + ({ lib, ... }: { + fileSystems."/" = { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + }; + fileSystems."/boot/firmware" = { + device = "/dev/disk/by-label/FIRMWARE"; + fsType = "vfat"; + }; + }) + ]; + }; + + mkPifinderMigration = { includeSDImage ? false }: nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + modules = migrationModules ++ [ + { pifinder.devMode = false; } + ({ lib, ... }: { + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" ]; + boot.loader.timeout = 0; + }) + ] ++ nixpkgs.lib.optionals includeSDImage [ + "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix" + ({ config, pkgs, lib, ... }: { + sdImage.populateRootCommands = '' + mkdir -p ./files/home/pifinder/PiFinder_data + mkdir -p ./files/var/lib/pifinder + # ADR 0003: last-ditch fallback for first-boot resolution when the + # update manifest is unreachable. The manifest is the primary + # source; this file is otherwise ignored and removed on success. + # (The old closure-based tarball builder used to write it; the + # image-based pipeline must bake it.) + echo "${(mkPifinderSystem {}).config.system.build.toplevel}" \ + > ./files/var/lib/pifinder/first-boot-target + ''; + sdImage.populateFirmwareCommands = lib.mkForce '' + (cd ${pkgs.raspberrypifw}/share/raspberrypi/boot && cp bootcode.bin fixup*.dat start*.elf $NIX_BUILD_TOP/firmware/) + + cp ${configTxt} firmware/config.txt + + # Pi3 files + cp ${pkgs.ubootRaspberryPi3_64bit}/u-boot.bin firmware/u-boot-rpi3.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-2-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b-plus.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-cm3.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2-w.dtb firmware/ + + # Pi4 files + cp ${ubootSD}/u-boot.bin firmware/u-boot-rpi4.bin + cp ${pkgs.raspberrypi-armstubs}/armstub8-gic.bin firmware/armstub8-gic.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-4-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-400.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4s.dtb firmware/ + ''; + }) + ] ++ nixpkgs.lib.optionals (!includeSDImage) [ + ({ lib, ... }: { + fileSystems."/" = { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + }; + fileSystems."/boot/firmware" = { + device = "/dev/disk/by-label/FIRMWARE"; + fsType = "vfat"; + }; + }) + ]; + }; + + # Netboot configuration — NFS root, DHCP network in initrd + mkPifinderNetboot = nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + specialArgs = pythonInputs; + modules = commonModules ++ [ + { pifinder.devMode = true; } + { pifinder.cameraType = nixpkgs.lib.mkDefault "imx477"; } # HQ camera for netboot dev + # Camera specialisations for netboot (base is imx477) + ({ ... }: { + specialisation = { + imx296.configuration = { pifinder.cameraType = "imx296"; }; + imx462.configuration = { pifinder.cameraType = "imx462"; }; + }; + }) + ({ lib, pkgs, ... }: + let + boot-splash = import ./nixos/pkgs/boot-splash.nix { inherit pkgs; }; + in { + # Static passwd/group — NFS can't run activation scripts + users.mutableUsers = false; + # DNS for netboot (udhcpc doesn't configure resolvconf properly) + networking.nameservers = [ "192.168.5.1" "8.8.8.8" ]; + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" "nfs" ]; + boot.initrd.supportedFilesystems = [ "nfs" ]; + # Add SPI kernel module for early OLED splash + boot.initrd.kernelModules = [ "spi_bcm2835" ]; + # Override the minimal module list from commonModules — add network drivers + # Note: genet (RPi4 ethernet) is built into the kernel, not a module + boot.initrd.availableKernelModules = lib.mkForce [ + "mmc_block" "usbhid" "usb_storage" "vc4" + ]; + # Add boot-splash to initrd + boot.initrd.extraUtilsCommands = '' + copy_bin_and_libs ${boot-splash}/bin/boot-splash + ''; + # Disable predictable interface names so eth0 works + boot.kernelParams = [ "net.ifnames=0" "biosdevname=0" ]; + boot.initrd.network = { + enable = true; + }; + # Show static splash, then configure network + boot.initrd.postDeviceCommands = '' + # Create device nodes for SPI OLED + mkdir -p /dev + mknod -m 666 /dev/spidev0.0 c 153 0 2>/dev/null || true + mknod -m 666 /dev/gpiochip0 c 254 0 2>/dev/null || true + + # Show static splash image (--static flag = display once and exit) + boot-splash --static || true + # Wait for interface to appear (up to 30 seconds) + echo "Waiting for eth0..." + for i in $(seq 1 60); do + if ip link show eth0 >/dev/null 2>&1; then + echo "eth0 found after $i attempts" + break + fi + sleep 0.5 + done + + ip link set eth0 up + + # Wait for link carrier (cable connected) + echo "Waiting for link carrier..." + for i in $(seq 1 20); do + if [ "$(cat /sys/class/net/eth0/carrier 2>/dev/null)" = "1" ]; then + echo "Link up after $i attempts" + break + fi + sleep 0.5 + done + + # DHCP with retries + echo "Starting DHCP..." + for attempt in 1 2 3; do + if udhcpc -i eth0 -t 5 -T 3 -n -q -s /etc/udhcpc.script; then + echo "DHCP succeeded on attempt $attempt" + break + fi + echo "DHCP attempt $attempt failed, retrying..." + sleep 2 + done + + # Verify we got an IP + if ip addr show eth0 | grep -q "inet "; then + echo "Network configured:" + ip addr show eth0 + else + echo "WARNING: No IP address on eth0!" + ip addr show eth0 + fi + ''; + # NFS root filesystem - NFSv4 with disabled caching for Nix compatibility + fileSystems."/" = { + device = "192.168.5.12:/srv/nfs/pifinder"; + fsType = "nfs"; + options = [ "vers=4" "noac" "actimeo=0" ]; + }; + # Dummy /boot — not used for netboot but NixOS requires it + fileSystems."/boot" = { + device = "none"; + fsType = "tmpfs"; + neededForBoot = false; + }; + }) + ]; + }; + # Custom u-boot variants + pkgsAarch64 = import nixpkgs { system = "aarch64-linux"; }; + # SD boot: skip PCI/USB/net probe, go straight to mmc extlinux + ubootSD = pkgsAarch64.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_CMD_PXE=y + CONFIG_CMD_SYSBOOT=y + CONFIG_BOOTDELAY=0 + CONFIG_PREBOOT="" + CONFIG_BOOTCOMMAND="sysboot mmc 0:2 any 0x02400000 /boot/extlinux/extlinux.conf" + CONFIG_PCI=n + CONFIG_USB=n + CONFIG_CMD_USB=n + CONFIG_CMD_PCI=n + CONFIG_USB_KEYBOARD=n + CONFIG_BCMGENET=n + ''; + }; + # Netboot: PCI + DHCP + PXE + ubootNetboot = pkgsAarch64.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_BOOTCOMMAND="pci enum; dhcp; pxe get; pxe boot" + ''; + }; + + configTxt = pkgsAarch64.writeText "config.txt" '' + [pi3] + kernel=u-boot-rpi3.bin + + [pi02] + kernel=u-boot-rpi3.bin + + [pi4] + kernel=u-boot-rpi4.bin + enable_gic=1 + armstub=armstub8-gic.bin + + disable_overscan=1 + arm_boost=1 + + [cm4] + otg_mode=1 + + [all] + arm_64bit=1 + enable_uart=1 + avoid_warnings=1 + ''; + + # Reproducible development environment for both desktop Linux and the + # aarch64 PiFinder itself. Runtime-native bindings come from Nix, just as + # they do in the systemd service; uv supplies the locked Python workspace. + mkDevShell = system: let + pkgs = import nixpkgs { + inherit system; + overlays = [(final: prev: { + libcamera = prev.libcamera.overrideAttrs (old: { + mesonFlags = (old.mesonFlags or []) ++ [ "-Dpycamera=enabled" ]; + buildInputs = (old.buildInputs or []) ++ [ + final.python313 + final.python313.pkgs.pybind11 + ]; + }); + })]; + }; + pyPkgs = import ./nixos/pkgs/uv-python.nix { + inherit pkgs pyproject-nix uv2nix pyproject-build-systems; + }; + cedar-detect = import ./nixos/pkgs/cedar-detect.nix { inherit pkgs; }; + in pkgs.mkShell { + packages = [ + pyPkgs.devEnv + pkgs.bashInteractive + pkgs.ruff + pkgs.uv + pkgs.git + pkgs.rsync + pkgs.gobject-introspection + pkgs.networkmanager + pkgs.libcamera + pkgs.gpsd + cedar-detect + ]; + shellHook = '' + export PYTHONPATH="${pkgs.libcamera}/lib/python3.13/site-packages:$PYTHONPATH" + export GI_TYPELIB_PATH="${pkgs.lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib.out + pkgs.gobject-introspection + ]}:$GI_TYPELIB_PATH" + export LIBCAMERA_IPA_MODULE_PATH="${pkgs.libcamera}/lib/libcamera" + ''; + }; + + in { + nixosConfigurations = { + # SD card boot — camera baked into DT, switched via specialisations + pifinder = mkPifinderSystem {}; + # Migration — minimal bootable system, defers full system to first boot + pifinder-migration = mkPifinderMigration {}; + # NFS netboot — for development on proxnix + pifinder-netboot = mkPifinderNetboot; + }; + images = { + pifinder = (mkPifinderSystem { includeSDImage = true; }).config.system.build.sdImage; + pifinder-migration = (mkPifinderMigration { includeSDImage = true; }).config.system.build.sdImage; + }; + packages.aarch64-linux = { + uboot-sd = ubootSD; + uboot-netboot = ubootNetboot; + migration-boot-firmware = pkgsAarch64.runCommand "migration-boot-firmware" {} '' + mkdir -p $out + FW=${pkgsAarch64.raspberrypifw}/share/raspberrypi/boot + + # RPi firmware + cp $FW/bootcode.bin $FW/fixup*.dat $FW/start*.elf $out/ + + # Pi3 DTBs + cp $FW/bcm2710-rpi-2-b.dtb $FW/bcm2710-rpi-3-b.dtb $FW/bcm2710-rpi-3-b-plus.dtb $out/ + cp $FW/bcm2710-rpi-cm3.dtb $FW/bcm2710-rpi-zero-2.dtb $FW/bcm2710-rpi-zero-2-w.dtb $out/ + + # Pi4 DTBs + cp $FW/bcm2711-rpi-4-b.dtb $FW/bcm2711-rpi-400.dtb $FW/bcm2711-rpi-cm4.dtb $FW/bcm2711-rpi-cm4s.dtb $out/ + + # config.txt + cp ${configTxt} $out/config.txt + + # u-boot binaries + cp ${pkgsAarch64.ubootRaspberryPi3_64bit}/u-boot.bin $out/u-boot-rpi3.bin + cp ${ubootSD}/u-boot.bin $out/u-boot-rpi4.bin + + # armstub + cp ${pkgsAarch64.raspberrypi-armstubs}/armstub8-gic.bin $out/armstub8-gic.bin + ''; + }; + + devShells = { + x86_64-linux.default = mkDevShell "x86_64-linux"; + aarch64-linux.default = mkDevShell "aarch64-linux"; + }; + + devShells.aarch64-darwin.default = let + pkgs = import nixpkgs { system = "aarch64-darwin"; }; + pyPkgs = import ./nixos/pkgs/uv-python-darwin.nix { + inherit pkgs pyproject-nix uv2nix pyproject-build-systems; + }; + cedar-detect = import ./nixos/pkgs/cedar-detect.nix { inherit pkgs; }; + in pkgs.mkShell { + packages = [ pyPkgs.devEnv pkgs.ruff pkgs.uv cedar-detect ]; + }; + }; +} diff --git a/migration_gate.json b/migration_gate.json deleted file mode 100644 index 703d93f21..000000000 --- a/migration_gate.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "nixos_for_everyone": false, - "nixos_url": "https://github.com/mrosseel/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst" -} diff --git a/migration_source/v1.x.x.sh b/migration_source/v1.x.x.sh deleted file mode 100644 index e17ee2c65..000000000 --- a/migration_source/v1.x.x.sh +++ /dev/null @@ -1,48 +0,0 @@ -# GPSD -sudo apt install -y gpsd -sudo dpkg-reconfigure -plow gpsd -sudo cp ~/PiFinder/pi_config_files/gpsd.conf /etc/default/gpsd - -# PWM -sudo sed -zi '/dtoverlay=pwm,pin=13,func=4\n/!s/$/\ndtoverlay=pwm,pin=13,func=4\n/' /boot/config.txt - -# Uart for GPS -sudo sed -zi '/dtoverlay=uart3\n/!s/$/\ndtoverlay=uart3\n/' /boot/config.txt - -# Migrate DB -if [ -f "/home/pifinder/PiFinder/astro_data/observations.db" ] -then - echo "Migrating astro_data DB" - python -c "from PiFinder import setup;setup.create_logging_tables();" - sqlite3 < /home/pifinder/PiFinder/migrate_db.sql - rm /home/pifinder/PiFinder/astro_data/observations.db -fi - -# Migrate Config files -if ! [ -f "/home/pifinder/PiFinder_data/config.json" ] -then - echo "Migrating config.json" - mv /home/pifinder/PiFinder/config.json /home/pifinder/PiFinder_data/config.json -fi - -# Adjust service definition -sudo systemctl disable pifinder -sudo rm /etc/systemd/system/pifinder.service -sudo cp /home/pifinder/PiFinder/pi_config_files/pifinder.service /lib/systemd/system/pifinder.service -sudo systemctl daemon-reload -sudo systemctl enable pifinder - -# add PiFinder_splash if not already in place -if ! [ -f "/lib/systemd/system/pifinder_spash.service" ] -then - sudo cp /home/pifinder/PiFinder/pi_config_files/pifinder_splash.service /lib/systemd/system/pifinder_splash.service - sudo systemctl daemon-reload - sudo systemctl enable pifinder_splash -fi - -# open permissisons on wpa_supplicant file so we can adjust network config -sudo chmod 666 /etc/wpa_supplicant/wpa_supplicant.conf - -# DONE -echo "Post Update Complete" - diff --git a/migration_source/v2.1.0.sh b/migration_source/v2.1.0.sh deleted file mode 100644 index b066c1875..000000000 --- a/migration_source/v2.1.0.sh +++ /dev/null @@ -1,7 +0,0 @@ -# swap tetra3 submodule -git submodule sync -git submodule update --init --recursive - -# Set up symlink -ln -s /home/pifinder/PiFinder/python/PiFinder/tetra3/tetra3 /home/pifinder/PiFinder/python/tetra3 - diff --git a/migration_source/v2.2.1.sh b/migration_source/v2.2.1.sh deleted file mode 100644 index a2c5a38e9..000000000 --- a/migration_source/v2.2.1.sh +++ /dev/null @@ -1,6 +0,0 @@ -# install lib input -sudo apt install -y libinput10 - -# Add PiFinder user to input group -sudo usermod -G input -a "pifinder" - diff --git a/migration_source/v2.2.2.sh b/migration_source/v2.2.2.sh deleted file mode 100644 index 3aa07b7f1..000000000 --- a/migration_source/v2.2.2.sh +++ /dev/null @@ -1,7 +0,0 @@ -# Enable usb-host on usb-c port - -#Add it to the dw2 line if it exist -sudo sed -zi "s/dtoverlay=dwc2\n/dtoverlay=dwc2,dr_mode=host\n/" /boot/config.txt - -#Add the line if it does not exist -sudo sed -zi '/dtoverlay=dwc2,dr_mode=host\n/!s/$/\ndtoverlay=dwc2,dr_mode=host\n/' /boot/config.txt diff --git a/migration_source/v2.4.0.sh b/migration_source/v2.4.0.sh deleted file mode 100644 index 949219087..000000000 --- a/migration_source/v2.4.0.sh +++ /dev/null @@ -1,4 +0,0 @@ -#Add and enable cedar-detect as system process -sudo cp /home/pifinder/PiFinder/pi_config_files/cedar_detect.service /lib/systemd/system/cedar_detect.service -sudo systemctl daemon-reload -sudo systemctl enable cedar_detect diff --git a/migration_source/v2.6.0.sh b/migration_source/v2.6.0.sh deleted file mode 100644 index 65b771c00..000000000 --- a/migration_source/v2.6.0.sh +++ /dev/null @@ -1,6 +0,0 @@ -# Clear stale flop_image=true on the shipped "Generic Dobsonian" default. -# flip/flop are now applied to the object-detail image; a Dobsonian needs -# neither flag, so repair any persisted config that froze the bad default. -# Idempotent and version-gated by pifinder_post_update.sh. See -# docs/adr/0003-object-image-orientation.md. -python /home/pifinder/PiFinder/python/PiFinder/migrations/v2_6_0_dob_flop.py /home/pifinder/PiFinder_data/config.json diff --git a/nixos/RELEASE.md b/nixos/RELEASE.md new file mode 100644 index 000000000..5f45ecba6 --- /dev/null +++ b/nixos/RELEASE.md @@ -0,0 +1,171 @@ +# NixOS Release Process + +How PiFinder NixOS builds are versioned, published, and updated on devices. + +> Not to be confused with the repo-root `RELEASE.md`, which is hand-written release notes for a specific version. This file documents the plumbing. + +## Single Source Of Truth + +``` +update-manifest.json (committed to the metadata-only nixos-manifest branch) + │ + └─ channels[] + ├─ "version": "3.0.0" ← what the device displays + └─ "store_path": "/nix/store/…" ← what the device installs +``` + +Source branches stay source-only. CI writes generated install metadata to the +manifest branch after successful builds and releases. The device fetches the raw +manifest JSON; it does not call the GitHub API and it does not probe branch-head +`pifinder-build.json` files. + +At runtime, `python/PiFinder/utils.py::get_version()` reads +`/var/lib/pifinder/current-build.json` — the device's single identity file. +The image builder seeds it with the system's own store path; the updater +rewrites it (with version/label/channel) on every install. Human version +labels come from the update manifest, which maps store paths to versions. +(`pifinder-build.json` is retired.) + +## Artifacts + +| Artifact | Where | Purpose | +| ------------------------------ | --------------------------------- | -------------------------------------- | +| Release closure on Attic | `cache.pifinder.eu/pifinder-release` | What the device upgrade pulls (retained) | +| `update-manifest.json` | `nixos-manifest` branch | Tells the channel checker what's live | +| Git tag `vX.Y.Z` | GitHub | Marks a release commit | +| GitHub Release | GitHub Releases | Carries the SD image + tarball | +| `pifinder-vX.Y.Z.img.zst` | GitHub Release asset | SD card image for fresh installs | +| `pifinder-migration-vX.Y.Z.tar.zst` | GitHub Release asset | Tarball for in-place migration | + +## Binary caches + +Two self-hosted Attic caches on `cache.pifinder.eu` (ADR 0004): + +| Cache | Pushed by | Retention | Holds | +| ------------------ | -------------------------- | ---------------- | ------------------------------ | +| `pifinder-release` | `release.yml` | never GC'd | tagged release closures | +| `pifinder` | `build.yml` | short (dev GC) | dev + nightly branch builds | + +Release closures go to `pifinder-release` so a device upgrading long after a +release still resolves the store path; dev builds churn through `pifinder`. +Devices subscribe to both (`nixos/services.nix`), release cache first, with +`cache.nixos.org` as the fall-through for upstream paths. `cachix.org` is no +longer used. + +Both caches are declared server-side in nixos-config +(`machines/general-server/attic-service.nix`). To prune the dev cache later, set +retention **per-cache** (`attic cache configure local:pifinder --retention-period +`), never globally — a global retention would also evict `pifinder-release`. + +## Who Writes `update-manifest.json` + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ CI dev build (.github/workflows/build.yml :: update-manifest) │ +│ After a successful build, commits to nixos-manifest only: │ +│ PR → channel=unstable, kind=pr, store_path= │ +│ else→ channel=unstable, kind=trunk, store_path= │ +├──────────────────────────────────────────────────────────────────┤ +│ Release workflow (.github/workflows/release.yml) │ +│ workflow_dispatch with `version: 3.0.0`. Builds, tags, │ +│ publishes the GitHub Release, then updates stable/beta in the │ +│ manifest branch. │ +└──────────────────────────────────────────────────────────────────┘ +``` + +That's it. Generated metadata never lands on the source branch. + +## Update channels + +`python/PiFinder/ui/software.py` (Software-update menu) discovers what to offer: + +| Channel | Source | +| ------- | ---------------------------------------------------------------------- | +| stable | `update-manifest.json` release entries (`kind=release`) | +| beta | `update-manifest.json` prerelease entries (`kind=release`) | +| unstable | `update-manifest.json` trunk + PR entries | + +For each candidate, it reads `version` (to display) and `store_path` (to +install). Entries with `available=false` or invalid store paths are visible but +not installable. + +## Release flow + +``` + workflow_dispatch (Release) + inputs: version=3.0.0, type=stable|beta, source_branch=main, notes=… + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ 1. checkout source_branch │ + │ 2. nix build .#…toplevel → store path A │ + │ 3. nix build .#images.pifinder → SD image embedding A│ + │ (image seeds /var/lib/pifinder/current-build.json │ + │ with store path A; labels resolve via the manifest) │ + │ 5. extract migration tarball from SD image │ + │ 6. attic push A → pifinder-release (retained) │ + │ 7. tag v3.0.0 (or v3.0.0-beta) │ + │ 8. create GitHub Release with SD image + tarball │ + │ 9. update nixos-manifest with store_path A │ + └─────────────────────────────────────────────────────────────┘ +``` + +SD image, tarball, Attic (`pifinder-release`) closure, and manifest entry all +agree on store path A. Devices display `3.0.0`. Channel checker sees `3.0.0` +pointing at A. + +## Dev build flow + +``` + push / testable PR build + │ + ▼ + ┌─────────────────────────────────────────┐ + │ build.yml │ + │ 1. nix build closure (native + emulated) │ + │ 2. attic push → pifinder (dev cache) │ + │ 3. update-manifest job: │ + │ version = "-" or PR │ + │ commit update-manifest.json only │ + │ on nixos-manifest │ + │ 4. (nixos branch only) build migration tarball, │ + │ upload to GitHub Release │ + └─────────────────────────────────────────┘ +``` + +A device installed from the manifest reports the exact manifest version selected. +There is no one-commit lag and no source-branch stamp commit. + +## Cutting a release + +1. Make sure `source_branch` (usually `main` or `nixos`) is at the commit you want to release. +2. GitHub → Actions → **Release** → Run workflow. +3. Inputs: + - `version`: semver only, no `v` prefix — e.g. `3.0.0` + - `notes`: markdown body for the GitHub Release + - `type`: `stable` or `beta` (beta tags as `vX.Y.Z-beta` and marks the release as prerelease) + - `source_branch`: branch to release from (default `main`) +4. Workflow runs end-to-end (~30–45 min). +5. Verify the GitHub Release has both `pifinder-vX.Y.Z.img.zst` and `pifinder-migration-vX.Y.Z.tar.zst`. +6. If the release should hide older entries, update the manifest generator policy + or prune the manifest branch in a follow-up change. + +## Hotfix release + +Use `source_branch=release/X.Y` (long-lived hotfix branches). The release +workflow builds and tags that source branch, then writes install metadata to +`nixos-manifest`. + +## Files of interest + +| File | Role | +| ------------------------------------- | ------------------------------------------ | +| `.github/scripts/update_manifest.py` | Manifest merge/update helper | +| `update-manifest.json` | Generated JSON on `nixos-manifest` | +| `python/PiFinder/utils.py` | `get_version()` reader | +| `python/PiFinder/ui/software.py` | Manifest-driven channel update UI | +| `nixos/pkgs/pifinder-src.nix` | Copies the source tree into the store path | +| `nixos/services.nix` | Symlinks `/home/pifinder/PiFinder` → store path | +| `nixos/device.nix` | `BUILD_JSON_URL` for nightly channel check | +| `.github/workflows/build.yml` | Dev builds + manifest update | +| `.github/workflows/release.yml` | Manual release dispatcher | diff --git a/nixos/brickbots-attic-setup.md b/nixos/brickbots-attic-setup.md new file mode 100644 index 000000000..a43567345 --- /dev/null +++ b/nixos/brickbots-attic-setup.md @@ -0,0 +1,74 @@ +# Giving brickbots/PiFinder access to the Attic cache + +The NixOS CI builds substitute from the self-hosted Attic cache +`cache.pifinder.eu/pifinder` (ADR 0004). There are two levels of access: + +| Access | Needs a token? | Who | Status | +| ------ | -------------- | --- | ------ | +| **Pull** (download prebuilt paths) | No — public, via the cache's public key | everyone, incl. fork PRs | ✅ already wired in the workflows | +| **Push** (upload build results) | **Yes** — `ATTIC_TOKEN` secret | trusted (non-fork) runs only | ⬇️ optional, set up below | + +**Pull already works with no setup.** The workflows configure the public +substituter directly: + +``` +extra-substituters = https://cache.pifinder.eu/pifinder +extra-trusted-public-keys = pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE= +``` + +So brickbots PR builds (and the hosted `ubuntu-24.04-arm` runner) download from +the cache without any secret. GitHub never exposes secrets to **fork** PRs, which +is why push is gated and pull must be tokenless. + +You only need the steps below if you want **brickbots' own CI builds** (pushes to +its `main`/branches, or maintainer-triggered runs) to **upload** their results so +the shared cache stays warm. + +## 1. Mint a push token (mrosseel — cache admin) + +On the Attic server (the cache lives in `nixos-config`, +`machines/general-server/attic-service.nix`): + +```bash +# Scope the token to the `pifinder` cache: pull + push, 1-year validity. +atticd-atticadm make-token \ + --sub "brickbots-ci" \ + --validity "1y" \ + --pull "pifinder" \ + --push "pifinder" +``` + +This prints a JWT. Treat it as a secret. Scope it to **only** the `pifinder` +cache (not `pifinder-release`) so a leak can't poison release closures. + +## 2. Add it as a repo secret (brickbots — maintainer) + +In **github.com/brickbots/PiFinder**: + +1. **Settings → Secrets and variables → Actions → New repository secret** +2. Name: `ATTIC_TOKEN` +3. Value: the JWT from step 1 +4. Save. + +(Use an **organization** secret instead if more than one repo needs it.) + +## 3. That's it + +The workflows already do the right thing once the secret exists: + +- **With `ATTIC_TOKEN`** (brickbots' own branch pushes / trusted runs): the + `Attic login for push` step logs in and the `Push to Attic` step uploads. +- **Without it** (fork PRs): those steps no-op; the build still pulls from the + public cache and is verify-only. + +No workflow edits are required on the brickbots side — the logic keys off whether +the secret is present. + +## Security notes + +- The token is exposed only to non-fork runs, so external contributors' fork PRs + can never push, even after this is set up. +- Rotate by minting a new token and updating the secret; revoke the old one on + the Attic server. +- Keep push scoped to `pifinder` (dev cache). Release closures go to + `pifinder-release` via the separate, mrosseel-only release workflow. diff --git a/nixos/device.nix b/nixos/device.nix new file mode 100644 index 000000000..bdf30d539 --- /dev/null +++ b/nixos/device.nix @@ -0,0 +1,323 @@ +{ config, lib, pkgs, ... }: +let + boot-splash = import ./pkgs/boot-splash.nix { inherit pkgs; }; +in { + options.pifinder = { + devMode = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable development mode (NFS netboot support, etc.)"; + }; + }; + + config = { + # --------------------------------------------------------------------------- + # Minimal system packages for migration troubleshooting + # --------------------------------------------------------------------------- + environment.systemPackages = with pkgs; [ + # nano over vim: 40MB smaller on a size-critical image (2GB boards must + # hold the whole tarball in RAM during migration) + nano + htop + e2fsprogs + dosfstools + parted + file + curl + ]; + + + # --------------------------------------------------------------------------- + # Binary substituters — Pi downloads pre-built paths, never compiles. + # Two Attic caches on cache.pifinder.eu (NixOS ADR 0001): pifinder-release + # (retained release closures) and pifinder (dev/nightly). The first-boot + # download below resolves its target from the update manifest's best available + # channel (NixOS ADR 0003). + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.pifinder.eu/pifinder-release" + "https://cache.pifinder.eu/pifinder" + "https://cache.nixos.org" + ]; + trusted-public-keys = [ + # Attic cache signing keys (same values as nixos/services.nix); pifinder + # restored to the original 8UU after the cutover rotation stranded the + # fleet. Real keys — never ship a placeholder; invalid base64 aborts nix. + "pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE=" + "pifinder-release:WG/Fw1cIX7YpwfWrbWTP5eCzn3bz6AaicW5qKxLKpoM=" + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + ]; + }; + + # Don't pull nixpkgs source into closure (~186 MB) + nix.channel.enable = false; + nix.registry = lib.mkForce {}; + nix.nixPath = lib.mkForce []; + + # nixos-rebuild-ng pulls in Python 3.13 (~110 MB) — not needed for migration + system.disableInstallerTools = true; + + # Perl is included by default (~59 MB) — not needed for migration + environment.defaultPackages = lib.mkForce []; + + # Strip NetworkManager VPN plugins (openconnect/stoken/gtk3 deps) + networking.networkmanager.plugins = lib.mkForce []; + + # --------------------------------------------------------------------------- + # SD card optimizations + # --------------------------------------------------------------------------- + boot.loader.generic-extlinux-compatible.configurationLimit = 2; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 3d"; + }; + nix.settings.auto-optimise-store = true; + + boot.tmp.useTmpfs = true; + boot.tmp.tmpfsSize = "200M"; + + services.journald.extraConfig = '' + Storage=volatile + RuntimeMaxUse=50M + ''; + + zramSwap = { + enable = true; + memoryPercent = 50; + }; + + fileSystems."/" = lib.mkDefault { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + options = [ "noatime" "nodiratime" ]; + }; + + # --------------------------------------------------------------------------- + # Nix DB registration (first boot after migration) + # --------------------------------------------------------------------------- + systemd.services.nix-path-registration = { + description = "Load Nix store path registration from migration"; + after = [ "local-fs.target" ]; + before = [ "nix-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "/nix-path-registration"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix coreutils ]; + script = '' + nix-store --load-db < /nix-path-registration + rm /nix-path-registration + ''; + }; + + # --------------------------------------------------------------------------- + # First boot: download full PiFinder system from the binary cache and switch + # --------------------------------------------------------------------------- + systemd.services.pifinder-first-boot = { + description = "Download full PiFinder NixOS system from the binary cache"; + # time-sync.target ordering pairs with the explicit clock-wait in the + # script below — the Pi has no RTC, and TLS to the binary cache fails + # while the clock is still in the past. + after = [ "network-online.target" "time-sync.target" "nix-path-registration.service" "nix-daemon.service" ]; + wants = [ "time-sync.target" ]; + requires = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + # No existence condition: the manifest is the primary source (ADR 0003) and + # needs no local file. The baked first-boot-target, when present, is only + # the offline fallback — the old ConditionPathExists on it silently skipped + # the whole service when the tarball pipeline stopped baking the file. + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + TimeoutStartSec = "30min"; + }; + path = with pkgs; [ nix coreutils systemd curl jq gnugrep ]; + script = '' + set -euo pipefail + + # Real-progress splash on the OLED, fed via a progress file (0-100). + PROGRESS_FILE=/run/pifinder-boot-progress + echo 0 > "$PROGRESS_FILE" + ${boot-splash}/bin/boot-splash --progress "$PROGRESS_FILE" & + SPLASH_PID=$! + trap 'kill $SPLASH_PID 2>/dev/null || true' EXIT + + # Resolve the full system from the update manifest — the same file the + # on-device updater reads (NixOS ADR 0003). Migration rides the newest entry + # in the best available channel: stable, then beta, then the unstable trunk. + # Stable holds only releases, whose closures live in the retained + # pifinder-release cache, so a resolved stable path can't be GC'd out from + # under a published tarball. Falls back to the baked-in target if the + # manifest can't be fetched. + MANIFEST_URL="https://raw.githubusercontent.com/brickbots/PiFinder/nixos-manifest/update-manifest.json" + STORE_PATH="" + if MANIFEST_JSON=$(curl -sf --max-time 15 "$MANIFEST_URL" 2>/dev/null); then + # jq comma-stream encodes the priority order; first available, valid path + # wins. TEMPORARY: the unstable trunk is pinned to source_ref "nixos" + # because the NixOS line still lives on the nixos branch, not main. Drop + # the source_ref guard once nixos becomes the mainline trunk (ADR 0003). + STORE_PATH=$(printf '%s\n' "$MANIFEST_JSON" | jq -r ' + [ ( .channels.stable[]?, + .channels.beta[]?, + (.channels.unstable[]? | select(.kind == "trunk" and .source_ref == "nixos")) ) + | select(.available == true and ((.store_path // "") | startswith("/nix/store/"))) + | .store_path ] | .[0] // empty' 2>/dev/null) + [ -n "$STORE_PATH" ] && echo "Resolved full system from manifest: $STORE_PATH" + fi + if [ -z "$STORE_PATH" ] || [[ "$STORE_PATH" != /nix/store/* ]]; then + echo "Manifest unavailable or empty, falling back to baked-in target" + [ -f /var/lib/pifinder/first-boot-target ] && \ + STORE_PATH=$(cat /var/lib/pifinder/first-boot-target) + fi + if [ -z "$STORE_PATH" ] || [[ "$STORE_PATH" != /nix/store/* ]]; then + echo "ERROR: No valid store path found" + exit 1 + fi + + # The Pi has no RTC: at cold boot the clock starts in the past, so TLS + # validation against the binary cache fails ("certificate is not yet + # valid") and the download aborts. Wait for timesyncd to fix the clock. + echo "Waiting for clock synchronization..." + for _ in $(seq 1 120); do + [ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)" = yes ] && break + [ -e /run/systemd/timesync/synchronized ] && break + sleep 1 + done + echo "Clock: $(date -u)" + + # First-boot fetches the whole system, so per-path byte sizing would mean + # tens of thousands of cache lookups — too slow. Count the paths to fetch + # (one dry-run, timeout-bounded so it can't hang) and show a path-count + # percentage on the splash. set +e keeps it advisory — never aborts. + echo "Computing download size..." + set +e + TOTAL_PATHS=$(timeout 120 nix-store --realise --dry-run "$STORE_PATH" 2>&1 | grep -c '^ /nix/store/') + [ "$TOTAL_PATHS" -gt 0 ] 2>/dev/null || TOTAL_PATHS=0 + set -e + echo "Downloading full PiFinder system: $STORE_PATH ($TOTAL_PATHS paths)" + + COPIED=0 + nix build "$STORE_PATH" --max-jobs 0 2>&1 | while IFS= read -r line; do + echo "$line" + case "$line" in + *"copying path "*) + COPIED=$((COPIED + 1)) + [ "$TOTAL_PATHS" -gt 0 ] && echo "$((COPIED * 100 / TOTAL_PATHS))" > "$PROGRESS_FILE" + ;; + esac + done + echo 100 > "$PROGRESS_FILE" + + echo "Setting system profile..." + nix-env -p /nix/var/nix/profiles/system --set "$STORE_PATH" + + # Record the device identity: the update UI hides the running build by + # store path (current-build.json), and every later upgrade rewrites this + # file. Label/version/channel come from the manifest entry we resolved; + # a fallback-target install records just the store path. + IDENTITY=$(printf '%s\n' "''${MANIFEST_JSON:-}" | jq -c --arg sp "$STORE_PATH" ' + [ .channels | to_entries[] | .key as $ch | .value[]? + | select(.store_path == $sp) + | {channel: $ch, label: .label, version: .version, store_path: .store_path} ] + | .[0] // empty' 2>/dev/null) + [ -n "$IDENTITY" ] || IDENTITY=$(jq -nc --arg sp "$STORE_PATH" '{store_path: $sp}') + printf '%s\n' "$IDENTITY" > /var/lib/pifinder/current-build.json + + echo "Configuring bootloader..." + "$STORE_PATH/bin/switch-to-configuration" boot + + echo "Removing first-boot trigger..." + rm -f /var/lib/pifinder/first-boot-target + + echo "Cleaning up migration closure..." + nix-env --delete-generations +2 -p /nix/var/nix/profiles/system || true + nix-collect-garbage || true + + echo "Rebooting into full PiFinder system..." + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # Polkit rules for NetworkManager control + # --------------------------------------------------------------------------- + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if (subject.user == "pifinder") { + if (action.id.indexOf("org.freedesktop.NetworkManager") == 0) { + return polkit.Result.YES; + } + if (action.id == "org.freedesktop.login1.reboot" || + action.id == "org.freedesktop.login1.reboot-multiple-sessions" || + action.id == "org.freedesktop.login1.power-off" || + action.id == "org.freedesktop.login1.power-off-multiple-sessions") { + return polkit.Result.YES; + } + } + }); + ''; + + # --------------------------------------------------------------------------- + # Sudoers — minimal for migration + # --------------------------------------------------------------------------- + security.sudo.extraRules = [{ + users = [ "pifinder" ]; + commands = [ + { command = "/run/current-system/sw/bin/shutdown -r now"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/shutdown now"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/hostname *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/avahi-set-host-name *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder-first-boot.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder*"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl status *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; } + ]; + }]; + + # --------------------------------------------------------------------------- + # Early boot splash + # --------------------------------------------------------------------------- + systemd.services.boot-splash = { + description = "Early boot splash screen"; + wantedBy = [ "sysinit.target" ]; + after = [ "systemd-modules-load.service" ]; + wants = [ "systemd-modules-load.service" ]; + unitConfig.DefaultDependencies = false; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "boot-splash-wait" '' + for i in $(seq 1 40); do + [ -e /dev/spidev0.0 ] && exec ${boot-splash}/bin/boot-splash --static + sleep 0.25 + done + echo "SPI device never appeared" >&2 + exit 1 + ''; + }; + }; + + # --------------------------------------------------------------------------- + # SSH access + # --------------------------------------------------------------------------- + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = true; + PermitRootLogin = "no"; + }; + }; + + # NetworkManager-wait-online adds ~10s to boot but is needed for + # pifinder-first-boot to have internet. The first-boot script also has + # its own connectivity retry loop as a fallback. + systemd.services.NetworkManager-wait-online.serviceConfig.TimeoutStartSec = "30s"; + + system.stateVersion = "24.11"; + }; # config +} diff --git a/nixos/hardware.nix b/nixos/hardware.nix new file mode 100644 index 000000000..b5693803a --- /dev/null +++ b/nixos/hardware.nix @@ -0,0 +1,225 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.pifinder; + + # Camera overlay name mapping. imx462 deliberately uses the imx290 overlay: + # that is the exact configuration PiFinder ships on Raspbian + # (switch_camera.py writes "dtoverlay=imx290,clock-frequency=74250000"), so + # the sony,imx290lqr driver path is field-proven on this sensor. The kernel + # also ships imx462.dtbo (sony,imx462lqr, dedicated init registers) — an + # untested-here alternative. The clock-frequency parameter is the part that + # actually matters; see cameraClockDtbo below. + cameraDriver = { + imx296 = "imx296"; + imx462 = "imx290"; + imx477 = "imx477"; + }.${cfg.cameraType}; + + # Compile DTS text to DTBO + compileOverlay = name: dtsText: pkgs.deviceTree.compileDTS { + name = "${name}-dtbo"; + dtsFile = pkgs.writeText "${name}.dts" dtsText; + }; + + # SPI0 — no nixos-hardware option, use custom overlay + spi0Dtbo = compileOverlay "spi0" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &spi0 { status = "okay"; }; + ''; + + # UART3 for the on-board GPS (published as /dev/gpsuart by udev) + uart3Dtbo = compileOverlay "uart3" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &uart3 { status = "okay"; }; + ''; + + # Peripheral I2C (BNO055 IMU, rev-4 BQ25895 charger) as a bit-banged + # i2c-gpio bus on the standard SDA/SCL pins (GPIO2/GPIO3). The BCM2711 + # hardware I2C block corrupts transfers when a slave stretches the clock + # (a silicon bug; the BNO055 stretches routinely) — the previous + # workaround, running &i2c1 at 10 kHz, only lowered the corruption odds + # while making every IMU transaction ~10x slower. i2c-gpio implements + # clock stretching per spec at ~60-100 kHz effective. &i2c1 is disabled + # explicitly so the hardware block never claims the pins; the Python + # side (PiFinder.i2c_bus.get_i2c) discovers this adapter through sysfs. + i2cGpioDtbo = compileOverlay "i2c-gpio" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &i2c1 { status = "disabled"; }; + &{/} { + i2c_gpio: i2c-gpio { + compatible = "i2c-gpio"; + sda-gpios = <&gpio 2 6>; /* GPIO_OPEN_DRAIN */ + scl-gpios = <&gpio 3 6>; /* GPIO_OPEN_DRAIN */ + i2c-gpio,delay-us = <2>; + #address-cells = <1>; + #size-cells = <0>; + }; + }; + ''; + + # PWM: GPIO 13 (channel 1) keypad backlight + GPIO 12 (channel 0) rev-4 + # buzzer earcons. Both ALT0 (function 4): GPIO12 = PWM0_0, GPIO13 = PWM0_1. + # Muxing GPIO12 unconditionally is safe — rev-3 boards leave it unconnected + # and the sound process only spawns when the rev-4 charger is detected. + pwmDtbo = compileOverlay "pwm" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &gpio { + pwm_pins: pwm_pins { + brcm,pins = <12 13>; + brcm,function = <4 4>; /* ALT0 = PWM0_0, PWM0_1 */ + }; + }; + &pwm { + status = "okay"; + pinctrl-names = "default"; + pinctrl-0 = <&pwm_pins>; + }; + ''; + + # Rev-4 power-off latch (ADR 0007 on main): driving GPIO14 low trips the + # LTC2954 and cuts power. The kernel's gpio-poweroff handler runs strictly + # after filesystems are down, and only on a real power-off (reboot takes a + # different path). Active-low; the board's hardware pull-up on GPIO14 holds + # power on until the handler fires. Deliberately unconditional across + # revisions: on rev-3 GPIO14-low does nothing electrically — the only effect + # is a cosmetic kernel WARN + ~3s wait at halt. + gpioPoweroffDtbo = compileOverlay "gpio-poweroff" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &{/} { + power_ctrl: power_ctrl { + compatible = "gpio-poweroff"; + gpios = <&gpio 14 1>; /* GPIO_ACTIVE_LOW */ + timeout-ms = <3000>; + }; + }; + ''; + + # Camera overlay from kernel's DTB overlays directory + cameraDtbo = "${config.boot.kernelPackages.kernel}/dtbs/overlays/${cameraDriver}.dtbo"; + + # PiFinder's imx462 module has a 74.25 MHz oscillator, but the kernel's + # imx290/imx462 overlays default the xclk to 37.125 MHz. Raspbian fixes this + # with the overlay parameter "clock-frequency=74250000"; fdtoverlay cannot + # apply overlay parameters (__overrides__), so without this the default + # sneaks through and the driver programs INCKSEL/PLL for the wrong xclk — + # the sensor enumerates on I2C but never delivers frames and libcamera + # reports "Camera frontend has timed out" with an empty kernel log. + # Override both places the Raspbian parameter writes: the fixed-clock node + # (the driver's clk_set_rate must match its rate) and the sensor node + # property (the driver selects the INCKSEL register set from it). Must be + # applied after cameraDtbo: &cam_node is defined by the camera overlay and + # resolves from its merged symbols. + cameraClockDtbo = compileOverlay "imx462-xclk" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &cam1_clk { clock-frequency = <74250000>; }; + &cam_node { clock-frequency = <74250000>; }; + ''; +in { + options.pifinder = { + cameraType = lib.mkOption { + type = lib.types.enum [ "imx296" "imx462" "imx477" ]; + default = "imx462"; + description = "Camera sensor type for PiFinder"; + }; + }; + + config = { + # BCM2711 device trees: Pi 4B (PiFinder rev 3) and CM4 (PiFinder v4). + # The deviceTree.package override below processes exactly these two. + hardware.deviceTree.filter = "bcm2711-rpi-*.dtb"; + # No explicit deviceTree.name: extlinux then emits FDTDIR instead of FDT, + # and u-boot appends its board-detected ${fdtfile} to it — which on 64-bit + # u-boot already carries the broadcom/ subdirectory prefix (DTB_DIR in + # board/raspberrypi/rpi/rpi.c). One image thus boots the matching DTB on + # both the 4B and the CM4. Rollback if a board fails to pick its DTB: + # set hardware.deviceTree.name = "broadcom/.dtb" to force FDT. + hardware.deviceTree.name = null; + + # Firmware: the nixos-hardware Pi 4 module enables the full redistributable + # set — linux-firmware alone is ~723MB uncompressed, 40% of the migration + # tarball, for hardware this board doesn't have. The Pi 4 needs only the + # Broadcom wifi/BT blobs (~10MB). Boot firmware (start.elf etc.) is + # separate and unaffected (populateFirmwareCommands). + hardware.enableRedistributableFirmware = lib.mkForce false; + hardware.firmware = [ pkgs.raspberrypiWirelessFirmware ]; + + # I2C enabled (loads i2c-dev module, creates i2c group) + hardware.i2c.enable = true; + # The bit-banged bus driver (DT modalias autoload also works when built + # as a module; listing it here makes the dependency explicit) + boot.kernelModules = [ "i2c-gpio" ]; + + # GPIO14 is the rev-4 power-off kill line (gpio-poweroff overlay above) — + # its default function is UART0 TXD, so nothing may drive serial console + # bytes onto it (ADR 0007). No console= kernel param points there, and this + # keeps a getty from ever claiming the port. + systemd.services."serial-getty@ttyAMA0".enable = false; + + # Apply all DT overlays via fdtoverlay, bypassing NixOS apply_overlays.py + # which rejects RPi camera overlays due to compatible string mismatch + # (overlays declare "brcm,bcm2835" but kernel DTBs use "brcm,bcm2711") + hardware.deviceTree.package = let + kernelDtbs = config.hardware.deviceTree.dtbSource; + in lib.mkForce (pkgs.runCommand "device-tree-with-overlays" { + nativeBuildInputs = [ pkgs.dtc ]; + } '' + mkdir -p $out/broadcom + for dtb in ${kernelDtbs}/broadcom/bcm2711-rpi-4-b.dtb \ + ${kernelDtbs}/broadcom/bcm2711-rpi-cm4.dtb; do + fdtoverlay -i "$dtb" \ + -o "$out/broadcom/$(basename $dtb)" \ + ${i2cGpioDtbo} ${spi0Dtbo} ${uart3Dtbo} ${pwmDtbo} ${gpioPoweroffDtbo} ${cameraDtbo} \ + ${lib.optionalString (cfg.cameraType == "imx462") cameraClockDtbo} + done + ''); + + # udev rules for hardware access without root + services.udev.extraRules = '' + SUBSYSTEM=="spidev", GROUP="spi", MODE="0660" + SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660" + SUBSYSTEM=="pwm", GROUP="gpio", MODE="0660" + SUBSYSTEM=="gpio", GROUP="gpio", MODE="0660" + KERNEL=="gpiomem", GROUP="gpio", MODE="0660" + # On-board GPS UART (uart3, fe201600 on BCM2711): the kernel's ttyAMA + # numbering is not stable across versions, so match the DT node and + # publish a fixed name for gpsd to open. + SUBSYSTEM=="tty", KERNELS=="fe201600.serial", SYMLINK+="gpsuart", GROUP="dialout", MODE="0660", TAG+="systemd", ENV{SYSTEMD_WANTS}+="gpsd-add-uart.service" + # DMA heap for libcamera/picamera2 (CMA memory allocation) + SUBSYSTEM=="dma_heap", GROUP="video", MODE="0660" + ''; + + # Deterministic root password (sha-512 crypt of "solveit"), enforced on + # every activation — unlike initialPassword, which only applies at account + # creation and drifts if changed at runtime. Test-device convenience; the + # hash lives in the world-readable store, which is fine for a known cred. + users.users.root.hashedPassword = + "$6$caME5a7TbhnPfrV2$sXHx/OuQCaRkjCG/Lba8vxL5R8.SgD72YHKWzHwDVj9CfDgz1xJ766ht0VCB18Q/igzceaoQM8fwgYNj2ygap/"; + users.users.pifinder = { + isNormalUser = true; + # MUST stay initialPassword (not hashedPassword): the web UI changes this + # password at runtime via `sudo chpasswd` (sys_utils.change_password). + # initialPassword applies only at account creation, so that change + # persists; hashedPassword would re-enforce "solveit" on every activation + # and silently revert the user's password on the next upgrade. + initialPassword = "solveit"; + extraGroups = [ "spi" "i2c" "gpio" "dialout" "video" "networkmanager" "systemd-journal" "input" "kmem" ]; + }; + users.groups = { + spi = {}; + i2c = {}; + gpio = {}; + }; + }; +} diff --git a/nixos/networking.nix b/nixos/networking.nix new file mode 100644 index 000000000..8115945bb --- /dev/null +++ b/nixos/networking.nix @@ -0,0 +1,136 @@ +{ config, lib, pkgs, ... }: +{ + networking = { + hostName = "pifinder"; + networkmanager.enable = true; + wireless.enable = false; # NetworkManager handles WiFi + firewall = { + checkReversePath = "loose"; # Allow multi-interface (WiFi + ethernet) on same subnet + allowedUDPPorts = [ 53 67 ]; # DNS + DHCP for AP mode + # Web UI and the LX200 server used by SkySafari/planetarium clients. + allowedTCPPorts = [ 80 4030 ]; + }; + }; + + # Robust time sync for the RTC-less Pi: NTP= servers are always tried (and + # combined with any per-interface/DHCP servers), so a dead DHCP-advertised + # NTP server can't block the clock. FallbackNTP alone is skipped whenever a + # per-interface server is known — too fragile to rely on for first-boot + # migration, which gates the binary-cache fetch on a synchronized clock. + services.timesyncd.servers = [ + "0.pool.ntp.org" + "1.pool.ntp.org" + "2.pool.ntp.org" + "3.pool.ntp.org" + ]; + + # dnsmasq for NetworkManager AP shared mode (DHCP for AP clients) + services.dnsmasq.enable = false; # NM manages its own dnsmasq instance + environment.systemPackages = [ pkgs.dnsmasq ]; + + # Wired ethernet with DHCP (autoconnect) + environment.etc."NetworkManager/system-connections/Wired.nmconnection" = { + text = '' + [connection] + id=Wired + type=ethernet + autoconnect=true + + [ipv4] + method=auto + + [ipv6] + method=auto + ''; + mode = "0600"; + }; + + # Pre-configured AP profile (activated on demand via nmcli) + environment.etc."NetworkManager/system-connections/PiFinder-AP.nmconnection" = { + text = '' + [connection] + id=PiFinder-AP + type=wifi + # Never self-start: NetworkManager would activate the AP instantly at + # boot (own radio, no scan needed) and win the race against a client + # network that still has to scan + associate, then stay on it. The AP is + # brought up only on demand by pifinder-wifi-fallback below. + autoconnect=false + + [wifi] + mode=ap + ssid=PiFinderAP + band=bg + channel=7 + + [ipv4] + method=shared + address1=10.10.10.1/24 + + [ipv6] + method=disabled + ''; + mode = "0600"; + }; + + # The PiFinder-AP profile has autoconnect disabled, so NetworkManager joins + # a known client network when one is reachable and never self-starts the AP. + # AP-as-fallback policy (wired > wifi client > AP) is enforced by + # pifinder-net-policy in services.nix (full system) or by + # wifi-fallback-minimal.nix (migration image, no Python env) — this shared + # module deliberately carries no fallback service so the two can differ. + + # --------------------------------------------------------------------------- + # Avahi/mDNS for hostname discovery (.local). It lives in this shared + # networking module on purpose: BOTH the running system (commonModules) and + # the migration build (migrationModules) import networking.nix, whereas + # services.nix and device.nix are each only in one of those — so avahi must + # not live in either alone or one system ends up with no mDNS at all. + # --------------------------------------------------------------------------- + services.avahi = { + enable = true; + # PiFinder's application listeners (including LX200) are IPv4-only and AP + # mode disables IPv6. Do not advertise an unusable link-local AAAA address + # ahead of the working IPv4 address to mobile clients. + ipv6 = false; + nssmdns4 = true; + publish = { + enable = true; + addresses = true; + domain = true; + workstation = true; + }; + }; + + systemd.services.avahi-daemon.serviceConfig.ExecStartPre = + "${pkgs.coreutils}/bin/rm -f /run/avahi-daemon/pid"; + + # Apply user-chosen hostname from PiFinder_data (survives NixOS rebuilds), + # overriding networking.hostName above. + systemd.services.pifinder-hostname = { + description = "Apply PiFinder custom hostname"; + after = [ "avahi-daemon.service" ]; + wants = [ "avahi-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "apply-hostname" '' + f=/home/pifinder/PiFinder_data/hostname + [ -f "$f" ] || exit 0 + name=$(cat "$f") + [ -n "$name" ] || exit 0 + /run/current-system/sw/bin/hostname "$name" + /run/current-system/sw/bin/avahi-set-host-name "$name" || \ + /run/current-system/sw/bin/systemctl restart avahi-daemon.service + ''; + }; + }; + + # Avahi watches interface/address changes itself, so it must remain running + # while NetworkManager brings links up and down. Restarting it from a + # dispatcher briefly withdraws the .local record and also resets a custom + # runtime hostname to the static value above. NetworkManager must not manage + # the hostname either, or it undoes pifinder-hostname's persisted value. + networking.networkmanager.settings.main."hostname-mode" = "none"; +} diff --git a/nixos/pkgs/boot-splash.c b/nixos/pkgs/boot-splash.c new file mode 100644 index 000000000..328664c6d --- /dev/null +++ b/nixos/pkgs/boot-splash.c @@ -0,0 +1,548 @@ +/* + * boot-splash - Early boot splash for PiFinder + * + * Displays welcome image with Knight Rider animation until stopped. + * Designed for NixOS early boot (before Python starts). + * + * Hardware: SPI0.0, DC=GPIO24, RST=GPIO25. Two panels: + * - rev 3 (Pi 4B): 128x128 SSD1351 OLED + * - v4 (CM4): 176x176 SSD1333 OLED, mounted 270° (luma rotate=3) + * The panel is picked from the device-tree model string: a CM4 carrier is + * a v4 board. That signal is available in early boot / initrd, unlike the + * BQ25895 charger probe the Python app uses (needs the i2c-gpio bus up). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_DIM 176 +#define SPI_DEVICE "/dev/spidev0.0" +#define SPI_SPEED 40000000 +#define GPIO_DC 24 +#define GPIO_RST 25 + +/* RGB565 colors (display interprets as RGB despite BGR setting) */ +#define COL_BLACK 0x0000 +#define COL_RED 0xF800 +#define COL_DKRED 0x3800 /* dim red — unfilled progress track */ + +#define PROGRESS_FILE_DEFAULT "/run/pifinder-boot-progress" + +/* Include generated image data (128x128, both panels center it) */ +#include "welcome_image.h" +#define IMG_W 128 +#define IMG_H 128 + +enum panel_type { PANEL_SSD1351, PANEL_SSD1333 }; + +static enum panel_type panel = PANEL_SSD1351; +static int disp_w = 128; +static int disp_h = 128; + +static int spi_fd = -1; +static int gpio_fd = -1; +static struct gpio_v2_line_request dc_req; +static struct gpio_v2_line_request rst_req; +/* Logical orientation (matches the Python app's drawing space); the + * SSD1333's 270° panel mounting is applied at flush time. */ +static uint16_t framebuf[MAX_DIM * MAX_DIM]; +static volatile int running = 1; + +static void signal_handler(int sig) { + (void)sig; + running = 0; +} + +static void msleep(int ms) { + struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L }; + nanosleep(&ts, NULL); +} + +/* v4 boards are CM4-based; rev 3 is a Pi 4B. Reads the DT model string, + * which the firmware populates from the board it actually booted on. */ +static void detect_panel(void) { + static const char *paths[] = { + "/sys/firmware/devicetree/base/model", + "/proc/device-tree/model", + }; + char model[128] = {0}; + + for (size_t i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) { + FILE *f = fopen(paths[i], "r"); + if (!f) + continue; + size_t n = fread(model, 1, sizeof(model) - 1, f); + fclose(f); + model[n] = '\0'; + break; + } + + if (strstr(model, "Compute Module 4")) { + panel = PANEL_SSD1333; + disp_w = 176; + disp_h = 176; + } +} + +static int gpio_request_line(int chip_fd, int pin, struct gpio_v2_line_request *req) { + struct gpio_v2_line_request r = {0}; + r.offsets[0] = pin; + r.num_lines = 1; + r.config.flags = GPIO_V2_LINE_FLAG_OUTPUT; + snprintf(r.consumer, sizeof(r.consumer), "boot-splash"); + + if (ioctl(chip_fd, GPIO_V2_GET_LINE_IOCTL, &r) < 0) { + perror("GPIO_V2_GET_LINE_IOCTL"); + return -1; + } + *req = r; + return 0; +} + +static void gpio_set(struct gpio_v2_line_request *req, int value) { + struct gpio_v2_line_values vals = {0}; + vals.bits = value ? 1 : 0; + vals.mask = 1; + ioctl(req->fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals); +} + +static void spi_write(const uint8_t *data, size_t len) { + const size_t chunk_size = 4096; + while (len > 0) { + size_t this_len = len > chunk_size ? chunk_size : len; + struct spi_ioc_transfer tr = {0}; + tr.tx_buf = (unsigned long)data; + tr.len = this_len; + tr.speed_hz = SPI_SPEED; + tr.bits_per_word = 8; + ioctl(spi_fd, SPI_IOC_MESSAGE(1), &tr); + data += this_len; + len -= this_len; + } +} + +static void oled_cmd(uint8_t cmd) { + gpio_set(&dc_req, 0); + spi_write(&cmd, 1); +} + +static void oled_data(const uint8_t *data, size_t len) { + gpio_set(&dc_req, 1); + spi_write(data, len); +} + +static void oled_reset(void) { + gpio_set(&rst_req, 1); + msleep(10); + gpio_set(&rst_req, 0); + msleep(10); + gpio_set(&rst_req, 1); + msleep(10); +} + +static void ssd1351_init(void) { + uint8_t d; + + oled_reset(); + + oled_cmd(0xFD); d = 0x12; oled_data(&d, 1); /* Unlock */ + oled_cmd(0xFD); d = 0xB1; oled_data(&d, 1); /* Unlock commands */ + oled_cmd(0xAE); /* Display off */ + oled_cmd(0xB3); d = 0xF1; oled_data(&d, 1); /* Clock divider */ + oled_cmd(0xCA); d = 0x7F; oled_data(&d, 1); /* Mux ratio */ + + uint8_t col[2] = {0x00, 0x7F}; + oled_cmd(0x15); oled_data(col, 2); /* Column address */ + uint8_t row[2] = {0x00, 0x7F}; + oled_cmd(0x75); oled_data(row, 2); /* Row address */ + + oled_cmd(0xA0); d = 0x74; oled_data(&d, 1); /* BGR, 65k color */ + oled_cmd(0xA1); d = 0x00; oled_data(&d, 1); /* Start line */ + oled_cmd(0xA2); d = 0x00; oled_data(&d, 1); /* Display offset */ + oled_cmd(0xB5); d = 0x00; oled_data(&d, 1); /* GPIO */ + oled_cmd(0xAB); d = 0x01; oled_data(&d, 1); /* Function select */ + oled_cmd(0xB1); d = 0x32; oled_data(&d, 1); /* Precharge */ + + uint8_t vsl[3] = {0xA0, 0xB5, 0x55}; + oled_cmd(0xB4); oled_data(vsl, 3); /* VSL */ + + oled_cmd(0xBE); d = 0x05; oled_data(&d, 1); /* VCOMH */ + oled_cmd(0xC7); d = 0x0F; oled_data(&d, 1); /* Master contrast */ + oled_cmd(0xB6); d = 0x01; oled_data(&d, 1); /* Precharge2 */ + oled_cmd(0xA6); /* Normal display */ + + uint8_t contrast[3] = {0xFF, 0xFF, 0xFF}; + oled_cmd(0xC1); oled_data(contrast, 3); /* Contrast */ +} + +/* Mirrors PiFinder.ssd1333_device._init_sequence (bgr=True → remap 0x74). + * Key differences from the SSD1351: MUX 175, no second unlock, no GPIO / + * Function Select / VSL commands, built-in linear LUT (0xB9). */ +static void ssd1333_init(void) { + uint8_t d; + + oled_reset(); + + oled_cmd(0xFD); d = 0x12; oled_data(&d, 1); /* Unlock */ + oled_cmd(0xAE); /* Display off */ + oled_cmd(0xB3); d = 0xF1; oled_data(&d, 1); /* Clock divider */ + oled_cmd(0xCA); d = 0xAF; oled_data(&d, 1); /* Mux ratio = 175 */ + + uint8_t col[2] = {0x00, 0xAF}; + oled_cmd(0x15); oled_data(col, 2); /* Column address */ + uint8_t row[2] = {0x00, 0xAF}; + oled_cmd(0x75); oled_data(row, 2); /* Row address */ + + oled_cmd(0xA0); d = 0x74; oled_data(&d, 1); /* Remap: 65k color, BGR */ + oled_cmd(0xA1); d = 0x00; oled_data(&d, 1); /* Start line */ + oled_cmd(0xA2); d = 0x00; oled_data(&d, 1); /* Display offset */ + oled_cmd(0xB1); d = 0x32; oled_data(&d, 1); /* Precharge */ + oled_cmd(0xBB); d = 0x17; oled_data(&d, 1); /* Pre-charge voltage */ + oled_cmd(0xBE); d = 0x05; oled_data(&d, 1); /* VCOMH */ + oled_cmd(0xC7); d = 0x0F; oled_data(&d, 1); /* Master contrast */ + oled_cmd(0xB6); d = 0x08; oled_data(&d, 1); /* Precharge2 */ + oled_cmd(0xB9); /* Built-in linear LUT */ + oled_cmd(0xA6); /* Normal display */ + + uint8_t contrast[3] = {0xFF, 0xFF, 0xFF}; + oled_cmd(0xC1); oled_data(contrast, 3); /* Contrast */ +} + +static void oled_flush(void) { + uint8_t max = (uint8_t)(disp_w - 1); + uint8_t col[2] = {0x00, max}; + oled_cmd(0x15); oled_data(col, 2); + uint8_t row[2] = {0x00, max}; + oled_cmd(0x75); oled_data(row, 2); + oled_cmd(0x5C); /* Write RAM */ + + static uint8_t buf[MAX_DIM * MAX_DIM * 2]; + if (panel == PANEL_SSD1333) { + /* The v4 panel is mounted 270° (the app drives it with luma + * rotate=3, i.e. the image is rotated 90° CCW before the RAM + * write): logical (x,y) lands at RAM (y, w-1-x). */ + for (int y = 0; y < disp_h; y++) { + for (int x = 0; x < disp_w; x++) { + uint16_t px = framebuf[y * disp_w + x]; + int ram = (disp_w - 1 - x) * disp_w + y; + buf[ram * 2] = px >> 8; + buf[ram * 2 + 1] = px & 0xFF; + } + } + } else { + for (int i = 0; i < disp_w * disp_h; i++) { + buf[i * 2] = framebuf[i] >> 8; + buf[i * 2 + 1] = framebuf[i] & 0xFF; + } + } + oled_data(buf, (size_t)disp_w * disp_h * 2); +} + +/* Fill the framebuffer with the welcome image, centered (the image is + * 128x128; the v4 panel is 176x176, leaving a black border). */ +static void draw_welcome(void) { + memset(framebuf, 0, sizeof(uint16_t) * disp_w * disp_h); + int x0 = (disp_w - IMG_W) / 2; + int y0 = (disp_h - IMG_H) / 2; + for (int y = 0; y < IMG_H; y++) { + memcpy(&framebuf[(y0 + y) * disp_w + x0], + &welcome_image[y * IMG_W], + IMG_W * sizeof(uint16_t)); + } +} + +static void draw_scanner(int pos, int scanner_width) { + draw_welcome(); + + /* Draw Knight Rider scanner at bottom (last 4 rows) */ + int y_start = disp_h - 4; + int center = pos; + + for (int x = 0; x < disp_w; x++) { + int dist = abs(x - center); + uint16_t color = COL_BLACK; + + if (dist < scanner_width) { + /* Gradient: brighter at center */ + int intensity = 31 - (dist * 31 / scanner_width); + if (intensity < 8) intensity = 8; /* Minimum brightness */ + /* RGB565: RRRRRGGGGGGBBBBB - red is high 5 bits */ + color = ((uint16_t)intensity & 0x1F) << 11; + } + + for (int y = y_start; y < disp_h; y++) { + framebuf[y * disp_w + x] = color; + } + } + + oled_flush(); +} + +/* Read a 0-100 percentage from a file. Returns -1 if missing/unparseable. */ +static int read_progress(const char *path) { + FILE *f = fopen(path, "r"); + if (!f) return -1; + int pct = -1; + if (fscanf(f, "%d", &pct) != 1) pct = -1; + fclose(f); + if (pct < 0) return -1; + if (pct > 100) pct = 100; + return pct; +} + +static void draw_progress(int pct) { + draw_welcome(); + + /* Progress bar across the bottom 4 rows, filling left-to-right. + * Filled portion bright red, remaining track dim red. */ + int y_start = disp_h - 4; + int fill = pct * disp_w / 100; + + for (int x = 0; x < disp_w; x++) { + uint16_t color = (x < fill) ? COL_RED : COL_DKRED; + for (int y = y_start; y < disp_h; y++) { + framebuf[y * disp_w + x] = color; + } + } + + oled_flush(); +} + +/* Classic 5x7 bitmap font, A-Z + space + '!'. Each glyph is 5 column bytes, + * bit 0 = top row. Enough for the watchdog's failure screen; night-vision red + * like everything else on this display. */ +static const uint8_t font5x7[28][5] = { + {0x7E, 0x11, 0x11, 0x11, 0x7E}, /* A */ + {0x7F, 0x49, 0x49, 0x49, 0x36}, /* B */ + {0x3E, 0x41, 0x41, 0x41, 0x22}, /* C */ + {0x7F, 0x41, 0x41, 0x22, 0x1C}, /* D */ + {0x7F, 0x49, 0x49, 0x49, 0x41}, /* E */ + {0x7F, 0x09, 0x09, 0x09, 0x01}, /* F */ + {0x3E, 0x41, 0x49, 0x49, 0x7A}, /* G */ + {0x7F, 0x08, 0x08, 0x08, 0x7F}, /* H */ + {0x00, 0x41, 0x7F, 0x41, 0x00}, /* I */ + {0x20, 0x40, 0x41, 0x3F, 0x01}, /* J */ + {0x7F, 0x08, 0x14, 0x22, 0x41}, /* K */ + {0x7F, 0x40, 0x40, 0x40, 0x40}, /* L */ + {0x7F, 0x02, 0x0C, 0x02, 0x7F}, /* M */ + {0x7F, 0x04, 0x08, 0x10, 0x7F}, /* N */ + {0x3E, 0x41, 0x41, 0x41, 0x3E}, /* O */ + {0x7F, 0x09, 0x09, 0x09, 0x06}, /* P */ + {0x3E, 0x41, 0x51, 0x21, 0x5E}, /* Q */ + {0x7F, 0x09, 0x19, 0x29, 0x46}, /* R */ + {0x46, 0x49, 0x49, 0x49, 0x31}, /* S */ + {0x01, 0x01, 0x7F, 0x01, 0x01}, /* T */ + {0x3F, 0x40, 0x40, 0x40, 0x3F}, /* U */ + {0x1F, 0x20, 0x40, 0x20, 0x1F}, /* V */ + {0x3F, 0x40, 0x38, 0x40, 0x3F}, /* W */ + {0x63, 0x14, 0x08, 0x14, 0x63}, /* X */ + {0x07, 0x08, 0x70, 0x08, 0x07}, /* Y */ + {0x61, 0x51, 0x49, 0x45, 0x43}, /* Z */ + {0x00, 0x00, 0x00, 0x00, 0x00}, /* space */ + {0x00, 0x00, 0x5F, 0x00, 0x00}, /* ! */ +}; + +static const uint8_t *glyph_for(char c) { + if (c >= 'a' && c <= 'z') c -= 32; + if (c >= 'A' && c <= 'Z') return font5x7[c - 'A']; + if (c == '!') return font5x7[27]; + return font5x7[26]; /* everything else renders as space */ +} + +static void draw_text_centered(int y, const char *s, int scale, uint16_t color) { + int len = (int)strlen(s); + int char_w = 6 * scale; /* 5 columns + 1 spacing */ + int x0 = (disp_w - len * char_w) / 2; + if (x0 < 0) x0 = 0; + + for (int i = 0; i < len; i++) { + const uint8_t *g = glyph_for(s[i]); + for (int col = 0; col < 5; col++) { + for (int row = 0; row < 7; row++) { + if (!(g[col] >> row & 1)) + continue; + for (int sy = 0; sy < scale; sy++) { + for (int sx = 0; sx < scale; sx++) { + int px = x0 + i * char_w + col * scale + sx; + int py = y + row * scale + sy; + if (px >= 0 && px < disp_w && py >= 0 && py < disp_h) + framebuf[py * disp_w + px] = color; + } + } + } + } + } +} + +/* Generic message screen: centered lines on black, night-vision red. Lines + * short enough for the big font are drawn at 2x, longer ones at 1x. Used by + * pifinder-watchdog for the update-failure screen; usable by any boot-time + * service that needs to talk to the operator without the app running. */ +static void draw_message(char *const lines[], int nlines) { + memset(framebuf, 0, sizeof(uint16_t) * disp_w * disp_h); + + /* Pick a scale per line and total the height (7*scale + 5px gap each). */ + int scales[16]; + int total_h = 0; + if (nlines > 16) nlines = 16; + for (int i = 0; i < nlines; i++) { + scales[i] = ((int)strlen(lines[i]) * 12 <= disp_w) ? 2 : 1; + total_h += 7 * scales[i] + 5; + } + + int y = (disp_h - total_h) / 2; + if (y < 0) y = 0; + for (int i = 0; i < nlines; i++) { + draw_text_centered(y, lines[i], scales[i], COL_RED); + y += 7 * scales[i] + 5; + } + oled_flush(); +} + +static int hw_init(void) { + spi_fd = open(SPI_DEVICE, O_RDWR); + if (spi_fd < 0) { + perror("open spi"); + return -1; + } + + uint8_t mode = SPI_MODE_0; + uint8_t bits = 8; + uint32_t speed = SPI_SPEED; + ioctl(spi_fd, SPI_IOC_WR_MODE, &mode); + ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &bits); + ioctl(spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed); + + gpio_fd = open("/dev/gpiochip0", O_RDWR); + if (gpio_fd < 0) { + perror("open gpiochip0"); + return -1; + } + + if (gpio_request_line(gpio_fd, GPIO_DC, &dc_req) < 0) + return -1; + if (gpio_request_line(gpio_fd, GPIO_RST, &rst_req) < 0) + return -1; + + if (panel == PANEL_SSD1333) + ssd1333_init(); + else + ssd1351_init(); + return 0; +} + +static void hw_cleanup(void) { + if (dc_req.fd > 0) close(dc_req.fd); + if (rst_req.fd > 0) close(rst_req.fd); + if (gpio_fd >= 0) close(gpio_fd); + if (spi_fd >= 0) close(spi_fd); +} + +static void show_static_image(void) { + draw_welcome(); + oled_flush(); +} + +int main(int argc, char *argv[]) { + int static_mode = 0; + int progress_mode = 0; + const char *progress_path = PROGRESS_FILE_DEFAULT; + char **message_lines = NULL; + int message_nlines = 0; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--static") == 0) { + static_mode = 1; + } else if (strcmp(argv[i], "--progress") == 0) { + progress_mode = 1; + /* Optional next arg overrides the progress file path */ + if (i + 1 < argc && argv[i + 1][0] != '-') { + progress_path = argv[++i]; + } + } else if (strcmp(argv[i], "--message") == 0) { + /* All remaining args are message lines */ + message_lines = &argv[i + 1]; + message_nlines = argc - i - 1; + break; + } + } + + signal(SIGTERM, signal_handler); + signal(SIGINT, signal_handler); + + detect_panel(); + + if (hw_init() < 0) { + fprintf(stderr, "Hardware init failed\n"); + hw_cleanup(); + return 1; + } + + /* Turn on display */ + oled_cmd(0xAF); + + if (static_mode) { + /* Static mode: show image once and exit */ + show_static_image(); + hw_cleanup(); + return 0; + } + + if (message_nlines > 0) { + /* Message mode: render the lines once and exit, leaving them shown */ + draw_message(message_lines, message_nlines); + hw_cleanup(); + return 0; + } + + if (progress_mode) { + /* Progress mode: render a real bar from the progress file until 100% + * or until signalled. Only flush when the value changes. */ + int last = -1; + while (running) { + int pct = read_progress(progress_path); + if (pct < 0) pct = 0; + if (pct != last) { + draw_progress(pct); + last = pct; + } + if (pct >= 100) break; + msleep(100); + } + hw_cleanup(); + return 0; + } + + /* Animation mode: Knight Rider scanner */ + int pos = 0; + int dir = 1; + int scanner_width = 20; + + while (running) { + draw_scanner(pos, scanner_width); + + pos += dir * 4; /* Speed */ + if (pos >= disp_w - scanner_width/2) { + pos = disp_w - scanner_width/2; + dir = -1; + } else if (pos <= scanner_width/2) { + pos = scanner_width/2; + dir = 1; + } + + msleep(30); /* ~33 FPS */ + } + + hw_cleanup(); + return 0; +} diff --git a/nixos/pkgs/boot-splash.nix b/nixos/pkgs/boot-splash.nix new file mode 100644 index 000000000..9dfad935e --- /dev/null +++ b/nixos/pkgs/boot-splash.nix @@ -0,0 +1,24 @@ +{ pkgs }: + +pkgs.stdenv.mkDerivation { + pname = "boot-splash"; + version = "0.1.0"; + + src = ./.; + + buildInputs = [ pkgs.linuxHeaders ]; + + buildPhase = '' + $CC -O2 -Wall -o boot-splash boot-splash.c + ''; + + installPhase = '' + mkdir -p $out/bin + cp boot-splash $out/bin/ + ''; + + meta = { + description = "Early boot splash for PiFinder OLED display"; + platforms = [ "aarch64-linux" ]; + }; +} diff --git a/nixos/pkgs/cedar-detect-Cargo.lock b/nixos/pkgs/cedar-detect-Cargo.lock new file mode 100644 index 000000000..e5bb4b5eb --- /dev/null +++ b/nixos/pkgs/cedar-detect-Cargo.lock @@ -0,0 +1,2633 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cedar_detect" +version = "0.8.0" +dependencies = [ + "approx", + "clap", + "env_logger", + "image", + "imageproc", + "libc", + "log", + "prctl", + "prost", + "prost-build", + "prost-types", + "tokio", + "tonic", + "tonic-build", + "tonic-web", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imageproc" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.5", + "rand_distr", + "rayon", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.13.0", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prctl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059a34f111a9dee2ce1ac2826a68b24601c4298cfeb1a587c3cb493d5ab46f52" +dependencies = [ + "libc", + "nix", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4ef6dd70a610078cb4e338a0f79d06bc759ff1b22d2120c2ff02ae264ba9c2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn", +] + +[[package]] +name = "tonic-web" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3b0e1cedbf19fdfb78ef3d672cb9928e0a91a9cb4629cc0c916e8cff8aaaa1" +dependencies = [ + "base64", + "bytes", + "http", + "http-body", + "hyper", + "pin-project", + "tokio-stream", + "tonic", + "tower-http", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core 0.5.1", +] diff --git a/nixos/pkgs/cedar-detect.nix b/nixos/pkgs/cedar-detect.nix new file mode 100644 index 000000000..da84d849d --- /dev/null +++ b/nixos/pkgs/cedar-detect.nix @@ -0,0 +1,27 @@ +{ pkgs }: +pkgs.rustPlatform.buildRustPackage rec { + pname = "cedar-detect-server"; + version = "0.5.0-unstable-2026-02-11"; + + src = pkgs.fetchFromGitHub { + owner = "smroid"; + repo = "cedar-detect"; + rev = "da6be9d318976a1a0853ecdf6dd6cefe41615352"; + hash = "sha256-SqWJ35cBOSCu8w5nK2lcdlMWK/bHINatzjr/p+MH3/o="; + }; + + cargoLock.lockFile = ./cedar-detect-Cargo.lock; + + postPatch = '' + ln -s ${./cedar-detect-Cargo.lock} Cargo.lock + ''; + + nativeBuildInputs = [ pkgs.protobuf ]; + + cargoBuildFlags = [ "--bin" "cedar-detect-server" ]; + + meta = { + description = "Cedar Detect star detection gRPC server"; + homepage = "https://github.com/smroid/cedar-detect"; + }; +} diff --git a/nixos/pkgs/picamera2-optional-previews.patch b/nixos/pkgs/picamera2-optional-previews.patch new file mode 100644 index 000000000..2d31b7d8f --- /dev/null +++ b/nixos/pkgs/picamera2-optional-previews.patch @@ -0,0 +1,22 @@ +Make picamera2's DRM/Qt preview imports optional. + +previews/__init__.py imports DrmPreview (needs pykms) and the Qt previews +(need PyQt) unconditionally, so on a headless device without those native +deps `import picamera2` fails outright and PiFinder's camera process crashes. +PiFinder only ever uses NullPreview, so guard the optional backends. + +--- a/picamera2/previews/__init__.py ++++ b/picamera2/previews/__init__.py +@@ -1,3 +1,10 @@ +-from .drm_preview import DrmPreview + from .null_preview import NullPreview +-from .qt_previews import QtGlPreview, QtPreview ++ ++try: ++ from .drm_preview import DrmPreview ++except ImportError: ++ DrmPreview = None ++try: ++ from .qt_previews import QtGlPreview, QtPreview ++except ImportError: ++ QtGlPreview = QtPreview = None diff --git a/nixos/pkgs/pifinder-src.nix b/nixos/pkgs/pifinder-src.nix new file mode 100644 index 000000000..4ca6346a9 --- /dev/null +++ b/nixos/pkgs/pifinder-src.nix @@ -0,0 +1,65 @@ +{ pkgs, python ? pkgs.python313 }: +let + # Stable astro data — catalogs, star patterns, ephemeris (~193MB, rarely changes) + # hip_main.dat is now committed to astro_data/ upstream, so cp -r picks it up. + astro-data = pkgs.stdenv.mkDerivation { + pname = "pifinder-astro-data"; + version = "1.0"; + src = ../../astro_data; + phases = [ "installPhase" ]; + installPhase = '' + mkdir -p $out + cp -r $src/* $out/ + ''; + }; + + # UI fonts — ~31MB, effectively never change. Own derivation + symlink so they + # are distributed once and not rewritten on every code change. + fonts = pkgs.stdenv.mkDerivation { + pname = "pifinder-fonts"; + version = "1.0"; + src = ../../fonts; + phases = [ "installPhase" ]; + installPhase = '' + mkdir -p $out + cp -r $src/* $out/ + ''; + }; + +in +pkgs.stdenv.mkDerivation { + pname = "pifinder-src"; + version = "0.0.1"; + src = ../..; + + nativeBuildInputs = [ python ]; + phases = [ "installPhase" ]; + + installPhase = '' + mkdir -p $out + + # Copy everything except build artifacts and non-runtime directories + cp -r --no-preserve=mode $src/* $out/ || true + + # Remove directories not needed at runtime + rm -rf $out/.git $out/.github $out/nixos $out/result* $out/.venv + rm -rf $out/case $out/docs $out/gerbers $out/kicad + rm -rf $out/pi_config_files $out/scripts + rm -rf $out/bin + + # Strip doc photos from images/ but keep welcome.png (used at runtime) + find $out/images -type f ! -name 'welcome.png' -delete + + # Bulky, stable inputs live in their own derivations and are symlinked in, + # so a code change rewrites only the (small) code path — not astro-data + # (~193MB) or fonts (~31MB). See ADR 0001. + rm -rf $out/astro_data + ln -s ${astro-data} $out/astro_data + rm -rf $out/fonts + ln -s ${fonts} $out/fonts + + # Pre-compile .pyc bytecode so Python skips compilation at runtime + chmod -R u+w $out/python + python3 -m compileall -q $out/python + ''; +} diff --git a/nixos/pkgs/rpi-gpio-pi-detect.patch b/nixos/pkgs/rpi-gpio-pi-detect.patch new file mode 100644 index 000000000..eebe60114 --- /dev/null +++ b/nixos/pkgs/rpi-gpio-pi-detect.patch @@ -0,0 +1,29 @@ +Make RPi.GPIO board detection fall back to /proc/device-tree/model. + +get_rpi_info() reads the board revision from +/proc/device-tree/system/linux,revision or the /proc/cpuinfo "Revision" +line. On a NixOS Pi 4 (mainline device tree, arm64) neither is present, so +the C module init aborts with "This module can only be run on a Raspberry +Pi!" and any importer (adafruit-blinka -> board -> RPi.GPIO) crashes. Fall +back to the model string, which is always present, and synthesise a Pi 4 +Model B revision code so detection succeeds. + +--- a/source/cpuinfo.c ++++ b/source/cpuinfo.c +@@ -66,6 +66,16 @@ + else + return -1; + fclose(fp); ++ if (!found) { ++ FILE *mp; ++ if ((mp = fopen("/proc/device-tree/model", "r"))) { ++ if (fgets(buffer, sizeof(buffer), mp) && strstr(buffer, "Raspberry Pi")) { ++ found = 1; ++ strcpy(revision, "c03111"); ++ } ++ fclose(mp); ++ } ++ } + + if (!found) + return -1; diff --git a/nixos/pkgs/uv-python-darwin.nix b/nixos/pkgs/uv-python-darwin.nix new file mode 100644 index 000000000..3bea6fadb --- /dev/null +++ b/nixos/pkgs/uv-python-darwin.nix @@ -0,0 +1,47 @@ +{ pkgs, lib ? pkgs.lib, pyproject-nix, uv2nix, pyproject-build-systems }: +let + python = pkgs.python313; + + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ../../python; }; + + overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; + + # Only the overrides that are needed on macOS — Linux-only packages + # (spidev, rpi-gpio, picamera2, dbus-python, pygobject, python-libinput, + # python-prctl, python-pam, evdev, adafruit-blinka, pidng, videodev2) are + # gated behind sys_platform == 'linux' in pyproject.toml so they are never + # resolved for this platform and need no override here. + pyprojectOverrides = final: prev: { + # cedar-solve (the tetra3 plate-solver) is installed from git source and + # uses setup.py but doesn't declare setuptools as a build dependency. + cedar-solve = prev.cedar-solve.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + timezonefinder = prev.timezonefinder.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + sh = prev.sh.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + }; + + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope + (lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + pyprojectOverrides + ]); +in { + inherit pythonSet; + pifinderEnv = pythonSet.mkVirtualEnv "pifinder-env" workspace.deps.default; + devEnv = pythonSet.mkVirtualEnv "pifinder-dev-env" workspace.deps.all; +} diff --git a/nixos/pkgs/uv-python.nix b/nixos/pkgs/uv-python.nix new file mode 100644 index 000000000..86f54c944 --- /dev/null +++ b/nixos/pkgs/uv-python.nix @@ -0,0 +1,223 @@ +{ pkgs, lib ? pkgs.lib, pyproject-nix, uv2nix, pyproject-build-systems }: +let + python = pkgs.python313; + + # The uv workspace lives at the repo root (python/pyproject.toml + uv.lock). + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ../../python; }; + + # Prefer prebuilt wheels; fall back to sdist where no wheel exists. + overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; + + # Native/C-extension packages that can't build from PyPI metadata alone. + # These mirror the patches the old hand-written python-packages.nix carried. + pyprojectOverrides = final: prev: { + python-libinput = prev.python-libinput.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config ] + ++ final.resolveBuildSystem { setuptools = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.libinput pkgs.systemd ]; + postPatch = (old.postPatch or "") + '' + substituteInPlace setup.py \ + --replace-fail 'from imp import load_source' 'import importlib.util, types +def load_source(name, path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod' + substituteInPlace libinput/__init__.py \ + --replace-fail "CDLL('libudev.so.1')" "CDLL('${lib.getLib pkgs.systemd}/lib/libudev.so.1')" \ + --replace-fail "CDLL('libinput.so.10')" "CDLL('${lib.getLib pkgs.libinput}/lib/libinput.so.10')" + ''; + }); + + python-prctl = prev.python-prctl.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.libcap ]; + }); + + # Installed from a prebuilt wheel (no source at patchPhase), so patch the + # installed module in $out: ctypes find_library("pam") can't locate libpam on + # NixOS, so pin it to the store path. + python-pam = prev.python-pam.overrideAttrs (old: { + postInstall = (old.postInstall or "") + '' + substituteInPlace "$out/${python.sitePackages}/pam/__internals.py" \ + --replace-fail 'find_library("pam")' '"${pkgs.pam}/lib/libpam.so"' \ + --replace-fail 'find_library("pam_misc")' '"${pkgs.pam}/lib/libpam_misc.so"' + ''; + }); + + # dbus-python and PyGObject build from sdist with meson-python; that build + # backend (resolveBuildSystem) plus pkg-config and the C libraries must be on + # the build inputs, otherwise the sdist build fails with "No module named + # 'mesonpy'". + dbus-python = prev.dbus-python.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config pkgs.ninja ] + ++ final.resolveBuildSystem { meson-python = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.dbus pkgs.glib ]; + }); + + pygobject = prev.pygobject.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config pkgs.ninja ] + ++ final.resolveBuildSystem { meson-python = []; }; + buildInputs = + (old.buildInputs or []) + ++ [ pkgs.glib pkgs.gobject-introspection pkgs.cairo pkgs.python313Packages.pycairo ]; + }); + + # evdev builds a C extension from sdist: it needs the setuptools backend, the + # kernel input headers on the compiler path (for build_ext), and its setup.py + # only searches /usr/include for linux/input.h — repoint that at linuxHeaders. + evdev = prev.evdev.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.linuxHeaders ]; + postPatch = (old.postPatch or "") + '' + substituteInPlace setup.py \ + --replace-fail 'include_paths.add("/usr/include")' 'include_paths.add("${pkgs.linuxHeaders}/include")' + ''; + }); + + # pycairo builds from sdist with meson-python (pulled in by pygobject's + # cairo support); same meson stack as dbus-python/pygobject. + pycairo = prev.pycairo.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config pkgs.ninja ] + ++ final.resolveBuildSystem { meson-python = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.cairo ]; + }); + + # Legacy setup.py packages (no [build-system]) need the setuptools backend + # provided explicitly, else the sdist build fails with "No module named + # 'setuptools'". + pidng = prev.pidng.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + adafruit-extended-bus = prev.adafruit-extended-bus.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + # cedar-solve declares the setuptools.build_meta backend but ships no + # setuptools in its build env, so the sdist build fails with "No module + # named 'setuptools'" — provide the backend like the other legacy packages. + cedar-solve = prev.cedar-solve.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + rpi-gpio = prev.rpi-gpio.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + # RPi.GPIO's C module init aborts with "This module can only be run on a + # Raspberry Pi!" when the board revision is in neither the device tree + # nor /proc/cpuinfo — the case on a mainline-DT arm64 NixOS Pi 4. Without + # this every importer (adafruit-blinka -> board -> RPi.GPIO) crashes and + # the whole app crash-loops. Patch in a /proc/device-tree/model fallback. + postPatch = + (old.postPatch or "") + + '' + patch -p1 < ${./rpi-gpio-pi-detect.patch} + ''; + }); + + # picamera2 installs from a py3-none-any wheel (no source patchPhase to + # hook), so patch the installed module in $out. It imports its DRM (pykms) + # and Qt preview backends unconditionally; the headless device has neither, + # so `import picamera2` dies on a missing 'pykms' and the camera process + # crash-loops. PiFinder only uses NullPreview, so make those optional. + picamera2 = prev.picamera2.overrideAttrs (old: { + postInstall = + (old.postInstall or "") + + '' + f=$(find "$out" -path '*/picamera2/previews/__init__.py' | head -1) + if [ -z "$f" ]; then + echo "picamera2: previews/__init__.py not found under $out" >&2 + exit 1 + fi + echo "picamera2: patching $f" + patch "$f" < ${./picamera2-optional-previews.patch} + ''; + }); + + # No aarch64 wheel, so it builds from sdist on the Pi (fine on x86 via wheel). + timezonefinder = prev.timezonefinder.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + sh = prev.sh.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + spidev = prev.spidev.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + # The manylinux pygame wheel bundles libSDL2, which dlopen()s libX11 / + # libwayland / libxkbcommon / libGL / libdecor at runtime instead of listing + # them as NEEDED, so autoPatchelf never discovers them. On a Nix host those + # libraries aren't on the loader path, so SDL falls back to the "offscreen" + # video driver and the emulator window never opens. Append them to the + # runpath so SDL can load its x11/wayland backends. Dev-only (pulled in via + # luma-emulator); pygame is not in the Pi runtime env. + pygame = prev.pygame.overrideAttrs (old: { + appendRunpaths = map (p: "${lib.getLib p}/lib") [ + pkgs.xorg.libX11 + pkgs.xorg.libXext + pkgs.xorg.libXcursor + pkgs.xorg.libXrandr + pkgs.xorg.libXi + pkgs.xorg.libXfixes + pkgs.libxrender + pkgs.libxscrnsaver + pkgs.libxinerama + pkgs.libxkbcommon + pkgs.wayland + pkgs.libGL + pkgs.libdecor + ]; + }); + + # adafruit-blinka's wheel vendors prebuilt libgpiod_pulsein helpers for + # non-Pi SoCs (amlogic, etc.) that link libgpiod.so.2. PiFinder never uses + # them (BNO055 is I2C), so don't fail auto-patchelf on that missing lib. + adafruit-blinka = prev.adafruit-blinka.overrideAttrs (old: { + autoPatchelfIgnoreMissingDeps = + (old.autoPatchelfIgnoreMissingDeps or []) ++ [ "libgpiod.so.2" ]; + }); + }; + + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope + (lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + pyprojectOverrides + ]); +in { + inherit pythonSet; + # Runtime env: [project.dependencies] only. + pifinderEnv = pythonSet.mkVirtualEnv "pifinder-env" workspace.deps.default; + # Dev env: adds the [dependency-groups].dev set (pytest, mypy, selenium…). + devEnv = pythonSet.mkVirtualEnv "pifinder-dev-env" workspace.deps.all; +} diff --git a/nixos/pkgs/welcome_image.h b/nixos/pkgs/welcome_image.h new file mode 100644 index 000000000..ef8cfc2ff --- /dev/null +++ b/nixos/pkgs/welcome_image.h @@ -0,0 +1,1027 @@ +// Auto-generated from welcome.png - 128x128 BGR565 +static const uint16_t welcome_image[16384] = { + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, + 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, + 0x6800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, + 0x6800, 0x6800, 0x7000, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6800, 0x6800, 0x7000, + 0x7000, 0x5800, 0x5800, 0x6000, 0x6800, 0x7800, 0x6000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6800, + 0x6000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x7800, 0x8000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x8000, 0x9000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, + 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x5000, 0x5800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6800, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x6800, 0x6800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x6800, 0x6000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x5000, 0x6000, 0x6800, 0x7800, 0x8000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x6000, 0x8000, 0x7800, 0x7000, 0x6800, 0x5800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3000, 0x4800, 0x6000, 0x7800, 0x8800, 0x9000, 0x8000, 0x7000, 0x6800, 0x5800, 0x5000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x5800, 0x6000, 0x7000, 0x7800, 0x8800, 0xA000, 0xA000, 0x9000, 0x8000, 0x6800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x5000, 0x7000, + 0x8800, 0x8000, 0x6800, 0x5800, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5800, 0x7000, 0x9000, 0xA800, 0xB000, 0x9000, 0x7000, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x7000, 0x6800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x6800, 0x8800, 0x7800, 0x6000, + 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x5000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x6000, 0x6800, 0x8000, 0x9800, + 0xA800, 0x9000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, 0x6800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x7800, 0x8000, 0x6000, 0x3800, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5800, 0x6000, 0x6000, 0x5800, 0x5000, + 0x6000, 0x8000, 0xA000, 0x9800, 0x7000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5800, 0x8000, 0x7000, 0x5000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x7000, 0x9800, 0xA000, 0x7800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x8000, 0x7000, 0x4000, 0x3000, 0x3000, 0x4800, 0x3800, 0x3800, 0x3800, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x6000, 0x9000, 0xA000, 0x7000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x4800, 0x7800, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x6800, 0x9800, 0x9800, 0x8000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x7000, 0xA800, 0x8000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0x7800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3000, 0x3000, 0x6800, 0x8000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x4800, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0xA800, 0x8800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x6800, 0x9800, 0x8000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x4800, 0x8000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x8800, 0xA000, 0x6800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x8000, 0x4800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA000, 0x8000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, + 0x8800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x4000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x9000, 0x9800, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6800, + 0x7800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x7800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, + 0xA000, 0x6800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x7000, 0x5000, 0x5800, 0x6000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x8800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x7000, 0xA000, 0x7000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, + 0x5000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x5000, 0x6000, + 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x8800, + 0xA800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, + 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x4800, + 0x5800, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x7000, 0x5800, 0x7000, 0x6800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4800, 0x5000, 0x4800, 0x4800, 0x6000, 0x9800, 0x5000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x6800, 0x6000, 0x6000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x6000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0xA800, 0x6800, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, 0xA000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0xA000, 0x9000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xB000, 0x7800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, + 0x7000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x4000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x7800, 0xB000, 0x6000, 0x5800, 0x5800, 0x5000, 0x6800, + 0x9800, 0x6800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3800, 0x6000, 0x3800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4800, 0x4000, 0x5000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x9000, 0x8800, 0x5800, 0x5800, 0x5000, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5800, 0x6800, 0x5800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5000, 0x6800, 0x6000, 0x4800, 0x3800, 0x4000, 0x3800, 0x5800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0xA000, 0x6800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x7000, 0x8000, 0x8000, 0x6800, 0x5800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x5800, 0x6800, 0x8000, 0x8800, 0x7000, 0x5800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4800, 0x6000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x6800, 0x9000, 0x5000, 0x5000, 0x5000, + 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x5800, 0x8000, 0x7000, 0x5800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x5800, 0x7800, 0x8800, 0x6000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x9000, 0x7000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x6800, 0x6800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x7800, 0x6000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x5800, 0x8000, 0x6800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3800, 0x4000, 0x5000, 0x7000, 0x8800, 0x6800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x5000, 0x8000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x9800, 0x4800, 0x4800, + 0x4000, 0x4000, 0x8000, 0x7800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x7000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4800, + 0x7800, 0x6800, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x4000, 0x7000, 0x8800, 0x5000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x8800, 0x7000, 0x4800, + 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5000, + 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x9000, 0x8800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6000, 0x7800, + 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x8000, 0x6800, + 0x3800, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x9800, 0x4800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x6800, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x7000, + 0x7800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x8800, 0x6800, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7800, 0x5800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x6000, 0x8000, 0x4000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x6000, 0x9000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x7000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7800, 0x5000, 0x2800, 0x2800, 0x2800, + 0x3000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x5800, 0x8800, 0x4000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x9000, + 0x5000, 0x3800, 0x3800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7800, 0x5000, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5800, 0x8000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x6800, + 0x7000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x5800, 0x2800, 0x2800, 0x3800, 0x4000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x6000, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x4800, + 0x8800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x6800, 0x6000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x6800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x8000, 0x5000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x8000, 0x5000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x6000, 0x7000, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x8800, 0x7000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4800, 0x8800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0xA000, 0x7800, 0x5000, 0x4800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x6800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x8000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x8800, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0xD000, 0x9000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x7000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x7000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x5800, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x8000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, + 0x4000, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x8800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, + 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x4000, 0xA000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x5800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3800, 0x6000, + 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x7800, 0x5000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x9000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x7000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2000, 0x2800, 0x6800, 0x5800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x4000, 0x2800, 0x3000, 0x6000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x5000, 0x6800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x5800, 0x7000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x7800, 0x5000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x5000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x7000, 0x6800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2000, 0x8000, 0x2800, 0x2000, 0x4000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x5800, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x5800, 0x6000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x8800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x7000, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x5800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x3800, 0x7000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x8000, 0x4800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x6000, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x4800, 0x5800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x6800, 0x5800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5800, 0x7000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x5800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, + 0x9000, 0x6000, 0x2000, 0x2800, 0x2800, 0x2800, 0x5800, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x5800, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5000, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x2800, 0x2800, 0x2800, 0x4800, 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, + 0x7000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x4000, 0x5800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x4000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x4800, + 0x5800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x6000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x6000, 0x6000, 0x6000, + 0x5000, 0x6000, 0x4000, 0x5800, 0x6000, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x8800, 0x9000, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA000, 0xA800, 0xA800, 0xA800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x6000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x8000, 0xA000, 0x8000, + 0x6000, 0xB000, 0x6800, 0x5800, 0x9800, 0x8000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xE000, 0xD000, + 0xC000, 0xB800, 0xB800, 0xC000, 0xC000, 0xB800, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7800, 0x9800, 0x9800, 0xA000, 0xA000, 0x9800, 0x9800, 0x9800, 0x9000, 0x9800, 0x9800, + 0x9800, 0x9000, 0x7800, 0x2800, 0x2800, 0x8800, 0x9000, 0x9000, 0x9000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0xA000, 0xB000, 0xA800, 0xA800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x9800, 0x4000, + 0x4000, 0xA000, 0x9000, 0x5000, 0xB000, 0x8000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xF000, 0xE000, + 0xC800, 0xC000, 0xC000, 0xC000, 0xC800, 0xC800, 0xC000, 0xB800, 0x7800, 0x2800, 0x2800, 0x3800, 0x4800, 0xF800, 0xF800, 0xF800, + 0xF800, 0xF800, 0x2800, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x9800, 0xA000, 0x9800, 0x9800, 0x9800, 0x9800, 0x9800, + 0x9800, 0x9800, 0x7800, 0x2800, 0x2800, 0x9000, 0x9000, 0x9000, 0x9000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0xA800, 0xB000, 0xB000, 0xB800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4800, 0x9800, 0x4000, + 0x4000, 0xA800, 0x9800, 0x8800, 0x8800, 0x8000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xF800, 0xF000, + 0xE000, 0xD000, 0xC800, 0xD000, 0xC800, 0xC800, 0xC800, 0xC000, 0xB000, 0x8800, 0x2000, 0x8000, 0xA800, 0x4800, 0x2000, 0xF800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x9800, 0xA000, 0xA000, 0xA000, 0xA000, 0xA000, 0xA000, + 0xA000, 0xA000, 0x8800, 0x2800, 0x2800, 0x5000, 0x9000, 0x9000, 0x7800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA800, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x9800, 0x4000, + 0x4000, 0xB000, 0x7000, 0xA800, 0x6800, 0x8000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xB000, 0xF800, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0xA000, 0xC800, 0xC000, 0xB800, 0xB000, 0x6800, 0x4000, 0x5000, 0x3000, 0x2000, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x7000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x5000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA800, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x5800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x6000, 0x4000, + 0x4000, 0x6000, 0x4000, 0x5800, 0x4800, 0x5000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x4800, 0x3000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0xA800, 0xF000, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xA800, 0xC000, 0xB800, 0xB000, 0xA000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x7800, 0x2800, 0x2000, 0x3000, 0x4000, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4800, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x5800, 0x3800, 0x3800, 0xB000, 0xB800, 0xC000, 0xC000, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x5800, 0x4800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x5800, 0x4800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0xA000, 0xE800, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x8800, 0xC000, 0xB800, 0xB800, 0xB800, 0x3000, 0x2000, 0x5800, 0xB000, 0xA000, + 0x9800, 0x8800, 0x2800, 0x2800, 0x2800, 0x7000, 0x9000, 0x9800, 0x9800, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x8800, 0xA000, 0xA000, 0xA000, 0x7000, 0x6000, 0x2800, 0x6000, 0x8800, 0x8800, 0x9000, + 0x5800, 0x4000, 0x6000, 0x9000, 0x9000, 0x9800, 0x8800, 0x6000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x6000, + 0xA000, 0xC800, 0xC800, 0xD000, 0x8800, 0x3800, 0xB000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x7000, 0x9000, 0xB800, 0xB800, 0xB800, 0xA000, 0x8000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0xB000, 0xB800, 0xA800, + 0xA000, 0x4800, 0x6800, 0x9000, 0x9800, 0x8000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2800, 0x4800, 0x5800, 0x2000, 0x4000, 0x3800, 0x2000, 0x2800, 0x2800, 0x9800, 0xE000, 0xE800, 0xF000, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0xB800, 0xB800, 0xB000, 0xB000, 0x4000, 0x2000, 0x6000, 0xB800, 0xB000, + 0xA000, 0x8800, 0x2000, 0x2800, 0x2800, 0x7800, 0x9800, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA000, 0x7800, 0x5000, 0x2800, 0x6000, 0x9000, 0x9000, 0x9000, + 0x7800, 0x9000, 0x9000, 0x9000, 0x9000, 0x9000, 0x9800, 0x9800, 0x6800, 0x3000, 0x3000, 0x4000, 0x3000, 0x3000, 0x7000, 0xC000, + 0xC000, 0xC800, 0xC800, 0xC800, 0xD000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0x7000, 0x4000, 0x4000, 0x4000, 0x4800, 0xA800, + 0xC000, 0xC000, 0xC000, 0xC000, 0xB800, 0xB800, 0xB800, 0xA800, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, 0xB000, 0xB800, 0xA800, + 0xA000, 0x7000, 0xA000, 0xA000, 0xA000, 0x8800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, + 0x2000, 0x2800, 0x2000, 0x3800, 0x7000, 0x2000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x9800, 0xD000, 0xD800, 0xE000, 0xB000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x5000, 0xB800, 0xB800, 0xB000, 0xB000, 0x3000, 0x2000, 0x6000, 0xC800, 0xC000, + 0xB000, 0x9000, 0x2800, 0x2800, 0x4000, 0x8800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA800, 0x8800, 0x4000, 0x2800, 0x6800, 0x9000, 0x9800, 0x9800, + 0xA000, 0xA000, 0x9800, 0x9000, 0x9000, 0x9000, 0x9800, 0xA000, 0xA000, 0x4000, 0x3000, 0x4000, 0x4000, 0x5800, 0xB800, 0xB800, + 0xC000, 0xC000, 0xC800, 0xC800, 0xC800, 0xD000, 0xC800, 0xC000, 0xC000, 0xC000, 0x6800, 0x4000, 0x4000, 0x4000, 0xC800, 0xD000, + 0xC800, 0xC800, 0xC000, 0xC000, 0xB800, 0xB800, 0xB800, 0xB800, 0xA800, 0x4800, 0x4000, 0x4000, 0x4800, 0xA800, 0xB800, 0xA800, + 0xA800, 0xA000, 0xA000, 0xA000, 0xA800, 0x9800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x7800, 0x3000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x9800, 0xD000, 0xD000, 0xD800, 0xA800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x8800, 0xA800, 0xB000, 0xB800, 0xA800, 0x2800, 0x2000, 0x6000, 0xD000, 0xC800, + 0xB800, 0xA000, 0x2800, 0x5800, 0x7800, 0x8000, 0xA000, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA800, 0x9000, 0x3000, 0x2800, 0x6800, 0xA000, 0xA000, 0xA000, + 0xA000, 0x7800, 0x2800, 0x2800, 0x5000, 0x9000, 0x9800, 0x9800, 0xA000, 0x7000, 0x4000, 0x6800, 0x6000, 0x9800, 0xB000, 0xB800, + 0xB800, 0xA800, 0x8000, 0x5800, 0x5000, 0xA800, 0xC800, 0xC000, 0xC000, 0xC000, 0x6800, 0x4000, 0x4000, 0xA000, 0xE800, 0xD800, + 0xD000, 0xC000, 0x7800, 0x3800, 0x4800, 0xA000, 0xC000, 0xC000, 0xC000, 0x8000, 0x4000, 0x4000, 0x4000, 0xA800, 0xB800, 0xA800, + 0xA800, 0xA000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x4800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x9000, 0xC000, 0xC800, 0xD000, 0xA800, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x8000, 0xA800, 0xA000, 0xA800, 0xB000, 0x8800, 0x5000, 0x2800, 0x6000, 0xC800, 0xC800, + 0xC000, 0xA800, 0x4800, 0x6000, 0x3000, 0x7800, 0xA800, 0xB000, 0xB800, 0xB800, 0xB800, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, + 0xC000, 0x9800, 0x2800, 0x2800, 0x2800, 0x9800, 0xB000, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x7000, 0xA800, 0xA800, 0xA800, + 0x8800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0xA000, 0xA000, 0xA000, 0x8000, 0x5000, 0x9000, 0x5800, 0xA800, 0xA800, 0xB000, + 0xB800, 0x4800, 0x7800, 0x4800, 0x3800, 0x4000, 0xB800, 0xC000, 0xC000, 0xC800, 0x6800, 0x4000, 0x4000, 0xE800, 0xF000, 0xE000, + 0xD000, 0x8000, 0x3800, 0x3800, 0x3800, 0x4800, 0xB800, 0xC000, 0xC000, 0xB800, 0x4000, 0x4000, 0x4000, 0xA800, 0xB000, 0xA800, + 0xA800, 0x6800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x6800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xD000, + 0xF000, 0xF800, 0xF800, 0xE800, 0xD000, 0xB800, 0xA800, 0xA000, 0xA000, 0x9000, 0x2000, 0x2800, 0x2800, 0x6000, 0xC000, 0xC000, + 0xC000, 0xB000, 0x2800, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, + 0xC800, 0xA000, 0x2800, 0x2800, 0x2800, 0x9800, 0xB800, 0xB000, 0xB000, 0x7800, 0x2800, 0x2800, 0x7000, 0xA800, 0xB000, 0xB000, + 0x8800, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0xA800, 0xA800, 0xA800, 0x9000, 0x3000, 0x2800, 0x6000, 0xA800, 0xA800, 0xA800, + 0x9800, 0x3000, 0x8800, 0x3800, 0x3000, 0x3800, 0xB000, 0xC000, 0xC000, 0xC000, 0x7000, 0x4000, 0x7000, 0xF000, 0xF000, 0xE000, + 0xD000, 0x4800, 0x3800, 0x3800, 0x3800, 0x4000, 0xA000, 0xC000, 0xC000, 0xC000, 0x6000, 0x4000, 0x4000, 0xA800, 0xB000, 0xB000, + 0xB000, 0x5800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x5000, 0x5800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x7000, 0x9000, 0xA000, 0xB000, 0xC800, + 0xE000, 0xF000, 0xF000, 0xE000, 0xD000, 0xC000, 0xB000, 0xA000, 0x7800, 0x3000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB800, 0xA800, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xC800, 0xD000, 0xD000, 0xC800, 0xD000, 0xD800, 0xE000, + 0xD800, 0xA800, 0x2800, 0x2800, 0x2800, 0xA000, 0xB800, 0xB800, 0xB800, 0x6800, 0x2000, 0x2000, 0x7000, 0xB000, 0xB800, 0xB800, + 0x8800, 0x2800, 0x2800, 0x3000, 0x3800, 0x6800, 0xA800, 0xB000, 0xB000, 0x9000, 0x3000, 0x2800, 0x7000, 0xA800, 0xA800, 0xA800, + 0x8000, 0x3800, 0x8000, 0x3000, 0x3000, 0x3800, 0xB000, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x8800, 0xE800, 0xE800, 0xE000, + 0xC000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x9800, 0xC800, 0xC000, 0xC000, 0x7000, 0x4000, 0x4000, 0xA800, 0xB800, 0xB800, + 0xB800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x5000, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x6800, 0x9000, 0x9800, 0xA800, 0xC000, + 0xD000, 0xD800, 0xE000, 0xD800, 0xD000, 0xC000, 0x7800, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB800, 0xA800, 0x2000, 0x2000, 0x2000, 0x7800, 0xA800, 0xB800, 0xC000, 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0xA800, 0xC000, 0xC000, 0xC000, 0x7000, 0x2000, 0x2000, 0x7800, 0xB800, 0xB800, 0xB800, + 0x8800, 0x2800, 0x2800, 0x3000, 0x3800, 0x6800, 0xA000, 0xA800, 0xB800, 0x9000, 0x3000, 0x3000, 0x7000, 0xA800, 0xA800, 0xA800, + 0x7000, 0x5000, 0x6800, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xB800, 0xC000, 0x6800, 0x3800, 0x9000, 0xE000, 0xE000, 0xE000, + 0xD800, 0xD000, 0xD000, 0xD800, 0xD800, 0xD800, 0xD800, 0xD000, 0xC800, 0xC000, 0x8000, 0x4000, 0x4000, 0xA800, 0xC000, 0xC000, + 0xC000, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x6800, 0x6800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x7800, 0x3000, 0x2800, 0x2000, 0x2000, 0x7000, 0x9800, 0x9800, 0xA000, 0x8800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB000, 0xA000, 0x2000, 0x2000, 0x2000, 0x8000, 0xB000, 0xB800, 0xC000, 0x9000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0xB000, 0xD000, 0xD000, 0xC800, 0x7000, 0x1800, 0x2000, 0x7800, 0xC000, 0xC000, 0xC000, + 0x9000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0xA000, 0xA000, 0xA800, 0x8800, 0x3000, 0x3000, 0x7000, 0xA000, 0xA000, 0xA000, + 0x7000, 0x6000, 0x5800, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3800, 0x8800, 0xD800, 0xD800, 0xD800, + 0xD000, 0xD000, 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xD800, 0xD000, 0x8000, 0x4000, 0x4000, 0xB000, 0xC000, 0xC000, + 0xC800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0x6000, 0x2000, 0x2000, 0x2000, 0x7800, 0xA000, 0xA000, 0xA000, 0x8000, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB800, 0xB000, + 0xA800, 0xA000, 0x2800, 0x2800, 0x2800, 0x8000, 0xB000, 0xB000, 0xB800, 0x9000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xB000, 0xD800, 0xE000, 0xE800, 0x7800, 0x2800, 0x2000, 0x8000, 0xC000, 0xC000, 0xC000, + 0x9000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x7000, 0xA000, 0xA000, 0xA800, + 0x8000, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC800, 0x6800, 0x3800, 0x8800, 0xD000, 0xD000, 0xD000, + 0xD000, 0xD000, 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xE000, 0xD800, 0x8800, 0x3800, 0x4000, 0xB800, 0xC000, 0xC800, + 0xC800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x7000, 0x3800, 0x2000, 0x2000, 0x7800, 0x9800, 0x9800, 0xA000, 0x8000, + 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0xB000, 0xB000, + 0xA800, 0x9800, 0x2000, 0x2000, 0x2800, 0x8000, 0xA800, 0xA800, 0xB000, 0x8800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xB800, 0xE000, 0xF000, 0xF000, 0x9800, 0x3800, 0x2000, 0x8000, 0xC800, 0xC800, 0xC800, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x6000, 0xA800, 0xA800, 0xA800, + 0x9800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC800, 0x6800, 0x3800, 0x6800, 0xC800, 0xC800, 0xC800, + 0xB800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC800, + 0xC000, 0x5800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x7000, 0x2800, 0x2800, 0x7000, 0x9000, 0x9000, 0x9000, 0x7800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB000, 0xB800, + 0xB000, 0x9800, 0x2000, 0x2000, 0x2800, 0x8000, 0xA800, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xC000, 0xE000, 0xF000, 0xF000, 0x9000, 0x2000, 0x2000, 0x8800, 0xD000, 0xD000, 0xC800, + 0x9800, 0x3000, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x3800, 0xB000, 0xB000, 0xB000, + 0xB000, 0x7000, 0x3000, 0x3000, 0x3000, 0x4000, 0xB800, 0xC000, 0xC000, 0xC800, 0x6800, 0x3000, 0x3800, 0xC000, 0xC800, 0xC800, + 0xC800, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x5800, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0x6000, 0x2800, 0x6800, 0x9000, 0x9000, 0x9000, 0x7000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB000, 0xB000, + 0xB800, 0xA000, 0x2000, 0x2000, 0x2800, 0x7800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x6000, 0xB800, 0xE000, 0xF000, 0xF800, 0x8800, 0x2000, 0x2000, 0x8800, 0xD000, 0xD000, 0xC800, + 0xA000, 0x3800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x9800, 0xB800, 0xC000, + 0xB800, 0xA800, 0x5000, 0x3000, 0x4800, 0x9800, 0xC000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3000, 0x3000, 0x8800, 0xC800, 0xD000, + 0xD000, 0xD000, 0x9000, 0x3800, 0x3800, 0x3800, 0x9000, 0xD000, 0xD000, 0x6800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0x5000, 0x6000, 0x8800, 0x9000, 0x9000, 0x7000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x5000, 0xA800, 0xB000, + 0xB800, 0xA000, 0x2000, 0x2000, 0x2000, 0x7800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x5000, 0x7000, 0xB800, 0xE800, 0xF800, 0xF800, 0x8800, 0x2000, 0x2800, 0x8800, 0xD000, 0xD000, 0xC800, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB000, 0xA800, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x5000, 0xC000, 0xC000, + 0xC000, 0xB800, 0xB800, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0xA800, 0xD000, + 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xE000, 0xD800, 0xD800, 0xC800, 0x4800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x7000, 0x8800, 0x8800, 0x8800, 0x6800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0xA000, 0xB000, + 0xB800, 0xA800, 0x2000, 0x2000, 0x2000, 0x8000, 0xA000, 0xA800, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2000, 0x4800, 0x7000, 0x3000, 0xB800, 0xF000, 0xF800, 0xF800, 0x9000, 0x2800, 0x2800, 0x9000, 0xD800, 0xD000, 0xD000, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB000, 0xA800, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x3000, 0x7800, 0xC800, + 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xB800, 0xB000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0xA800, + 0xD000, 0xD800, 0xE000, 0xE000, 0xE800, 0xE800, 0xE000, 0xE000, 0xC800, 0x6800, 0x3800, 0x3800, 0x3800, 0xB800, 0xC800, 0xC000, + 0xC000, 0x6000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x3000, 0x8000, 0x8800, 0x8800, 0x8800, 0x6800, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0xA000, 0xB000, + 0xB800, 0xA800, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xA800, 0xA800, 0x8000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, + 0x2800, 0x4800, 0x7000, 0x3000, 0x2000, 0xB800, 0xF000, 0xF800, 0xF800, 0x9000, 0x2800, 0x2800, 0x9800, 0xE000, 0xE000, 0xD800, + 0xA000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB800, 0xB000, 0xA800, 0x9000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x9000, + 0x9800, 0xC000, 0xC000, 0xC000, 0x8800, 0x5000, 0x8000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x6800, 0x9800, 0xE000, 0xE000, 0xE800, 0xE800, 0xB800, 0x8800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0xB800, 0xC000, 0xC000, + 0xC000, 0x5000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x7000, 0x5000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x5000, 0x7000, 0x6000, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x3000, 0x3000, 0x2800, 0x4800, 0x7800, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3000, 0x3800, 0x5800, 0x6000, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x6800, 0x6000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x5000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, + 0x6800, 0x2800, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x7000, 0x5000, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x5800, 0x5000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x5800, 0x7000, + 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x4800, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x4000, 0x8000, 0x3000, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, + 0x7000, 0x6000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x3800, 0x2800, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6000, 0x7000, 0x4000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x5800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2800, 0x5000, 0x7800, 0x5800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, 0x7800, 0x5000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x8000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x6800, 0x6800, 0x7000, 0x6800, 0x4800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4800, 0x6800, 0x7800, 0x5000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x3000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x5000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x4000, 0x2800, 0x2000, 0x4000, 0x6000, 0x7800, 0x7000, 0x5800, 0x4800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x4800, 0x5800, 0x7000, 0x7800, 0x6000, 0x4000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x7800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x4800, 0x5800, 0x4800, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x4800, 0x5800, 0x4800, 0x3800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x4000, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4800, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2800, 0x4000, + 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x6800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x4000, 0x3800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x5800, 0x7800, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4800, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x4800, 0x8000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x6000, 0x5000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x5800, 0x3800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x6000, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x3800, 0xA000, 0x5800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x7000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x4800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x8800, + 0x8000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x5800, 0x4800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x5800, + 0x5000, 0x4000, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4000, 0x8000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x3000, 0x4000, 0x5800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x2800, 0x4000, 0x8000, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3800, 0x3000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x6800, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x4000, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2800, 0x3000, 0x3000, 0x3000, 0x2000, 0x2000, 0x2800, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x4000, 0x8000, 0x5000, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5000, 0x3800, 0x3000, 0x3800, 0x3800, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x3800, 0x3800, 0x3800, 0x2800, 0x1800, 0x2000, + 0x3000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x3000, 0x4000, 0x4000, 0x4000, 0x3800, 0x2000, 0x2000, 0x3800, 0x4800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x4000, 0x8000, 0x4800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x2800, 0x3000, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, + 0x3800, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x3800, 0x5000, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x4800, 0x8000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, + 0x3800, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x3800, 0x4800, 0x4800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, + 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x3000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2800, 0x2800, 0x3800, 0x4800, 0x4800, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3800, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6800, 0x7000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2800, 0x3800, 0x3800, 0x2800, 0x2000, 0x2000, 0x2000, 0x4800, 0x4800, + 0x7000, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x4000, 0x7800, 0x5800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x2800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x2000, 0x2000, 0x4000, 0x4000, + 0x3800, 0x7000, 0x5800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x7800, 0x4000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x4000, 0x4000, + 0x2000, 0x2800, 0x5800, 0x7000, 0x4000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x3800, 0x6800, 0x6800, 0x4000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x3000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, + 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7000, 0x7000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, + 0x2800, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x7800, 0x5800, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2800, 0x6000, 0x3800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x6800, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, 0x6800, 0x5800, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x5800, 0xA000, 0x3800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x4000, 0x6800, 0x7800, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x3800, 0x6000, 0x6800, 0x4800, 0x2800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3800, 0x2800, 0x1800, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x5000, 0x7800, 0x7000, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x3800, 0x2000, 0x2800, 0x2800, 0x3800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x5000, 0x7000, 0x6000, 0x4000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x2800, 0x3000, + 0x3000, 0x2000, 0x1800, 0x1800, 0x2000, 0x1800, 0x2800, 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x5000, 0x7000, + 0x7800, 0x6000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, 0x2000, 0x2000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x3800, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x5800, + 0x7000, 0x6800, 0x5800, 0x4000, 0x2800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x4000, + 0x4800, 0x2000, 0x1800, 0x1800, 0x2800, 0x4800, 0x2800, 0x2000, 0x2800, 0x3000, 0x4800, 0x6000, 0x7800, 0x7800, 0x6000, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x3800, 0x3000, 0x1800, 0x1800, 0x1800, 0x3000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, + 0x2000, 0x3000, 0x4000, 0x5800, 0x7000, 0x7000, 0x6000, 0x5000, 0x4800, 0x4000, 0x3800, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2800, 0x3000, 0x4800, 0x6000, 0x5800, 0x6800, 0x7800, 0x7800, 0x6800, 0x5000, 0x3800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, 0x3000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, + 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3800, 0x3800, 0x3000, 0x3000, 0x2800, 0x1800, 0x2000, 0x2000, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2800, 0x3000, 0x1800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x3000, 0x4000, 0x4800, 0x6000, 0x6800, 0x4000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x4000, 0x6000, 0x5800, 0x4800, 0x4800, 0x3800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3000, 0x3800, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x2000, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x1800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x2800, 0x2000, 0x1800, 0x1800, 0x1800, 0x3000, 0x3800, 0x3800, 0x4000, + 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x5000, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x1800, 0x2000, 0x2800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2800, 0x2800, 0x1800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x6000, 0x7000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x4800, 0x2800, 0x3000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2000, 0x1000, 0x2800, 0x3000, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x2000, 0x1800, 0x1800, 0x2000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2000, 0x2800, 0x3000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x1800, 0x2000, 0x2000, 0x3000, 0x2800, 0x1800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x3000, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x1800, 0x1800, 0x2000, 0x2800, 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x3000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x4000, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, +}; diff --git a/nixos/python-env.nix b/nixos/python-env.nix new file mode 100644 index 000000000..60b9b2113 --- /dev/null +++ b/nixos/python-env.nix @@ -0,0 +1,39 @@ +{ config, lib, pkgs, pyproject-nix, uv2nix, pyproject-build-systems, ... }: +let + env = (import ./pkgs/uv-python.nix { + inherit pkgs lib pyproject-nix uv2nix pyproject-build-systems; + }).pifinderEnv; +in { + # libcamera overlay — enable Python bindings for picamera2 + nixpkgs.overlays = [(final: prev: { + libcamera = prev.libcamera.overrideAttrs (old: { + mesonFlags = (old.mesonFlags or []) ++ [ + "-Dpycamera=enabled" + ]; + buildInputs = (old.buildInputs or []) ++ [ + final.python313 + final.python313.pkgs.pybind11 + ]; + }); + })]; + + environment.systemPackages = [ + env + pkgs.gobject-introspection + pkgs.networkmanager + pkgs.libcamera + pkgs.gpsd + ]; + + # Ensure GI_TYPELIB_PATH includes NetworkManager typelib + environment.sessionVariables.GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib + ]; + + # Add libcamera Python bindings to PYTHONPATH (for picamera2) + environment.sessionVariables.PYTHONPATH = "${pkgs.libcamera}/lib/python3.13/site-packages"; + + # Export the Python environment for use by services.nix + _module.args.pifinderPythonEnv = env; +} diff --git a/nixos/services.nix b/nixos/services.nix new file mode 100644 index 000000000..d8a9a30c9 --- /dev/null +++ b/nixos/services.nix @@ -0,0 +1,780 @@ +{ config, lib, pkgs, pifinderPythonEnv, ... }: +let + cfg = config.pifinder; + cedar-detect = import ./pkgs/cedar-detect.nix { inherit pkgs; }; + pifinder-src = import ./pkgs/pifinder-src.nix { inherit pkgs; }; + boot-splash = import ./pkgs/boot-splash.nix { inherit pkgs; }; + # Point the extlinux DEFAULT at a specific camera's boot entry. Device-tree + # overlays load only at boot and the generic-extlinux builder always writes + # DEFAULT=nixos-default (the base camera), so without this a switched camera + # never actually boots its matching DTB. Boot-critical and best-effort: on any + # doubt it leaves the existing (bootable) DEFAULT untouched. + set-extlinux-default = pkgs.writeShellScriptBin "set-extlinux-default" '' + set -euo pipefail + CAM="''${1:?usage: set-extlinux-default }" + CONF=/boot/extlinux/extlinux.conf + + [ -f "$CONF" ] || { echo "set-extlinux-default: $CONF missing" >&2; exit 0; } + + if [ "$CAM" = "${cfg.cameraType}" ]; then + # The base camera is the builder's own default entry. + TARGET=nixos-default + else + # Highest-numbered generation carrying this camera's specialisation entry. + TARGET=$(grep -oE "^LABEL nixos-[0-9]+-$CAM" "$CONF" \ + | sed 's/^LABEL //' | sort -t- -k2,2n | tail -n1 || true) + fi + + if [ -z "$TARGET" ] || ! grep -qx "LABEL $TARGET" "$CONF"; then + echo "set-extlinux-default: no boot entry for '$CAM'; DEFAULT left unchanged" >&2 + exit 0 + fi + + TMP="$CONF.tmp.$$" + sed "s/^DEFAULT .*/DEFAULT $TARGET/" "$CONF" > "$TMP" + # Refuse to install anything that isn't exactly one DEFAULT pointing at a + # real LABEL — a malformed extlinux.conf would brick the next boot. + if [ "$(grep -c '^DEFAULT ' "$TMP")" = "1" ] && grep -qx "LABEL $TARGET" "$TMP"; then + mv "$TMP" "$CONF" + sync + echo "set-extlinux-default: DEFAULT -> $TARGET" >&2 + else + rm -f "$TMP" + echo "set-extlinux-default: sanity check failed; DEFAULT left unchanged" >&2 + exit 0 + fi + ''; + pifinder-switch-camera = pkgs.writeShellScriptBin "pifinder-switch-camera" '' + set -euo pipefail + CAM="''${1:?usage: pifinder-switch-camera }" + PERSIST="/var/lib/pifinder/camera-type" + mkdir -p /var/lib/pifinder + + # Accept only the base camera or a camera with a built specialisation. + if [ "$CAM" != "${cfg.cameraType}" ] && [ ! -d "/run/current-system/specialisation/$CAM" ]; then + echo "Unknown camera: $CAM" >&2 + exit 1 + fi + + # Regenerate the bootloader (installs every specialisation entry; 'boot' + # mode touches no running services), make the chosen camera the boot + # default, and persist the choice. + /run/current-system/bin/switch-to-configuration boot + ${set-extlinux-default}/bin/set-extlinux-default "$CAM" + echo "$CAM" > "$PERSIST" + + # Device-tree overlays load only at boot, so apply the new camera by + # rebooting into its entry. + exec ${pkgs.systemd}/bin/systemctl reboot + ''; +in { + options.pifinder = { + devMode = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable development mode (NFS netboot support, etc.)"; + }; + gpsBaud = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + example = 115200; + description = '' + Fixed serial speed for gpsd (-s). null keeps gpsd's autobaud hunt, + which handles both the rev-3 GPS and the v4 u-blox Gen10 (UBX at + 115200). Set it only to pin a known receiver and skip the hunt. + ''; + }; + }; + + config = { + # --------------------------------------------------------------------------- + # Camera switch wrapper (used by pifinder UI via sudo) + # --------------------------------------------------------------------------- + environment.systemPackages = with pkgs; [ + pifinder-switch-camera + set-extlinux-default + + # Diagnostic tools for SSH troubleshooting + htop + vim + tcpdump + iftop + lsof + strace + file + dnsutils # dig, nslookup + curl + usbutils # lsusb + pciutils # lspci + i2c-tools # i2cdetect (sensor debugging) + iotop + ] ++ lib.optionals cfg.devMode [ + # On-device development only (excluded from the production image). Not used + # by the NixOS image updater, which is manifest/store-path based (ADR 0003). + git # clone/pull a checkout to run live + rsync # sync a checkout from a desktop without re-copying everything + ]; + + + + # --------------------------------------------------------------------------- + # Binary substituters — Pi downloads pre-built paths, never compiles. + # Two Attic caches on cache.pifinder.eu (NixOS ADR 0001): + # pifinder-release — tagged release closures, never garbage-collected, so a + # device upgrading long after a release still resolves it. + # pifinder — dev/nightly builds, short retention. + # cache.nixos.org serves everything not built locally. + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.pifinder.eu/pifinder-release" + "https://cache.pifinder.eu/pifinder" + "https://cache.nixos.org" + ]; + trusted-public-keys = [ + # Attic cache signing keys. pifinder is the original 8UU key: the S3 + # cutover briefly rotated it (Vkem), but nothing deployed trusted the new + # key so the whole fleet was stranded — the cache and this config were + # restored to 8UU. pifinder-release was minted fresh with the cutover (no + # device trusted a release key before). Real keys — never swap one for a + # placeholder; invalid base64 aborts every nix op and bricks upgrades. + "pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE=" + "pifinder-release:WG/Fw1cIX7YpwfWrbWTP5eCzn3bz6AaicW5qKxLKpoM=" + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + ]; + }; + + # --------------------------------------------------------------------------- + # SD card optimizations + # --------------------------------------------------------------------------- + + # Keep 2 generations max in bootloader + boot.loader.generic-extlinux-compatible.configurationLimit = 2; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 3d"; + }; + # Disable store optimization on NFS (hard links cause issues) + nix.settings.auto-optimise-store = !cfg.devMode; + + boot.tmp.useTmpfs = true; + boot.tmp.tmpfsSize = "200M"; + + services.journald.extraConfig = '' + Storage=volatile + RuntimeMaxUse=50M + ''; + + zramSwap = { + enable = true; + memoryPercent = 50; + }; + + fileSystems."/" = lib.mkDefault { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + options = [ "noatime" "nodiratime" ]; + }; + + # --------------------------------------------------------------------------- + # Tmpfiles — runtime directory for upgrade ref file + # --------------------------------------------------------------------------- + systemd.tmpfiles.rules = [ + "d /run/pifinder 0755 pifinder users -" + ]; + + # --------------------------------------------------------------------------- + # PWM permissions setup for keypad backlight + # --------------------------------------------------------------------------- + systemd.services.pwm-permissions = { + description = "Set PWM sysfs permissions for pifinder"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + # Export PWM channels: 1 (GPIO 13, keypad backlight) and 0 (GPIO 12, + # rev-4 buzzer — harmless no-op wiring on rev-3). + for ch in 0 1; do + if [ ! -d /sys/class/pwm/pwmchip0/pwm$ch ]; then + echo $ch > /sys/class/pwm/pwmchip0/export || true + sleep 0.5 + fi + done + # sysfs doesn't support chgrp, so make files world-writable + chmod 0666 /sys/class/pwm/pwmchip0/export /sys/class/pwm/pwmchip0/unexport + for ch in 0 1; do + if [ -d /sys/class/pwm/pwmchip0/pwm$ch ]; then + chmod 0666 /sys/class/pwm/pwmchip0/pwm$ch/{enable,period,duty_cycle,polarity} + fi + done + # Red PWR LED — the app turns it off for night vision (sys_utils + # set_power_led writes these directly, no sudo). + if [ -d /sys/class/leds/PWR ]; then + chmod 0666 /sys/class/leds/PWR/trigger /sys/class/leds/PWR/brightness + fi + ''; + }; + + # --------------------------------------------------------------------------- + # Nix DB registration (first boot after migration) + # --------------------------------------------------------------------------- + # The migration tarball includes /nix-path-registration with store path data. + # Load it into the Nix DB so nix-store and nixos-rebuild work correctly. + systemd.services.nix-path-registration = { + description = "Load Nix store path registration from migration"; + after = [ "local-fs.target" ]; + before = [ "nix-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "/nix-path-registration"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix coreutils ]; + script = '' + nix-store --load-db < /nix-path-registration + rm /nix-path-registration + ''; + }; + + # --------------------------------------------------------------------------- + # Repair /nix/store ownership before NetworkManager starts + # --------------------------------------------------------------------------- + # NetworkManager (like other security-sensitive plugin loaders) silently + # refuses to load any plugin file not owned by root. Tarball-based migration + # and single-user nix imports can leave /nix/store paths owned by a non-root + # uid; NM then drops its wifi device plugin entirely — wlan0 shows as + # "unmanaged", WIFI-HW as "missing", and no wifi client connection ever comes + # up. Normalise ownership back to root before NM reads its plugins. Idempotent + # and cheap on a clean store (early-exits without touching the ro mount). + systemd.services.fix-nix-store-ownership = { + description = "Normalise /nix/store ownership to root (NM rejects non-root plugins)"; + after = [ "local-fs.target" ]; + before = [ "NetworkManager.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ util-linux findutils coreutils ]; + script = '' + set -u + if [ -z "$(find /nix/store -mindepth 1 -maxdepth 1 ! -uid 0 -print -quit)" ] \ + && [ "$(stat -c %u /nix/var/nix/db)" = 0 ]; then + exit 0 + fi + echo "normalising non-root /nix/store ownership" + # /nix/store is a read-only bind mount of the same device as /. The + # remount MUST carry "bind" so it flips only this mount's per-mount + # ro flag; a plain "remount,ro" would flip the shared superblock and + # take / (and /nix/var) read-only with it. + remounted=0 + if findmnt -no OPTIONS /nix/store | grep -qw ro; then + if mount -o remount,bind,rw /nix/store; then + remounted=1 + else + echo "WARNING: could not remount /nix/store rw; skipping repair" + exit 0 + fi + fi + find /nix/store -mindepth 1 -maxdepth 1 ! -uid 0 -exec chown -R 0:0 {} + || true + chown 0:0 /nix/var/nix/db || true + if [ "$remounted" = 1 ]; then + mount -o remount,bind,ro /nix/store || true + fi + echo "store ownership normalised" + ''; + }; + + # --------------------------------------------------------------------------- + # PiFinder source + data directory setup + # --------------------------------------------------------------------------- + system.activationScripts.pifinder-home = lib.stringAfter [ "users" ] '' + # Create writable data directory + mkdir -p /home/pifinder/PiFinder_data + chown pifinder:users /home/pifinder/PiFinder_data + + # Symlink immutable source tree from Nix store + # Database is opened read-only, so no need for writable copy + PFHOME=/home/pifinder/PiFinder + + # Remove existing directory (not symlink) to allow symlink creation + if [ -e "$PFHOME" ] && [ ! -L "$PFHOME" ]; then + rm -rf "$PFHOME" + fi + + # Create symlink to immutable Nix store path + ln -sfT ${pifinder-src} "$PFHOME" + ''; + + # --------------------------------------------------------------------------- + # Sudoers — pifinder user can start upgrade and restart services + # --------------------------------------------------------------------------- + # Polkit rules for pifinder user (D-Bus hostname changes, NetworkManager) + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if (subject.user == "pifinder") { + // Allow hostname changes via systemd-hostnamed + if (action.id == "org.freedesktop.hostname1.set-static-hostname" || + action.id == "org.freedesktop.hostname1.set-hostname") { + return polkit.Result.YES; + } + // Allow NetworkManager control + if (action.id.indexOf("org.freedesktop.NetworkManager") == 0) { + return polkit.Result.YES; + } + // Allow reboot/shutdown via D-Bus (logind) + if (action.id == "org.freedesktop.login1.reboot" || + action.id == "org.freedesktop.login1.reboot-multiple-sessions" || + action.id == "org.freedesktop.login1.power-off" || + action.id == "org.freedesktop.login1.power-off-multiple-sessions") { + return polkit.Result.YES; + } + } + }); + ''; + + security.sudo.extraRules = [{ + users = [ "pifinder" ]; + commands = [ + { command = "/run/current-system/sw/bin/systemctl start --no-block pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl start pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl reset-failed pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl stop pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl start pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart avahi-daemon.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/avahi-set-host-name *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/shutdown -r now"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/shutdown now"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/chpasswd"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/hostname *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/pifinder-switch-camera imx296"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/pifinder-switch-camera imx462"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/pifinder-switch-camera imx477"; options = [ "NOPASSWD" ]; } + ]; + }]; + + # --------------------------------------------------------------------------- + # Cedar Detect star detection gRPC server + # --------------------------------------------------------------------------- + systemd.services.cedar-detect = { + description = "Cedar Detect Star Detection Server"; + after = [ "basic.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "idle"; + User = "pifinder"; + ExecStart = "${cedar-detect}/bin/cedar-detect-server --port 50551"; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + + # --------------------------------------------------------------------------- + # Early boot splash — show static welcome image, pifinder overwrites when ready + # --------------------------------------------------------------------------- + systemd.services.boot-splash = { + description = "Early boot splash screen"; + wantedBy = [ "sysinit.target" ]; + after = [ "systemd-modules-load.service" ]; + wants = [ "systemd-modules-load.service" ]; + unitConfig.DefaultDependencies = false; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "boot-splash-wait" '' + for i in $(seq 1 40); do + [ -e /dev/spidev0.0 ] && exec ${boot-splash}/bin/boot-splash --static + sleep 0.25 + done + echo "SPI device never appeared" >&2 + exit 1 + ''; + }; + }; + + # --------------------------------------------------------------------------- + # Main PiFinder application + # --------------------------------------------------------------------------- + systemd.services.pifinder = { + description = "PiFinder"; + after = [ "basic.target" "cedar-detect.service" "gpsd.socket" ]; + wants = [ "cedar-detect.service" "gpsd.socket" ]; + wantedBy = [ "multi-user.target" ]; + path = let + # Runtime paths not in the nix store — symlinks resolve at boot, not build time + wrapperBins = pkgs.runCommand "wrapper-bins" {} '' + mkdir -p $out + ln -s /run/wrappers/bin $out/bin + ''; + systemBins = pkgs.runCommand "system-bins" {} '' + mkdir -p $out + ln -s /run/current-system/sw/bin $out/bin + ''; + in [ wrapperBins systemBins pkgs.gpsd ]; + environment = { + PIFINDER_HOME = "/home/pifinder/PiFinder"; + PIFINDER_DATA = "/home/pifinder/PiFinder_data"; + GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib.out # Use .out to get the main package with typelibs, not glib-bin + pkgs.gobject-introspection + ]; + # libcamera Python bindings for picamera2 + PYTHONPATH = "${pkgs.libcamera}/lib/python3.13/site-packages"; + # libcamera IPA modules path + LIBCAMERA_IPA_MODULE_PATH = "${pkgs.libcamera}/lib/libcamera"; + }; + serviceConfig = { + # The app sends READY=1 once the UI is constructed and drawing + # (utils.sd_notify in main.py). "active" therefore means "the screen is + # live", which is what the boot watchdog's health check keys off — a + # build that starts but never turns the screen on times out, restarts, + # and fails its trial. + Type = "notify"; + # Cold start on a Pi is ~30-60s (imports dominate); leave ample slack. + TimeoutStartSec = 180; + User = "pifinder"; + Group = "users"; + WorkingDirectory = "/home/pifinder/PiFinder/python"; + ExecStart = "${pifinderPythonEnv}/bin/python -m PiFinder.main"; + # Allow binding to privileged ports (80 for web UI) + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + + # --------------------------------------------------------------------------- + # PiFinder Network Policy + # --------------------------------------------------------------------------- + # Enforces connectivity priority wired > wifi client > AP via libnm + # (PiFinder/net_policy.py). Event-driven on NetworkManager state changes; + # brings the AP up only as an offline fallback and periodically drops an + # idle AP so NM can rejoin a client network. The migration image, which has + # no Python env, uses wifi-fallback-minimal.nix instead. + systemd.services.pifinder-net-policy = { + description = "PiFinder network policy (wired > wifi client > AP)"; + after = [ "NetworkManager.service" ]; + wants = [ "NetworkManager.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ pkgs.iw ]; + environment = { + PIFINDER_DATA = "/home/pifinder/PiFinder_data"; + GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib.out + pkgs.gobject-introspection + ]; + }; + serviceConfig = { + WorkingDirectory = "/home/pifinder/PiFinder/python"; + ExecStart = "${pifinderPythonEnv}/bin/python -m PiFinder.net_policy"; + Restart = "always"; + RestartSec = 5; + }; + }; + + # --------------------------------------------------------------------------- + # PiFinder NixOS Upgrade + # --------------------------------------------------------------------------- + # Downloads from binary caches, sets profile, updates bootloader, reboots. + # No live switch-to-configuration — avoids killing running services. + # The pifinder-watchdog handles rollback if the new generation fails to boot. + systemd.services.pifinder-upgrade = { + description = "PiFinder NixOS Upgrade"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + WorkingDirectory = "/home/pifinder/PiFinder/python"; + ExecStart = "${pifinderPythonEnv}/bin/python -m PiFinder.nixos_upgrade --default-camera ${cfg.cameraType}"; + }; + path = with pkgs; [ nix systemd coreutils set-extlinux-default ]; + }; + + # --------------------------------------------------------------------------- + # PiFinder Boot Health Watchdog — self-arming trial/commit + # --------------------------------------------------------------------------- + # A generation is on probation until it has passed a health check once + # (recorded in confirmed-generations). Any boot of an UNCONFIRMED generation + # is a trial — whether or not the (possibly older, marker-unaware) system + # that installed it armed the trial marker. Protection never depends on the + # previous build's code. + # - confirmed generation -> never roll back, so a transient failure in + # the field can't cause a surprise downgrade + # - trial gen healthy -> confirm it + # - trial gen unhealthy -> capture the journal to PiFinder_data (journald + # is volatile to spare the SD card; a failed boot is the one moment worth + # a write), leave a notice the app shows after reboot, show the failure + # splash, roll back (marker hint first, else newest other generation), + # reboot. With no rollback target at all, stay up for rescue instead of + # boot-looping. + systemd.services.pifinder-watchdog = { + description = "PiFinder Boot Health Watchdog"; + after = [ "multi-user.target" "pifinder.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix systemd coreutils jq gnugrep boot-splash ]; + script = '' + set -euo pipefail + MARKER=/var/lib/pifinder/trial-generation.json + CONFIRMED=/var/lib/pifinder/confirmed-generations + DATA=/home/pifinder/PiFinder_data + CURRENT=$(readlink -f /run/current-system) + + is_confirmed() { + [ -f "$CONFIRMED" ] && grep -qxF "$1" "$CONFIRMED" + } + + if is_confirmed "$CURRENT"; then + # Stale marker from an aborted/rolled-back upgrade attempt is harmless + # here but must not survive to a later boot. + rm -f "$MARKER" + # Never ACT on a confirmed generation — but still REPORT (ADR 0005): + # if the app can't start, the frozen splash gets replaced by an + # advisory naming the recovery hold, so the escape hatch reveals + # itself exactly when it is needed. + echo "Generation already confirmed — report-only watch." + for i in $(seq 1 24); do + if systemctl is-active --quiet pifinder.service; then + exit 0 + fi + sleep 5 + done + echo "Confirmed generation's app is not starting — showing recovery advisory (no action taken)." + # The crash-looping app redraws its boot console between restarts, so + # re-assert the advisory periodically (bounded — 30 min, then leave + # the last draw standing) while bailing out if the app recovers. + for i in $(seq 1 60); do + if systemctl is-active --quiet pifinder.service; then + exit 0 + fi + boot-splash --message "PIFINDER" "FAILED TO START" "HOLD SQUARE" "AT POWER ON" "FOR RECOVERY" || true + sleep 30 + done + exit 0 + fi + + echo "Trial boot of unconfirmed generation $CURRENT: waiting up to 120s for pifinder.service..." + for i in $(seq 1 24); do + if systemctl is-active --quiet pifinder.service; then + # Verify it stays running (not crash-looping) + UPTIME=$(systemctl show pifinder.service --property=ExecMainStartTimestamp --value) + START_EPOCH=$(date -d "$UPTIME" +%s 2>/dev/null || echo 0) + NOW_EPOCH=$(date +%s) + RUNNING_FOR=$((NOW_EPOCH - START_EPOCH)) + if [ "$RUNNING_FOR" -ge 15 ]; then + echo "pifinder.service healthy (running ''${RUNNING_FOR}s) — confirming generation." + mkdir -p "$(dirname "$CONFIRMED")" + echo "$CURRENT" >> "$CONFIRMED" + rm -f "$MARKER" + exit 0 + fi + fi + sleep 5 + done + + # ----- unhealthy: pick a rollback target ------------------------------ + # Marker hint (exact pre-upgrade system, specialisation included) first; + # otherwise walk the profile, newest first, skipping any generation that + # boots into this same failed build (directly or via a specialisation) + # and preferring confirmed generations. + TARGET="" + if [ -f "$MARKER" ]; then + HINT=$(jq -r '.previous // empty' "$MARKER" 2>/dev/null || true) + if [ -n "$HINT" ] && [ -e "$HINT" ] && [ "$HINT" != "$CURRENT" ]; then + TARGET="$HINT" + fi + fi + if [ -z "$TARGET" ]; then + FALLBACK="" + for GEN in $(ls -d /nix/var/nix/profiles/system-*-link 2>/dev/null | sort -t- -k2 -rn); do + G=$(readlink -f "$GEN") + [ "$G" = "$CURRENT" ] && continue + SKIP=0 + for S in "$G"/specialisation/*/; do + [ -e "$S" ] || continue + [ "$(readlink -f "$S")" = "$CURRENT" ] && SKIP=1 && break + done + [ "$SKIP" = 1 ] && continue + if is_confirmed "$G"; then + TARGET="$G" + break + fi + [ -z "$FALLBACK" ] && FALLBACK="$G" + done + [ -z "$TARGET" ] && TARGET="$FALLBACK" + fi + + # ----- capture evidence ------------------------------------------------ + echo "ERROR: trial generation unhealthy. Capturing evidence..." + TS=$(date +%Y%m%d-%H%M%S) + mkdir -p "$DATA" + journalctl -b > "$DATA/failed-boot-$TS.log" || true + jq -n --arg failed "$CURRENT" --arg reverted_to "''${TARGET:-none}" --arg at "$TS" \ + '{failed: $failed, reverted_to: $reverted_to, at: $at}' \ + > "$DATA/upgrade_failed.json" || true + chown pifinder:users "$DATA/failed-boot-$TS.log" "$DATA/upgrade_failed.json" 2>/dev/null || true + + # Stop the crash-looping app so the display is free for the failure + # message (and so the reboot is clean). + systemctl stop pifinder.service || true + + if [ -z "$TARGET" ]; then + echo "FATAL: no rollback target exists — staying up for rescue (SSH) instead of boot-looping." + boot-splash --message "UPDATE" "FAILED" "NO ROLLBACK" "USE SSH OR REFLASH" "HOLD SQ AT POWER ON" "FOR RECOVERY" || true + exit 1 + fi + + boot-splash --message "UPDATE" "FAILED" "ROLLING BACK" "PLEASE WAIT" "HOLD SQ AT POWER ON" "FOR RECOVERY" || true + + echo "Rolling back to $TARGET and rebooting..." + rm -f "$MARKER" + # current-build.json was written for the (now failed) generation before + # its reboot; left in place it makes the rolled-back system misreport + # its identity (and the update UI mis-hide entries). Remove it — version + # display falls back to the baked build metadata. + rm -f /var/lib/pifinder/current-build.json + nix-env -p /nix/var/nix/profiles/system --set "$TARGET" + "$TARGET/bin/switch-to-configuration" boot || true + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # GPSD for GPS receiver - full USB hotplug support + # --------------------------------------------------------------------------- + # Don't use services.gpsd module - it doesn't support hotplug. + # Instead, use gpsd's own systemd units with socket activation. + + # Install gpsd's udev rules (25-gpsd.rules) for USB GPS auto-detection + # Includes u-blox 5/6/7/8/9 and many other GPS receivers + services.udev.packages = [ pkgs.gpsd ]; + + # Install gpsd's systemd units (gpsd.service, gpsd.socket, gpsdctl@.service) + systemd.packages = [ pkgs.gpsd ]; + + # Enable socket activation - gpsd starts when something connects to port 2947 + systemd.sockets.gpsd = { + wantedBy = [ "sockets.target" ]; + }; + + # /etc/default/gpsd — same shape as upstream pi_config_files/gpsd.conf. + # DEVICES opens the on-board UART GPS at startup via its stable udev name + # (see hardware.nix — ttyAMA numbering shifts between kernels); USBAUTO lets + # udev hotplug USB GPSes via gpsdctl. GPSD_SOCKET is intentionally omitted — + # gpsd's default (/var/run/gpsd.sock) is already what we want. + environment.etc."default/gpsd".text = '' + DEVICES="/dev/gpsuart" + GPSD_OPTIONS="${lib.optionalString (cfg.gpsBaud != null) "-s ${toString cfg.gpsBaud}"}" + USBAUTO="true" + ''; + + # Ensure gpsd user/group exist (normally created by services.gpsd module) + users.users.gpsd = { + isSystemUser = true; + group = "gpsd"; + description = "GPSD daemon user"; + }; + users.groups.gpsd = {}; + + # Add the on-board UART GPS to gpsd (uart3 overlay, published as + # /dev/gpsuart by udev — platform UARTs are not auto-detected the way USB + # GPSes are). Started by udev via SYSTEMD_WANTS when the device appears + # (see hardware.nix), so a unit without an on-board GPS never starts it + # and USB-only setups still work through USBAUTO hotplug alone. + systemd.services.gpsd-add-uart = { + description = "Add UART GPS to gpsd"; + after = [ "gpsd.socket" "dev-gpsuart.device" ]; + requires = [ "gpsd.socket" ]; + # BindsTo ensures this stops if the GPS UART disappears + bindsTo = [ "dev-gpsuart.device" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.gpsd}/sbin/gpsdctl add /dev/gpsuart"; + ExecStop = "${pkgs.gpsd}/sbin/gpsdctl remove /dev/gpsuart"; + }; + }; + + # --------------------------------------------------------------------------- + # PAM service for PiFinder web UI password verification + # --------------------------------------------------------------------------- + security.pam.services.pifinder = { + # Auth-only: no account/session management (avoids setuid and pam_lastlog2 errors) + allowNullPassword = false; + unixAuth = true; + setLoginUid = false; + updateWtmp = false; + }; + + # --------------------------------------------------------------------------- + # Samba for file sharing (observation data, backups) + # --------------------------------------------------------------------------- + system.stateVersion = "24.11"; + + # --------------------------------------------------------------------------- + # SSH access + # --------------------------------------------------------------------------- + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = true; + PermitRootLogin = "no"; + }; + }; + + # Avahi/mDNS + the PiFinder custom-hostname service live in nixos/device.nix + # (single owner — this block used to be duplicated here and there). + + # Don't block boot waiting for network — NM still works, just async + systemd.services.NetworkManager-wait-online.enable = false; + + services.samba = { + enable = true; + openFirewall = true; + settings = { + global = { + workgroup = "WORKGROUP"; + security = "user"; + # Anonymous access, as on the original Raspbian PiFinder: unauthenticated + # clients are mapped to the pifinder user, which owns the share, so no SMB + # password is ever needed. (Samba's passdb is separate from the Unix login, + # so "solveit" never authenticated SMB anyway.) + "map to guest" = "bad user"; + "guest account" = "pifinder"; + }; + PiFinder_data = { + path = "/home/pifinder/PiFinder_data"; + browseable = "yes"; + "read only" = "no"; + "guest ok" = "yes"; + }; + }; + }; + + # Advertise the Samba share over mDNS so it appears in file-manager "Network" + # browse views (Finder, Nautilus). Samba itself never publishes an + # _smb._tcp record; Avahi (configured in networking.nix) does the DNS-SD. + # Lives here, tied to the samba block, so only the device build advertises it. + services.avahi.extraServiceFiles.smb = '' + + + + %h + + _smb._tcp + 445 + + + ''; + }; # config +} diff --git a/nixos/wifi-fallback-minimal.nix b/nixos/wifi-fallback-minimal.nix new file mode 100644 index 000000000..5982feae2 --- /dev/null +++ b/nixos/wifi-fallback-minimal.nix @@ -0,0 +1,55 @@ +# Minimal AP fallback for the migration image, which has no Python +# environment. The full system runs pifinder-net-policy (libnm daemon, +# services.nix) instead; this is a stripped-down shell version of the same +# priority — wired > wifi client > AP — good enough for the short-lived +# bootstrap system whose only job is staying reachable until first boot. +{ pkgs, ... }: +{ + systemd.services.pifinder-wifi-fallback = { + description = "Bring up PiFinder AP when offline (migration image)"; + after = [ "NetworkManager.service" ]; + wants = [ "NetworkManager.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ pkgs.networkmanager pkgs.coreutils pkgs.gnugrep ]; + serviceConfig.Type = "oneshot"; + script = '' + modefile=/home/pifinder/PiFinder_data/wifi_mode + + if [ -r "$modefile" ] && [ "$(cat "$modefile")" = "AP" ]; then + nmcli connection up PiFinder-AP || true + exit 0 + fi + + # Wired connectivity is sufficient — never start the AP over it. + eth_up() { + nmcli -t -f TYPE,STATE device | grep -q '^ethernet:connected' + } + # A wifi CLIENT connection. Matching the device state alone would + # count the AP itself as "connected" and make the AP sticky. + wifi_client_up() { + nmcli -t -f TYPE,NAME connection show --active \ + | grep '^802-11-wireless:' \ + | grep -qvx '802-11-wireless:PiFinder-AP' + } + + # Give NetworkManager a grace period to land on something better. + for _ in $(seq 1 45); do + if eth_up || wifi_client_up; then + exit 0 + fi + sleep 1 + done + + nmcli connection up PiFinder-AP || true + ''; + }; + + systemd.timers.pifinder-wifi-fallback = { + description = "Periodically ensure WiFi falls back to AP when offline"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "30s"; + OnUnitActiveSec = "120s"; + }; + }; +} diff --git a/python/DEPENDENCIES.md b/python/DEPENDENCIES.md new file mode 100644 index 000000000..4cd998f32 --- /dev/null +++ b/python/DEPENDENCIES.md @@ -0,0 +1,105 @@ +> **Auto-generated** from the Nix development shell on 2026-02-13. +> Do not edit manually — regenerate with: +> ``` +> nix develop --command ./scripts/generate-dependencies-md.sh +> ``` + +> **Note:** These dependencies are managed by Nix (`nixos/pkgs/python-packages.nix`). +> The versions listed here reflect the nixpkgs pin used by the flake and are +> **not necessarily installable via pip**. Some packages require system libraries +> or hardware (SPI, I2C, GPIO) only available on the Raspberry Pi. + +# Python Dependencies + +Python 3.13.11 + +## Runtime + +| Package | Version | +|---------|---------| +| aiofiles | 24.1.0 | +| attrs | 25.3.0 | +| av | 16.0.1 | +| bottle | 0.13.4 | +| cbor2 | 5.7.0 | +| certifi | 2025.7.14 | +| cffi | 2.0.0 | +| charset-normalizer | 3.4.3 | +| cheroot | 10.0.1 | +| dataclasses-json | 0.6.7 | +| dbus-python | 1.4.0 | +| Deprecated | 1.2.18 | +| evdev | 1.9.2 | +| flatbuffers | 25.9.23 | +| gpsdclient | 1.3.2 | +| grpcio | 1.76.0 | +| h3 | 4.3.1 | +| idna | 3.11 | +| jaraco.functools | 4.2.1 | +| joblib | 1.5.1 | +| jplephem | 2.23 | +| json5 | 0.12.0 | +| jsonpath-ng | 1.7.0 | +| jsonschema | 4.25.0 | +| jsonschema-specifications | 2025.4.1 | +| libarchive-c | 5.3 | +| luma.core | 2.4.2 | +| luma.lcd | 2.11.0 | +| luma.oled | 3.13.0 | +| lz4 | 4.4.4 | +| marshmallow | 3.26.2 | +| more-itertools | 10.7.0 | +| numpy | 2.3.4 | +| pandas | 2.3.1 | +| pillow | 12.1.0 | +| ply | 3.11 | +| protobuf | 6.33.1 | +| psutil | 7.1.2 | +| pycairo | 1.28.0 | +| pycparser | 2.23 | +| pydeepskylog | 1.6 | +| pyftdi | 0.57.1 | +| Pygments | 2.19.2 | +| PyGObject | 3.54.5 | +| PyJWT | 2.10.1 | +| pyserial | 3.5 | +| python-dateutil | 2.9.0.post0 | +| python-libinput | 0.3.0a0 | +| python-pam | 2.0.2 | +| pytz | 2025.2 | +| pyusb | 1.3.1 | +| referencing | 0.36.2 | +| requests | 2.32.5 | +| rpds-py | 0.25.0 | +| scikit-learn | 1.7.1 | +| scipy | 1.16.3 | +| sgp4 | 2.25 | +| sh | 1.14.3 | +| six | 1.17.0 | +| skyfield | 1.53 | +| smbus2 | 0.5.0 | +| spidev | 3.8 | +| threadpoolctl | 3.6.0 | +| timezonefinder | 8.1.0 | +| tqdm | 4.67.1 | +| typing_extensions | 4.15.0 | +| typing_inspect | 0.9.0 | +| tzdata | 2025.2 | +| urllib3 | 2.5.0 | +| wrapt | 1.17.2 | + +## Development only + +| Package | Version | +|---------|---------| +| iniconfig | 2.1.0 | +| luma.emulator | 1.5.0 | +| mypy | 1.17.1 | +| mypy_extensions | 1.1.0 | +| pathspec | 0.12.1 | +| pluggy | 1.6.0 | +| pygame | 2.6.1 | +| PyHotKey | 1.5.2 | +| pynput | 1.8.1 | +| pytest | 8.4.2 | +| python-xlib | 0.33 | diff --git a/python/PiFinder/audit_images.py b/python/PiFinder/audit_images.py index ef37fdb70..e34664ce5 100644 --- a/python/PiFinder/audit_images.py +++ b/python/PiFinder/audit_images.py @@ -44,8 +44,8 @@ def check_object_image(catalog_object): aka_rec = conn.execute( f""" SELECT common_name from names - where catalog = "{catalog_object['catalog']}" - and sequence = "{catalog_object['sequence']}" + where catalog = "{catalog_object["catalog"]}" + and sequence = "{catalog_object["sequence"]}" and common_name like "NGC%" """ ).fetchone() diff --git a/python/PiFinder/auto_exposure.py b/python/PiFinder/auto_exposure.py index 0ac4149c7..6a5bfc72f 100644 --- a/python/PiFinder/auto_exposure.py +++ b/python/PiFinder/auto_exposure.py @@ -211,64 +211,10 @@ def __init__( logger.info( f"AutoExposure SNR: target_bg={target_background}, " f"range=[{min_background}, {max_background}] ADU, " - f"exp_range=[{min_exposure/1000:.0f}, {max_exposure/1000:.0f}]ms, " + f"exp_range=[{min_exposure / 1000:.0f}, {max_exposure / 1000:.0f}]ms, " f"adjustment={adjustment_factor}x" ) - @classmethod - def from_camera_profile( - cls, - camera_type: str, - min_exposure: int = 10000, - max_exposure: int = 1000000, - adjustment_factor: float = 1.3, - ) -> "ExposureSNRController": - """ - Create controller with thresholds derived from camera profile. - - Calculates min/target/max background based on bit depth and bias offset. - - Args: - camera_type: Camera type (e.g., "imx296_processed", "imx462_processed") - min_exposure: Minimum exposure in microseconds - max_exposure: Maximum exposure in microseconds - adjustment_factor: Multiplicative adjustment step - - Returns: - ExposureSNRController configured for the camera - """ - from PiFinder.sqm.camera_profiles import get_camera_profile - - profile = get_camera_profile(camera_type) - - # Derive thresholds from camera specs - max_adu = (2**profile.bit_depth) - 1 - bias = profile.bias_offset - - # min_background: bias + margin (2x bias or bias + 8, whichever larger) - min_background = int(max(bias * 2, bias + 8)) - - # max_background: ~40% of full range (avoid saturation/nonlinearity) - max_background = int(max_adu * 0.4) - - # target_background: just above min (lower = shorter exposure = more linear response) - target_background = min_background + 2 - - logger.info( - f"SNR controller from {camera_type}: " - f"bit_depth={profile.bit_depth}, bias={bias:.0f}, " - f"thresholds=[{min_background}, {target_background}, {max_background}]" - ) - - return cls( - min_exposure=min_exposure, - max_exposure=max_exposure, - target_background=target_background, - min_background=min_background, - max_background=max_background, - adjustment_factor=adjustment_factor, - ) - def update( self, current_exposure: int, @@ -304,7 +250,7 @@ def update( background = float(np.percentile(img_array, 10)) logger.debug( - f"SNR AE: bg={background:.1f}, min={min_bg:.1f} ADU, exp={current_exposure/1000:.0f}ms" + f"SNR AE: bg={background:.1f}, min={min_bg:.1f} ADU, exp={current_exposure / 1000:.0f}ms" ) # Determine adjustment @@ -315,14 +261,14 @@ def update( new_exposure = int(current_exposure * self.adjustment_factor) logger.info( f"SNR AE: Background too low ({background:.1f} < {min_bg:.1f}), " - f"increasing exposure {current_exposure/1000:.0f}ms → {new_exposure/1000:.0f}ms" + f"increasing exposure {current_exposure / 1000:.0f}ms → {new_exposure / 1000:.0f}ms" ) elif background > self.max_background: # Too bright - decrease exposure new_exposure = int(current_exposure / self.adjustment_factor) logger.info( f"SNR AE: Background too high ({background:.1f} > {self.max_background}), " - f"decreasing exposure {current_exposure/1000:.0f}ms → {new_exposure/1000:.0f}ms" + f"decreasing exposure {current_exposure / 1000:.0f}ms → {new_exposure / 1000:.0f}ms" ) else: # Background is in acceptable range diff --git a/python/PiFinder/battery_bq25895.py b/python/PiFinder/battery_bq25895.py index 65a2b20da..a62d969d0 100644 --- a/python/PiFinder/battery_bq25895.py +++ b/python/PiFinder/battery_bq25895.py @@ -2,7 +2,7 @@ # -*- coding:utf-8 -*- """ Battery telemetry and fast-charge configuration for the rev-4 PiFinder -board's TI BQ25895 single-cell Li-ion charger (I2C address 0x6A on bus 1). +board's TI BQ25895 single-cell Li-ion charger (I2C address 0x6A). Mostly telemetry: it reads battery voltage, charge status, power source and a few diagnostics. The remaining writes are deliberate and narrow: @@ -40,8 +40,8 @@ Structure note: ``decode_registers`` and ``estimate_soc`` are PURE (no hardware) so the bulk of the logic is unit-testable without a board. -``board`` is imported lazily-guarded so this module — and the pure -pieces ``battery_fake`` reuses — imports cleanly on dev machines. +The I2C bus factory is imported lazily-guarded so this module — and the +pure pieces ``battery_fake`` reuses — imports cleanly on dev machines. """ import logging @@ -51,17 +51,18 @@ from PiFinder.types.hardware import BatteryState, ChargeStatus try: - import board from adafruit_bus_device.i2c_device import I2CDevice + + from PiFinder.i2c_bus import get_i2c except (ImportError, NotImplementedError): # No blinka / not on real hardware: the pure decode helpers and module # constants still import. The BQ25895 class raises on construction. - board = None + get_i2c = None # type: ignore[assignment] I2CDevice = None logger = logging.getLogger("Battery.bq25895") -# I2C address of the BQ25895 (bus 1 on the rev-4 board). +# I2C address of the BQ25895 on the rev-4 board. BQ25895_ADDRESS = 0x6A # --- Register addresses (verified against the datasheet register map) --- @@ -252,10 +253,10 @@ class BQ25895: """ def __init__(self, address: int = BQ25895_ADDRESS, i2c=None): - if board is None or I2CDevice is None: + if get_i2c is None or I2CDevice is None: raise RuntimeError("blinka / board unavailable — no I2C bus") if i2c is None: - i2c = board.I2C() + i2c = get_i2c() self._device = I2CDevice(i2c, address) def read_reg(self, reg: int) -> int: diff --git a/python/PiFinder/camera_interface.py b/python/PiFinder/camera_interface.py index fa03e8a17..b1dd1dbe6 100644 --- a/python/PiFinder/camera_interface.py +++ b/python/PiFinder/camera_interface.py @@ -12,6 +12,7 @@ from typing import Tuple, Optional from PIL import Image import os +import random import time import numpy as np import queue @@ -25,7 +26,6 @@ ExposureSNRController, generate_exposure_sweep, ) -from PiFinder.sqm.camera_profiles import detect_camera_type logger = logging.getLogger("Camera.Interface") @@ -168,8 +168,6 @@ def get_image_loop( shared_state.set_camera_type(camera_type) logger.info(f"Camera type set to: {camera_type}") - debug = False - # Check if auto-exposure was previously enabled in config config_exp = cfg.get_option("camera_exp") if config_exp == "auto": @@ -184,6 +182,17 @@ def get_image_loop( screen_direction = cfg.get_option("screen_direction") camera_rotation = cfg.get_option("camera_rotation") + # Publish the display rotation applied to the solve image (PIL + # CCW degrees, relative to the stored raw frame) so SQM can map + # solve-image centroids back onto the raw for photometry. + if camera_rotation is not None: + solve_rotation = (-int(camera_rotation)) % 360 + elif screen_direction in ["right", "straight", "flat3", "as_bloom"]: + solve_rotation = 90 + else: + solve_rotation = 270 + shared_state.set_solve_image_rotation(solve_rotation) + # Set path for test mode image root_dir = os.path.realpath( os.path.join(os.path.dirname(__file__), "..", "..") @@ -219,7 +228,11 @@ def get_image_loop( imu_start = shared_state.imu() image_start_time = time.time() if self._camera_started: - if not debug: + # Test mode is owned by shared state (persisted in config + # and toggled from the menu), so it stays in sync with the + # UI and survives restarts. + test_mode_on = shared_state.test_mode() + if not test_mode_on: base_image = self._capture_with_timeout() if base_image is None: # Capture hung; fall back to a blank frame so the @@ -269,12 +282,12 @@ def get_image_loop( pointing_diff = 0.0 # Make image available - if debug and abs(pointing_diff) > 0.01: - # Check if we moved and return a blank image + if test_mode_on and abs(pointing_diff) > 0.01: + # Scope moved during the fake exposure: return a blank + # image so the solver doesn't report a stale solve camera_image.paste(self._blank_capture()) else: camera_image.paste(base_image) - image_metadata = { "exposure_start": image_start_time, "exposure_end": image_end_time, @@ -282,6 +295,7 @@ def get_image_loop( "imu_delta": np.rad2deg(pointing_diff), "exposure_time": self.exposure_time, "gain": self.gain, + "sensor_temp_c": getattr(self, "last_sensor_temp", None), } shared_state.set_last_image_metadata(image_metadata) @@ -317,23 +331,12 @@ def get_image_loop( if self._auto_exposure_mode == "snr": # SNR mode: use background-based controller (for SQM measurements) if self._auto_exposure_snr is None: - # Use camera profile to derive thresholds - try: - cam_type = detect_camera_type( - self.get_cam_type() - ) - cam_type = f"{cam_type}_processed" - self._auto_exposure_snr = ExposureSNRController.from_camera_profile( - cam_type - ) - except ValueError as e: - # Unknown camera, use defaults - logger.warning( - f"Camera detection failed: {e}, using default SNR thresholds" - ) - self._auto_exposure_snr = ( - ExposureSNRController() - ) + # Default 8-bit thresholds; the + # controller measures the processed + # display image. + self._auto_exposure_snr = ( + ExposureSNRController() + ) # Get adaptive noise floor from shared state adaptive_noise_floor = ( self.shared_state.noise_floor() @@ -382,12 +385,6 @@ def get_image_loop( logger.error(f"CameraInterface: Command error: {e}") try: - if command == "debug": - if debug: - debug = False - else: - debug = True - if command.startswith("set_exp"): exp_value = command.split(":")[1] if exp_value == "auto": @@ -568,6 +565,12 @@ def get_image_loop( sweep_exposures = generate_exposure_sweep( min_exp, max_exp, num_images ) + # Capture in random order: a monotonic ramp lets any + # temporal sky drift (twilight, passing haze) alias + # into an apparent exposure dependence in analysis. + # Each file name carries its exposure, so order is + # irrelevant downstream. + random.shuffle(sweep_exposures) # Generate timestamp for this sweep session using GPS time gps_time = shared_state.datetime() @@ -575,9 +578,7 @@ def get_image_loop( timestamp = gps_time.strftime("%Y%m%d_%H%M%S") else: # Fallback to Pi time if GPS not available - timestamp = timez.local_now().strftime( - "%Y%m%d_%H%M%S" - ) + timestamp = timez.local_now().strftime("%Y%m%d_%H%M%S") logger.warning( "GPS time not available, using Pi system time for sweep directory name" ) @@ -693,7 +694,8 @@ def get_image_loop( dec_deg=dec_deg, altitude_deg=altitude_deg, azimuth_deg=azimuth_deg, - notes=f"Exposure sweep: {num_images} images, {min_exp/1000:.1f}-{max_exp/1000:.1f}ms", + camera_type=shared_state.camera_type(), + notes=f"Exposure sweep: {num_images} images, {min_exp / 1000:.1f}-{max_exp / 1000:.1f}ms", ) logger.info( f"Successfully saved sweep metadata to {sweep_dir}/sweep_metadata.json" diff --git a/python/PiFinder/camera_pi.py b/python/PiFinder/camera_pi.py index 756dfafb7..dad32cf44 100644 --- a/python/PiFinder/camera_pi.py +++ b/python/PiFinder/camera_pi.py @@ -89,6 +89,9 @@ def capture(self) -> Image.Image: f"Captured frame - Requested: {self.exposure_time}µs/{self.gain}x gain, " f"Actual: {actual_exposure}µs/{actual_gain:.2f}x gain" ) + # Sensor die temperature (diagnostic only): the black level wanders + # ±2 ADU night-to-night and temperature is the prime suspect. + self.last_sensor_temp = metadata.get("SensorTemperature") _request.release() diff --git a/python/PiFinder/catalog_cache.py b/python/PiFinder/catalog_cache.py index b7cb11705..c8a55cac1 100644 --- a/python/PiFinder/catalog_cache.py +++ b/python/PiFinder/catalog_cache.py @@ -23,7 +23,10 @@ # Bump when CompositeObject shape, _create_full_composite_object output, or # the pickled payload structure changes. -CACHE_VERSION = 1 +# v2: CompositeObject gained `list_descriptions` (external observing lists). +# Caches pickled at v1 restore objects without that attribute, crashing +# composed_sections() on the object details screen. +CACHE_VERSION = 2 CACHE_DIR = data_dir / "cache" / "catalogs" PICKLE_PATH = CACHE_DIR / "composite_objects.pkl" diff --git a/python/PiFinder/catalog_imports/catalog_import_utils.py b/python/PiFinder/catalog_imports/catalog_import_utils.py index e828e37b2..b46a4e8f5 100644 --- a/python/PiFinder/catalog_imports/catalog_import_utils.py +++ b/python/PiFinder/catalog_imports/catalog_import_utils.py @@ -261,7 +261,8 @@ def insert_catalog_max_sequence(catalog_name): if result: query = f""" update catalogs set max_sequence = { - dict(result)['MAX(sequence)']} where catalog_code = '{catalog_name}' + dict(result)["MAX(sequence)"] + } where catalog_code = '{catalog_name}' """ db_c.execute(query) conn.commit() @@ -411,7 +412,7 @@ def resolve_object_images(): ORDER BY {priority_case_sql} ) as priority_rank FROM catalog_objects co - WHERE co.catalog_code IN ({','.join(['?'] * len(catalog_priority))}) + WHERE co.catalog_code IN ({",".join(["?"] * len(catalog_priority))}) ) SELECT o.id as object_id, diff --git a/python/PiFinder/catalog_imports/main.py b/python/PiFinder/catalog_imports/main.py index 7fcaa5bae..130988f90 100644 --- a/python/PiFinder/catalog_imports/main.py +++ b/python/PiFinder/catalog_imports/main.py @@ -130,6 +130,14 @@ def main(): conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") conn.execute("PRAGMA journal_mode = DELETE") + # Finalize database for read-only deployment (NixOS) + logging.info("Finalizing database for read-only deployment...") + conn, _ = objects_db.get_conn_cursor() + conn.execute("PRAGMA journal_mode = DELETE") # Required for read-only FS + conn.execute("VACUUM") # Compact database + conn.commit() + logging.info("Database finalization complete") + if __name__ == "__main__": main() diff --git a/python/PiFinder/catalog_imports/specialized_loaders.py b/python/PiFinder/catalog_imports/specialized_loaders.py index e8d68aee1..61fbfdcd5 100644 --- a/python/PiFinder/catalog_imports/specialized_loaders.py +++ b/python/PiFinder/catalog_imports/specialized_loaders.py @@ -612,7 +612,7 @@ def expand(name): for additional in parts[1:]: if additional.isdigit(): # If the additional part is a number, add it directly - expanded_list.append(f"{base_part[:-len(additional)]}{additional}") + expanded_list.append(f"{base_part[: -len(additional)]}{additional}") else: expanded_list.append(additional) else: diff --git a/python/PiFinder/catalog_imports/wds_loader.py b/python/PiFinder/catalog_imports/wds_loader.py index 395f35ec0..983f2f5e8 100644 --- a/python/PiFinder/catalog_imports/wds_loader.py +++ b/python/PiFinder/catalog_imports/wds_loader.py @@ -263,7 +263,7 @@ def handle_multiples(key, values) -> dict: coord_2000 = entry["Coordinates_2000"] coord_arcsec = entry["Coordinates_Arcsec"] logging.error( - f"Empty or invalid RA/DEC detected for WDS object at line {i+1}" + f"Empty or invalid RA/DEC detected for WDS object at line {i + 1}" ) logging.error(f" Coordinates_2000: '{coord_2000}'") logging.error(f" Coordinates_Arcsec: '{coord_arcsec}'") @@ -273,7 +273,7 @@ def handle_multiples(key, values) -> dict: ) logging.error(f" Final RA: {entry['ra']}, DEC: {entry['dec']}") raise ValueError( - f"Invalid RA/DEC coordinates for WDS object at line {i+1}: RA={entry['ra']}, DEC={entry['dec']}" + f"Invalid RA/DEC coordinates for WDS object at line {i + 1}: RA={entry['ra']}, DEC={entry['dec']}" ) # make a dictionary of WDS objects to group duplicates diff --git a/python/PiFinder/comets.py b/python/PiFinder/comets.py index 4621f32a1..2f9819608 100644 --- a/python/PiFinder/comets.py +++ b/python/PiFinder/comets.py @@ -251,8 +251,16 @@ def _calc_comets_vectorized(comets_df: pd.DataFrame, dt) -> Dict[str, Any]: # builder), propagated in a single call -> heliocentric state, AU, # equatorial ICRF, relative to the Sun. kepler = mpc._comet_orbits(comets_df, sf_utils.ts, GM_SUN) - helio_pos = kepler._at(t)[0] - if helio_pos.ndim == 1: # propagate() squeezes a single comet to (3,) + # Skyfield 1.51+ lays the result out as (3, #orbits, #times) but sets + # output_shape from only t1.shape, so a batched orbit only reshapes cleanly + # when the target time is itself shaped (#orbits, 1). Give every comet the + # same target time as an (N, 1) column. Versions through 1.50 squeeze the + # singleton time dimension back out, so accept both return shapes. + t_batched = sf_utils.ts.tt_jd(np.full((len(comets_df), 1), t.tt)) + helio_pos = kepler._at(t_batched)[0] + if helio_pos.ndim == 3: + helio_pos = helio_pos[:, :, 0] + elif helio_pos.ndim == 1: helio_pos = helio_pos[:, np.newaxis] # Sun and observer are single 3-vectors relative to the solar-system diff --git a/python/PiFinder/composite_object.py b/python/PiFinder/composite_object.py index 36fdc39f2..127d007ed 100644 --- a/python/PiFinder/composite_object.py +++ b/python/PiFinder/composite_object.py @@ -298,7 +298,11 @@ def composed_sections(self, extra_descriptions=None, dedup=True) -> list: sections: list = [] seen: set = set() have_list_description = False - for source, desc in self.list_descriptions.items(): + # getattr guard: objects restored from a pre-v2 pickle cache lack this + # field (it isn't applied on unpickle). The cache version bump rebuilds + # such caches, but this keeps the details screen from hard-crashing if a + # stale object ever reaches here. + for source, desc in getattr(self, "list_descriptions", {}).items(): if desc: sections.append((source, desc)) have_list_description = True diff --git a/python/PiFinder/db/objects_db.py b/python/PiFinder/db/objects_db.py index 95eaa3c2b..e2d947739 100644 --- a/python/PiFinder/db/objects_db.py +++ b/python/PiFinder/db/objects_db.py @@ -11,20 +11,7 @@ class ObjectsDatabase(Database): def __init__(self, db_path=utils.pifinder_db): conn, cursor = self.get_database(db_path) super().__init__(conn, cursor, db_path) - - # Performance optimizations for Pi/SD card environments - logging.info("Applying database performance optimizations...") - self.cursor.execute("PRAGMA foreign_keys = ON;") - self.cursor.execute("PRAGMA mmap_size = 268435456;") # 256MB memory mapping - self.cursor.execute("PRAGMA cache_size = -64000;") # 64MB cache (negative = KB) - self.cursor.execute("PRAGMA temp_store = MEMORY;") # Keep temporary data in RAM - self.cursor.execute( - "PRAGMA synchronous = NORMAL;" - ) # Balanced safety/performance - logging.info("Database optimizations applied") - - self.conn.commit() - self.bulk_mode = False # Flag to disable commits during bulk operations + self.bulk_mode = False def create_tables(self): # Create objects table @@ -315,6 +302,53 @@ def get_catalog_objects(self): ) return results + def get_priority_catalog_joined(self, priority_codes=("NGC", "IC", "M")): + """Combined JOIN query: catalog_objects + objects for priority catalogs only.""" + start_time = time.time() + placeholders = ",".join("?" * len(priority_codes)) + self.cursor.execute( + f""" + SELECT co.id, co.object_id, co.catalog_code, co.sequence, co.description, + o.ra, o.dec, o.obj_type, o.const, o.size, o.mag, o.surface_brightness + FROM catalog_objects co + JOIN objects o ON co.object_id = o.id + WHERE co.catalog_code IN ({placeholders}) + """, + priority_codes, + ) + rows = self.cursor.fetchall() + elapsed = time.time() - start_time + logging.info( + f"get_priority_catalog_joined took {elapsed:.2f}s, returned {len(rows)} rows" + ) + return rows + + def get_priority_names(self, priority_codes=("NGC", "IC", "M")): + """Get names only for objects in priority catalogs (much smaller than full names table).""" + start_time = time.time() + placeholders = ",".join("?" * len(priority_codes)) + self.cursor.execute( + f""" + SELECT n.object_id, n.common_name FROM names n + WHERE n.object_id IN ( + SELECT DISTINCT co.object_id FROM catalog_objects co + WHERE co.catalog_code IN ({placeholders}) + ) + """, + priority_codes, + ) + results = self.cursor.fetchall() + name_dict = defaultdict(list) + for object_id, common_name in results: + name_dict[object_id].append(common_name.strip()) + for object_id in name_dict: + name_dict[object_id] = list(set(name_dict[object_id])) + elapsed = time.time() - start_time + logging.info( + f"get_priority_names took {elapsed:.2f}s, {len(results)} rows for {len(name_dict)} objects" + ) + return name_dict + # ---- IMAGES_OBJECTS methods ---- def insert_image_object(self, object_id, image_name): self.cursor.execute( diff --git a/python/PiFinder/db/observations_db.py b/python/PiFinder/db/observations_db.py index 7b532fd7c..33eede870 100644 --- a/python/PiFinder/db/observations_db.py +++ b/python/PiFinder/db/observations_db.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from typing import Tuple +from typing import Optional, Tuple from sqlite3 import Connection, Cursor from PiFinder.db.db import Database import PiFinder.utils as utils @@ -8,7 +8,12 @@ class ObservationsDatabase(Database): - def __init__(self, db_path: Path = utils.observations_db): + def __init__(self, db_path: Optional[Path] = None): + # Resolved at call time, not as a default argument: an import-time + # default captures utils.observations_db before the test sandbox + # patches it, sending writes to the real ~/PiFinder_data. + if db_path is None: + db_path = utils.observations_db new_db = False if not db_path.exists(): new_db = True diff --git a/python/PiFinder/displays.py b/python/PiFinder/displays.py index 9d64be2ca..0db7e30e0 100644 --- a/python/PiFinder/displays.py +++ b/python/PiFinder/displays.py @@ -1,4 +1,5 @@ import functools +import logging from collections import namedtuple import numpy as np @@ -13,6 +14,7 @@ from PiFinder.ui.fonts import Fonts +logger = logging.getLogger("Display") ColorMask = namedtuple("ColorMask", ["mask", "mode"]) RED_RGB: ColorMask = ColorMask(np.array([1, 0, 0]), "RGB") @@ -76,7 +78,6 @@ class DisplayPygame_128(DisplayBase): def __init__(self): from luma.emulator.device import pygame - # init display (SPI hardware) pygame = pygame( width=128, height=128, diff --git a/python/PiFinder/focus.py b/python/PiFinder/focus.py index ec24d6ef0..e848e6379 100644 --- a/python/PiFinder/focus.py +++ b/python/PiFinder/focus.py @@ -21,10 +21,11 @@ """ from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import List, Optional, Sequence, Tuple import numpy as np from scipy import ndimage +from scipy.optimize import linear_sum_assignment @dataclass @@ -56,11 +57,16 @@ class FocusResult: when there is no usable star to measure. n_used: number of detected stars HFD was measured on. background: global background level (ADU) of the frame. - peak: brightest detected-star peak (ADU), or None when nothing detected. - Used as the white point for the display stretch. + peak: brightest detected-star peak (ADU), or None when nothing detected; + retained for frame diagnostics. too_defocused: True when there is clear signal but every blob is larger than the size cap -- i.e. measurable stars exist but are too broad to quantify. Drives the "keep adjusting" hint. + median_fwhm: Median area-equivalent FWHM estimate (px) over the same + stars, for the statistics display. HFD remains the focus metric. + blobs: Brightest detected blobs, including blobs too broad for an HFD + measurement. The Focus screen uses their positions for raw star + cutouts. """ median_hfd: Optional[float] @@ -68,6 +74,8 @@ class FocusResult: background: float peak: Optional[float] too_defocused: bool + median_fwhm: Optional[float] = None + blobs: Tuple[Blob, ...] = () def _estimate_background_noise(np_image: np.ndarray) -> Tuple[float, float]: @@ -116,14 +124,14 @@ def _find_blobs( *, max_blob_px: int, sigma_k: float, -) -> Tuple[List[Blob], int, float, Optional[float]]: +) -> Tuple[List[Blob], List[Blob], float, Optional[float]]: """Label connected regions above the detection threshold. - Returns (usable_blobs, n_oversized, background, brightest_peak) where + Returns (usable_blobs, oversized_blobs, background, brightest_peak) where usable_blobs are blobs at least 2 px in size and no larger than the size cap, - sorted brightest-first. ``n_oversized`` counts blobs that exceed the size cap - (signal present but too defocused to measure). ``brightest_peak`` is the peak - of the brightest blob of any size, or None when nothing was detected. + sorted brightest-first. ``oversized_blobs`` contains signal too defocused to + measure but still useful for the visual focus tiles. ``brightest_peak`` is + the peak of the brightest blob of any size, or None when nothing was detected. """ img = np.asarray(np_image, dtype=np.float32) background, sigma = _estimate_background_noise(img) @@ -137,12 +145,12 @@ def _find_blobs( mask = smoothed > threshold labeled, n_labels = ndimage.label(mask) if n_labels == 0: - return [], 0, background, None + return [], [], background, None slices = ndimage.find_objects(labeled) usable: List[Blob] = [] - n_oversized = 0 + oversized: List[Blob] = [] brightest_peak: Optional[float] = None for label_idx, sl in enumerate(slices, start=1): @@ -166,28 +174,112 @@ def _find_blobs( width = sl[1].stop - sl[1].start extent = int(max(height, width)) - # Too broad to measure usefully -- treat as "too defocused". - if extent > max_blob_px: - n_oversized += 1 - continue - - cy = (sl[0].start + sl[0].stop - 1) / 2.0 - cx = (sl[1].start + sl[1].stop - 1) / 2.0 - local_bg = _local_background(img, cy, cx, extent) - - usable.append( - Blob( - y=cy, - x=cx, - peak=peak, - background=local_bg, - extent=extent, - size_px=size_px, - ) + bbox_cy = (sl[0].start + sl[0].stop - 1) / 2.0 + bbox_cx = (sl[1].start + sl[1].stop - 1) / 2.0 + local_bg = _local_background(img, bbox_cy, bbox_cx, extent) + + # Center the display crop on the star's flux, not on the geometric + # center of its thresholded bounding box. A one-pixel change at the + # threshold boundary otherwise becomes a conspicuous jump after the + # 10x focus enlargement. + weights = np.clip(patch - local_bg, 0.0, None) * region_mask + total_weight = float(weights.sum()) + if total_weight > 0.0: + patch_y, patch_x = np.indices(patch.shape) + cy = sl[0].start + float((patch_y * weights).sum() / total_weight) + cx = sl[1].start + float((patch_x * weights).sum() / total_weight) + local_bg = _local_background(img, cy, cx, extent) + else: + cy, cx = bbox_cy, bbox_cx + blob = Blob( + y=cy, + x=cx, + peak=peak, + background=local_bg, + extent=extent, + size_px=size_px, ) + if extent > max_blob_px: + oversized.append(blob) + else: + usable.append(blob) + usable.sort(key=lambda b: b.peak, reverse=True) - return usable, n_oversized, background, brightest_peak + oversized.sort(key=lambda b: b.peak, reverse=True) + return usable, oversized, background, brightest_peak + + +def track_blobs( + previous: Sequence[Blob], + candidates: Sequence[Blob], + *, + n: int = 4, + max_relative_motion: float = 20.0, + max_candidates: int = 12, +) -> Tuple[Blob, ...]: + """Keep stars in stable slots while allowing the whole image to shift. + + Each previous/current star pair proposes a global translation. For every + proposal, Hungarian assignment measures how well the remaining stars share + that same motion. This uses the relative geometry of the 2--4 star pattern, + so a bump while focusing can move the pattern by any distance without + causing brightness-order swaps between quadrants. Small residual changes + from wind, rotation, and focus breathing are accepted. + + When fewer than two stars can establish relative geometry, selection falls + back to current brightness order. Missing slots are filled with the + brightest unused candidates. + """ + current = tuple(candidates[:max_candidates]) + old = tuple(previous[:n]) + if len(old) < 2 or len(current) < 2: + return current[:n] + + old_xy = np.asarray([(blob.x, blob.y) for blob in old], dtype=np.float64) + current_xy = np.asarray([(blob.x, blob.y) for blob in current], dtype=np.float64) + best_score = None + best_matches = None + + for old_anchor in old_xy: + for current_anchor in current_xy: + translation = current_anchor - old_anchor + predicted = old_xy + translation + distances = np.linalg.norm( + predicted[:, np.newaxis, :] - current_xy[np.newaxis, :, :], axis=2 + ) + rows, columns = linear_sum_assignment(distances) + valid = distances[rows, columns] <= max_relative_motion + match_count = int(valid.sum()) + if match_count < 2: + continue + residual = float(distances[rows[valid], columns[valid]].mean()) + score = (match_count, -residual) + if best_score is None or score > best_score: + best_score = score + best_matches = tuple( + (int(row), int(column)) + for row, column, is_valid in zip(rows, columns, valid) + if is_valid + ) + + if best_matches is None: + return current[:n] + + slots: List[Optional[Blob]] = [None] * min(len(old), n) + used = set() + for old_index, current_index in best_matches: + slots[old_index] = current[current_index] + used.add(current_index) + + unused = (blob for index, blob in enumerate(current) if index not in used) + for index, blob in enumerate(slots): + if blob is None: + slots[index] = next(unused, None) + while len(slots) < min(n, len(current)): + slots.append(next(unused, None)) + + return tuple(blob for blob in slots if blob is not None) def detect_stars( @@ -244,10 +336,36 @@ def half_flux_diameter( return 2.0 * weighted_r / total_flux +def full_width_half_maximum(np_image: np.ndarray, blob: Blob) -> float: + """Area-equivalent FWHM diameter for one detected star, in raw pixels. + + Pixels above half the local peak-minus-background are counted inside a + circular aperture around the blob. The diameter of a circle with that area + equals the analytic FWHM for a circular Gaussian. This is a supplementary + statistic; HFD remains preferable for saturated and defocused stars. + """ + cy, cx = blob.y, blob.x + height, width = np_image.shape + aperture_radius = max(blob.extent, 4) + y_min = max(0, int(cy) - aperture_radius) + y_max = min(height, int(cy) + aperture_radius + 1) + x_min = max(0, int(cx) - aperture_radius) + x_max = min(width, int(cx) + aperture_radius + 1) + + patch = np.asarray(np_image[y_min:y_max, x_min:x_max], dtype=np.float32) + y_grid, x_grid = np.ogrid[y_min:y_max, x_min:x_max] + aperture = (x_grid - cx) ** 2 + (y_grid - cy) ** 2 <= aperture_radius**2 + half_max = blob.background + (blob.peak - blob.background) / 2.0 + area = int(np.count_nonzero((patch >= half_max) & aperture)) + if area == 0: + return 0.0 + return 2.0 * float(np.sqrt(area / np.pi)) + + def focus_hfd( np_image: np.ndarray, *, - n: int = 5, + n: int = 4, max_blob_px: int = 50, sigma_k: float = 5.0, ) -> FocusResult: @@ -257,9 +375,12 @@ def focus_hfd( found; ``too_defocused`` is True when signal is present but every blob is larger than ``max_blob_px``. """ - usable, n_oversized, background, brightest_peak = _find_blobs( + usable, oversized, background, brightest_peak = _find_blobs( np_image, max_blob_px=max_blob_px, sigma_k=sigma_k ) + display_blobs = tuple( + sorted((*usable, *oversized), key=lambda blob: blob.peak, reverse=True) + ) if not usable: # No measurable star. If oversized blobs exist there is signal, but the @@ -269,11 +390,13 @@ def focus_hfd( n_used=0, background=background, peak=brightest_peak, - too_defocused=n_oversized > 0, + too_defocused=bool(oversized), + blobs=display_blobs, ) img = np.asarray(np_image, dtype=np.float32) hfds = [] + fwhms = [] for blob in usable[:n]: aperture_radius = int(np.clip(blob.extent, 10, max_blob_px)) hfd = half_flux_diameter( @@ -284,6 +407,9 @@ def focus_hfd( ) if hfd > 0.0: hfds.append(hfd) + fwhm = full_width_half_maximum(img, blob) + if fwhm > 0.0: + fwhms.append(fwhm) if not hfds: return FocusResult( @@ -291,7 +417,8 @@ def focus_hfd( n_used=0, background=background, peak=brightest_peak, - too_defocused=n_oversized > 0, + too_defocused=bool(oversized), + blobs=display_blobs, ) return FocusResult( @@ -300,4 +427,6 @@ def focus_hfd( background=background, peak=usable[0].peak, too_defocused=False, + median_fwhm=float(np.median(fwhms)) if fwhms else None, + blobs=display_blobs, ) diff --git a/python/PiFinder/gen_images.py b/python/PiFinder/gen_images.py index 10e3ec9a3..63ac568cb 100644 --- a/python/PiFinder/gen_images.py +++ b/python/PiFinder/gen_images.py @@ -59,7 +59,7 @@ def check_sdss_image(image: Image.Image) -> bool: return False black_pixel_count = 0 - for pixel in image.getdata(): + for pixel in cast(List, image.getdata()): if pixel == 0: black_pixel_count += 1 if black_pixel_count > 120000: diff --git a/python/PiFinder/gps_ubx_parser.py b/python/PiFinder/gps_ubx_parser.py index 0c41f41b7..12e4a062e 100644 --- a/python/PiFinder/gps_ubx_parser.py +++ b/python/PiFinder/gps_ubx_parser.py @@ -7,17 +7,24 @@ from PiFinder.multiproclogging import MultiprocLogging import asyncio import aiofiles -from typing import Dict, Callable, Optional, Tuple +from typing import Dict, Callable, Optional, Tuple, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from aiofiles.threadpool.binary import AsyncBufferedReader from dataclasses import dataclass from enum import IntEnum import datetime logger = logging.getLogger("GPS.parser") -# u-blox quality indicator (qualityInd / flags bits 0-2) value at which the -# signal is code locked; below this the receiver is still searching and any -# reported C/N0 is an unconfirmed acquisition candidate. -QUALITY_CODE_LOCKED = 4 +# u-blox quality indicator (qualityInd / flags bits 0-2) scale: 0 no signal, +# 1 searching, 2 signal acquired, 3 signal detected but unusable, 4 code +# locked, 5-7 code and carrier locked. At 1 the receiver is still searching +# and any reported C/N0 is only an estimate for a candidate — counting those +# makes the seen count start near the channel count and sink; counting only +# code-locked (>= 4) makes it flap to zero during marginal re-acquisition. +# "Signal acquired" is the honest definition of a satellite being seen. +QUALITY_SIGNAL_ACQUIRED = 2 class UBXClass(IntEnum): @@ -55,7 +62,9 @@ class UBXParser: def __init__( self, log_queue, - reader: Optional[asyncio.StreamReader] = None, + # StreamReader when connected to gpsd, AsyncBufferedReader when + # replaying a capture file (from_file); both only need .read(). + reader: Optional[Union[asyncio.StreamReader, "AsyncBufferedReader"]] = None, writer: Optional[asyncio.StreamWriter] = None, file_path: Optional[str] = None, ): @@ -164,7 +173,7 @@ async def connect(cls, log_queue, host="127.0.0.1", port=2947, max_attempts=5): async def from_file(cls, file_path: str): """Create a UBXParser instance from a file.""" f = await aiofiles.open(file_path, "rb") - return cls(log_queue=None, reader=f, file_path=file_path) # type:ignore[arg-type] + return cls(log_queue=None, reader=f, file_path=file_path) async def close(self): """Clean up resources and close the connection.""" @@ -321,9 +330,7 @@ def _parse_nav_sat(self, data: bytes) -> dict: azim = int.from_bytes(data[offset + 4 : offset + 6], "little", signed=True) flags = data[offset + 8] # X4 bitfield, only the first byte is needed here: # bits 0-2 are the quality indicator, bit 3 is svUsed - # NAV-SAT lists every known satellite, including acquisition - # candidates with an estimated C/N0; only count tracked signals - if (flags & 0x07) >= QUALITY_CODE_LOCKED: + if cno > 0 and (flags & 0x07) >= QUALITY_SIGNAL_ACQUIRED: satellites.append( { "id": svId, @@ -374,11 +381,7 @@ def _parse_nav_svinfo(self, data: bytes) -> dict: if is_used: used_sats += 1 - # During cold-start acquisition the receiver reports estimated - # C/N0 for candidates it is still searching for; counting those - # makes the seen count start high and sink as they fail to - # confirm. Only quality >= 4 (code locked) is really tracked. - if quality >= QUALITY_CODE_LOCKED: + if cno > 0 and quality >= QUALITY_SIGNAL_ACQUIRED: satellites.append( { "id": svid, diff --git a/python/PiFinder/hardware_detect.py b/python/PiFinder/hardware_detect.py index 58aaa488e..1eb6d224e 100644 --- a/python/PiFinder/hardware_detect.py +++ b/python/PiFinder/hardware_detect.py @@ -9,8 +9,8 @@ BQ25895 charger?". The battery monitor only spawns when the charger is detected. -Import-safe on dev machines: ``board`` is imported under try/except so -this module loads even without blinka / an I2C bus. +Import-safe on dev machines: the I2C bus factory is imported under +try/except so this module loads even without blinka / an I2C bus. """ import logging @@ -18,17 +18,17 @@ from PiFinder.types.hardware import HardwareCapabilities try: - import board + from PiFinder.i2c_bus import get_i2c except (ImportError, NotImplementedError): - board = None + get_i2c = None # type: ignore[assignment] logger = logging.getLogger("HardwareDetect") -# BQ25895 single-cell Li-ion charger, I2C address 0x6A on bus 1. +# BQ25895 single-cell Li-ion charger, I2C address 0x6A. BQ25895_ADDRESS = 0x6A -def i2c_present(address: int, bus: int = 1) -> bool: +def i2c_present(address: int) -> bool: """Non-destructive I2C presence check: does ``address`` ACK on the bus? @@ -41,10 +41,10 @@ def i2c_present(address: int, bus: int = 1) -> bool: Raises if no I2C bus is available (no blinka); callers that want a soft answer should catch. """ - if board is None: + if get_i2c is None: raise RuntimeError("blinka / board unavailable — no I2C bus") - i2c = board.I2C() + i2c = get_i2c() locked = False try: while not i2c.try_lock(): @@ -66,6 +66,10 @@ def detect_capabilities() -> HardwareCapabilities: a bare GPIO piezo (PWM ch0) that can't be probed directly, so the BQ25895 ACK is the rev-4 marker that implies both (see CONTEXT-MAP "Sound → system-wide"). + + The BQ25895 ACK is a valid whole-board rev-4 marker because a rev-4 + always has a battery installed (mandatory by design); an unpowered + charger that fails to ACK only occurs on bare prototype boards. """ try: present = i2c_present(BQ25895_ADDRESS) diff --git a/python/PiFinder/i2c_bus.py b/python/PiFinder/i2c_bus.py new file mode 100644 index 000000000..4232bc1a3 --- /dev/null +++ b/python/PiFinder/i2c_bus.py @@ -0,0 +1,62 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +I2C bus selection for PiFinder peripherals (BNO055 IMU, BQ25895 charger). + +The BCM2835/BCM2711 hardware I2C block (Pi 4 and earlier) has a +well-documented silicon bug: when a slave stretches the clock — which the +BNO055 does on almost every transaction — the controller can emit a +too-short SCL pulse and corrupt the transfer. Boards affected by that bug +provision a software (bit-banged) i2c-gpio bus on the same SDA/SCL pins +via a device-tree overlay; i2c-gpio implements clock stretching per spec. + +Which bus exists is a boot-configuration decision (the per-board NixOS +hardware profile). This module hands out whatever the device tree +provides: the i2c-gpio adapter when one is present, the default hardware +bus otherwise. Boards with a correct I2C controller (Pi 5 / CM5 with RP1) +simply don't provision the overlay and get the hardware bus. +""" + +import glob +import logging + +import board +from adafruit_extended_bus import ExtendedI2C + +logger = logging.getLogger("I2C") + + +def _is_gpio_adapter(adapter_dir: str) -> bool: + """Return True when the sysfs adapter dir belongs to an i2c-gpio bus. + + Checks the adapter name first, then the platform device's device-tree + compatible string (the adapter dir's parent) — the name format has + varied across kernel versions, the compatible string has not. + """ + try: + with open(adapter_dir + "/name") as handle: + if handle.read().strip().startswith("i2c-gpio"): + return True + except OSError: + pass + try: + with open(adapter_dir + "/../of_node/compatible", "rb") as handle: + return b"i2c-gpio" in handle.read() + except OSError: + return False + + +def get_i2c(): + """Return the I2C bus object for PiFinder peripherals. + + Prefers a bit-banged i2c-gpio adapter when the device tree provides + one; falls back to the default hardware bus (``board.I2C()``). + """ + for name_path in sorted(glob.glob("/sys/bus/i2c/devices/i2c-*/name")): + adapter_dir = name_path.rsplit("/", 1)[0] + if _is_gpio_adapter(adapter_dir): + bus_number = int(adapter_dir.rsplit("/i2c-", 1)[1]) + logger.info("Using i2c-gpio bus /dev/i2c-%d", bus_number) + return ExtendedI2C(bus_number) + logger.info("Using default hardware I2C bus") + return board.I2C() diff --git a/python/PiFinder/image_util.py b/python/PiFinder/image_util.py index cf1d1b4fe..6768e82bd 100644 --- a/python/PiFinder/image_util.py +++ b/python/PiFinder/image_util.py @@ -10,7 +10,6 @@ from PIL import Image, ImageChops import numpy as np -import scipy.ndimage def make_red(in_image, colors): @@ -37,6 +36,8 @@ def gamma_correct(in_value, gamma): def subtract_background(image, percent=1): + import scipy.ndimage + image = np.asarray(image, dtype=np.float32) if image.ndim == 3: assert image.shape[2] in (1, 3), "Colour image must have 1 or 3 colour channels" diff --git a/python/PiFinder/imu_pi.py b/python/PiFinder/imu_pi.py index adc2a85f3..348635ebb 100644 --- a/python/PiFinder/imu_pi.py +++ b/python/PiFinder/imu_pi.py @@ -9,7 +9,7 @@ from PiFinder import config from PiFinder.multiproclogging import MultiprocLogging from PiFinder.types.positioning import ImuSample -import board +from PiFinder.i2c_bus import get_i2c import adafruit_bno055 import logging import quaternion # Numpy quaternion @@ -27,7 +27,7 @@ class Imu: """ def __init__(self): - i2c = board.I2C() + i2c = get_i2c() self.sensor = adafruit_bno055.BNO055_I2C(i2c) # IMPLUS mode: Accelerometer + Gyro + Fusion data self.sensor.mode = adafruit_bno055.IMUPLUS_MODE @@ -197,9 +197,33 @@ def imu_monitor(shared_state, console_queue, log_queue): # fake-IMU fallback has no such attr (and self-throttles), hence the default. sample_period = getattr(imu, "imu_sample_frequency", 1 / 30) + # A transient bus error (e.g. the BCM2711 clock-stretching bug NACKing + # a transfer) must not kill the IMU process: skip the sample and retry. + # Only a persistent failure warrants re-creating Imu() — construction + # re-opens the bus and restores IMUPLUS mode, which matters because a + # power-glitched BNO055 wakes up in CONFIG mode. + reinit_after_errors = 60 # consecutive errors (~2 s at 30 Hz) + consecutive_errors = 0 + while True: loop_start = time.monotonic() - imu.update() + try: + imu.update() + consecutive_errors = 0 + except (OSError, RuntimeError) as e: + consecutive_errors += 1 + if consecutive_errors == 1: + logger.warning("IMU: bus error, retrying: %s", e) + if consecutive_errors >= reinit_after_errors: + logger.error("IMU: persistent bus errors, re-initialising sensor") + console_queue.put("IMU: bus errors, re-initialising") + try: + imu = Imu() + except (OSError, RuntimeError) as reinit_error: + logger.error("IMU: re-init failed: %s", reinit_error) + consecutive_errors = 0 + time.sleep(sample_period) + continue imu_sample.status = imu.calibration # Raw data + read epoch are captured by imu.update() in the same diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index 6755897da..1bc512f7b 100644 --- a/python/PiFinder/main.py +++ b/python/PiFinder/main.py @@ -22,7 +22,9 @@ import datetime import json import uuid +import sys import logging +import traceback import argparse import pickle from pathlib import Path @@ -120,6 +122,31 @@ def set_brightness(level, cfg): set_keypad_brightness(level * 0.05 * keypad_offsets[keypad_brightness]) +def apply_test_mode_gps(shared_state, location, console): + """ + Fake GPS fix + datetime used while test mode is active. + Called on toggle-ON and at startup when test_mode was persisted, + so a reboot in test mode comes back fully in test mode. + """ + dt = timez.utc(2025, 6, 28, 11, 0, 0) + shared_state.set_datetime(dt) + location.lat = 41.13 + location.lon = -120.97 + location.altitude = 1315 + location.source = "test" + location.error_in_m = 5 + location.lock = True + location.lock_type = 3 + location.last_gps_lock = timez.local_now().time().isoformat()[:8] + console.write(f"GPS: Location {location.lat} {location.lon} {location.altitude}") + shared_state.set_location(location) + sf_utils.set_location( + location.lat, + location.lon, + location.altitude, + ) + + def setup_dirs(): utils.create_path(Path(utils.data_dir)) utils.create_path(Path(utils.data_dir, "captures")) @@ -149,6 +176,8 @@ def __init__(self, cfg, shared_state, display_device): self.shared_state = shared_state self.display_device = display_device self.last_activity = time.time() + self.sleep_start_time = None + self.screen_off_start_time = None def register_activity(self): """ @@ -158,8 +187,9 @@ def register_activity(self): self.last_activity = time.time() # power states - # 0 = Sleep - # 1 = Wake + # -1 = Screen off + # 0 = Sleep + # 1 = Wake if self.shared_state.power_state() < 1: # wake up self.wake_up() @@ -172,6 +202,8 @@ def wake_up(self): Do all the wakeup things """ self.last_activity = time.time() + self.sleep_start_time = None + self.screen_off_start_time = None self.shared_state.set_power_state(1) self.wake_screen() @@ -180,6 +212,7 @@ def go_to_sleep(self): Do all the sleep things """ self.shared_state.set_power_state(0) + self.sleep_start_time = time.time() self.sleep_screen() def update(self): @@ -198,11 +231,36 @@ def update(self): if time.time() - self.last_activity > self.get_sleep_timeout(): self.go_to_sleep() - else: # We are asleepd, should we wake up? + elif self.shared_state.power_state() == 0: + # We are asleep, should we wake up or go to screen off? _imu = self.shared_state.imu() if _imu: if _imu.moving: self.wake_up() + return + + # Check if we should turn screen off + screen_off_timeout = self.get_screen_off_timeout() + if ( + screen_off_timeout > 0 + and self.sleep_start_time is not None + and time.time() - self.sleep_start_time > screen_off_timeout + ): + self.screen_off() + + # Screen off mode: LED heartbeat, longer sleep + if self.shared_state.power_state() == -1: + _imu = self.shared_state.imu() + if _imu and _imu.moving: + self.wake_up() + return + self.update_heartbeat() + time.sleep(1.0) + return + + # should we pause execution for a bit? + if self.shared_state.power_state() < 1: + time.sleep(0.2) def get_sleep_timeout(self): """ @@ -243,6 +301,23 @@ def sleep_screen(self): set_brightness(int(screen_brightness / 4), self.cfg) self.display_device.device.show() + def screen_off(self): + """Completely blank screen and turn off LEDs""" + self.shared_state.set_power_state(-1) + self.screen_off_start_time = time.time() + self.display_device.device.hide() + set_keypad_brightness(0) + + def update_heartbeat(self): + """Pulse all LEDs briefly every hour""" + if self.screen_off_start_time is None: + return + seconds_into_hour = (time.time() - self.screen_off_start_time) % 3600 + if seconds_into_hour < 0.5: + set_keypad_brightness(2) + else: + set_keypad_brightness(0) + def start_profiling(): """Start profiling for performance analysis""" @@ -440,14 +515,31 @@ def main( shared_state.set_ui_state(ui_state) shared_state.set_arch(arch) # Normal shared_state.set_hardware(capabilities) + # Initialize test_mode from config so camera process can read it at startup + shared_state.set_test_mode(cfg.get_option("test_mode", False)) logger.debug("Ui state in main is" + str(shared_state.ui_state())) console = UIConsole( display_device, None, shared_state, command_queues, cfg, Catalogs([]) ) + if shared_state.test_mode(): + apply_test_mode_gps(shared_state, location, console) console.write("Starting....") console.update() logger.info("Starting ....") + # One-shot notice from the boot watchdog: a failed upgrade was + # auto-rolled-back to this (previous) generation. + upgrade_failed_notice = utils.data_dir / "upgrade_failed.json" + if upgrade_failed_notice.exists(): + console.write("!! Update failed") + console.write("!! Rolled back") + console.update() + logger.warning("Previous upgrade failed; watchdog rolled back") + try: + upgrade_failed_notice.unlink() + except OSError: + pass + # spawn gps service.... console.write(" GPS") console.update() @@ -654,6 +746,12 @@ def main( logger.info(" Event Loop") console.update() + # Everything is constructed and the display is live: declare readiness + # to systemd. This is the health signal the boot watchdog keys off — + # a build that dies before this line never reports READY and fails its + # trial. No-op outside systemd (development runs). + utils.sd_notify("READY=1") + # Stop profiling (uncomment to analyze startup performance) # stop_profiling(profiler, startup_profile_start) @@ -799,27 +897,17 @@ def main( catalogs.catalog_filter.mark_dirty() menu_manager.message(_("Catalogs\nFully Loaded"), 2) elif ui_command == "test_mode": - dt = timez.utc(2025, 6, 28, 11, 0, 0) - shared_state.set_datetime(dt) - location.lat = 41.13 - location.lon = -120.97 - location.altitude = 1315 - location.source = "test" - location.error_in_m = 5 - location.lock = True - location.lock_type = 3 - location.last_gps_lock = ( - timez.local_now().time().isoformat()[:8] - ) - console.write( - f"GPS: Location {location.lat} {location.lon} {location.altitude}" - ) - shared_state.set_location(location) - sf_utils.set_location( - location.lat, - location.lon, - location.altitude, - ) + # Toggle test mode (store in both shared_state and config). + # The camera process follows shared_state.test_mode() + # directly, so this is the single point of control. + new_test_mode = not cfg.get_option("test_mode", False) + shared_state.set_test_mode(new_test_mode) + cfg.set_option("test_mode", new_test_mode) + if new_test_mode: + apply_test_mode_gps(shared_state, location, console) + menu_manager.message(_("Test Mode ON\nfake cam+GPS"), 2) + else: + menu_manager.message(_("Test Mode\nOFF"), 2) elif ui_command == "set_volume": # Master volume changed in the menu: re-push the level # (main owns both cfg and sound_queue). The player plays @@ -1062,11 +1150,6 @@ def main( if __name__ == "__main__": import sys - # Ensure the active log config symlink exists, defaulting to logconf_default.json - _logconf_link = Path("pifinder_logconf.json") - if not _logconf_link.exists(): - _logconf_link.symlink_to("logconf_default.json") - debug_no_file_logs = "--debug-no-file-logs" in sys.argv if debug_no_file_logs: os.environ["PIFINDER_DEBUG_NO_FILE_LOGS"] = "1" @@ -1077,13 +1160,13 @@ def main( rlogger.setLevel(logging.DEBUG if debug_no_file_logs else logging.INFO) if debug_no_file_logs: - log_helper = MultiprocLogging(Path("pifinder_logconf.json"), console_only=True) + log_helper = MultiprocLogging(utils.active_logconf_path(), console_only=True) MultiprocLogging.configurer(log_helper.get_queue()) else: log_path = utils.data_dir / "pifinder.log" try: log_helper = MultiprocLogging( - Path("pifinder_logconf.json"), + utils.active_logconf_path(), log_path, ) MultiprocLogging.configurer(log_helper.get_queue()) @@ -1287,4 +1370,11 @@ def main( main(log_helper, args.script, args.fps, args.verbose, args.profile_startup) except Exception: rlogger.exception("Exception in main(). Aborting program.") + # Logging is multiprocess (QueueHandler -> listener); os._exit() below + # can kill this process before the queued traceback is ever written to + # the log file. Write it straight to stderr (captured by the journal) + # and flush every handler so the cause is never lost on a hard abort. + traceback.print_exc() + sys.stderr.flush() + logging.shutdown() os._exit(1) diff --git a/python/PiFinder/multiproclogging.py b/python/PiFinder/multiproclogging.py index 46c780a12..9e150af51 100644 --- a/python/PiFinder/multiproclogging.py +++ b/python/PiFinder/multiproclogging.py @@ -10,7 +10,6 @@ import multiprocessing.queues from pathlib import Path from multiprocessing import Queue, Process -import multiprocessing from queue import Empty from time import sleep from typing import TextIO, List, Optional @@ -19,6 +18,8 @@ import logging.config import logging.handlers +from PiFinder import utils + class MultiprocLogging: """ @@ -190,7 +191,7 @@ def configurer(queue: Queue): queue, multiprocessing.queues.Queue ), "That's not a Queue! You have to pass a queue" - log_conf_file = Path("pifinder_logconf.json") + log_conf_file = utils.active_logconf_path() with open(log_conf_file, "r") as logconf: config = json5.load(logconf) logging.config.dictConfig(config) diff --git a/python/PiFinder/nearby.py b/python/PiFinder/nearby.py index ea7c36a9a..84e703c59 100644 --- a/python/PiFinder/nearby.py +++ b/python/PiFinder/nearby.py @@ -2,7 +2,6 @@ from typing import List import time import numpy as np -from sklearn.neighbors import BallTree import logging logger = logging.getLogger("Catalog.Nearby") @@ -80,6 +79,8 @@ def calculate_objects_balltree(self, objects: list[CompositeObject]) -> None: object_radecs = np.array( [[np.deg2rad(x.ra), np.deg2rad(x.dec)] for x in deduplicated_objects] ) + from sklearn.neighbors import BallTree + self._objects = np.array(deduplicated_objects) self._objects_balltree = BallTree( object_radecs, leaf_size=20, metric="haversine" diff --git a/python/PiFinder/net_policy.py b/python/PiFinder/net_policy.py new file mode 100644 index 000000000..10b9284f7 --- /dev/null +++ b/python/PiFinder/net_policy.py @@ -0,0 +1,206 @@ +"""Network policy daemon: wired -> wifi client -> access point. + +Event-driven via NetworkManager's GObject API (libnm) — the same API +sys_utils uses — plus a slow tick for the time-based rules. Replaces the +nmcli-parsing shell fallback, whose "wifi connected" check matched the AP +itself, so once the AP was up nothing ever retried the client network. + +The decision logic lives in net_policy_core (pure, unit-tested); this module +only observes NetworkManager and executes the returned actions. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import time +from pathlib import Path +from typing import Optional + +import gi + +gi.require_version("NM", "1.0") +from gi.repository import GLib, NM # noqa: E402 + +from PiFinder.net_policy_core import ( # noqa: E402 + AP_DOWN, + AP_UP, + PolicyState, + Snapshot, + decide, +) + +logger = logging.getLogger("NetPolicy") + +AP_CONNECTION_NAME = "PiFinder-AP" +WIFI_MODE_FILE = ( + Path(os.environ.get("PIFINDER_DATA", "/home/pifinder/PiFinder_data")) / "wifi_mode" +) + +# Safety-net re-evaluation cadence; NM signals are the primary trigger. +TICK_SECONDS = 10 +# Collapse bursts of NM signals into one evaluation. +DEBOUNCE_SECONDS = 2 + + +def _read_forced_ap() -> bool: + try: + return WIFI_MODE_FILE.read_text().strip() == "AP" + except OSError: + return False + + +def _count_ap_stations(iface: str) -> int: + """Associated stations on the AP interface, via `iw` (libnm has no API + for AP client lists). Errs toward "occupied" so a counting failure never + causes the retry logic to yank a possibly-used AP.""" + try: + out = subprocess.run( + ["iw", "dev", iface, "station", "dump"], + capture_output=True, + text=True, + timeout=10, + ).stdout + except (OSError, subprocess.SubprocessError): + return 1 + return sum(1 for line in out.splitlines() if line.startswith("Station")) + + +class NetPolicyDaemon: + def __init__(self) -> None: + self._client = NM.Client.new(None) + self._state = PolicyState() + self._debounce_id: Optional[int] = None + + self._client.connect("notify::active-connections", self._on_change) + self._client.connect("device-added", self._on_device_added) + self._client.connect("device-removed", self._on_change) + for dev in self._client.get_devices(): + self._hook_device(dev) + + GLib.timeout_add_seconds(TICK_SECONDS, self._on_tick) + self._evaluate() + + # -- NM signal plumbing -------------------------------------------------- + + def _hook_device(self, dev: NM.Device) -> None: + dev.connect("state-changed", self._on_change) + + def _on_device_added(self, _client, dev) -> None: + self._hook_device(dev) + self._schedule_evaluate() + + def _on_change(self, *_args) -> None: + self._schedule_evaluate() + + def _on_tick(self) -> bool: + self._evaluate() + return True # keep the tick alive + + def _schedule_evaluate(self) -> None: + if self._debounce_id is not None: + GLib.source_remove(self._debounce_id) + self._debounce_id = GLib.timeout_add_seconds( + DEBOUNCE_SECONDS, self._debounced_evaluate + ) + + def _debounced_evaluate(self) -> bool: + self._debounce_id = None + self._evaluate() + return False # one-shot + + # -- state observation --------------------------------------------------- + + def _wifi_iface(self) -> Optional[str]: + for dev in self._client.get_devices(): + if dev.get_device_type() == NM.DeviceType.WIFI: + return dev.get_iface() + return None + + def _snapshot(self) -> Snapshot: + eth_connected = False + wifi_client_active = False + ap_active = False + + for ac in self._client.get_active_connections(): + if ac.get_state() != NM.ActiveConnectionState.ACTIVATED: + continue + conn_type = ac.get_connection_type() + if conn_type == "802-3-ethernet": + eth_connected = True + elif conn_type == "802-11-wireless": + if ac.get_id() == AP_CONNECTION_NAME: + ap_active = True + else: + wifi_client_active = True + + ap_stations = 0 + if ap_active: + iface = self._wifi_iface() + ap_stations = _count_ap_stations(iface) if iface else 1 + + return Snapshot( + forced_ap=_read_forced_ap(), + eth_connected=eth_connected, + wifi_client_active=wifi_client_active, + ap_active=ap_active, + ap_stations=ap_stations, + ) + + # -- actions --------------------------------------------------------------- + + def _evaluate(self) -> None: + snap = self._snapshot() + action = decide(snap, self._state, time.monotonic()) + if action == AP_UP: + logger.info("no connectivity after grace period — bringing AP up") + self._ap_up() + elif action == AP_DOWN: + reason = ( + "wired connectivity present" + if snap.eth_connected + else ("idle AP — retrying client network") + ) + logger.info("%s — bringing AP down", reason) + self._ap_down() + + def _ap_up(self) -> None: + conn = None + for c in self._client.get_connections(): + if c.get_id() == AP_CONNECTION_NAME: + conn = c + break + if conn is None: + logger.error("AP connection %r not found", AP_CONNECTION_NAME) + return + self._client.activate_connection_async( + conn, None, None, None, self._on_action_done, "activate" + ) + + def _ap_down(self) -> None: + for ac in self._client.get_active_connections(): + if ac.get_id() == AP_CONNECTION_NAME: + self._client.deactivate_connection_async( + ac, None, self._on_action_done, "deactivate" + ) + return + + def _on_action_done(self, client, result, verb) -> None: + try: + if verb == "activate": + client.activate_connection_finish(result) + else: + client.deactivate_connection_finish(result) + except GLib.Error as e: + logger.warning("AP %s failed: %s", verb, e.message) + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") + NetPolicyDaemon() + GLib.MainLoop().run() + + +if __name__ == "__main__": + main() diff --git a/python/PiFinder/net_policy_core.py b/python/PiFinder/net_policy_core.py new file mode 100644 index 000000000..5b59547bd --- /dev/null +++ b/python/PiFinder/net_policy_core.py @@ -0,0 +1,95 @@ +"""Decision core for the PiFinder network policy daemon. + +Pure logic with no NetworkManager dependency, so it is unit-testable on any +machine. The daemon (net_policy.py) feeds it snapshots of the current network +state and executes the actions it returns. + +Connectivity priority: wired -> wifi client -> access point. The AP is a +fallback for reaching an otherwise-offline device, never a preference: with a +cable plugged in or a client network joined, the AP stays down. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +# How long NetworkManager gets to join a known client network before the AP +# comes up. Also applies after an idle-AP retry drops the AP. +GRACE_SECONDS = 45 + +# While the AP is up with nobody connected to it, drop it this often so NM can +# rescan and rejoin a client network that has come into range. Without this +# the AP is sticky: an active AP counts as "wifi connected", so nothing ever +# retries the client network. +CLIENT_RETRY_SECONDS = 300 + +AP_UP = "ap_up" +AP_DOWN = "ap_down" + + +@dataclass +class Snapshot: + """Point-in-time network state, as observed by the daemon.""" + + forced_ap: bool # operator forced AP mode via PiFinder_data/wifi_mode + eth_connected: bool # an ethernet connection is activated + wifi_client_active: bool # a non-AP wifi connection is activated + ap_active: bool # the PiFinder-AP connection is activated + ap_stations: int # clients associated to the AP (0 when ap inactive) + + +@dataclass +class PolicyState: + """Timing state carried between decisions.""" + + disconnected_since: Optional[float] = None + last_client_retry: Optional[float] = None + + +def decide(snap: Snapshot, state: PolicyState, now: float) -> Optional[str]: + """Return the action to take (AP_UP / AP_DOWN / None), updating `state`. + + `now` is a monotonic timestamp supplied by the caller. + """ + if snap.forced_ap: + state.disconnected_since = None + return None if snap.ap_active else AP_UP + + if snap.eth_connected: + # Wired connectivity is sufficient: the device is reachable and + # online. Dropping the AP frees the radio so NM autoconnects to a + # client network whenever one appears. + state.disconnected_since = None + return AP_DOWN if snap.ap_active else None + + if snap.wifi_client_active: + state.disconnected_since = None + return None + + if snap.ap_active: + # Offline fallback is serving. Periodically drop an *idle* AP so NM + # can retry the client network; the grace path below restores the AP + # if nothing joins. Never yanks the AP away from a connected user. + if state.last_client_retry is None: + state.last_client_retry = now + return None + if ( + snap.ap_stations == 0 + and now - state.last_client_retry >= CLIENT_RETRY_SECONDS + ): + state.last_client_retry = now + state.disconnected_since = now + return AP_DOWN + return None + + # Fully disconnected: give NM a grace period to join a known client + # network, then bring up the AP. + if state.disconnected_since is None: + state.disconnected_since = now + return None + if now - state.disconnected_since >= GRACE_SECONDS: + state.disconnected_since = None + state.last_client_retry = now + return AP_UP + return None diff --git a/python/PiFinder/nixos_migration_wifi.py b/python/PiFinder/nixos_migration_wifi.py deleted file mode 100644 index 03b0e688e..000000000 --- a/python/PiFinder/nixos_migration_wifi.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Convert wpa_supplicant.conf to NetworkManager keyfiles. - -Runs during the pre-migration phase on Debian, before reboot into the -initramfs. Keyfiles get staged into the initramfs build dir and the -init script just copies them into the new rootfs — much safer than -generating them in busybox shell after the rootfs has been formatted. -""" - -from __future__ import annotations - -import argparse -import os -import re -import sys -import uuid -from pathlib import Path -from typing import Callable, Iterable, List, Optional - - -WPA_NETWORK_OPEN = re.compile(r"^\s*network\s*=\s*\{") -WPA_NETWORK_CLOSE = re.compile(r"^\s*\}") -WPA_KEY_VALUE = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*$") - -HEX_PSK_RE = re.compile(r"^[0-9a-fA-F]{64}$") -HEX_STRING_RE = re.compile(r"^(?:[0-9a-fA-F]{2})+$") - - -class Network: - __slots__ = ("ssid", "psk") - - def __init__(self, ssid: str, psk: Optional[str]) -> None: - self.ssid = ssid - self.psk = psk - - def __eq__(self, other: object) -> bool: - return ( - isinstance(other, Network) - and self.ssid == other.ssid - and self.psk == other.psk - ) - - def __repr__(self) -> str: - psk_repr = "None" if self.psk is None else f"<{len(self.psk)}c>" - return f"Network(ssid={self.ssid!r}, psk={psk_repr})" - - -def _unquote(value: str) -> str: - """Strip a single surrounding pair of double quotes if present.""" - if len(value) >= 2 and value.startswith('"') and value.endswith('"'): - return value[1:-1] - return value - - -def _parse_ssid(value: str) -> str: - """Decode a wpa_supplicant ssid value. - - Quoted values are plain strings. Unquoted values are hex-encoded byte - strings per wpa_supplicant syntax — decode them here, or the network - name ends up mangled; surrogateescape keeps non-UTF-8 SSID bytes - round-trippable into the keyfile byte list. - """ - if len(value) >= 2 and value.startswith('"') and value.endswith('"'): - return value[1:-1] - if HEX_STRING_RE.fullmatch(value): - return bytes.fromhex(value).decode("utf-8", "surrogateescape") - return value - - -def parse_wpa_supplicant_conf(text: str) -> List[Network]: - """Parse the subset of wpa_supplicant.conf we care about. - - Recognises `network={ ... }` blocks containing `ssid=` and `psk=`. - Quoted values get their outer quotes stripped. Unquoted PSKs (the - 64-hex-char pre-shared-key form) are kept verbatim — NetworkManager - accepts both. Networks without an SSID are skipped. - - Returns the networks in declaration order. - """ - networks: List[Network] = [] - in_net = False - ssid: Optional[str] = None - psk: Optional[str] = None - - for raw_line in text.splitlines(): - line = raw_line.split("#", 1)[0].strip() - if not line: - continue - - if WPA_NETWORK_OPEN.match(line): - in_net = True - ssid = None - psk = None - continue - - if WPA_NETWORK_CLOSE.match(line): - if in_net and ssid is not None: - networks.append(Network(ssid=ssid, psk=psk)) - in_net = False - ssid = None - psk = None - continue - - if not in_net: - continue - - match = WPA_KEY_VALUE.match(line) - if not match: - continue - key, value = match.group(1), match.group(2) - if key == "ssid": - ssid = _parse_ssid(value) - elif key == "psk": - psk = _unquote(value) - - return networks - - -def ssid_to_bytelist(ssid: str) -> str: - """Encode an SSID as a NetworkManager keyfile byte list (`97;112;...`). - - NM's keyfile format documents exactly two ssid forms: a plain string and - a semicolon-separated list of DECIMAL byte values. Anything else (hex, - 0x-prefixed or not) is silently kept as a literal-string SSID, mangling - the network name so the device can never join it. surrogateescape - restores non-UTF-8 bytes captured from wpa_supplicant's hex ssid form. - """ - return "".join(f"{b};" for b in ssid.encode("utf-8", "surrogateescape")) - - -def escape_keyfile_value(value: str) -> str: - """Escape characters that have meaning in NM keyfile values. - - Backslash and semicolon are the only special characters in a plain - string value. (The byte-list format uses the semicolon as separator, - but we don't use that for the PSK.) - """ - return value.replace("\\", "\\\\").replace(";", "\\;") - - -_SAFE_FN_CHARS = re.compile(r"[^A-Za-z0-9._-]") - - -def sanitize_filename(ssid: str) -> str: - """Build a safe filename for a connection keyfile from an SSID. - - Non-alphanumeric characters (except `.`, `_`, `-`) are replaced with - `_`. The result is also guarded against the empty string and `.`/`..` - so a hostile or odd SSID can't escape the connections directory. - """ - cleaned = _SAFE_FN_CHARS.sub("_", ssid) - if cleaned in ("", ".", ".."): - return "wifi" - return cleaned - - -def build_keyfile( - ssid: str, - psk: Optional[str], - connection_uuid: Optional[str] = None, -) -> str: - """Build a NetworkManager keyfile body for a single WiFi connection. - - `psk=None` produces an open-network keyfile (no [wifi-security]). - `connection_uuid=None` generates a fresh v4 UUID. - """ - if connection_uuid is None: - connection_uuid = str(uuid.uuid4()) - - id_escaped = escape_keyfile_value(ssid) - ssid_encoded = ssid_to_bytelist(ssid) - - lines = [ - "[connection]", - f"id={id_escaped}", - f"uuid={connection_uuid}", - "type=wifi", - "autoconnect=true", - "", - "[wifi]", - "mode=infrastructure", - f"ssid={ssid_encoded}", - "", - ] - - if psk is not None and psk != "": - psk_escaped = escape_keyfile_value(psk) - lines.extend( - [ - "[wifi-security]", - "key-mgmt=wpa-psk", - f"psk={psk_escaped}", - "", - ] - ) - - lines.extend( - [ - "[ipv4]", - "method=auto", - "", - "[ipv6]", - "method=auto", - "", - ] - ) - return "\n".join(lines) - - -def emit_keyfiles( - networks: Iterable[Network], - output_dir: Path, - uuid_factory: Callable[[], str] = lambda: str(uuid.uuid4()), -) -> List[Path]: - """Write a keyfile per network into `output_dir`. - - Filenames are based on a sanitised SSID; collisions get a numeric - suffix. Files are mode 0600. Returns the list of paths written. - """ - output_dir.mkdir(parents=True, exist_ok=True) - written: List[Path] = [] - used: set = set() - - for net in networks: - base = sanitize_filename(net.ssid) - name = base - n = 1 - while name in used: - n += 1 - name = f"{base}_{n}" - used.add(name) - - path = output_dir / f"{name}.nmconnection" - body = build_keyfile(net.ssid, net.psk, connection_uuid=uuid_factory()) - path.write_text(body) - os.chmod(path, 0o600) - written.append(path) - - return written - - -def _main(argv: Optional[List[str]] = None) -> int: - parser = argparse.ArgumentParser( - description=( - "Convert wpa_supplicant.conf to NetworkManager keyfiles for " - "the PiFinder NixOS migration." - ) - ) - parser.add_argument( - "--wpa-conf", - required=True, - help="Path to the source wpa_supplicant.conf file.", - ) - parser.add_argument( - "--out", - required=True, - help="Directory to write the .nmconnection files into (created if absent).", - ) - args = parser.parse_args(argv) - - src = Path(args.wpa_conf) - if not src.exists(): - print(f"wpa_supplicant.conf not found at {src}", file=sys.stderr) - return 0 - - networks = parse_wpa_supplicant_conf(src.read_text()) - if not networks: - print(f"No networks parsed from {src}", file=sys.stderr) - return 0 - - written = emit_keyfiles(networks, Path(args.out)) - print(f"Wrote {len(written)} keyfile(s) to {args.out}", file=sys.stderr) - return 0 - - -if __name__ == "__main__": - sys.exit(_main()) diff --git a/python/PiFinder/nixos_upgrade.py b/python/PiFinder/nixos_upgrade.py new file mode 100644 index 000000000..f5eb06568 --- /dev/null +++ b/python/PiFinder/nixos_upgrade.py @@ -0,0 +1,567 @@ +"""NixOS upgrade runner for PiFinder. + +This module is intentionally small and standard-library only. It is launched by +systemd as root, writes the status file consumed by the UI, and guarantees a +terminal status for every non-reboot exit. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import re +import subprocess +import urllib.error +import urllib.request +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +logger = logging.getLogger("PiFinder.nixos_upgrade") + +RUN_DIR = Path("/run/pifinder") +UPGRADE_REF_FILE = RUN_DIR / "upgrade-ref" +UPGRADE_SELECTION_FILE = RUN_DIR / "upgrade-selection.json" +UPGRADE_STATUS_FILE = RUN_DIR / "upgrade-status" +UPGRADE_LOG_FILE = RUN_DIR / "upgrade-nix.log" +CURRENT_BUILD_FILE = Path("/var/lib/pifinder/current-build.json") +CAMERA_TYPE_FILE = Path("/var/lib/pifinder/camera-type") +# Arms pifinder-watchdog: present = the next boot is a trial of an unproven +# generation (roll back on failure); absent = committed system, never touched. +TRIAL_MARKER_FILE = Path("/var/lib/pifinder/trial-generation.json") + +RELEASE_CACHE = "https://cache.pifinder.eu/pifinder-release" +DEV_CACHE = "https://cache.pifinder.eu/pifinder" +CACHES = (DEV_CACHE, RELEASE_CACHE) + +STORE_PATH_RE = re.compile(r"/nix/store/[a-z0-9]+-[A-Za-z0-9._+=?,-]+") + +# nix's --dry-run prints e.g. "(0.0 KiB download, 894.9 MiB unpacked)". Attic +# narinfos carry no compressed FileSize, so the unpacked figure is the only +# whole-download size nix can report; we use it as the progress denominator. +_UNPACKED_RE = re.compile(r"([\d.]+)\s+(B|KiB|MiB|GiB|TiB)\s+unpacked") +_SIZE_UNITS = {"B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3, "TiB": 1024**4} + + +def parse_unpacked_total(dry_output: str) -> int: + m = _UNPACKED_RE.search(dry_output) + if not m: + return 0 + return int(float(m.group(1)) * _SIZE_UNITS[m.group(2)]) + + +class UpgradeError(RuntimeError): + """Generic upgrade failure.""" + + +class UnavailableError(UpgradeError): + """Selected store path is no longer available from configured caches.""" + + +@dataclass(frozen=True) +class ProgressEvent: + action: str + activity_id: int + activity_type: int | None + path: str | None + done: int | None = None + expected: int | None = None + + +@dataclass(frozen=True) +class DownloadEstimate: + paths: tuple[str, ...] + # nix's dry-run "unpacked" byte total (0 if unknown). Per-path byte progress + # streams live from the build's internal-json, so we keep no size map here. + total_bytes: int = 0 + + @property + def path_count(self) -> int: + return len(self.paths) + + +def write_status(status: str, status_file: Path = UPGRADE_STATUS_FILE) -> None: + status_file.parent.mkdir(parents=True, exist_ok=True) + status_file.write_text(status) + + +def valid_store_path(ref: str) -> bool: + return bool(STORE_PATH_RE.fullmatch(ref)) + + +def parse_store_paths(text: str) -> tuple[str, ...]: + return tuple(dict.fromkeys(STORE_PATH_RE.findall(text))) + + +def parse_progress_event(line: str) -> ProgressEvent | None: + if not line.startswith("@nix "): + return None + try: + payload = json.loads(line[5:]) + except json.JSONDecodeError: + return None + + action = payload.get("action") + activity_id = payload.get("id") + if not isinstance(activity_id, int): + return None + + # resProgress (type 105): fields = [done, expected, running, failed]. Used + # for smooth within-path byte progress (summed over copyPath activities). + if action == "result" and payload.get("type") == 105: + fields = payload.get("fields") + if ( + isinstance(fields, list) + and len(fields) >= 2 + and isinstance(fields[0], int) + and isinstance(fields[1], int) + ): + return ProgressEvent( + "result", activity_id, None, None, fields[0], fields[1] + ) + return None + + if action not in ("start", "stop"): + return None + + activity_type = payload.get("type") + if activity_type is not None and not isinstance(activity_type, int): + activity_type = None + + path = None + for value in payload.values(): + if isinstance(value, str): + match = STORE_PATH_RE.search(value) + if match: + path = match.group(0) + break + + return ProgressEvent(action, activity_id, activity_type, path) + + +def command( + args: list[str], + *, + check: bool = True, + timeout: int | None = None, + input_text: str | None = None, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + args, + input=input_text, + capture_output=True, + text=True, + timeout=timeout, + ) + if check and result.returncode != 0: + raise UpgradeError( + f"{args[0]} failed rc={result.returncode}: {result.stderr.strip()}" + ) + return result + + +def path_exists(path: str) -> bool: + return Path(path).exists() + + +# Availability of a build on the binary caches, kept distinct on purpose: a +# cache we cannot reach must never be reported as "the build is gone". +AVAILABLE = "available" +ABSENT = "absent" +UNREACHABLE = "unreachable" + + +def _narinfo_url(store_path: str, cache: str) -> str: + digest = Path(store_path).name.split("-", 1)[0] + return f"{cache.rstrip('/')}/{digest}.narinfo" + + +def classify_store_path( + store_path: str, caches: Iterable[str] = CACHES, timeout: int = 15 +) -> str: + """Decide whether a build is downloadable, gone, or simply unreachable. + + Probes each cache's narinfo over HTTPS so a network failure is never + mistaken for a deleted build: + - AVAILABLE already in the local store, or a cache serves the narinfo + - ABSENT every cache answered and at least one returned 404 — the + build really is gone + - UNREACHABLE no cache could be reached (offline / DNS / cache down), so + availability is unknown and the upgrade should be retried + """ + if path_exists(store_path): + return AVAILABLE + + saw_404 = False + unreachable = False + for cache in caches: + url = _narinfo_url(store_path, cache) + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + if resp.status == 200: + return AVAILABLE + except urllib.error.HTTPError as exc: + if exc.code == 404: + saw_404 = True + else: + # 5xx and friends mean a troubled cache, not a deleted build. + unreachable = True + except (urllib.error.URLError, OSError, TimeoutError): + unreachable = True + + if unreachable: + return UNREACHABLE + return ABSENT if saw_404 else UNREACHABLE + + +def fetch_cache_public_keys( + caches: Iterable[str] = CACHES, timeout: int = 15 +) -> list[str]: + """Fetch each cache's current signing key from its anonymous Attic + cache-config endpoint, so the upgrade trusts whatever key the cache uses + *now*. This makes a cache signing-key rotation invisible to devices — they + can never be stranded by a key change — while signature verification stays + on (verified against the freshly-fetched key, over the same HTTPS trust + boundary as the cache we already pull from). Best-effort: a cache we cannot + reach contributes no key and we fall back to the device's configured keys. + """ + keys: list[str] = [] + for cache in caches: + base, _, name = cache.rstrip("/").rpartition("/") + url = f"{base}/_api/v1/cache-config/{name}" + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + key = json.load(resp).get("public_key") + if key: + keys.append(key) + except Exception as exc: # network / JSON errors are non-fatal + logger.warning("could not fetch cache key from %s: %s", url, exc) + return keys + + +def estimate_download(store_path: str) -> DownloadEstimate: + """Best-effort delta estimate: which paths nix will fetch, plus nix's own + "unpacked" byte total from a dry-run. We deliberately do NOT query per-path + sizes — the real byte progress streams live from the build's internal-json. + An empty estimate must never block the actual build. + """ + try: + dry_result = command( + ["nix-store", "--realise", "--dry-run", store_path], + check=False, + timeout=120, + ) + dry = f"{dry_result.stdout}\n{dry_result.stderr}" + except (subprocess.TimeoutExpired, OSError): + return DownloadEstimate(()) + return DownloadEstimate(parse_store_paths(dry), parse_unpacked_total(dry)) + + +def _short_pkg(path: str | None) -> str: + """A screen-friendly package name from a store path: drop the + /nix/store/- prefix and trim, e.g. + '/nix/store/xxx-python3-3.13.11' -> 'python3-3.13.11'.""" + if not path: + return "" + return path.rsplit("/", 1)[-1].split("-", 1)[-1][:22] + + +class _DownloadProgress: + """Best-effort download progress from nix's internal-json stream. + + Numerator = running sum of bytes copied across copyPath activities (their + resProgress events); denominator = nix's dry-run "unpacked" total. So the + bar moves *within* a path, not only when one finishes — and it names the + package being copied. Status writes are throttled (the stream emits hundreds + of thousands of events). Best-effort throughout: run_build wraps feed() so a + bug here can never abort the upgrade. + """ + + def __init__(self, total_bytes: int, total_paths: int, status_file: Path): + self.total_bytes = total_bytes + self.use_bytes = total_bytes > 0 + self.total_paths = total_paths + self.status_file = status_file + self._active: dict[int, str] = {} # copyPath id -> short label + self._done: dict[int, int] = {} # copyPath id -> bytes copied + self._expected: dict[int, int] = {} # copyPath id -> expected bytes + self._bytes = 0 + self._paths_seen = 0 + self._paths_done = 0 + self._label = "" + self._last_written = -1 + # Only rewrite the status file every ~0.5% of the total (or 1 MiB). + self._step = max(1 << 20, total_bytes // 200) if self.use_bytes else 0 + + def feed(self, line: str) -> None: + event = parse_progress_event(line) + if event is None: + return + if event.action == "result": + self._on_progress(event) + elif event.activity_type == 100: + if event.action == "start": + self._on_start(event) + elif event.action == "stop": + self._on_stop(event) + + def _on_start(self, event: ProgressEvent) -> None: + self._active[event.activity_id] = _short_pkg(event.path) + self._paths_seen += 1 + self._label = self._active[event.activity_id] or self._label + if not self.use_bytes: + self._write_paths() + + def _on_progress(self, event: ProgressEvent) -> None: + aid = event.activity_id + if aid not in self._active: # only copyPath activities we track + return + self._bytes += (event.done or 0) - self._done.get(aid, 0) + self._done[aid] = event.done or 0 + if event.expected: + self._expected[aid] = event.expected + if self.use_bytes: + pct = min(self._bytes, self.total_bytes) + if pct - self._last_written >= self._step: + self._write_bytes() + + def _on_stop(self, event: ProgressEvent) -> None: + aid = event.activity_id + if aid not in self._active: + return + label = self._active.pop(aid) + self._paths_done += 1 + if self.use_bytes: + full = self._expected.get(aid, self._done.get(aid, 0)) + self._bytes += full - self._done.get(aid, 0) + self._done[aid] = full + # show something still in flight, else the path that just finished + self._label = next(iter(self._active.values()), label) or self._label + self._write_bytes() + else: + self._write_paths() + + def _write_bytes(self) -> None: + pct = min(self._bytes, self.total_bytes) + self._last_written = pct + msg = f"downloading {pct}/{self.total_bytes}" + if self._label: + msg += f" {self._label}" + write_status(msg, self.status_file) + + def _write_paths(self) -> None: + denom = self.total_paths or self._paths_seen + write_status(f"downloading {self._paths_done}/{denom} paths", self.status_file) + + +def run_build( + store_path: str, + estimate: DownloadEstimate, + *, + status_file: Path = UPGRADE_STATUS_FILE, + log_file: Path = UPGRADE_LOG_FILE, +) -> int: + if estimate.total_bytes > 0: + write_status(f"downloading 0/{estimate.total_bytes}", status_file) + else: + write_status(f"downloading 0/{estimate.path_count} paths", status_file) + + # Trust the cache's current signing key(s), fetched from the cache itself, + # so a key rotation can never strand this device mid-upgrade. This ADDS to + # the trusted set (verification stays on) — it is not a require-sigs bypass. + build_args = [ + "nix", + "--log-format", + "internal-json", + "build", + store_path, + "--max-jobs", + "0", + "--no-link", + ] + cache_keys = fetch_cache_public_keys() + if cache_keys: + build_args += ["--option", "extra-trusted-public-keys", " ".join(cache_keys)] + + progress = _DownloadProgress(estimate.total_bytes, estimate.path_count, status_file) + tail: deque[str] = deque(maxlen=40) + + process = subprocess.Popen( + build_args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert process.stdout is not None + for line in process.stdout: + tail.append(line.rstrip()) + # Progress is a nice-to-have: never let an accounting bug stall the + # stream (which would deadlock the build) or abort the upgrade. + try: + progress.feed(line) + except Exception: + logger.debug("progress tracking error", exc_info=True) + return_code = process.wait() + + # Persist only a short tail for diagnostics — not the ~800k-line stream. + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + log_file.write_text("\n".join(tail) + "\n") + except OSError: + logger.debug("could not write upgrade log tail", exc_info=True) + + if return_code != 0: + logger.error("nix build failed rc=%s; tail=%s", return_code, list(tail)) + return return_code + + +def load_selection(selection_file: Path = UPGRADE_SELECTION_FILE) -> dict: + try: + with selection_file.open() as f: + data = json.load(f) + if isinstance(data, dict): + return data + except (FileNotFoundError, OSError, json.JSONDecodeError): + pass + return {} + + +def persist_current_build(store_path: str, selection: dict) -> None: + CURRENT_BUILD_FILE.parent.mkdir(parents=True, exist_ok=True) + data = { + "store_path": store_path, + "version": selection.get("version") or selection.get("label") or store_path, + "label": selection.get("label"), + "channel": selection.get("channel"), + } + CURRENT_BUILD_FILE.write_text(json.dumps(data, sort_keys=True) + "\n") + + +def arm_trial_marker(boot_target: Path) -> None: + """Arm the boot-health watchdog for the next boot. + + Records the currently-running (known-good) system and the generation the + next boot is expected to run, so pifinder-watchdog can roll back if that + generation fails its first boot. The watchdog deletes the marker once the + new generation proves healthy (commit); no marker means a committed + system, which is never auto-rolled-back. + + Best-effort: a marker failure must not block the upgrade — it only means + this upgrade proceeds without the automatic safety net. + """ + try: + previous = Path("/run/current-system").resolve() + new = boot_target.resolve() + TRIAL_MARKER_FILE.parent.mkdir(parents=True, exist_ok=True) + TRIAL_MARKER_FILE.write_text( + json.dumps({"previous": str(previous), "new": str(new)}, sort_keys=True) + + "\n" + ) + except OSError as exc: + logger.warning("could not arm trial marker: %s", exc) + + +def activate_system(store_path: str, default_camera: str) -> None: + write_status("activating") + command(["nix-env", "-p", "/nix/var/nix/profiles/system", "--set", store_path]) + + try: + camera = CAMERA_TYPE_FILE.read_text().strip() + except OSError: + camera = default_camera + + if camera and camera != default_camera: + specialisation = Path(store_path) / "specialisation" / camera + if specialisation.is_dir(): + # The specialisation has its own toplevel — arm the watchdog with + # what will actually be running after reboot. + arm_trial_marker(specialisation) + command([str(specialisation / "bin/switch-to-configuration"), "boot"]) + set_extlinux_default(camera) + return + + arm_trial_marker(Path(store_path)) + command([str(Path(store_path) / "bin/switch-to-configuration"), "boot"]) + set_extlinux_default(default_camera) + + +def set_extlinux_default(camera: str) -> None: + """Point the extlinux DEFAULT at the selected camera's boot entry. + + Device-tree overlays load only at boot, and the generic-extlinux builder + rewrites DEFAULT to the base camera on every activation — so without this an + upgrade would reboot into the base camera's DTB regardless of the device's + chosen camera. Best-effort: the helper leaves a bootable DEFAULT in place if + the entry is missing, so a hiccup here never blocks the upgrade. + """ + command(["set-extlinux-default", camera], check=False) + + +def cleanup_old_generations() -> None: + # Keep the 3 newest generations: current + 2 rollback targets (surfaced in + # the Software screen's Rollback channel). + command( + ["nix-env", "--delete-generations", "+3", "-p", "/nix/var/nix/profiles/system"], + check=False, + ) + command(["nix-collect-garbage"], check=False) + + +def run_upgrade(ref_file: Path, default_camera: str) -> int: + terminal = False + selected_unavailable = False + try: + write_status("starting") + store_path = ref_file.read_text().strip() + if not valid_store_path(store_path): + raise UpgradeError(f"invalid store path: {store_path!r}") + + estimate = estimate_download(store_path) + build_rc = run_build(store_path, estimate) + if build_rc != 0: + availability = classify_store_path(store_path) + if availability == ABSENT: + selected_unavailable = True + write_status("unavailable") + terminal = True + raise UnavailableError( + f"{store_path} is no longer on configured caches" + ) + if availability == UNREACHABLE: + # Couldn't reach the caches to download — a connection problem, + # not a missing build. Retryable, so don't claim it's gone. + write_status("connfail") + terminal = True + raise UpgradeError(f"caches unreachable for {store_path}") + raise UpgradeError(f"nix build failed rc={build_rc}") + + selection = load_selection() + activate_system(store_path, default_camera) + persist_current_build(store_path, selection) + cleanup_old_generations() + write_status("rebooting") + command(["systemctl", "reboot"]) + terminal = True + return 0 + except UnavailableError: + return 1 + except Exception as exc: + logger.exception("upgrade failed: %s", exc) + if not terminal and not selected_unavailable: + write_status("failed") + return 1 + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--ref-file", type=Path, default=UPGRADE_REF_FILE) + parser.add_argument("--default-camera", default="imx462") + args = parser.parse_args(list(argv) if argv is not None else None) + logging.basicConfig(level=logging.INFO) + return run_upgrade(args.ref_file, args.default_camera) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/PiFinder/plot.py b/python/PiFinder/plot.py index 265ae122c..7cf1094cb 100644 --- a/python/PiFinder/plot.py +++ b/python/PiFinder/plot.py @@ -426,9 +426,9 @@ def render_starfield_pil( # Keep edges where at least one endpoint is on-screen. start_on = (sx_pos > 0) & (sx_pos < W) & (sy_pos > 0) & (sy_pos < H) end_on = (ex_pos > 0) & (ex_pos < W) & (ey_pos > 0) & (ey_pos < H) - for i in np.flatnonzero(start_on | end_on): + for edge_i in np.flatnonzero(start_on | end_on): idraw.line( - [sx_pos[i], sy_pos[i], ex_pos[i], ey_pos[i]], + [sx_pos[edge_i], sy_pos[edge_i], ex_pos[edge_i], ey_pos[edge_i]], fill=constellation_brightness, ) diff --git a/python/PiFinder/server.py b/python/PiFinder/server.py index cc943e178..e5240fb19 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -48,6 +48,16 @@ class SignedIntConverter(IntegerConverter): SESSION_SECRET = str(uuid.uuid4()) +def parse_coordinate(value, field_name): + """Parse a coordinate/measurement field, accepting comma or period decimals.""" + if value is None: + raise ValueError(_("%s is required") % field_name) + try: + return float(str(value).strip().replace(",", ".")) + except ValueError: + raise ValueError(_("%s must be a number") % field_name) + + def auth_required(func): def auth_wrapper(*args, **kwargs): # check for and validate session @@ -100,7 +110,7 @@ def __init__( shared_state=None, is_debug=False, ): - self.version_txt = f"{utils.pifinder_dir}/version.txt" + self._software_version = utils.get_version() self.keyboard_queue = keyboard_queue or multiprocessing.Queue() self.ui_queue = ui_queue or multiprocessing.Queue() self.gps_queue = gps_queue or multiprocessing.Queue() @@ -201,12 +211,8 @@ def send_css(filename): def home(): # logger.debug("/ called") # Get version info - software_version = "Unknown" - try: - with open(self.version_txt, "r") as ver_f: - software_version = ver_f.read() - except (FileNotFoundError, IOError) as e: - logger.warning(f"Could not read version file: {str(e)}") + + software_version = self._software_version # Try to update GPS state try: @@ -244,7 +250,7 @@ def home(): software_version=software_version, wifi_mode=self.network.wifi_mode(), ip=self.network.local_ip(), - network_name=self.network.get_connected_ssid(), + network_name=self.network.get_active_label(), gps_icon=gps_icon, gps_text=gps_text, lat_text=lat_text, @@ -358,11 +364,13 @@ def locations_page(): @auth_required def location_add(): try: - name = request.form.get("name").strip() - lat = float(request.form.get("latitude")) - lon = float(request.form.get("longitude")) - altitude = float(request.form.get("altitude")) - error_in_m = float(request.form.get("error_in_m", "0")) + name = (request.form.get("name") or "").strip() + lat = parse_coordinate(request.form.get("latitude"), _("Latitude")) + lon = parse_coordinate(request.form.get("longitude"), _("Longitude")) + altitude = parse_coordinate(request.form.get("altitude"), _("Altitude")) + error_in_m = parse_coordinate( + request.form.get("error_in_m", "0"), _("Error") + ) source = request.form.get("source", "Manual Entry") # Server-side validation @@ -416,11 +424,13 @@ def location_rename(location_id): if not (0 <= location_id < len(cfg.locations.locations)): raise ValueError("Invalid location ID") - name = request.form.get("name").strip() - lat = float(request.form.get("latitude")) - lon = float(request.form.get("longitude")) - altitude = float(request.form.get("altitude")) - error_in_m = float(request.form.get("error_in_m", "0")) + name = (request.form.get("name") or "").strip() + lat = parse_coordinate(request.form.get("latitude"), _("Latitude")) + lon = parse_coordinate(request.form.get("longitude"), _("Longitude")) + altitude = parse_coordinate(request.form.get("altitude"), _("Altitude")) + error_in_m = parse_coordinate( + request.form.get("error_in_m", "0"), _("Error") + ) source = request.form.get("source", "Manual Entry") # Server-side validation @@ -522,7 +532,18 @@ def network_update(): self.network.set_wifi_mode(wifi_mode) self.network.set_ap_name(ap_name) self.network.set_host_name(host_name) - return app.jinja_env.get_template("restart.html").render(title=_("Restart")) + + applied_host = self.network.get_host_name() + return app.jinja_env.get_template("network.html").render( + title=_("Network"), + net=self.network, + show_new_form=0, + status_message=_( + "Network settings updated — no restart needed. This device is " + "now reachable at http://{host}.local. If you changed the host " + "name, the previous address stops working, so reconnect there." + ).format(host=applied_host), + ) @app.route("/tools/pwchange", methods=["POST"]) @auth_required @@ -680,7 +701,7 @@ def equipment_import(): try: cfg.equipment.eyepieces.index(new_eyepiece) except ValueError: - cfg.equipment.eyepieces.add_eyepiece(new_eyepiece) + cfg.equipment.eyepieces.append(new_eyepiece) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -727,13 +748,13 @@ def equipment_add_eyepiece(eyepiece_id: int): ) if eyepiece_id >= 0: - cfg.equipment.update_eyepiece(eyepiece_id, eyepiece) + cfg.equipment.eyepieces[eyepiece_id] = eyepiece else: try: index = cfg.equipment.telescopes.index(eyepiece) - cfg.equipment.update_eyepiece(index, eyepiece) + cfg.equipment.eyepieces[index] = eyepiece except ValueError: - cfg.equipment.add_eyepiece(eyepiece) + cfg.equipment.eyepieces.append(eyepiece) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -1014,23 +1035,16 @@ def remove_file(response): @app.route("/logs/configs") @auth_required def list_log_configs(): - """Return all available logconf_*.json files with display names.""" - import glob - + """Return all available logconf_*.json presets with display names.""" + active = utils.active_logconf_name() configs = [] - active = ( - os.path.realpath("pifinder_logconf.json") - if os.path.exists("pifinder_logconf.json") - else None - ) - for path in sorted(glob.glob("logconf_*.json")): - stem = path[len("logconf_") : -len(".json")] - display = stem.replace("_", " ").title() + for name in utils.available_logconfs(): + stem = name[len("logconf_") : -len(".json")] configs.append( { - "file": path, - "name": display, - "active": os.path.realpath(path) == active, + "file": name, + "name": stem.replace("_", " ").title(), + "active": name == active, } ) return jsonify({"configs": configs}) @@ -1038,29 +1052,15 @@ def list_log_configs(): @app.route("/logs/switch_config", methods=["POST"]) @auth_required def switch_log_config(): - """Atomically repoint pifinder_logconf.json to the chosen config, then restart.""" + """Persist the chosen log config to the data dir, then restart.""" logconf_file = request.form.get("logconf_file", "").strip() - if ( - not logconf_file - or not logconf_file.startswith("logconf_") - or not logconf_file.endswith(".json") - ): + try: + utils.set_active_logconf(logconf_file) + logger.info("Switched log config to %s", logconf_file) + except (ValueError, FileNotFoundError): return jsonify( {"status": "error", "message": "Invalid log config file name"} ) - if not os.path.exists(logconf_file): - return jsonify( - { - "status": "error", - "message": f"Log config file not found: {logconf_file}", - } - ) - try: - link = "pifinder_logconf.json" - tmp = link + ".tmp" - os.symlink(logconf_file, tmp) - os.replace(tmp, link) - logger.info("Switched log config to %s", logconf_file) except Exception as e: logger.error("Failed to switch log config: %s", e) return jsonify({"status": "error", "message": str(e)}) diff --git a/python/PiFinder/solver.py b/python/PiFinder/solver.py index b3ce6f242..f88b308ee 100644 --- a/python/PiFinder/solver.py +++ b/python/PiFinder/solver.py @@ -13,7 +13,6 @@ import numpy as np import time import logging -import sys from time import perf_counter as precision_timestamp import os import platform @@ -28,6 +27,8 @@ from PiFinder import utils from PiFinder import timez from PiFinder.sqm import SQM as SQMCalculator + +from PiFinder.sqm.wings import WingEstimator from PiFinder.state import SQM as SQMState from PiFinder.types.positioning import ( AlignCancel, @@ -41,7 +42,6 @@ SuccessfulSolve, ) -sys.path.append(str(utils.tetra3_dir)) import tetra3 from tetra3 import cedar_detect_client @@ -52,13 +52,103 @@ def create_sqm_calculator(shared_state): - """Create a new SQM calculator instance with current calibration.""" + """Create a new SQM calculator instance with current calibration. + + Photometry always runs on the raw linear frame (green channel for Bayer + sensors); the 8-bit processed image is for solving/display only. + """ camera_type = shared_state.camera_type() - camera_type_processed = f"{camera_type}_processed" + logger.info(f"Creating raw-green SQM calculator for camera: {camera_type}") + return SQMCalculator(camera_type=camera_type) + + +def _extract_raw_photometry_image(raw, profile): + """Build the linear photometry image from the stored raw frame. + + For Bayer sensors (SRGGB*) returns the averaged green channel (half-res); + for mono sensors returns the raw frame as-is. Returns None on any shape/ + dtype problem so the caller can skip the SQM cycle. + """ + if raw is None: + return None + arr = np.asarray(raw) + if arr.ndim != 2: + return None + if str(profile.format).upper().startswith("SRGGB"): + # RGGB green sites: (0,1) and (1,0). Even crops / no odd rotation keep phase. + h, w = arr.shape + if h < 2 or w < 2: + return None + g = ( + arr[0 : h - h % 2 : 2, 1:w:2].astype(np.float32) + + arr[1:h:2, 0 : w - w % 2 : 2].astype(np.float32) + ) / 2.0 + return g + return arr.astype(np.float32) + + +def _scale_solution_centroids(solution, scale): + """Return a shallow copy of solution with matched_centroids scaled. + + The solve runs on the 512x512 processed image; the raw photometry image has a + different pixel pitch, so the matched star positions must be rescaled to it. + """ + scaled = dict(solution) + mc = np.asarray(solution["matched_centroids"], dtype=np.float64) * scale + scaled["matched_centroids"] = mc + return scaled + - logger.info(f"Creating SQM calculator for camera: {camera_type_processed}") +def _derotate_centroids(points, rotation_deg, size): + """Map (y, x) centroids from the display-rotated solve image back onto + the unrotated raw frame's pixel grid. - return SQMCalculator(camera_type=camera_type_processed) + The camera process rotates the solve/display image by ``rotation_deg`` + (PIL CCW) relative to the raw it stores in shared state; photometry runs + on the raw, so star positions must be counter-rotated or every aperture + lands on the wrong sky (SQM then reads magnitudes too bright). + + Args: + points: (N, 2) array of (y, x) positions in the rotated image. + rotation_deg: degrees the solve image was rotated (PIL CCW). + size: side length of the (square) pixel grid the points live on. + """ + pts = np.asarray(points, dtype=np.float64) + k = int(rotation_deg) % 360 + y, x = pts[:, 0], pts[:, 1] + m = size - 1 + if k == 0: + return pts + if k == 90: + # solve = raw rotated 90 CCW: raw_y = x, raw_x = m - y + return np.stack([x, m - y], axis=1) + if k == 180: + return np.stack([m - y, m - x], axis=1) + if k == 270: + # solve = raw rotated 270 CCW: raw_y = m - x, raw_x = y + return np.stack([m - x, y], axis=1) + # Arbitrary angle: rotate about the image centre. PIL's rotate(a) fills + # dest(x2, y2) from src at c + R(a)·(p2 − c) in (x, y) with y down. + c = m / 2.0 + a = np.radians(k) + dx, dy = x - c, y - c + rx = c + np.cos(a) * dx - np.sin(a) * dy + ry = c + np.sin(a) * dx + np.cos(a) * dy + return np.stack([ry, rx], axis=1) + + +def _apply_sqm_correct(sqm_value, details, correct_delta): + """Apply the session correction delta (reference-meter offset) to the + calculated SQM and its altitude-corrected companion. The delta is recorded + in the details either way so the UI can show correction status.""" + details["sqm_correct_delta"] = correct_delta + if not correct_delta: + return sqm_value, details + if sqm_value is not None: + sqm_value += correct_delta + if details.get("sqm_altitude_corrected") is not None: + details["sqm_altitude_corrected"] += correct_delta + return sqm_value, details def update_sqm( @@ -66,13 +156,13 @@ def update_sqm( sqm_calculator, centroids, solution, - image_processed, exposure_sec, altitude_deg, calculation_interval_seconds=5.0, aperture_radius=5, - annulus_inner_radius=6, - annulus_outer_radius=14, + annulus_inner_radius=10, + annulus_outer_radius=18, + wing_estimator=None, ): """ Calculate SQM from image. @@ -82,13 +172,15 @@ def update_sqm( sqm_calculator: SQM calculator instance centroids: List of detected star centroids solution: Tetra3 solve solution with matched stars - image_processed: Processed image array (numpy) exposure_sec: Exposure time in seconds altitude_deg: Altitude in degrees for extinction correction calculation_interval_seconds: Minimum time between calculations (default: 5.0) aperture_radius: Aperture radius for photometry (default: 5) - annulus_inner_radius: Inner annulus radius (default: 6) - annulus_outer_radius: Outer annulus radius (default: 14) + annulus_inner_radius: Inner annulus radius (default: 10) + annulus_outer_radius: Outer annulus radius (default: 18) + wing_estimator: WingEstimator that supplies the rolling aperture + (wing-loss) mzero correction and is fed each frame's photometry + image + matched centroids. Returns: bool: True if SQM was calculated and updated, False otherwise @@ -117,23 +209,89 @@ def update_sqm( if not should_calculate: return False + profile = sqm_calculator.profile + + try: + raw = shared_state.cam_raw() + except (BrokenPipeError, ConnectionResetError): + raw = None + green = _extract_raw_photometry_image(raw, profile) + if green is None or green.shape[0] < 256: + # cam_raw() is None until the first real capture (test mode never + # fills it), and a malformed frame comes through far smaller than a + # real one. A genuine green frame is several hundred px per side — + # e.g. ~490 for the imx462/imx290 crop, larger for the imx296 — so + # the floor only rejects missing/garbage frames, not valid sensors. + # Photometry runs at the green frame's own scale, so a side shorter + # than the 512px solve image is fine. + logger.debug("Raw frame unavailable/invalid for SQM; skipping this cycle") + return False + scale = green.shape[0] / 512.0 + calc_image = green + calc_solution = _scale_solution_centroids(solution, scale) + # All detected centroids, scaled to the photometry image: sqm masks them + # out of background annuli (neighbour-star contamination in dense fields). + calc_centroids = ( + np.asarray(centroids, dtype=np.float64) * scale + if centroids is not None and len(centroids) > 0 + else None + ) + + # The solve image is display-rotated relative to the raw; counter-rotate + # all star positions onto the raw's grid before photometry. + try: + solve_rotation = shared_state.solve_image_rotation() + except (BrokenPipeError, ConnectionResetError, AttributeError): + solve_rotation = None + if solve_rotation: + side = green.shape[0] + calc_solution["matched_centroids"] = _derotate_centroids( + calc_solution["matched_centroids"], solve_rotation, side + ) + if calc_centroids is not None: + calc_centroids = _derotate_centroids(calc_centroids, solve_rotation, side) + image_pixels_per_side = int(green.shape[0]) + # 0.70 of full scale, not ~1.0: CMOS response bends well before hard + # clip, and stars peaking at 75-90% already read systematically low. + saturation_threshold = int(0.70 * (2**profile.bit_depth - 1)) + + mzero_correction = 0.0 + if wing_estimator is not None: + mzero_correction = wing_estimator.correction() + try: # Calculate SQM from image sqm_value, details = sqm_calculator.calculate( - centroids=centroids, - solution=solution, - image=image_processed, + centroids=calc_centroids, + solution=calc_solution, + image=calc_image, exposure_sec=exposure_sec, altitude_deg=altitude_deg, aperture_radius=aperture_radius, annulus_inner_radius=annulus_inner_radius, annulus_outer_radius=annulus_outer_radius, + saturation_threshold=saturation_threshold, + image_pixels_per_side=image_pixels_per_side, + mzero_correction=mzero_correction, ) - # Update noise floor in shared state (for SNR auto-exposure) - noise_floor_details = details.get("noise_floor_details") - if noise_floor_details and "noise_floor_adu" in noise_floor_details: - shared_state.set_noise_floor(noise_floor_details["noise_floor_adu"]) + # Feed this frame's stars into the rolling wing (aperture-loss) fit. + if ( + wing_estimator is not None + and calc_solution.get("matched_centroids") is not None + ): + wing_estimator.add_frame( + calc_image, + calc_solution["matched_centroids"], + saturation_threshold, + ) + + # Session correction: additive offset from a reference-meter reading + try: + correct_delta = shared_state.sqm_correct_delta() + except (BrokenPipeError, ConnectionResetError, AttributeError): + correct_delta = 0.0 + sqm_value, details = _apply_sqm_correct(sqm_value, details, correct_delta) # Store SQM details (filter out large per-star arrays) filtered_details = { @@ -279,6 +437,13 @@ def _get_stub(self): ) return self._stub + def _alloc_shmem(self, size): + # Report a freshly created segment (not just a resized one) so the + # request sets reopen_shmem and the server drops its stale cached fd. + fresh = self._shmem is None + resized = super()._alloc_shmem(size) + return resized or fresh + def extract_centroids( self, image, sigma, max_size, use_binned, detect_hot_pixels=True ): @@ -292,14 +457,17 @@ def extract_centroids( # Use shared memory path (same machine) if self._use_shmem: - self._alloc_shmem(size=width * height) + reopen = self._alloc_shmem(size=width * height) shimg = np.ndarray( np_image.shape, dtype=np_image.dtype, buffer=self._shmem.buf ) shimg[:] = np_image[:] im = cedar_detect_pb2.Image( - width=width, height=height, shmem_name=self._shmem.name + width=width, + height=height, + shmem_name=self._shmem.name, + reopen_shmem=reopen, ) req = cedar_detect_pb2.CentroidsRequest( input_image=im, @@ -314,6 +482,11 @@ def extract_centroids( except grpc.RpcError as err: if err.code() == grpc.StatusCode.INTERNAL: # Shared memory issue, fall back to non-shmem + logger.warning( + "Cedar shmem transfer failed (%s); " + "falling back to inline image passing", + err.details(), + ) self._del_shmem() self._use_shmem = False else: @@ -438,7 +611,9 @@ def solver( ): MultiprocLogging.configurer(log_queue) logger.debug("Starting Solver") - t3 = tetra3.Tetra3(str(utils.tetra3_dir / "data" / "default_database.npz")) + # Load tetra3's bundled pattern database by name; tetra3 resolves it from + # its own package data dir (shipped inside the cedar-solve wheel). + t3 = tetra3.Tetra3("default_database") align_ra = 0 align_dec = 0 last_solve_attempt: float = 0.0 @@ -447,8 +622,15 @@ def solver( centroids = [] log_no_stars_found = True - # Create SQM calculator - can be reloaded via command queue - sqm_calculator = create_sqm_calculator(shared_state) + # SQM calculator is created lazily on the first solve, not here: at solver + # startup shared_state.camera_type() still holds the pre-camera default, + # and a calculator built from it would photometer with the wrong sensor + # profile (pedestal etc.). The camera process records the real type before + # it captures its first frame, and a solve requires a captured frame, so + # first use is guaranteed to see the real camera type. + sqm_calculator = None + # Rolling aperture (wing-loss) correction, fed by bright matched stars + sqm_wing_estimator = WingEstimator() while True: logger.info("Starting Solver Loop") @@ -481,9 +663,11 @@ def solver( align_ra = 0 align_dec = 0 elif isinstance(command, ReloadSqmCalibration): + # Invalidate; the next solve recreates the calculator + # with fresh calibration (single creation site). logger.info("Reloading SQM calibration...") - sqm_calculator = create_sqm_calculator(shared_state) - logger.info("SQM calibration reloaded") + sqm_calculator = None + sqm_wing_estimator.reset() else: logger.warning( "Unknown solver command (type=%s): %r", @@ -565,6 +749,10 @@ def solver( ) if "matched_centroids" in solution: + if sqm_calculator is None: + sqm_calculator = create_sqm_calculator(shared_state) + sqm_wing_estimator.reset() + # Update SQM (auto-exposure consumes the noise floor) exposure_sec = ( last_image_metadata["exposure_time"] / 1_000_000.0 @@ -576,10 +764,10 @@ def solver( sqm_calculator=sqm_calculator, centroids=centroids, solution=solution, - image_processed=np_image, exposure_sec=exposure_sec, altitude_deg=altitude_for_sqm, calculation_interval_seconds=SQM_CALCULATION_INTERVAL_SECONDS, + wing_estimator=sqm_wing_estimator, ) # Don't clutter printed solution with these fields (use pop to safely remove) diff --git a/python/PiFinder/splash.py b/python/PiFinder/splash.py index 9346ebef2..2de2988eb 100644 --- a/python/PiFinder/splash.py +++ b/python/PiFinder/splash.py @@ -13,7 +13,7 @@ import os from PIL import Image, ImageDraw from PiFinder import displays -from PiFinder import hardware_detect +from PiFinder import hardware_detect, utils import numpy as np @@ -46,8 +46,7 @@ def show_splash(): screen_draw = ImageDraw.Draw(welcome_image) # Display version and Wifi mode in a top banner spanning the panel width - with open(os.path.join(root_dir, "version.txt"), "r") as ver_f: - version = "v" + ver_f.read() + version = utils.get_version() with open(os.path.join(root_dir, "wifi_status.txt"), "r") as wifi_f: wifi_mode = wifi_f.read() diff --git a/python/PiFinder/sqm/camera_profiles.py b/python/PiFinder/sqm/camera_profiles.py index a9b007a25..7397e1e98 100644 --- a/python/PiFinder/sqm/camera_profiles.py +++ b/python/PiFinder/sqm/camera_profiles.py @@ -72,6 +72,30 @@ class CameraProfile: # Used to sanity-check SQM estimates typical_sky_background: float = 21.0 + # SQM colour transformation coefficient T for mag_eff = V - T*(B-V). + # The catalog magnitude is Johnson V, but the flux is measured in the + # sensor's own passband. On a sensor run without an IR-cut filter the near-IR + # leak over-fluxes red stars, so T is positive. Measured per sensor model: + # imx462/imx290 bare color ~0.8; hq (factory IR-cut) ~0.0. 0.0 = no correction. + color_coefficient: float = 0.0 + + # Sky-passband offset (mag), added to the final SQM. The colour term + # matches the *stars* to the sensor passband, but the *sky* is then also + # measured in that passband: a bare sensor sees NIR sky emission (airglow, + # LED/sodium light pollution beyond 700nm) that a V-band SQM meter does + # not, so its sky reads genuinely brighter. This constant converts the + # sensor-band sky brightness back to the meter's V-band scale. + # + # NOT a pure sensor constant: it is (sensor passband, fixed) x (sky + # spectrum, environmental). The values below are calibrated under an + # LP-dominated suburban sky (Ghent), where the city's stable spectrum + # makes the offset constant to ~0.05 mag across nights. Under an + # airglow-dominated dark sky the NIR fraction is different and variable, + # so expect a different (likely larger) value there and real night-to- + # night wander. Refine per sky regime with side-by-side reference-meter + # or paired IR-cut-camera sessions. + sqm_band_offset: float = 0.0 + def crop_and_rotate(self, raw_array): """ Apply camera-specific cropping and rotation to raw array. @@ -129,7 +153,10 @@ def __repr__(self) -> str: analog_gain=15.0, # Maximum analog gain for this sensor digital_gain=1.0, # TODO: find optimum value bit_depth=10, - bias_offset=32.0, # Measured from actual dark frames + # Sony-standard black level (240 @ 12-bit -> 60 @ 10-bit); confirmed by + # the 2025-10-31 on-sky sweep intercept (60.3). The old 32.0 was a + # mis-measurement. + bias_offset=60.0, # Image cropping and orientation crop_y=(0, 0), # No vertical crop crop_x=(184, 184), # Crop to square from horizontal rectangle @@ -139,6 +166,15 @@ def __repr__(self) -> str: dark_current_rate=8.0, # Datasheet: 3.2 e⁻/p/s @ 25°C → ~8 ADU/s @ 10-bit thermal_coeff=0.08, # Typical for CMOS sensors (no sensor temp available) typical_sky_background=21.0, + # Measured on-sky (2025-10-31 sweep, 460 stars): +0.21. Small because + # the Pregius mono QE falls through the NIR, unlike the STARVIS colour + # sensors' NIR-heavy green channel. + color_coefficient=0.21, + # Refit for the growth-curve pipeline from the same single moonlit + # 2025-10-31 sweep vs its 17.8-17.9 hand-held reference (+/-0.2). + # Near zero is physically consistent: the Pregius mono passband is + # the closest of the three sensors to the meter's. + sqm_band_offset=-0.10, ), "imx462": CameraProfile( # Hardware configuration @@ -147,7 +183,7 @@ def __repr__(self) -> str: analog_gain=30.0, digital_gain=1.0, # TODO: find optimum value bit_depth=12, - bias_offset=50.0, # TODO: measure with dark frames + bias_offset=238.0, # Measured: dark-frame CAL 238.0 + on-sky sweep intercept 238.6 (raw green, gain 30) # Image cropping and orientation crop_y=(50, 50), # Crop vertical edges crop_x=(470, 470), # Crop horizontal edges to square @@ -157,6 +193,16 @@ def __repr__(self) -> str: dark_current_rate=0.05, # Estimated - needs measurement thermal_coeff=0.10, # Typical for CMOS sensors (no sensor temp available) typical_sky_background=21.0, + # Measured on-sky: +0.79 ± 0.04 (bare color sensor, NIR leak over-fluxes + # red stars). Cross-checked against HQ w/ IR-cut (~0.0) and synthetic + # photometry. See docs/adr for the SQM colour-term decision. + color_coefficient=0.8, + # Bare sensor sees NIR sky emission a V-band meter doesn't. Calibrated + # from 6 referenced clear-night sweeps (2026-07-11..16) with the + # growth-curve aperture correction (which measures f=1.0 on this + # optics): residuals +/-0.06. Coupled to the estimator and the + # centroid-excluded annulus background -- recalibrate together. + sqm_band_offset=0.61, ), "imx290": CameraProfile( # Hardware configuration (same as imx462 - driver compatibility) @@ -165,7 +211,7 @@ def __repr__(self) -> str: analog_gain=30.0, digital_gain=1.0, # TODO: find optimum value bit_depth=12, - bias_offset=50.0, # TODO: measure with dark frames + bias_offset=238.0, # Measured: dark-frame CAL 238.0 + on-sky sweep intercept 238.6 (raw green, gain 30) # Image cropping and orientation (same as imx462) crop_y=(50, 50), # Crop vertical edges crop_x=(470, 470), # Crop horizontal edges to square @@ -175,6 +221,9 @@ def __repr__(self) -> str: dark_current_rate=0.04, # Estimated - needs measurement thermal_coeff=0.10, # Typical for CMOS sensors (no sensor temp available) typical_sky_background=21.0, + # Same sensor family/optics as imx462 (driver-compatible), same NIR leak. + color_coefficient=0.8, + sqm_band_offset=0.61, # mirror of imx462 (same sensor family, no sweeps yet) ), "hq": CameraProfile( # Hardware configuration @@ -193,62 +242,17 @@ def __repr__(self) -> str: dark_current_rate=0.02, # Estimated - needs measurement thermal_coeff=0.09, # Typical for CMOS sensors (no sensor temp available) typical_sky_background=21.0, - ), - # Processed image profiles (8-bit images after camera.capture() processing) - # These have been rescaled to 0-255 but still have residual offset from: - # - Imperfect bias subtraction in camera.capture() - # - Quantization floor after 8-bit conversion - # - Read noise floor - # Measured from actual processed images: darkest pixels ~9-12 ADU - # Use conservative offset to avoid over-subtraction - # Note: Hardware fields not used for processed images (already 8-bit) - "imx296_processed": CameraProfile( - format="L", # 8-bit grayscale (not used) - raw_size=(512, 512), # Already processed size (not used) - analog_gain=1.0, # Not applicable to processed images - digital_gain=1.0, # Already applied during processing - bit_depth=8, - bias_offset=6.0, # Calibrated against reference SQM meter - read_noise_adu=1.5, # Quantization + residual noise in 8-bit - dark_current_rate=0.0, # Negligible after processing - thermal_coeff=0.0, - typical_sky_background=21.0, - ), - "imx462_processed": CameraProfile( - format="L", - raw_size=(512, 512), - analog_gain=1.0, - digital_gain=1.0, - bit_depth=8, - bias_offset=8.0, - read_noise_adu=1.5, - dark_current_rate=0.0, - thermal_coeff=0.0, - typical_sky_background=21.0, - ), - "imx290_processed": CameraProfile( - format="L", - raw_size=(512, 512), - analog_gain=1.0, - digital_gain=1.0, - bit_depth=8, - bias_offset=8.0, - read_noise_adu=1.5, - dark_current_rate=0.0, - thermal_coeff=0.0, - typical_sky_background=21.0, - ), - "hq_processed": CameraProfile( - format="L", - raw_size=(512, 512), - analog_gain=1.0, - digital_gain=1.0, - bit_depth=8, - bias_offset=8.0, - read_noise_adu=1.5, - dark_current_rate=0.0, - thermal_coeff=0.0, - typical_sky_background=21.0, + # Measured on-sky: -0.05 ± 0.01 -> effectively 0. HQ ships with a factory + # IR-cut filter, so no NIR leak and the green passband ~ Johnson V. + color_coefficient=0.0, + # Calibrated from 3 independent clear-night reference readings + # (2025-11-16, 2025-11-18, 2026-07-16) with the growth-curve aperture + # correction (this optics shows mild, focus-dependent wings, + # f 0.87-1.0, measured per session). Residuals within +/-0.2; the + # shared 2025-11-16 reading remains the outlier. Non-zero despite the + # IR-cut: the residual absorbs passband + optics differences vs the + # meter. Coupled to the estimator -- recalibrate together. + sqm_band_offset=0.60, ), } @@ -275,6 +279,7 @@ def detect_camera_type(hardware_id: str) -> str: # Mapping of hardware ID substrings to profile names hardware_mappings = { "imx296": "imx296", + "imx462": "imx462", # Sensor self-reports as imx462 "imx290": "imx462", # IMX290 uses IMX462 profile (driver compatibility) "imx477": "hq", } @@ -296,20 +301,13 @@ def get_camera_profile(camera_type: str) -> CameraProfile: Get the noise profile for a camera type. Args: - camera_type: Camera model identifier - Raw sensors: imx296, imx462, imx290, hq - Processed (8-bit): imx296_processed, imx462_processed, imx290_processed, hq_processed + camera_type: Camera model identifier (imx296, imx462, imx290, hq) Returns: CameraNoiseProfile for the camera Raises: ValueError: If camera type is not recognized - - Note: - Use "_processed" variants when working with 8-bit images that have already - had bias offset subtracted and been scaled (e.g., from camera.capture()). - Use raw variants only when working with unprocessed sensor data. """ if camera_type not in CAMERA_PROFILES: raise ValueError( diff --git a/python/PiFinder/sqm/color_index.py b/python/PiFinder/sqm/color_index.py new file mode 100644 index 000000000..06ebdf0fc --- /dev/null +++ b/python/PiFinder/sqm/color_index.py @@ -0,0 +1,137 @@ +""" +Hipparcos B-V colour indices for SQM colour correction. + +The tetra3 catalog magnitude is Johnson V, but SQM measures flux in the sensor's +own passband. On a sensor without an IR-cut filter the near-IR leak over-fluxes +red stars, so the effective catalog magnitude is ``V - T*(B-V)`` (see +``camera_profiles.color_coefficient``). This module supplies the per-star B-V +keyed by HIP number (the ``matched_catID`` returned by tetra3 solves). + +B-V is read from ``astro_data/hip_main.dat`` field 37 and cached as a compact +sorted ``.npz`` (~0.5 MB) so the ~1s fixed-width parse only happens once, the +same pattern ``plot.py`` uses for the Hipparcos DataFrame. +""" + +import logging +from pathlib import Path +from typing import Optional + +import numpy as np + +from PiFinder import utils + +logger = logging.getLogger("SQM.ColorIndex") + +# Pipe-separated field indices in hip_main.dat +_HIP_FIELD = 1 +_BV_FIELD = 37 + +_HIP_SORTED: Optional[np.ndarray] = None +_BV_SORTED: Optional[np.ndarray] = None + + +def _dat_path() -> Path: + return Path(utils.astro_data_dir, "hip_main.dat") + + +def _cache_path() -> Path: + return Path(utils.data_dir, "cache", "hip_bv.npz") + + +def _parse_dat(dat_path: Path): + """Parse HIP -> B-V from hip_main.dat. Returns (hip_sorted, bv_sorted).""" + hips = [] + bvs = [] + with open(dat_path) as f: + for line in f: + fields = line.split("|") + if len(fields) <= _BV_FIELD: + continue + try: + hip = int(fields[_HIP_FIELD]) + except ValueError: + continue + try: + bv = float(fields[_BV_FIELD]) + except ValueError: + continue # blank/missing B-V (~0.1% of stars) + hips.append(hip) + bvs.append(bv) + + hip_arr = np.asarray(hips, dtype=np.uint32) + bv_arr = np.asarray(bvs, dtype=np.float32) + order = np.argsort(hip_arr) + return hip_arr[order], bv_arr[order] + + +def _load() -> None: + """Populate the module-level sorted lookup arrays (cached on disk).""" + global _HIP_SORTED, _BV_SORTED + if _HIP_SORTED is not None: + return + + dat_path = _dat_path() + cache_path = _cache_path() + + if ( + cache_path.exists() + and dat_path.exists() + and cache_path.stat().st_mtime >= dat_path.stat().st_mtime + ): + try: + data = np.load(cache_path) + _HIP_SORTED = data["hip"] + _BV_SORTED = data["bv"] + logger.info("Loaded B-V cache: %s (%d stars)", cache_path, len(_HIP_SORTED)) + return + except Exception as e: + logger.warning("B-V cache unreadable, reparsing %s: %s", dat_path, e) + + if not dat_path.exists(): + logger.warning( + "hip_main.dat not found at %s; B-V correction disabled", dat_path + ) + _HIP_SORTED = np.empty(0, dtype=np.uint32) + _BV_SORTED = np.empty(0, dtype=np.float32) + return + + logger.info("Parsing B-V from %s", dat_path) + _HIP_SORTED, _BV_SORTED = _parse_dat(dat_path) + + utils.create_path(cache_path.parent) + try: + np.savez(cache_path, hip=_HIP_SORTED, bv=_BV_SORTED) + logger.info("Wrote B-V cache: %s (%d stars)", cache_path, len(_HIP_SORTED)) + except OSError as e: + logger.warning("Failed to write B-V cache %s: %s", cache_path, e) + + +def get_bv(hip_ids) -> np.ndarray: + """ + Look up B-V for an array of HIP numbers. + + Args: + hip_ids: iterable of HIP catalog numbers (tetra3 ``matched_catID``). + + Returns: + float array of B-V, ``nan`` where the star is missing or has no B-V. + """ + _load() + ids = np.asarray(list(hip_ids), dtype=np.int64) + out = np.full(ids.shape, np.nan, dtype=np.float32) + if _HIP_SORTED is None or _BV_SORTED is None or len(_HIP_SORTED) == 0: + return out + + pos = np.searchsorted(_HIP_SORTED, ids) + in_range = pos < len(_HIP_SORTED) + pos_clamped = np.where(in_range, pos, 0) + hit = in_range & (_HIP_SORTED[pos_clamped] == ids) + out[hit] = _BV_SORTED[pos_clamped[hit]] + return out + + +def reset_cache() -> None: + """Drop the in-process cache (for tests).""" + global _HIP_SORTED, _BV_SORTED + _HIP_SORTED = None + _BV_SORTED = None diff --git a/python/PiFinder/sqm/save_sweep_metadata.py b/python/PiFinder/sqm/save_sweep_metadata.py index 293131716..7b1ea330c 100644 --- a/python/PiFinder/sqm/save_sweep_metadata.py +++ b/python/PiFinder/sqm/save_sweep_metadata.py @@ -10,6 +10,8 @@ from typing import Optional, Dict, Any import pytz +from .sqm import SQM + logger = logging.getLogger("SweepMetadata") @@ -90,6 +92,21 @@ def save_sweep_metadata( # Fallback: just record camera type if no noise floor details metadata["noise_floor_estimator"] = {"camera_type": camera_type} + # Record the effective photometry constants so the sweep is + # self-describing: bias_offset is calibration-aware (SQM loads the + # per-camera calibration JSON on init), not just the profile default. + if camera_type is not None: + try: + profile = SQM(camera_type).profile + metadata["camera"] = { + "type": camera_type, + "bias_offset": profile.bias_offset, + "sqm_band_offset": profile.sqm_band_offset, + "color_coefficient": profile.color_coefficient, + } + except Exception as e: + logger.warning(f"Could not record camera constants: {e}") + if notes: metadata["notes"] = notes diff --git a/python/PiFinder/sqm/sqm.ipynb b/python/PiFinder/sqm/sqm.ipynb index 490d27c57..8d37e393d 100644 --- a/python/PiFinder/sqm/sqm.ipynb +++ b/python/PiFinder/sqm/sqm.ipynb @@ -32,9 +32,11 @@ "import logging as logger\n", "from pathlib import Path\n", "import matplotlib.pyplot as plt\n", + "\n", "%matplotlib inline\n", "import pprint\n", - "pp = pprint.PrettyPrinter(depth=5)\n" + "\n", + "pp = pprint.PrettyPrinter(depth=5)" ] }, { @@ -70,33 +72,28 @@ } ], "source": [ - "os.chdir('/Users/mike/dev/amateur_astro/myPiFinder/wt-sqm/python')\n", + "os.chdir(\"/Users/mike/dev/amateur_astro/myPiFinder/wt-sqm/python\")\n", "cwd = Path(os.getcwd())\n", "print(cwd)\n", - "tetra3_path = cwd / \"PiFinder/tetra3/tetra3\"\n", - "root_path = cwd / '..'\n", - "\n", - "# Add it only once if it's not already there\n", - "if str(tetra3_path) not in sys.path:\n", - " sys.path.append(str(tetra3_path))\n", + "root_path = cwd / \"..\"\n", "\n", "# Silence tetra3 DEBUG output BEFORE importing tetra3\n", "import logging\n", + "\n", "logging.basicConfig(level=logging.WARNING)\n", - "logging.getLogger('tetra3.Tetra3').setLevel(logging.WARNING)\n", - "logging.getLogger('Solver').setLevel(logging.WARNING)\n", + "logging.getLogger(\"tetra3.Tetra3\").setLevel(logging.WARNING)\n", + "logging.getLogger(\"Solver\").setLevel(logging.WARNING)\n", "\n", "# Now try importing\n", "\n", "\n", - "import PiFinder.tetra3.tetra3 as tetra3\n", - "from PiFinder.tetra3.tetra3 import cedar_detect_client\n", + "import tetra3\n", + "from tetra3 import cedar_detect_client\n", "from PiFinder import utils\n", + "\n", "os_detail, platform, arch = utils.get_os_info()\n", "\n", - "t3 = tetra3.Tetra3(\n", - " str(tetra3_path / \"data/default_database.npz\")\n", - ")\n", + "t3 = tetra3.Tetra3(\"default_database\")\n", "\n", "logger.info(\"Starting Solver Loop\")\n", "# Start cedar detect server\n", @@ -160,26 +157,26 @@ "outputs": [], "source": [ "images = {\n", - " 'sqm1833.png': {'realsqm': 18.33},\n", - " 'sqm1837.png': {'realsqm': 18.37},\n", - " 'sqm1845.png': {'realsqm': 18.45},\n", - " 'sqm1855.png': {'realsqm': 18.55},\n", - " 'sqm1860.png': {'realsqm': 18.60},\n", - " 'sqm1870.png': {'realsqm': 18.70},\n", - " 'sqm1980.png': {'realsqm': 19.80},\n", - " 'sqm2000_0.8-4.png': {'realsqm': 20.00},\n", - " 'sqm2000_0.8-3.png': {'realsqm': 20.00},\n", - " 'sqm1818_raw_new_0.2.png': {'realsqm': 18.18}, \n", - " 'sqm1818_raw_new_1.png': {'realsqm': 18.18}\n", + " \"sqm1833.png\": {\"realsqm\": 18.33},\n", + " \"sqm1837.png\": {\"realsqm\": 18.37},\n", + " \"sqm1845.png\": {\"realsqm\": 18.45},\n", + " \"sqm1855.png\": {\"realsqm\": 18.55},\n", + " \"sqm1860.png\": {\"realsqm\": 18.60},\n", + " \"sqm1870.png\": {\"realsqm\": 18.70},\n", + " \"sqm1980.png\": {\"realsqm\": 19.80},\n", + " \"sqm2000_0.8-4.png\": {\"realsqm\": 20.00},\n", + " \"sqm2000_0.8-3.png\": {\"realsqm\": 20.00},\n", + " \"sqm1818_raw_new_0.2.png\": {\"realsqm\": 18.18},\n", + " \"sqm1818_raw_new_1.png\": {\"realsqm\": 18.18},\n", "}\n", "\n", "#\n", "# {\n", - "# 'sqmbla.png' : {'realsqm': 18.44, \n", + "# 'sqmbla.png' : {'realsqm': 18.44,\n", "#\n", "#\n", - "#images = {'sqm1833.png': images['sqm1833.png']}\n", - "#images = {'sqm1837.png': images['sqm1837.png']}" + "# images = {'sqm1833.png': images['sqm1833.png']}\n", + "# images = {'sqm1837.png': images['sqm1837.png']}" ] }, { @@ -197,7 +194,7 @@ "metadata": {}, "outputs": [], "source": [ - "def load_image(current_image, image_path = Path('../test_images/')):\n", + "def load_image(current_image, image_path=Path(\"../test_images/\")):\n", " img = Image.open(image_path / current_image)\n", " rgb_np_image = np.asarray(img, dtype=np.uint8)\n", " np_image = rgb_np_image[:, :, 0] # Takes just the red values\n", @@ -205,16 +202,18 @@ " # np_image = ((stretched - stretched.min()) * (255.0/(stretched.max() - stretched.min()))).astype(np.uint8)\n", " return np_image, img\n", "\n", + "\n", "def show_image(image):\n", - " plt.imshow(image, cmap='gray')\n", + " plt.imshow(image, cmap=\"gray\")\n", " plt.title(\"Test image\")\n", " plt.colorbar()\n", - " plt.show() \n", + " plt.show()\n", + "\n", "\n", "# To use just one specific method:\n", "def percentile_stretch(image, name, low=5, high=99):\n", " p_low, p_high = np.percentile(image, (low, high))\n", - " plt.imshow(image, cmap='gray', vmin=p_low, vmax=p_high)\n", + " plt.imshow(image, cmap=\"gray\", vmin=p_low, vmax=p_high)\n", " plt.title(name)\n", " plt.colorbar()\n", " plt.show()" @@ -536,7 +535,7 @@ "for filename in images:\n", " print(f\"{filename}\")\n", " np_image, image = load_image(filename)\n", - " images[filename]['np_image'] = np_image\n", + " images[filename][\"np_image\"] = np_image\n", " show_image(np_image)\n", " percentile_stretch(np_image, filename)" ] @@ -592,10 +591,10 @@ " fov_max_error=4.0,\n", " match_max_error=0.005,\n", " return_matches=True,\n", - " target_pixel=(128,128),\n", + " target_pixel=(128, 128),\n", " solve_timeout=1000,\n", " )\n", - " \n", + "\n", " if \"matched_centroids\" in solution:\n", " # Don't clutter printed solution with these fields.\n", " # del solution['matched_centroids']\n", @@ -607,13 +606,16 @@ " del solution[\"cache_hit_fraction\"]\n", " return centroids, solution\n", "\n", - "for key, value in images.items(): \n", - " centroids, solution = detect(value['np_image'])\n", - " value['centroids'] = centroids # Store ALL detected centroids\n", - " value['matched_stars'] = solution['matched_stars']\n", - " value['matched_centroids'] = solution['matched_centroids']\n", - " value['fov'] = solution['FOV']\n", - " print(f\"For {key}, there are {len(value['matched_stars'])} matched_stars and {len(centroids)} total centroids\")" + "\n", + "for key, value in images.items():\n", + " centroids, solution = detect(value[\"np_image\"])\n", + " value[\"centroids\"] = centroids # Store ALL detected centroids\n", + " value[\"matched_stars\"] = solution[\"matched_stars\"]\n", + " value[\"matched_centroids\"] = solution[\"matched_centroids\"]\n", + " value[\"fov\"] = solution[\"FOV\"]\n", + " print(\n", + " f\"For {key}, there are {len(value['matched_stars'])} matched_stars and {len(centroids)} total centroids\"\n", + " )" ] }, { @@ -632,11 +634,11 @@ "outputs": [], "source": [ "def enhance_centroids(value: dict):\n", - " matched_centroids = value['matched_centroids']\n", - " matched_stars = value['matched_stars']\n", + " matched_centroids = value[\"matched_centroids\"]\n", + " matched_stars = value[\"matched_stars\"]\n", " xymags = []\n", " for centr, stars in zip(matched_centroids, matched_stars):\n", - " xymags.append([*centr,*stars])\n", + " xymags.append([*centr, *stars])\n", " xymags = np.array(xymags)\n", " xymags_sorted = xymags[xymags[:, 4].argsort()]\n", " # pixel_x, pixel_y - sorted\n", @@ -645,16 +647,16 @@ " matched_stars_s = [[x[2], x[3], x[4]] for x in xymags_sorted]\n", " # pixel_x, pixel_y, mag - sorted\n", " matched = [[x[0], x[1], x[4]] for x in xymags_sorted]\n", - " value['matched_centroids'] = matched_centroids_s\n", - " value['matched_stars'] = matched_stars_s\n", - " value['matched'] = matched\n", + " value[\"matched_centroids\"] = matched_centroids_s\n", + " value[\"matched_stars\"] = matched_stars_s\n", + " value[\"matched\"] = matched\n", " return value\n", - " \n", + "\n", + "\n", "for key, value in images.items():\n", " images[key] = enhance_centroids(value)\n", "\n", - "#pp.pprint(images)\n", - "\n" + "# pp.pprint(images)" ] }, { @@ -685,15 +687,16 @@ "source": [ "radius = 4\n", "plt.title(f\"circles with radius {radius}\")\n", - "plt.imshow(np.log1p(np_image), cmap='gray')\n", + "plt.imshow(np.log1p(np_image), cmap=\"gray\")\n", "plt.colorbar()\n", "# Add circles\n", "for i, (y, x) in enumerate(centroids):\n", - " circle = plt.Circle((x, y), radius, fill=False, color='red')\n", + " circle = plt.Circle((x, y), radius, fill=False, color=\"red\")\n", " plt.gca().add_artist(circle)\n", - " # Add number annotation\n", - " plt.annotate(str(i), (x, y), color='yellow', fontsize=8, \n", - " ha='right', va='top') # ha/va center the text on the point\n", + " # Add number annotation\n", + " plt.annotate(\n", + " str(i), (x, y), color=\"yellow\", fontsize=8, ha=\"right\", va=\"top\"\n", + " ) # ha/va center the text on the point\n", "plt.show()" ] }, @@ -729,31 +732,38 @@ "def histogram(image):\n", " # Method 1: Using PIL's built-in histogram\n", " hist = image.histogram()\n", - " \n", + "\n", " # Method 2: Better visualization with matplotlib\n", " np_image = np.array(image)\n", - " \n", + "\n", " plt.figure(figsize=(10, 6))\n", " plt.hist(np_image.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - " plt.xlabel('Pixel Value')\n", - " plt.ylabel('Frequency')\n", - " plt.title('Image Histogram')\n", + " plt.xlabel(\"Pixel Value\")\n", + " plt.ylabel(\"Frequency\")\n", + " plt.title(\"Image Histogram\")\n", " plt.grid(True, alpha=0.2)\n", - " \n", + "\n", " # Optional: Add vertical line for mean\n", " mean_val = np_image.mean()\n", - " plt.axvline(mean_val, color='r', linestyle='dashed', alpha=0.5, \n", - " label=f'Mean: {mean_val:.1f}')\n", + " plt.axvline(\n", + " mean_val,\n", + " color=\"r\",\n", + " linestyle=\"dashed\",\n", + " alpha=0.5,\n", + " label=f\"Mean: {mean_val:.1f}\",\n", + " )\n", " plt.legend()\n", - " \n", + "\n", " plt.show()\n", - " \n", + "\n", " # Print some statistics\n", " print(f\"Min: {np_image.min()}\")\n", " print(f\"Max: {np_image.max()}\")\n", " print(f\"Mean: {np_image.mean():.2f}\")\n", " print(f\"Median: {np.median(np_image):.2f}\")\n", " print(f\"Std Dev: {np_image.std():.2f}\")\n", + "\n", + "\n", "histogram(image)" ] }, @@ -795,19 +805,21 @@ "\n", "plt.subplot(121)\n", "plt.hist(np_array.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - "plt.title('Original Histogram')\n", - "plt.xlabel('Pixel Value')\n", - "plt.ylabel('Frequency')\n", + "plt.title(\"Original Histogram\")\n", + "plt.xlabel(\"Pixel Value\")\n", + "plt.ylabel(\"Frequency\")\n", "\n", "# Linear stretch (normalize to 0-255)\n", "stretched = np_array.astype(float)\n", - "stretched = ((stretched - stretched.min()) * (255.0/(stretched.max() - stretched.min()))).astype(np.uint8)\n", + "stretched = (\n", + " (stretched - stretched.min()) * (255.0 / (stretched.max() - stretched.min()))\n", + ").astype(np.uint8)\n", "\n", "plt.subplot(122)\n", "plt.hist(stretched.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - "plt.title('Stretched Histogram')\n", - "plt.xlabel('Pixel Value')\n", - "plt.ylabel('Frequency')\n", + "plt.title(\"Stretched Histogram\")\n", + "plt.xlabel(\"Pixel Value\")\n", + "plt.ylabel(\"Frequency\")\n", "\n", "plt.tight_layout()\n", "plt.show()\n", @@ -910,46 +922,52 @@ "\n", "# Parameters for local background measurement\n", "APERTURE_RADIUS = 5 # Star flux aperture (pixels)\n", - "ANNULUS_INNER = 6 # Inner radius of background annulus (pixels)\n", - "ANNULUS_OUTER = 14 # Outer radius of background annulus (pixels)\n", - "ALTITUDE = 90 # Zenith for now (no extinction correction until we have real altitude)\n", - "PEDESTAL = 0 # No pedestal correction for now\n", + "ANNULUS_INNER = 6 # Inner radius of background annulus (pixels)\n", + "ANNULUS_OUTER = 14 # Outer radius of background annulus (pixels)\n", + "ALTITUDE = 90 # Zenith for now (no extinction correction until we have real altitude)\n", + "PEDESTAL = 0 # No pedestal correction for now\n", "\n", "print(\"Production SQM Implementation Results (Local Annulus Backgrounds)\")\n", "print(\"=\" * 100)\n", - "print(f\"{'Image':<25} {'Expected':<12} {'Calculated':<12} {'Error':<12} {'Error %':<12}\")\n", + "print(\n", + " f\"{'Image':<25} {'Expected':<12} {'Calculated':<12} {'Error':<12} {'Error %':<12}\"\n", + ")\n", "print(\"-\" * 100)\n", "\n", "for key, value in images.items():\n", " # Build solution dict from the existing data\n", " solution = {\n", - " 'FOV': value['fov'],\n", - " 'matched_centroids': value['matched_centroids'],\n", - " 'matched_stars': value['matched_stars']\n", + " \"FOV\": value[\"fov\"],\n", + " \"matched_centroids\": value[\"matched_centroids\"],\n", + " \"matched_stars\": value[\"matched_stars\"],\n", " }\n", - " \n", + "\n", " # Calculate SQM using local annulus backgrounds\n", " sqm_val, details = sqm.calculate(\n", - " centroids=value['centroids'],\n", + " centroids=value[\"centroids\"],\n", " solution=solution,\n", - " image=value['np_image'], \n", + " image=value[\"np_image\"],\n", " altitude_deg=ALTITUDE,\n", " aperture_radius=APERTURE_RADIUS,\n", " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", - " pedestal=PEDESTAL\n", + " pedestal=PEDESTAL,\n", " )\n", - " \n", + "\n", " if sqm_val is not None:\n", - " value['sqm_calculated'] = sqm_val\n", - " value['sqm_details'] = details\n", - " \n", - " expected = value['realsqm']\n", + " value[\"sqm_calculated\"] = sqm_val\n", + " value[\"sqm_details\"] = details\n", + "\n", + " expected = value[\"realsqm\"]\n", " calc_err = sqm_val - expected\n", " err_pct = 100 * calc_err / expected\n", - " \n", - " print(f\"{key:<25} {expected:>10.2f} {sqm_val:>10.2f} {calc_err:>10.2f} {err_pct:>10.1f}%\")\n", - " print(f\"{'':>25} mzero={details['mzero']:>6.2f}, bg={details['background_per_pixel']:>6.1f} ADU/px, {details['n_matched_stars']} stars\")\n", + "\n", + " print(\n", + " f\"{key:<25} {expected:>10.2f} {sqm_val:>10.2f} {calc_err:>10.2f} {err_pct:>10.1f}%\"\n", + " )\n", + " print(\n", + " f\"{'':>25} mzero={details['mzero']:>6.2f}, bg={details['background_per_pixel']:>6.1f} ADU/px, {details['n_matched_stars']} stars\"\n", + " )\n", " else:\n", " print(f\"{key:<25} FAILED\")\n", "\n", @@ -1053,17 +1071,17 @@ "\n", "# Define all test images\n", "all_images = {\n", - " 'sqm1833.png': {'realsqm': 18.33},\n", - " 'sqm1837.png': {'realsqm': 18.37},\n", - " 'sqm1845.png': {'realsqm': 18.45},\n", - " 'sqm1855.png': {'realsqm': 18.55},\n", - " 'sqm1860.png': {'realsqm': 18.60},\n", - " 'sqm1870.png': {'realsqm': 18.70},\n", - " 'sqm1980.png': {'realsqm': 19.80},\n", - " 'sqm2000_0.8-4.png': {'realsqm': 20.00},\n", - " 'sqm2000_0.8-3.png': {'realsqm': 20.00},\n", - " 'sqm1818_raw_new_0.2.png': {'realsqm': 18.18}, \n", - " 'sqm1818_raw_new_1.png': {'realsqm': 18.18}\n", + " \"sqm1833.png\": {\"realsqm\": 18.33},\n", + " \"sqm1837.png\": {\"realsqm\": 18.37},\n", + " \"sqm1845.png\": {\"realsqm\": 18.45},\n", + " \"sqm1855.png\": {\"realsqm\": 18.55},\n", + " \"sqm1860.png\": {\"realsqm\": 18.60},\n", + " \"sqm1870.png\": {\"realsqm\": 18.70},\n", + " \"sqm1980.png\": {\"realsqm\": 19.80},\n", + " \"sqm2000_0.8-4.png\": {\"realsqm\": 20.00},\n", + " \"sqm2000_0.8-3.png\": {\"realsqm\": 20.00},\n", + " \"sqm1818_raw_new_0.2.png\": {\"realsqm\": 18.18},\n", + " \"sqm1818_raw_new_1.png\": {\"realsqm\": 18.18},\n", "}\n", "\n", "# Parameters for local annulus background\n", @@ -1081,25 +1099,27 @@ "\n", "for filename, info in all_images.items():\n", " print(f\"\\nProcessing {filename}...\")\n", - " \n", + "\n", " # Load image\n", " np_image, _ = load_image(filename)\n", - " \n", + "\n", " # Detect stars and solve\n", " centroids, solution = detect(np_image)\n", - " \n", + "\n", " # Check if solve succeeded\n", - " if 'matched_centroids' not in solution or len(solution['matched_centroids']) == 0:\n", + " if \"matched_centroids\" not in solution or len(solution[\"matched_centroids\"]) == 0:\n", " print(\" ❌ Failed to solve\")\n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': None,\n", - " 'error': None,\n", - " 'status': 'SOLVE_FAILED'\n", - " })\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": None,\n", + " \"error\": None,\n", + " \"status\": \"SOLVE_FAILED\",\n", + " }\n", + " )\n", " continue\n", - " \n", + "\n", " # Calculate SQM\n", " sqm_val, details = sqm.calculate(\n", " centroids=centroids,\n", @@ -1109,33 +1129,41 @@ " aperture_radius=APERTURE_RADIUS,\n", " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", - " pedestal=PEDESTAL\n", + " pedestal=PEDESTAL,\n", " )\n", - " \n", + "\n", " if sqm_val is not None:\n", - " error = sqm_val - info['realsqm']\n", - " print(f\" ✓ SQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {error:+.2f})\")\n", - " print(f\" mzero={details['mzero']:.2f}, stars={details['n_matched_stars']}, centroids={details['n_centroids']}\")\n", - " \n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': sqm_val,\n", - " 'error': error,\n", - " 'mzero': details['mzero'],\n", - " 'n_stars': details['n_matched_stars'],\n", - " 'n_centroids': details['n_centroids'],\n", - " 'status': 'OK'\n", - " })\n", + " error = sqm_val - info[\"realsqm\"]\n", + " print(\n", + " f\" ✓ SQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {error:+.2f})\"\n", + " )\n", + " print(\n", + " f\" mzero={details['mzero']:.2f}, stars={details['n_matched_stars']}, centroids={details['n_centroids']}\"\n", + " )\n", + "\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": sqm_val,\n", + " \"error\": error,\n", + " \"mzero\": details[\"mzero\"],\n", + " \"n_stars\": details[\"n_matched_stars\"],\n", + " \"n_centroids\": details[\"n_centroids\"],\n", + " \"status\": \"OK\",\n", + " }\n", + " )\n", " else:\n", " print(\" ❌ Failed to calculate SQM\")\n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': None,\n", - " 'error': None,\n", - " 'status': 'CALC_FAILED'\n", - " })\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": None,\n", + " \"error\": None,\n", + " \"status\": \"CALC_FAILED\",\n", + " }\n", + " )\n", "\n", "print(\"\\n\" + \"=\" * 100)\n", "print(\"SUMMARY\")\n", @@ -1144,22 +1172,28 @@ "print(\"-\" * 100)\n", "\n", "for result in results_summary:\n", - " if result['status'] == 'OK':\n", - " print(f\"{result['filename']:<30} {result['expected']:>10.2f} {result['calculated']:>10.2f} {result['error']:>10.2f} {result['status']:<15}\")\n", + " if result[\"status\"] == \"OK\":\n", + " print(\n", + " f\"{result['filename']:<30} {result['expected']:>10.2f} {result['calculated']:>10.2f} {result['error']:>10.2f} {result['status']:<15}\"\n", + " )\n", " else:\n", - " print(f\"{result['filename']:<30} {result['expected']:>10.2f} {'---':>10} {'---':>10} {result['status']:<15}\")\n", + " print(\n", + " f\"{result['filename']:<30} {result['expected']:>10.2f} {'---':>10} {'---':>10} {result['status']:<15}\"\n", + " )\n", "\n", "# Calculate statistics for successful measurements\n", - "successful = [r for r in results_summary if r['status'] == 'OK']\n", + "successful = [r for r in results_summary if r[\"status\"] == \"OK\"]\n", "if successful:\n", - " errors = [r['error'] for r in successful]\n", + " errors = [r[\"error\"] for r in successful]\n", " print(\"\\n\" + \"=\" * 100)\n", " print(\"STATISTICS\")\n", " print(\"=\" * 100)\n", " print(f\"Successful measurements: {len(successful)}/{len(results_summary)}\")\n", " print(f\"Mean error: {np.mean(errors):+.2f} mag/arcsec²\")\n", " print(f\"Std dev: {np.std(errors):.2f} mag/arcsec²\")\n", - " print(f\"RMS error: {np.sqrt(np.mean(np.array(errors)**2)):.2f} mag/arcsec²\")\n", + " print(\n", + " f\"RMS error: {np.sqrt(np.mean(np.array(errors) ** 2)):.2f} mag/arcsec²\"\n", + " )\n", " print(f\"Max error: {np.max(np.abs(errors)):.2f} mag/arcsec²\")" ] }, @@ -1329,36 +1363,40 @@ "from matplotlib.gridspec import GridSpec\n", "from scipy import stats\n", "\n", + "\n", "def sigma_clip_mean(data, sigma=2.0, max_iter=3):\n", " \"\"\"Calculate mean after sigma clipping outliers. Returns mean, std, and mask matching input size.\"\"\"\n", " data = np.array(data)\n", " original_indices = np.arange(len(data))\n", " mask = np.ones(len(data), dtype=bool)\n", - " \n", + "\n", " current_data = data.copy()\n", " current_indices = original_indices.copy()\n", - " \n", + "\n", " for _ in range(max_iter):\n", " mean = np.mean(current_data)\n", " std = np.std(current_data)\n", " keep = np.abs(current_data - mean) < sigma * std\n", - " \n", + "\n", " if np.sum(keep) == len(current_data):\n", " break\n", - " \n", + "\n", " current_data = current_data[keep]\n", " current_indices = current_indices[keep]\n", - " \n", + "\n", " # Create mask for original array\n", " final_mask = np.zeros(len(data), dtype=bool)\n", " final_mask[current_indices] = True\n", - " \n", + "\n", " return np.mean(current_data), np.std(current_data), final_mask\n", "\n", - "def detect_aperture_overlaps(star_centroids, aperture_radius, annulus_inner, annulus_outer):\n", + "\n", + "def detect_aperture_overlaps(\n", + " star_centroids, aperture_radius, annulus_inner, annulus_outer\n", + "):\n", " \"\"\"\n", " Detect overlapping apertures and annuli between star pairs.\n", - " \n", + "\n", " Returns list of overlaps with format:\n", " {\n", " 'star1_idx': int,\n", @@ -1370,61 +1408,68 @@ " \"\"\"\n", " overlaps = []\n", " n_stars = len(star_centroids)\n", - " \n", + "\n", " for i in range(n_stars):\n", - " for j in range(i+1, n_stars):\n", + " for j in range(i + 1, n_stars):\n", " x1, y1 = star_centroids[i]\n", " x2, y2 = star_centroids[j]\n", - " distance = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)\n", - " \n", + " distance = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)\n", + "\n", " # Check different overlap types\n", " if distance < 2 * aperture_radius:\n", " # CRITICAL: Aperture-aperture overlap (star flux contamination)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'CRITICAL',\n", - " 'description': f'Aperture overlap (d={distance:.1f}px < {2*aperture_radius}px)'\n", - " })\n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"CRITICAL\",\n", + " \"description\": f\"Aperture overlap (d={distance:.1f}px < {2 * aperture_radius}px)\",\n", + " }\n", + " )\n", " elif distance < aperture_radius + annulus_outer:\n", " # HIGH: Aperture inside another star's annulus (background contamination)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'HIGH',\n", - " 'description': f'Aperture-annulus overlap (d={distance:.1f}px < {aperture_radius + annulus_outer}px)'\n", - " })\n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"HIGH\",\n", + " \"description\": f\"Aperture-annulus overlap (d={distance:.1f}px < {aperture_radius + annulus_outer}px)\",\n", + " }\n", + " )\n", " elif distance < 2 * annulus_outer:\n", " # MEDIUM: Annulus-annulus overlap (less critical)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'MEDIUM',\n", - " 'description': f'Annulus overlap (d={distance:.1f}px < {2*annulus_outer}px)'\n", - " })\n", - " \n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"MEDIUM\",\n", + " \"description\": f\"Annulus overlap (d={distance:.1f}px < {2 * annulus_outer}px)\",\n", + " }\n", + " )\n", + "\n", " return overlaps\n", "\n", + "\n", "# Process each image with full diagnostics\n", "for filename, info in all_images.items():\n", - " print(f\"\\n{'='*100}\")\n", + " print(f\"\\n{'=' * 100}\")\n", " print(f\"Processing: {filename}\")\n", " print(f\"Expected SQM: {info['realsqm']:.2f} mag/arcsec²\")\n", - " print(f\"{'='*100}\\n\")\n", - " \n", + " print(f\"{'=' * 100}\\n\")\n", + "\n", " # Load image\n", " np_image, _ = load_image(filename)\n", - " \n", + "\n", " # Detect stars and solve\n", " centroids, solution = detect(np_image)\n", - " \n", - " if 'matched_centroids' not in solution or len(solution['matched_centroids']) == 0:\n", + "\n", + " if \"matched_centroids\" not in solution or len(solution[\"matched_centroids\"]) == 0:\n", " print(f\"❌ Failed to solve {filename}\\n\")\n", " continue\n", - " \n", + "\n", " # Calculate SQM WITHOUT overlap correction\n", " sqm_val, details = sqm.calculate(\n", " centroids=centroids,\n", @@ -1435,9 +1480,9 @@ " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", " pedestal=PEDESTAL,\n", - " correct_overlaps=False\n", + " correct_overlaps=False,\n", " )\n", - " \n", + "\n", " # Calculate SQM WITH overlap correction\n", " sqm_val_corrected, details_corrected = sqm.calculate(\n", " centroids=centroids,\n", @@ -1448,90 +1493,102 @@ " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", " pedestal=PEDESTAL,\n", - " correct_overlaps=True\n", + " correct_overlaps=True,\n", " )\n", - " \n", + "\n", " if sqm_val is None:\n", " print(f\"❌ Failed to calculate SQM for {filename}\\n\")\n", " continue\n", - " \n", + "\n", " # Extract details (use non-corrected for visualization, but we have both)\n", - " star_centroids = np.array(details['star_centroids'])\n", - " star_mags = details['star_mags']\n", - " star_fluxes = details['star_fluxes']\n", - " star_mzeros = details['star_mzeros']\n", - " star_local_bgs = details.get('star_local_backgrounds', [None] * len(star_mags))\n", - " \n", + " star_centroids = np.array(details[\"star_centroids\"])\n", + " star_mags = details[\"star_mags\"]\n", + " star_fluxes = details[\"star_fluxes\"]\n", + " star_mzeros = details[\"star_mzeros\"]\n", + " star_local_bgs = details.get(\"star_local_backgrounds\", [None] * len(star_mags))\n", + "\n", " # ========== APERTURE OVERLAP DETECTION ==========\n", - " overlaps = detect_aperture_overlaps(star_centroids, APERTURE_RADIUS, ANNULUS_INNER, ANNULUS_OUTER)\n", - " \n", + " overlaps = detect_aperture_overlaps(\n", + " star_centroids, APERTURE_RADIUS, ANNULUS_INNER, ANNULUS_OUTER\n", + " )\n", + "\n", " # Build set of stars affected by overlaps\n", " overlapping_stars = set()\n", " for overlap in overlaps:\n", - " overlapping_stars.add(overlap['star1_idx'])\n", - " overlapping_stars.add(overlap['star2_idx'])\n", - " \n", + " overlapping_stars.add(overlap[\"star1_idx\"])\n", + " overlapping_stars.add(overlap[\"star2_idx\"])\n", + "\n", " # Categorize overlaps by severity\n", - " critical_overlaps = [o for o in overlaps if o['type'] == 'CRITICAL']\n", - " high_overlaps = [o for o in overlaps if o['type'] == 'HIGH']\n", - " medium_overlaps = [o for o in overlaps if o['type'] == 'MEDIUM']\n", - " \n", + " critical_overlaps = [o for o in overlaps if o[\"type\"] == \"CRITICAL\"]\n", + " high_overlaps = [o for o in overlaps if o[\"type\"] == \"HIGH\"]\n", + " medium_overlaps = [o for o in overlaps if o[\"type\"] == \"MEDIUM\"]\n", + "\n", " # Print overlap summary\n", " if overlaps:\n", " print(f\"⚠️ OVERLAPS DETECTED: {len(overlaps)} total\")\n", " print(f\" CRITICAL (aperture-aperture): {len(critical_overlaps)}\")\n", " print(f\" HIGH (aperture-annulus): {len(high_overlaps)}\")\n", " print(f\" MEDIUM (annulus-annulus): {len(medium_overlaps)}\")\n", - " print(f\" Stars affected: {len(overlapping_stars)}/{len(star_centroids)} ({100*len(overlapping_stars)/len(star_centroids):.0f}%)\")\n", + " print(\n", + " f\" Stars affected: {len(overlapping_stars)}/{len(star_centroids)} ({100 * len(overlapping_stars) / len(star_centroids):.0f}%)\"\n", + " )\n", " print()\n", " else:\n", " print(\"✓ No aperture overlaps detected\\n\")\n", - " \n", + "\n", " # Calculate alternative mzero methods - filter for valid stars (flux > 0 and mzero not None)\n", - " valid_indices = [i for i in range(len(star_fluxes)) \n", - " if star_fluxes[i] > 0 and star_mzeros[i] is not None]\n", + " valid_indices = [\n", + " i\n", + " for i in range(len(star_fluxes))\n", + " if star_fluxes[i] > 0 and star_mzeros[i] is not None\n", + " ]\n", " valid_mzeros = np.array([star_mzeros[i] for i in valid_indices])\n", " valid_mags = np.array([star_mags[i] for i in valid_indices])\n", " valid_fluxes = np.array([star_fluxes[i] for i in valid_indices])\n", - " \n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " mzero_mean = np.mean(valid_mzeros)\n", " mzero_median = np.median(valid_mzeros)\n", " mzero_std = np.std(valid_mzeros)\n", - " \n", + "\n", " # Sigma clipping\n", " if len(valid_mzeros) >= 3:\n", - " mzero_sigclip, mzero_sigclip_std, sigclip_mask = sigma_clip_mean(valid_mzeros, sigma=2.0)\n", + " mzero_sigclip, mzero_sigclip_std, sigclip_mask = sigma_clip_mean(\n", + " valid_mzeros, sigma=2.0\n", + " )\n", " n_clipped = len(valid_mzeros) - np.sum(sigclip_mask)\n", " else:\n", " mzero_sigclip = mzero_mean\n", " mzero_sigclip_std = mzero_std\n", " n_clipped = 0\n", " sigclip_mask = np.ones(len(valid_mzeros), dtype=bool)\n", - " \n", + "\n", " # Trendline correction methods\n", " if len(valid_mzeros) >= 3:\n", " # Method 1: Trendline on all valid stars\n", - " slope_all, intercept_all, r_value_all, _, _ = stats.linregress(valid_mags, valid_mzeros)\n", + " slope_all, intercept_all, r_value_all, _, _ = stats.linregress(\n", + " valid_mags, valid_mzeros\n", + " )\n", " # Evaluate trend at median magnitude\n", " median_mag = np.median(valid_mags)\n", " mzero_trend = slope_all * median_mag + intercept_all\n", - " \n", + "\n", " # Calculate residuals for quality metric\n", " predicted_all = slope_all * valid_mags + intercept_all\n", " residuals_all = valid_mzeros - predicted_all\n", " trend_rms_all = np.sqrt(np.mean(residuals_all**2))\n", - " \n", + "\n", " # Method 2: Sigma clip THEN fit trendline\n", " clipped_mags = valid_mags[sigclip_mask]\n", " clipped_mzeros = valid_mzeros[sigclip_mask]\n", - " \n", + "\n", " if len(clipped_mzeros) >= 3:\n", - " slope_clip, intercept_clip, r_value_clip, _, _ = stats.linregress(clipped_mags, clipped_mzeros)\n", + " slope_clip, intercept_clip, r_value_clip, _, _ = stats.linregress(\n", + " clipped_mags, clipped_mzeros\n", + " )\n", " median_mag_clip = np.median(clipped_mags)\n", " mzero_trend_sigclip = slope_clip * median_mag_clip + intercept_clip\n", - " \n", + "\n", " predicted_clip = slope_clip * clipped_mags + intercept_clip\n", " residuals_clip = clipped_mzeros - predicted_clip\n", " trend_rms_clip = np.sqrt(np.mean(residuals_clip**2))\n", @@ -1552,398 +1609,632 @@ " r_value_clip = 0\n", " trend_rms_all = mzero_std\n", " trend_rms_clip = mzero_sigclip_std\n", - " \n", + "\n", " # Calculate SQM with alternative methods\n", - " bg_flux_density = details['background_flux_density']\n", - " extinction = details['extinction_correction']\n", - " \n", + " bg_flux_density = details[\"background_flux_density\"]\n", + " extinction = details[\"extinction_correction\"]\n", + "\n", " sqm_median = mzero_median - 2.5 * np.log10(bg_flux_density) + extinction\n", " sqm_sigclip = mzero_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", " sqm_trend = mzero_trend - 2.5 * np.log10(bg_flux_density) + extinction\n", - " sqm_trend_sigclip = mzero_trend_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", + " sqm_trend_sigclip = (\n", + " mzero_trend_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", + " )\n", " else:\n", - " mzero_mean = mzero_median = mzero_sigclip = mzero_trend = mzero_trend_sigclip = None\n", + " mzero_mean = mzero_median = mzero_sigclip = mzero_trend = (\n", + " mzero_trend_sigclip\n", + " ) = None\n", " sqm_median = sqm_sigclip = sqm_trend = sqm_trend_sigclip = None\n", " n_clipped = 0\n", " slope_all = slope_clip = 0\n", " r_value_all = r_value_clip = 0\n", - " \n", + "\n", " # Create comprehensive figure with 4x3 grid\n", " fig = plt.figure(figsize=(24, 16))\n", " gs = GridSpec(4, 3, figure=fig, hspace=0.35, wspace=0.3)\n", - " \n", + "\n", " # ========== Panel 1: Image with apertures (spans 2x2) ==========\n", " ax1 = fig.add_subplot(gs[0:2, 0:2])\n", - " \n", + "\n", " # Display image with log stretch\n", " vmin, vmax = np.percentile(np_image, [1, 99.5])\n", - " im = ax1.imshow(np_image, cmap='gray', vmin=vmin, vmax=vmax, origin='lower')\n", - " \n", + " im = ax1.imshow(np_image, cmap=\"gray\", vmin=vmin, vmax=vmax, origin=\"lower\")\n", + "\n", " # Draw connecting lines for overlaps FIRST (so they appear behind circles)\n", " for overlap in overlaps:\n", - " x1, y1 = star_centroids[overlap['star1_idx']]\n", - " x2, y2 = star_centroids[overlap['star2_idx']]\n", - " \n", + " x1, y1 = star_centroids[overlap[\"star1_idx\"]]\n", + " x2, y2 = star_centroids[overlap[\"star2_idx\"]]\n", + "\n", " # Color by severity\n", - " if overlap['type'] == 'CRITICAL':\n", - " line_color = 'red'\n", - " elif overlap['type'] == 'HIGH':\n", - " line_color = 'orange'\n", + " if overlap[\"type\"] == \"CRITICAL\":\n", + " line_color = \"red\"\n", + " elif overlap[\"type\"] == \"HIGH\":\n", + " line_color = \"orange\"\n", " else:\n", - " line_color = 'yellow'\n", - " \n", - " ax1.plot([x1, x2], [y1, y2], color=line_color, linestyle=':', linewidth=2, alpha=0.7)\n", - " \n", + " line_color = \"yellow\"\n", + "\n", + " ax1.plot(\n", + " [x1, x2], [y1, y2], color=line_color, linestyle=\":\", linewidth=2, alpha=0.7\n", + " )\n", + "\n", " # Draw apertures on matched stars\n", - " for i, (centroid, flux, mag, local_bg) in enumerate(zip(star_centroids, star_fluxes, star_mags, star_local_bgs)):\n", + " for i, (centroid, flux, mag, local_bg) in enumerate(\n", + " zip(star_centroids, star_fluxes, star_mags, star_local_bgs)\n", + " ):\n", " x, y = centroid\n", - " \n", + "\n", " # Color code by flux status, outlier detection, and overlap\n", " is_outlier = False\n", " if flux > 0 and mzero_mean is not None and len(star_mzeros) > i:\n", " mzero_val = star_mzeros[i]\n", " is_outlier = abs(mzero_val - mzero_mean) > 2.0 * mzero_std\n", - " \n", + "\n", " is_overlapping = i in overlapping_stars\n", - " \n", + "\n", " if flux <= 0:\n", - " color = 'red'\n", + " color = \"red\"\n", " alpha = 0.8\n", " elif is_overlapping:\n", - " color = 'magenta' # Magenta for overlapping stars\n", + " color = \"magenta\" # Magenta for overlapping stars\n", " alpha = 0.8\n", " elif is_outlier:\n", - " color = 'orange'\n", + " color = \"orange\"\n", " alpha = 0.7\n", " else:\n", - " color = 'lime'\n", + " color = \"lime\"\n", " alpha = 0.6\n", - " \n", + "\n", " # Draw aperture circle (solid)\n", - " circle = mpatches.Circle((x, y), APERTURE_RADIUS, \n", - " fill=False, edgecolor=color, linewidth=2, alpha=alpha)\n", + " circle = mpatches.Circle(\n", + " (x, y),\n", + " APERTURE_RADIUS,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=2,\n", + " alpha=alpha,\n", + " )\n", " ax1.add_patch(circle)\n", - " \n", + "\n", " # Draw annulus inner (dashed)\n", - " annulus_inner = mpatches.Circle((x, y), ANNULUS_INNER,\n", - " fill=False, edgecolor=color, linewidth=1, \n", - " linestyle='--', alpha=0.4)\n", + " annulus_inner = mpatches.Circle(\n", + " (x, y),\n", + " ANNULUS_INNER,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " alpha=0.4,\n", + " )\n", " ax1.add_patch(annulus_inner)\n", - " \n", + "\n", " # Draw annulus outer (dashed)\n", - " annulus_outer_circle = mpatches.Circle((x, y), ANNULUS_OUTER,\n", - " fill=False, edgecolor=color, linewidth=1, \n", - " linestyle='--', alpha=0.4)\n", + " annulus_outer_circle = mpatches.Circle(\n", + " (x, y),\n", + " ANNULUS_OUTER,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " alpha=0.4,\n", + " )\n", " ax1.add_patch(annulus_outer_circle)\n", - " \n", + "\n", " # Label star\n", - " label_text = f'{i}\\nm={mag:.1f}\\nf={flux:.0f}\\nbg={local_bg:.0f}' if local_bg else f'{i}\\nm={mag:.1f}'\n", - " ax1.text(x + ANNULUS_OUTER + 3, y, label_text,\n", - " color=color, fontsize=7, va='center', weight='bold',\n", - " bbox=dict(boxstyle='round,pad=0.3', facecolor='black', alpha=0.5))\n", - " \n", - " ax1.set_title(f'{filename}\\nSQM: {sqm_val:.2f} (expected: {info[\"realsqm\"]:.2f}, error: {sqm_val - info[\"realsqm\"]:+.2f})',\n", - " fontsize=14, weight='bold')\n", - " ax1.set_xlabel('X (pixels)', fontsize=11)\n", - " ax1.set_ylabel('Y (pixels)', fontsize=11)\n", - " plt.colorbar(im, ax=ax1, label='ADU')\n", - " \n", + " label_text = (\n", + " f\"{i}\\nm={mag:.1f}\\nf={flux:.0f}\\nbg={local_bg:.0f}\"\n", + " if local_bg\n", + " else f\"{i}\\nm={mag:.1f}\"\n", + " )\n", + " ax1.text(\n", + " x + ANNULUS_OUTER + 3,\n", + " y,\n", + " label_text,\n", + " color=color,\n", + " fontsize=7,\n", + " va=\"center\",\n", + " weight=\"bold\",\n", + " bbox=dict(boxstyle=\"round,pad=0.3\", facecolor=\"black\", alpha=0.5),\n", + " )\n", + "\n", + " ax1.set_title(\n", + " f\"{filename}\\nSQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {sqm_val - info['realsqm']:+.2f})\",\n", + " fontsize=14,\n", + " weight=\"bold\",\n", + " )\n", + " ax1.set_xlabel(\"X (pixels)\", fontsize=11)\n", + " ax1.set_ylabel(\"Y (pixels)\", fontsize=11)\n", + " plt.colorbar(im, ax=ax1, label=\"ADU\")\n", + "\n", " # Legend\n", " legend_elements = [\n", - " mpatches.Patch(color='lime', label='Valid star'),\n", - " mpatches.Patch(color='magenta', label='Overlapping'),\n", - " mpatches.Patch(color='orange', label='Outlier (|Δmzero| > 2σ)'),\n", - " mpatches.Patch(color='red', label='Bad flux (≤ 0)'),\n", - " mpatches.Circle((0, 0), 1, fill=False, edgecolor='white', linewidth=2, label=f'Aperture (r={APERTURE_RADIUS}px)'),\n", - " mpatches.Circle((0, 0), 1, fill=False, edgecolor='white', linewidth=1, linestyle='--', label=f'Annulus ({ANNULUS_INNER}-{ANNULUS_OUTER}px)')\n", + " mpatches.Patch(color=\"lime\", label=\"Valid star\"),\n", + " mpatches.Patch(color=\"magenta\", label=\"Overlapping\"),\n", + " mpatches.Patch(color=\"orange\", label=\"Outlier (|Δmzero| > 2σ)\"),\n", + " mpatches.Patch(color=\"red\", label=\"Bad flux (≤ 0)\"),\n", + " mpatches.Circle(\n", + " (0, 0),\n", + " 1,\n", + " fill=False,\n", + " edgecolor=\"white\",\n", + " linewidth=2,\n", + " label=f\"Aperture (r={APERTURE_RADIUS}px)\",\n", + " ),\n", + " mpatches.Circle(\n", + " (0, 0),\n", + " 1,\n", + " fill=False,\n", + " edgecolor=\"white\",\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " label=f\"Annulus ({ANNULUS_INNER}-{ANNULUS_OUTER}px)\",\n", + " ),\n", " ]\n", - " ax1.legend(handles=legend_elements, loc='upper right', fontsize=9)\n", - " \n", + " ax1.legend(handles=legend_elements, loc=\"upper right\", fontsize=9)\n", + "\n", " # ========== Panel 2: Per-Star Statistics Table ==========\n", " ax2 = fig.add_subplot(gs[0:2, 2])\n", - " ax2.axis('off')\n", - " \n", + " ax2.axis(\"off\")\n", + "\n", " # Build table data\n", - " table_data = [['#', 'Mag', 'Flux\\n(ADU)', 'Bg\\n(ADU)', 'mzero', 'Δmz', 'OK']]\n", - " for i, (mag, flux, local_bg, mzero) in enumerate(zip(star_mags, star_fluxes, star_local_bgs, star_mzeros)):\n", - " status = '✓' if flux > 0 else '✗'\n", + " table_data = [[\"#\", \"Mag\", \"Flux\\n(ADU)\", \"Bg\\n(ADU)\", \"mzero\", \"Δmz\", \"OK\"]]\n", + " for i, (mag, flux, local_bg, mzero) in enumerate(\n", + " zip(star_mags, star_fluxes, star_local_bgs, star_mzeros)\n", + " ):\n", + " status = \"✓\" if flux > 0 else \"✗\"\n", " delta_mzero = (mzero - mzero_mean) if (flux > 0 and mzero_mean) else None\n", - " \n", - " table_data.append([\n", - " f'{i}',\n", - " f'{mag:.2f}',\n", - " f'{flux:.0f}',\n", - " f'{local_bg:.0f}' if local_bg is not None else 'N/A',\n", - " f'{mzero:.2f}' if flux > 0 else 'N/A',\n", - " f'{delta_mzero:+.2f}' if delta_mzero is not None else 'N/A',\n", - " status\n", - " ])\n", - " \n", + "\n", + " table_data.append(\n", + " [\n", + " f\"{i}\",\n", + " f\"{mag:.2f}\",\n", + " f\"{flux:.0f}\",\n", + " f\"{local_bg:.0f}\" if local_bg is not None else \"N/A\",\n", + " f\"{mzero:.2f}\" if flux > 0 else \"N/A\",\n", + " f\"{delta_mzero:+.2f}\" if delta_mzero is not None else \"N/A\",\n", + " status,\n", + " ]\n", + " )\n", + "\n", " # Create table\n", - " table = ax2.table(cellText=table_data, cellLoc='center', loc='center',\n", - " colWidths=[0.08, 0.12, 0.15, 0.12, 0.12, 0.10, 0.08])\n", + " table = ax2.table(\n", + " cellText=table_data,\n", + " cellLoc=\"center\",\n", + " loc=\"center\",\n", + " colWidths=[0.08, 0.12, 0.15, 0.12, 0.12, 0.10, 0.08],\n", + " )\n", " table.auto_set_font_size(False)\n", " table.set_fontsize(7)\n", " table.scale(1, 1.8)\n", - " \n", + "\n", " # Style header row\n", " for i in range(7):\n", - " table[(0, i)].set_facecolor('#4CAF50')\n", - " table[(0, i)].set_text_props(weight='bold', color='white')\n", - " \n", + " table[(0, i)].set_facecolor(\"#4CAF50\")\n", + " table[(0, i)].set_text_props(weight=\"bold\", color=\"white\")\n", + "\n", " # Color code rows\n", " for i in range(1, len(table_data)):\n", - " flux = star_fluxes[i-1]\n", - " is_overlapping = (i-1) in overlapping_stars\n", - " \n", + " flux = star_fluxes[i - 1]\n", + " is_overlapping = (i - 1) in overlapping_stars\n", + "\n", " if flux <= 0:\n", - " color = '#FFCDD2' # Red\n", + " color = \"#FFCDD2\" # Red\n", " elif is_overlapping:\n", - " color = '#F8BBD0' # Magenta/pink\n", - " elif i-1 < len(star_mzeros) and mzero_mean is not None:\n", - " delta = abs(star_mzeros[i-1] - mzero_mean)\n", + " color = \"#F8BBD0\" # Magenta/pink\n", + " elif i - 1 < len(star_mzeros) and mzero_mean is not None:\n", + " delta = abs(star_mzeros[i - 1] - mzero_mean)\n", " if delta > 2.0 * mzero_std:\n", - " color = '#FFE0B2' # Orange\n", + " color = \"#FFE0B2\" # Orange\n", " elif delta > 1.0 * mzero_std:\n", - " color = '#FFF9C4' # Yellow\n", + " color = \"#FFF9C4\" # Yellow\n", " else:\n", - " color = '#E8F5E9' # Green\n", + " color = \"#E8F5E9\" # Green\n", " else:\n", - " color = 'white'\n", - " \n", + " color = \"white\"\n", + "\n", " for j in range(7):\n", " table[(i, j)].set_facecolor(color)\n", - " \n", - " ax2.set_title('Per-Star Breakdown\\n(Δmz = deviation from mean)', fontsize=11, weight='bold', pad=20)\n", - " \n", + "\n", + " ax2.set_title(\n", + " \"Per-Star Breakdown\\n(Δmz = deviation from mean)\",\n", + " fontsize=11,\n", + " weight=\"bold\",\n", + " pad=20,\n", + " )\n", + "\n", " # ========== Panel 3: mzero Values vs Magnitude with Trendlines ==========\n", " ax3 = fig.add_subplot(gs[2, 0])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Scatter plot of individual mzero values\n", - " colors = ['red' if not sigclip_mask[i] else 'blue' for i in range(len(valid_mzeros))]\n", - " ax3.scatter(valid_mags, valid_mzeros, s=80, alpha=0.7, c=colors, edgecolors='black', linewidths=1, label='Stars', zorder=3)\n", - " \n", + " colors = [\n", + " \"red\" if not sigclip_mask[i] else \"blue\" for i in range(len(valid_mzeros))\n", + " ]\n", + " ax3.scatter(\n", + " valid_mags,\n", + " valid_mzeros,\n", + " s=80,\n", + " alpha=0.7,\n", + " c=colors,\n", + " edgecolors=\"black\",\n", + " linewidths=1,\n", + " label=\"Stars\",\n", + " zorder=3,\n", + " )\n", + "\n", " # Horizontal lines for different methods\n", - " ax3.axhline(mzero_mean, color='blue', linestyle='-', linewidth=2, label=f'Mean: {mzero_mean:.3f}', alpha=0.6)\n", - " ax3.axhline(mzero_median, color='green', linestyle='--', linewidth=2, label=f'Median: {mzero_median:.3f}', alpha=0.6)\n", - " \n", + " ax3.axhline(\n", + " mzero_mean,\n", + " color=\"blue\",\n", + " linestyle=\"-\",\n", + " linewidth=2,\n", + " label=f\"Mean: {mzero_mean:.3f}\",\n", + " alpha=0.6,\n", + " )\n", + " ax3.axhline(\n", + " mzero_median,\n", + " color=\"green\",\n", + " linestyle=\"--\",\n", + " linewidth=2,\n", + " label=f\"Median: {mzero_median:.3f}\",\n", + " alpha=0.6,\n", + " )\n", + "\n", " # Trendlines\n", " if len(valid_mzeros) >= 3:\n", " mag_range = np.array([valid_mags.min(), valid_mags.max()])\n", - " \n", + "\n", " # All stars trend\n", " trend_line_all = slope_all * mag_range + intercept_all\n", - " ax3.plot(mag_range, trend_line_all, 'purple', linestyle='-.', linewidth=2.5, \n", - " label=f'Trend (all): R²={r_value_all**2:.3f}', zorder=2)\n", - " \n", + " ax3.plot(\n", + " mag_range,\n", + " trend_line_all,\n", + " \"purple\",\n", + " linestyle=\"-.\",\n", + " linewidth=2.5,\n", + " label=f\"Trend (all): R²={r_value_all**2:.3f}\",\n", + " zorder=2,\n", + " )\n", + "\n", " # Sigma-clipped trend\n", " if n_clipped > 0:\n", " trend_line_clip = slope_clip * mag_range + intercept_clip\n", - " ax3.plot(mag_range, trend_line_clip, 'red', linestyle=':', linewidth=2.5, \n", - " label=f'Trend (σ-clip): R²={r_value_clip**2:.3f}', zorder=2)\n", - " \n", + " ax3.plot(\n", + " mag_range,\n", + " trend_line_clip,\n", + " \"red\",\n", + " linestyle=\":\",\n", + " linewidth=2.5,\n", + " label=f\"Trend (σ-clip): R²={r_value_clip**2:.3f}\",\n", + " zorder=2,\n", + " )\n", + "\n", " # Mark median magnitude\n", - " ax3.axvline(np.median(valid_mags), color='gray', linestyle='--', linewidth=1, alpha=0.5, zorder=1)\n", - " \n", + " ax3.axvline(\n", + " np.median(valid_mags),\n", + " color=\"gray\",\n", + " linestyle=\"--\",\n", + " linewidth=1,\n", + " alpha=0.5,\n", + " zorder=1,\n", + " )\n", + "\n", " # Std deviation bands\n", - " ax3.axhspan(mzero_mean - mzero_std, mzero_mean + mzero_std, alpha=0.15, color='blue', zorder=0)\n", - " \n", - " ax3.set_xlabel('Catalog Magnitude', fontsize=10)\n", - " ax3.set_ylabel('mzero', fontsize=10)\n", - " ax3.set_title(f'mzero vs Magnitude\\nσ = {mzero_std:.3f}, Trend slope = {slope_all:.4f}', fontsize=10, weight='bold')\n", - " ax3.legend(fontsize=7, loc='best')\n", + " ax3.axhspan(\n", + " mzero_mean - mzero_std,\n", + " mzero_mean + mzero_std,\n", + " alpha=0.15,\n", + " color=\"blue\",\n", + " zorder=0,\n", + " )\n", + "\n", + " ax3.set_xlabel(\"Catalog Magnitude\", fontsize=10)\n", + " ax3.set_ylabel(\"mzero\", fontsize=10)\n", + " ax3.set_title(\n", + " f\"mzero vs Magnitude\\nσ = {mzero_std:.3f}, Trend slope = {slope_all:.4f}\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax3.legend(fontsize=7, loc=\"best\")\n", " ax3.grid(True, alpha=0.3)\n", " ax3.invert_xaxis() # Brighter stars on right\n", " else:\n", - " ax3.text(0.5, 0.5, 'No valid stars', transform=ax3.transAxes, ha='center', va='center')\n", - " \n", + " ax3.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax3.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 4: mzero Values vs Flux ==========\n", " ax4 = fig.add_subplot(gs[2, 1])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Scatter plot of mzero vs log(flux)\n", " log_fluxes = np.log10(valid_fluxes)\n", - " colors = ['red' if not sigclip_mask[i] else 'blue' for i in range(len(valid_mzeros))]\n", - " ax4.scatter(log_fluxes, valid_mzeros, s=80, alpha=0.7, c=colors, edgecolors='black', linewidths=1)\n", - " \n", + " colors = [\n", + " \"red\" if not sigclip_mask[i] else \"blue\" for i in range(len(valid_mzeros))\n", + " ]\n", + " ax4.scatter(\n", + " log_fluxes,\n", + " valid_mzeros,\n", + " s=80,\n", + " alpha=0.7,\n", + " c=colors,\n", + " edgecolors=\"black\",\n", + " linewidths=1,\n", + " )\n", + "\n", " # Horizontal lines\n", - " ax4.axhline(mzero_mean, color='blue', linestyle='-', linewidth=2, label='Mean', alpha=0.6)\n", - " ax4.axhline(mzero_median, color='green', linestyle='--', linewidth=2, label='Median', alpha=0.6)\n", - " \n", + " ax4.axhline(\n", + " mzero_mean,\n", + " color=\"blue\",\n", + " linestyle=\"-\",\n", + " linewidth=2,\n", + " label=\"Mean\",\n", + " alpha=0.6,\n", + " )\n", + " ax4.axhline(\n", + " mzero_median,\n", + " color=\"green\",\n", + " linestyle=\"--\",\n", + " linewidth=2,\n", + " label=\"Median\",\n", + " alpha=0.6,\n", + " )\n", + "\n", " # Check for trend with flux\n", " if len(valid_mzeros) >= 3:\n", - " slope_flux, intercept_flux, r_value_flux, _, _ = stats.linregress(log_fluxes, valid_mzeros)\n", + " slope_flux, intercept_flux, r_value_flux, _, _ = stats.linregress(\n", + " log_fluxes, valid_mzeros\n", + " )\n", " if abs(r_value_flux) > 0.3: # Significant correlation\n", " x_fit = np.array([log_fluxes.min(), log_fluxes.max()])\n", " y_fit = slope_flux * x_fit + intercept_flux\n", - " ax4.plot(x_fit, y_fit, 'orange', linestyle=':', linewidth=2, \n", - " label=f'Flux trend: R²={r_value_flux**2:.3f}')\n", - " \n", - " ax4.set_xlabel('log₁₀(Flux [ADU])', fontsize=10)\n", - " ax4.set_ylabel('mzero', fontsize=10)\n", - " ax4.set_title('mzero vs Flux\\n(Should be flat if aperture correct)', fontsize=10, weight='bold')\n", - " ax4.legend(fontsize=8, loc='best')\n", + " ax4.plot(\n", + " x_fit,\n", + " y_fit,\n", + " \"orange\",\n", + " linestyle=\":\",\n", + " linewidth=2,\n", + " label=f\"Flux trend: R²={r_value_flux**2:.3f}\",\n", + " )\n", + "\n", + " ax4.set_xlabel(\"log₁₀(Flux [ADU])\", fontsize=10)\n", + " ax4.set_ylabel(\"mzero\", fontsize=10)\n", + " ax4.set_title(\n", + " \"mzero vs Flux\\n(Should be flat if aperture correct)\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax4.legend(fontsize=8, loc=\"best\")\n", " ax4.grid(True, alpha=0.3)\n", " else:\n", - " ax4.text(0.5, 0.5, 'No valid stars', transform=ax4.transAxes, ha='center', va='center')\n", - " \n", + " ax4.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax4.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 5: mzero Distribution Histogram ==========\n", " ax5 = fig.add_subplot(gs[2, 2])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Histogram\n", - " ax5.hist(valid_mzeros, bins=min(15, len(valid_mzeros)), \n", - " color='steelblue', alpha=0.7, edgecolor='black')\n", - " \n", + " ax5.hist(\n", + " valid_mzeros,\n", + " bins=min(15, len(valid_mzeros)),\n", + " color=\"steelblue\",\n", + " alpha=0.7,\n", + " edgecolor=\"black\",\n", + " )\n", + "\n", " # Mark different estimators\n", - " ax5.axvline(mzero_mean, color='blue', linestyle='-', linewidth=2, label='Mean')\n", - " ax5.axvline(mzero_median, color='green', linestyle='--', linewidth=2, label='Median')\n", + " ax5.axvline(mzero_mean, color=\"blue\", linestyle=\"-\", linewidth=2, label=\"Mean\")\n", + " ax5.axvline(\n", + " mzero_median, color=\"green\", linestyle=\"--\", linewidth=2, label=\"Median\"\n", + " )\n", " if n_clipped > 0:\n", - " ax5.axvline(mzero_sigclip, color='red', linestyle='-.', linewidth=2, label='σ-clip')\n", + " ax5.axvline(\n", + " mzero_sigclip, color=\"red\", linestyle=\"-.\", linewidth=2, label=\"σ-clip\"\n", + " )\n", " if len(valid_mzeros) >= 3:\n", - " ax5.axvline(mzero_trend, color='purple', linestyle=':', linewidth=2, label='Trend')\n", - " \n", - " ax5.set_xlabel('mzero', fontsize=10)\n", - " ax5.set_ylabel('Count', fontsize=10)\n", - " ax5.set_title(f'mzero Distribution\\nRange: [{np.min(valid_mzeros):.2f}, {np.max(valid_mzeros):.2f}]', \n", - " fontsize=10, weight='bold')\n", + " ax5.axvline(\n", + " mzero_trend, color=\"purple\", linestyle=\":\", linewidth=2, label=\"Trend\"\n", + " )\n", + "\n", + " ax5.set_xlabel(\"mzero\", fontsize=10)\n", + " ax5.set_ylabel(\"Count\", fontsize=10)\n", + " ax5.set_title(\n", + " f\"mzero Distribution\\nRange: [{np.min(valid_mzeros):.2f}, {np.max(valid_mzeros):.2f}]\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", " ax5.legend(fontsize=8)\n", - " ax5.grid(True, alpha=0.3, axis='y')\n", + " ax5.grid(True, alpha=0.3, axis=\"y\")\n", " else:\n", - " ax5.text(0.5, 0.5, 'No valid stars', transform=ax5.transAxes, ha='center', va='center')\n", - " \n", + " ax5.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax5.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 6: SQM Comparison Table with Overlap Correction ==========\n", " ax6 = fig.add_subplot(gs[3, 0])\n", - " ax6.axis('off')\n", - " \n", + " ax6.axis(\"off\")\n", + "\n", " # Compare different methods INCLUDING overlap-corrected\n", - " comparison_data = [['Method', 'mzero', 'SQM', 'Error', 'Note']]\n", - " \n", + " comparison_data = [[\"Method\", \"mzero\", \"SQM\", \"Error\", \"Note\"]]\n", + "\n", " if mzero_mean is not None:\n", - " comparison_data.append([\n", - " 'Mean',\n", - " f'{mzero_mean:.3f}',\n", - " f'{sqm_val:.2f}',\n", - " f'{sqm_val - info[\"realsqm\"]:+.2f}',\n", - " '← Current'\n", - " ])\n", - " comparison_data.append([\n", - " 'Median',\n", - " f'{mzero_median:.3f}',\n", - " f'{sqm_median:.2f}',\n", - " f'{sqm_median - info[\"realsqm\"]:+.2f}',\n", - " ''\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"Mean\",\n", + " f\"{mzero_mean:.3f}\",\n", + " f\"{sqm_val:.2f}\",\n", + " f\"{sqm_val - info['realsqm']:+.2f}\",\n", + " \"← Current\",\n", + " ]\n", + " )\n", + " comparison_data.append(\n", + " [\n", + " \"Median\",\n", + " f\"{mzero_median:.3f}\",\n", + " f\"{sqm_median:.2f}\",\n", + " f\"{sqm_median - info['realsqm']:+.2f}\",\n", + " \"\",\n", + " ]\n", + " )\n", " if n_clipped > 0:\n", - " comparison_data.append([\n", - " 'σ-clipped',\n", - " f'{mzero_sigclip:.3f}',\n", - " f'{sqm_sigclip:.2f}',\n", - " f'{sqm_sigclip - info[\"realsqm\"]:+.2f}',\n", - " f'-{n_clipped} star'\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"σ-clipped\",\n", + " f\"{mzero_sigclip:.3f}\",\n", + " f\"{sqm_sigclip:.2f}\",\n", + " f\"{sqm_sigclip - info['realsqm']:+.2f}\",\n", + " f\"-{n_clipped} star\",\n", + " ]\n", + " )\n", " if len(valid_mzeros) >= 3:\n", - " comparison_data.append([\n", - " 'Trend (all)',\n", - " f'{mzero_trend:.3f}',\n", - " f'{sqm_trend:.2f}',\n", - " f'{sqm_trend - info[\"realsqm\"]:+.2f}',\n", - " f'R²={r_value_all**2:.2f}'\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"Trend (all)\",\n", + " f\"{mzero_trend:.3f}\",\n", + " f\"{sqm_trend:.2f}\",\n", + " f\"{sqm_trend - info['realsqm']:+.2f}\",\n", + " f\"R²={r_value_all**2:.2f}\",\n", + " ]\n", + " )\n", " if n_clipped > 0:\n", - " comparison_data.append([\n", - " 'Trend+σ-clip',\n", - " f'{mzero_trend_sigclip:.3f}',\n", - " f'{sqm_trend_sigclip:.2f}',\n", - " f'{sqm_trend_sigclip - info[\"realsqm\"]:+.2f}',\n", - " f'R²={r_value_clip**2:.2f}'\n", - " ])\n", - " \n", + " comparison_data.append(\n", + " [\n", + " \"Trend+σ-clip\",\n", + " f\"{mzero_trend_sigclip:.3f}\",\n", + " f\"{sqm_trend_sigclip:.2f}\",\n", + " f\"{sqm_trend_sigclip - info['realsqm']:+.2f}\",\n", + " f\"R²={r_value_clip**2:.2f}\",\n", + " ]\n", + " )\n", + "\n", " # Add overlap-corrected result\n", - " if sqm_val_corrected is not None and details_corrected.get('n_stars_excluded_overlaps', 0) > 0:\n", - " n_excl = details_corrected['n_stars_excluded_overlaps']\n", - " comparison_data.append([\n", - " 'Overlap-corrected',\n", - " f'{details_corrected[\"mzero\"]:.3f}',\n", - " f'{sqm_val_corrected:.2f}',\n", - " f'{sqm_val_corrected - info[\"realsqm\"]:+.2f}',\n", - " f'-{n_excl} overlap'\n", - " ])\n", - " \n", - " comparison_data.append([\n", - " 'Expected',\n", - " '—',\n", - " f'{info[\"realsqm\"]:.2f}',\n", - " '0.00',\n", - " 'Target'\n", - " ])\n", - " \n", - " comp_table = ax6.table(cellText=comparison_data, cellLoc='center', loc='center',\n", - " colWidths=[0.23, 0.18, 0.15, 0.15, 0.29])\n", + " if (\n", + " sqm_val_corrected is not None\n", + " and details_corrected.get(\"n_stars_excluded_overlaps\", 0) > 0\n", + " ):\n", + " n_excl = details_corrected[\"n_stars_excluded_overlaps\"]\n", + " comparison_data.append(\n", + " [\n", + " \"Overlap-corrected\",\n", + " f\"{details_corrected['mzero']:.3f}\",\n", + " f\"{sqm_val_corrected:.2f}\",\n", + " f\"{sqm_val_corrected - info['realsqm']:+.2f}\",\n", + " f\"-{n_excl} overlap\",\n", + " ]\n", + " )\n", + "\n", + " comparison_data.append(\n", + " [\"Expected\", \"—\", f\"{info['realsqm']:.2f}\", \"0.00\", \"Target\"]\n", + " )\n", + "\n", + " comp_table = ax6.table(\n", + " cellText=comparison_data,\n", + " cellLoc=\"center\",\n", + " loc=\"center\",\n", + " colWidths=[0.23, 0.18, 0.15, 0.15, 0.29],\n", + " )\n", " comp_table.auto_set_font_size(False)\n", " comp_table.set_fontsize(8)\n", " comp_table.scale(1, 2.2)\n", - " \n", + "\n", " # Style header\n", " for i in range(5):\n", - " comp_table[(0, i)].set_facecolor('#2196F3')\n", - " comp_table[(0, i)].set_text_props(weight='bold', color='white')\n", - " \n", + " comp_table[(0, i)].set_facecolor(\"#2196F3\")\n", + " comp_table[(0, i)].set_text_props(weight=\"bold\", color=\"white\")\n", + "\n", " # Highlight best method\n", " if len(comparison_data) > 2:\n", " errors = [abs(float(row[3])) for row in comparison_data[1:-1]]\n", " best_idx = np.argmin(errors) + 1\n", " for j in range(5):\n", - " comp_table[(best_idx, j)].set_facecolor('#C8E6C9')\n", - " \n", - " ax6.set_title('mzero Method Comparison', fontsize=11, weight='bold', pad=20)\n", - " \n", + " comp_table[(best_idx, j)].set_facecolor(\"#C8E6C9\")\n", + "\n", + " ax6.set_title(\"mzero Method Comparison\", fontsize=11, weight=\"bold\", pad=20)\n", + "\n", " # ========== Panel 7: Background Annuli ==========\n", " ax7 = fig.add_subplot(gs[3, 1])\n", - " \n", + "\n", " # Create visualization showing annulus regions\n", " height, width = np_image.shape\n", " y, x = np.ogrid[:height, :width]\n", " annulus_mask_img = np.zeros((height, width), dtype=bool)\n", " for centroid in star_centroids:\n", " cx, cy = centroid\n", - " dist_sq = (x - cx)**2 + (y - cy)**2\n", + " dist_sq = (x - cx) ** 2 + (y - cy) ** 2\n", " star_annulus = (dist_sq > ANNULUS_INNER**2) & (dist_sq <= ANNULUS_OUTER**2)\n", " annulus_mask_img |= star_annulus\n", - " \n", + "\n", " annulus_display = np.where(annulus_mask_img, np_image, np.nan)\n", - " ax7.imshow(annulus_display, cmap='viridis', vmin=vmin, vmax=vmax, origin='lower')\n", - " ax7.set_title(f'Background Annuli\\n(median={details[\"background_per_pixel\"]:.1f} ADU)', \n", - " fontsize=10, weight='bold')\n", - " ax7.set_xlabel('X (pixels)', fontsize=9)\n", - " ax7.set_ylabel('Y (pixels)', fontsize=9)\n", - " \n", + " ax7.imshow(annulus_display, cmap=\"viridis\", vmin=vmin, vmax=vmax, origin=\"lower\")\n", + " ax7.set_title(\n", + " f\"Background Annuli\\n(median={details['background_per_pixel']:.1f} ADU)\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax7.set_xlabel(\"X (pixels)\", fontsize=9)\n", + " ax7.set_ylabel(\"Y (pixels)\", fontsize=9)\n", + "\n", " # ========== Panel 8: Calculation Summary with Overlap Info ==========\n", " ax8 = fig.add_subplot(gs[3, 2])\n", - " ax8.axis('off')\n", - " \n", + " ax8.axis(\"off\")\n", + "\n", " # Find best method\n", " if mzero_mean is not None:\n", - " methods = ['Mean', 'Median', 'σ-clip', 'Trend', 'Trend+σ-clip', 'Overlap-corrected']\n", - " sqm_values = [sqm_val, sqm_median, sqm_sigclip, sqm_trend, sqm_trend_sigclip, sqm_val_corrected]\n", - " errors = [abs(sqm - info['realsqm']) for sqm in sqm_values if sqm is not None]\n", + " methods = [\n", + " \"Mean\",\n", + " \"Median\",\n", + " \"σ-clip\",\n", + " \"Trend\",\n", + " \"Trend+σ-clip\",\n", + " \"Overlap-corrected\",\n", + " ]\n", + " sqm_values = [\n", + " sqm_val,\n", + " sqm_median,\n", + " sqm_sigclip,\n", + " sqm_trend,\n", + " sqm_trend_sigclip,\n", + " sqm_val_corrected,\n", + " ]\n", + " errors = [abs(sqm - info[\"realsqm\"]) for sqm in sqm_values if sqm is not None]\n", " valid_methods = [m for m, sqm in zip(methods, sqm_values) if sqm is not None]\n", " if errors:\n", " best_method = valid_methods[np.argmin(errors)]\n", " else:\n", - " best_method = 'Mean'\n", + " best_method = \"Mean\"\n", " else:\n", - " best_method = 'N/A'\n", - " \n", - " corrected_str = f\"{sqm_val_corrected:.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", - " error_str = f\"{sqm_val_corrected - info['realsqm']:+.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", + " best_method = \"N/A\"\n", + "\n", + " corrected_str = (\n", + " f\"{sqm_val_corrected:.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", + " )\n", + " error_str = (\n", + " f\"{sqm_val_corrected - info['realsqm']:+.2f}\"\n", + " if sqm_val_corrected is not None\n", + " else \"N/A\"\n", + " )\n", "\n", " summary_text = f\"\"\"CALCULATION SUMMARY\n", - "{'='*35}\n", + "{\"=\" * 35}\n", "\n", - "Stars: {details['n_matched_stars']} matched\n", - " {details['n_centroids']} total centroids\n", + "Stars: {details[\"n_matched_stars\"]} matched\n", + " {details[\"n_centroids\"]} total centroids\n", "\n", "OVERLAPS: {len(overlaps)} total\n", " CRITICAL: {len(critical_overlaps)}\n", @@ -1954,7 +2245,7 @@ "Background: Local annuli\n", " Aperture: {APERTURE_RADIUS} px\n", " Annulus: {ANNULUS_INNER}-{ANNULUS_OUTER} px\n", - " Sky: {details['background_per_pixel']:.2f} ADU/px\n", + " Sky: {details[\"background_per_pixel\"]:.2f} ADU/px\n", "\n", "mzero Statistics:\n", " Mean: {mzero_mean:.3f} ± {mzero_std:.3f}\n", @@ -1967,60 +2258,86 @@ "Trend Analysis:\n", " Slope: {slope_all:.4f} mag/mag\n", " R²: {r_value_all**2:.4f}\n", - " Sig? {'YES' if abs(r_value_all) > 0.5 else 'NO'}\n", + " Sig? {\"YES\" if abs(r_value_all) > 0.5 else \"NO\"}\n", "\n", "SQM Results:\n", " Without overlap correction:\n", " Current: {sqm_val:.2f} mag/arcsec²\n", - " Error: {sqm_val - info['realsqm']:+.2f}\n", + " Error: {sqm_val - info[\"realsqm\"]:+.2f}\n", " \n", " With overlap correction:\n", " Corrected: {corrected_str} mag/arcsec²\n", " Error: {error_str}\n", - " Excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)} stars\n", + " Excluded: {details_corrected.get(\"n_stars_excluded_overlaps\", 0)} stars\n", "\n", - "Expected: {info['realsqm']:.2f}\n", + "Expected: {info[\"realsqm\"]:.2f}\n", "\n", "Best Method: {best_method}\n", "\"\"\"\n", - " \n", - " ax8.text(0.05, 0.95, summary_text, \n", - " transform=ax8.transAxes, fontsize=7.5, \n", - " verticalalignment='top', fontfamily='monospace',\n", - " bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))\n", - " \n", + "\n", + " ax8.text(\n", + " 0.05,\n", + " 0.95,\n", + " summary_text,\n", + " transform=ax8.transAxes,\n", + " fontsize=7.5,\n", + " verticalalignment=\"top\",\n", + " fontfamily=\"monospace\",\n", + " bbox=dict(boxstyle=\"round\", facecolor=\"lightyellow\", alpha=0.8),\n", + " )\n", + "\n", " plt.tight_layout()\n", " plt.show()\n", - " \n", + "\n", " # Print detailed overlap information\n", " if overlaps:\n", " print(\"\\nDETAILED OVERLAP INFORMATION:\")\n", - " print(f\"{'='*100}\")\n", + " print(f\"{'=' * 100}\")\n", " for overlap in overlaps:\n", - " i, j = overlap['star1_idx'], overlap['star2_idx']\n", + " i, j = overlap[\"star1_idx\"], overlap[\"star2_idx\"]\n", " print(f\" [{overlap['type']:8}] Stars {i} ↔ {j}: {overlap['description']}\")\n", - " print(f\" Star {i}: mag={star_mags[i]:.2f}, flux={star_fluxes[i]:.0f} ADU\")\n", - " print(f\" Star {j}: mag={star_mags[j]:.2f}, flux={star_fluxes[j]:.0f} ADU\")\n", - " print(f\"{'='*100}\\n\")\n", - " \n", + " print(\n", + " f\" Star {i}: mag={star_mags[i]:.2f}, flux={star_fluxes[i]:.0f} ADU\"\n", + " )\n", + " print(\n", + " f\" Star {j}: mag={star_mags[j]:.2f}, flux={star_fluxes[j]:.0f} ADU\"\n", + " )\n", + " print(f\"{'=' * 100}\\n\")\n", + "\n", " # Print summary\n", " print(f\"\\n✓ Processed {filename}\")\n", " print(\"\\n WITHOUT overlap correction:\")\n", - " print(f\" SQM (mean): {sqm_val:.2f} (error: {sqm_val - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (median): {sqm_median:.2f} (error: {sqm_median - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (σ-clipped): {sqm_sigclip:.2f} (error: {sqm_sigclip - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (trend): {sqm_trend:.2f} (error: {sqm_trend - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (trend+σ-clip): {sqm_trend_sigclip:.2f} (error: {sqm_trend_sigclip - info['realsqm']:+.2f})\")\n", - " \n", + " print(\n", + " f\" SQM (mean): {sqm_val:.2f} (error: {sqm_val - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (median): {sqm_median:.2f} (error: {sqm_median - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (σ-clipped): {sqm_sigclip:.2f} (error: {sqm_sigclip - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (trend): {sqm_trend:.2f} (error: {sqm_trend - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (trend+σ-clip): {sqm_trend_sigclip:.2f} (error: {sqm_trend_sigclip - info['realsqm']:+.2f})\"\n", + " )\n", + "\n", " if sqm_val_corrected is not None:\n", " print(\"\\n WITH overlap correction:\")\n", - " print(f\" SQM (overlap-corr): {sqm_val_corrected:.2f} (error: {sqm_val_corrected - info['realsqm']:+.2f})\")\n", - " print(f\" Stars excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)}/{details_corrected.get('n_matched_stars_original', 0)}\")\n", - " print(f\" Improvement: {(sqm_val_corrected - sqm_val):+.2f} mag/arcsec²\")\n", - " \n", + " print(\n", + " f\" SQM (overlap-corr): {sqm_val_corrected:.2f} (error: {sqm_val_corrected - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" Stars excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)}/{details_corrected.get('n_matched_stars_original', 0)}\"\n", + " )\n", + " print(\n", + " f\" Improvement: {(sqm_val_corrected - sqm_val):+.2f} mag/arcsec²\"\n", + " )\n", + "\n", " print(f\"\\n Trend: slope={slope_all:.4f}, R²={r_value_all**2:.4f}\")\n", " print(f\" Best method: {best_method}\")\n", - " \n", + "\n", " # Flag issues\n", " issues = []\n", " if any(f <= 0 for f in star_fluxes):\n", @@ -2030,19 +2347,23 @@ " issues.append(f\"⚠️ High mzero scatter: {mzero_std:.3f}\")\n", " if abs(r_value_all) > 0.5:\n", " issues.append(f\"⚠️ Significant magnitude trend: R²={r_value_all**2:.3f}\")\n", - " if abs(sqm_val - info['realsqm']) > 0.5:\n", - " issues.append(f\"⚠️ Large error (no corr): {sqm_val - info['realsqm']:+.2f} mag/arcsec²\")\n", + " if abs(sqm_val - info[\"realsqm\"]) > 0.5:\n", + " issues.append(\n", + " f\"⚠️ Large error (no corr): {sqm_val - info['realsqm']:+.2f} mag/arcsec²\"\n", + " )\n", " if n_clipped > 0:\n", " issues.append(f\"ℹ️ {n_clipped} outliers removed by σ-clipping\")\n", " if overlaps:\n", - " issues.append(f\"⚠️ {len(overlaps)} aperture overlaps detected ({len(overlapping_stars)} stars affected)\")\n", - " \n", + " issues.append(\n", + " f\"⚠️ {len(overlaps)} aperture overlaps detected ({len(overlapping_stars)} stars affected)\"\n", + " )\n", + "\n", " if issues:\n", " print(\"\\n Notes:\")\n", " for issue in issues:\n", " print(f\" {issue}\")\n", - " \n", - " print(\"\")\n" + "\n", + " print(\"\")" ] }, { diff --git a/python/PiFinder/sqm/sqm.py b/python/PiFinder/sqm/sqm.py index d5426a020..9ab9e46d4 100644 --- a/python/PiFinder/sqm/sqm.py +++ b/python/PiFinder/sqm/sqm.py @@ -1,14 +1,21 @@ import json import logging +import math from pathlib import Path -from typing import Tuple, Dict, Optional +from typing import Tuple, Dict, List, Optional import numpy as np +from . import color_index from .camera_profiles import get_camera_profile logger = logging.getLogger("Solver") +# The colour term V - T*(B-V) is linear only over the B-V range it was fitted +# on (~0 to ~1). Clamp lookups so very red giants aren't over-corrected. +BV_CLAMP_MIN = -0.5 +BV_CLAMP_MAX = 1.2 + class SQM: """ @@ -32,6 +39,12 @@ class SQM: (extended source), giving SQM in mag/arcsec². """ + # Zero-point stars are taken from this catalog-magnitude band (see + # _calculate_mzero); outside-band stars still get per-star mzeros for + # diagnostics but don't vote on the frame zero point. + MZERO_MAG_BAND = (3.5, 6.5) + MZERO_MAG_BAND_MIN_STARS = 5 + def __init__( self, camera_type: str = "imx296", @@ -41,7 +54,6 @@ def __init__( Args: camera_type: Camera model (imx296, imx462, imx290, hq). - Use "_processed" suffix for 8-bit ISP-processed images. """ self.camera_type = camera_type self.profile = get_camera_profile(camera_type) @@ -92,11 +104,18 @@ def _load_calibration(self) -> bool: logger.warning(f"Failed to load calibration: {e}, using defaults") return False - def _calc_field_parameters(self, fov_degrees: float) -> None: - """Calculate field of view parameters.""" + def _calc_field_parameters( + self, fov_degrees: float, pixels_per_side: int = 512 + ) -> None: + """Calculate field of view parameters. + + pixels_per_side is the side length of the image the photometry runs on + (512 for the processed image, but e.g. 490/760 for a Bayer green channel). + The per-pixel solid angle depends on it, so it must match the image. + """ self.fov_degrees = fov_degrees self.field_arcsec_squared = (fov_degrees * 3600) ** 2 - self.pixels_total = 512**2 + self.pixels_total = pixels_per_side**2 self.arcsec_squared_per_pixel = self.field_arcsec_squared / self.pixels_total def _pickering_airmass(self, altitude_deg: float) -> float: @@ -126,6 +145,7 @@ def _measure_star_flux_with_local_background( annulus_inner_radius: int, annulus_outer_radius: int, saturation_threshold: int = 250, + exclusion_centroids=None, ) -> Tuple[list, list, int]: """ Measure star flux with local background from annulus around each star. @@ -158,6 +178,16 @@ def _measure_star_flux_with_local_background( annulus_inner_r2 = annulus_inner_radius**2 annulus_outer_r2 = annulus_outer_radius**2 + # Exclusion disks: every *detected* star in the frame (matched or not) + # is masked out of background annuli so neighbours cannot inflate the + # local sky in dense fields. Radius: the photometry aperture. + excl = ( + np.asarray(exclusion_centroids, dtype=np.float64) + if exclusion_centroids is not None and len(exclusion_centroids) > 0 + else None + ) + excl_r2 = float(aperture_radius**2) + for cy, cx in centroids: # centroids are in (y, x) format after swap # Use bounding box instead of full-frame masks for huge speedup # Box needs to contain outer annulus radius @@ -182,15 +212,41 @@ def _measure_star_flux_with_local_background( dist_squared <= annulus_outer_r2 ) - # Measure local background from annulus (median for robustness) + # Mask out every known star (except this one) from the annulus. + if excl is not None: + near = excl[ + (np.abs(excl[:, 0] - cy) <= annulus_outer_radius + aperture_radius) + & ( + np.abs(excl[:, 1] - cx) + <= annulus_outer_radius + aperture_radius + ) + ] + for ey, ex in near: + if (ey - cy) ** 2 + (ex - cx) ** 2 <= 4.0: + continue # this star itself + annulus_mask &= ((x_grid - ex) ** 2 + (y_grid - ey) ** 2) > excl_r2 + + # Measure local background from the cleaned annulus: median after + # one sigma-clip pass (backstop for stars the detector missed). annulus_pixels = image_patch[annulus_mask] - if len(annulus_pixels) > 0: + if len(annulus_pixels) >= 8: + med = np.median(annulus_pixels) + sig = np.std(annulus_pixels) + kept = annulus_pixels[np.abs(annulus_pixels - med) <= 3.0 * sig] + local_bg_per_pixel = float(np.median(kept) if len(kept) >= 8 else med) + elif len(annulus_pixels) > 0: local_bg_per_pixel = float(np.median(annulus_pixels)) else: - # this is impossible - local_bg_per_pixel = float(np.median(image)) - logger.warning( - f"Star at ({cx:.0f},{cy:.0f}) has no annulus pixels, using global median" + # Exclusion emptied the annulus (extremely dense field): + # fall back to the uncleaned annulus median. + raw_annulus = image_patch[ + (dist_squared > annulus_inner_r2) + & (dist_squared <= annulus_outer_r2) + ] + local_bg_per_pixel = ( + float(np.median(raw_annulus)) + if len(raw_annulus) > 0 + else float(np.median(image)) ) # Check for saturation in aperture @@ -223,27 +279,39 @@ def _calculate_mzero( self, star_fluxes: list, star_mags: list ) -> Tuple[Optional[float], list]: """ - Calculate photometric zero point from calibrated stars using flux-weighted mean. + Calculate photometric zero point from calibrated stars. For point sources: mzero = catalog_mag + 2.5 × log10(total_flux_ADU) This zero point allows converting any ADU measurement to magnitudes: mag = mzero - 2.5 × log10(flux_ADU) - Uses flux-weighted mean: brighter stars have higher SNR so their - mzero estimates are more reliable. + Uses the median over stars, not a flux-weighted mean: a flux-weighted + mean hands most of the vote to the 1-2 brightest stars, which are + exactly the ones prone to systematic error (sensor nonlinearity near + saturation, colour-term extrapolation on very red giants). One such + star can drag a weighted mzero by half a magnitude; the median is + unmoved. + + The median is taken over stars inside a fixed catalog-magnitude band + (MZERO_MAG_BAND) when enough are present: per-star mzero has a mild + magnitude dependence, and the set of matched stars shifts several + magnitudes across the auto-exposure range, so an all-star median + drifts ~0.1-0.2 mag/decade of exposure. A fixed band samples the same + physical stars at every exposure (measured: drift -0.10 -> -0.01 + mag/decade on the imx462 sweep ramps). Falls back to all stars when + the field is too poor for the band. Args: star_fluxes: Background-subtracted star fluxes (ADU) star_mags: Catalog magnitudes for matched stars Returns: - Tuple of (weighted_mean_mzero, list_of_individual_mzeros) + Tuple of (median_mzero, list_of_individual_mzeros) Note: The mzeros list will contain None for stars with invalid flux """ mzeros: list[Optional[float]] = [] - valid_mzeros = [] - valid_fluxes = [] + valid = [] # (mzero, mag) for flux, mag in zip(star_fluxes, star_mags): if flux <= 0: @@ -256,19 +324,20 @@ def _calculate_mzero( # Calculate zero point: ZP = m + 2.5*log10(F) mzero = mag + 2.5 * np.log10(flux) mzeros.append(mzero) - valid_mzeros.append(mzero) - valid_fluxes.append(flux) + valid.append((mzero, mag)) - if len(valid_mzeros) == 0: + if len(valid) == 0: logger.error("No valid stars for mzero calculation") return None, mzeros - # Flux-weighted mean: brighter stars contribute more - valid_mzeros_arr = np.array(valid_mzeros) - valid_fluxes_arr = np.array(valid_fluxes) - weighted_mzero = float(np.average(valid_mzeros_arr, weights=valid_fluxes_arr)) - - return weighted_mzero, mzeros + lo, hi = self.MZERO_MAG_BAND + in_band = [mz for mz, mag in valid if lo <= mag <= hi] + pool = ( + in_band + if len(in_band) >= self.MZERO_MAG_BAND_MIN_STARS + else [mz for mz, _ in valid] + ) + return float(np.median(pool)), mzeros def _detect_aperture_overlaps( self, @@ -309,7 +378,7 @@ def _detect_aperture_overlaps( excluded_stars.add(i) excluded_stars.add(j) logger.debug( - f"CRITICAL overlap: stars {i} and {j} (d={distance:.1f}px < {2*aperture_radius}px)" + f"CRITICAL overlap: stars {i} and {j} (d={distance:.1f}px < {2 * aperture_radius}px)" ) # HIGH: Aperture inside another star's annulus (background contamination) elif distance < aperture_radius + annulus_outer_radius: @@ -366,17 +435,23 @@ def calculate( exposure_sec: float, altitude_deg: float = 90.0, aperture_radius: int = 5, - annulus_inner_radius: int = 6, - annulus_outer_radius: int = 14, + annulus_inner_radius: int = 10, + annulus_outer_radius: int = 18, correct_overlaps: bool = False, saturation_threshold: int = 250, - include_noise_floor_details: bool = False, + pedestal_override: Optional[float] = None, + color_coefficient: Optional[float] = None, + image_pixels_per_side: Optional[int] = None, + mzero_correction: float = 0.0, ) -> Tuple[Optional[float], Dict]: """ Calculate SQM (Sky Quality Meter) value using local background annuli. Args: - centroids: All detected centroids (unused, kept for compatibility) + centroids: All detected centroids in the frame, (y, x) rows in + the SAME pixel space as `image`; masked out of background + annuli so neighbouring stars cannot inflate the local sky. + Pass None/[] to skip exclusion. solution: Tetra3 solution dict with 'FOV', 'matched_centroids', 'matched_stars'. Note: matched_centroids uses (row, col) = (y, x) convention to match numpy array indexing (image[row, col]). @@ -384,12 +459,20 @@ def calculate( exposure_sec: Exposure time in seconds (required for noise floor estimation) altitude_deg: Altitude of field center for extinction correction (default: 90 = zenith) aperture_radius: Radius for star photometry in pixels (default: 5) - annulus_inner_radius: Inner radius of background annulus in pixels (default: 6) - annulus_outer_radius: Outer radius of background annulus in pixels (default: 14) + annulus_inner_radius: Inner radius of background annulus in pixels (default: 10) + annulus_outer_radius: Outer radius of background annulus in pixels (default: 18) correct_overlaps: If True, exclude stars with overlapping apertures/annuli (default: False) saturation_threshold: Pixel value threshold for saturation detection (default: 250) - include_noise_floor_details: If True, run NoiseFloorEstimator and include full output - in details dict under "noise_floor_estimator" key (default: False) + pedestal_override: If given, use this black-level pedestal instead of the + profile bias_offset (e.g. a per-frame joint-fit estimate). + color_coefficient: If given (and non-zero), correct each star's catalog V + magnitude to the sensor passband via mag_eff = V - T*(B-V), with B-V + looked up by HIP from solution['matched_catID']. Defaults to the + camera profile's color_coefficient when None; pass 0.0 to disable. + image_pixels_per_side: Side length of `image` (defaults to image.shape). + Controls the per-pixel solid angle; must match the photometry image. + mzero_correction: Additive aperture (wing-loss) correction to mzero, + ``-2.5*log10(enclosed_fraction)`` from a WingEstimator (default 0). Returns: Tuple of (sqm_value, details_dict) where: @@ -414,7 +497,12 @@ def calculate( return None, {} fov_estimate = solution["FOV"] - self._calc_field_parameters(fov_estimate) + pixels_per_side = ( + image_pixels_per_side + if image_pixels_per_side is not None + else int(image.shape[0]) + ) + self._calc_field_parameters(fov_estimate, pixels_per_side) # Validate solution has matched stars if "matched_centroids" not in solution or "matched_stars" not in solution: @@ -430,7 +518,18 @@ def calculate( # Don't swap - centroids are already in (row, col) = (y, x) format matched_centroids_arr = matched_centroids - star_mags = [s[2] for s in matched_stars] + star_mags = [s[2] for s in matched_stars] # Johnson V catalog magnitudes + + # Colour term: catalog is Johnson V but flux is in the sensor passband. + # Correct to mag_eff = V - T*(B-V) per star (B-V looked up by HIP). + color_coef = ( + color_coefficient + if color_coefficient is not None + else self.profile.color_coefficient + ) + star_bv: Optional[List[float]] = None + if color_coef and "matched_catID" in solution: + star_bv = list(color_index.get_bv(solution["matched_catID"])) # 0a. Detect and filter overlapping stars if requested n_stars_original = len(matched_centroids_arr) @@ -454,10 +553,12 @@ def calculate( ] matched_centroids_arr = matched_centroids_arr[valid_indices] star_mags = [star_mags[i] for i in valid_indices] + if star_bv is not None: + star_bv = [star_bv[i] for i in valid_indices] logger.info( f"Overlap correction: excluded {n_stars_excluded}/{n_stars_original} stars " - f"({n_stars_excluded*100//n_stars_original}%), using {len(valid_indices)} stars" + f"({n_stars_excluded * 100 // n_stars_original}%), using {len(valid_indices)} stars" ) if len(valid_indices) < 3: @@ -471,17 +572,27 @@ def calculate( # - Dark current: contributes to noise variance, not a DC offset - do NOT subtract # - Read noise: random fluctuation around 0 - do NOT subtract # Validated against SQM-L reference meter: bias_offset only gives ±0.07 mag accuracy - pedestal = self.profile.bias_offset + # A per-frame estimate (joint-fit over the auto-exposure stream) supersedes + # the static bias_offset when provided, since the true black level differs + # from the profile constant on some sensors. + pedestal = ( + pedestal_override + if pedestal_override is not None + else self.profile.bias_offset + ) # Calculate temporal noise for diagnostics (not used in pedestal) - read_noise = self.profile.read_noise_adu - dark_current_contribution = self.profile.dark_current_rate * exposure_sec + # Default to 0.0: these feed a diagnostic noise term only, so a None + # left by an incomplete calibration file must not crash the whole SQM. + read_noise = self.profile.read_noise_adu or 0.0 + dark_current_rate = self.profile.dark_current_rate or 0.0 + dark_current_contribution = dark_current_rate * exposure_sec temporal_noise = read_noise + dark_current_contribution logger.debug( f"Calibration: pedestal={pedestal:.1f} ADU (bias_offset), " f"read_noise={read_noise:.2f}, dark_current={dark_current_contribution:.2f} " - f"(rate={self.profile.dark_current_rate:.3f} ADU/s × {exposure_sec:.2f}s), " + f"(rate={dark_current_rate:.3f} ADU/s × {exposure_sec:.2f}s), " f"temporal_noise={temporal_noise:.2f}" ) @@ -494,6 +605,7 @@ def calculate( annulus_inner_radius, annulus_outer_radius, saturation_threshold, + exclusion_centroids=centroids, ) ) @@ -521,12 +633,34 @@ def calculate( ) background_corrected = 1.0 - # 4. Calculate photometric zero point - mzero, mzeros = self._calculate_mzero(star_fluxes, star_mags) + # 4. Calculate photometric zero point. + # Apply the colour term per star where B-V is known; stars with no B-V + # fall back to their raw V magnitude. B-V is clamped to the range the + # linear colour term was fitted on -- extrapolating T*(B-V) to very red + # giants (B-V > 1.2) over-corrects them by up to a magnitude. + n_color_corrected = 0 + if star_bv is not None and color_coef: + effective_mags = [] + for v_mag, bv in zip(star_mags, star_bv): + if bv is not None and math.isfinite(bv): + bv_clamped = min(max(bv, BV_CLAMP_MIN), BV_CLAMP_MAX) + effective_mags.append(v_mag - color_coef * bv_clamped) + n_color_corrected += 1 + else: + effective_mags.append(v_mag) + else: + effective_mags = star_mags + + mzero, mzeros = self._calculate_mzero(star_fluxes, effective_mags) if mzero is None: return None, {} + # Aperture (wing-loss) correction: the finite aperture misses PSF-wing + # flux, biasing mzero low. The caller supplies -2.5*log10(f) from a + # rolling WingEstimator; f is the aperture's enclosed-flux fraction. + mzero += mzero_correction + # 5. Convert background to flux density (ADU per arcsec²) background_flux_density = background_corrected / self.arcsec_squared_per_pixel @@ -545,10 +679,17 @@ def calculate( altitude_deg ) # 0.28*(airmass-1) + # Sky-passband offset: the colour term matches the stars to the sensor + # passband, so the sky is measured in that passband too. A bare sensor + # sees NIR sky emission a V-band meter doesn't; this per-sensor + # constant converts back to the meter's V-band scale. See + # CameraProfile.sqm_band_offset and docs/adr/0020. + band_offset = self.profile.sqm_band_offset + # Main SQM value: no extinction correction (raw measurement) - sqm_final = sqm_uncorrected + sqm_final = sqm_uncorrected + band_offset # Altitude-corrected value: adds extinction for altitude comparison - sqm_altitude_corrected = sqm_uncorrected + extinction_for_altitude + sqm_altitude_corrected = sqm_uncorrected + band_offset + extinction_for_altitude # Filter out None values for statistics in diagnostics valid_mzeros_for_stats = [mz for mz in mzeros if mz is not None] @@ -568,7 +709,16 @@ def calculate( "background_per_pixel": background_per_pixel, "background_method": "local_annulus", "pedestal": pedestal, - "pedestal_source": self._determine_pedestal_source(), + "pedestal_source": ( + "per_frame_estimate" + if pedestal_override is not None + else self._determine_pedestal_source() + ), + "color_coefficient": color_coef or 0.0, + "n_color_corrected": n_color_corrected, + "pixels_per_side": pixels_per_side, + "mzero_correction": mzero_correction, + "sqm_band_offset": band_offset, "read_noise_adu": read_noise, "dark_current_rate": self.profile.dark_current_rate, "dark_current_contribution": dark_current_contribution, @@ -599,27 +749,6 @@ def calculate( "star_mzeros": mzeros, } - # Optionally include full NoiseFloorEstimator output - if include_noise_floor_details: - try: - from .noise_floor import NoiseFloorEstimator - - estimator = NoiseFloorEstimator( - camera_type=self.camera_type, - enable_zero_sec_sampling=False, - ) - _, nf_details = estimator.estimate_noise_floor( - image=image, - exposure_sec=exposure_sec, - ) - # Remove internal flags, add camera type - nf_details.pop("request_zero_sec_sample", None) - nf_details["camera_type"] = self.camera_type - details["noise_floor_estimator"] = nf_details - except Exception as e: - logger.warning(f"Failed to get noise floor details: {e}") - details["noise_floor_estimator"] = {"error": str(e)} - logger.debug( f"SQM: mzero={mzero:.2f}±{np.std(valid_mzeros_for_stats):.2f}, " f"bg={background_flux_density:.6f} ADU/arcsec², pedestal={pedestal:.2f}, " diff --git a/python/PiFinder/sqm/wings.py b/python/PiFinder/sqm/wings.py new file mode 100644 index 000000000..f2aae507c --- /dev/null +++ b/python/PiFinder/sqm/wings.py @@ -0,0 +1,159 @@ +""" +Rolling aperture-correction estimate for SQM photometry. + +If the lens PSF spills flux beyond the photometry aperture, the fitted +photometric zero point (``mzero``) comes out too low and SQM reads too +bright. Whether and how much flux spills is a property of the optics (focus, +lens halo) and drifts slowly — it is NOT a per-frame quantity, and it is not +measurable per star: a single star's wings sit at or below the sky noise, so +any per-star boundary search integrates noise and reports missing flux even +for a wingless PSF. + +So this estimator uses the standard aperture-correction method: + +- **Stack**: per frame, median-stack the sky-subtracted, aperture-normalized + patches of the bright unsaturated matched stars. The stack's SNR grows with + the number of stars, putting the faint outer profile above the noise. +- **Measure once**: the stack's enclosed-flux curve of growth is normalized + to the aperture, so its plateau value equals ``total/aperture = 1/f``. The + plateau is read well outside the aperture (radii 10-16) where any real + wing flux has been integrated. +- **Smooth** the per-frame samples in a rolling window (median) and apply + ``-2.5*log10(f)`` to ``mzero`` on every frame. + +For optics whose PSF is fully enclosed by the aperture the plateau is 1.0 +within noise and the correction is 0 — the estimator degenerates gracefully +instead of inventing wings. +""" + +import logging +from collections import deque +from typing import Optional + +import numpy as np + +logger = logging.getLogger("SQM.Wings") + + +class WingEstimator: + """Rolling estimate of the aperture enclosed-flux fraction ``f``.""" + + def __init__( + self, + aperture_radius: int = 5, + max_radius: int = 20, + max_samples: int = 20, + min_samples: int = 3, + min_stars: int = 5, + min_peak_snr: float = 8.0, + ): + """ + Args: + aperture_radius: production photometry aperture the correction is for. + max_radius: patch half-size; sky comes from beyond ``max_radius - 4``. + max_samples: rolling window of per-frame ``f`` samples. + min_samples: samples needed before the correction is applied. + min_stars: per-frame minimum of stacked stars for one ``f`` sample. + min_peak_snr: star peak must exceed this multiple of the per-pixel + sky noise to enter the stack (keeps noise out of the median). + """ + self.aperture_radius = aperture_radius + self.max_radius = max_radius + self.max_samples = max_samples + self.min_samples = min_samples + self.min_stars = min_stars + self.min_peak_snr = min_peak_snr + # Radii at which the growth curve is read as its plateau: far enough + # out that real wing flux is integrated, well inside the sky region. + self.plateau_radii = (10, 12, 14, 16) + self._samples: deque = deque(maxlen=max_samples) + + def add_frame( + self, + image: np.ndarray, + centroids, + saturation_threshold: float, + ) -> Optional[float]: + """ + Measure one frame's enclosed fraction from a bright-star stack. + + Args: + image: linear photometry image (raw green channel). + centroids: matched star centroids, (y, x) rows, in `image` pixels. + saturation_threshold: pixel level above which a star core is skipped. + + Returns: + The frame's enclosed fraction if measurable, else None. + """ + if image is None or centroids is None or len(centroids) == 0: + return None + height, width = image.shape + box = self.max_radius + yy, xx = np.mgrid[-box : box + 1, -box : box + 1] + r = np.hypot(yy, xx) + sky_ring = r > (self.max_radius - 4) + core_zone = r <= 2 + + patches = [] + for cy, cx in centroids: + iy, ix = int(round(cy)), int(round(cx)) + if iy - box < 0 or ix - box < 0 or iy + box >= height or ix + box >= width: + continue + patch = image[iy - box : iy + box + 1, ix - box : ix + box + 1].astype( + np.float64 + ) + if patch[r <= self.aperture_radius].max() >= saturation_threshold: + continue + sky = float(np.median(patch[sky_ring])) + noise = float(np.std(patch[sky_ring])) + signal = patch - sky + if signal[core_zone].max() < self.min_peak_snr * max(noise, 1e-3): + continue + aperture_flux = float(signal[r <= self.aperture_radius].sum()) + if aperture_flux <= 0: + continue + patches.append(signal / aperture_flux) + + if len(patches) < self.min_stars: + return None + + stack = np.median(np.stack(patches), axis=0) + plateau = float(np.median([stack[r <= q].sum() for q in self.plateau_radii])) + if plateau <= 0: + return None + f = 1.0 / plateau + if f >= 1.0 or not np.isfinite(f): + # Plateau at/below the aperture flux: no measurable spill. Record + # a clean 1.0 so the window converges to "no correction". + f = 1.0 + elif f <= 0.2: + return None # unphysical; crowding or a corrupted stack + + self._samples.append(f) + logger.debug( + "Wing sample f=%.3f from %d stars (window %d)", + f, + len(patches), + len(self._samples), + ) + return f + + def enclosed_fraction(self) -> Optional[float]: + """Smoothed enclosed fraction, or None until conditioned.""" + if len(self._samples) < self.min_samples: + return None + return float(np.median(self._samples)) + + def correction(self) -> float: + """Additive mzero correction ``-2.5*log10(f)``; 0.0 until conditioned.""" + f = self.enclosed_fraction() + if f is None: + return 0.0 + return float(-2.5 * np.log10(f)) + + @property + def is_conditioned(self) -> bool: + return len(self._samples) >= self.min_samples + + def reset(self) -> None: + self._samples.clear() diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index 948a9dfad..d5d588b0f 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -297,6 +297,7 @@ def __init__(self) -> None: 10.0 # Adaptive noise floor in ADU (default fallback) ) self.__sqm_details: dict = {} # Full SQM calculation details for calibration + self.__sqm_correct_delta: float = 0.0 # Session correction vs reference meter self.__datetime = None self.__datetime_time = None self.__datetime_manual = False # True when manually set, blocks GPS overrides @@ -305,11 +306,15 @@ def __init__(self) -> None: self.__arch = None self.__camera_align = False self.__camera_type = "imx296" # Default, will be set by camera process + # Degrees the camera process rotates the solve/display image relative + # to the stored raw frame (PIL CCW). None until the camera reports. + self.__solve_image_rotation = None self.__cam_raw = None # Are we prepared to do alt/az math # We need gps lock and datetime self.__tz_finder = TimezoneFinder() self.__current_ui_state = None + self.__test_mode = False def serialize(self, output_file): with open(output_file, "wb") as f: @@ -336,10 +341,11 @@ def power_state(self): def set_power_state(self, v): """ - Sets the power_state. Allowed states are 0 (sleep) or 1 (awake). If - the input v is any other value, power_state will be unchanged. + Sets the power_state. Allowed states are -1 (screen off), 0 (sleep) + or 1 (awake). If the input v is any other value, power_state will be + unchanged. """ - if v in (0, 1): + if v in (-1, 0, 1): self.__power_state = v else: logger.error( @@ -367,6 +373,12 @@ def set_camera_align(self, v: bool): def camera_type(self): return self.__camera_type + def solve_image_rotation(self): + return self.__solve_image_rotation + + def set_solve_image_rotation(self, v): + self.__solve_image_rotation = v + def set_camera_type(self, v: str): self.__camera_type = v @@ -443,6 +455,15 @@ def set_sqm_details(self, v: dict): """Update the SQM calculation details""" self.__sqm_details = v + def sqm_correct_delta(self) -> float: + """Session SQM correction: additive offset from a reference-meter + reading (see UISQMCorrect). 0.0 = no correction set.""" + return self.__sqm_correct_delta + + def set_sqm_correct_delta(self, v: float): + """Set the session SQM correction delta (from the SQM Correct UI)""" + self.__sqm_correct_delta = v + def get_sky_brightness(self): """Return just the numeric SQM value for convenience""" return self.__sqm.value @@ -504,11 +525,11 @@ def set_datetime(self, dt, force=False): This keeps ``datetime()`` / ``utc_datetime()`` always UTC-aware. See ADR-0018. """ - if dt.utcoffset() is None: # naive, assume it's UTC + if dt.utcoffset() is None: # naive, assume it's UTC # we could use replace() instead since UTC has # no DST, but the idiom is dangerous for non-UTC dt = pytz.utc.localize(dt) - else: # timezone-aware -> convert to UTC + else: # timezone-aware -> convert to UTC dt = dt.astimezone(pytz.utc) if force: @@ -595,3 +616,9 @@ def __str__(self): f"Screen: {self.__screen}\n" f"Target Pixel: {self.__target_pixel}" ) + + def test_mode(self): + return self.__test_mode + + def set_test_mode(self, v: bool): + self.__test_mode = v diff --git a/python/PiFinder/sys_utils.py b/python/PiFinder/sys_utils.py index a3094dc08..9ed1d9d7b 100644 --- a/python/PiFinder/sys_utils.py +++ b/python/PiFinder/sys_utils.py @@ -1,571 +1,809 @@ -import glob -import json +""" +NixOS system utilities for PiFinder. + +Uses: +- NetworkManager GLib bindings (gi.repository.NM) for WiFi management +- python-pam for password verification +- D-Bus for hostname/reboot/shutdown +- stdlib zipfile for backup/restore +- NixOS specialisations for camera switching +- systemd service for software updates +""" + +import os import re -from typing import Dict, Any +import json +import subprocess +import logging + +from PiFinder import timez +from pathlib import Path +from typing import Optional +import dbus import pam -import requests -import sh -from sh import wpa_cli, unzip, passwd +import gi -import socket -from PiFinder import utils -import logging +gi.require_version("NM", "1.0") +from gi.repository import GLib, NM # noqa: E402 + +from PiFinder.sys_utils_base import ( # noqa: E402 + NetworkBase, + BACKUP_PATH, # noqa: F401 + remove_backup, # noqa: F401 + backup_userdata, # noqa: F401 + restore_userdata, # noqa: F401 + restart_pifinder, # noqa: F401 +) + +AP_CONNECTION_NAME = "PiFinder-AP" -BACKUP_PATH = "/home/pifinder/PiFinder_data/PiFinder_backup.zip" +logger = logging.getLogger("SysUtils.NixOS") -logger = logging.getLogger("SysUtils") + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run a command, logging failures.""" + result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + if result.returncode != 0: + logger.error( + "Command %s failed (rc=%d): %s", + cmd, + result.returncode, + result.stderr.strip(), + ) + return result -class Network: +def _nm_client() -> NM.Client: + """Create a NetworkManager client (synchronous).""" + return NM.Client.new(None) + + +def _nm_run_async(async_fn, *args): """ - Provides wifi network info + Run an async NM operation synchronously by spinning a local GLib MainLoop. """ + loop = GLib.MainLoop.new(None, False) + state = {"result": None, "error": None} + + def callback(source, async_result, _user_data): + try: + method_name = async_fn.__name__.replace("_async", "_finish") + finish_fn = getattr(source, method_name) + state["result"] = finish_fn(async_result) + except Exception as e: + state["error"] = e + finally: + loop.quit() + + async_fn(*args, callback, None) + loop.run() + + if state["error"]: + raise state["error"] + return state["result"] + + +def _get_system_bus() -> dbus.SystemBus: + return dbus.SystemBus() + + +# --------------------------------------------------------------------------- +# Network class — WiFi management via NM GLib bindings +# --------------------------------------------------------------------------- - def __init__(self): - self.wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" - with open(self.wifi_txt, "r") as wifi_f: - self._wifi_mode = wifi_f.read() +class Network(NetworkBase): + """ + Provides wifi network info via NetworkManager GLib bindings (libnm). + """ + + def __init__(self): + self._client = _nm_client() + self._wifi_networks: list[dict] = [] + self._wifi_mode = self._detect_wifi_mode() self.populate_wifi_networks() + def _detect_wifi_mode(self) -> str: + """Detect whether we're in AP or Client mode.""" + for ac in self._client.get_active_connections(): + if ac.get_id() == AP_CONNECTION_NAME: + return "AP" + return "Client" + def populate_wifi_networks(self) -> None: - wpa_supplicant_path = "/etc/wpa_supplicant/wpa_supplicant.conf" + """Get saved WiFi connections from NetworkManager.""" self._wifi_networks = [] - try: - with open(wpa_supplicant_path, "r") as wpa_conf: - contents = wpa_conf.readlines() - except IOError as e: - logger.error(f"Error reading wpa_supplicant.conf: {e}") - return - - self._wifi_networks = Network._parse_wpa_supplicant(contents) - - @staticmethod - def _parse_wpa_supplicant(contents: list[str]) -> list: - """ - Parses wpa_supplicant.conf to get current config - """ - wifi_networks = [] - network_dict: Dict[str, Any] = {} network_id = 0 - in_network_block = False - for line in contents: - line = line.strip() - if line.startswith("network={"): - in_network_block = True - network_dict = { + for conn in self._client.get_connections(): + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + continue + if conn.get_id() == AP_CONNECTION_NAME: + continue + ssid_bytes = s_wifi.get_ssid() + ssid = ( + ssid_bytes.get_data().decode("utf-8", "replace") if ssid_bytes else "" + ) + self._wifi_networks.append( + { "id": network_id, - "ssid": None, + "uuid": conn.get_uuid(), + "ssid": ssid, "psk": None, - "key_mgmt": None, + "key_mgmt": "WPA-PSK", } - - elif line == "}" and in_network_block: - in_network_block = False - wifi_networks.append(network_dict) - network_id += 1 - - elif in_network_block: - match = re.match(r"(\w+)=(.+)", line) - if match: - key, value = match.groups() - if key in network_dict: - network_dict[key] = value.strip('"') - - return wifi_networks + ) + network_id += 1 def get_wifi_networks(self): - return self._wifi_networks + """Return the saved networks, re-queried live from NetworkManager. - def delete_wifi_network(self, network_id): + The list is not cached: changes made outside this process (the AP/CLI + switch, another tool, a repair) are reflected on the next read. """ - Immediately deletes a wifi network - """ - self._wifi_networks.pop(network_id) - - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "r") as wpa_conf: - wpa_contents = list(wpa_conf) + self.populate_wifi_networks() + return self._wifi_networks - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "w") as wpa_conf: - in_networks = False - for line in wpa_contents: - if not in_networks: - if line.startswith("network={"): - in_networks = True - else: - wpa_conf.write(line) + def wifi_mode(self) -> str: + """Report the actual current mode from NetworkManager. - for network in self._wifi_networks: - ssid = network["ssid"] - key_mgmt = network["key_mgmt"] - psk = network["psk"] + AP fallback (or any out-of-band change) can flip the radio after init, + so detect live rather than trusting the value cached at construction — + otherwise the UI shows "Client" while the device is really broadcasting + the AP. Refreshing the cached field keeps local_ip()/set_wifi_mode() + consistent too. + """ + self._wifi_mode = self._detect_wifi_mode() + return self._wifi_mode - wpa_conf.write("\nnetwork={\n") - wpa_conf.write(f'\tssid="{ssid}"\n') - if key_mgmt == "WPA-PSK": - wpa_conf.write(f'\tpsk="{psk}"\n') - wpa_conf.write(f"\tkey_mgmt={key_mgmt}\n") + def is_wired_connected(self) -> bool: + """True if an ethernet device has an active connection.""" + try: + for dev in self._client.get_devices(): + if ( + dev.get_device_type() == NM.DeviceType.ETHERNET + and dev.get_active_connection() is not None + ): + return True + except Exception: + return False + return False - wpa_conf.write("}\n") + def delete_wifi_network(self, network_id): + """Delete a saved WiFi connection by its NetworkManager UUID. + Matching on the UUID (not the connection id or SSID) is what makes this + robust: a connection's id need not equal its SSID, and corrupt entries + store unrelated text in the SSID field, so an id/SSID match silently + fails to delete them. + """ + if network_id < 0 or network_id >= len(self._wifi_networks): + logger.error("Invalid network_id: %d", network_id) + return + entry = self._wifi_networks[network_id] + conn = self._client.get_connection_by_uuid(entry["uuid"]) + if conn is None: + logger.error("Connection uuid %s not found", entry["uuid"]) + else: + try: + _nm_run_async(conn.delete_async, None) + except Exception as e: + logger.error("Failed to delete connection '%s': %s", entry["ssid"], e) self.populate_wifi_networks() def add_wifi_network(self, ssid, key_mgmt, psk=None): - """ - Add a wifi network - """ - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "a") as wpa_conf: - wpa_conf.write("\nnetwork={\n") - wpa_conf.write(f'\tssid="{ssid}"\n') - if key_mgmt == "WPA-PSK": - wpa_conf.write(f'\tpsk="{psk}"\n') - wpa_conf.write(f"\tkey_mgmt={key_mgmt}\n") + """Add and connect to a WiFi network.""" + profile = NM.SimpleConnection.new() + + s_con = NM.SettingConnection.new() + s_con.set_property(NM.SETTING_CONNECTION_ID, ssid) + s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") + s_con.set_property(NM.SETTING_CONNECTION_AUTOCONNECT, True) + profile.add_setting(s_con) + + s_wifi = NM.SettingWireless.new() + s_wifi.set_property( + NM.SETTING_WIRELESS_SSID, + GLib.Bytes.new(ssid.encode("utf-8")), + ) + s_wifi.set_property(NM.SETTING_WIRELESS_MODE, "infrastructure") + profile.add_setting(s_wifi) + + if key_mgmt == "WPA-PSK" and psk: + s_wsec = NM.SettingWirelessSecurity.new() + s_wsec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk") + s_wsec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, psk) + profile.add_setting(s_wsec) + + s_ip4 = NM.SettingIP4Config.new() + s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto") + profile.add_setting(s_ip4) + + # Persist the connection first. Saving must not depend on being able to + # activate it right now: wlan0 is often unavailable at add time (in AP + # mode, or out of range of the new network), and add_and_activate would + # then fail and save nothing. + try: + conn = _nm_run_async(self._client.add_connection_async, profile, True, None) + except Exception as e: + logger.error("Failed to add WiFi network '%s': %s", ssid, e) + self.populate_wifi_networks() + return - wpa_conf.write("}\n") + # Best effort: bring it up now if the radio is available. + device = self._client.get_device_by_iface("wlan0") + if device is not None: + try: + _nm_run_async( + self._client.activate_connection_async, conn, device, None, None + ) + except Exception as e: + logger.warning("Saved '%s' but could not activate it now: %s", ssid, e) self.populate_wifi_networks() - if self._wifi_mode == "Client": - # Restart the supplicant - wpa_cli("reconfigure") - - def get_ap_name(self): - with open("/etc/hostapd/hostapd.conf", "r") as conf: - for line in conf: - if line.startswith("ssid="): - return line[5:-1] - return "UNKN" - - def set_ap_name(self, ap_name): + + def get_ap_name(self) -> str: + """Get the current AP SSID from the PiFinder-AP profile.""" + for conn in self._client.get_connections(): + if conn.get_id() == AP_CONNECTION_NAME: + s_wifi = conn.get_setting_wireless() + if s_wifi: + ssid_bytes = s_wifi.get_ssid() + if ssid_bytes: + return ssid_bytes.get_data().decode("utf-8") + return "PiFinderAP" + + def set_ap_name(self, ap_name: str) -> None: + """Change the AP SSID. + + Commit the new SSID to the PiFinder-AP profile and, when AP is the live + WiFi mode, re-activate the connection so the running access point + rebroadcasts under the new name without a reboot. (Clients must rejoin + anyway once the SSID changes.) + """ if ap_name == self.get_ap_name(): return - with open("/tmp/hostapd.conf", "w") as new_conf: - with open("/etc/hostapd/hostapd.conf", "r") as conf: - for line in conf: - if line.startswith("ssid="): - line = f"ssid={ap_name}\n" - new_conf.write(line) - sh.sudo("cp", "/tmp/hostapd.conf", "/etc/hostapd/hostapd.conf") - - def get_host_name(self): - return socket.gethostname() + conn = None + for c in self._client.get_connections(): + if c.get_id() == AP_CONNECTION_NAME: + conn = c + break + if conn is None: + logger.error("Connection '%s' not found", AP_CONNECTION_NAME) + return + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + return + s_wifi.set_property( + NM.SETTING_WIRELESS_SSID, + GLib.Bytes.new(ap_name.encode("utf-8")), + ) + try: + _nm_run_async(conn.commit_changes_async, True, None) + except Exception as e: + logger.error("Failed to update AP SSID: %s", e) + return + if self.wifi_mode() == "AP": + self._activate_connection(AP_CONNECTION_NAME) def get_connected_ssid(self) -> str: - """ - Returns the SSID of the connected wifi network or - None if not connected or in AP mode - """ + """Returns the SSID of the connected wifi network.""" if self.wifi_mode() == "AP": return "" - # get output from iwgetid - try: - iwgetid = sh.Command("iwgetid") - _t = iwgetid(_ok_code=(0, 255)).strip() - return _t.split(":")[-1].strip('"') - except sh.CommandNotFound: - return "ssid_not_found" + device = self._client.get_device_by_iface("wlan0") + if device is None: + return "" + ac = device.get_active_connection() + if ac is None: + return "" + conn = ac.get_connection() + if conn is None: + return "" + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + return "" + ssid_bytes = s_wifi.get_ssid() + if ssid_bytes is None: + return "" + return ssid_bytes.get_data().decode("utf-8") + + _HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$") - def set_host_name(self, hostname) -> None: + def set_host_name(self, hostname: str) -> None: + """Set kernel hostname and update avahi mDNS announcement. + + NixOS makes /etc/hostname read-only (nix store symlink), so we set + the kernel hostname directly and persist to a file that a boot + service reads on startup. + """ + hostname = hostname.strip() + if not self._HOSTNAME_RE.match(hostname): + logger.warning("Invalid hostname rejected: %r", hostname) + return if hostname == self.get_host_name(): return - _result = sh.sudo("hostnamectl", "set-hostname", hostname) - self._update_etc_hosts(hostname) + subprocess.run(["sudo", "hostname", hostname], check=False) + result = subprocess.run(["sudo", "avahi-set-host-name", hostname], check=False) + if result.returncode != 0: + logger.warning( + "avahi-set-host-name failed (rc=%d), restarting avahi-daemon", + result.returncode, + ) + subprocess.run( + ["sudo", "systemctl", "restart", "avahi-daemon.service"], + check=False, + ) + data_dir = Path(os.environ.get("PIFINDER_DATA", "/home/pifinder/PiFinder_data")) + (data_dir / "hostname").write_text(hostname) + + def _go_ap(self) -> None: + """Activate the AP connection and remember the choice across reboots.""" + self._persist_wifi_mode("AP") + self._activate_connection(AP_CONNECTION_NAME) + + def _go_client(self) -> None: + """Deactivate the AP connection (fall back to client).""" + self._persist_wifi_mode("Client") + self._deactivate_connection(AP_CONNECTION_NAME) @staticmethod - def _rewrite_hosts(contents: str, new_hostname: str) -> str: - """ - Rewrite the Debian-convention ``127.0.1.1`` line in /etc/hosts to point - at ``new_hostname``. Preserves indentation, the IP, and any trailing - aliases/comments. If no ``127.0.1.1`` line exists, appends one so that - ``sudo`` can still resolve the host. - """ - lines = contents.splitlines(keepends=True) - pattern = re.compile(r"^(\s*127\.0\.1\.1\s+)\S+(.*)$") - replaced = False - for i, line in enumerate(lines): - match = pattern.match(line) - if match: - eol = "\n" if line.endswith("\n") else "" - lines[i] = f"{match.group(1)}{new_hostname}{match.group(2)}{eol}" - replaced = True - break - if not replaced: - if lines and not lines[-1].endswith("\n"): - lines[-1] += "\n" - lines.append(f"127.0.1.1\t{new_hostname}\n") - return "".join(lines) + def _persist_wifi_mode(mode: str) -> None: + """Persist the desired WiFi mode for the boot-time fallback service. - def _update_etc_hosts(self, new_hostname: str) -> None: + The PiFinder-AP NetworkManager profile has a low autoconnect priority, + so a forced AP would otherwise be lost on reboot; the fallback service + reads this file to restore it. + """ + data_dir = Path(os.environ.get("PIFINDER_DATA", "/home/pifinder/PiFinder_data")) try: - with open("/etc/hosts", "r") as hosts_f: - contents = hosts_f.read() - except IOError as e: - logger.error(f"Error reading /etc/hosts: {e}") + (data_dir / "wifi_mode").write_text(mode) + except OSError as e: + logger.warning("Could not persist WiFi mode %r: %s", mode, e) + + def _activate_connection(self, name: str) -> None: + """Activate a saved connection by name.""" + conn = None + for c in self._client.get_connections(): + if c.get_id() == name: + conn = c + break + if conn is None: + logger.error("Connection '%s' not found", name) return - new_contents = Network._rewrite_hosts(contents, new_hostname) - with open("/tmp/hosts", "w") as new_hosts: - new_hosts.write(new_contents) - sh.sudo("cp", "/tmp/hosts", "/etc/hosts") + device = self._client.get_device_by_iface("wlan0") + try: + _nm_run_async( + self._client.activate_connection_async, + conn, + device, + None, + None, + ) + except Exception as e: + logger.error("Failed to activate '%s': %s", name, e) + + def _deactivate_connection(self, name: str) -> None: + """Deactivate an active connection by name.""" + for ac in self._client.get_active_connections(): + if ac.get_id() == name: + try: + _nm_run_async( + self._client.deactivate_connection_async, + ac, + None, + ) + except Exception as e: + logger.error("Failed to deactivate '%s': %s", name, e) + return + logger.warning("No active connection named '%s' to deactivate", name) - def wifi_mode(self): - return self._wifi_mode - def set_wifi_mode(self, mode): - if mode == self._wifi_mode: - return - if mode == "AP": - go_wifi_ap() +# --------------------------------------------------------------------------- +# Module-level WiFi switching (called by callbacks.py and status.py) +# --------------------------------------------------------------------------- - if mode == "Client": - go_wifi_cli() +_network_instance: Optional[Network] = None - def local_ip(self): - if self._wifi_mode == "AP": - return "10.10.10.1" - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - s.connect(("192.255.255.255", 1)) - ip = s.getsockname()[0] - except Exception: - ip = "NONE" - finally: - s.close() - return ip +def _get_network() -> Network: + global _network_instance + if _network_instance is None: + _network_instance = Network() + return _network_instance def go_wifi_ap(): logger.info("SYS: Switching to AP") - sh.sudo("/home/pifinder/PiFinder/switch-ap.sh") + net = _get_network() + net.set_wifi_mode("AP") return True def go_wifi_cli(): logger.info("SYS: Switching to Client") - sh.sudo("/home/pifinder/PiFinder/switch-cli.sh") + net = _get_network() + net.set_wifi_mode("Client") return True -def remove_backup(): - """ - Removes backup file - """ - sh.sudo("rm", BACKUP_PATH, _ok_code=(0, 1)) +def get_wifi_mode() -> str: + """The live WiFi mode ("AP" or "Client") from NetworkManager.""" + return _get_network().wifi_mode() -def backup_userdata(): - """ - Back up userdata to a single zip file for later - restore. Returns the path to the zip file. +# --------------------------------------------------------------------------- +# System control (systemctl subprocess + D-Bus for reboot/shutdown) +# --------------------------------------------------------------------------- - Backs up: - config.json - observations.db - obslist/* - """ - remove_backup() +def restart_system() -> None: + """Restart the system via D-Bus to login1.""" + logger.info("SYS: Initiating System Restart") + try: + bus = _get_system_bus() + login1 = bus.get_object( + "org.freedesktop.login1", + "/org/freedesktop/login1", + ) + manager = dbus.Interface(login1, "org.freedesktop.login1.Manager") + manager.Reboot(False) + except dbus.DBusException as e: + logger.error("D-Bus reboot failed, falling back to subprocess: %s", e) + _run(["sudo", "shutdown", "-r", "now"]) - _zip = sh.Command("zip") - _zip( - BACKUP_PATH, - "/home/pifinder/PiFinder_data/config.json", - "/home/pifinder/PiFinder_data/observations.db", - glob.glob("/home/pifinder/PiFinder_data/obslists/*"), - ) - return BACKUP_PATH +def shutdown() -> None: + """Shut down the system via D-Bus to login1.""" + logger.info("SYS: Initiating Shutdown") + try: + bus = _get_system_bus() + login1 = bus.get_object( + "org.freedesktop.login1", + "/org/freedesktop/login1", + ) + manager = dbus.Interface(login1, "org.freedesktop.login1.Manager") + manager.PowerOff(False) + except dbus.DBusException as e: + logger.error("D-Bus shutdown failed, falling back to subprocess: %s", e) + _run(["sudo", "shutdown", "now"]) -def restore_userdata(zip_path): - """ - Compliment to backup_userdata - restores userdata - OVERWRITES existing data! - """ - unzip("-d", "/", "-o", zip_path) +# --------------------------------------------------------------------------- +# Software updates — async upgrade via systemd service +# --------------------------------------------------------------------------- +UPGRADE_STATE_IDLE = "idle" +UPGRADE_STATE_RUNNING = "running" +UPGRADE_STATE_SUCCESS = "success" +UPGRADE_STATE_FAILED = "failed" -def restart_pifinder() -> None: - """ - Uses systemctl to restart the PiFinder - service - """ - logger.info("SYS: Restarting PiFinder") - sh.sudo("systemctl", "restart", "pifinder") +UPGRADE_REF_FILE = Path("/run/pifinder/upgrade-ref") +UPGRADE_SELECTION_FILE = Path("/run/pifinder/upgrade-selection.json") +UPGRADE_STATUS_FILE = Path("/run/pifinder/upgrade-status") -def restart_system() -> None: - """ - Restarts the system - """ - logger.info("SYS: Initiating System Restart") - sh.sudo("shutdown", "-r", "now") +def _upgrade_service_state() -> str: + result = subprocess.run( + ["systemctl", "is-active", "pifinder-upgrade.service"], + capture_output=True, + text=True, + ) + return result.stdout.strip() -def shutdown() -> None: - """ - shuts down the system - """ - logger.info("SYS: Initiating Shutdown") - sh.sudo("shutdown", "now") - +def start_upgrade(ref: str = "release", selection: Optional[dict] = None) -> bool: + """Start pifinder-upgrade.service with a specific git ref.""" + try: + UPGRADE_REF_FILE.write_text(ref) + if selection: + UPGRADE_SELECTION_FILE.write_text(json.dumps(selection, sort_keys=True)) + else: + UPGRADE_SELECTION_FILE.unlink(missing_ok=True) + except OSError as e: + logger.error("Failed to write upgrade ref file: %s", e) + return False -def update_software(): - """ - Uses systemctl to git pull and then restart - service - """ - logger.info("SYS: Running update") - sh.bash("/home/pifinder/PiFinder/pifinder_update.sh") + # Clean stale status from previous run + UPGRADE_STATUS_FILE.unlink(missing_ok=True) + + # reset-failed errors on a unit that isn't loaded, so only clear an + # actual failed state + if _upgrade_service_state() == "failed": + _run(["sudo", "systemctl", "reset-failed", "pifinder-upgrade.service"]) + result = _run( + [ + "sudo", + "systemctl", + "start", + "--no-block", + "pifinder-upgrade.service", + ] + ) + if result.returncode != 0: + UPGRADE_STATUS_FILE.write_text("failed") + return False return True -def verify_password(username, password): - """ - Checks the provided password against the provided user - password +def list_rollback_targets(profile_dir: Path = Path("/nix/var/nix/profiles")) -> list: + """On-disk system generations available for rollback (all but the current). + + Reads only immutable generation data — the profile symlinks and the + store-path names — so there is NO sidecar state file to evolve or corrupt, + and it works even when the updater is offline. Each entry mirrors a + Software-screen version entry so the same list UI can render it. """ - p = pam.pam() + try: + current = (profile_dir / "system").resolve() + except OSError: + return [] - return p.authenticate(username, password) + targets = [] + for link in profile_dir.glob("system-*-link"): + try: + generation = int(link.name.split("-")[1]) + store_path = link.resolve() + mtime = link.lstat().st_mtime + except (OSError, ValueError, IndexError): + continue + if store_path == current: + continue + marker = "nixos-system-pifinder-" + name = store_path.name + label = name.split(marker, 1)[-1] if marker in name else name + # Local time for display, via the tz-aware timez helper (DTZ) + date = timez.utc_from_timestamp(mtime).astimezone().strftime("%d %b %H:%M") + targets.append( + ( + generation, + { + "ref": str(store_path), + "label": label, + "version": label, + "notes": None, + "subtitle": f"gen {generation} · {date}", + "channel": "rollback", + }, + ) + ) + targets.sort(key=lambda t: t[0], reverse=True) + return [entry for _generation, entry in targets] -def change_password(username, current_password, new_password): - """ - Changes the PiFinder User password +def get_upgrade_state() -> str: + """Poll upgrade status file written by the upgrade service.""" + try: + status = UPGRADE_STATUS_FILE.read_text().strip() + except FileNotFoundError: + # Service hasn't written status yet — check if it's still starting + svc = _upgrade_service_state() + if svc in ("activating", "active"): + return UPGRADE_STATE_RUNNING + if svc == "failed": + return UPGRADE_STATE_FAILED + return UPGRADE_STATE_IDLE + + if status == "success": + return UPGRADE_STATE_SUCCESS + elif status in ("failed", "unavailable", "connfail"): + return UPGRADE_STATE_FAILED + elif status.startswith("downloading") or status in ( + "starting", + "activating", + "rebooting", + ): + return UPGRADE_STATE_RUNNING + return UPGRADE_STATE_IDLE + + +def get_upgrade_progress() -> dict: + """Return structured upgrade progress for UI display. + + Returns dict with keys: + phase: "starting" | "downloading" | "activating" | "rebooting" + | "success" | "failed" | "unavailable" | "connfail" | "" + done: int (downloaded so far, in `unit`) + total: int (total to download, in `unit`) + unit: "bytes" | "paths" + percent: int (0-100) + + The download status line is "downloading /" in bytes; + a trailing " paths" marks the fallback where byte sizes were not + available and the figures are path counts instead. """ - result = passwd( - username, - _in=f"{current_password}\n{new_password}\n{new_password}\n", - _ok_code=(0, 10), + empty = { + "phase": "", + "done": 0, + "total": 0, + "unit": "bytes", + "percent": 0, + "item": "", + } + try: + raw = UPGRADE_STATUS_FILE.read_text().strip() + except FileNotFoundError: + svc = _upgrade_service_state() + if svc in ("activating", "active"): + return {**empty, "phase": "starting"} + if svc == "failed": + return {**empty, "phase": "failed"} + return empty + + svc = _upgrade_service_state() + if raw in ("starting", "activating") or raw.startswith("downloading "): + if svc in ("failed", "inactive"): + return {**empty, "phase": "failed"} + + if raw.startswith("downloading "): + body = raw[len("downloading ") :].strip() + unit = "bytes" + if body.endswith(" paths"): + unit = "paths" + body = body[: -len(" paths")].strip() + # body is "/" optionally followed by " " + nums, _sep, item = body.partition(" ") + parts = nums.split("/") + try: + done, total = int(parts[0]), int(parts[1]) + pct = int(done * 100 / total) if total > 0 else 0 + pct = max(0, min(100, pct)) + return { + "phase": "downloading", + "done": done, + "total": total, + "unit": unit, + "percent": pct, + "item": item.strip(), + } + except (ValueError, IndexError): + return {**empty, "phase": "downloading"} + if raw == "starting": + return {**empty, "phase": "starting"} + if raw == "activating": + return {**empty, "phase": "activating", "percent": 100} + if raw == "rebooting": + return {**empty, "phase": "rebooting", "percent": 100} + if raw == "success": + return {**empty, "phase": "success", "percent": 100} + if raw == "unavailable": + return {**empty, "phase": "unavailable"} + if raw == "connfail": + return {**empty, "phase": "connfail"} + if raw == "failed": + return {**empty, "phase": "failed"} + return empty + + +def get_upgrade_log_tail(lines: int = 3) -> str: + """Last N lines from upgrade journal for UI display.""" + result = _run( + [ + "journalctl", + "-u", + "pifinder-upgrade.service", + "-n", + str(lines), + "--no-pager", + "-o", + "cat", + ] ) + return result.stdout.strip() if result.returncode == 0 else "" - if result.exit_code == 0: - return True - else: - return False +def update_software(ref: str = "release", selection: Optional[dict] = None) -> bool: + """Start the upgrade service (non-blocking). -def switch_cam_imx477() -> None: - logger.info("SYS: Switching cam to imx477") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx477") + The service downloads, sets the boot profile, and reboots. + UI should poll get_upgrade_progress() for status. + """ + return start_upgrade(ref=ref, selection=selection) -def switch_cam_imx296() -> None: - logger.info("SYS: Switching cam to imx296") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx296") +# --------------------------------------------------------------------------- +# Password management (python-pam + chpasswd) +# --------------------------------------------------------------------------- -def switch_cam_imx462() -> None: - logger.info("SYS: Switching cam to imx462") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx462") +def verify_password(username: str, password: str) -> bool: + """Verify a password against PAM.""" + p = pam.pam() + return p.authenticate(username, password, service="pifinder") -def check_and_sync_gpsd_config(baud_rate: int) -> bool: - """ - Checks if GPSD configuration matches the desired baud rate, - and updates it only if necessary. +def change_password(username: str, current_password: str, new_password: str) -> bool: + """Change the user password via chpasswd.""" + if not verify_password(username, current_password): + return False + result = subprocess.run( + ["sudo", "chpasswd"], + input=f"{username}:{new_password}\n", + capture_output=True, + text=True, + ) + return result.returncode == 0 - Args: - baud_rate: The desired baud rate (9600 or 115200) - Returns: - True if configuration was updated, False if already correct - """ - logger.info(f"SYS: Checking GPSD config for baud rate {baud_rate}") +# --------------------------------------------------------------------------- +# Camera switching (specialisations + reboot) +# --------------------------------------------------------------------------- - try: - # Read current config - with open("/etc/default/gpsd", "r") as f: - content = f.read() - - # Determine expected GPSD_OPTIONS - if baud_rate == 115200: - # NOTE: the space before -s in the next line is really needed - expected_options = 'GPSD_OPTIONS=" -s 115200"' - else: - expected_options = 'GPSD_OPTIONS=""' - - # Check if update is needed - current_match = re.search(r"^GPSD_OPTIONS=.*$", content, re.MULTILINE) - if current_match: - current_options = current_match.group(0) - if current_options == expected_options: - logger.info("SYS: GPSD config already correct, no update needed") - return False - - # Update is needed - logger.info(f"SYS: GPSD config mismatch, updating to {expected_options}") - update_gpsd_config(baud_rate) - return True - - except Exception as e: - logger.error(f"SYS: Error checking/syncing GPSD config: {e}") - return False +CAMERA_TYPE_FILE = "/var/lib/pifinder/camera-type" -def update_gpsd_config(baud_rate: int) -> None: +def switch_camera(cam_type: str) -> None: """ - Updates the GPSD configuration file with the specified baud rate - and restarts the GPSD service. - - Args: - baud_rate: The baud rate to configure (9600 or 115200) + Switch camera via NixOS specialisation. + Requires reboot (dtoverlay change). """ - logger.info(f"SYS: Updating GPSD config with baud rate {baud_rate}") - - try: - # Read the current config - with open("/etc/default/gpsd", "r") as f: - lines = f.readlines() - - # Update GPSD_OPTIONS line - updated_lines = [] - for line in lines: - if line.startswith("GPSD_OPTIONS="): - if baud_rate == 115200: - # NOTE: the space before -s in the next line is really needed - updated_lines.append('GPSD_OPTIONS=" -s 115200"\n') - else: - updated_lines.append('GPSD_OPTIONS=""\n') - else: - updated_lines.append(line) - - # Write the updated config to a temporary file - with open("/tmp/gpsd.conf", "w") as f: - f.writelines(updated_lines) - - # Copy the temp file to the actual location with sudo - sh.sudo("cp", "/tmp/gpsd.conf", "/etc/default/gpsd") + logger.info("SYS: Switching camera to %s via specialisation", cam_type) + result = _run(["sudo", "pifinder-switch-camera", cam_type]) + if result.returncode != 0: + logger.error("SYS: Camera switch failed: %s", result.stderr) - # Restart GPSD service - sh.sudo("systemctl", "restart", "gpsd") - logger.info("SYS: GPSD configuration updated and service restarted") +def get_camera_type() -> list[str]: + try: + with open(CAMERA_TYPE_FILE) as f: + return [f.read().strip()] + except FileNotFoundError: + return ["imx462"] - except Exception as e: - logger.error(f"SYS: Error updating GPSD config: {e}") - raise +def switch_cam_imx477() -> None: + logger.info("SYS: Switching cam to imx477") + switch_camera("imx477") -# Raspberry Pi red power LED. It is a plain gpio-led (on/off only, not -# dimmable), so the brightness file is effectively a boolean. -PWR_LED_PATH = "/sys/class/leds/PWR" +def switch_cam_imx296() -> None: + logger.info("SYS: Switching cam to imx296") + switch_camera("imx296") -def set_power_led(on: bool) -> None: - """ - Turn the Raspberry Pi's red PWR LED on or off. - The LED is not dimmable, so this is strictly on/off. We set the kernel - trigger to "none" first, otherwise the firmware's "default-on" trigger - keeps re-asserting the LED, then write the brightness directly. Uses - passwordless sudo, like the other privileged helpers in this module. - """ - value = "1" if on else "0" - sh.sudo( - "sh", - "-c", - f"echo none > {PWR_LED_PATH}/trigger; " - f"echo {value} > {PWR_LED_PATH}/brightness", - ) - logger.info("SYS: Power LED %s", "on" if on else "off") +def switch_cam_imx462() -> None: + logger.info("SYS: Switching cam to imx462") + switch_camera("imx462") # --------------------------------------------------------------------------- -# NixOS migration +# GPSD config (declarative on NixOS — no-ops) # --------------------------------------------------------------------------- -MIGRATION_PROGRESS_FILE = "/tmp/nixos_migration_progress" -MIGRATION_SCRIPT = "/home/pifinder/PiFinder/python/scripts/nixos_migration.sh" - -def _fetch_migration_sha256(version_info: dict) -> str: - """Fetch SHA256 from sidecar URL, falling back to hardcoded value.""" - sha256_url = version_info.get("migration_sha256_url", "") - if sha256_url: - try: - resp = requests.get(sha256_url, timeout=15) - if resp.status_code == 200: - sha256 = resp.text.strip().split()[0] - logger.info(f"SYS: Fetched migration SHA256: {sha256[:16]}...") - return sha256 - logger.warning(f"SYS: SHA256 fetch returned {resp.status_code}") - except requests.exceptions.RequestException as e: - logger.warning(f"SYS: Failed to fetch SHA256: {e}") - - sha256 = version_info.get("migration_sha256", "") - if sha256: - logger.info("SYS: Using hardcoded migration SHA256") - return sha256 - - -def start_nixos_migration(version_info: dict) -> None: +def check_and_sync_gpsd_config(baud_rate: int) -> bool: """ - Start the NixOS migration process in the background. - - Raises ValueError if migration_url or a migration SHA256 cannot be - obtained — an in-place OS replacement must not run without checksum - verification. + On NixOS, GPSD config is managed declaratively via services.nix. + This is a no-op. """ - url = version_info.get("migration_url", "") - if not url: - raise ValueError("Missing migration_url") - sha256 = _fetch_migration_sha256(version_info) - if not sha256: - raise ValueError( - "No migration SHA256 available (neither migration_sha256_url nor " - "migration_sha256 produced a value); refusing to migrate without " - "checksum verification" - ) - display_class = str(version_info.get("display_class", "")) - display_resolution_value = version_info.get("display_resolution", "") - if isinstance(display_resolution_value, (list, tuple)): - display_resolution = "x".join(str(part) for part in display_resolution_value) - else: - display_resolution = str(display_resolution_value) - - logger.info(f"SYS: Starting NixOS migration to {version_info.get('version', '?')}") + logger.info("SYS: GPSD baud rate %d — managed by NixOS configuration", baud_rate) + return False - with open(MIGRATION_PROGRESS_FILE, "w") as f: - json.dump({"percent": 0, "status": "Starting..."}, f) - def _log_output(line): - logger.info(f"SYS: migration: {line.strip()}") +def update_gpsd_config(baud_rate: int) -> None: + """On NixOS, GPSD configuration is declarative. This is a no-op.""" + logger.info( + "SYS: GPSD config is managed declaratively on NixOS (baud=%d)", baud_rate + ) - def _log_error(line): - logger.error(f"SYS: migration: {line.strip()}") - def _on_done(cmd, success, exit_code): - if not success: - logger.error(f"SYS: Migration script failed with exit code {exit_code}") +# Raspberry Pi red power LED — a plain gpio-led (on/off only, not dimmable). +PWR_LED_PATH = Path("/sys/class/leds/PWR") - try: - sh.bash( - MIGRATION_SCRIPT, - url, - sha256, - MIGRATION_PROGRESS_FILE, - display_class, - display_resolution, - _bg=True, - _bg_exc=False, - _out=_log_output, - _err=_log_error, - _done=_on_done, - ) - except Exception as e: - logger.error(f"SYS: Migration failed to start: {e}") - raise +def set_power_led(on: bool) -> None: + """Turn the Raspberry Pi's red PWR LED on or off. -def get_migration_progress() -> Dict[str, Any]: - """ - Read current migration progress from the progress file. + The kernel trigger is set to "none" first, otherwise the firmware's + "default-on" trigger keeps re-asserting the LED. Direct sysfs writes — + pwm-permissions (services.nix) makes these files user-writable at boot, + so no sudo is needed. A missing LED (dev box, other SBC) raises OSError, + which the caller treats as non-fatal. """ - try: - with open(MIGRATION_PROGRESS_FILE, "r") as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return {} + (PWR_LED_PATH / "trigger").write_text("none") + (PWR_LED_PATH / "brightness").write_text("1" if on else "0") diff --git a/python/PiFinder/sys_utils_base.py b/python/PiFinder/sys_utils_base.py new file mode 100644 index 000000000..8dcb5ccfb --- /dev/null +++ b/python/PiFinder/sys_utils_base.py @@ -0,0 +1,181 @@ +""" +Abstract base for PiFinder system utilities. + +Defines the public API contract and shared implementations used by all +platform backends (Debian, NixOS, fake/testing). +""" + +import logging +import socket +import zipfile +from abc import ABC, abstractmethod +from pathlib import Path + +from PiFinder import utils + +BACKUP_PATH = str(utils.data_dir / "PiFinder_backup.zip") + +logger = logging.getLogger("SysUtils") + + +# --------------------------------------------------------------------------- +# Network ABC — shared + abstract methods +# --------------------------------------------------------------------------- + + +class NetworkBase(ABC): + """Base class for platform-specific Network implementations.""" + + _wifi_mode: str = "Client" + _wifi_networks: list = [] + + def get_host_name(self) -> str: + return socket.gethostname() + + def is_wired_connected(self) -> bool: + """True when a wired (ethernet) link is the active uplink. Overridden + on hardware; the base default assumes no wired link.""" + return False + + def local_ip(self) -> str: + # In AP mode the only address is the AP's own — unless an ethernet + # cable is plugged in, in which case the device is really reachable on + # the wired IP, so fall through to it. + if self._wifi_mode == "AP" and not self.is_wired_connected(): + return "10.10.10.1" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("192.255.255.255", 1)) + ip = s.getsockname()[0] + except Exception: + ip = "NONE" + finally: + s.close() + return ip + + def get_active_label(self) -> str: + """Label for the active uplink, for the status display: a wired link + wins (shown as 'Ethernet'), then the connected client SSID, then the + AP name; empty if nothing is up.""" + if self.is_wired_connected(): + return "Ethernet" + ssid = self.get_connected_ssid() + if ssid: + return ssid + if self.wifi_mode() == "AP": + return self.get_ap_name() + return "" + + def wifi_mode(self) -> str: + return self._wifi_mode + + def get_wifi_networks(self): + return self._wifi_networks + + def set_wifi_mode(self, mode: str) -> None: + if mode == self._wifi_mode: + return + if mode == "AP": + self._go_ap() + elif mode == "Client": + self._go_client() + self._wifi_mode = mode + + @abstractmethod + def _go_ap(self) -> None: ... + + @abstractmethod + def _go_client(self) -> None: ... + + @abstractmethod + def populate_wifi_networks(self) -> None: ... + + @abstractmethod + def delete_wifi_network(self, network_id) -> None: ... + + @abstractmethod + def add_wifi_network(self, ssid, key_mgmt, psk=None) -> None: ... + + @abstractmethod + def get_ap_name(self) -> str: ... + + @abstractmethod + def set_ap_name(self, ap_name: str) -> None: ... + + @abstractmethod + def get_connected_ssid(self) -> str: ... + + @abstractmethod + def set_host_name(self, hostname: str) -> None: ... + + +# --------------------------------------------------------------------------- +# Backup / restore (stdlib zipfile — portable across all platforms) +# --------------------------------------------------------------------------- + + +def remove_backup() -> None: + """Removes backup file.""" + path = Path(BACKUP_PATH) + if path.exists(): + path.unlink() + + +def backup_userdata() -> str: + """ + Back up userdata to a single zip file. + + Backs up: + config.json + observations.db + obslists/* + """ + remove_backup() + + files = [ + utils.data_dir / "config.json", + utils.data_dir / "observations.db", + ] + for p in utils.data_dir.glob("obslists/*"): + files.append(p) + + with zipfile.ZipFile(BACKUP_PATH, "w", zipfile.ZIP_DEFLATED) as zf: + for filepath in files: + filepath = Path(filepath) + if filepath.exists(): + zf.write(filepath, filepath.relative_to("/")) + + return BACKUP_PATH + + +def restore_userdata(zip_path: str) -> None: + """ + Restore userdata from a zip backup. + OVERWRITES existing data! + """ + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall("/") + + +# --------------------------------------------------------------------------- +# Service control (shared across Debian + NixOS) +# --------------------------------------------------------------------------- + + +def restart_pifinder() -> None: + """Restart the PiFinder service via systemctl.""" + import subprocess + + logger.info("SYS: Restarting PiFinder") + # Must be the full unit name: the NixOS sudoers rule allows exactly + # "systemctl restart pifinder.service", and sudo matches arguments + # verbatim — "restart pifinder" is refused and the restart silently + # never happens (the UI shows "Restarting..." but the stale process + # keeps running, e.g. with the old screen_direction IMU geometry). + result = subprocess.run( + ["sudo", "-n", "systemctl", "restart", "pifinder.service"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + logger.error("SYS: PiFinder restart failed: %s", result.stderr.strip()) diff --git a/python/PiFinder/sys_utils_fake.py b/python/PiFinder/sys_utils_fake.py index 77ca71504..9f6952e58 100644 --- a/python/PiFinder/sys_utils_fake.py +++ b/python/PiFinder/sys_utils_fake.py @@ -1,244 +1,91 @@ -import socket import logging -import os -import zipfile -import tempfile - -# For testing, use a directory structure that mimics the production setup -# but in a writable location. The server serves from /home/pifinder/PiFinder_data -# so we need to create a backup file that can be served from there. -# Since we can't write to /home/pifinder as a regular user, we'll use the current -# user's directory structure that mirrors the production layout. -_pifinder_data_dir = os.path.expanduser("~/PiFinder_data") -os.makedirs(_pifinder_data_dir, exist_ok=True) -BACKUP_PATH = os.path.join(_pifinder_data_dir, "PiFinder_backup.zip") + +from PiFinder.sys_utils_base import ( + NetworkBase, + BACKUP_PATH, +) logger = logging.getLogger("SysUtils.Fake") -class Network: +class Network(NetworkBase): """ - Provides wifi network info + Fake network for testing/development. """ def __init__(self): - pass + self._wifi_mode = "Client" + self._wifi_networks: list = [] - def populate_wifi_networks(self): - """ - Parses wpa_supplicant.conf to get current config - """ + def populate_wifi_networks(self) -> None: pass - def get_wifi_networks(self): - return "" - - def delete_wifi_network(self, network_id): - """ - Immediately deletes a wifi network - """ + def delete_wifi_network(self, network_id) -> None: pass - def add_wifi_network(self, ssid, key_mgmt, psk=None): - """ - Add a wifi network - """ + def add_wifi_network(self, ssid, key_mgmt, psk=None) -> None: pass - def get_ap_name(self): + def get_ap_name(self) -> str: return "UNKN" - def set_ap_name(self, ap_name): + def set_ap_name(self, ap_name: str) -> None: pass - def get_host_name(self): - return socket.gethostname() - - def get_connected_ssid(self): - """ - Returns the SSID of the connected wifi network or - None if not connected or in AP mode - """ - return "UNKN" - - def set_host_name(self, hostname): - if hostname == self.get_host_name(): - return - - def wifi_mode(self): + def get_connected_ssid(self) -> str: return "UNKN" - def set_wifi_mode(self, mode): + def set_host_name(self, hostname: str) -> None: pass - def local_ip(self): - return "NONE" + def _go_ap(self) -> None: + logger.info("SYS: Fake switching to AP") + def _go_client(self) -> None: + logger.info("SYS: Fake switching to Client") -def remove_backup(): - """ - Removes backup file - """ - try: - if os.path.exists(BACKUP_PATH): - os.remove(BACKUP_PATH) - except OSError: - pass +def remove_backup() -> None: + pass -def backup_userdata(): - """ - Back up userdata to a single zip file for later - restore. Returns the path to the zip file. - - Backs up: - config.json - observations.db - obslist/* - """ - remove_backup() - - # Use actual files from ~/PiFinder_data directory - source_dir = _pifinder_data_dir - - # Create zip file with actual user data - with zipfile.ZipFile(BACKUP_PATH, "w", zipfile.ZIP_DEFLATED) as zipf: - # Add config.json if it exists - config_path = os.path.join(source_dir, "config.json") - if os.path.exists(config_path): - zipf.write(config_path, "home/pifinder/PiFinder_data/config.json") - - # Add observations.db if it exists - db_path = os.path.join(source_dir, "observations.db") - if os.path.exists(db_path): - zipf.write(db_path, "home/pifinder/PiFinder_data/observations.db") - - # Add all files from obslists directory if it exists - obslists_dir = os.path.join(source_dir, "obslists") - if os.path.exists(obslists_dir): - for filename in os.listdir(obslists_dir): - file_path = os.path.join(obslists_dir, filename) - if os.path.isfile(file_path): - zipf.write( - file_path, f"home/pifinder/PiFinder_data/obslists/{filename}" - ) +def backup_userdata() -> str: return BACKUP_PATH -def restore_userdata(zip_path): - """ - Compliment to backup_userdata - "restores" userdata +def restore_userdata(zip_path) -> None: + pass - For the fake version, this compares the zip contents - with the current ~/PiFinder_data contents and throws - an exception if they don't match. - """ - import zipfile - import filecmp - - if not os.path.exists(zip_path): - raise FileNotFoundError(f"Backup file not found: {zip_path}") - - # Extract zip to temporary directory for comparison - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(zip_path, "r") as zipf: - # Extract all files - zipf.extractall(temp_dir) - - # Compare extracted files with actual files in ~/PiFinder_data - extracted_base = os.path.join(temp_dir, "home", "pifinder", "PiFinder_data") - actual_base = _pifinder_data_dir - - if not os.path.exists(extracted_base): - raise ValueError( - "Invalid backup file: missing expected directory structure" - ) - - # Check each file that should exist - files_to_check = ["config.json", "observations.db"] - - for filename in files_to_check: - extracted_file = os.path.join(extracted_base, filename) - actual_file = os.path.join(actual_base, filename) - - # If file exists in backup but not in actual directory - if os.path.exists(extracted_file) and not os.path.exists(actual_file): - raise ValueError( - f"Backup contains {filename} but it doesn't exist in {actual_base}" - ) - - # If file exists in both, compare contents - if os.path.exists(extracted_file) and os.path.exists(actual_file): - if not filecmp.cmp(extracted_file, actual_file, shallow=False): - raise ValueError( - f"Backup file {filename} differs from current version in {actual_base}" - ) - - # Check obslists directory - extracted_obslists = os.path.join(extracted_base, "obslists") - actual_obslists = os.path.join(actual_base, "obslists") - - if os.path.exists(extracted_obslists): - if not os.path.exists(actual_obslists): - raise ValueError( - "Backup contains obslists directory but it doesn't exist in current data" - ) - - # Compare each file in obslists - for filename in os.listdir(extracted_obslists): - extracted_obslist = os.path.join(extracted_obslists, filename) - actual_obslist = os.path.join(actual_obslists, filename) - - if os.path.isfile(extracted_obslist): - if not os.path.exists(actual_obslist): - raise ValueError( - f"Backup contains obslist {filename} but it doesn't exist in current obslists" - ) - - if not filecmp.cmp( - extracted_obslist, actual_obslist, shallow=False - ): - raise ValueError( - f"Backup obslist {filename} differs from current version" - ) - - # If we get here, all files match - logger.info("Restore validation successful: backup contents match current data") - return True - - -def shutdown(): - """ - shuts down the Pi - """ + +def shutdown() -> None: logger.info("SYS: Initiating Shutdown") - return True -def update_software(): - """ - Uses systemctl to git pull and then restart - service - """ - logger.info("SYS: Running update") +def update_software(ref: str = "release", selection=None): + logger.info("SYS: Running update (ref=%s)", ref) return True -def restart_pifinder(): - """ - Uses systemctl to restart the PiFinder - service - """ +def list_rollback_targets() -> list: + return [] + + +def get_upgrade_progress() -> dict: + return { + "phase": "", + "done": 0, + "total": 0, + "unit": "bytes", + "percent": 0, + "item": "", + } + + +def restart_pifinder() -> None: logger.info("SYS: Restarting PiFinder") - return True -def restart_system(): - """ - Restarts the system - """ +def restart_system() -> None: logger.info("SYS: Initiating System Restart") @@ -252,29 +99,41 @@ def go_wifi_cli(): return True +def get_wifi_mode() -> str: + return "Client" + + def verify_password(username, password): - """ - Checks the provided password against the provided user - password - """ return True def change_password(username, current_password, new_password): - """ - Changes the PiFinder User password - """ return False +def get_camera_type() -> list[str]: + return ["imx462"] + + def switch_cam_imx477() -> None: logger.info("SYS: Switching cam to imx477") - logger.info('sh.sudo("python", "-m", "PiFinder.switch_camera", "imx477")') def switch_cam_imx296() -> None: logger.info("SYS: Switching cam to imx296") - logger.info('sh.sudo("python", "-m", "PiFinder.switch_camera", "imx296")') + + +def switch_cam_imx462() -> None: + logger.info("SYS: Switching cam to imx462") + + +def check_and_sync_gpsd_config(baud_rate: int) -> bool: + logger.info("SYS: Checking GPSD config for baud rate %d (fake)", baud_rate) + return False + + +def update_gpsd_config(baud_rate: int) -> None: + logger.info("SYS: Updating GPSD config with baud rate %d (fake)", baud_rate) def set_power_led(on: bool) -> None: diff --git a/python/PiFinder/tetra3 b/python/PiFinder/tetra3 deleted file mode 160000 index 38c3f48f5..000000000 --- a/python/PiFinder/tetra3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 38c3f48f57d1005e9b65cbb26136f9f13ec0a1b0 diff --git a/python/PiFinder/timez.py b/python/PiFinder/timez.py index 70051046b..9c6bf501c 100644 --- a/python/PiFinder/timez.py +++ b/python/PiFinder/timez.py @@ -22,6 +22,7 @@ def utc_now() -> datetime.datetime: """Current instant, timezone-aware in UTC. Use for civil/astronomical time.""" return datetime.datetime.now(pytz.utc) + def local_now() -> datetime.datetime: """Current instant as a naive datetime in the host's local timezone. @@ -30,26 +31,44 @@ def local_now() -> datetime.datetime: """ return datetime.datetime.now() + def utc_from_timestamp(ts: float) -> datetime.datetime: """A POSIX timestamp as a timezone-aware UTC datetime (e.g. a file mtime).""" return datetime.datetime.fromtimestamp(ts, pytz.utc) + def parse(value: str, fmt: str) -> datetime.datetime: """``strptime`` producing a naive datetime; the caller attaches the zone.""" return datetime.datetime.strptime(value, fmt) -def naive(year: int, month: int, day: int, - hour: int = 0, minute: int = 0, second: int = 0, - microsecond: int = 0) -> datetime.datetime: + +def naive( + year: int, + month: int, + day: int, + hour: int = 0, + minute: int = 0, + second: int = 0, + microsecond: int = 0, +) -> datetime.datetime: """Construct a naive datetime from explicit fields; the caller localizes it.""" # trick linter that refuses regular naive construction, pass "acceptable" # datetime then nuke the tzinfo - return datetime.datetime(year, month, day, hour, minute, second, - microsecond, tzinfo=pytz.utc).replace(tzinfo=None) + return datetime.datetime( + year, month, day, hour, minute, second, microsecond, tzinfo=pytz.utc + ).replace(tzinfo=None) + -def utc(year: int, month: int, day: int, - hour: int = 0, minute: int = 0, second: int = 0, - microsecond: int = 0) -> datetime.datetime: +def utc( + year: int, + month: int, + day: int, + hour: int = 0, + minute: int = 0, + second: int = 0, + microsecond: int = 0, +) -> datetime.datetime: """Construct a timezone-aware UTC datetime from explicit fields.""" - return datetime.datetime(year, month, day, hour, minute, second, - microsecond, tzinfo=pytz.utc) + return datetime.datetime( + year, month, day, hour, minute, second, microsecond, tzinfo=pytz.utc + ) diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index da51395a6..1f598796f 100644 --- a/python/PiFinder/ui/base.py +++ b/python/PiFinder/ui/base.py @@ -8,7 +8,7 @@ import time import uuid from itertools import cycle -from typing import Type, Union +from typing import Union from PIL import Image, ImageDraw from PiFinder import utils @@ -47,7 +47,9 @@ def __init__(self, shared_state, interval=3.0, fade_speed=0.15): def _get_text(self, use_sqm): if use_sqm: sqm = self.shared_state.sqm() - return f"{sqm.value:.1f}" if sqm and sqm.value else "---" + if sqm and sqm.last_update is not None: + return f"{sqm.value:.1f}" + return "---" else: sol = self.shared_state.solution() return sol.constellation if sol and sol.constellation else "---" @@ -102,6 +104,7 @@ class UIModule: __uuid__ = str(uuid.uuid1()).split("-")[0] _config_options: dict _CAM_ICON = "" + _CAM_ICON_HOLLOW = "" _IMU_ICON = "" _GPS_ICON = "󰤉" _LEFT_ARROW = "" @@ -131,7 +134,7 @@ class UIModule: def __init__( self, - display_class: Type[DisplayBase], + display_class: DisplayBase, camera_image, shared_state, command_queues, @@ -482,6 +485,8 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: if self.shared_state: if self.shared_state.solve_state(): solution = self.shared_state.solution() + if solution is None: + return cam_active = solution.is_camera_solve() # a fresh cam solve sets unmoved to True self._unmoved = True if cam_active else self._unmoved @@ -493,9 +498,14 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: # self.draw.rectangle([115, 2, 125, 14], fill=bg) if self._unmoved: + is_test = self.config_object.get_option("test_mode", False) + icon_x = self.display_class.resX * 0.91 + # In test mode the camera feed is faked, so show the + # hollow (outline) camera icon as a subtle indicator + # rather than a bright inverted box. self.draw.text( - (self.display_class.resX * 0.91, icon_y), - self._CAM_ICON, + (icon_x, icon_y), + self._CAM_ICON_HOLLOW if is_test else self._CAM_ICON, font=self.fonts.icon_bold_large.font, fill=var_fg, ) diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 6b48ddda6..dd225e979 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -71,13 +71,18 @@ def reset_filters(ui_module: UIModule) -> None: def activate_debug(ui_module: UIModule) -> None: """ - Sets camera into debug - add fake gps info + Toggles test mode (fake camera image + fake GPS). + Main flips shared_state/config; the camera process follows + shared_state.test_mode() on its own. """ - ui_module.command_queues["camera"].put("debug") - ui_module.command_queues["console"].put("Test Mode Activated") ui_module.command_queues["ui_queue"].put("test_mode") - ui_module.message(_("Test Mode")) + + +def test_mode_suffix(ui_module: UIModule) -> str: + """Returns ON/OFF suffix for Test Mode menu entry.""" + if ui_module.config_object.get_option("test_mode", False): + return " ON" + return " OFF" def set_exposure(ui_module: UIModule) -> None: @@ -211,21 +216,7 @@ def switch_cam_imx462(ui_module: UIModule) -> None: def get_camera_type(ui_module: UIModule) -> list[str]: - cam_id = "000" - - # read config.txt into a list - with open("/boot/config.txt", "r") as boot_in: - boot_lines = list(boot_in) - - # Look for the line without a comment... - for line in boot_lines: - if line.startswith("dtoverlay=imx"): - cam_id = line[10:16] - # imx462 uses imx290 driver - if cam_id == "imx290": - cam_id = "imx462" - - return [cam_id] + return sys_utils.get_camera_type() def switch_language(ui_module: UIModule) -> None: @@ -237,9 +228,6 @@ def switch_language(ui_module: UIModule) -> None: ) lang.install() logger.info("Switch Language: %s", iso2_code) - if iso2_code == "zh": - # Chinese requires a new font, so we have to restart - restart_pifinder(ui_module) def go_wifi_ap(ui_module: UIModule) -> None: @@ -255,9 +243,15 @@ def go_wifi_cli(ui_module: UIModule) -> None: def get_wifi_mode(ui_module: UIModule) -> list[str]: - wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" - with open(wifi_txt, "r") as wfs: - return [wfs.read()] + # Report the live mode from NetworkManager (as the web UI does), not the + # static wifi_status.txt — that file is written once at setup and never + # tracks reality, so it showed "Client" while the device was on the AP. + try: + return [sys_utils.get_wifi_mode()] + except Exception: + wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" + with open(wifi_txt, "r") as wfs: + return [wfs.read()] def set_location(ui_module: UIModule) -> None: diff --git a/python/PiFinder/ui/console.py b/python/PiFinder/ui/console.py index db20086a4..3af561e5b 100644 --- a/python/PiFinder/ui/console.py +++ b/python/PiFinder/ui/console.py @@ -11,7 +11,6 @@ from PIL import Image from PiFinder.ui.base import GPS_ANIM_RATE, UIModule -from PiFinder import timez from PiFinder.ui.layout import rows_below_titlebar from PiFinder.image_util import convert_image_to_mode @@ -36,7 +35,7 @@ def __init__(self, *args, **kwargs): self.dirty = True self.welcome = True - # load welcome image to screen + # Load welcome image as startup backdrop root_dir = os.path.realpath( os.path.join(os.path.dirname(__file__), "..", "..", "..") ) @@ -53,21 +52,13 @@ def __init__(self, *args, **kwargs): self.lines = ["---- TOP ---", "Sess UUID:" + self.__uuid__] self.scroll_offset = 0 - self.debug_mode = False def set_shared_state(self, shared_state): self.shared_state = shared_state def key_number(self, number): if number == 0: - self.command_queues["camera"].put("debug") - if self.debug_mode: - self.debug_mode = False - else: - self.debug_mode = True - self.command_queues["console"].put("Debug: " + str(self.debug_mode)) - dt = timez.utc(2022, 11, 15, 2, 0, 0) - self.shared_state.set_datetime(dt) + self.command_queues["ui_queue"].put("test_mode") def key_enter(self): # reset scroll offset @@ -94,6 +85,13 @@ def write(self, line): self.scroll_offset = 0 self.dirty = True + def finish_startup(self): + """End the startup splash phase and clear the welcome backdrop.""" + self.welcome = False + self.clear_screen() + self.dirty = True + self.update() + def active(self): self.welcome = False self.dirty = True @@ -188,12 +186,28 @@ def screen_update(self, title_bar=True, button_hints=True): # self.draw.rectangle([115, 2, 125, 14], fill=bg) if self._unmoved: - self.draw.text( - (self.display_class.resX * 0.91, -2), - self._CAM_ICON, - font=self.fonts.icon_bold_large.font, - fill=var_fg, - ) + is_test = self.config_object.get_option("test_mode", False) + icon_x = self.display_class.resX * 0.91 + icon_y = -2 + if is_test: + # Invert camera icon: white bg, dark icon + self.draw.rectangle( + [icon_x - 1, 0, icon_x + 13, 13], + fill=self.colors.get(128), + ) + self.draw.text( + (icon_x, icon_y), + self._CAM_ICON, + font=self.fonts.icon_bold_large.font, + fill=self.colors.get(0), + ) + else: + self.draw.text( + (icon_x, icon_y), + self._CAM_ICON, + font=self.fonts.icon_bold_large.font, + fill=var_fg, + ) if len(self.title) < 9: # draw the constellation diff --git a/python/PiFinder/ui/menu_manager.py b/python/PiFinder/ui/menu_manager.py index f6b6c993d..9ecd79aee 100644 --- a/python/PiFinder/ui/menu_manager.py +++ b/python/PiFinder/ui/menu_manager.py @@ -126,7 +126,7 @@ def __init__( self._stack_anim_counter: float = 0 self._stack_anim_direction: int = 0 - self.stack: list[type[UIModule]] = [] + self.stack: list[UIModule] = [] self.add_to_stack(menu_structure.pifinder_menu) self.marking_menu_stack: list[MarkingMenu] = [] @@ -150,7 +150,7 @@ def __init__( def screengrab(self): self.ss_count += 1 - filename = f"{self.stack[-1].__uuid__}_{self.ss_count :0>3}_{self.stack[-1].title.replace('/','-')}" + filename = f"{self.stack[-1].__uuid__}_{self.ss_count:0>3}_{self.stack[-1].title.replace('/', '-')}" ss_imagepath = self.ss_path + f"/{filename}.png" ss = self.shared_state.screen().copy() ss.save(ss_imagepath) @@ -159,9 +159,9 @@ def screengrab(self): def remove_from_stack(self) -> None: if len(self.stack) > 1: self._stack_top_image = self.stack[-1].screen.copy() - self.stack[-1].inactive() # type: ignore[call-arg] + self.stack[-1].inactive() self.stack.pop() - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() self._stack_anim_counter = time.time() + self.config_object.get_option( "menu_anim_speed", 0 ) @@ -195,7 +195,7 @@ def add_to_stack(self, item: dict) -> None: item dict """ if item.get("state") is not None: - self.stack[-1].inactive() # type: ignore[call-arg] + self.stack[-1].inactive() self.stack.append(item["state"]) else: self.stack.append( @@ -215,7 +215,7 @@ def add_to_stack(self, item: dict) -> None: if item.get("stateful", False): item["state"] = self.stack[-1] - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() if len(self.stack) > 1: self._stack_anim_counter = time.time() + self.config_object.get_option( "menu_anim_speed", 0 @@ -223,7 +223,7 @@ def add_to_stack(self, item: dict) -> None: self._stack_anim_direction = -1 def message(self, message: str, timeout: float) -> None: - self.stack[-1].message(message, timeout) # type: ignore[arg-type] + self.stack[-1].message(message, timeout) def jump_to_label(self, label: str) -> None: # to prevent many recent/object UI modules @@ -235,7 +235,7 @@ def jump_to_label(self, label: str) -> None: for stack_index, ui_module in enumerate(self.stack): if ui_module.item_definition.get("label", "") == label: self.stack = self.stack[: stack_index + 1] - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() return # either this is not a special case, or we didn't find # the label already in the stack @@ -290,7 +290,7 @@ def update(self) -> None: return # Business as usual, update the module at the top of the stack - self.stack[-1].update() # type: ignore[call-arg] + self.stack[-1].update() # are we animating? if self._stack_anim_counter > time.time(): diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index 149b68ec2..47d159b1e 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -1183,7 +1183,11 @@ def _(key: str) -> Any: }, {"name": _("Console"), "class": UIConsole}, {"name": _("Software Upd"), "class": UISoftware}, - {"name": _("Test Mode"), "callback": callbacks.activate_debug}, + { + "name": _("Test Mode"), + "callback": callbacks.activate_debug, + "name_suffix_callback": callbacks.test_mode_suffix, + }, { "name": _("Experimental"), "class": UITextMenu, @@ -1199,6 +1203,29 @@ def _(key: str) -> Any: "class": UITextMenu, "select": "single", "items": [ + { + "name": _("Dev Mode"), + "class": UITextMenu, + "select": "single", + "config_option": "dev_mode", + "items": [ + {"name": _("Off"), "value": False}, + {"name": _("On"), "value": True}, + ], + }, + { + "name": _("Screen Off"), + "class": UITextMenu, + "select": "single", + "config_option": "screen_off_timeout", + "items": [ + {"name": _("Off"), "value": "Off"}, + {"name": "30s", "value": "30s"}, + {"name": "1m", "value": "1m"}, + {"name": "10m", "value": "10m"}, + {"name": "30m", "value": "30m"}, + ], + }, { "name": _("Telemetry"), "class": UITextMenu, diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index 5815f760a..535a1e257 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -1,79 +1,86 @@ #!/usr/bin/python # -*- coding:utf-8 -*- -""" -This module contains the UIPreview class, a UI module for displaying and interacting with camera images. +"""Raw, magnified multi-star Focus screen.""" -It handles image processing and provides zoom -functionality. It also manages a marking menu for adjusting camera settings and draws the focus -strip and star selectors on the images. -""" - -import sys +import math import time from collections import deque +from typing import Optional import numpy as np -from PIL import Image, ImageChops +from PIL import Image, ImageChops, ImageDraw, ImageOps -from PiFinder import focus, utils -from PiFinder.ui.camera_render import resize_for_display -from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu +from PiFinder import focus from PiFinder.ui.base import UIModule -from PiFinder.ui.ui_utils import outline_text - -sys.path.append(str(utils.tetra3_dir)) - -# Focus indicator tuning (see docs/ax/ui/CONTEXT.md "Focus indicator" and -# docs/adr/0005-focus-hfd-self-contained-in-ui.md). Starting values -- adjust -# on real hardware. -FOCUS_WINDOW_S = 10.0 # rolling V-curve window -# V-curve axis: 4 px is about the best a real camera/lens can hit, so it anchors -# the bottom; 20 px is "clearly defocused". Readings outside [4, 20] clamp to the -# axis ends (the big numeric readout still shows the true value). -HFD_AXIS_MIN = 4.0 # log Y-axis bottom (px) -- best achievable focus -HFD_AXIS_MAX = 20.0 # log Y-axis top (px) -- clearly defocused -# Display-stretch: smaller alpha + larger min span keep the preview calm (the -# stretch was over-reacting frame to frame); the dither breaks 8-bit banding. -STRETCH_EMA_ALPHA = 0.15 # display-stretch black/white smoothing (lower = calmer) -STRETCH_MIN_SPAN = 50.0 # min ADU span so a faint frame isn't stretched hard -STRETCH_DITHER_FRAC = 0.5 # uniform dither amplitude as a fraction of one step - -# Native camera frame size. target_pixel and centroid coordinates live in this -# (square) pixel space (see SharedStateObj.target_pixel, documented 512x512); -# the preview scales them down to the display resolution. -CAMERA_NATIVE_RES = 512 +from PiFinder.ui.marking_menus import MarkingMenu, MarkingMenuOption + +# Ten times the apparent size of the old full-frame preview. On a square panel +# this maps a 26x26 patch from the 512x512 camera frame into each half-screen +# tile. The crop expands for a broad blob so a defocused star remains visible. +FOCUS_NOMINAL_ZOOM = 10 +FOCUS_MIN_ZOOM = 4 +FOCUS_MAX_ZOOM = 16 +FOCUS_ZOOM_STEP = 2 +FOCUS_BLOB_MARGIN = 1.35 +FOCUS_VISUAL_MAX_BLOB_PX = 128 +FOCUS_TILE_COUNT = 4 +FOCUS_WINDOW_S = 10.0 +HFD_MIN_DISPLAY_SPAN = 1.0 +HFD_RANGE_PADDING = 1.15 +DISPLAY_STARS = "stars" +DISPLAY_IMAGE = "image" +DISPLAY_STATS = "stats" +DISPLAY_SINGLE = "single" + + +def focus_crop_size( + frame_size: tuple[int, int], + tile_size: tuple[int, int], + blob_extent: int, + nominal_zoom: int = FOCUS_NOMINAL_ZOOM, +) -> tuple[int, int]: + """Return an aspect-correct native crop size for one magnified star tile. + + ``nominal_zoom`` is relative to the old full-frame preview. The nominal + crop is used for compact, focused stars. Broad stars get a larger crop with + a small margin, reducing the effective zoom instead of clipping the blob. + """ + frame_w, frame_h = frame_size + tile_w, tile_h = tile_size + if frame_w <= 0 or frame_h <= 0 or tile_w <= 0 or tile_h <= 0: + raise ValueError("frame and tile dimensions must be positive") + if nominal_zoom <= 0: + raise ValueError("nominal_zoom must be positive") + + crop_h = max( + math.ceil(frame_h / (2 * nominal_zoom)), + math.ceil(blob_extent * FOCUS_BLOB_MARGIN), + ) + crop_w = math.ceil(crop_h * tile_w / tile_h) + + if crop_w > frame_w: + crop_w = frame_w + crop_h = max(1, round(crop_w * tile_h / tile_w)) + if crop_h > frame_h: + crop_h = frame_h + crop_w = max(1, round(crop_h * tile_w / tile_h)) + return crop_w, crop_h class UIPreview(UIModule): - from PiFinder import tetra3 - __title__ = "CAMERA" __help_name__ = "camera" - _STAR_ICON = "\uf005" # NerdFont star icon (Font Awesome solid) + _display_mode_list = [DISPLAY_STARS, DISPLAY_SINGLE, DISPLAY_IMAGE, DISPLAY_STATS] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.last_update = time.time() - self.solution = None - - self.zoom_level = 0 - - self.capture_prefix = f"{self.__uuid__}_diag" - self.capture_count = 0 - - # the centroiding returns an ndarray - # so we're initialiazing one here - self.star_list = np.empty((0, 2)) - self.highlight_count = 0 - - # Focus indicator: strip on by default (square toggles it). Rolling - # state is (re)initialised in _reset_focus_state(), also called on - # active() so the V-curve clears each time the screen is entered. - self.show_focus_strip = True - self._reset_focus_state() + self.focus_zoom = FOCUS_NOMINAL_ZOOM + self.last_focus_result = None + self._tracked_focus_blobs: tuple[focus.Blob, ...] = () + self._last_focus_frame_time = 0.0 + self.focus_history: deque[tuple[float, float]] = deque() - # Marking menu definition self.marking_menu = MarkingMenu( left=MarkingMenuOption( label=_("Exposure"), @@ -83,378 +90,435 @@ def __init__(self, *args, **kwargs): right=MarkingMenuOption(), ) - def _reset_focus_state(self): - """Clear rolling focus-indicator state (history, stretch EMA points).""" - # (timestamp, hfd) samples over the rolling window; hfd is None for a - # frame with no usable star (a gap -- never carried forward). - self.focus_history: deque = deque() + def active(self): + """Discard stale measurements when the Focus screen is entered.""" self.last_focus_result = None + self._tracked_focus_blobs = () self._last_focus_frame_time = 0.0 - # Display-stretch black/white points (raw ADU), EMA-smoothed. - self._stretch_black = None - self._stretch_white = None - - def active(self): - """Reset the rolling focus history when the screen is entered.""" - self._reset_focus_state() - - def _measure_focus(self, raw_np): - """Run the self-contained HFD detector on a raw frame and update state. + self.focus_history.clear() + + def _measure_focus( + self, raw_np: np.ndarray, *, record_history: bool = True + ) -> None: + """Measure HFD and locate display blobs from one raw frame.""" + self.last_focus_result = focus.focus_hfd(raw_np) + candidates = tuple( + blob + for blob in self.last_focus_result.blobs + if blob.extent <= FOCUS_VISUAL_MAX_BLOB_PX + ) + self._tracked_focus_blobs = focus.track_blobs( + self._tracked_focus_blobs, candidates, n=FOCUS_TILE_COUNT + ) + if record_history: + self._record_focus_sample(self.last_focus_result.median_hfd) - Appends a timestamped sample (HFD or None for a gap), prunes the rolling - window, and updates the EMA display-stretch points. All measurement is on - the raw frame. - """ - result = focus.focus_hfd(raw_np) - self.last_focus_result = result + def _record_focus_sample(self, hfd: Optional[float]) -> None: + """Record a numeric HFD; missing measurements leave history frozen.""" + if hfd is None: + return now = time.time() - - self.focus_history.append((now, result.median_hfd)) + self.focus_history.append((now, hfd)) cutoff = now - FOCUS_WINDOW_S while self.focus_history and self.focus_history[0][0] < cutoff: self.focus_history.popleft() - # Display stretch: black = background, white = brightest detected peak, - # with a minimum span so a starless frame stays near-black. - black = result.background - white = result.peak if result.peak is not None else black + STRETCH_MIN_SPAN - white = max(white, black + STRETCH_MIN_SPAN) - if self._stretch_black is None: - self._stretch_black, self._stretch_white = black, white - else: - a = STRETCH_EMA_ALPHA - self._stretch_black = a * black + (1 - a) * self._stretch_black - self._stretch_white = a * white + (1 - a) * self._stretch_white - - def _focus_marker(self): - """Return the best (min) HFD over the window, or None if no samples.""" - samples = [h for (_t, h) in self.focus_history if h is not None] - return min(samples) if samples else None - - def _apply_stretch(self, image_obj): - """Background-anchored linear stretch of a mode-'L' image (cosmetic). - - Replaces per-frame autocontrast: black/white points come from the - detector's EMA-smoothed background/peak, so the stretch is stable and a - starless frame does not get its noise amplified. The minimum span keeps - a faint frame from being stretched hard, and a little uniform dither is - added before quantising back to 8-bit so a narrow stretch doesn't band - into visible contour steps. Cosmetic only -- HFD is measured on the raw - frame, never on this. + def _display_blobs(self) -> tuple[focus.Blob, ...]: + """Return the four brightest visual blobs from anywhere in the frame.""" + tracked = getattr(self, "_tracked_focus_blobs", None) + if tracked is not None: + return tracked + if self.last_focus_result is None: + return () + return tuple(self.last_focus_result.blobs[:FOCUS_TILE_COUNT]) + + def _focus_center(self) -> tuple[int, int]: + """Return the center of the visible area below the title bar.""" + res_x, res_y = self.display_class.resolution + content_top = min(self.display_class.titlebar_height + 1, res_y) + return res_x // 2, content_top + (res_y - content_top) // 2 + + def _tile_boxes(self) -> tuple[tuple[int, int, int, int], ...]: + """Split the visible camera area into four equally sized quadrants.""" + res_x, res_y = self.display_class.resolution + content_top = min(self.display_class.titlebar_height + 1, res_y) + mid_x = res_x // 2 + mid_y = self._focus_center()[1] + return ( + (0, content_top, mid_x, mid_y), + (mid_x, content_top, res_x, mid_y), + (0, mid_y, mid_x, res_y), + (mid_x, mid_y, res_x, res_y), + ) + + def _render_focus_tiles(self, raw_image: Image.Image) -> Image.Image: + """Render four raw star crops with nearest-neighbour enlargement. + + The camera data receives no contrast stretch, filtering, sharpening, + or interpolating resample. Conversion to luminance only normalizes RGB + debug frames to the hardware camera's native single-channel shape. """ - if self._stretch_black is None or self._stretch_white is None: - return image_obj - black = self._stretch_black - span = max(self._stretch_white - black, STRETCH_MIN_SPAN) - scale = 255.0 / span - - arr = np.asarray(image_obj, dtype=np.float32) - stretched = (arr - black) * scale - # Uniform dither, peak-to-peak ~ one output step, so a narrow stretch - # blends across band boundaries instead of posterising into contours. - dither = scale * STRETCH_DITHER_FRAC - stretched += np.random.uniform(-dither, dither, size=arr.shape) - np.clip(stretched, 0, 255, out=stretched) - return Image.fromarray(stretched.astype(np.uint8), mode="L") - - def draw_star_selectors(self): - # Draw star selectors - if self.star_list.shape[0] > 0: - self.highlight_count = 3 - if self.star_list.shape[0] < self.highlight_count: - self.highlight_count = self.star_list.shape[0] - - for _i in range(self.highlight_count): - raw_y, raw_x = self.star_list[_i] - # centroids are in native camera space; scale to the display - star_x = int(raw_x * self.display_class.resX / CAMERA_NATIVE_RES) - star_y = int(raw_y * self.display_class.resY / CAMERA_NATIVE_RES) - - x_direction = 1 - x_text_offset = 6 - y_direction = 1 - y_text_offset = -12 - - # flip the marker/label when too close to the right edge or top - if star_x > self.display_class.resX - 20: - x_direction = -1 - x_text_offset = -10 - if star_y < self.display_class.titlebar_height + 21: - y_direction = -1 - y_text_offset = 1 - - self.draw.line( - [ - (star_x, star_y - (4 * y_direction)), - (star_x, star_y - (12 * y_direction)), - ], - fill=self.colors.get(128), - ) + raw_l = raw_image.convert("L") + res_x, res_y = self.display_class.resolution + mosaic = Image.new("L", (res_x, res_y), 0) + + for blob, box in zip(self._display_blobs(), self._tile_boxes()): + left, top, right, bottom = box + tile_size = (right - left, bottom - top) + crop_w, crop_h = focus_crop_size( + raw_l.size, + tile_size, + blob.extent, + self.focus_zoom, + ) + # Keep the crop wholly inside the sensor frame. PIL pads an + # out-of-bounds crop with black, which otherwise appears as a bar + # when a selected star is close to an edge. + crop_left = min(max(round(blob.x - crop_w / 2), 0), raw_l.width - crop_w) + crop_top = min(max(round(blob.y - crop_h / 2), 0), raw_l.height - crop_h) + crop = raw_l.crop( + (crop_left, crop_top, crop_left + crop_w, crop_top + crop_h) + ) + enlarged = crop.resize(tile_size, resample=Image.Resampling.NEAREST) + mosaic.paste(enlarged, (left, top)) + + # Apply the display's red/grey channel mask without changing luminance. + return ImageChops.multiply(mosaic.convert("RGB"), self.colors.red_image) + + def _render_brightest_star(self, raw_image: Image.Image) -> Image.Image: + """Fill the panel with the brightest detected star's raw crop.""" + raw_l = raw_image.convert("L") + target_size = self.display_class.resolution + rendered = Image.new("L", target_size, 0) + blobs = self._display_blobs() + if blobs: + blob = blobs[0] + # Reuse a tile's native crop across the full panel, giving Single + # twice the apparent magnification selected by +/- in Stars. + crop_w, crop_h = focus_crop_size( + raw_l.size, + target_size, + blob.extent, + self.focus_zoom, + ) + crop_left = min(max(round(blob.x - crop_w / 2), 0), raw_l.width - crop_w) + crop_top = min(max(round(blob.y - crop_h / 2), 0), raw_l.height - crop_h) + crop = raw_l.crop( + (crop_left, crop_top, crop_left + crop_w, crop_top + crop_h) + ) + rendered = crop.resize(target_size, resample=Image.Resampling.NEAREST) - self.draw.line( - [ - (star_x + (4 * x_direction), star_y), - (star_x + (12 * x_direction), star_y), - ], - fill=self.colors.get(128), - ) + return ImageChops.multiply(rendered.convert("RGB"), self.colors.red_image) - self.draw.text( - (star_x + x_text_offset, star_y + y_text_offset), - str(_i + 1), - font=self.fonts.small.font, - fill=self.colors.get(128), - ) + def _render_image_frame(self, raw_image: Image.Image) -> Image.Image: + """Fit and autocontrast the full camera image for display only.""" + resized = raw_image.convert("L").resize( + self.display_class.resolution, resample=Image.Resampling.NEAREST + ) + red = ImageChops.multiply(resized.convert("RGB"), self.colors.red_image) + return ImageOps.autocontrast(red) - def format_exposure_display(self) -> str: - """Format exposure time for overlay display, just the number like 0.4s.""" - try: - metadata = self.shared_state.last_image_metadata() - - # Get actual exposure from metadata - if metadata and "exposure_time" in metadata: - actual_exp = metadata["exposure_time"] - exp_sec = actual_exp / 1_000_000 - if exp_sec < 0.1: - return f"{int(exp_sec * 1000)}ms" - else: - # Truncate to 2 decimal places - exp_truncated = int(exp_sec * 100) / 100 - return f"{exp_truncated:g}s" - except Exception: - pass - return "N/A" - - def _matched_star_text(self): - """Recent matched-star count (the solver's catalog matches), or '-'. - - Its 0 -> N jump signals "sharp enough to solve"; kept alongside the - self-contained detected-star count. - """ - try: - solution = self.shared_state.solution() - solve_source = solution.solve_source if solution else None - estimate_time = solution.estimate_time if solution else None - if solve_source in ("CAM", "CAM_FAILED") and estimate_time: - if time.time() - estimate_time < 10: - return str(solution.diagnostics.Matches) - except Exception: - pass - return "-" - - def _hfd_to_y(self, hfd, plot_top, plot_bottom): - """Map an HFD value to a screen y on the fixed log axis (low = bottom).""" - clamped = min(max(hfd, HFD_AXIS_MIN), HFD_AXIS_MAX) - norm = np.log(clamped / HFD_AXIS_MIN) / np.log(HFD_AXIS_MAX / HFD_AXIS_MIN) - return int(plot_bottom - norm * (plot_bottom - plot_top)) - - def draw_focus_strip(self): - """Render the focus strip: big HFD readout, V-curve, marker, and HUD. - - Bottom band, on by default; square hides it. Persists across all zoom - levels (HFD is zoom-independent). Layout: a large right-justified HFD - number (the hero readout) fills the strip height; the V-curve and small - labels sit in the freed left region. - - Geometry is resolution-flexible (ADR 0009): the band is a fixed fraction - of the screen height (~38 px on the 128 panel, proportionally taller on - a larger panel) and the label clearances derive from the small-font - height, so the rows never collide with the V-curve as the font grows. + def _focus_readout_text(self) -> str: + """Format current HFD, using one unmistakable unavailable value.""" + result = self.last_focus_result + if result is not None and result.median_hfd is not None: + return f"{result.median_hfd:.1f}" + return "?.?" + + def _focus_history_gap(self, center, text, font) -> tuple[int, int]: + """Return signal endpoints with equal padding from rendered outline.""" + mask = Image.new("1", self.display_class.resolution) + mask_draw = ImageDraw.Draw(mask) + mask_draw.text( + center, + text, + font=font, + fill=1, + anchor="mm", + stroke_width=1, + stroke_fill=1, + ) + ink_box = mask.getbbox() + if ink_box is None: + return center[0], center[0] + + # Endpoints are inclusive. Leave exactly three blank pixels between + # each endpoint and the first/last rendered outline pixel. + padding = 3 + return ink_box[0] - padding - 1, ink_box[2] + padding + + def _draw_focus_overlay(self) -> None: + """Draw quadrant separators, HFD history, and the current HFD.""" + res_x, res_y = self.display_class.resolution + content_top = min(self.display_class.titlebar_height + 1, res_y) + center = self._focus_center() + separator = self.colors.get(64) + self.draw.line( + [(center[0], content_top), (center[0], res_y - 1)], fill=separator + ) + self.draw.line([(0, center[1]), (res_x - 1, center[1])], fill=separator) + + text = self._focus_readout_text() + + font = self.fonts.large.font + gap_left, gap_right = self._focus_history_gap(center, text, font) + self._draw_focus_history(center[1], gap_left, gap_right) + self.draw.text( + center, + text, + font=font, + fill=self.colors.get(255), + anchor="mm", + stroke_width=1, + stroke_fill=self.colors.get(0), + ) + + def _draw_single_focus_overlay(self) -> None: + """Draw HFD and history over a translucent lower-third panel.""" + res_x, res_y = self.display_class.resolution + overlay_top = math.ceil(res_y * 2 / 3) + center = (res_x // 2, overlay_top + (res_y - overlay_top) // 2) + self.draw.rectangle((0, overlay_top, res_x, res_y), fill=(0, 0, 0, 128)) + + text = self._focus_readout_text() + + font = self.fonts.large.font + gap_left, gap_right = self._focus_history_gap(center, text, font) + self._draw_focus_history(center[1], gap_left, gap_right) + self.draw.text( + center, + text, + font=font, + fill=self.colors.get(255), + anchor="mm", + stroke_width=1, + stroke_fill=self.colors.get(0), + ) + + def _draw_focus_history(self, center_y: int, gap_left: int, gap_right: int) -> None: + """Draw the centered rolling HFD signal across the middle divider. + + The time axis passes through an omitted center interval, leaving the + outlined numeric readout unobstructed while older and newer samples + appear to its left and right. The recent value range is centered on the + divider; lower HFD is below it. A minimum span prevents measurement + noise from filling the plot height. """ - res_x = self.display_class.resX - res_y = self.display_class.resY - # Bottom band height scales with the screen (38 px / 128 px on the - # 128 panel); strip_top was a 128-only literal (90) before #453. - strip_top = res_y - round(res_y * 38 / 128) - # Small-font height drives the label-row clearances (9 px on the 128 - # panel); deriving them keeps the rows clear of the V-curve at 176. - small_h = self.fonts.small.height - - # Dim band so the overlay stays legible over a bright image. - self.draw.rectangle([0, strip_top, res_x, res_y], fill=(0, 0, 0, 150)) + res_x, res_y = self.display_class.resolution + plot_half_height = max(8, round(res_y * 10 / 128)) + left_edge = 2 + right_edge = res_x - 3 + gap_left = max(left_edge, gap_left) + gap_right = min(right_edge, gap_right) + left_width = max(gap_left - left_edge, 0) + right_width = max(right_edge - gap_right, 0) + drawable_width = left_width + right_width + if drawable_width <= 0: + return + + # Wall time makes stale measurements visibly recede during missing + # frames. Missing frames add no samples; the next numeric sample prunes + # expired history and starts drawing immediately at the right edge. + now = time.time() + window_start = now - FOCUS_WINDOW_S + while self.focus_history and self.focus_history[0][0] < window_start: + self.focus_history.popleft() + samples = [hfd for _timestamp, hfd in self.focus_history] + if samples: + range_center = (min(samples) + max(samples)) / 2 + half_span = max( + (max(samples) - min(samples)) * HFD_RANGE_PADDING / 2, + HFD_MIN_DISPLAY_SPAN / 2, + ) + else: + range_center = 0.0 + half_span = HFD_MIN_DISPLAY_SPAN / 2 + + def y_of(hfd: float) -> int: + relative = min(max((hfd - range_center) / half_span, -1.0), 1.0) + return round(center_y - relative * plot_half_height) + + def x_of(timestamp: float) -> tuple[int, bool, float]: + fraction = min(max((timestamp - window_start) / FOCUS_WINDOW_S, 0), 1) + offset = fraction * drawable_width + if offset <= left_width: + return round(left_edge + offset), False, offset + return round(gap_right + offset - left_width), True, offset bright = self.colors.get(255) - medium = self.colors.get(128) - dim = self.colors.get(64) - result = self.last_focus_result - detected = str(result.n_used) if result is not None else "0" - - # --- HFD readout: right-justified in a fixed-width slot so the V-curve's - # right edge never shifts as the value changes. A real reading is the big - # hero number (filling the strip height); the no-reading states fall back - # to a small dim hint rather than a giant placeholder glyph. --- - big_font = self.fonts.huge - slot_w = int(self.draw.textlength("00.0", font=big_font.font)) - num_right = res_x - 2 - num_left = num_right - slot_w - num_mid_y = (strip_top + res_y) // 2 - 1 + def draw_segment(start: tuple[int, int], end: tuple[int, int]) -> None: + self.draw.line((start, end), fill=bright) - if result is not None and result.median_hfd is not None: - self.draw.text( - (num_right, num_mid_y), - f"{result.median_hfd:.1f}", - font=big_font.font, + def draw_isolated_sample(point: tuple[int, int], right_side: bool) -> None: + """Make the first sample after a gap visible without bridging it.""" + x, y = point + side_left = gap_right if right_side else left_edge + side_right = right_edge if right_side else gap_left + self.draw.line( + (max(side_left, x - 1), y, min(side_right, x + 1), y), fill=bright, - anchor="rm", ) - else: - # too_defocused = a star is there but too broad to measure (keep - # adjusting toward focus); otherwise nothing usable was found. - hint = _("keep going") if (result and result.too_defocused) else "—" - outline_text( - self.draw, - (num_right, num_mid_y), - hint, - align="right", - font=self.fonts.base, - fill=dim, - shadow_color=(0, 0, 0), - stroke=1, - anchor="rm", + + previous: Optional[tuple[tuple[int, int], bool, float]] = None + for timestamp, hfd in self.focus_history: + x, right_side, offset = x_of(timestamp) + current = ( + (x, y_of(hfd)), + right_side, + offset, ) + if previous is not None and previous[1] == right_side: + draw_segment(previous[0], current[0]) + elif previous is not None: + # Clip a segment crossing the number at equal left/right gap + # boundaries. Dropping the whole segment makes the apparent + # spacing depend on the camera sample interval. + span = current[2] - previous[2] + fraction = (left_width - previous[2]) / span + crossing_y = round( + previous[0][1] + fraction * (current[0][1] - previous[0][1]) + ) + draw_segment(previous[0], (gap_left, crossing_y)) + draw_segment((gap_right, crossing_y), current[0]) + else: + draw_isolated_sample(current[0], right_side) + previous = current + + @staticmethod + def _format_exposure(exposure_us) -> str: + try: + exposure_us = float(exposure_us) + except (TypeError, ValueError): + return "—" + if exposure_us < 1000: + return f"{exposure_us:.0f}us" + if exposure_us < 100_000: + return f"{exposure_us / 1000:g}ms" + return f"{exposure_us / 1_000_000:g}s" + + def _draw_stats(self, raw_np: np.ndarray, metadata: dict) -> None: + """Draw focus/exposure statistics and a raw histogram.""" + res_x, res_y = self.display_class.resolution + self.draw.rectangle((0, 0, res_x, res_y), fill=self.colors.get(0)) + bright = self.colors.get(255) + medium = self.colors.get(128) + dim = self.colors.get(64) + result = self.last_focus_result - # --- Left region: V-curve framed by small labels --- - # plot_top clears the top label row; plot_bottom sits just above the - # bottom label row -- both derived from the small-font height so they - # track the font across resolutions (9 / 10 px on the 128 panel). - plot_left = 2 - plot_right = num_left - 3 - plot_top = strip_top + small_h - plot_bottom = res_y - small_h - 1 - - # Top labels: exposure (left), matched-star count (right of the graph). - # The matched 0 -> N jump still signals "sharp enough to solve". - outline_text( - self.draw, - (plot_left, strip_top), - self.format_exposure_display(), - align="left", - font=self.fonts.small, - fill=medium, - shadow_color=(0, 0, 0), - stroke=1, + hfd = self._focus_readout_text() + fwhm = ( + f"{result.median_fwhm:.1f} px" + if result is not None and result.median_fwhm is not None + else "—" ) - outline_text( - self.draw, - (plot_right, strip_top), - f"{self._STAR_ICON}{self._matched_star_text()}", - align="left", - font=self.fonts.small, - fill=medium, - shadow_color=(0, 0, 0), - stroke=1, - anchor="ra", + detected = len(result.blobs) if result is not None else 0 + exposure_setting = self.config_object.get_option("camera_exp") + exposure_mode = "AUTO" if str(exposure_setting).lower() == "auto" else "MANUAL" + exposure = self._format_exposure(metadata.get("exposure_time")) + gain = metadata.get("gain") + gain_text = f"{gain:g}" if isinstance(gain, (int, float)) else "—" + + # screen_update() draws the standard title bar after this method. Keep + # the hero value below it so the bar never masks the HFD number. + top = self.display_class.titlebar_height + 4 + self.draw.text((2, top), "HFD", font=self.fonts.base.font, fill=medium) + self.draw.text( + (res_x - 2, top), + hfd, + font=self.fonts.huge.font, + fill=bright, + anchor="rt", ) - # Bottom label: detected-star count (the self-contained detector). - outline_text( - self.draw, - (plot_left, res_y - small_h), - _("det {n}").format(n=detected), - align="left", - font=self.fonts.small, - fill=medium, - shadow_color=(0, 0, 0), - stroke=1, + line_h = self.fonts.small.height + 1 + stats_y = top + self.fonts.huge.height + lines = ( + f"FWHM {fwhm} Stars {detected}", + f"{exposure_mode} {exposure} Gain {gain_text}", ) - - # --- V-curve + best-focus marker over the rolling window --- - now = time.time() - window_start = now - FOCUS_WINDOW_S - span = max(plot_right - plot_left, 1) - - def x_of(ts): - frac = (ts - window_start) / FOCUS_WINDOW_S - return int(plot_left + min(max(frac, 0.0), 1.0) * span) - - marker = self._focus_marker() - if marker is not None: - marker_y = self._hfd_to_y(marker, plot_top, plot_bottom) - self.draw.line([(plot_left, marker_y), (plot_right, marker_y)], fill=dim) - - prev = None - for ts, hfd in self.focus_history: - if hfd is None: - prev = None # gap -- break the line - continue - point = (x_of(ts), self._hfd_to_y(hfd, plot_top, plot_bottom)) - if prev is not None: - self.draw.line([prev, point], fill=bright) - else: - self.draw.point(point, fill=bright) - prev = point - - def update(self, force=False): + for line in lines: + self.draw.text((2, stats_y), line, font=self.fonts.small.font, fill=medium) + stats_y += line_h + + label_h = self.fonts.small.height + plots_top = min(stats_y + 1, res_y - label_h - 4) + label_xy = (2, plots_top) + self.draw.text(label_xy, "RAW HIST", font=self.fonts.small.font, fill=dim) + label_box = self.draw.textbbox(label_xy, "RAW HIST", font=self.fonts.small.font) + plot_left, plot_top, plot_right, plot_bottom = ( + 2, + min(label_box[3] + 2, res_y - 2), + res_x - 2, + res_y - 1, + ) + plot_height = max(plot_bottom - plot_top, 1) + plot_width = max(plot_right - plot_left + 1, 1) + bins = min(32, plot_width) + counts, _bin_edges = np.histogram(raw_np, bins=bins, range=(0, 256)) + heights = np.log1p(counts.astype(np.float64)) + peak = float(heights.max()) + if peak > 0: + heights *= plot_height / peak + for index, height in enumerate(heights): + x0 = plot_left + round(index * plot_width / bins) + x1 = max(x0, plot_left + round((index + 1) * plot_width / bins) - 1) + y = plot_bottom - round(float(height)) + self.draw.rectangle((x0, y, x1, plot_bottom), fill=medium) + + def update(self, force: bool = False): if force: self.last_update = 0 - # display an image + metadata = self.shared_state.last_image_metadata() last_image_time = metadata["exposure_end"] image_updated = False if last_image_time > self.last_update: image_updated = True - # camera_image is a multiprocessing-manager proxy; .copy() returns a - # real PIL Image. Copy once, measure on the raw 512x512 frame, then - # reuse the same copy for the (zoomed) display transform. raw_image = self.camera_image.copy() - # Measure focus on the RAW frame before any display transform, and - # only for a genuinely new frame (not a forced redraw). - new_frame = last_image_time != self._last_focus_frame_time - if new_frame: - # focus_hfd needs a 2D array; convert to luminance so it works - # for both mode-"L" hardware frames and RGB debug frames. - self._measure_focus(np.asarray(raw_image.convert("L"))) + raw_np = np.asarray(raw_image.convert("L")) + if last_image_time != self._last_focus_frame_time: + self._measure_focus(raw_np) self._last_focus_frame_time = last_image_time - - resX, resY = self.display_class.resX, self.display_class.resY - - # Resize / zoom. Zoom crops a centred region of the native camera - # frame (half of it for 2x, a quarter for 4x) then scales to the - # display, so the zoom factor stays 2x / 4x at any resolution. - # (Shared with the daytime-align screen via ui.camera_render.) - image_obj = resize_for_display(raw_image, (resX, resY), self.zoom_level) - - # Background-anchored linear stretch (replaces autocontrast), then RED. - # Stretch on a single luminance band (debug frames are RGB; hardware - # frames are already mode "L"). - image_obj = image_obj.convert("L") - image_obj = self._apply_stretch(image_obj) - image_obj = image_obj.convert("RGB") - image_obj = ImageChops.multiply(image_obj, self.colors.red_image) - - self.screen.paste(image_obj) + elif force: + # A forced redraw can race the separately published camera + # metadata. Re-measure this exact display copy, but do not add + # a duplicate point to the time history. + self._measure_focus(raw_np, record_history=False) + + if self.display_mode == DISPLAY_STARS: + self.screen.paste(self._render_focus_tiles(raw_image)) + elif self.display_mode == DISPLAY_IMAGE: + self.screen.paste(self._render_image_frame(raw_image)) + elif self.display_mode == DISPLAY_STATS: + self._draw_stats(raw_np, metadata) + else: + self.screen.paste(self._render_brightest_star(raw_image)) self.last_update = last_image_time - # Image paste cleared the screen, so redraw overlays after a paste. - if image_updated or force: - if self.zoom_level > 0: - # Zoom label relocated out of the focus-strip area (top-left, - # just under the titlebar). - zoom_number = self.zoom_level * 2 - self.draw.text( - (2, self.display_class.titlebar_height + 1), - _("Zoom x{zoom_number}").format(zoom_number=zoom_number), - font=self.fonts.bold.font, - fill=self.colors.get(128), - ) - if self.show_focus_strip: - self.draw_focus_strip() + if (image_updated or force) and self.display_mode == DISPLAY_STARS: + self._draw_focus_overlay() + elif (image_updated or force) and self.display_mode == DISPLAY_SINGLE: + self._draw_single_focus_overlay() return self.screen_update() def key_plus(self): - self.zoom_level += 1 - if self.zoom_level > 2: - self.zoom_level = 2 + """Increase the nominal focused-star magnification.""" + if self.display_mode not in (DISPLAY_STARS, DISPLAY_SINGLE): + return + self.focus_zoom = min(FOCUS_MAX_ZOOM, self.focus_zoom + FOCUS_ZOOM_STEP) + self.update(force=True) def key_minus(self): - self.zoom_level -= 1 - if self.zoom_level < 0: - self.zoom_level = 0 + """Decrease the nominal focused-star magnification.""" + if self.display_mode not in (DISPLAY_STARS, DISPLAY_SINGLE): + return + self.focus_zoom = max(FOCUS_MIN_ZOOM, self.focus_zoom - FOCUS_ZOOM_STEP) + self.update(force=True) def key_square(self): - """Toggle the focus strip (V-curve + HUD) on/off with the square button.""" - self.show_focus_strip = not self.show_focus_strip + """Cycle Stars -> Single -> Image -> Stats using the display-mode key.""" + self.cycle_display_mode() self.update(force=True) diff --git a/python/PiFinder/ui/software.py b/python/PiFinder/ui/software.py index 09acb39fd..5489e3b3f 100644 --- a/python/PiFinder/ui/software.py +++ b/python/PiFinder/ui/software.py @@ -1,144 +1,305 @@ #!/usr/bin/python # -*- coding:utf-8 -*- """ -This module contains the UI Module classes for -software updates and NixOS migration. +UI modules for software updates, channel selection, and release notes. + +Channels: + - stable: release entries from update-manifest.json + - beta: prerelease entries from update-manifest.json + - unstable: trunk + testable PR entries from update-manifest.json """ +import json import logging -import time -from typing import Any, Optional, TYPE_CHECKING +import re +import threading +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING import requests -from PiFinder import utils -from PiFinder.ui.base import UIModule -from PiFinder.ui.ui_utils import TextLayouter - if TYPE_CHECKING: + # At runtime PiFinder.i18n gettext-installs _ into builtins; mypy only + # sees it inside annotated functions, so give it a signature here. + def _(message: str) -> str: ... - def _(a) -> Any: - return a +from PiFinder import utils +from PiFinder.ui.base import UIModule +from PiFinder.ui.ui_utils import TextLayouter, TextLayouterScroll sys_utils = utils.get_sys_utils() logger = logging.getLogger("UISoftware") -REQUEST_TIMEOUT = 10 -MIGRATION_GATE_URL = ( - "https://raw.githubusercontent.com/brickbots/PiFinder/release/migration_gate.json" -) +# --- Update channel source ----------------------------------------------------- +# CI publishes generated update metadata to a metadata-only branch. Devices read +# one raw JSON file instead of calling the GitHub REST API, so they do not burn +# unauthenticated rate limits. +MANIFEST_REPO = "brickbots/PiFinder" +MANIFEST_BRANCH = "nixos-manifest" +# ------------------------------------------------------------------------------ UPDATE_MANIFEST_URL = ( - "https://raw.githubusercontent.com/brickbots/PiFinder/" - "nixos-manifest/update-manifest.json" + f"https://raw.githubusercontent.com/{MANIFEST_REPO}/" + f"{MANIFEST_BRANCH}/update-manifest.json" ) +REQUEST_TIMEOUT = 10 +_STORE_PATH_RE = re.compile(r"^/nix/store/[a-z0-9]+-[A-Za-z0-9._+=?,-]+$") -# Secret unlock: 7x square button -_UNLOCK_SEQUENCE = ["square"] * 7 +# Last successfully fetched manifest, kept on disk so the screen can render a +# version list immediately on entry while a fresh copy is fetched in the +# background. +MANIFEST_CACHE_PATH = utils.data_dir / "update_manifest.json" -# Migration targets are read from the update manifest, consulted in descending -# stability; the first available entry carrying a migration tarball wins. -_MIGRATION_CHANNELS = ("stable", "beta", "unstable") +# A trunk entry is only offered when the branch it tracks is actually a NixOS +# branch — detected by this file existing in the branch's tree. Keeps a +# non-NixOS upstream main from showing up as an installable build. +NIXOS_MARKER_FILE = "flake.nix" -def _fetch_migration_config() -> Optional[dict]: - """Fetch and parse the remote migration gate JSON. +def _entry_from_manifest(item: dict, channel: str) -> Optional[dict]: + label = item.get("label") + if not isinstance(label, str) or not label: + return None - Returns the parsed dict on success; None on network error, non-200 - response, or malformed JSON. Only the `nixos_for_everyone` flag is used by - the caller — the tarball itself comes from the update manifest. + title = item.get("title") or item.get("subtitle") or label + entry = { + "label": label, + "ref": item.get("store_path"), + "notes": item.get("notes") or None, + "version": item.get("version") or label, + "subtitle": title, + "title": title, + "channel": channel, + "kind": item.get("kind"), + "number": item.get("number"), + "built_at": item.get("built_at"), + "source_ref": item.get("source_ref"), + "source_sha": item.get("source_sha"), + } + if item.get("kind") == "trunk": + entry["is_trunk"] = True + + store_path = item.get("store_path") + available = item.get("available", bool(store_path)) + if not available or not isinstance(store_path, str): + entry["ref"] = None + entry["unavailable"] = True + reason = item.get("reason") + if reason: + entry["subtitle"] = f"{title} ({reason})" + elif not _STORE_PATH_RE.fullmatch(store_path): + entry["ref"] = None + entry["unavailable"] = True + entry["subtitle"] = f"{title} (invalid build)" + + return entry + + +def _fetch_raw_manifest() -> dict: """ - try: - res = requests.get(MIGRATION_GATE_URL, timeout=REQUEST_TIMEOUT) - except requests.exceptions.RequestException: - return None - if res.status_code != 200: - return None - try: - data = res.json() - except ValueError: - return None - if not isinstance(data, dict): - return None - return data + Fetch CI-generated update metadata as the raw manifest document. + Raises RequestException for network failures so the caller can show offline. + """ + res = requests.get(UPDATE_MANIFEST_URL, timeout=REQUEST_TIMEOUT) + res.raise_for_status() + manifest = res.json() + if manifest.get("schema") != 1: + raise ValueError("unsupported update manifest schema") + if not isinstance(manifest.get("channels", {}), dict): + raise ValueError("invalid update manifest channels") + return manifest + +def _parse_manifest(manifest: dict) -> dict[str, list[dict]]: + """Convert a raw manifest document into per-channel UI entries. -def _fetch_update_manifest() -> Optional[dict]: - """Fetch and parse the update manifest, or None on any failure.""" + Trunk entries whose branch is known to lack the NixOS marker file are + dropped (annotation left by _annotate_trunk_entries; unknown means keep). + """ + channels: dict[str, list[dict]] = {} + manifest_channels = manifest.get("channels", {}) + if not isinstance(manifest_channels, dict): + manifest_channels = {} + + for channel in ("stable", "beta", "unstable"): + entries: list[dict] = [] + raw_entries = manifest_channels.get(channel, []) + if not isinstance(raw_entries, list): + continue + for item in raw_entries: + if not isinstance(item, dict): + continue + if item.get("kind") == "trunk" and item.get("nixos_branch") is False: + continue + entry = _entry_from_manifest(item, channel) + if entry is not None: + entries.append(entry) + channels[channel] = entries + + return channels + + +def _fetch_update_manifest() -> dict[str, list[dict]]: + """Fetch and parse update metadata in one step (no cache involvement).""" + return _parse_manifest(_fetch_raw_manifest()) + + +def _load_cached_manifest() -> Optional[dict]: + """Return the last cached raw manifest, or None when absent/invalid.""" try: - res = requests.get(UPDATE_MANIFEST_URL, timeout=REQUEST_TIMEOUT) - except requests.exceptions.RequestException: + with open(MANIFEST_CACHE_PATH) as f: + manifest = json.load(f) + except (OSError, ValueError): return None - if res.status_code != 200: + if not isinstance(manifest, dict) or manifest.get("schema") != 1: return None + return manifest + + +def _save_cached_manifest(manifest: dict) -> None: + """Best-effort atomic write of the manifest cache.""" try: - data = res.json() - except ValueError: - return None - if not isinstance(data, dict): - return None - return data + MANIFEST_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp_path = MANIFEST_CACHE_PATH.with_suffix(".json.tmp") + with open(tmp_path, "w") as f: + json.dump(manifest, f) + tmp_path.replace(MANIFEST_CACHE_PATH) + except OSError as e: + logger.warning("Could not cache update manifest: %s", e) -def _fetch_download_size_mb(url: str) -> Optional[int]: - """Return the tarball download size in MB from a HEAD request, or None. +def _branch_has_nixos_marker(source_repo: str, source_ref: str) -> Optional[bool]: + """Whether the branch's tree contains the NixOS marker file. - GitHub release assets 302-redirect to a signed URL whose response carries - the real Content-Length, so redirects must be followed. Returns None on - network error, non-200 status, or a missing/unparseable Content-Length. + Returns None when the check is inconclusive (network trouble, unexpected + status) so callers can keep the entry rather than hide a real build. """ + url = ( + f"https://raw.githubusercontent.com/{source_repo}/" + f"{source_ref}/{NIXOS_MARKER_FILE}" + ) try: - res = requests.head(url, allow_redirects=True, timeout=REQUEST_TIMEOUT) + res = requests.head(url, timeout=REQUEST_TIMEOUT) except requests.exceptions.RequestException: return None - if res.status_code != 200: - return None - length = res.headers.get("Content-Length") - if length is None: + if res.status_code == 200: + return True + if res.status_code == 404: + return False + return None + + +def _annotate_trunk_entries(manifest: dict) -> None: + """Stamp nixos_branch on trunk entries in a raw manifest (in place). + + An inconclusive check leaves the entry unannotated, and unannotated trunk + entries stay visible — a flaky network must not hide a real build. The + annotation is persisted with the cache, so the cached list applies the + same verdict without re-checking. + """ + channels = manifest.get("channels", {}) + if not isinstance(channels, dict): + return + unstable = channels.get("unstable", []) + if not isinstance(unstable, list): + return + for item in unstable: + if not isinstance(item, dict) or item.get("kind") != "trunk": + continue + repo = item.get("source_repo") + ref = item.get("source_ref") + if not repo or not ref: + continue + marker = _branch_has_nixos_marker(repo, ref) + if marker is not None: + item["nixos_branch"] = marker + + +def _format_age(built_at: Optional[str]) -> Optional[str]: + """Human-readable age of a build timestamp ("5m ago", "3h ago", "2d ago").""" + if not built_at: return None try: - return round(int(length) / (1024 * 1024)) - except (TypeError, ValueError): + built = datetime.fromisoformat(built_at) + except ValueError: return None + if built.tzinfo is None: + built = built.replace(tzinfo=timezone.utc) + minutes = max(0, int((datetime.now(timezone.utc) - built).total_seconds() // 60)) + if minutes < 60: + return _("{minutes}m ago").format(minutes=minutes) + hours = minutes // 60 + if hours < 24: + return _("{hours}h ago").format(hours=hours) + return _("{days}d ago").format(days=hours // 24) + + +def _entry_row_parts(entry: dict) -> Tuple[str, str]: + """Split a version row into a fixed prefix and the scrollable text. + + PR rows lead with the bare PR number, trunk rows with a dot; the store + hash never appears in the list — titles/branch names are what a human + scans for. + """ + if entry.get("kind") == "pr" and entry.get("number"): + return f"{entry['number']} ", entry.get("title") or entry["label"] + if entry.get("is_trunk"): + return "• ", entry.get("source_ref") or entry["label"] + return "", entry["label"] -def _migration_version_info_from_manifest() -> Optional[dict]: - """Select a migration target from the update manifest. +def _entry_detail(entry: dict) -> str: + """Second line of a focused version row: unavailability beats build info. - Walks channels stable -> beta -> unstable and returns version_info for the - first available entry that carries a migration tarball, or None if the - manifest can't be fetched or has no such entry. + Build info is age plus the short commit hash ("built 5h ago · 2692406") — + the hash stays out of the list rows but remains one focus away. """ - manifest = _fetch_update_manifest() - if not manifest: - return None - channels = manifest.get("channels") - if not isinstance(channels, dict): - return None - for channel in _MIGRATION_CHANNELS: - entries = channels.get(channel) - if not isinstance(entries, list): - continue - for entry in entries: - if not isinstance(entry, dict) or not entry.get("available"): - continue - url = entry.get("migration_url") - sha_url = entry.get("migration_sha256_url") - if not url or not sha_url: - continue - version_info = { - "version": entry.get("version", "?"), - "type": "upgrade", - "migration_url": url, - "migration_sha256_url": sha_url, - } - # Size comes from a live HEAD on the tarball; omitted on failure - # (the confirm screen then shows "?"). - size_mb = _fetch_download_size_mb(url) - if size_mb is not None: - version_info["migration_size_mb"] = size_mb - return version_info - return None + if entry.get("unavailable"): + return entry.get("subtitle", "") + parts = [] + age = _format_age(entry.get("built_at")) + if age: + parts.append(_("built {age}").format(age=age)) + sha = entry.get("source_sha") + if isinstance(sha, str) and sha: + parts.append(sha[:7]) + if parts: + return " · ".join(parts) + return entry.get("subtitle", "") + + +def _current_store_path() -> Optional[str]: + """Store path of the running build, or None when unknown. + + The store path — not the version string — is a build's identity: a re-cut + release keeps its version/label but is a different system. Prefer + current-build.json (it names the base store path even on a camera-specialised + device), but only when it actually describes the running system: it is + written before the reboot, so a failed boot or rollback can leave it naming a + build that isn't running. When stale, fall back to the actually-running + system so the update list hides the real build, not a phantom one. + """ + try: + with open(utils.current_build_json) as f: + recorded = json.load(f).get("store_path") or None + except (OSError, ValueError): + recorded = None + if recorded and utils.build_is_running(recorded): + return recorded + return utils.running_system_store_path() + + +def _hide_current_build(entries: List[dict], current_ref: Optional[str]) -> List[dict]: + """Hide only the exact running build, matched by store path. + + A same-version entry with a different store path (e.g. a re-cut release) + is a real upgrade and must stay visible. When the current build is + unknown, nothing is hidden — offering the running build is harmless, + hiding a real upgrade is not. + """ + if not current_ref: + return list(entries) + return [e for e in entries if e.get("ref") != current_ref] def update_needed(current_version: str, repo_version: str) -> bool: @@ -173,463 +334,802 @@ def update_needed(current_version: str, repo_version: str) -> bool: class UISoftware(UIModule): """ - UI for updating software versions. - Includes secret 7x square unlock to trigger NixOS migration. + Software update UI. + + Phases: + loading - animated "Checking for updates..." + browse - header (version + channel selector) + scrollable version list + confirm - selected version details + Install / Notes / Cancel + upgrading - progress bar with download progress, then reboot + failed - update failed + Retry / Cancel """ __title__ = "SOFTWARE" + MAX_VISIBLE = 4 def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self.version_txt = f"{utils.pifinder_dir}/version.txt" self.wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" - with open(self.wifi_txt, "r") as wfs: - self._wifi_mode = wfs.read() - with open(self.version_txt, "r") as ver: - self._software_version = ver.read() - - self._release_version = "-.-.-" + with open(self.wifi_txt, "r") as f: + self._wifi_mode = f.read().strip() + self._software_version = utils.get_version() + self._software_subtitle: Optional[str] = None + + self._channels: Dict[str, List[dict]] = {} + self._manifest_channels: Dict[str, List[dict]] = {} + self._channel_names: List[str] = [] + self._channel_index = 0 + + self._version_list: List[dict] = [] + self._list_index = 0 + self._scroll_offset = 0 + + self._phase = "loading" + self._focus = "channel" # "channel" or "list" (browse phase) self._elipsis_count = 0 - self._go_for_update = False - self._option_select = "Update" - - # Unlock sequence tracking (7x square triggers migration) - self._key_buffer: list = [] - - def _record_key(self, key_name: str): - """Record a key press for unlock sequence detection.""" - self._key_buffer.append(key_name) - if len(self._key_buffer) > len(_UNLOCK_SEQUENCE): - self._key_buffer = self._key_buffer[-len(_UNLOCK_SEQUENCE) :] - if self._key_buffer == _UNLOCK_SEQUENCE: - self._key_buffer = [] - # Unlock: offer the first available migration target from the - # manifest, ignoring the nixos_for_everyone gate (that governs only - # the public path). - version_info = _migration_version_info_from_manifest() - if version_info: - self._trigger_migration(version_info) - else: - self.message(_("No release found"), 2) - - def _trigger_migration(self, version_info: dict): - """Push UIMigrationConfirm onto the UI stack with the supplied - version_info (must already contain migration_url and - migration_sha256_url).""" - self.message("System Upgrade", 1) - self.add_to_stack( - { - "class": UIMigrationConfirm, - "version_info": version_info, - "current_version": self._software_version.strip(), - } + + self._selected_version: Optional[dict] = None + self._confirm_options: List[str] = [] + self._confirm_index = 0 + + self._fail_option = "Retry" + self._fail_reason = "" + self._unstable_entries: List[dict] = [] + + # Background manifest refresh: the worker thread writes a single + # ("ok", channels) / ("error", None) tuple; update() consumes it. + self._refresh_thread: Optional[threading.Thread] = None + self._refresh_result: Optional[Tuple[str, Optional[Dict[str, List[dict]]]]] = ( + None ) + self._checking = False + self._check_failed = False - def get_release_version(self): - """ - Fetches current release version from - github, sets class variable if found. - Also checks the remote migration config. - """ - config = _fetch_migration_config() - if config and config.get("nixos_for_everyone"): - # Gate is open for everyone; the tarball comes from the manifest - # (stable -> beta -> unstable). - version_info = _migration_version_info_from_manifest() - if version_info: - self._trigger_migration(version_info) - return + self._scrollers: Dict[str, TextLayouterScroll] = {} + self._scroller_phase: Optional[str] = None + self._scroller_index: Optional[int] = None + def active(self): + super().active() + self._elipsis_count = 0 + self._focus = "channel" + self._channel_index = 0 + self._list_index = 0 + self._scroll_offset = 0 + self._selected_version = None + self._scrollers = {} + self._scroller_phase = None + self._scroller_index = None + self._check_failed = False + + # Render the last cached manifest immediately; a fresh copy is fetched + # in the background and swapped in when it lands. + cached = _load_cached_manifest() + if cached is not None: + self._apply_manifest(_parse_manifest(cached)) + self._phase = "browse" + else: + self._phase = "loading" + self._start_refresh() + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + + def _list_rollback_targets(self) -> List[dict]: + # Rollback targets come from local, immutable generation data, so they + # are available even when the manifest can't be fetched — which is + # exactly when rollback matters most. Entries are validated like + # manifest entries: anything without a string label can't be rendered + # or installed, so it is dropped rather than crash the screen. try: - res = requests.get( - "https://raw.githubusercontent.com/brickbots/PiFinder/release/version.txt", - timeout=REQUEST_TIMEOUT, - ) - except requests.exceptions.RequestException: - logger.warning("Could not fetch release version from github") - self._release_version = "Unknown" + targets = sys_utils.list_rollback_targets() + return [ + t + for t in targets + if isinstance(t, dict) and isinstance(t.get("label"), str) + ] + except Exception as e: # never let rollback listing break the screen + logger.warning("Could not list rollback targets: %s", e) + return [] + + def _start_refresh(self): + if self._refresh_thread is not None and self._refresh_thread.is_alive(): return + self._checking = True + self._refresh_result = None + self._refresh_thread = threading.Thread( + target=self._refresh_worker, daemon=True + ) + self._refresh_thread.start() - if res.status_code == 200: - self._release_version = res.text[:-1] + def _refresh_worker(self): + """Background thread: fetch the manifest, annotate, cache, parse.""" + try: + manifest = _fetch_raw_manifest() + except (requests.exceptions.RequestException, ValueError) as e: + logger.warning("Software update check failed (offline/invalid?): %s", e) + self._refresh_result = ("error", None) + return + _annotate_trunk_entries(manifest) + _save_cached_manifest(manifest) + self._refresh_result = ("ok", _parse_manifest(manifest)) + + def _consume_refresh_result(self): + """Apply a finished background refresh, if any (main thread only).""" + result = self._refresh_result + if result is None: + return + self._refresh_result = None + self._checking = False + status, manifest_channels = result + + if status == "ok" and manifest_channels is not None: + self._check_failed = False + self._apply_manifest(manifest_channels, keep_position=True) + if self._phase in ("loading", "offline"): + self._phase = "browse" + return + + # Refresh failed. With a cached list on screen just flag it; without + # one fall back to rollback-only browse, or the offline notice. + if self._phase != "loading": + self._check_failed = True + return + rollback = self._list_rollback_targets() + if rollback: + self._check_failed = True + self._manifest_channels = {} + self._channels = {"rollback": rollback} + self._channel_names = list(self._channels.keys()) + self._channel_index = 0 + self._refresh_version_list() + self._phase = "browse" + else: + self._phase = "offline" + + def _apply_manifest( + self, manifest_channels: Dict[str, List[dict]], keep_position: bool = False + ): + self._manifest_channels = manifest_channels + self._channels = { + "stable": manifest_channels.get("stable", []), + "beta": manifest_channels.get("beta", []), + } + + if self.config_object.get_option("dev_mode", False): + self._unstable_entries = manifest_channels.get("unstable", []) + self._channels["unstable"] = self._unstable_entries + + rollback = self._list_rollback_targets() + if rollback: + self._channels["rollback"] = rollback + + # Try to find subtitle for current version from fetched entries + self._software_subtitle = self._find_current_subtitle() + + # A background refresh should not yank the user's channel selection. + prev_channel = ( + self._channel_names[self._channel_index] if self._channel_names else None + ) + self._channel_names = list(self._channels.keys()) + if keep_position and prev_channel in self._channel_names: + self._channel_index = self._channel_names.index(prev_channel) else: - self._release_version = "Unknown" + self._channel_index = 0 + self._refresh_version_list(keep_position=keep_position) - def update_software(self): - self.message(_("Updating..."), 10) - if sys_utils.update_software(): - self.message(_("Ok! Restarting"), 10) - sys_utils.restart_system() + def _find_current_subtitle(self) -> Optional[str]: + """Find a subtitle for the running build. + + Matches by store path (the build's identity) first, falling back to + the version string when the current store path is unknown. + """ + current_ref = _current_store_path() + for entries in self._channels.values(): + for entry in entries: + if current_ref and entry.get("ref") == current_ref: + return entry.get("subtitle") + if not current_ref and entry.get("version") == self._software_version: + return entry.get("subtitle") + + return None + + def _refresh_version_list(self, keep_position: bool = False): + if not self._channel_names: + self._version_list = [] + return + channel = self._channel_names[self._channel_index] + entries = self._channels.get(channel, []) + if channel == "rollback": + self._version_list = entries else: - self.message(_("Error on Upd"), 3) + self._version_list = _hide_current_build(entries, _current_store_path()) + if keep_position and self._version_list: + self._list_index = min(self._list_index, len(self._version_list) - 1) + self._scroll_offset = min(self._scroll_offset, self._list_index) + else: + self._list_index = 0 + self._scroll_offset = 0 + self._scrollers = {} + self._scroller_phase = None + self._scroller_index = None + + def _get_scrollspeed_config(self): + scroll_dict = { + "Off": 0, + "Fast": TextLayouterScroll.FAST, + "Med": TextLayouterScroll.MEDIUM, + "Slow": TextLayouterScroll.SLOW, + } + scrollspeed = self.config_object.get_option("text_scroll_speed", "Med") + return scroll_dict[scrollspeed] + + def _get_scroller(self, key: str, text: str, font, color, width: int): + """Get or create a cached scroller, reset cache on phase/index change.""" + phase_index = (self._phase, self._list_index) + if (self._scroller_phase, self._scroller_index) != phase_index: + self._scrollers = {} + self._scroller_phase = self._phase + self._scroller_index = self._list_index + + if key not in self._scrollers: + self._scrollers[key] = TextLayouterScroll( + text, + draw=self.draw, + color=color, + font=font, + width=width, + scrollspeed=self._get_scrollspeed_config(), + ) + return self._scrollers[key] - def update(self, force=False): - self.clear_screen() - draw_pos = self.display_class.titlebar_height + 2 + # ------------------------------------------------------------------ + # Drawing helpers + # ------------------------------------------------------------------ + + def _draw_separator(self, y): + self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) + + def _draw_loading(self): + y = self.display_class.titlebar_height + 2 + ver_scroller = self._get_scroller( + "loading_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) + dots = "." * (self._elipsis_count // 10) self.draw.text( - (0, draw_pos), - _("Wifi Mode: {mode}").format(mode=self._wifi_mode), - font=self.fonts.base.font, - fill=self.colors.get(128), + (10, 90), + _("Checking for"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - draw_pos += self.fonts.base.height + 4 - self.draw.text( - (0, draw_pos), - _("Current Version"), - font=self.fonts.bold.font, - fill=self.colors.get(128), + (10, 105), + _("updates{elipsis}").format(elipsis=dots), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - draw_pos += self.fonts.bold.height - 3 + self._elipsis_count += 1 + if self._elipsis_count > 39: + self._elipsis_count = 0 + def _draw_wifi_warning(self): + y = self.display_class.titlebar_height + 2 + ver_scroller = self._get_scroller( + "wifi_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) self.draw.text( - (10, draw_pos), - f"{self._software_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), + (10, 90), + _("WiFi must be"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - draw_pos += self.fonts.bold.height + 3 - self.draw.text( - (0, draw_pos), - _("Release Version"), - font=self.fonts.bold.font, - fill=self.colors.get(128), + (10, 105), + _("client mode"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - draw_pos += self.fonts.bold.height - 3 + def _draw_offline(self): + y = self.display_class.titlebar_height + 2 + ver_scroller = self._get_scroller( + "offline_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) self.draw.text( - (10, draw_pos), - f"{self._release_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), + (10, 90), + _("No internet -"), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, 105), + _("check WiFi"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - # The two-line status / action message is anchored up from the bottom - # so it clears the (taller-font) info block on larger displays. - msg_pitch = self.fonts.large.height - msg_top = self.display_class.resY - 2 * msg_pitch - 6 - msg_bottom = msg_top + msg_pitch + def _draw_browse(self): + y = self.display_class.titlebar_height + 2 - if self._wifi_mode != "Client": - self.draw.text( - (10, msg_top), - _("WiFi must be"), - font=self.fonts.large.font, - fill=self.colors.get(255), + # Current version + ver_scroller = self._get_scroller( + "browse_cur_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) + y += 12 + if self._software_subtitle: + sub_scroller = self._get_scroller( + "browse_cur_sub", + self._software_subtitle, + self.fonts.base, + self.colors.get(128), + self.fonts.base.line_length, ) + sub_scroller.draw((0, y)) + y += 12 + else: + y += 2 + + # Channel selector + channel_name = ( + self._channel_names[self._channel_index].capitalize() + if self._channel_names + else "---" + ) + if self._focus == "channel": self.draw.text( - (10, msg_bottom), - _("client mode"), - font=self.fonts.large.font, + (0, y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, fill=self.colors.get(255), ) - return self.screen_update() - - if self._release_version == "-.-.-": - # check elipsis count here... if we are at >30 check for - # release versions - if self._elipsis_count > 30: - self.get_release_version() self.draw.text( - (10, msg_top), - _("Checking for"), - font=self.fonts.large.font, + (10, y), + channel_name, + font=self.fonts.bold.font, fill=self.colors.get(255), ) + else: self.draw.text( - (10, msg_bottom), - _("updates{elipsis}").format( - elipsis="." * int(self._elipsis_count / 10) - ), - font=self.fonts.large.font, - fill=self.colors.get(255), + (10, y), + channel_name, + font=self.fonts.base.font, + fill=self.colors.get(128), ) - self._elipsis_count += 1 - if self._elipsis_count > 39: - self._elipsis_count = 0 - return self.screen_update() + y += 14 - if not update_needed( - self._software_version.strip(), self._release_version.strip() - ): + self._draw_separator(y) + y += 4 + + # Version list + if not self._version_list: self.draw.text( - (10, msg_top), - _("No Update"), - font=self.fonts.large.font, - fill=self.colors.get(255), + (10, y + 10), + _("No versions"), + font=self.fonts.base.font, + fill=self.colors.get(128), ) self.draw.text( - (10, msg_bottom), - _("needed"), - font=self.fonts.large.font, - fill=self.colors.get(255), + (10, y + 22), + _("available"), + font=self.fonts.base.font, + fill=self.colors.get(128), ) - return self.screen_update() + self._draw_refresh_status() + return - # If we are here, go for update! - self._go_for_update = True - self.draw.text( - (10, 90), - _("Update Now"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - self.draw.text( - (10, 105), - _("Cancel"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - if self._option_select == "Update": - ind_pos = msg_top + label_width = self.fonts.base.line_length - 2 + list_bottom = 114 if (self._checking or self._check_failed) else 128 + current_y = y + for i in range(len(self._version_list)): + idx = self._scroll_offset + i + if idx >= len(self._version_list): + break + entry = self._version_list[idx] + prefix, text = _entry_row_parts(entry) + + if self._focus == "list" and idx == self._list_index: + if current_y + 24 > list_bottom: + break + self.draw.text( + (0, current_y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + # The prefix (PR number / trunk dot) stays fixed; only the + # title scrolls after it. + text_x = 10 + scroll_width = label_width + if prefix: + self.draw.text( + (text_x, current_y), + prefix, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + text_x += int(self.fonts.bold.width * len(prefix)) + scroll_width = max(1, label_width - len(prefix)) + scroller = self._get_scroller( + "browse_label", + text, + self.fonts.bold, + self.colors.get(255), + scroll_width, + ) + scroller.draw((text_x, current_y)) + current_y += 12 + detail = _entry_detail(entry) + if detail: + sub_scroller = self._get_scroller( + "browse_sub", + detail, + self.fonts.base, + self.colors.get(255), + label_width, + ) + sub_scroller.draw((10, current_y)) + current_y += 12 + else: + # Unfocused rows stay dim so the selected row and its detail + # line carry the visual weight. + if current_y + 12 > list_bottom: + break + # The trunk ("main") row stands out from the PR rows: bold and + # brighter, with a leading dot. + if entry.get("is_trunk"): + self.draw.text( + (10, current_y), + f"{prefix}{text}"[:label_width], + font=self.fonts.bold.font, + fill=self.colors.get(192), + ) + else: + self.draw.text( + (10, current_y), + f"{prefix}{text}"[:label_width], + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + current_y += 12 + + self._draw_refresh_status() + + def _draw_refresh_status(self): + """Bottom-line indicator for the background manifest refresh.""" + if self._checking: + dots = "." * ((self._elipsis_count // 10) % 4) + text = _("checking for updates") + dots + self._elipsis_count += 1 + if self._elipsis_count > 39: + self._elipsis_count = 0 + elif self._check_failed: + text = _("update check failed") else: - ind_pos = msg_bottom + return self.draw.text( - (0, ind_pos), - self._RIGHT_ARROW, - font=self.fonts.large.font, - fill=self.colors.get(255), + (4, 115), + text, + font=self.fonts.base.font, + fill=self.colors.get(96), ) - return self.screen_update() - - def toggle_option(self): - if not self._go_for_update: - return - if self._option_select == "Update": - self._option_select = "Cancel" - else: - self._option_select = "Update" - - def key_square(self): - self._record_key("square") - - def key_up(self): - self.toggle_option() - - def key_down(self): - self.toggle_option() - - def key_right(self): - if self._option_select == "Cancel": - self.remove_from_stack() - else: - self.update_software() - - -class UIMigrationConfirm(UIModule): - """ - Warning screen before initiating NixOS migration. - Shows version info, warns about irreversibility, requires confirmation. - """ - - __title__ = "UPGRADE" - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._version_info = self.item_definition.get("version_info", {}) - self._current_version = self.item_definition.get("current_version", "?") - self._target_version = self._version_info.get("version", "?") - self._option_index = 0 - self._options = [_("Confirm"), _("Cancel")] - - def update(self, force=False): - time.sleep(1 / 30) - self.clear_screen() + def _draw_confirm(self): y = self.display_class.titlebar_height + 2 self.draw.text( (0, y), - _("Major Upgrade"), - font=self.fonts.bold.font, - fill=self.colors.get(255), + _("Update to:"), + font=self.fonts.base.font, + fill=self.colors.get(128), ) y += 14 - self.draw.text( - (5, y), - f"{self._current_version} -> {self._target_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), + label_width = self.fonts.base.line_length + version_label = ( + self._selected_version.get("version") or self._selected_version["label"] ) - y += 16 - - # Separator - self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) - y += 4 - - self.draw.text( - (0, y), - _("IRREVERSIBLE"), - font=self.fonts.bold.font, - fill=self.colors.get(255), + scroller = self._get_scroller( + "confirm_label", + version_label, + self.fonts.bold, + self.colors.get(255), + label_width, ) + scroller.draw((0, y)) y += 12 - size_mb = self._version_info.get("migration_size_mb", "?") - self.draw.text( - (0, y), - _("Download: {}MB").format(size_mb), - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - y += 11 - - self.draw.text( - (0, y), - _("Power + WiFi req"), - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - y += 11 + subtitle = self._selected_version.get("subtitle", "") + if subtitle: + sub_scroller = self._get_scroller( + "confirm_sub", + subtitle, + self.fonts.base, + self.colors.get(128), + label_width, + ) + sub_scroller.draw((0, y)) + y += 14 - if not self._version_info.get( - "migration_sha256_url" - ) and not self._version_info.get("migration_sha256"): + age = _format_age(self._selected_version.get("built_at")) + if age: self.draw.text( (0, y), - _("No checksum avail."), + _("built {age}").format(age=age), font=self.fonts.base.font, fill=self.colors.get(128), ) y += 11 - y += 5 + self._draw_separator(y) + y += 4 - # Options - for i, label in enumerate(self._options): - oy = y + i * 12 + for i, opt in enumerate(self._confirm_options): + item_y = y + i * 12 + if i == self._confirm_index: + self.draw.text( + (0, item_y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, item_y), + _(opt), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + else: + self.draw.text( + (10, item_y), + _(opt), + font=self.fonts.base.font, + fill=self.colors.get(192), + ) + + def _draw_failed(self): + y = self.display_class.titlebar_height + 20 + reason = self._fail_reason or _("Update failed!") + for line in reason.split("\n"): self.draw.text( - (10, oy), - label, + (10, y), + line, font=self.fonts.bold.font, fill=self.colors.get(255), ) - if i == self._option_index: + y += 14 + y += 6 + for label in ("Retry", "Cancel"): + if self._fail_option == label: self.draw.text( - (0, oy), + (0, y), self._RIGHT_ARROW, font=self.fonts.bold.font, fill=self.colors.get(255), ) + self.draw.text( + (10, y), + _(label), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += 12 - return self.screen_update() + # ------------------------------------------------------------------ + # Main update loop + # ------------------------------------------------------------------ - def key_up(self): - self._option_index = (self._option_index - 1) % len(self._options) + def update(self, force=False): + self.clear_screen() - def key_down(self): - self._option_index = (self._option_index + 1) % len(self._options) + if self._phase == "upgrading": + self._draw_upgrading() + return self.screen_update() - def key_left(self): - return True + if self._phase == "failed": + self._draw_failed() + return self.screen_update() - def key_right(self): - if self._options[self._option_index] == _("Cancel"): - self.remove_from_stack() - elif self._options[self._option_index] == _("Confirm"): - self.add_to_stack( - { - "class": UIMigrationProgress, - "version_info": self._version_info, - } - ) + if self._wifi_mode != "Client": + self._draw_wifi_warning() + return self.screen_update() + self._consume_refresh_result() -class UIMigrationProgress(UIModule): - """ - Migration download and preparation progress screen. - Triggers the actual migration via sys_utils. - """ + if self._phase == "loading": + self._draw_loading() + return self.screen_update() - __title__ = "UPGRADE" + if self._phase == "offline": + self._draw_offline() + return self.screen_update() - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._version_info = self.item_definition.get("version_info", {}) - self._started = False - self._status = _("Starting...") - self._progress = 0 - self._terminal_failure = False - self._status_layout = TextLayouter( - self._status, - draw=self.draw, - color=self.colors.get(255), - colors=self.colors, - font=self.fonts.base, - available_lines=4, - ) + if self._phase == "browse": + self._draw_browse() + elif self._phase == "confirm": + self._draw_confirm() - def active(self): - super().active() - if not self._started: - self._started = True - self._start_migration() + return self.screen_update() - def _start_migration(self): - """Kick off the migration process in the background.""" - self._status = _("Downloading...") - try: - version_info = dict(self._version_info) - version_info["display_class"] = self.display_class.__class__.__name__ - version_info["display_resolution"] = list(self.display_class.resolution) - supported_displays = { - "DisplaySSD1351": (128, 128), - "DisplaySSD1333": (176, 176), - } - display_class = version_info["display_class"] - display_resolution = tuple(version_info["display_resolution"]) - display_supported = ( - supported_displays.get(display_class) == display_resolution - ) - display_supported = display_supported or ( - "SSD1333" in display_class and display_resolution == (176, 176) - ) - if not display_supported: - logger.error( - "Unsupported migration progress renderer display: " - f"{display_class} {version_info['display_resolution']}" + # ------------------------------------------------------------------ + # Key handlers + # ------------------------------------------------------------------ + + def key_up(self): + if self._phase == "upgrading": + return + if self._phase == "failed": + self._fail_option = "Cancel" if self._fail_option == "Retry" else "Retry" + elif self._phase == "browse": + if self._focus == "list": + if self._list_index == 0: + self._focus = "channel" + else: + self._list_index -= 1 + if self._list_index < self._scroll_offset: + self._scroll_offset = self._list_index + elif self._phase == "confirm": + if self._confirm_index > 0: + self._confirm_index -= 1 + + def key_down(self): + if self._phase == "upgrading": + return + if self._phase == "failed": + self._fail_option = "Cancel" if self._fail_option == "Retry" else "Retry" + elif self._phase == "browse": + if self._focus == "channel": + if self._version_list: + self._focus = "list" + self._list_index = 0 + self._scroll_offset = 0 + elif self._focus == "list": + if self._list_index < len(self._version_list) - 1: + self._list_index += 1 + if self._list_index >= self._scroll_offset + self.MAX_VISIBLE: + self._scroll_offset = self._list_index - self.MAX_VISIBLE + 1 + elif self._phase == "confirm": + if self._confirm_index < len(self._confirm_options) - 1: + self._confirm_index += 1 + + def key_right(self): + if self._phase == "upgrading": + return + if self._phase == "failed": + if self._fail_option == "Retry": + # Re-enters update_software() → "upgrading" phase, so Retry + # reuses the same progress UI as the first attempt. + self.update_software() + else: + self.remove_from_stack() + elif self._phase == "browse": + if self._focus == "channel" and self._channel_names: + self._channel_index = (self._channel_index + 1) % len( + self._channel_names ) - self._status = _("Not supported") - return - sys_utils.start_nixos_migration(version_info) - except AttributeError: - logger.error("sys_utils.start_nixos_migration not available") - self._status = _("Not supported") - self._status_layout.set_text(self._status) - self._terminal_failure = True - except Exception as e: - logger.error(f"Migration failed to start: {e}") - self._status = _("Failed: ") + str(e) - self._status_layout.set_text(self._status) - self._terminal_failure = True + self._refresh_version_list() + elif self._focus == "list" and self._version_list: + self._selected_version = self._version_list[self._list_index] + self._confirm_options = [] + if not self._selected_version.get("unavailable"): + self._confirm_options.append("Install") + if self._selected_version.get("notes"): + self._confirm_options.append("Notes") + self._confirm_options.append("Cancel") + self._confirm_index = 0 + self._phase = "confirm" + elif self._phase == "confirm": + opt = self._confirm_options[self._confirm_index] + if opt == "Install": + self.update_software() + elif opt == "Notes": + notes = self._selected_version.get("notes") + if notes: + self.add_to_stack({"class": UIReleaseNotes, "notes_text": notes}) + elif opt == "Cancel": + self._phase = "browse" - def update(self, force=False): - time.sleep(1 / 30) + def key_left(self): + if self._phase == "upgrading": + return False + if self._phase == "confirm": + self._phase = "browse" + return False + return True + + def key_square(self): + # Manual refresh: re-fetch the manifest with the same feedback as the + # automatic check on entry — the bottom status line in browse, the + # full "Checking for updates" screen when currently offline. + if self._phase == "browse": + self._start_refresh() + elif self._phase == "offline": + self._phase = "loading" + self._elipsis_count = 0 + self._start_refresh() + + # ------------------------------------------------------------------ + # Update action + # ------------------------------------------------------------------ + + def update_software(self): + if not self._selected_version: + return + if self._selected_version.get("unavailable"): + self._phase = "failed" + self._fail_reason = _("Version no\nlonger available") + self._fail_option = "Cancel" + return + self._phase = "upgrading" self.clear_screen() + self._draw_upgrading() + self.screen_update() + + ref = self._selected_version.get("ref") or "release" + selection = { + "ref": ref, + "label": self._selected_version.get("label"), + "version": self._selected_version.get("version"), + "channel": self._selected_version.get("channel"), + } + if not sys_utils.update_software(ref=ref, selection=selection): + self._phase = "failed" + self._fail_option = "Retry" + + def _draw_upgrading(self): y = self.display_class.titlebar_height + 2 - # Try to read progress from sys_utils. AttributeError happens when - # running against sys_utils_fake (no migration support); the helper - # itself swallows OS/JSON errors and returns {}. - try: - progress = sys_utils.get_migration_progress() - except AttributeError: - progress = None - if progress: - try: - self._progress = int(progress.get("percent", self._progress)) - except (TypeError, ValueError): - pass # bad/missing percent — keep prior value - new_status = progress.get("status", self._status) - if isinstance(new_status, str) and new_status != self._status: - self._status = new_status - self._status_layout.set_text(self._status) + progress = sys_utils.get_upgrade_progress() + phase = progress["phase"] + pct = progress["percent"] + done = progress["done"] + total = progress["total"] + unit = progress.get("unit", "bytes") + + if phase in ("failed", "unavailable", "connfail"): + if phase == "unavailable": + self._fail_reason = _("Version no\nlonger available") + elif phase == "connfail": + self._fail_reason = _("Can't reach\nupdate server") + else: + self._fail_reason = _("Update failed!") + self._phase = "failed" + self._fail_option = "Retry" + return + + # Title + if phase == "rebooting": + label = _("Rebooting...") + elif phase == "activating": + label = _("Activating...") + elif phase == "starting": + label = _("Preparing...") + else: + label = _("Downloading...") self.draw.text( (0, y), - _("System Upgrade"), + label, font=self.fonts.bold.font, fill=self.colors.get(255), ) @@ -637,17 +1137,21 @@ def update(self, force=False): # Progress bar bar_x, bar_w, bar_h = 4, 120, 12 + # Background fill so bar is always visible self.draw.rectangle( [bar_x, y, bar_x + bar_w, y + bar_h], - outline=self.colors.get(64), + fill=self.colors.get(48), + outline=self.colors.get(128), ) - fill_w = int(bar_w * self._progress / 100) + fill_w = int(bar_w * pct / 100) if fill_w > 0: self.draw.rectangle( [bar_x + 1, y + 1, bar_x + fill_w, y + bar_h - 1], fill=self.colors.get(255), ) - pct_text = f"{self._progress}%" + + # Percentage centered on bar + pct_text = f"{pct}%" pct_bbox = self.fonts.base.font.getbbox(pct_text) pct_w = pct_bbox[2] - pct_bbox[0] pct_h = pct_bbox[3] - pct_bbox[1] @@ -657,44 +1161,46 @@ def update(self, force=False): (pct_x, pct_y), pct_text, font=self.fonts.base.font, - fill=self.colors.get(0) if self._progress > 45 else self.colors.get(192), + fill=self.colors.get(0) if pct > 45 else self.colors.get(192), ) - y += bar_h + 4 - - # Use TextLayouter for scrollable status text - self._status_layout.draw((0, y)) - - return self.screen_update() - - def key_up(self): - self._status_layout.previous() + y += bar_h + 6 - def key_down(self): - self._status_layout.next() - - def key_left(self): - # Allow exit only if the migration never actually started (e.g., - # pre-flight refused due to missing checksum or unsupported display). - # Once the bash script is running, going back is unsafe. - if self._terminal_failure: - self.remove_from_stack() - return True - return False + # Amount below the bar: megabytes downloaded out of the total, or a + # path count in the fallback case where byte sizes were unavailable. + if phase == "downloading" and total > 0: + if unit == "bytes": + amount_text = f"{done / 1048576:.0f}/{total / 1048576:.0f} MB" + else: + amount_text = f"{done}/{total} paths" + self.draw.text( + (4, y), + amount_text, + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + # Name the package currently being copied, if known. + item = progress.get("item", "") + if item: + self.draw.text( + (4, y + 12), + item[:22], + font=self.fonts.base.font, + fill=self.colors.get(96), + ) class UIReleaseNotes(UIModule): """ Scrollable release notes viewer. - Fetches markdown from a URL and displays as plain text. + Accepts markdown text directly via notes_text in item_definition. """ __title__ = "NOTES" def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self._notes_url = self.item_definition.get("notes_url", "") + self._notes_text = self.item_definition.get("notes_text", "") self._loaded = False - self._error = False self._text_layout = TextLayouter( "", draw=self.draw, @@ -707,38 +1213,31 @@ def __init__(self, *args, **kwargs) -> None: def active(self): super().active() if not self._loaded: - self._fetch_notes() - - def _fetch_notes(self): - """Fetch release notes from the configured URL.""" - try: - res = requests.get(self._notes_url, timeout=REQUEST_TIMEOUT) - if res.status_code == 200: - text = _strip_markdown(res.text) - self._text_layout.set_text(text) - self._loaded = True - else: - self._error = True - logger.warning(f"Failed to fetch release notes: HTTP {res.status_code}") - except requests.exceptions.RequestException as e: - self._error = True - logger.warning(f"Failed to fetch release notes: {e}") + self._load_notes() + + def _load_notes(self): + """Process notes text for display.""" + if self._notes_text: + text = _strip_markdown(self._notes_text) + self._text_layout.set_text(text) + self._loaded = True + else: + self._loaded = True def update(self, force=False): - time.sleep(1 / 30) self.clear_screen() draw_pos = self.display_class.titlebar_height + 2 - if self._error: + if not self._notes_text: self.draw.text( (10, draw_pos + 20), - _("Could not load"), + _("No release notes"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( (10, draw_pos + 35), - _("release notes"), + _("available"), font=self.fonts.large.font, fill=self.colors.get(255), ) diff --git a/python/PiFinder/ui/sqm.py b/python/PiFinder/ui/sqm.py index ba4042af5..7be9cc361 100644 --- a/python/PiFinder/ui/sqm.py +++ b/python/PiFinder/ui/sqm.py @@ -36,21 +36,28 @@ def __init__(self, *args, **kwargs): font=self.fonts.base, ) - # Marking menu definition + # Marking menu definition. CORRECT is the everyday action (set tonight's + # scale from a hand-held reference meter); CALIB (noise constants) and + # SWEEP (diagnostic exposure sweep) are occasional/expert tools. self.marking_menu = MarkingMenu( left=MarkingMenuOption( label=_( - "CAL" + "CALIB" ), # TRANSLATORS: Marking menu option to launch SQM calibration wizard callback=self._launch_calibration, ), down=MarkingMenuOption( label=_( "CORRECT" - ), # TRANSLATORS: Marking menu option to launch SQM correction sweep tool + ), # TRANSLATORS: Marking menu option to correct SQM against a reference meter + callback=self._launch_correct, + ), + right=MarkingMenuOption( + label=_( + "SWEEP" + ), # TRANSLATORS: Marking menu option to launch SQM diagnostic exposure sweep callback=self._launch_sqm_sweep, ), - right=MarkingMenuOption(), ) def update(self, force=False): @@ -193,7 +200,7 @@ def update(self, force=False): if image_metadata and "exposure_time" in image_metadata: exp_ms = image_metadata["exposure_time"] / 1000 # Convert µs to ms if exp_ms >= 1000: - exp_str = f"{exp_ms/1000:.2f}s" + exp_str = f"{exp_ms / 1000:.2f}s" else: exp_str = f"{exp_ms:.0f}ms" self.draw.text( @@ -244,6 +251,14 @@ def update(self, force=False): font=self.fonts.base.font, fill=self.colors.get(64), ) + correct_delta = sqm_details.get("sqm_correct_delta", 0.0) + if correct_delta: + self.draw.text( + (stars_x, detail_y), + _("corr {d:+.2f}").format(d=correct_delta), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) # Bortle class if details: @@ -284,11 +299,8 @@ def key_minus(self): def _is_calibrated(self) -> bool: """Check if SQM calibration file exists for current camera.""" camera_type = self.shared_state.camera_type() - camera_type_processed = f"{camera_type}_processed" calibration_file = ( - Path.home() - / "PiFinder_data" - / f"sqm_calibration_{camera_type_processed}.json" + Path.home() / "PiFinder_data" / f"sqm_calibration_{camera_type}.json" ) return calibration_file.exists() @@ -308,6 +320,18 @@ def inactive(self): # Switch back to PID auto-exposure mode self.command_queues["camera"].put("set_ae_mode:pid") + def _launch_correct(self, marking_menu, selected_item): + """Launch the SQM correction entry (reference-meter offset)""" + from PiFinder.ui.sqm_correct import UISQMCorrect + + correct_def = { + "name": _("SQM Correct"), + "class": UISQMCorrect, + "label": "sqm_correct", + } + self.add_to_stack(correct_def) + return True # Exit marking menu + def _launch_calibration(self, marking_menu, selected_item): """Launch the SQM calibration wizard""" from PiFinder.ui.sqm_calibration import UISQMCalibration diff --git a/python/PiFinder/ui/sqm_calibration.py b/python/PiFinder/ui/sqm_calibration.py index a82596ed5..53d0f3ae8 100644 --- a/python/PiFinder/ui/sqm_calibration.py +++ b/python/PiFinder/ui/sqm_calibration.py @@ -23,7 +23,8 @@ from enum import Enum from typing import Optional, List -from PiFinder.types.positioning import PointingEstimate +from PiFinder.solver import _extract_raw_photometry_image, _scale_solution_centroids +from PiFinder.types.positioning import PointingEstimate, ReloadSqmCalibration from PiFinder.ui.base import UIModule from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder import timez @@ -73,12 +74,7 @@ def __init__(self, *args, **kwargs) -> None: # Marking menu for debug toggle self.marking_menu = self._create_marking_menu() - # Separate storage for each frame type - PROCESSED (8-bit) - self.bias_frames: List[np.ndarray] = [] - self.dark_frames: List[np.ndarray] = [] - self.sky_frames: List[np.ndarray] = [] - - # Separate storage for each frame type - RAW (16-bit) + # Frame storage - RAW (16-bit); SQM photometry is raw-only self.bias_frames_raw: List[np.ndarray] = [] self.dark_frames_raw: List[np.ndarray] = [] self.sky_frames_raw: List[np.ndarray] = [] @@ -86,12 +82,6 @@ def __init__(self, *args, **kwargs) -> None: # Store solution for each sky frame (needed for SQM calculation) self.sky_solutions: List[PointingEstimate] = [] - # Calibration results - PROCESSED (8-bit) - self.bias_offset: Optional[float] = None - self.read_noise: Optional[float] = None - self.dark_current_rate: Optional[float] = None - self.sky_brightness: Optional[float] = None - # Calibration results - RAW (16-bit) self.bias_offset_raw: Optional[float] = None self.read_noise_raw: Optional[float] = None @@ -367,7 +357,7 @@ def _draw_analyzing(self): y += self.fonts.base.height + 1 def _draw_results(self): - """Draw final results - both processed and raw""" + """Draw final results (raw calibration)""" tb = self.display_class.titlebar_height self.draw.text( (10, tb + 1), @@ -378,43 +368,31 @@ def _draw_results(self): y = tb + 19 - # Header row - self.draw.text( - (10, y), - " 8-bit 16-bit", - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - y += self.fonts.base.height + 2 - # Bias offset - if self.bias_offset is not None and self.bias_offset_raw is not None: + if self.bias_offset_raw is not None: self.draw.text( (10, y), - f"Bias: {self.bias_offset:4.1f} {self.bias_offset_raw:6.1f}", + f"Bias: {self.bias_offset_raw:6.1f}", font=self.fonts.base.font, fill=self.colors.get(192), ) y += self.fonts.base.height + 2 # Read noise - if self.read_noise is not None and self.read_noise_raw is not None: + if self.read_noise_raw is not None: self.draw.text( (10, y), - f"Read: {self.read_noise:4.2f} {self.read_noise_raw:6.2f}", + f"Read: {self.read_noise_raw:6.2f}", font=self.fonts.base.font, fill=self.colors.get(192), ) y += self.fonts.base.height + 2 # Dark current - if ( - self.dark_current_rate is not None - and self.dark_current_rate_raw is not None - ): + if self.dark_current_rate_raw is not None: self.draw.text( (10, y), - f"Dark: {self.dark_current_rate:4.2f} {self.dark_current_rate_raw:6.2f}", + f"Dark: {self.dark_current_rate_raw:6.2f}", font=self.fonts.base.font, fill=self.colors.get(192), ) @@ -493,7 +471,6 @@ def _capture_bias_frame(self): # First frame: set exposure to minimum (closest to 0) self.command_queues["camera"].put("set_exp:1") # Minimum exposure time.sleep(0.2) # Wait for camera to apply setting - self.bias_frames = [] self.bias_frames_raw = [] # Set save flag if debug enabled, then capture @@ -506,12 +483,6 @@ def _capture_bias_frame(self): time.sleep(0.3) # Wait for capture - # Get PROCESSED image (8-bit) from shared memory - img = self.camera_image.copy() - img = img.convert(mode="L") - np_image = np.asarray(img, dtype=np.uint8) - self.bias_frames.append(np_image) - # Get RAW image (16-bit) from shared state raw_array = self.shared_state.cam_raw() if raw_array is not None: @@ -532,7 +503,6 @@ def _capture_bias_frame(self): def _capture_dark_frame(self): """Capture a dark frame (actual exposure with cap on) - both processed and raw""" if self.current_frame == 0: - self.dark_frames = [] self.dark_frames_raw = [] # Set save flag if debug enabled, then capture @@ -545,12 +515,6 @@ def _capture_dark_frame(self): time.sleep(0.3) # Wait for capture - # Get PROCESSED image (8-bit) from shared memory - img = self.camera_image.copy() - img = img.convert(mode="L") - np_image = np.asarray(img, dtype=np.uint8) - self.dark_frames.append(np_image) - # Get RAW image (16-bit) from shared state raw_array = self.shared_state.cam_raw() if raw_array is not None: @@ -568,7 +532,6 @@ def _capture_dark_frame(self): def _capture_sky_frame(self): """Capture a sky frame (actual exposure, needs plate solve) - both processed and raw""" if self.current_frame == 0: - self.sky_frames = [] self.sky_frames_raw = [] self.sky_solutions = [] # Start timeout timer @@ -609,19 +572,12 @@ def _capture_sky_frame(self): time.sleep(0.3) # Wait for capture - # Get PROCESSED image (8-bit) from shared memory - img = self.camera_image.copy() - img = img.convert(mode="L") - np_image = np.asarray(img, dtype=np.uint8) - self.sky_frames.append(np_image) - - # Get RAW image (16-bit) from shared state + # Get RAW image (16-bit) from shared state; solution is stored + # alongside so the two lists stay index-aligned. raw_array = self.shared_state.cam_raw() if raw_array is not None: self.sky_frames_raw.append(raw_array.copy()) - - # Store the solution for this frame (deep copy so it doesn't change) - self.sky_solutions.append(copy.deepcopy(solution)) + self.sky_solutions.append(copy.deepcopy(solution)) self.current_frame += 1 @@ -637,25 +593,14 @@ def _capture_sky_frame(self): # ============================================ def _analyze_calibration(self): - """Analyze captured frames and compute calibration parameters for BOTH processed and raw""" + """Analyze captured raw frames and compute calibration parameters""" try: # This runs once, then moves to results - if self.bias_offset is not None: + if self.bias_offset_raw is not None: # Already analyzed, show results self.state = CalibrationState.RESULTS return - # Check that we have enough frames for BOTH processed and raw - if len(self.bias_frames) < self.num_frames: - self.error_message = f"Not enough bias frames ({len(self.bias_frames)})" - self.state = CalibrationState.ERROR - return - - if len(self.dark_frames) < self.num_frames: - self.error_message = f"Not enough dark frames ({len(self.dark_frames)})" - self.state = CalibrationState.ERROR - return - if len(self.bias_frames_raw) < self.num_frames: camera_type = self.shared_state.camera_type() if "Debug" in camera_type: @@ -676,29 +621,6 @@ def _analyze_calibration(self): exposure_sec = self.exposure_time_us / 1_000_000.0 - # ========== PROCESSED (8-bit) CALIBRATION ========== - - # 1. Compute bias offset (median of all pixels in all bias frames) - bias_stack = np.array(self.bias_frames, dtype=np.float32) - self.bias_offset = float(np.median(bias_stack)) - - # 2. Compute read noise using temporal variance (not spatial) - # Spatial std includes fixed pattern noise (PRNU), which is wrong. - # Temporal variance at each pixel measures true read noise. - temporal_variance = np.var( - bias_stack, axis=0 - ) # variance across frames per pixel - self.read_noise = float(np.sqrt(np.mean(temporal_variance))) - - # 3. Compute dark current rate - dark_stack = np.array(self.dark_frames, dtype=np.float32) - dark_median = float(np.median(dark_stack)) - self.dark_current_rate = (dark_median - self.bias_offset) / exposure_sec - - # Ensure dark current is not negative - if self.dark_current_rate < 0: - self.dark_current_rate = 0.0 - # ========== RAW (16-bit) CALIBRATION ========== # 1. Compute bias offset from raw frames @@ -745,36 +667,36 @@ def _calculate_sky_sqm(self, exposure_sec: float): try: from PiFinder.sqm import SQM - if len(self.sky_frames) == 0: + if len(self.sky_frames_raw) == 0: logger.warning("No sky frames to calculate SQM") self.sqm_median = None return - if len(self.sky_solutions) != len(self.sky_frames): + if len(self.sky_solutions) != len(self.sky_frames_raw): logger.error( - f"Mismatch: {len(self.sky_frames)} frames but {len(self.sky_solutions)} solutions" + f"Mismatch: {len(self.sky_frames_raw)} frames but " + f"{len(self.sky_solutions)} solutions" ) self.sqm_median = None return - # Create SQM calculator with the newly measured calibration - # Use PROCESSED (8-bit) pipeline - camera_type_processed = f"{self.shared_state.camera_type()}_processed" - sqm_calc = SQM(camera_type=camera_type_processed) + # Create SQM calculator with the newly measured raw calibration + sqm_calc = SQM(camera_type=self.shared_state.camera_type()) # Manually set the calibration values we just measured - if self.bias_offset is not None: - sqm_calc.profile.bias_offset = self.bias_offset - if self.read_noise is not None: - sqm_calc.profile.read_noise_adu = self.read_noise - if self.dark_current_rate is not None: - sqm_calc.profile.dark_current_rate = self.dark_current_rate + if self.bias_offset_raw is not None: + sqm_calc.profile.bias_offset = self.bias_offset_raw + if self.read_noise_raw is not None: + sqm_calc.profile.read_noise_adu = self.read_noise_raw + if self.dark_current_rate_raw is not None: + sqm_calc.profile.dark_current_rate = self.dark_current_rate_raw self.sqm_values = [] + saturation_threshold = int(0.70 * (2**sqm_calc.profile.bit_depth - 1)) - # Calculate SQM for each sky frame using its stored solution + # Calculate SQM for each raw sky frame using its stored solution for i, (sky_frame, solution) in enumerate( - zip(self.sky_frames, self.sky_solutions) + zip(self.sky_frames_raw, self.sky_solutions) ): if solution is None or not solution.has_pointing(): # No valid solve - skip SQM calculation @@ -796,22 +718,33 @@ def _calculate_sky_sqm(self, exposure_sec: float): logger.warning(f"Empty centroids for sky frame {i}, skipping SQM") continue + # Raw photometry image (green channel for Bayer sensors); + # centroids come from the 512px solve image and are rescaled. + green = _extract_raw_photometry_image(sky_frame, sqm_calc.profile) + if green is None: + logger.warning(f"Bad raw frame {i}, skipping SQM") + continue + # Adapter dict for SQM (sqm.calculate still consumes a # raw-tetra3-shaped dict so SQM stays loose of our types). - sqm_solution = { - "FOV": solution.diagnostics.FOV, - "matched_centroids": solution.matched_centroids, - "matched_stars": solution.matched_stars, - } + sqm_solution = _scale_solution_centroids( + { + "FOV": solution.diagnostics.FOV, + "matched_centroids": solution.matched_centroids, + "matched_stars": solution.matched_stars, + }, + green.shape[0] / 512.0, + ) - # Calculate SQM for this frame (using processed 8-bit image) # Returns Tuple[Optional[float], Dict] sqm_value, _details = sqm_calc.calculate( centroids=centroids, solution=sqm_solution, - image=sky_frame, + image=green, exposure_sec=exposure_sec, altitude_deg=altitude_deg, + saturation_threshold=saturation_threshold, + image_pixels_per_side=green.shape[0], ) if sqm_value is not None: @@ -836,33 +769,12 @@ def _calculate_sky_sqm(self, exposure_sec: float): self.sqm_median = None def _save_calibration(self): - """Save calibration data for BOTH raw and processed profiles with measured values""" + """Save the measured raw calibration for the camera profile""" try: # Import here to avoid circular dependencies from PiFinder.sqm import NoiseFloorEstimator - # Get camera type from shared state - camera_type_raw_sensor = self.shared_state.camera_type() - - # ========== Save PROCESSED (8-bit) calibration ========== - camera_type_processed = f"{camera_type_raw_sensor}_processed" - estimator_processed = NoiseFloorEstimator( - camera_type=camera_type_processed, enable_zero_sec_sampling=False - ) - - success_processed = estimator_processed.save_calibration( - bias_offset=self.bias_offset, - read_noise=self.read_noise, - dark_current_rate=self.dark_current_rate, - ) - - if not success_processed: - raise RuntimeError( - f"Failed to save processed calibration for {camera_type_processed}" - ) - - # ========== Save RAW (16-bit) calibration ========== - camera_type_raw = camera_type_raw_sensor # e.g., "imx296", "hq" + camera_type_raw = self.shared_state.camera_type() # e.g., "imx296", "hq" estimator_raw = NoiseFloorEstimator( camera_type=camera_type_raw, enable_zero_sec_sampling=False ) @@ -878,7 +790,7 @@ def _save_calibration(self): f"Failed to save raw calibration for {camera_type_raw}" ) - # Tell solver to reload the calibration immediately (for processed profile) + # Tell solver to reload the calibration immediately self._notify_solver_to_reload() except Exception as e: @@ -893,7 +805,7 @@ def _notify_solver_to_reload(self): try: # Use align_command queue to send reload command to solver if "align_command" in self.command_queues: - self.command_queues["align_command"].put(["reload_sqm_calibration"]) + self.command_queues["align_command"].put(ReloadSqmCalibration()) else: import logging diff --git a/python/PiFinder/ui/sqm_correct.py b/python/PiFinder/ui/sqm_correct.py new file mode 100644 index 000000000..f8ce6d3a7 --- /dev/null +++ b/python/PiFinder/ui/sqm_correct.py @@ -0,0 +1,265 @@ +""" +SQM Correct: set a session correction from a hand-held reference meter. + +The PiFinder SQM is stable within a night but its absolute scale depends on +the night's sky spectrum through the per-sensor band offset (see +CameraProfile.sqm_band_offset -- calibrated for one sky regime). Aiming a +reference meter at the camera's field and entering its reading corrects the +whole session to tonight's actual sky: every subsequent reading is shifted by +``delta = reference - current``. + +The correction lives in shared state only (resets on restart -- tonight's +spectrum is tonight's) and every correction event is appended to +``~/PiFinder_data/sqm_corrects.jsonl``, building the per-site calibration +dataset as a side effect of normal use. +""" + +import json +import logging +from enum import Enum +from typing import Any, TYPE_CHECKING + +from PiFinder import timez, utils +from PiFinder.ui.base import UIModule + +if TYPE_CHECKING: + + def _(a) -> Any: + return a + + +logger = logging.getLogger("UI.SQMCorrect") + + +class CorrectState(Enum): + ASK = "ask" # entering the meter reading + DONE = "done" # correction applied, showing result + + +class UISQMCorrect(UIModule): + """Enter a reference-meter reading to correct the session's SQM scale.""" + + __title__ = "SQM CORRECT" + __help_name__ = "" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.state = CorrectState.ASK + self.sqm_input = "" + self.applied_delta = None + + def active(self): + self.state = CorrectState.ASK + self.sqm_input = "" + # Keep the stable SNR auto-exposure the SQM screen uses + self.command_queues["camera"].put("set_ae_mode:snr") + self.update(force=True) + + def inactive(self): + self.command_queues["camera"].put("set_ae_mode:pid") + + def _current_sqm(self): + sqm_state = self.shared_state.sqm() + if sqm_state.last_update is None or sqm_state.value is None: + return None + return sqm_state.value + + def update(self, force=False): + self.clear_screen() + tb = self.display_class.titlebar_height + base_h = self.fonts.base.height + left = 10 + y = tb + 3 + + current = self._current_sqm() + old_delta = self.shared_state.sqm_correct_delta() + + if self.state == CorrectState.ASK: + self.draw.text( + (left, y), + _("METER SQM"), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += self.fonts.bold.height + 4 + if current is not None: + self.draw.text( + (left, y), + _("PiFinder: {v:.2f}").format(v=current), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + else: + self.draw.text( + (left, y), + _("NO SQM DATA YET"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + y += base_h + 2 + + if current is None: + # Correcting needs a live reading to offset against; there is + # nothing to enter until the SQM has a value. + self.draw.text( + (left, y), + _("wait for a solve"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + y = self.display_class.resY - base_h - 7 + self.draw.text( + (left, y), + f"{self._SQUARE_}: " + _("EXIT"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + return self.screen_update(title_bar=True) + + if old_delta: + self.draw.text( + (left, y), + _("correct now: {d:+.2f}").format(d=old_delta), + font=self.fonts.base.font, + fill=self.colors.get(64), + ) + y += base_h + y += 4 + + # Entry field, XX.XX + if len(self.sqm_input) <= 2: + display = self.sqm_input + "_" * (2 - len(self.sqm_input)) + ".__" + else: + display = ( + self.sqm_input[:2] + + "." + + self.sqm_input[2:] + + "_" * (4 - len(self.sqm_input)) + ) + self.draw.text( + (left, y), + display, + font=self.fonts.huge.font, + fill=self.colors.get(192), + ) + y = self.display_class.resY - 2 * base_h - 7 + self.draw.text( + (left, y), + _("0: clear -: del"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + y += base_h + 2 + self.draw.text( + (left, y), + f"{self._SQUARE_}: " + _("SET"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + + elif self.state == CorrectState.DONE: + self.draw.text( + (left, y), + _("CORRECTED"), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += self.fonts.bold.height + 6 + if self.applied_delta is not None: + self.draw.text( + (left, y), + _("delta {d:+.2f}").format(d=self.applied_delta), + font=self.fonts.large.font, + fill=self.colors.get(192), + ) + y = self.display_class.resY - base_h - 7 + self.draw.text( + (left, y), + f"{self._SQUARE_}: " + _("EXIT"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + + return self.screen_update(title_bar=True) + + def _log_correct(self, reference, measured, old_delta, new_delta): + """Append the correction event to the calibration dataset.""" + try: + record = { + "timestamp": timez.local_now().isoformat(), + "reference_sqm": reference, + "pifinder_sqm": measured, + "old_delta": old_delta, + "new_delta": new_delta, + "camera_type": self.shared_state.camera_type(), + } + details = self.shared_state.sqm_details() + if details: + for k in ("sqm_band_offset", "mzero_correction", "n_matched_stars"): + if k in details: + record[k] = details[k] + location = self.shared_state.location() + if location: + record["lat"] = round(location.lat, 3) + record["lon"] = round(location.lon, 3) + path = utils.data_dir / "sqm_corrects.jsonl" + with open(path, "a") as f: + f.write(json.dumps(record) + "\n") + except Exception as e: + logger.warning(f"Could not log correct event: {e}") + + def _set_correct(self, reference): + current = self._current_sqm() + if current is None: + return False + old_delta = self.shared_state.sqm_correct_delta() + # `current` already includes the old delta; the new total delta + # re-corrects directly onto the fresh reference. + new_delta = old_delta + (reference - current) + self.shared_state.set_sqm_correct_delta(new_delta) + self._log_correct(reference, current, old_delta, new_delta) + logger.info( + f"SQM correct set: ref={reference:.2f} measured={current:.2f} " + f"delta={new_delta:+.2f}" + ) + self.applied_delta = new_delta + return True + + # Key handlers + def key_number(self, number): + if self.state != CorrectState.ASK: + return + if self._current_sqm() is None: + return + if number == 0 and self.sqm_input == "": + # Clear any existing correction + self.shared_state.set_sqm_correct_delta(0.0) + self.applied_delta = 0.0 + self.state = CorrectState.DONE + elif len(self.sqm_input) < 4: + self.sqm_input += str(number) + + def key_minus(self): + if self.state == CorrectState.ASK and self.sqm_input: + self.sqm_input = self.sqm_input[:-1] + + def key_square(self): + if self.state == CorrectState.ASK: + if self._current_sqm() is None: + # No reading to correct against; square just exits. + if self.remove_from_stack: + self.remove_from_stack() + return + if len(self.sqm_input) == 4: + try: + reference = float(self.sqm_input[:2] + "." + self.sqm_input[2:]) + except ValueError: + self.sqm_input = "" + return + if self._set_correct(reference): + self.state = CorrectState.DONE + else: + self.sqm_input = "" + elif self.state == CorrectState.DONE: + if self.remove_from_stack: + self.remove_from_stack() diff --git a/python/PiFinder/ui/sqm_correction.py b/python/PiFinder/ui/sqm_correction.py deleted file mode 100644 index b1022c078..000000000 --- a/python/PiFinder/ui/sqm_correction.py +++ /dev/null @@ -1,449 +0,0 @@ -""" -SQM Correction UI - Allows user to manually correct SQM values -and save correction data packages for calibration validation. -""" - -import os -import json -import time -import zipfile -import logging -from pathlib import Path -from typing import Any, Dict, TYPE_CHECKING -from PIL import Image -import numpy as np - -from PiFinder.ui.base import UIModule -from PiFinder.ui.numeric_entry import ( - NumericEntryField, - EntryLegend, - LegendItem, - BlinkingCursor, -) -from PiFinder import utils -from PiFinder import timez - -if TYPE_CHECKING: - - def _(a) -> Any: - return a - - -logger = logging.getLogger("PiFinder.SQMCorrection") - - -class UISQMCorrection(UIModule): - """ - UI for correcting SQM values and creating calibration packages. - - User enters a corrected SQM value, and the system creates a zip file containing: - - Raw 16-bit TIFF image - - Processed 8-bit PNG image - - JSON metadata with original/corrected SQM, GPS location, solve data, etc. - """ - - __title__ = _("SQM Correction") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Get current SQM from shared state - sqm_state = self.shared_state.sqm() - self.original_sqm = sqm_state.value if sqm_state.value else 18.0 - - # Initialize numeric entry field with XX.XX format for SQM values - self.entry_field = NumericEntryField( - format_pattern="XX.XX", - validation_range=(10.0, 23.0), - placeholder_char="_", - ) - - # Initialize blinking cursor - self.cursor = BlinkingCursor(blink_interval=0.5) - - # Initialize legend with proper icons (copied from radec_entry.py) - back_icon = "" - go_icon = "" - - self.legend = EntryLegend( - items=[ - LegendItem(icon=back_icon, label=_("Cancel")), # left arrow - LegendItem(icon=go_icon, label=_("Save")), # right arrow - LegendItem(icon="- ", label=_("Del")), # minus with space - ], - show_separator=True, - layout="single_line", - ) - - self.error_message = None - self.success_message = None - self.message_time = None - - def update(self, force=False): - """Draw the correction UI""" - self.clear_screen() - - # Title - title = _("SQM Correction") - self.draw.text( - (0, 5), - title, - font=self.fonts.bold.font, - fill=self.colors.get(255), - ) - - # Stack the rows below the title bar; offsets derive from font heights - # so the block fills a larger panel instead of clustering at the top - # (reproduces the 128 panel's 25 / 45 / 60 / 85 positions). - tb = self.display_class.titlebar_height - base_h = self.fonts.base.height - original_y = tb + 8 - corrected_y = original_y + base_h + 9 - entry_y = corrected_y + base_h + 4 - message_y = entry_y + self.fonts.large.height + 9 - - # Show original SQM value - original_text = _("Original: {sqm:.2f}").format(sqm=self.original_sqm) - self.draw.text( - (0, original_y), - original_text, - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - - # Show correction input label - corrected_label = _("Corrected:") - self.draw.text( - (0, corrected_y), - corrected_label, - font=self.fonts.base.font, - fill=self.colors.get(192), - ) - - # Center the entry field on the actual screen width - char_width = self.fonts.large.width - total_width = char_width * len(self.entry_field.positions) - entry_x = (self.display_class.resX - total_width) // 2 - - # Draw numeric entry field using component with blinking cursor - self.entry_field.draw( - draw=self.draw, - screen=self.screen, - x=entry_x, - y=entry_y, - font=self.fonts.large.font, - char_width=char_width, - char_height=self.fonts.large.height, - normal_color=self.colors.get(255), - blinking_cursor=self.cursor, - ) - - # Show error or success message - if self.error_message and self.message_time: - # Show error for 3 seconds - if (timez.local_now() - self.message_time).total_seconds() < 3: - self.draw.text( - (0, message_y), - self.error_message, - font=self.fonts.base.font, - fill=self.colors.get(255), - ) - else: - self.error_message = None - self.message_time = None - - if self.success_message and self.message_time: - # Show success for 3 seconds, then exit - if (timez.local_now() - self.message_time).total_seconds() < 3: - self.draw.text( - (0, message_y), - self.success_message, - font=self.fonts.base.font, - fill=self.colors.get(255), - ) - else: - # Auto-exit after showing success message - if self.remove_from_stack: - self.remove_from_stack() - - # Draw legend at bottom using component - self.legend.draw( - draw=self.draw, - screen_width=self.display_class.resX, - screen_height=self.display_class.resY, - font=self.fonts.base.font, - font_height=self.fonts.base.height, - separator_color=self.colors.get(128), - text_color=self.colors.get(128), - margin=2, - ) - - return self.screen_update() - - def key_number(self, number): - """Handle number key input""" - self.entry_field.insert_digit(number) - - def key_0(self): - """Handle 0 key""" - self.entry_field.insert_digit(0) - - def key_plus(self): - """Delete - remove digit at cursor""" - self.entry_field.delete_digit() - - def key_minus(self): - """Delete - remove digit at cursor""" - self.entry_field.delete_digit() - - def key_left(self): - """Cancel and exit""" - if self.remove_from_stack: - self.remove_from_stack() - - def key_right(self): - """Save correction package""" - # Validate input using entry field component - is_valid, corrected_sqm = self.entry_field.validate() - - if not is_valid: - if corrected_sqm is None: - self.error_message = _("Enter a value") - else: - self.error_message = _("Range: 10-23") - self.message_time = timez.local_now() - return - - # Show saving message and force screen update - self.success_message = _("Saving...") - self.message_time = timez.local_now() - self.update(force=True) - - # Create correction package - try: - zip_path = self._create_correction_package(corrected_sqm) - self.success_message = _("Saved: {filename}").format( - filename=os.path.basename(zip_path) - ) - self.message_time = timez.local_now() - logger.info(f"SQM correction package saved: {zip_path}") - except Exception as e: - logger.error(f"Failed to create correction package: {e}") - self.error_message = _("Save failed") - self.message_time = timez.local_now() - - def _wait_for_exposure(self, target_exp_us: int, timeout: float = 5.0) -> bool: - """Wait for camera to capture image at target exposure.""" - start = time.time() - while time.time() - start < timeout: - metadata = self.shared_state.last_image_metadata() - if ( - metadata - and abs(metadata.get("exposure_time", 0) - target_exp_us) < 1000 - ): - # Wait one more frame for image to be ready - time.sleep(0.3) - return True - time.sleep(0.1) - return False - - def _capture_at_exposure( - self, exp_us: int, corrections_dir: Path, timestamp: str, label: str - ) -> Dict[str, Any]: - """Capture image at specific exposure and return file info.""" - # Set exposure - self.command_queues["camera"].put(f"set_exp:{exp_us}") - - # Wait for image at this exposure - if not self._wait_for_exposure(exp_us): - logger.warning(f"Timeout waiting for exposure {exp_us}µs") - return {} - - # Capture images - camera_image = self.camera_image.copy() - raw_image = self.shared_state.cam_raw() - - files: Dict[str, Any] = {} - - # Save processed PNG - processed_path = f"correction_{timestamp}_{label}_processed.png" - camera_image.save(str(corrections_dir / processed_path)) - files["processed"] = processed_path - - # Save raw TIFF if available - if raw_image is not None: - raw_path = f"correction_{timestamp}_{label}_raw.tiff" - raw_image_pil = Image.fromarray(np.asarray(raw_image, dtype=np.uint16)) - raw_image_pil.save(str(corrections_dir / raw_path)) - files["raw"] = raw_path - - files["exposure_us"] = exp_us - return files - - def _create_correction_package(self, corrected_sqm: float) -> str: - """ - Create a zip file containing correction data with bracketed exposures. - - Captures 3 exposures: current, +1 stop (2x), -1 stop (0.5x) - If +1 stop exceeds max (1s), captures -2 stops instead. - - Returns: - Path to created zip file - """ - # Create corrections directory - corrections_dir = Path(utils.data_dir) / "captures" / "sqm_corrections" - corrections_dir.mkdir(parents=True, exist_ok=True) - - # Generate timestamp for filename - timestamp = timez.local_now().strftime("%Y%m%d_%H%M%S") - zip_filename = f"sqm_correction_{timestamp}.zip" - zip_path = corrections_dir / zip_filename - - # Get current exposure - image_metadata = self.shared_state.last_image_metadata() - current_exp = ( - image_metadata.get("exposure_time", 500000) if image_metadata else 500000 - ) - - # Calculate bracket exposures (in microseconds) - max_exp = 1000000 # 1 second max - min_exp = 10000 # 10ms min - - exp_above = min(current_exp * 2, max_exp) - exp_below = max(current_exp // 2, min_exp) - exp_below2 = max(current_exp // 4, min_exp) - - # Determine which 3 exposures to capture - if exp_above <= current_exp: - # Can't go above, use current and two below - exposures = [ - (current_exp, "base"), - (exp_below, "minus1"), - (exp_below2, "minus2"), - ] - else: - # Normal bracket: below, current, above - exposures = [ - (exp_below, "minus1"), - (current_exp, "base"), - (exp_above, "plus1"), - ] - - # Collect metadata first (before changing exposure) - metadata = self._collect_metadata(corrected_sqm) - metadata["bracket_exposures"] = [] - - # Capture all bracketed exposures - all_files = [] - for exp_us, label in exposures: - # Update saving message - self.success_message = _("Saving {label}...").format(label=label) - self.update(force=True) - - files = self._capture_at_exposure(exp_us, corrections_dir, timestamp, label) - if files: - all_files.append(files) - metadata["bracket_exposures"].append( - { - "label": label, - "exposure_us": exp_us, - "exposure_sec": exp_us / 1_000_000.0, - } - ) - - # Re-enable auto-exposure - self.command_queues["camera"].put("set_exp:auto") - - # Create zip file - with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - # Add all captured images - for files in all_files: - if "processed" in files: - zf.write( - corrections_dir / files["processed"], arcname=files["processed"] - ) - (corrections_dir / files["processed"]).unlink() - if "raw" in files: - zf.write(corrections_dir / files["raw"], arcname=files["raw"]) - (corrections_dir / files["raw"]).unlink() - - # Add metadata JSON - metadata_path = f"correction_{timestamp}_metadata.json" - with open(corrections_dir / metadata_path, "w") as f: - json.dump(metadata, f, indent=2, default=str) - zf.write(corrections_dir / metadata_path, arcname=metadata_path) - (corrections_dir / metadata_path).unlink() - - return str(zip_path) - - def _collect_metadata(self, corrected_sqm: float) -> Dict[str, Any]: - """Collect all relevant metadata for the correction package""" - metadata: Dict[str, Any] = { - "timestamp": timez.local_now().isoformat(), - "sqm": { - "original": self.original_sqm, - "corrected": corrected_sqm, - "difference": corrected_sqm - self.original_sqm, - }, - } - - # Get SQM source - sqm_state = self.shared_state.sqm() - if sqm_state.source: - metadata["sqm"]["source"] = sqm_state.source - - # Get GPS location - location = self.shared_state.location() - if location and location.lock: - metadata["location"] = { - "latitude": location.lat, - "longitude": location.lon, - "altitude_m": location.altitude, - "timezone": location.timezone, - } - - # Get GPS datetime - gps_datetime = self.shared_state.datetime() - if gps_datetime: - metadata["gps_datetime"] = gps_datetime.isoformat() - - # Get solve data (RA/Dec/Alt/Az) - solution = self.shared_state.solution() - if solution and solution.has_pointing(): - aligned = solution.pointing.aligned.estimate - metadata["solve"] = { - "ra_deg": aligned.RA, - "dec_deg": aligned.Dec, - "altitude_deg": solution.Alt, - "azimuth_deg": solution.Az, - "fov_deg": solution.diagnostics.FOV, - "matches": solution.diagnostics.Matches, - "rmse": solution.diagnostics.RMSE, - } - - # Get image metadata (exposure, gain, etc.) - image_metadata = self.shared_state.last_image_metadata() - if image_metadata: - metadata["image"] = { - "exposure_us": image_metadata.get("exposure_time"), - "exposure_sec": image_metadata.get("exposure_time", 0) / 1_000_000.0, - "gain": image_metadata.get("gain"), - "imu_delta": image_metadata.get("imu_delta"), - } - - # Get camera type - metadata["camera_type"] = self.shared_state.camera_type() - - # Get adaptive noise floor (used for auto-exposure and SQM pedestal) - metadata["noise_floor_adu"] = self.shared_state.noise_floor() - - # Get full SQM calculation details (mzero, background, extinction, etc.) - sqm_details = self.shared_state.sqm_details() - if sqm_details: - metadata["sqm_calculation"] = sqm_details - - return metadata - - def active(self): - """Called when module becomes active""" - pass diff --git a/python/PiFinder/ui/sqm_sweep.py b/python/PiFinder/ui/sqm_sweep.py index 9449d469a..a27a7c7ef 100644 --- a/python/PiFinder/ui/sqm_sweep.py +++ b/python/PiFinder/ui/sqm_sweep.py @@ -300,17 +300,18 @@ def _add_detailed_metadata(self): with open(metadata_file, "r") as f: metadata = json.load(f) - # Add current SQM state (like sqm_correction does) + # Always record the SQM/reference comparison, even when the live + # SQM is unavailable, so every sweep carries a calibration record. sqm_state = self.shared_state.sqm() - if sqm_state: - metadata["sqm"] = { - "pifinder_value": sqm_state.value, - "reference_value": self.reference_sqm, - "difference": (self.reference_sqm - sqm_state.value) - if self.reference_sqm and sqm_state.value - else None, - "source": sqm_state.source, - } + pifinder_value = sqm_state.value if sqm_state else None + metadata["sqm"] = { + "pifinder_value": pifinder_value, + "reference_value": self.reference_sqm, + "difference": (self.reference_sqm - pifinder_value) + if self.reference_sqm and pifinder_value + else None, + "source": sqm_state.source if sqm_state else None, + } # Add full SQM calculation details sqm_details = self.shared_state.sqm_details() @@ -326,46 +327,51 @@ def _add_detailed_metadata(self): / 1_000_000.0, "gain": image_metadata.get("gain"), "imu_delta": image_metadata.get("imu_delta"), + "sensor_temp_c": image_metadata.get("sensor_temp_c"), } - # Add solve data - solution = self.shared_state.solution() - if solution and solution.has_pointing(): - aligned = solution.pointing.aligned.estimate - metadata["solve"] = { - "ra_deg": aligned.RA, - "dec_deg": aligned.Dec, - "altitude_deg": solution.Alt, - "azimuth_deg": solution.Az, - "fov_deg": solution.diagnostics.FOV, - "matches": solution.diagnostics.Matches, - "rmse": solution.diagnostics.RMSE, - } - - # Add NoiseFloorEstimator output - camera_type = self.shared_state.camera_type() - camera_type_processed = f"{camera_type}_processed" - exposure_sec = ( - image_metadata.get("exposure_time", 500000) / 1_000_000.0 - if image_metadata - else 0.5 - ) - - if self.camera_image is not None: - image_array = np.array(self.camera_image.convert("L")) - - estimator = NoiseFloorEstimator( - camera_type=camera_type_processed, - enable_zero_sec_sampling=False, - ) - _, nf_details = estimator.estimate_noise_floor( - image=image_array, - exposure_sec=exposure_sec, + # Each optional enrichment below is guarded on its own: a failure in + # one section must not discard the metadata already collected. + try: + solution = self.shared_state.solution() + if solution and solution.has_pointing(): + aligned = solution.pointing.aligned.estimate + metadata["solve"] = { + "ra_deg": aligned.RA, + "dec_deg": aligned.Dec, + "altitude_deg": solution.Alt, + "azimuth_deg": solution.Az, + "fov_deg": solution.diagnostics.FOV, + "matches": solution.diagnostics.Matches, + "rmse": solution.diagnostics.RMSE, + } + except Exception as e: + logger.warning(f"Could not record solve metadata: {e}") + + try: + camera_type = self.shared_state.camera_type() + exposure_sec = ( + image_metadata.get("exposure_time", 500000) / 1_000_000.0 + if image_metadata + else 0.5 ) + if self.camera_image is not None: + image_array = np.array(self.camera_image.convert("L")) + + estimator = NoiseFloorEstimator( + camera_type=camera_type, + enable_zero_sec_sampling=False, + ) + _, nf_details = estimator.estimate_noise_floor( + image=image_array, + exposure_sec=exposure_sec, + ) - nf_details.pop("request_zero_sec_sample", None) - nf_details["camera_type"] = camera_type_processed - metadata["noise_floor_estimator"] = nf_details + nf_details.pop("request_zero_sec_sample", None) + nf_details["camera_type"] = camera_type + metadata["noise_floor_estimator"] = nf_details + except Exception as e: + logger.warning(f"Could not record noise-floor metadata: {e}") # Save updated metadata with open(metadata_file, "w") as f: diff --git a/python/PiFinder/ui/status.py b/python/PiFinder/ui/status.py index 838b69364..c2c104648 100644 --- a/python/PiFinder/ui/status.py +++ b/python/PiFinder/ui/status.py @@ -49,10 +49,6 @@ def __init__(self, *args, **kwargs): "CPU TMP": "--", } - with open(f"{utils.pifinder_dir}/wifi_status.txt", "r") as wfs: - wifi_mode = wfs.read() - self.status_dict["WIFI"] = "Client" if wifi_mode == "Client" else "AP" - self.last_temp_time = 0 self.last_IP_time = 0 self.net = sys_utils.Network() @@ -105,16 +101,14 @@ def update_status_dict(self): self.status_dict["RA/DEC"] = "--/--" else: hh, mm, _ = calc_utils.ra_to_hms(aligned.RA) - self.status_dict["RA/DEC"] = ( - f"{hh:02.0f}h{mm:02.0f}m/{aligned.Dec :.2f}" - ) + self.status_dict["RA/DEC"] = f"{hh:02.0f}h{mm:02.0f}m/{aligned.Dec:.2f}" # AZ/ALT if solution.Az is None or solution.Alt is None: self.status_dict["AZ/ALT"] = "--/--" else: self.status_dict["AZ/ALT"] = ( - f"{solution.Az : >6.2f}/{solution.Alt : >6.2f}" + f"{solution.Az: >6.2f}/{solution.Alt: >6.2f}" ) imu = self.shared_state.imu() @@ -125,10 +119,10 @@ def update_status_dict(self): mtext = "Moving" else: mtext = "Static" - self.status_dict["IMU"] = f"{mtext : >11}" + " " + str(imu.status) + self.status_dict["IMU"] = f"{mtext: >11}" + " " + str(imu.status) - self.status_dict["IMU qw,qx"] = f"{imu.quat.w:>.2f},{imu.quat.x : >.2f}" - self.status_dict["IMU qy,qz"] = f"{imu.quat.y:>.2f},{imu.quat.z : >.2f}" + self.status_dict["IMU qw,qx"] = f"{imu.quat.w:>.2f},{imu.quat.x: >.2f}" + self.status_dict["IMU qy,qz"] = f"{imu.quat.y:>.2f},{imu.quat.z: >.2f}" else: self.status_dict["IMU"] = "--" self.status_dict["IMU qw,qx"] = "--" @@ -161,18 +155,17 @@ def update_status_dict(self): try: with open("/sys/class/thermal/thermal_zone0/temp", "r") as f: raw_temp = int(f.read().strip()) - self.status_dict["CPU TMP"] = f"{raw_temp / 1000 : >13.1f}" + self.status_dict["CPU TMP"] = f"{raw_temp / 1000: >13.1f}" except FileNotFoundError: self.status_dict["CPU TMP"] = "Error" if time.time() - self.last_IP_time > 20: self.last_IP_time = time.time() - # IP address + # Live network state: WIFI radio mode, the reachable IP, and the + # active-uplink label (Ethernet when wired, else SSID / AP name). + self.status_dict["WIFI"] = self.net.wifi_mode() self.status_dict["IP"] = self.net.local_ip() - if self.net.wifi_mode() == "AP": - self.status_dict["SSID"] = self.net.get_ap_name() - else: - self.status_dict["SSID"] = self.net.get_connected_ssid() + self.status_dict["SSID"] = self.net.get_active_label() def update(self, force=False): self.update_status_dict() diff --git a/python/PiFinder/ui/timeentry.py b/python/PiFinder/ui/timeentry.py index d0ecc48c0..ac75217ab 100644 --- a/python/PiFinder/ui/timeentry.py +++ b/python/PiFinder/ui/timeentry.py @@ -140,7 +140,7 @@ def draw_local_time_note(self): note_str = self.shared_state.location().timezone[:18] self.draw.text( (10, note_y), - _(note_str), + _(note_str), font=self.fonts.base.font, fill=self.red, ) diff --git a/python/PiFinder/ui/ui_utils.py b/python/PiFinder/ui/ui_utils.py index 661092691..86d3d9cad 100644 --- a/python/PiFinder/ui/ui_utils.py +++ b/python/PiFinder/ui/ui_utils.py @@ -375,10 +375,10 @@ def format_number(num: float, width=5): return f"{num:{width}d}" elif num < 1000000: decimal_places = max(0, width - 3) # 'K' and at least one digit - return f"{num/1000:{width}.{decimal_places}f}K" + return f"{num / 1000:{width}.{decimal_places}f}K" else: decimal_places = max(0, width - 3) # 'M' and at least one digit - return f"{num/1000000:{width}.{decimal_places}f}M" + return f"{num / 1000000:{width}.{decimal_places}f}M" def pointing_arrows(ui, point_az, point_alt, mount_type=None): @@ -448,7 +448,7 @@ def draw_pointing_instructions( decimals = 2 if value < 1 else 1 ui.draw.text( anchor, - f"{arrow}{value : >5.{decimals}f}", + f"{arrow}{value: >5.{decimals}f}", font=ui.fonts.huge.font, fill=ui.colors.get(brightness), ) diff --git a/python/PiFinder/utils.py b/python/PiFinder/utils.py index 5b9bbdb9d..15cccfbb0 100644 --- a/python/PiFinder/utils.py +++ b/python/PiFinder/utils.py @@ -1,6 +1,7 @@ import os import errno import fcntl +import socket import time import logging import json @@ -8,6 +9,8 @@ from typing import Optional import importlib +logger = logging.getLogger("Utils") + home_dir = Path.home() # Repo root, anchored on this file (python/PiFinder/utils.py) so paths @@ -15,12 +18,162 @@ pifinder_dir = Path(__file__).resolve().parents[2] assert (pifinder_dir / "astro_data").is_dir(), f"repo root not at {pifinder_dir}" astro_data_dir = pifinder_dir / "astro_data" -tetra3_dir = pifinder_dir / "python/PiFinder/tetra3/tetra3" data_dir = Path(Path.home(), "PiFinder_data") pifinder_db = astro_data_dir / "pifinder_objects.db" observations_db = data_dir / "observations.db" +# The device's single identity file: seeded with the store path at image +# build, rewritten (with version/label/channel) by every upgrade. Human +# version labels come from the update manifest, which maps store paths to +# versions — the retired pifinder-build.json duplicated that mapping. +current_build_json = Path("/var/lib/pifinder/current-build.json") +# The booted system's own toplevel — ground truth for "what is actually +# running". current_build_json is written at upgrade time, before the reboot, +# so it can outlive a failed boot or a rollback; this symlink cannot. +running_system_link = Path("/run/current-system") + + +def sd_notify(state: str) -> None: + """Send a systemd service notification (e.g. "READY=1"). + + The app's readiness signal is what the boot watchdog's health check keys + off (pifinder.service is Type=notify). Outside systemd — development + runs, tests — NOTIFY_SOCKET is unset and this is a silent no-op; a + notification failure must never be able to break the app itself. + """ + addr = os.environ.get("NOTIFY_SOCKET") + if not addr: + return + if addr.startswith("@"): + # Abstract-namespace socket (leading @ in the env var) + addr = "\0" + addr[1:] + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: + sock.connect(addr) + sock.sendall(state.encode()) + except OSError as e: + logger.warning("sd_notify failed (harmless outside systemd): %s", e) + + +def running_system_store_path() -> Optional[str]: + """Store path the system is actually running, or None when unavailable. + + Reflects the booted generation, so it stays correct across a failed reboot, + a manual rollback, or a watchdog revert — cases where current_build_json, + written before the reboot, still names the build that was merely selected. + None off-device (no /run/current-system symlink into the Nix store). + """ + try: + if not running_system_link.is_symlink(): + return None + resolved = os.path.realpath(running_system_link) + except OSError: + return None + return resolved if resolved.startswith("/nix/store/") else None + + +def build_is_running(store_path: Optional[str]) -> bool: + """Whether store_path is the running system, directly or as the base of the + running camera specialisation. + + A camera specialisation boots /specialisation/ — a different + store path from the recorded base — so a plain equality check would flag + every specialised device as stale. When the running path can't be read + (e.g. off-device), assume a match rather than cry stale. + """ + if not store_path: + return False + running = running_system_store_path() + if running is None: + return True + if os.path.realpath(store_path) == running: + return True + spec_dir = os.path.join(store_path, "specialisation") + try: + specs = os.listdir(spec_dir) + except OSError: + return False + return any( + os.path.realpath(os.path.join(spec_dir, name)) == running for name in specs + ) + + +def _store_path_hash(store_path: str) -> str: + """The 8-char store-hash prefix of a build, or "Unknown".""" + name = store_path.rsplit("/", 1)[-1] + return name.split("-", 1)[0][:8] if name else "Unknown" + + +def get_version() -> str: + """Best available version string for the running build. + + The upgrade service writes an explicit version; a freshly-flashed image + only knows its store path, so fall back to its hash prefix (the update + screen upgrades that to a proper label once the manifest is fetched). + + current_build_json is written before the reboot, so its label can be stale + (failed boot, rollback). When it doesn't describe the running system, ignore + the label and report the running build's hash rather than assert an identity + the device isn't running. + """ + try: + with open(current_build_json, "r") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return "Unknown" + store_path = data.get("store_path") or "" + if store_path and not build_is_running(store_path): + running = running_system_store_path() + return _store_path_hash(running) if running else "Unknown" + version = data.get("version") + if version: + return version + return _store_path_hash(store_path) if store_path else "Unknown" + + debug_dump_dir = data_dir / "solver_debug_dumps" -comet_file = astro_data_dir / Path("comets.txt") +comet_file = data_dir / "comets.txt" + +# Logging-config presets ship read-only in the source tree; the user's active +# selection is persisted in the writable data dir (like config.json), stored as +# a bare filename so it survives upgrades (no immutable store path is baked in). +logconf_dir = pifinder_dir / "python" +_active_logconf_file = data_dir / "log_config" +DEFAULT_LOGCONF = "logconf_default.json" + + +def _valid_logconf_name(name: str) -> bool: + return ( + name.startswith("logconf_") + and name.endswith(".json") + and (logconf_dir / name).is_file() + ) + + +def active_logconf_name() -> str: + """Name of the active logging-config preset (defaults to logconf_default.json).""" + try: + name = _active_logconf_file.read_text().strip() + except OSError: + return DEFAULT_LOGCONF + return name if _valid_logconf_name(name) else DEFAULT_LOGCONF + + +def active_logconf_path() -> Path: + """Absolute path to the active logging-config file in the source tree.""" + return logconf_dir / active_logconf_name() + + +def available_logconfs() -> list: + """Sorted bare filenames of the available logconf_*.json presets.""" + return sorted(p.name for p in logconf_dir.glob("logconf_*.json")) + + +def set_active_logconf(name: str) -> None: + """Persist the chosen logging-config preset name to the writable data dir.""" + if not _valid_logconf_name(name): + raise ValueError(f"Invalid log config: {name}") + _active_logconf_file.parent.mkdir(parents=True, exist_ok=True) + _active_logconf_file.write_text(name + "\n") def create_dir(adir: str): @@ -167,23 +320,28 @@ def serialize_solution(solution) -> str: return json.dumps(out_dict) +_sys_utils_module = None + + def get_sys_utils(): - # Check if we should use fake sys_utils for local development - use_fake = os.environ.get("PIFINDER_USE_FAKE_SYS_UTILS", "").lower() in ( - "1", - "true", - "yes", - ) + global _sys_utils_module + if _sys_utils_module is not None: + return _sys_utils_module - if use_fake: - sys_utils = importlib.import_module("PiFinder.sys_utils_fake") - else: - try: - # Attempt to import the real sys_utils - sys_utils = importlib.import_module("PiFinder.sys_utils") - except ImportError: - sys_utils = importlib.import_module("PiFinder.sys_utils_fake") - return sys_utils + try: + _sys_utils_module = importlib.import_module("PiFinder.sys_utils") + except Exception as exc: + logger.info( + "Running without on-device system controls (%s). This is normal " + "on a desktop/dev machine: WiFi/AP/hostname/reboot options are " + "stubbed out, everything else works as usual. On the PiFinder " + "hardware these controls are available automatically.", + exc, + ) + logger.debug("PiFinder.sys_utils import failed", exc_info=True) + _sys_utils_module = importlib.import_module("PiFinder.sys_utils_fake") + + return _sys_utils_module def get_os_info(): diff --git a/python/locale/de/LC_MESSAGES/messages.mo b/python/locale/de/LC_MESSAGES/messages.mo index 167794718..1fb58ea55 100644 Binary files a/python/locale/de/LC_MESSAGES/messages.mo and b/python/locale/de/LC_MESSAGES/messages.mo differ diff --git a/python/locale/de/LC_MESSAGES/messages.po b/python/locale/de/LC_MESSAGES/messages.po index b61ee1753..891228b41 100644 --- a/python/locale/de/LC_MESSAGES/messages.po +++ b/python/locale/de/LC_MESSAGES/messages.po @@ -3126,6 +3126,20 @@ msgstr "Hochladen und wiederherstellen" msgid "Restore User Data" msgstr "Benutzerdaten wiederherstellen" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:527 +msgid "" +"Network settings updated — no restart needed. This device is now " +"reachable at http://{host}.local. If you changed the host name, the " +"previous address stops working, so reconnect there." +msgstr "" +"Netzwerkeinstellungen aktualisiert – kein Neustart nötig. Dieses Gerät ist jetzt unter http://{host}.local erreichbar. Wenn Sie den Hostnamen geändert haben, funktioniert die vorherige Adresse nicht mehr – verbinden Sie sich dort neu." + +# AI-TRANSLATED (claude): needs human review +#: views/network.html:39 +msgid "Update" +msgstr "Aktualisieren" + # AI-TRANSLATED (claude): needs human review #: views/tools.html:93 msgid "" diff --git a/python/locale/es/LC_MESSAGES/messages.mo b/python/locale/es/LC_MESSAGES/messages.mo index 3a248fd15..007483398 100644 Binary files a/python/locale/es/LC_MESSAGES/messages.mo and b/python/locale/es/LC_MESSAGES/messages.mo differ diff --git a/python/locale/es/LC_MESSAGES/messages.po b/python/locale/es/LC_MESSAGES/messages.po index acffd7ddd..549f30d7e 100644 --- a/python/locale/es/LC_MESSAGES/messages.po +++ b/python/locale/es/LC_MESSAGES/messages.po @@ -2449,6 +2449,20 @@ msgstr "ss" msgid "Enter Local Time" msgstr "Ingresar Hora Local" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:527 +msgid "" +"Network settings updated — no restart needed. This device is now " +"reachable at http://{host}.local. If you changed the host name, the " +"previous address stops working, so reconnect there." +msgstr "" +"Configuración de red actualizada: no es necesario reiniciar. Este dispositivo ahora es accesible en http://{host}.local. Si cambió el nombre de host, la dirección anterior deja de funcionar; vuelva a conectarse allí." + +# AI-TRANSLATED (claude): needs human review +#: views/network.html:39 +msgid "Update" +msgstr "Actualizar" + # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/menu_structure.py:626 msgid "Volume" diff --git a/python/locale/fr/LC_MESSAGES/messages.mo b/python/locale/fr/LC_MESSAGES/messages.mo index 228563bb7..fe1c2156a 100644 Binary files a/python/locale/fr/LC_MESSAGES/messages.mo and b/python/locale/fr/LC_MESSAGES/messages.mo differ diff --git a/python/locale/fr/LC_MESSAGES/messages.po b/python/locale/fr/LC_MESSAGES/messages.po index a30dbfae4..af932b2a3 100644 --- a/python/locale/fr/LC_MESSAGES/messages.po +++ b/python/locale/fr/LC_MESSAGES/messages.po @@ -2524,6 +2524,20 @@ msgstr "ss" msgid "Enter Local Time" msgstr "Entrez heure locale" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:527 +msgid "" +"Network settings updated — no restart needed. This device is now " +"reachable at http://{host}.local. If you changed the host name, the " +"previous address stops working, so reconnect there." +msgstr "" +"Paramètres réseau mis à jour — aucun redémarrage nécessaire. Cet appareil est maintenant accessible à l'adresse http://{host}.local. Si vous avez modifié le nom d'hôte, l'adresse précédente cesse de fonctionner ; reconnectez-vous à la nouvelle adresse." + +# AI-TRANSLATED (claude): needs human review +#: views/network.html:39 +msgid "Update" +msgstr "Mettre à jour" + # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/menu_structure.py:626 msgid "Volume" diff --git a/python/locale/zh/LC_MESSAGES/messages.mo b/python/locale/zh/LC_MESSAGES/messages.mo index 48583e909..37034b88d 100644 Binary files a/python/locale/zh/LC_MESSAGES/messages.mo and b/python/locale/zh/LC_MESSAGES/messages.mo differ diff --git a/python/locale/zh/LC_MESSAGES/messages.po b/python/locale/zh/LC_MESSAGES/messages.po index d3520abde..125a6517a 100644 --- a/python/locale/zh/LC_MESSAGES/messages.po +++ b/python/locale/zh/LC_MESSAGES/messages.po @@ -2580,6 +2580,20 @@ msgstr "ss" msgid "Enter Local Time" msgstr "输入本地时间" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:527 +msgid "" +"Network settings updated — no restart needed. This device is now " +"reachable at http://{host}.local. If you changed the host name, the " +"previous address stops working, so reconnect there." +msgstr "" +"网络设置已更新——无需重启。现在可通过 http://{host}.local 访问本设备。如果您更改了主机名,之前的地址将失效,请在新地址重新连接。" + +# AI-TRANSLATED (claude): needs human review +#: views/network.html:39 +msgid "Update" +msgstr "更新" + # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/menu_structure.py:626 msgid "Volume" diff --git a/python/noxfile.py b/python/noxfile.py deleted file mode 100644 index 6166b3f93..000000000 --- a/python/noxfile.py +++ /dev/null @@ -1,143 +0,0 @@ -import nox - -nox.options.sessions = ["lint", "format", "type_hints", "smoke_tests"] - - -@nox.session(reuse_venv=True, python="3.9") -def lint(session: nox.Session) -> None: - """ - Lint the project's codebase. - - This session installs necessary dependencies for linting and then runs the linter to check for - stylistic errors and coding standards compliance across the project's codebase. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("ruff==0.4.8") - session.run("ruff", "check", "--fix", "--config", "builtins=['_']") - - -@nox.session(reuse_venv=True, python="3.9") -def format(session: nox.Session) -> None: - """ - Format the project's codebase. - - This session installs necessary dependencies for code formatting and runs the formatter - to check (and optionally correct) the code format according to the project's style guide. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("ruff==0.4.8") - session.run("ruff", "format") - - -@nox.session(reuse_venv=True, python="3.9") -def type_hints(session: nox.Session) -> None: - """ - Check type hints in the project's codebase. - - This session installs necessary dependencies for type checking and runs a static type checker - to validate the type hints throughout the project's codebase, ensuring they are correct and consistent. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - # First run populates the cache so --install-types knows what stubs are needed. - # success_codes=[0, 1] here is expected: missing-stub errors before stubs are - # installed. The second run (with stubs) must exit 0; real type errors fail CI. - # Targets PiFinder/ explicitly to avoid broken tetra3 symlink in the tree. - session.run("mypy", "PiFinder", success_codes=[0, 1]) - session.run("mypy", "--install-types", "--non-interactive", "PiFinder") - - -@nox.session(reuse_venv=True, python="3.9") -def unit_tests(session: nox.Session) -> None: - """ - Run the project's unit tests. - - This session installs the necessary dependencies and runs the project's unit tests. - It is focused on testing the functionality of individual units of code in isolation. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - session.run("pytest", "-m", "unit") - - -@nox.session(reuse_venv=True, python="3.9") -def web_tests(session: nox.Session) -> None: - """ - Run the project's test suite on the web interface. - - This session installs the necessary dependencies and tests the web interface using Selenium. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - session.run("pytest", "-m", "web") - - -@nox.session(reuse_venv=True, python="3.9") -def smoke_tests(session: nox.Session) -> None: - """ - Run the project's smoke tests. - nox - This session installs the necessary dependencies and runs a subset of tests designed to quickly - check the most important functions of the program, often as a prelude to more thorough testing. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - session.run("pytest", "-m", "smoke") - - -@nox.session(reuse_venv=True, python="3.9") -def ui_tests(session: nox.Session) -> None: - """ - Run the UI module smoke harness (tests/test_ui_modules.py). - - Constructs every UI screen through a real MenuManager and exercises its - key_* methods (crash-only smoke). Builds the real catalogs and, for - chart/align, may download hip_main.dat on first run. Heavier and more - network-dependent than the unit suite, so it lives in its own session. - - Args: - session (nox.Session): The Nox session being run, providing context and methods for session actions. - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - session.run("pytest", "-m", "integration", "tests/test_ui_modules.py") - - -@nox.session(reuse_venv=True, python="3.9") -def babel(session: nox.Session) -> None: - """ - Run the I18N toolchain - """ - session.install("-r", "requirements.txt") - session.install("-r", "requirements_dev.txt") - - session.run( - "pybabel", - "extract", - "-F", - "babel.cfg", - "-c", - "TRANSLATORS", - "-o", - "locale/messages.pot", - "./PiFinder", - "./views", - ) - session.run("pybabel", "update", "-i", "locale/messages.pot", "-d", "locale") - session.run("pybabel", "compile", "-d", "locale") diff --git a/python/pyproject.toml b/python/pyproject.toml index ad0f02e62..d90f917c2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,4 +1,142 @@ +[project] +name = "pifinder" +version = "0.0.0" +description = "PiFinder runtime dependencies (resolved by uv, realized into the Nix store via uv2nix)" +requires-python = ">=3.13,<3.14" +dependencies = [ + # Scientific / astronomy core + "numpy", + "numpy-quaternion", + "pyerfa", + "scipy", + "scikit-learn", + "pillow", + "pandas", + "skyfield", + "timezonefinder", + "pytz", + # Plate solver (the `tetra3` import package). Pinned to a cedar-solve rev via + # [tool.uv.sources]; its stale numpy<2/Pillow<9 caps are relaxed in + # [tool.uv.dependency-metadata] so it resolves against the env's numpy 2.x. + "cedar-solve", + # Web / RPC + "grpcio", + "protobuf", + "flask", + "flask-babel", + "waitress", + "requests", + "pyjwt", + "aiofiles", + "json5", + "jsonschema", + "libarchive-c", + "tqdm", + # System / IPC bindings + "pygobject; platform_machine == 'aarch64'", # libnm bindings — device-only (dev uses sys_utils_fake) + "dbus-python; platform_machine == 'aarch64'", + "av", + "smbus2", + "spidev; platform_machine == 'aarch64'", + # sh 2.x changed the API; PiFinder targets the 1.x interface. + "sh>=1.14,<2", + "gpsdclient", + "dataclasses-json", + "pydeepskylog", + "python-pam; sys_platform == 'linux'", + # 0.3.0a0 (prerelease) is the version that builds on 3.13; its setup.py + # and .so lookups are patched in the uv2nix override (see uv-python.nix). + "python-libinput==0.3.0a0; platform_machine == 'aarch64'", + # Display + "luma-oled", + "luma-lcd", + # Hardware / camera + "rpi-gpio; platform_machine == 'aarch64'", + "rpi-hardware-pwm; platform_machine == 'aarch64'", + "adafruit-blinka; sys_platform == 'linux'", + "adafruit-circuitpython-bno055; sys_platform == 'linux'", + "adafruit-extended-bus; sys_platform == 'linux'", + "picamera2; platform_machine == 'aarch64'", + "pidng; sys_platform == 'linux'", + "simplejpeg", + "python-prctl; platform_machine == 'aarch64'", + "videodev2; sys_platform == 'linux'", +] + +[dependency-groups] +dev = [ + "pytest", + "mypy", + "luma-emulator", + # pyhotkey -> pynput -> evdev (sdist, kernel headers): only buildable where + # the uv2nix override supplies headers (the device). Dev keyboard input on + # x86 uses the pygame path instead (docs/adr/0004-pygame-keyboard...). + "pyhotkey; platform_machine == 'aarch64'", + "selenium", + # pandas reads the Steinicke .xls catalog through xlrd (test_steinicke_parsing). + "xlrd>=2.0.1", + # mypy type stubs (main tracks these in requirements_dev.txt; here they + # ride the uv dev group) + "types-pytz", + # pynput drags evdev (sdist, kernel headers) — keep it off x86 dev boxes + "pynput; platform_machine == 'aarch64'", + "types-pynput", + "types-requests", + "types-aiofiles", + "types-tqdm", + "pandas-stubs", + "types-waitress", +] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +# Dependency-only (virtual) workspace — nothing here is installed into the env +# (the PiFinder source is deployed separately via pkgs/pifinder-src.nix). Declare +# an empty package set so uv2nix's build of the root produces an empty wheel +# instead of tripping setuptools flat-layout discovery on views/locale/PiFinder. +[tool.setuptools] +packages = [] + +[tool.uv] +# Dependency-only workspace: the PiFinder source itself is deployed separately +# (pkgs/pifinder-src.nix), so uv must not try to build/install this root project. +package = false +# Deployment + dev are both Linux (aarch64 Pi, x86_64 dev); keep the lock focused. +environments = ["sys_platform == 'linux'", "sys_platform == 'darwin'"] + +# Plate solver source: pinned cedar-solve rev (smroid upstream for now; point this +# at our fork here when we need to carry patches). +[tool.uv.sources] +cedar-solve = { git = "https://github.com/smroid/cedar-solve", rev = "d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" } + +# python-libinput's setup.py imports the removed `imp` module, so uv cannot +# execute it to discover metadata. Declare it statically; the actual build is +# patched in the uv2nix override. +[[tool.uv.dependency-metadata]] +name = "python-libinput" +version = "0.3.0a0" +requires-dist = ["cffi"] + +# python-prctl's setup.py aborts without libcap headers present; it has no +# runtime Python deps. libcap is supplied by the uv2nix build override. +[[tool.uv.dependency-metadata]] +name = "python-prctl" +version = "1.8.1" +requires-dist = [] + +# cedar-solve's published metadata caps numpy<2 / Pillow<9, but those bounds are +# stale: it runs against the env's numpy 2.x / Pillow 12 (as it did when vendored +# as a git submodule). Override so uv resolves it without dragging the env back. +[[tool.uv.dependency-metadata]] +name = "cedar-solve" +version = "0.5.1" +requires-dist = ["numpy", "pillow", "scipy"] + [tool.ruff] +builtins = ["_"] + # Exclude a variety of commonly ignored directories. exclude = [ ".bzr", @@ -27,18 +165,14 @@ exclude = [ "node_modules", "site-packages", "venv", - "tetra3", ] # Same as Black. line-length = 88 indent-width = 4 -# Assume Python 3.9 -target-version = "py39" - -# _ is the i18n/gettext builtin injected at runtime -builtins = ["_"] +# Assume Python 3.13 +target-version = "py313" [tool.ruff.lint] # Enable preview mode, allow os.env changes before imports @@ -60,6 +194,7 @@ unfixable = [] dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.lint.per-file-ignores] +"*.ipynb" = ["E402", "F841"] "PiFinder/timez.py" = ["DTZ"] [tool.ruff.format] @@ -90,7 +225,7 @@ docstring-code-format = false docstring-code-line-length = "dynamic" [tool.mypy] -exclude = "venv|tetra3|conftest" +exclude = "venv|conftest" # Start off with these warn_unused_configs = true warn_redundant_casts = true @@ -123,28 +258,54 @@ extra_checks = true [[tool.mypy.overrides]] module = [ 'board', - 'adafruit_bno055', 'adafruit_bus_device.*', + 'adafruit_bno055', + 'adafruit_extended_bus', 'scipy.*', 'luma.*', 'skyfield.*', 'sh.*', 'sklearn.*', - 'pam.*', 'PyHotKey.*', - 'PiFinder.tetra3.*', - 'quaternion', - 'tetra3.*', 'grpc', 'ceder_detect_pb2', 'RPi.*', 'picamera2', 'bottle', 'libinput', + 'pytz', + 'aiofiles', + 'requests', + 'tqdm', + 'pandas', + 'rpi_hardware_pwm', + 'gpsdclient', + 'timezonefinder', + 'pydeepskylog.*', + 'dbus', + 'pam', + 'pam.*', + 'quaternion', + 'gi', + 'gi.*', + 'pynput.*', + 'waitress', + 'google.*', + 'cedar_detect_pb2', + 'cedar_detect_pb2_grpc', ] ignore_missing_imports = true ignore_errors = true +# tetra3 / cedar-solve is an external (untyped) dependency. Treat it as opaque: +# skip following its imports so mypy never analyses it or its transitive deps +# (protobuf, grpc stubs, …). Without this, `mypy --install-types` tries to +# pip-install those stubs, which fails in the pip-less Nix dev env. +[[tool.mypy.overrides]] +module = ['tetra3', 'tetra3.*'] +follow_imports = "skip" +ignore_missing_imports = true + [tool.pytest.ini_options] pythonpath = ["."] testpaths = [ diff --git a/python/requirements.txt b/python/requirements.txt deleted file mode 100644 index 796057bdf..000000000 --- a/python/requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -adafruit-blinka==8.12.0 -adafruit-circuitpython-bno055 -cheroot==10.0.0 -Flask==3.0.3 -flask-babel==4.0.0 -waitress==3.0.1 -dataclasses_json==0.6.7 -gpsdclient==1.3.2 -grpcio==1.64.1 -json5==0.9.25 -luma.oled==3.12.0 -luma.lcd==2.11.0 -numpy==1.26.4 -numpy-quaternion==2023.0.4 -pam==0.2.0 -pandas==2.0.3 -pillow==10.4.0 -pydeepskylog==1.6 -pyerfa==2.0.1.5 -pyjwt==2.8.0 -python-libinput==0.3.0a0 -pytz==2022.7.1 -requests==2.28.2 -rpi-hardware-pwm==0.1.4 -scipy -scikit-learn==1.2.2 -sh==1.14.3 -skyfield==1.45 -timezonefinder==6.1.9 -tqdm==4.65.0 -protobuf==4.25.2 -aiofiles==24.1.0 diff --git a/python/requirements_dev.txt b/python/requirements_dev.txt deleted file mode 100644 index 7e864ae70..000000000 --- a/python/requirements_dev.txt +++ /dev/null @@ -1,24 +0,0 @@ -# dev requirements -luma.emulator==1.5.0 -PyHotKey==1.5.2 -ruff==0.4.8 -nox==2024.4.15 -mypy==1.10.0 -pytest==8.2.2 -pygame==2.6.1 -pre-commit==3.7.1 -Babel==2.16.0 -xlrd==2.0.2 -selenium==4.15.0 -types-pytz==2022.7.1 -pynput -types-pynput -types-requests==2.28.2 -types-aiofiles -types-tqdm==4.65.0 -pandas-stubs -types-waitress -# Test-only: drift guard for the .pifinder JSON Schema (test_pifinder_schema.py) -jsonschema==4.23.0 -# Pin to avoid pyobjc 12.0 which has macOS 15 build issues -pyobjc-framework-Quartz==11.1; sys_platform == "darwin" diff --git a/python/result-lib b/python/result-lib new file mode 120000 index 000000000..e080bfd6d --- /dev/null +++ b/python/result-lib @@ -0,0 +1 @@ +/nix/store/8lahnh9pn3lrrnhax5nk7ibvjcbjmnkm-gcc-15.2.0-lib \ No newline at end of file diff --git a/python/scripts/migration_progress b/python/scripts/migration_progress deleted file mode 100755 index 4d87f1758..000000000 Binary files a/python/scripts/migration_progress and /dev/null differ diff --git a/python/scripts/migration_progress.c b/python/scripts/migration_progress.c deleted file mode 100644 index 3b0a37319..000000000 --- a/python/scripts/migration_progress.c +++ /dev/null @@ -1,579 +0,0 @@ -/* - * migration_progress - OLED progress display for PiFinder initramfs - * - * Drives supported SPI OLED displays to show migration progress. - * Designed to be statically compiled and included in the initramfs. - * - * Usage: migration_progress - * percent: 0-100 - * message: status text (max ~20 chars fits on screen) - * - * Examples: - * migration_progress 0 1 22 "Starting..." - * migration_progress 45 10 22 "Moving data" - * migration_progress 100 22 22 "Complete!" - * - * Hardware: SPI0.0, DC=GPIO24, RST=GPIO25, BGR565 - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define MAX_WIDTH 176 -#define MAX_HEIGHT 176 -#define SPI_DEVICE "/dev/spidev0.0" -#define SPI_SPEED 40000000 -#define GPIO_DC 24 -#define GPIO_RST 25 - -/* BGR565 colors */ -#define COL_BLACK 0x0000 -#define COL_WHITE 0xFFFF -#define COL_RED 0x001F /* BGR565: blue=0, green=0, red=31 */ -#define COL_GREEN 0x07E0 -#define COL_DKGRAY 0x4208 -#define COL_DKRED 0x0010 - -static int spi_fd = -1; -static int gpio_fd = -1; -static struct gpio_v2_line_request dc_req; -static struct gpio_v2_line_request rst_req; -static uint16_t framebuf[MAX_WIDTH * MAX_HEIGHT]; - -enum oled_controller { - CTRL_SSD1351, - CTRL_SSD1333, -}; - -static enum oled_controller controller = CTRL_SSD1351; -static int display_width = 128; -static int display_height = 128; - -/* 5x7 bitmap font - ASCII 32-126 */ -static const uint8_t font5x7[][5] = { - {0x00,0x00,0x00,0x00,0x00}, /* space */ - {0x00,0x00,0x5F,0x00,0x00}, /* ! */ - {0x00,0x07,0x00,0x07,0x00}, /* " */ - {0x14,0x7F,0x14,0x7F,0x14}, /* # */ - {0x24,0x2A,0x7F,0x2A,0x12}, /* $ */ - {0x23,0x13,0x08,0x64,0x62}, /* % */ - {0x36,0x49,0x55,0x22,0x50}, /* & */ - {0x00,0x05,0x03,0x00,0x00}, /* ' */ - {0x00,0x1C,0x22,0x41,0x00}, /* ( */ - {0x00,0x41,0x22,0x1C,0x00}, /* ) */ - {0x08,0x2A,0x1C,0x2A,0x08}, /* * */ - {0x08,0x08,0x3E,0x08,0x08}, /* + */ - {0x00,0x50,0x30,0x00,0x00}, /* , */ - {0x08,0x08,0x08,0x08,0x08}, /* - */ - {0x00,0x60,0x60,0x00,0x00}, /* . */ - {0x20,0x10,0x08,0x04,0x02}, /* / */ - {0x3E,0x51,0x49,0x45,0x3E}, /* 0 */ - {0x00,0x42,0x7F,0x40,0x00}, /* 1 */ - {0x42,0x61,0x51,0x49,0x46}, /* 2 */ - {0x21,0x41,0x45,0x4B,0x31}, /* 3 */ - {0x18,0x14,0x12,0x7F,0x10}, /* 4 */ - {0x27,0x45,0x45,0x45,0x39}, /* 5 */ - {0x3C,0x4A,0x49,0x49,0x30}, /* 6 */ - {0x01,0x71,0x09,0x05,0x03}, /* 7 */ - {0x36,0x49,0x49,0x49,0x36}, /* 8 */ - {0x06,0x49,0x49,0x29,0x1E}, /* 9 */ - {0x00,0x36,0x36,0x00,0x00}, /* : */ - {0x00,0x56,0x36,0x00,0x00}, /* ; */ - {0x00,0x08,0x14,0x22,0x41}, /* < */ - {0x14,0x14,0x14,0x14,0x14}, /* = */ - {0x41,0x22,0x14,0x08,0x00}, /* > */ - {0x02,0x01,0x51,0x09,0x06}, /* ? */ - {0x32,0x49,0x79,0x41,0x3E}, /* @ */ - {0x7E,0x11,0x11,0x11,0x7E}, /* A */ - {0x7F,0x49,0x49,0x49,0x36}, /* B */ - {0x3E,0x41,0x41,0x41,0x22}, /* C */ - {0x7F,0x41,0x41,0x22,0x1C}, /* D */ - {0x7F,0x49,0x49,0x49,0x41}, /* E */ - {0x7F,0x09,0x09,0x01,0x01}, /* F */ - {0x3E,0x41,0x41,0x51,0x32}, /* G */ - {0x7F,0x08,0x08,0x08,0x7F}, /* H */ - {0x00,0x41,0x7F,0x41,0x00}, /* I */ - {0x20,0x40,0x41,0x3F,0x01}, /* J */ - {0x7F,0x08,0x14,0x22,0x41}, /* K */ - {0x7F,0x40,0x40,0x40,0x40}, /* L */ - {0x7F,0x02,0x04,0x02,0x7F}, /* M */ - {0x7F,0x04,0x08,0x10,0x7F}, /* N */ - {0x3E,0x41,0x41,0x41,0x3E}, /* O */ - {0x7F,0x09,0x09,0x09,0x06}, /* P */ - {0x3E,0x41,0x51,0x21,0x5E}, /* Q */ - {0x7F,0x09,0x19,0x29,0x46}, /* R */ - {0x46,0x49,0x49,0x49,0x31}, /* S */ - {0x01,0x01,0x7F,0x01,0x01}, /* T */ - {0x3F,0x40,0x40,0x40,0x3F}, /* U */ - {0x1F,0x20,0x40,0x20,0x1F}, /* V */ - {0x7F,0x20,0x18,0x20,0x7F}, /* W */ - {0x63,0x14,0x08,0x14,0x63}, /* X */ - {0x03,0x04,0x78,0x04,0x03}, /* Y */ - {0x61,0x51,0x49,0x45,0x43}, /* Z */ - {0x00,0x00,0x7F,0x41,0x41}, /* [ */ - {0x02,0x04,0x08,0x10,0x20}, /* \ */ - {0x41,0x41,0x7F,0x00,0x00}, /* ] */ - {0x04,0x02,0x01,0x02,0x04}, /* ^ */ - {0x40,0x40,0x40,0x40,0x40}, /* _ */ - {0x00,0x01,0x02,0x04,0x00}, /* ` */ - {0x20,0x54,0x54,0x54,0x78}, /* a */ - {0x7F,0x48,0x44,0x44,0x38}, /* b */ - {0x38,0x44,0x44,0x44,0x20}, /* c */ - {0x38,0x44,0x44,0x48,0x7F}, /* d */ - {0x38,0x54,0x54,0x54,0x18}, /* e */ - {0x08,0x7E,0x09,0x01,0x02}, /* f */ - {0x08,0x14,0x54,0x54,0x3C}, /* g */ - {0x7F,0x08,0x04,0x04,0x78}, /* h */ - {0x00,0x44,0x7D,0x40,0x00}, /* i */ - {0x20,0x40,0x44,0x3D,0x00}, /* j */ - {0x00,0x7F,0x10,0x28,0x44}, /* k */ - {0x00,0x41,0x7F,0x40,0x00}, /* l */ - {0x7C,0x04,0x18,0x04,0x78}, /* m */ - {0x7C,0x08,0x04,0x04,0x78}, /* n */ - {0x38,0x44,0x44,0x44,0x38}, /* o */ - {0x7C,0x14,0x14,0x14,0x08}, /* p */ - {0x08,0x14,0x14,0x18,0x7C}, /* q */ - {0x7C,0x08,0x04,0x04,0x08}, /* r */ - {0x48,0x54,0x54,0x54,0x20}, /* s */ - {0x04,0x3F,0x44,0x40,0x20}, /* t */ - {0x3C,0x40,0x40,0x20,0x7C}, /* u */ - {0x1C,0x20,0x40,0x20,0x1C}, /* v */ - {0x3C,0x40,0x30,0x40,0x3C}, /* w */ - {0x44,0x28,0x10,0x28,0x44}, /* x */ - {0x0C,0x50,0x50,0x50,0x3C}, /* y */ - {0x44,0x64,0x54,0x4C,0x44}, /* z */ - {0x00,0x08,0x36,0x41,0x00}, /* { */ - {0x00,0x00,0x7F,0x00,0x00}, /* | */ - {0x00,0x41,0x36,0x08,0x00}, /* } */ - {0x08,0x08,0x2A,0x1C,0x08}, /* ~ */ -}; - -static void msleep(int ms) -{ - struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L }; - nanosleep(&ts, NULL); -} - -static int gpio_request_line(int chip_fd, int pin, struct gpio_v2_line_request *req) -{ - struct gpio_v2_line_request r = {0}; - r.offsets[0] = pin; - r.num_lines = 1; - r.config.flags = GPIO_V2_LINE_FLAG_OUTPUT; - snprintf(r.consumer, sizeof(r.consumer), "migration"); - - if (ioctl(chip_fd, GPIO_V2_GET_LINE_IOCTL, &r) < 0) { - perror("GPIO_V2_GET_LINE_IOCTL"); - return -1; - } - *req = r; - return 0; -} - -static void gpio_set(struct gpio_v2_line_request *req, int value) -{ - struct gpio_v2_line_values vals = {0}; - vals.bits = value ? 1 : 0; - vals.mask = 1; - ioctl(req->fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals); -} - -static void spi_write(const uint8_t *data, size_t len) -{ - /* Chunk large transfers - SPI driver may limit to 4KB */ - const size_t chunk_size = 4096; - while (len > 0) { - size_t this_len = len > chunk_size ? chunk_size : len; - struct spi_ioc_transfer tr = {0}; - tr.tx_buf = (unsigned long)data; - tr.len = this_len; - tr.speed_hz = SPI_SPEED; - tr.bits_per_word = 8; - ioctl(spi_fd, SPI_IOC_MESSAGE(1), &tr); - data += this_len; - len -= this_len; - } -} - -static void oled_cmd(uint8_t cmd) -{ - gpio_set(&dc_req, 0); - spi_write(&cmd, 1); -} - -static void oled_data(const uint8_t *data, size_t len) -{ - gpio_set(&dc_req, 1); - spi_write(data, len); -} - -static void oled_cmd_data(uint8_t cmd, uint8_t data) -{ - oled_cmd(cmd); - oled_data(&data, 1); -} - -static int display_on = 0; - -static void detect_display(void) -{ - const char *display_class = getenv("MIGRATION_DISPLAY_CLASS"); - const char *display_resolution = getenv("MIGRATION_DISPLAY_RESOLUTION"); - - controller = CTRL_SSD1351; - display_width = 128; - display_height = 128; - - if (display_class && strstr(display_class, "SSD1333")) { - controller = CTRL_SSD1333; - display_width = 176; - display_height = 176; - return; - } - - if (display_resolution && strcmp(display_resolution, "176x176") == 0) { - controller = CTRL_SSD1333; - display_width = 176; - display_height = 176; - } -} - -static void oled_reset(void) -{ - gpio_set(&rst_req, 1); - msleep(10); - gpio_set(&rst_req, 0); - msleep(10); - gpio_set(&rst_req, 1); - msleep(10); -} - -static void oled_common_init(int mux_ratio, uint8_t remap) -{ - oled_reset(); - - /* SSD13xx 65k-color OLED setup. */ - oled_cmd_data(0xFD, 0x12); /* Unlock */ - oled_cmd_data(0xFD, 0xB1); /* Unlock commands */ - - oled_cmd(0xAE); /* Display off */ - oled_cmd_data(0xB3, 0xF1); /* Clock divider */ - oled_cmd_data(0xCA, (uint8_t)mux_ratio); /* Mux ratio */ - - oled_cmd(0x15); /* Column address */ - uint8_t col[2] = {0x00, (uint8_t)(display_width - 1)}; - oled_data(col, 2); - - oled_cmd(0x75); /* Row address */ - uint8_t row[2] = {0x00, (uint8_t)(display_height - 1)}; - oled_data(row, 2); - - oled_cmd_data(0xA0, remap); /* Remap/color depth */ - oled_cmd_data(0xA1, 0x00); /* Start line */ - oled_cmd_data(0xA2, 0x00); /* Display offset */ - oled_cmd_data(0xB5, 0x00); /* GPIO */ - oled_cmd_data(0xAB, 0x01); /* Function select */ - oled_cmd_data(0xB1, 0x32); /* Precharge */ - - oled_cmd(0xB4); /* VSL */ - uint8_t vsl[3] = {0xA0, 0xB5, 0x55}; - oled_data(vsl, 3); - - oled_cmd_data(0xBE, 0x05); /* VCOMH */ - oled_cmd_data(0xC7, 0x0F); /* Master contrast */ - oled_cmd_data(0xB6, 0x01); /* Precharge2 */ - oled_cmd(0xA6); /* Normal display */ - - /* Display ON (0xAF) happens after first framebuffer flush. */ -} - -static void oled_init(void) -{ - if (controller == CTRL_SSD1333) - oled_common_init(0xAF, 0x74); - else - oled_common_init(0x7F, 0x74); -} - -static void oled_flush(void) -{ - /* Set contrast before first frame (matching luma) */ - if (!display_on) { - oled_cmd(0xC1); /* Contrast */ - uint8_t contrast[3] = {0xFF, 0xFF, 0xFF}; - oled_data(contrast, 3); - } - - oled_cmd(0x15); - uint8_t col[2] = {0x00, (uint8_t)(display_width - 1)}; - oled_data(col, 2); - - oled_cmd(0x75); - uint8_t row[2] = {0x00, (uint8_t)(display_height - 1)}; - oled_data(row, 2); - - oled_cmd(0x5C); /* Write RAM */ - - /* Send framebuffer as big-endian 16-bit pixels */ - uint8_t buf[MAX_WIDTH * MAX_HEIGHT * 2]; - int pixels = display_width * display_height; - for (int i = 0; i < pixels; i++) { - buf[i * 2] = framebuf[i] >> 8; - buf[i * 2 + 1] = framebuf[i] & 0xFF; - } - oled_data(buf, (size_t)pixels * 2); - - /* Turn display on after first frame */ - if (!display_on) { - oled_cmd(0xAF); /* Display on */ - display_on = 1; - } -} - -static void fb_clear(uint16_t color) -{ - for (int i = 0; i < display_width * display_height; i++) - framebuf[i] = color; -} - -static void fb_pixel(int x, int y, uint16_t color) -{ - if (x >= 0 && x < display_width && y >= 0 && y < display_height) - framebuf[y * display_width + x] = color; -} - -static void fb_rect(int x, int y, int w, int h, uint16_t color) -{ - for (int j = y; j < y + h && j < display_height; j++) - for (int i = x; i < x + w && i < display_width; i++) - fb_pixel(i, j, color); -} - -static void fb_char(int x, int y, char c, uint16_t color, int scale) -{ - if (c < 32 || c > 126) - c = '?'; - const uint8_t *glyph = font5x7[c - 32]; - for (int col = 0; col < 5; col++) { - uint8_t bits = glyph[col]; - for (int row = 0; row < 7; row++) { - if (bits & (1 << row)) { - for (int sy = 0; sy < scale; sy++) - for (int sx = 0; sx < scale; sx++) - fb_pixel(x + col * scale + sx, - y + row * scale + sy, color); - } - } - } -} - -static void fb_string(int x, int y, const char *s, uint16_t color, int scale) -{ - int cx = x; - while (*s) { - fb_char(cx, y, *s, color, scale); - cx += 6 * scale; /* 5px char + 1px gap */ - s++; - } -} - -/* Center a string horizontally */ -static void fb_string_centered(int y, const char *s, uint16_t color, int scale) -{ - int len = strlen(s); - int px_width = len * 6 * scale - scale; /* subtract trailing gap */ - int x = (display_width - px_width) / 2; - if (x < 0) x = 0; - fb_string(x, y, s, color, scale); -} - -/* Center a string, shrinking the scale (and finally truncating) so it always - * fits the display width. Used for the variable-length stage name. */ -static void fb_string_centered_fit(int y, const char *s, uint16_t color, int max_scale) -{ - int len = strlen(s); - for (int scale = max_scale; scale >= 1; scale--) { - if (len * 6 * scale - scale <= display_width) { - fb_string_centered(y, s, color, scale); - return; - } - } - /* Too long even at scale 1: render a head that fits. */ - int max_chars = (display_width + 1) / 6; - char buf[64]; - if (max_chars > (int)sizeof(buf) - 1) - max_chars = (int)sizeof(buf) - 1; - int n = len < max_chars ? len : max_chars; - memcpy(buf, s, (size_t)n); - buf[n] = '\0'; - fb_string_centered(y, buf, color, 1); -} - -static void draw_progress(int percent, const char *stage, int stage_num, int stage_total) -{ - if (percent < 0) percent = 0; - if (percent > 100) percent = 100; - - fb_clear(COL_BLACK); - - int scale = display_width >= 160 ? 2 : 1; - int margin = display_width >= 160 ? 14 : 10; - int banner_h = display_width >= 160 ? 18 : 12; - int title_y = display_width >= 160 ? 28 : 18; - int subtitle_y = display_width >= 160 ? 55 : 38; - int stage_y = display_width >= 160 ? 76 : 52; - int bar_y = display_width >= 160 ? 94 : 65; - int pct_y = display_width >= 160 ? 116 : 82; - int stage_name_y = display_width >= 160 ? 145 : 105; - int wait_y = display_height - (8 * scale); - - /* Warning banner at top: solid bright bar with black text for contrast */ - fb_rect(0, 0, display_width, banner_h, COL_RED); - fb_string_centered(3, "DO NOT POWER OFF", COL_BLACK, scale); - - /* Title */ - fb_string_centered(title_y, "NixOS", COL_RED, 2); - fb_string_centered(subtitle_y, "Migration", COL_RED, scale); - - /* Stage indicator (e.g., "3/7") */ - if (stage_total > 0) { - char stage_str[32]; - snprintf(stage_str, sizeof(stage_str), "Stage %d/%d", stage_num, stage_total); - fb_string_centered(stage_y, stage_str, COL_DKGRAY, scale); - } - - /* Progress bar */ - int bar_x = margin; - int bar_w = display_width - (margin * 2); - int bar_h = display_width >= 160 ? 16 : 12; - - /* Border */ - fb_rect(bar_x, bar_y, bar_w, 1, COL_DKGRAY); - fb_rect(bar_x, bar_y + bar_h - 1, bar_w, 1, COL_DKGRAY); - fb_rect(bar_x, bar_y, 1, bar_h, COL_DKGRAY); - fb_rect(bar_x + bar_w - 1, bar_y, 1, bar_h, COL_DKGRAY); - - /* Fill */ - int fill_w = (bar_w - 4) * percent / 100; - if (fill_w > 0) - fb_rect(bar_x + 2, bar_y + 2, fill_w, bar_h - 4, COL_RED); - - /* Dark red background for unfilled */ - int unfill_x = bar_x + 2 + fill_w; - int unfill_w = (bar_w - 4) - fill_w; - if (unfill_w > 0) - fb_rect(unfill_x, bar_y + 2, unfill_w, bar_h - 4, COL_DKRED); - - /* Percentage */ - char pct_str[8]; - snprintf(pct_str, sizeof(pct_str), "%d%%", percent); - fb_string_centered(pct_y, pct_str, COL_RED, 2); - - /* Current stage name */ - if (stage && *stage) - fb_string_centered_fit(stage_name_y, stage, COL_RED, scale); - - /* Bottom warning */ - fb_string_centered(wait_y, "Please wait...", COL_DKGRAY, scale); - - oled_flush(); -} - -static int hw_init(void) -{ - /* Open SPI */ - spi_fd = open(SPI_DEVICE, O_RDWR); - if (spi_fd < 0) { - perror("open spi"); - return -1; - } - - uint8_t mode = SPI_MODE_0; - uint8_t bits = 8; - uint32_t speed = SPI_SPEED; - ioctl(spi_fd, SPI_IOC_WR_MODE, &mode); - ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &bits); - ioctl(spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed); - - /* Open GPIO chip */ - gpio_fd = open("/dev/gpiochip0", O_RDWR); - if (gpio_fd < 0) { - perror("open gpiochip0"); - return -1; - } - - if (gpio_request_line(gpio_fd, GPIO_DC, &dc_req) < 0) - return -1; - if (gpio_request_line(gpio_fd, GPIO_RST, &rst_req) < 0) - return -1; - - detect_display(); - oled_init(); - return 0; -} - -static void hw_cleanup(void) -{ - if (dc_req.fd > 0) close(dc_req.fd); - if (rst_req.fd > 0) close(rst_req.fd); - if (gpio_fd >= 0) close(gpio_fd); - if (spi_fd >= 0) close(spi_fd); -} - -/* Persistent mode: initialise the panel once, then redraw in place for each - * " " line read from stdin. - * Because the panel is never reset between frames, the display updates without - * blanking to black. Returns on EOF (writer closed). */ -static void serve_loop(void) -{ - char line[256]; - while (fgets(line, sizeof(line), stdin)) { - int percent = 0, stage_num = 0, stage_total = 0, name_off = 0; - if (sscanf(line, "%d %d %d %n", &percent, &stage_num, &stage_total, - &name_off) < 3) - continue; - char *name = line + name_off; - size_t len = strlen(name); - while (len > 0 && (name[len - 1] == '\n' || name[len - 1] == '\r')) - name[--len] = '\0'; - draw_progress(percent, name, stage_num, stage_total); - } -} - -int main(int argc, char *argv[]) -{ - int serve = (argc >= 2 && strcmp(argv[1], "--serve") == 0); - - if (!serve && argc < 5) { - fprintf(stderr, "Usage: %s --serve\n", argv[0]); - fprintf(stderr, " read ' '\n"); - fprintf(stderr, " lines from stdin and redraw in place (no flicker)\n"); - fprintf(stderr, " or: %s \n", argv[0]); - fprintf(stderr, " draw a single frame and exit\n"); - fprintf(stderr, "\nExample: %s 50 3 7 'Extracting system'\n", argv[0]); - return 1; - } - - if (hw_init() < 0) { - fprintf(stderr, "Hardware init failed\n"); - hw_cleanup(); - return 1; - } - - if (serve) { - serve_loop(); - } else { - draw_progress(atoi(argv[1]), argv[4], atoi(argv[2]), atoi(argv[3])); - } - - hw_cleanup(); - return 0; -} diff --git a/python/scripts/nixos_migration_calc.py b/python/scripts/nixos_migration_calc.py deleted file mode 100755 index d3500bdc7..000000000 --- a/python/scripts/nixos_migration_calc.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -""" -Pre-flight checks for NixOS migration. - -Validates hardware requirements before migration can proceed. -Run on the Pi to verify it meets minimum specs. - -Usage: python3 nixos_migration_calc.py [--json] --display-class CLASS --display-resolution WxH -""" - -import argparse -import json -import platform -import re -import shutil -import subprocess -import sys -from pathlib import Path - -MIN_RAM_MB = 1800 # 2GB Pi reports ~1849MB due to GPU memory reservation -MIN_SD_GB = 16 -REQUIRED_MODEL = "Raspberry Pi 4" -# Must match the initramfs progress renderer, not just the main PiFinder UI. -SUPPORTED_DISPLAYS = { - "DisplaySSD1351": "128x128", - "DisplaySSD1333": "176x176", -} -# The initramfs script hardcodes these paths and unconditionally extends -# partition 2 to fill the disk. Migration only supports the stock layout. -SD_DISK = "/dev/mmcblk0" -EXPECTED_BOOT = "/dev/mmcblk0p1" -EXPECTED_ROOT = "/dev/mmcblk0p2" -EXPECTED_PARTITION_COUNT = 2 - - -def get_model() -> str: - """Read Pi model from device-tree.""" - try: - return Path("/proc/device-tree/model").read_text().rstrip("\x00").strip() - except OSError: - return "Unknown" - - -def get_ram_mb() -> int: - """Get total RAM in MB from /proc/meminfo.""" - try: - text = Path("/proc/meminfo").read_text() - match = re.search(r"MemTotal:\s+(\d+)\s+kB", text) - if match: - return int(match.group(1)) // 1024 - except OSError: - pass - return 0 - - -def get_sd_size_gb() -> float: - """Get SD card size in GB (root device).""" - try: - result = subprocess.run( - ["lsblk", "-b", "-d", "-n", "-o", "SIZE", "/dev/mmcblk0"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return int(result.stdout.strip()) / (1024**3) - except (OSError, ValueError): - pass - return 0.0 - - -def get_free_space_gb(path: str = "/home/pifinder") -> float: - """Get free space in GB at the given path.""" - try: - usage = shutil.disk_usage(path) - return usage.free / (1024**3) - except OSError: - return 0.0 - - -def get_root_source() -> str: - """Device backing the / mount, e.g. /dev/mmcblk0p2.""" - try: - result = subprocess.run( - ["findmnt", "-no", "SOURCE", "/"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return result.stdout.strip() - except OSError: - pass - return "" - - -def get_partition_count(disk: str = SD_DISK) -> int: - """Number of partitions on the SD disk node (excludes the disk itself).""" - try: - result = subprocess.run( - ["lsblk", "-no", "NAME", "-l", disk], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return 0 - lines = [line for line in result.stdout.splitlines() if line.strip()] - return max(len(lines) - 1, 0) - except OSError: - return 0 - - -def get_wifi_mode() -> str: - """Detect WiFi mode.""" - wifi_status = Path("/home/pifinder/PiFinder/wifi_status.txt") - try: - return wifi_status.read_text().strip() - except OSError: - return "Unknown" - - -def normalize_resolution(value: str) -> str: - """Normalize a live UI resolution string to WIDTHxHEIGHT.""" - match = re.fullmatch(r"\s*(\d+)\s*[x,]\s*(\d+)\s*", value) - if not match: - return value.strip() - return f"{int(match.group(1))}x{int(match.group(2))}" - - -def is_pi4() -> bool: - """Check if running on a Raspberry Pi 4.""" - model = get_model() - return REQUIRED_MODEL in model - - -def check_all(display_class: str = "", display_resolution: str = "") -> dict: - """Run all pre-flight checks. Returns dict with results.""" - model = get_model() - ram_mb = get_ram_mb() - sd_gb = get_sd_size_gb() - free_gb = get_free_space_gb() - wifi = get_wifi_mode() - display_class = display_class.strip() or "Unknown" - display_resolution = normalize_resolution(display_resolution) or "Unknown" - display_ok = SUPPORTED_DISPLAYS.get(display_class) == display_resolution - display_ok = display_ok or ( - "SSD1333" in display_class and display_resolution == "176x176" - ) - root_source = get_root_source() - partition_count = get_partition_count() - boot_present = Path(EXPECTED_BOOT).is_block_device() - layout_ok = ( - root_source == EXPECTED_ROOT - and boot_present - and partition_count == EXPECTED_PARTITION_COUNT - ) - - checks = { - "model": model, - "is_pi4": REQUIRED_MODEL in model, - "ram_mb": ram_mb, - "ram_ok": ram_mb >= MIN_RAM_MB, - "sd_gb": round(sd_gb, 1), - "sd_ok": sd_gb >= MIN_SD_GB, - "free_gb": round(free_gb, 1), - "free_ok": free_gb >= 1.5, - "wifi_mode": wifi, - "wifi_ok": wifi == "Client", - "display_class": display_class, - "display_resolution": display_resolution, - "display_ok": display_ok, - "root_source": root_source, - "partition_count": partition_count, - "boot_present": boot_present, - "layout_ok": layout_ok, - "arch": platform.machine(), - } - checks["all_ok"] = all( - [ - checks["is_pi4"], - checks["ram_ok"], - checks["sd_ok"], - checks["free_ok"], - checks["wifi_ok"], - checks["display_ok"], - checks["layout_ok"], - ] - ) - return checks - - -def main(): - parser = argparse.ArgumentParser(description="NixOS migration pre-flight checks") - parser.add_argument("--json", action="store_true", help="Output as JSON") - parser.add_argument( - "--display-class", - default="", - help="Live PiFinder display class name from the running UI", - ) - parser.add_argument( - "--display-resolution", - default="", - help="Live PiFinder logical display resolution as WIDTHxHEIGHT", - ) - args = parser.parse_args() - - checks = check_all(args.display_class, args.display_resolution) - - if args.json: - print(json.dumps(checks, indent=2)) - sys.exit(0 if checks["all_ok"] else 1) - - print(f"Model: {checks['model']}") - print(f" Pi 4: {'OK' if checks['is_pi4'] else 'FAIL'}") - print(f"RAM: {checks['ram_mb']} MB") - print(f" >= {MIN_RAM_MB}MB: {'OK' if checks['ram_ok'] else 'FAIL'}") - print(f"SD Card: {checks['sd_gb']} GB") - print(f" >= {MIN_SD_GB}GB: {'OK' if checks['sd_ok'] else 'FAIL'}") - print(f"Free Space: {checks['free_gb']} GB") - print(f" >= 1.5GB: {'OK' if checks['free_ok'] else 'FAIL'}") - print(f"WiFi Mode: {checks['wifi_mode']}") - print(f" Client: {'OK' if checks['wifi_ok'] else 'FAIL'}") - print(f"Display: {checks['display_class']} {checks['display_resolution']}") - print( - f" initramfs renderer supported: " - f"{'OK' if checks['display_ok'] else 'FAIL'}" - ) - print(f"Root: {checks['root_source'] or 'Unknown'}") - print(f"Partitions: {checks['partition_count']} on {SD_DISK}") - print( - f" stock SD layout ({EXPECTED_BOOT} + {EXPECTED_ROOT}, 2 partitions): " - f"{'OK' if checks['layout_ok'] else 'FAIL'}" - ) - print(f"Arch: {checks['arch']}") - print() - if checks["all_ok"]: - print("All checks PASSED - migration can proceed") - else: - print("Some checks FAILED - migration cannot proceed") - - sys.exit(0 if checks["all_ok"] else 1) - - -if __name__ == "__main__": - main() diff --git a/python/tests/test_build_identity.py b/python/tests/test_build_identity.py new file mode 100644 index 000000000..7ec10f702 --- /dev/null +++ b/python/tests/test_build_identity.py @@ -0,0 +1,110 @@ +import json +import os + +import pytest + +from PiFinder import utils + + +def _write_build(tmp_path, monkeypatch, **fields): + f = tmp_path / "current-build.json" + f.write_text(json.dumps(fields)) + monkeypatch.setattr(utils, "current_build_json", f) + return f + + +def _fake_running(monkeypatch, store_path): + """Pretend the booted system resolves to store_path (None = off-device).""" + monkeypatch.setattr(utils, "running_system_store_path", lambda: store_path) + + +@pytest.mark.unit +class TestGetVersion: + def test_missing_file_is_unknown(self, tmp_path, monkeypatch): + monkeypatch.setattr(utils, "current_build_json", tmp_path / "nope.json") + assert utils.get_version() == "Unknown" + + def test_label_returned_when_build_is_running(self, tmp_path, monkeypatch): + store = "/nix/store/abc12345-nixos-system-pifinder" + _write_build(tmp_path, monkeypatch, store_path=store, version="PR362-005abc") + _fake_running(monkeypatch, store) + assert utils.get_version() == "PR362-005abc" + + def test_off_device_trusts_the_label(self, tmp_path, monkeypatch): + # No /run/current-system to compare against — don't cry stale. + _write_build( + tmp_path, monkeypatch, store_path="/nix/store/x-nixos-system", version="v1" + ) + _fake_running(monkeypatch, None) + assert utils.get_version() == "v1" + + def test_stale_label_falls_back_to_running_hash(self, tmp_path, monkeypatch): + # current-build.json names the selected build; the device booted another. + _write_build( + tmp_path, + monkeypatch, + store_path="/nix/store/selected00-nixos-system-pifinder", + version="PR362-005abc", + ) + _fake_running(monkeypatch, "/nix/store/running99-nixos-system-pifinder") + # Never assert the label the device isn't running; report the real build. + assert utils.get_version() == "running9" + + def test_stale_and_no_running_path_is_unknown(self, tmp_path, monkeypatch): + _write_build( + tmp_path, + monkeypatch, + store_path="/nix/store/selected00-nixos-system", + version="PR362-005abc", + ) + # build_is_running -> False (mismatch), running unknown -> Unknown, not a lie. + monkeypatch.setattr(utils, "build_is_running", lambda p: False) + _fake_running(monkeypatch, None) + assert utils.get_version() == "Unknown" + + +@pytest.mark.unit +class TestBuildIsRunning: + def test_direct_match(self, monkeypatch): + store = "/nix/store/base00-nixos-system-pifinder" + _fake_running(monkeypatch, store) + assert utils.build_is_running(store) is True + + def test_mismatch(self, monkeypatch): + _fake_running(monkeypatch, "/nix/store/other-nixos-system") + assert utils.build_is_running("/nix/store/base00-nixos-system") is False + + def test_none_running_assumes_match(self, monkeypatch): + _fake_running(monkeypatch, None) + assert utils.build_is_running("/nix/store/base00-nixos-system") is True + + def test_empty_store_path_is_false(self, monkeypatch): + _fake_running(monkeypatch, "/nix/store/x") + assert utils.build_is_running("") is False + assert utils.build_is_running(None) is False + + def test_running_camera_specialisation_matches_base(self, tmp_path, monkeypatch): + # The device boots /specialisation/, a distinct store path; + # the recorded base must still count as running. + base = tmp_path / "base-nixos-system" + spec_target = tmp_path / "specialised00-nixos-system" + spec_target.mkdir() + (base / "specialisation").mkdir(parents=True) + os.symlink(spec_target, base / "specialisation" / "imx462") + _fake_running(monkeypatch, os.path.realpath(spec_target)) + assert utils.build_is_running(str(base)) is True + + +@pytest.mark.unit +class TestRunningSystemStorePath: + def test_none_when_not_a_symlink(self, tmp_path, monkeypatch): + monkeypatch.setattr(utils, "running_system_link", tmp_path / "absent") + assert utils.running_system_store_path() is None + + def test_none_when_not_a_store_path(self, tmp_path, monkeypatch): + target = tmp_path / "somewhere" + target.mkdir() + link = tmp_path / "current-system" + os.symlink(target, link) + monkeypatch.setattr(utils, "running_system_link", link) + assert utils.running_system_store_path() is None diff --git a/python/tests/test_focus.py b/python/tests/test_focus.py index cb730a454..6747446eb 100644 --- a/python/tests/test_focus.py +++ b/python/tests/test_focus.py @@ -61,6 +61,14 @@ def test_saturated_core_is_finite(self): assert np.isfinite(hfd) assert hfd > 0.0 + def test_gaussian_fwhm_matches_theory(self): + sigma = 4.0 + img = _gaussian_frame(sigma, amplitude=200.0, background=10.0, noise=0.0) + blob = focus.detect_stars(img, n=1)[0] + fwhm = focus.full_width_half_maximum(img, blob) + expected = 2.0 * np.sqrt(2.0 * np.log(2.0)) * sigma + assert fwhm == pytest.approx(expected, rel=0.15) + def test_no_flux_returns_zero(self): img = np.full((128, 128), 30.0, dtype=np.float32) hfd = focus.half_flux_diameter(img, (64, 64), 30.0, aperture_radius=20) @@ -96,6 +104,77 @@ def test_returns_at_most_n(self): blobs = focus.detect_stars(img, n=3) assert len(blobs) == 3 + def test_centroid_is_flux_weighted_not_bounding_box_center(self): + img = np.zeros((128, 128), dtype=np.float32) + img[63:66, 62:65] = 40.0 + img[63:66, 65] = 220.0 + + blob = focus.detect_stars(img, sigma_k=3.0, n=1)[0] + + # The connected component spans x=62..65, whose box center is 63.5. + # The brighter right edge must pull the measured stellar centroid right. + assert blob.x > 64.0 + assert blob.y == pytest.approx(64.0, abs=0.1) + + +@pytest.mark.unit +class TestTrackBlobs: + @staticmethod + def _blob(x, y, peak): + return focus.Blob( + x=x, + y=y, + peak=peak, + background=10.0, + extent=8, + size_px=20, + ) + + def test_shared_translation_preserves_slots_when_brightness_order_changes(self): + previous = ( + self._blob(80, 90, 240), + self._blob(400, 100, 230), + self._blob(100, 390, 220), + self._blob(410, 400, 210), + ) + shift_x, shift_y = 47, -31 + translated = [ + self._blob(blob.x + shift_x, blob.y + shift_y, 100 + index * 30) + for index, blob in enumerate(previous) + ] + distractor = self._blob(250, 250, 255) + candidates = tuple( + sorted((*translated, distractor), key=lambda blob: blob.peak, reverse=True) + ) + + tracked = focus.track_blobs(previous, candidates) + + assert [(blob.x, blob.y) for blob in tracked] == [ + (blob.x + shift_x, blob.y + shift_y) for blob in previous + ] + + def test_missing_star_is_replaced_without_reordering_survivors(self): + previous = ( + self._blob(80, 90, 240), + self._blob(400, 100, 230), + self._blob(100, 390, 220), + self._blob(410, 400, 210), + ) + replacement = self._blob(250, 250, 205) + candidates = ( + self._blob(110, 70, 180), + self._blob(430, 80, 250), + self._blob(440, 380, 190), + replacement, + ) + + tracked = focus.track_blobs(previous, candidates) + + assert (tracked[0].x, tracked[0].y) == (110, 70) + assert (tracked[1].x, tracked[1].y) == (430, 80) + assert tracked[2] is replacement + assert (tracked[3].x, tracked[3].y) == (440, 380) + @pytest.mark.unit class TestFocusHfd: @@ -113,11 +192,14 @@ def test_oversized_blob_is_too_defocused(self): assert result.median_hfd is None assert result.n_used == 0 assert result.too_defocused is True + assert len(result.blobs) == 1 + assert result.blobs[0].extent > 50 def test_measures_clear_star(self): img = _gaussian_frame(4.0, amplitude=200.0, background=20.0) result = focus.focus_hfd(img) assert result.median_hfd is not None + assert result.median_fwhm is not None assert result.n_used >= 1 assert result.too_defocused is False expected = 2.0 * 4.0 * np.sqrt(np.pi / 2.0) @@ -137,3 +219,17 @@ def test_median_robust_to_outlier(self): result = focus.focus_hfd(img, n=5) tight_hfd = 2.0 * 3.0 * np.sqrt(np.pi / 2.0) assert result.median_hfd == pytest.approx(tight_hfd, rel=0.4) + + def test_display_blobs_are_brightest_first(self): + rng = np.random.default_rng(9) + img = rng.normal(20.0, 1.0, (512, 512)).astype(np.float32) + y, x = np.ogrid[:512, :512] + for amplitude, (cy, cx) in zip( + (80.0, 180.0, 120.0, 220.0), + ((100, 100), (100, 400), (400, 100), (400, 400)), + ): + img += amplitude * np.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (2 * 4.0**2)) + result = focus.focus_hfd(np.clip(img, 0, 255)) + peaks = [blob.peak for blob in result.blobs] + assert len(peaks) == 4 + assert peaks == sorted(peaks, reverse=True) diff --git a/python/tests/test_focus_preview.py b/python/tests/test_focus_preview.py new file mode 100644 index 000000000..72330591b --- /dev/null +++ b/python/tests/test_focus_preview.py @@ -0,0 +1,539 @@ +"""Unit tests for the raw, four-star Focus screen renderer.""" + +import time +from collections import deque +from itertools import cycle +from types import SimpleNamespace + +import numpy as np +import pytest +from PIL import Image, ImageDraw + +from PiFinder.ui import preview as preview_module +from PiFinder.displays import DisplayBase, Layout176, Layout320 +from PiFinder.focus import Blob, FocusResult +from PiFinder.ui.preview import ( + DISPLAY_IMAGE, + DISPLAY_SINGLE, + DISPLAY_STARS, + DISPLAY_STATS, + FOCUS_NOMINAL_ZOOM, + UIPreview, + focus_crop_size, +) + + +def _blob(*, x=256.0, y=256.0, peak=200.0, extent=8) -> Blob: + return Blob( + x=x, + y=y, + peak=peak, + background=10.0, + extent=extent, + size_px=max(2, extent * extent), + ) + + +@pytest.mark.unit +def test_focused_star_uses_calculated_ten_x_crop(): + crop_w, crop_h = focus_crop_size((512, 512), (64, 64), blob_extent=8) + assert (crop_w, crop_h) == (26, 26) + effective_zoom = 512 / (2 * crop_h) + assert effective_zoom == pytest.approx(FOCUS_NOMINAL_ZOOM, rel=0.03) + + +@pytest.mark.unit +def test_defocused_star_reduces_zoom_instead_of_clipping(): + focused = focus_crop_size((512, 512), (64, 64), blob_extent=8) + defocused = focus_crop_size((512, 512), (64, 64), blob_extent=40) + assert defocused[0] == defocused[1] + assert defocused[0] > focused[0] + assert defocused[0] >= 40 * 1.35 + + +@pytest.mark.unit +def test_crop_matches_rectangular_tile_aspect_ratio(): + crop_w, crop_h = focus_crop_size((512, 512), (160, 120), blob_extent=8) + assert crop_w / crop_h == pytest.approx(4 / 3, rel=0.03) + + +@pytest.mark.unit +def test_display_uses_four_brightest_visual_blobs(): + preview = object.__new__(UIPreview) + preview.last_focus_result = FocusResult( + median_hfd=8.0, + n_used=4, + background=10.0, + peak=250.0, + too_defocused=False, + blobs=tuple( + _blob(x=x, y=y, peak=peak, extent=extent) + for x, y, peak, extent in ( + (100, 100, 250, 8), + (120, 120, 245, 8), + (400, 100, 240, 9), + (100, 400, 230, 10), + (400, 400, 220, 11), + ) + ), + ) + assert [blob.peak for blob in preview._display_blobs()] == [250, 245, 240, 230] + + +@pytest.mark.unit +@pytest.mark.parametrize("layout", (DisplayBase, Layout176, Layout320)) +def test_quadrants_are_centered_below_title_bar_on_every_layout(layout): + preview = object.__new__(UIPreview) + preview.display_class = SimpleNamespace( + resolution=layout.resolution, + titlebar_height=layout.titlebar_height, + resY=layout.resolution[1], + ) + + content_top = preview.display_class.titlebar_height + 1 + boxes = preview._tile_boxes() + top_height = boxes[0][3] - boxes[0][1] + bottom_height = boxes[2][3] - boxes[2][1] + + assert boxes[0][1] == content_top + assert abs(top_height - bottom_height) <= 1 + assert ( + preview._focus_center()[1] + == content_top + (preview.display_class.resY - content_top) // 2 + ) + + +@pytest.mark.unit +def test_renderer_preserves_raw_luminance_values(): + preview = object.__new__(UIPreview) + preview.focus_zoom = FOCUS_NOMINAL_ZOOM + preview.display_class = SimpleNamespace(resolution=(128, 128), titlebar_height=17) + preview.colors = SimpleNamespace( + red_image=Image.new("RGB", (128, 128), (255, 0, 0)) + ) + preview.last_focus_result = FocusResult( + median_hfd=8.0, + n_used=1, + background=0.0, + peak=200.0, + too_defocused=False, + blobs=(_blob(x=128, y=128),), + ) + + # A discrete-valued frame makes any stretch, filtering, or interpolating + # resize visible as newly invented values. + raw = np.zeros((512, 512), dtype=np.uint8) + raw[240:272, 240:272] = np.tile( + np.array([20, 80, 140, 200], dtype=np.uint8), (32, 8) + ) + rendered = np.asarray(preview._render_focus_tiles(Image.fromarray(raw))) + + assert set(np.unique(rendered[:, :, 0])) <= {0, 20, 80, 140, 200} + assert np.all(rendered[:, :, 1:] == 0) + + +@pytest.mark.unit +def test_edge_star_crop_contains_only_source_frame_pixels(): + preview = object.__new__(UIPreview) + preview.focus_zoom = FOCUS_NOMINAL_ZOOM + preview.display_class = SimpleNamespace(resolution=(128, 128), titlebar_height=17) + preview.colors = SimpleNamespace( + red_image=Image.new("RGB", (128, 128), (255, 0, 0)) + ) + preview.last_focus_result = FocusResult( + median_hfd=8.0, + n_used=4, + background=0.0, + peak=240.0, + too_defocused=False, + blobs=( + _blob(x=128, y=128, peak=240), + _blob(x=384, y=128, peak=230), + _blob(x=128, y=384, peak=220), + _blob(x=511, y=511, peak=210), + ), + ) + + rendered = np.asarray( + preview._render_focus_tiles( + Image.fromarray(np.full((512, 512), 73, dtype=np.uint8)) + ) + ) + + content_top = preview.display_class.titlebar_height + 1 + assert np.all(rendered[:content_top, :, 0] == 0) + assert np.all(rendered[content_top:, :, 0] == 73) + assert np.all(rendered[:, :, 1:] == 0) + + +@pytest.mark.unit +def test_image_renderer_uses_original_display_autocontrast(): + preview = object.__new__(UIPreview) + preview.display_class = SimpleNamespace(resolution=(128, 128)) + preview.colors = SimpleNamespace( + red_image=Image.new("RGB", (128, 128), (255, 0, 0)) + ) + raw = np.tile( + np.repeat(np.array([20, 70, 120, 200], dtype=np.uint8), 128), (512, 1) + ) + rendered = np.asarray(preview._render_image_frame(Image.fromarray(raw))) + assert set(np.unique(rendered[:, :, 0])) == {0, 70, 141, 255} + assert np.all(rendered[:, :, 1:] == 0) + + +@pytest.mark.unit +def test_single_star_renderer_preserves_brightest_raw_crop(monkeypatch): + preview = object.__new__(UIPreview) + preview.focus_zoom = FOCUS_NOMINAL_ZOOM + preview.display_class = SimpleNamespace(resolution=(128, 128)) + preview.colors = SimpleNamespace( + red_image=Image.new("RGB", (128, 128), (255, 0, 0)) + ) + preview.last_focus_result = FocusResult( + median_hfd=6.0, + n_used=2, + background=0.0, + peak=220.0, + too_defocused=False, + blobs=( + _blob(x=128, y=128, peak=220), + _blob(x=384, y=384, peak=200), + ), + ) + raw = np.zeros((512, 512), dtype=np.uint8) + raw[102:154, 102:154] = 73 + raw[358:410, 358:410] = 149 + + nominal_zooms = [] + original_focus_crop_size = preview_module.focus_crop_size + + def recording_focus_crop_size(*args, **kwargs): + nominal_zooms.append(args[3]) + return original_focus_crop_size(*args, **kwargs) + + monkeypatch.setattr(preview_module, "focus_crop_size", recording_focus_crop_size) + rendered = np.asarray(preview._render_brightest_star(Image.fromarray(raw))) + + assert set(np.unique(rendered[:, :, 0])) <= {73} + assert np.all(rendered[:, :, 1:] == 0) + assert nominal_zooms == [FOCUS_NOMINAL_ZOOM] + + +@pytest.mark.unit +def test_focus_modes_follow_standard_square_cycle_order(): + assert UIPreview._display_mode_list == [ + DISPLAY_STARS, + DISPLAY_SINGLE, + DISPLAY_IMAGE, + DISPLAY_STATS, + ] + + preview = object.__new__(UIPreview) + preview._display_mode_cycle = cycle(UIPreview._display_mode_list) + preview.display_mode = next(preview._display_mode_cycle) + redraws = [] + preview.update = lambda force=False: redraws.append(force) + + preview.key_square() + assert preview.display_mode == DISPLAY_SINGLE + preview.key_square() + assert preview.display_mode == DISPLAY_IMAGE + preview.key_square() + assert preview.display_mode == DISPLAY_STATS + preview.key_square() + assert preview.display_mode == DISPLAY_STARS + assert redraws == [True, True, True, True] + + +@pytest.mark.unit +def test_zoom_controls_apply_to_magnified_star_views_only(): + preview = object.__new__(UIPreview) + preview.focus_zoom = FOCUS_NOMINAL_ZOOM + preview.display_mode = DISPLAY_STARS + redraws = [] + preview.update = lambda force=False: redraws.append(force) + + preview.key_plus() + assert preview.focus_zoom == FOCUS_NOMINAL_ZOOM + 2 + + preview.display_mode = DISPLAY_IMAGE + preview.key_minus() + assert preview.focus_zoom == FOCUS_NOMINAL_ZOOM + 2 + preview.display_mode = DISPLAY_SINGLE + preview.key_minus() + assert preview.focus_zoom == FOCUS_NOMINAL_ZOOM + assert redraws == [True, True] + + +@pytest.mark.unit +def test_single_star_readout_stays_in_translucent_lower_third(): + preview = object.__new__(UIPreview) + preview.display_class = DisplayBase() + preview.colors = preview.display_class.colors + preview.fonts = preview.display_class.fonts + preview.screen = Image.new("RGB", preview.display_class.resolution, (100, 0, 0)) + preview.draw = ImageDraw.Draw(preview.screen, mode="RGBA") + preview.last_focus_result = FocusResult( + median_hfd=5.0, + n_used=1, + background=10.0, + peak=220.0, + too_defocused=False, + ) + preview.focus_history = deque() + + preview._draw_single_focus_overlay() + + pixels = np.asarray(preview.screen) + overlay_top = int(np.ceil(preview.display_class.resY * 2 / 3)) + assert np.all(pixels[:overlay_top, :, 0] == 100) + assert np.any((pixels[overlay_top:, :, 0] > 0) & (pixels[overlay_top:, :, 0] < 100)) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("result", "expected"), + [ + (None, "?.?"), + ( + FocusResult( + median_hfd=None, + n_used=0, + background=20.0, + peak=None, + too_defocused=False, + ), + "?.?", + ), + ( + FocusResult( + median_hfd=None, + n_used=0, + background=20.0, + peak=255.0, + too_defocused=True, + ), + "?.?", + ), + ( + FocusResult( + median_hfd=5.25, + n_used=1, + background=20.0, + peak=220.0, + too_defocused=False, + ), + "5.2", + ), + ], +) +def test_focus_readout_uses_question_marks_when_hfd_is_unavailable(result, expected): + preview = object.__new__(UIPreview) + preview.last_focus_result = result + + assert preview._focus_readout_text() == expected + assert ">50" not in preview._focus_readout_text() + + +@pytest.mark.unit +def test_history_gap_has_equal_blank_pixels_from_rendered_outline(monkeypatch): + preview = object.__new__(UIPreview) + preview.display_class = DisplayBase() + preview.colors = preview.display_class.colors + preview.fonts = preview.display_class.fonts + preview.screen = Image.new("RGB", preview.display_class.resolution) + preview.draw = ImageDraw.Draw(preview.screen, mode="RGBA") + preview.last_focus_result = FocusResult( + median_hfd=6.1, + n_used=4, + background=20.0, + peak=220.0, + too_defocused=False, + ) + captured_gap = [] + monkeypatch.setattr( + preview, + "_draw_focus_history", + lambda center_y, gap_left, gap_right: captured_gap.append( + (center_y, gap_left, gap_right) + ), + ) + + preview._draw_focus_overlay() + + center = preview._focus_center() + mask = Image.new("1", preview.display_class.resolution) + mask_draw = ImageDraw.Draw(mask) + mask_draw.text( + center, + "6.1", + font=preview.fonts.large.font, + fill=1, + anchor="mm", + stroke_width=1, + stroke_fill=1, + ) + ink_box = mask.getbbox() + _, gap_left, gap_right = captured_gap[0] + left_blank_pixels = ink_box[0] - gap_left - 1 + right_blank_pixels = gap_right - ink_box[2] + assert left_blank_pixels == right_blank_pixels == 3 + + +@pytest.mark.unit +def test_hfd_history_runs_on_both_sides_of_center_readout(): + preview = object.__new__(UIPreview) + preview.display_class = DisplayBase() + preview.colors = preview.display_class.colors + preview.fonts = preview.display_class.fonts + preview.screen = Image.new("RGB", preview.display_class.resolution) + preview.draw = ImageDraw.Draw(preview.screen, mode="RGBA") + preview.last_focus_result = FocusResult( + median_hfd=7.5, + n_used=4, + background=20.0, + peak=220.0, + too_defocused=False, + ) + now = time.time() + preview.focus_history = deque( + [ + (now - 9, 12.0), + (now - 7, 10.0), + (now - 3, 8.5), + (now - 1, 7.5), + ] + ) + + preview._draw_focus_overlay() + + red = np.asarray(preview.screen)[:, :, 0] + center_y = preview._focus_center()[1] + band = slice(center_y - 12, center_y + 13) + assert np.any(red[band, :45] == 255) + assert np.any(red[band, 83:] == 255) + + +@pytest.mark.unit +def test_hfd_signal_survives_gap_and_reappears_on_first_measurement(monkeypatch): + preview = object.__new__(UIPreview) + preview.display_class = DisplayBase() + preview.colors = preview.display_class.colors + preview.screen = Image.new("RGB", preview.display_class.resolution) + preview.draw = ImageDraw.Draw(preview.screen) + start = time.time() + preview.focus_history = deque([(start, 6.0)]) + preview._record_focus_sample(None) + assert list(preview.focus_history) == [(start, 6.0)] + + monkeypatch.setattr(preview_module.time, "time", lambda: start + 11) + preview._record_focus_sample(4.5) + assert list(preview.focus_history) == [(start + 11, 4.5)] + + preview._draw_focus_history(preview.display_class.centerY, 52, 76) + + red = np.asarray(preview.screen)[:, :, 0] + assert np.count_nonzero(red[:, 76:] == 255) >= 2 + + +@pytest.mark.unit +def test_hfd_signal_recedes_when_no_new_measurements_arrive(monkeypatch): + preview = object.__new__(UIPreview) + preview.display_class = DisplayBase() + preview.colors = preview.display_class.colors + preview.focus_history = deque( + [(92.0, 5.0), (94.0, 5.0), (96.0, 5.0), (98.0, 5.0), (100.0, 5.0)] + ) + + def rightmost_signal(now: float) -> int: + monkeypatch.setattr(preview_module.time, "time", lambda: now) + preview.screen = Image.new("RGB", preview.display_class.resolution) + preview.draw = ImageDraw.Draw(preview.screen) + preview._draw_focus_history(preview.display_class.centerY, 52, 76) + _y, x = np.where(np.asarray(preview.screen)[:, :, 0] == 255) + return int(x.max()) + + at_last_measurement = rightmost_signal(100.0) + assert rightmost_signal(105.0) < at_last_measurement + + +@pytest.mark.unit +def test_hfd_signal_disappears_after_history_window(monkeypatch): + preview = object.__new__(UIPreview) + preview.display_class = DisplayBase() + preview.colors = preview.display_class.colors + preview.screen = Image.new("RGB", preview.display_class.resolution) + preview.draw = ImageDraw.Draw(preview.screen) + preview.focus_history = deque( + [(92.0, 5.0), (94.0, 5.0), (96.0, 5.0), (98.0, 5.0), (100.0, 5.0)] + ) + monkeypatch.setattr(preview_module.time, "time", lambda: 111.0) + + preview._draw_focus_history(preview.display_class.centerY, 52, 76) + + assert not preview.focus_history + assert np.asarray(preview.screen).max() == 0 + + +@pytest.mark.unit +def test_stats_renderer_draws_metrics_and_histogram(): + preview = object.__new__(UIPreview) + preview.display_class = DisplayBase() + preview.colors = preview.display_class.colors + preview.fonts = preview.display_class.fonts + preview.screen = Image.new("RGB", preview.display_class.resolution) + preview.draw = ImageDraw.Draw(preview.screen, mode="RGBA") + preview.config_object = SimpleNamespace(get_option=lambda name: "auto") + preview.last_focus_result = FocusResult( + median_hfd=8.2, + median_fwhm=6.4, + n_used=4, + background=20.0, + peak=220.0, + too_defocused=False, + blobs=tuple(_blob(peak=220 - index * 10) for index in range(6)), + ) + raw = np.tile(np.arange(256, dtype=np.uint8), (512, 2)) + + preview._draw_stats(raw, {"exposure_time": 500_000, "gain": 2.0}) + + assert preview.screen.getbbox() is not None + # The standard title bar is painted after this renderer; stats must not + # place the large HFD glyph underneath it. + pixels = np.asarray(preview.screen) + assert pixels[: preview.display_class.titlebar_height].max() == 0 + + top = preview.display_class.titlebar_height + 4 + stats_y = top + preview.fonts.huge.height + plots_top = stats_y + 2 * (preview.fonts.small.height + 1) + 1 + label_box = preview.draw.textbbox( + (2, plots_top), "RAW HIST", font=preview.fonts.small.font + ) + assert pixels[label_box[3] : label_box[3] + 2].max() == 0 + + +@pytest.mark.unit +def test_stats_hfd_uses_question_marks_when_measurement_is_unavailable(monkeypatch): + preview = object.__new__(UIPreview) + preview.display_class = DisplayBase() + preview.colors = preview.display_class.colors + preview.fonts = preview.display_class.fonts + preview.screen = Image.new("RGB", preview.display_class.resolution) + preview.draw = ImageDraw.Draw(preview.screen, mode="RGBA") + preview.config_object = SimpleNamespace(get_option=lambda name: "auto") + preview.last_focus_result = None + drawn_text = [] + original_text = preview.draw.text + + def recording_text(xy, text, *args, **kwargs): + drawn_text.append(text) + return original_text(xy, text, *args, **kwargs) + + monkeypatch.setattr(preview.draw, "text", recording_text) + preview._draw_stats( + np.zeros((512, 512), dtype=np.uint8), + {"exposure_time": None, "gain": None}, + ) + + assert "?.?" in drawn_text + assert ">50" not in drawn_text diff --git a/python/tests/test_gps_ubx_parser.py b/python/tests/test_gps_ubx_parser.py index c4265d7ea..a803f061f 100644 --- a/python/tests/test_gps_ubx_parser.py +++ b/python/tests/test_gps_ubx_parser.py @@ -49,22 +49,24 @@ def test_svinfo_field_alignment(parser): @pytest.mark.unit -def test_svinfo_only_code_locked_counted_as_seen(parser): - # Idle channels (e.g. SBAS) and cold-start acquisition candidates - # (quality < 4 with an estimated cno) must not inflate the seen count. +def test_svinfo_seen_needs_acquired_signal(parser): + # Search candidates (quality 1, estimated cno) and idle channels must + # not inflate the seen count, but satellites being acquired (quality + # 2-3) must count so the display climbs instead of flapping to zero + # during marginal re-acquisition. payload = make_svinfo_payload( [ - (0, 14, 0x0D, 4, 26, 30, 90), - (7, 25, 0x00, 2, 9, 0, 0), - (11, 120, 0x10, 1, 0, 0, 0), - (5, 193, 0x10, 1, 0, 0, 0), + (0, 14, 0x0D, 4, 26, 30, 90), # code locked, used + (7, 25, 0x00, 2, 9, 0, 0), # signal acquired: seen + (3, 30, 0x00, 1, 12, 0, 0), # search candidate: not seen + (11, 120, 0x10, 1, 0, 0, 0), # idle SBAS channel: not seen ] ) result = parser._parse_nav_svinfo(payload) - assert result["nSat"] == 1 + assert result["nSat"] == 2 assert result["uSat"] == 1 - assert result["satellites"][0]["id"] == 14 + assert [s["id"] for s in result["satellites"]] == [14, 25] @pytest.mark.unit @@ -88,7 +90,7 @@ def test_nav_sat_used_from_svused_bit(parser): [ (0, 17, 27, 45, 180, 0x0C), # quality 4, used (0, 13, 15, -5, 300, 0x04), # quality 4, tracked but not used - (0, 25, 9, 0, 0, 0x02), # acquisition candidate: not seen + (0, 25, 9, 0, 0, 0x01), # search candidate: not seen (6, 3, 0, 0, 0, 0x01), # searching, no signal: not seen ] ) diff --git a/python/tests/test_hardware_detect.py b/python/tests/test_hardware_detect.py index 72acb546d..3b4b61a7d 100644 --- a/python/tests/test_hardware_detect.py +++ b/python/tests/test_hardware_detect.py @@ -1,10 +1,10 @@ """ Unit tests for hardware_detect. -The I2C bus is faked: a stand-in ``board`` whose ``I2C().scan()`` returns -a chosen address list, so the BQ25895 presence probe can be exercised -both ways without real hardware. The no-blinka path (board is None) must -degrade to all-False capabilities. +The I2C bus is faked: a stand-in ``get_i2c`` factory whose bus ``scan()`` +returns a chosen address list, so the BQ25895 presence probe can be +exercised both ways without real hardware. The no-blinka path (get_i2c is +None) must degrade to all-False capabilities. """ import pytest @@ -29,46 +29,47 @@ def scan(self): return list(self._addresses) -class FakeBoard: - """Stand-in for the ``board`` module: ``I2C()`` returns a FakeI2C.""" +def fake_get_i2c(addresses): + """Stand-in for ``i2c_bus.get_i2c``: returns a FakeI2C factory.""" - def __init__(self, addresses): - self._addresses = addresses + def factory(): + return FakeI2C(addresses) - def I2C(self): - return FakeI2C(self._addresses) + return factory @pytest.mark.unit def test_i2c_present_true(monkeypatch): - monkeypatch.setattr(hardware_detect, "board", FakeBoard([0x28, BQ25895_ADDRESS])) + monkeypatch.setattr( + hardware_detect, "get_i2c", fake_get_i2c([0x28, BQ25895_ADDRESS]) + ) assert hardware_detect.i2c_present(BQ25895_ADDRESS) is True @pytest.mark.unit def test_i2c_present_false(monkeypatch): - monkeypatch.setattr(hardware_detect, "board", FakeBoard([0x28, 0x77])) + monkeypatch.setattr(hardware_detect, "get_i2c", fake_get_i2c([0x28, 0x77])) assert hardware_detect.i2c_present(BQ25895_ADDRESS) is False @pytest.mark.unit def test_detect_capabilities_present(monkeypatch): - monkeypatch.setattr(hardware_detect, "board", FakeBoard([BQ25895_ADDRESS])) + monkeypatch.setattr(hardware_detect, "get_i2c", fake_get_i2c([BQ25895_ADDRESS])) caps = hardware_detect.detect_capabilities() assert caps.has_bq25895 is True @pytest.mark.unit def test_detect_capabilities_absent(monkeypatch): - monkeypatch.setattr(hardware_detect, "board", FakeBoard([0x28])) + monkeypatch.setattr(hardware_detect, "get_i2c", fake_get_i2c([0x28])) caps = hardware_detect.detect_capabilities() assert caps.has_bq25895 is False @pytest.mark.unit def test_detect_capabilities_no_blinka(monkeypatch): - """No blinka / no bus (board is None) -> all-False, no exception.""" - monkeypatch.setattr(hardware_detect, "board", None) + """No blinka / no bus (get_i2c is None) -> all-False, no exception.""" + monkeypatch.setattr(hardware_detect, "get_i2c", None) caps = hardware_detect.detect_capabilities() assert caps.has_bq25895 is False # The raw probe surfaces the failure; detect_capabilities swallows it. diff --git a/python/tests/test_net_policy.py b/python/tests/test_net_policy.py new file mode 100644 index 000000000..d9f0ecde3 --- /dev/null +++ b/python/tests/test_net_policy.py @@ -0,0 +1,135 @@ +import pytest + +from PiFinder.net_policy_core import ( + AP_DOWN, + AP_UP, + CLIENT_RETRY_SECONDS, + GRACE_SECONDS, + PolicyState, + Snapshot, + decide, +) + + +def _snap(**overrides): + snap = Snapshot( + forced_ap=False, + eth_connected=False, + wifi_client_active=False, + ap_active=False, + ap_stations=0, + ) + for key, value in overrides.items(): + setattr(snap, key, value) + return snap + + +@pytest.mark.unit +class TestForcedAp: + def test_brings_ap_up(self): + assert decide(_snap(forced_ap=True), PolicyState(), 100.0) == AP_UP + + def test_noop_when_already_up(self): + snap = _snap(forced_ap=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + def test_forced_ap_wins_over_ethernet(self): + snap = _snap(forced_ap=True, eth_connected=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + +@pytest.mark.unit +class TestWiredPriority: + def test_ap_dropped_when_wired(self): + snap = _snap(eth_connected=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) == AP_DOWN + + def test_noop_when_wired_and_no_ap(self): + assert decide(_snap(eth_connected=True), PolicyState(), 100.0) is None + + def test_wired_resets_grace_timer(self): + state = PolicyState(disconnected_since=50.0) + decide(_snap(eth_connected=True), state, 100.0) + assert state.disconnected_since is None + + +@pytest.mark.unit +class TestWifiClient: + def test_noop_when_client_connected(self): + snap = _snap(wifi_client_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + def test_client_resets_grace_timer(self): + state = PolicyState(disconnected_since=50.0) + decide(_snap(wifi_client_active=True), state, 100.0) + assert state.disconnected_since is None + + +@pytest.mark.unit +class TestGraceFallback: + def test_no_immediate_ap(self): + state = PolicyState() + assert decide(_snap(), state, 100.0) is None + assert state.disconnected_since == 100.0 + + def test_ap_up_after_grace(self): + state = PolicyState() + decide(_snap(), state, 100.0) + assert decide(_snap(), state, 100.0 + GRACE_SECONDS) == AP_UP + + def test_no_ap_before_grace(self): + state = PolicyState() + decide(_snap(), state, 100.0) + assert decide(_snap(), state, 100.0 + GRACE_SECONDS - 1) is None + + def test_reconnect_within_grace_cancels_fallback(self): + state = PolicyState() + decide(_snap(), state, 100.0) + decide(_snap(wifi_client_active=True), state, 110.0) + # Disconnect again much later: the grace clock must restart. + assert decide(_snap(), state, 500.0) is None + assert state.disconnected_since == 500.0 + + +@pytest.mark.unit +class TestIdleApClientRetry: + def _aged_state(self, now): + """State as it looks after the AP has been up for a while.""" + return PolicyState(last_client_retry=now - CLIENT_RETRY_SECONDS) + + def test_idle_ap_retries_client(self): + now = 1000.0 + snap = _snap(ap_active=True, ap_stations=0) + assert decide(snap, self._aged_state(now), now) == AP_DOWN + + def test_occupied_ap_never_dropped(self): + now = 1000.0 + snap = _snap(ap_active=True, ap_stations=2) + assert decide(snap, self._aged_state(now), now) is None + + def test_no_retry_before_interval(self): + now = 1000.0 + state = PolicyState(last_client_retry=now - CLIENT_RETRY_SECONDS + 5) + assert decide(_snap(ap_active=True), state, now) is None + + def test_first_observation_arms_but_does_not_retry(self): + # An externally-activated AP (e.g. at boot) must not be dropped on + # the daemon's first look; the retry clock starts then. + state = PolicyState() + assert decide(_snap(ap_active=True), state, 1000.0) is None + assert state.last_client_retry == 1000.0 + + def test_retry_then_fallback_restores_ap(self): + # Full cycle: idle AP dropped for a retry, no client appears, + # grace expires, AP comes back. + now = 1000.0 + state = self._aged_state(now) + assert decide(_snap(ap_active=True, ap_stations=0), state, now) == AP_DOWN + assert decide(_snap(), state, now + GRACE_SECONDS) == AP_UP + + def test_retry_then_client_joins(self): + now = 1000.0 + state = self._aged_state(now) + assert decide(_snap(ap_active=True, ap_stations=0), state, now) == AP_DOWN + assert decide(_snap(wifi_client_active=True), state, now + 10) is None + assert state.disconnected_since is None diff --git a/python/tests/test_nixos_migration_wifi.py b/python/tests/test_nixos_migration_wifi.py deleted file mode 100644 index 9b621fb45..000000000 --- a/python/tests/test_nixos_migration_wifi.py +++ /dev/null @@ -1,321 +0,0 @@ -import re - -import pytest - -from PiFinder.nixos_migration_wifi import ( - Network, - _parse_ssid, - build_keyfile, - emit_keyfiles, - escape_keyfile_value, - parse_wpa_supplicant_conf, - sanitize_filename, - ssid_to_bytelist, -) - - -UUID_V4_RE = re.compile( - r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" -) - - -@pytest.mark.unit -class TestParseWpaSupplicantConf: - def test_empty(self): - assert parse_wpa_supplicant_conf("") == [] - - def test_single_wpa_network(self): - conf = """ - ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev - update_config=1 - country=BE - - network={ - ssid="APME" - psk="hunter12" - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("APME", "hunter12")] - - def test_open_network_has_no_psk(self): - conf = """ - network={ - ssid="OpenNet" - key_mgmt=NONE - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("OpenNet", None)] - - def test_multiple_networks_preserve_order(self): - conf = """ - network={ - ssid="first" - psk="pw1" - } - network={ - ssid="second" - psk="pw2" - } - network={ - ssid="third" - psk="pw3" - } - """ - nets = parse_wpa_supplicant_conf(conf) - assert [n.ssid for n in nets] == ["first", "second", "third"] - - def test_hex_psk_preserved_verbatim(self): - hex_psk = "a" * 64 - conf = f""" - network={{ - ssid="HexPsk" - psk={hex_psk} - }} - """ - result = parse_wpa_supplicant_conf(conf) - assert result == [Network("HexPsk", hex_psk)] - - def test_unquoted_ssid_kept(self): - # Some configs use unquoted SSIDs for short alphanumerics. - conf = """ - network={ - ssid=plain - psk="pw" - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("plain", "pw")] - - def test_ssid_with_special_chars(self): - conf = """ - network={ - ssid="0x20" - psk="pw" - } - network={ - ssid="hackerspace.gent" - psk="pw2" - } - """ - nets = parse_wpa_supplicant_conf(conf) - assert nets == [ - Network("0x20", "pw"), - Network("hackerspace.gent", "pw2"), - ] - - def test_network_without_ssid_skipped(self): - conf = """ - network={ - psk="orphan" - } - network={ - ssid="valid" - psk="pw" - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("valid", "pw")] - - def test_comments_and_blank_lines_ignored(self): - conf = """ - # global comment - ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev - - network={ - # inner comment - ssid="commented" - psk="pw" # trailing comment - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("commented", "pw")] - - -@pytest.mark.unit -class TestSsidToBytelist: - def test_ascii(self): - assert ssid_to_bytelist("APME") == "65;80;77;69;" - - def test_bytes_are_decimal(self): - # The whole point of this function — NM only parses DECIMAL byte - # lists; hex (with or without 0x prefix) is silently kept as a - # literal-string SSID and the network name is mangled. - assert ssid_to_bytelist("apollo") == "97;112;111;108;108;111;" - - def test_utf8_bytes(self): - # SSID can contain non-ASCII; should be encoded as utf-8 bytes - assert ssid_to_bytelist("é") == "195;169;" - - def test_empty(self): - assert ssid_to_bytelist("") == "" - - def test_non_utf8_bytes_round_trip(self): - # A hex wpa ssid holding non-UTF-8 bytes survives parse -> encode. - assert ssid_to_bytelist(_parse_ssid("ff00")) == "255;0;" - - -@pytest.mark.unit -class TestParseSsidValue: - def test_quoted_is_plain_string(self): - assert _parse_ssid('"apollo"') == "apollo" - - def test_unquoted_hex_is_decoded(self): - # wpa_supplicant stores SSIDs with special characters as unquoted - # hex strings; these must be decoded, not used as the name. - assert _parse_ssid("61706f6c6c6f") == "apollo" - - def test_quoted_hex_lookalike_stays_verbatim(self): - # A network genuinely NAMED like a hex string is quoted in - # wpa_supplicant, so it must not be decoded. - assert _parse_ssid('"61706f"') == "61706f" - - def test_non_hex_unquoted_stays_verbatim(self): - assert _parse_ssid("abc") == "abc" - - def test_hex_ssid_end_to_end(self): - conf = """ - network={ - ssid=61706f6c6c6f - psk="hunter12" - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("apollo", "hunter12")] - - -@pytest.mark.unit -class TestEscapeKeyfileValue: - def test_plain(self): - assert escape_keyfile_value("hunter12") == "hunter12" - - def test_semicolon_escaped(self): - assert escape_keyfile_value("a;b") == "a\\;b" - - def test_backslash_escaped(self): - assert escape_keyfile_value("a\\b") == "a\\\\b" - - def test_backslash_before_semicolon(self): - # Backslash must be escaped first so we don't double-escape the - # semicolon escape sequence we just produced. - assert escape_keyfile_value("\\;") == "\\\\\\;" - - -@pytest.mark.unit -class TestSanitizeFilename: - def test_plain(self): - assert sanitize_filename("APME") == "APME" - - def test_dot_preserved(self): - assert sanitize_filename("hackerspace.gent") == "hackerspace.gent" - - def test_slash_replaced(self): - assert sanitize_filename("a/b") == "a_b" - - def test_pathy_chars_replaced(self): - assert sanitize_filename("../etc/passwd") == ".._etc_passwd" - - def test_empty_becomes_wifi(self): - assert sanitize_filename("") == "wifi" - - def test_dot_becomes_wifi(self): - assert sanitize_filename(".") == "wifi" - - def test_dotdot_becomes_wifi(self): - assert sanitize_filename("..") == "wifi" - - -@pytest.mark.unit -class TestBuildKeyfile: - def test_contains_required_sections(self): - body = build_keyfile("APME", "hunter12", connection_uuid="fixed-uuid") - assert "[connection]" in body - assert "[wifi]" in body - assert "[wifi-security]" in body - assert "[ipv4]" in body - assert "[ipv6]" in body - - def test_uuid_present(self): - body = build_keyfile("APME", "pw", connection_uuid="abc-123") - assert "uuid=abc-123" in body - - def test_uuid_generated_when_not_provided(self): - body = build_keyfile("APME", "pw") - match = re.search(r"^uuid=(.+)$", body, re.MULTILINE) - assert match - assert UUID_V4_RE.match(match.group(1)) - - def test_ssid_encoded_as_decimal_bytelist(self): - body = build_keyfile("APME", "pw", connection_uuid="x") - assert "ssid=65;80;77;69;" in body - - def test_open_network_omits_security(self): - body = build_keyfile("OpenNet", None, connection_uuid="x") - assert "[wifi-security]" not in body - assert "key-mgmt" not in body - assert "psk=" not in body - - def test_empty_psk_treated_as_open(self): - body = build_keyfile("OpenNet", "", connection_uuid="x") - assert "[wifi-security]" not in body - - def test_psk_with_semicolon_escaped(self): - body = build_keyfile("S", "p;w", connection_uuid="x") - assert "psk=p\\;w" in body - - def test_psk_with_backslash_escaped(self): - body = build_keyfile("S", "p\\w", connection_uuid="x") - assert "psk=p\\\\w" in body - - def test_ipv4_method_auto(self): - body = build_keyfile("S", "pw", connection_uuid="x") - assert "[ipv4]\nmethod=auto" in body - - def test_id_uses_ssid(self): - body = build_keyfile("MyNet", "pw", connection_uuid="x") - assert "id=MyNet" in body - - -@pytest.mark.unit -class TestEmitKeyfiles: - def test_writes_one_file_per_network(self, tmp_path): - nets = [Network("a", "pw"), Network("b", None), Network("c", "pw3")] - written = emit_keyfiles(nets, tmp_path) - assert len(written) == 3 - assert {p.name for p in written} == { - "a.nmconnection", - "b.nmconnection", - "c.nmconnection", - } - - def test_file_mode_is_600(self, tmp_path): - emit_keyfiles([Network("a", "pw")], tmp_path) - mode = (tmp_path / "a.nmconnection").stat().st_mode & 0o777 - assert mode == 0o600 - - def test_creates_output_dir(self, tmp_path): - target = tmp_path / "nested" / "wifi" - emit_keyfiles([Network("a", "pw")], target) - assert (target / "a.nmconnection").exists() - - def test_collision_suffix(self, tmp_path): - # Two SSIDs that sanitize to the same filename - nets = [Network("a/b", "pw"), Network("a.b", "pw")] - # sanitize: "a/b" -> "a_b", "a.b" -> "a.b" (different) — pick another pair - nets = [Network("a/b", "pw"), Network("a;b", "pw")] - # Both sanitize to "a_b" - written = emit_keyfiles(nets, tmp_path) - names = sorted(p.name for p in written) - assert names == ["a_b.nmconnection", "a_b_2.nmconnection"] - - def test_each_file_gets_unique_uuid(self, tmp_path): - nets = [Network("a", "pw"), Network("b", "pw")] - emit_keyfiles(nets, tmp_path) - u1 = (tmp_path / "a.nmconnection").read_text() - u2 = (tmp_path / "b.nmconnection").read_text() - uuid1 = re.search(r"^uuid=(.+)$", u1, re.MULTILINE).group(1) - uuid2 = re.search(r"^uuid=(.+)$", u2, re.MULTILINE).group(1) - assert uuid1 != uuid2 - assert UUID_V4_RE.match(uuid1) - assert UUID_V4_RE.match(uuid2) - - def test_deterministic_with_injected_uuid(self, tmp_path): - nets = [Network("a", "pw")] - emit_keyfiles(nets, tmp_path, uuid_factory=lambda: "fixed-uuid-1234") - body = (tmp_path / "a.nmconnection").read_text() - assert "uuid=fixed-uuid-1234" in body diff --git a/python/tests/test_nixos_upgrade.py b/python/tests/test_nixos_upgrade.py new file mode 100644 index 000000000..4cd344b96 --- /dev/null +++ b/python/tests/test_nixos_upgrade.py @@ -0,0 +1,361 @@ +import json +import urllib.error + +import pytest + +from PiFinder import nixos_upgrade + + +STORE = "/nix/store/abc123-nixos-system-pifinder" + + +class _FakeResp: + def __init__(self, status): + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _fake_urlopen(outcomes): + """Build a urlopen stub that returns/raises one outcome per cache probe. + + Each outcome is either an int HTTP status (-> a response) or an Exception + instance to raise (a 404 HTTPError, a URLError, etc.). + """ + calls = iter(outcomes) + + def _open(url, timeout=None): + outcome = next(calls) + if isinstance(outcome, Exception): + raise outcome + return _FakeResp(outcome) + + return _open + + +def _http_error(code): + return urllib.error.HTTPError( + url="https://cache/abc.narinfo", code=code, msg="x", hdrs=None, fp=None + ) + + +@pytest.mark.unit +def test_valid_store_path_rejects_non_store_refs(): + assert nixos_upgrade.valid_store_path(STORE) + assert not nixos_upgrade.valid_store_path("release") + assert not nixos_upgrade.valid_store_path("/tmp/not-a-store-path") + + +@pytest.mark.unit +def test_parse_progress_event_ignores_malformed_lines(): + assert nixos_upgrade.parse_progress_event("copying path") is None + assert nixos_upgrade.parse_progress_event("@nix {") is None + + +@pytest.mark.unit +def test_parse_progress_event_extracts_copy_path(): + line = ( + '@nix {"action":"start","id":7,"type":100,' + f'"text":"copying path \'{STORE}\' from cache"}}' + ) + event = nixos_upgrade.parse_progress_event(line) + + assert event == nixos_upgrade.ProgressEvent("start", 7, 100, STORE) + + +@pytest.mark.unit +def test_parse_progress_event_extracts_byte_progress(): + line = '@nix {"action":"result","id":3,"type":105,"fields":[1024,4096,1,0]}' + event = nixos_upgrade.parse_progress_event(line) + assert event == nixos_upgrade.ProgressEvent("result", 3, None, None, 1024, 4096) + + +@pytest.mark.unit +def test_download_progress_tracks_bytes_and_label(monkeypatch): + statuses: list[str] = [] + monkeypatch.setattr( + nixos_upgrade, "write_status", lambda s, _f=None: statuses.append(s) + ) + progress = nixos_upgrade._DownloadProgress(10_000_000, 2, None) + progress.feed( + f'@nix {{"action":"start","id":1,"type":100,' + f'"text":"copying path \'{STORE}\' from cache"}}' + ) + progress.feed( + '@nix {"action":"result","id":1,"type":105,"fields":[5000000,8000000,1,0]}' + ) + progress.feed('@nix {"action":"stop","id":1,"type":100}') + + # within-path byte movement, the package label, and never a crash on junk + assert statuses and all(s.startswith("downloading ") for s in statuses) + assert any("nixos-system-pifinder" in s for s in statuses) + for bad in ["garbage", "@nix {oops", ""]: + progress.feed(bad) + + +@pytest.mark.unit +def test_run_build_uses_no_link(monkeypatch, tmp_path): + started = {} + + class FakeStdout: + def __iter__(self): + return iter(()) + + class FakeProcess: + stdout = FakeStdout() + + def wait(self): + return 0 + + def fake_popen(args, **kwargs): + started["args"] = args + return FakeProcess() + + monkeypatch.setattr(nixos_upgrade.subprocess, "Popen", fake_popen) + monkeypatch.setattr(nixos_upgrade, "fetch_cache_public_keys", lambda: []) + + rc = nixos_upgrade.run_build( + STORE, + nixos_upgrade.DownloadEstimate(()), + status_file=tmp_path / "status", + log_file=tmp_path / "log", + ) + + assert rc == 0 + assert "--no-link" in started["args"] + + +@pytest.mark.unit +def test_estimate_download_parses_paths_and_total(monkeypatch): + dry = ( + "these 1 paths will be fetched (0.0 KiB download, 12.5 MiB unpacked):\n" + f" {STORE}\n" + ) + + def fake_command(args, **kwargs): + class Result: + returncode = 0 + stdout = dry + stderr = "" + + return Result() + + monkeypatch.setattr(nixos_upgrade, "command", fake_command) + + estimate = nixos_upgrade.estimate_download(STORE) + + assert estimate.paths == (STORE,) + assert estimate.path_count == 1 + assert estimate.total_bytes == int(12.5 * 1024 * 1024) + + +def _capture_status(monkeypatch): + statuses = [] + monkeypatch.setattr(nixos_upgrade, "write_status", statuses.append) + return statuses + + +@pytest.mark.unit +def test_classify_local_path_is_available(monkeypatch): + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: True) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.AVAILABLE + + +@pytest.mark.unit +def test_classify_cache_hit_is_available(monkeypatch): + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: False) + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen([200])) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.AVAILABLE + + +@pytest.mark.unit +def test_classify_all_404_is_absent(monkeypatch): + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: False) + monkeypatch.setattr( + "urllib.request.urlopen", _fake_urlopen([_http_error(404), _http_error(404)]) + ) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.ABSENT + + +@pytest.mark.unit +def test_classify_connection_error_is_unreachable(monkeypatch): + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: False) + err = urllib.error.URLError("no route to host") + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen([err, err])) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.UNREACHABLE + + +@pytest.mark.unit +def test_classify_partial_unreachable_is_not_absent(monkeypatch): + # One cache says 404, the other can't be reached: the build might still be + # on the unreachable cache, so this must be retryable, not "gone". + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: False) + outcomes = [_http_error(404), urllib.error.URLError("timeout")] + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen(outcomes)) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.UNREACHABLE + + +@pytest.mark.unit +def test_run_upgrade_invalid_ref_writes_failed(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text("release") + statuses = _capture_status(monkeypatch) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "failed"] + + +@pytest.mark.unit +def test_run_upgrade_unavailable_writes_unavailable(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + statuses = _capture_status(monkeypatch) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 1) + monkeypatch.setattr( + nixos_upgrade, "classify_store_path", lambda _store: nixos_upgrade.ABSENT + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "unavailable"] + + +@pytest.mark.unit +def test_run_upgrade_unreachable_writes_connfail(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + statuses = _capture_status(monkeypatch) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 1) + monkeypatch.setattr( + nixos_upgrade, "classify_store_path", lambda _store: nixos_upgrade.UNREACHABLE + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "connfail"] + + +@pytest.mark.unit +def test_run_upgrade_build_failure_writes_failed(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + statuses = _capture_status(monkeypatch) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 1) + monkeypatch.setattr( + nixos_upgrade, "classify_store_path", lambda _store: nixos_upgrade.AVAILABLE + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "failed"] + + +@pytest.mark.unit +def test_run_upgrade_activation_failure_writes_failed(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + statuses = _capture_status(monkeypatch) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 0) + monkeypatch.setattr(nixos_upgrade, "load_selection", dict) + monkeypatch.setattr( + nixos_upgrade, + "activate_system", + lambda _store, _camera: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "failed"] + + +@pytest.mark.unit +def test_run_upgrade_success_writes_rebooting_and_persists(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + current_build = tmp_path / "current-build.json" + statuses = _capture_status(monkeypatch) + commands = [] + monkeypatch.setattr(nixos_upgrade, "CURRENT_BUILD_FILE", current_build) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 0) + monkeypatch.setattr( + nixos_upgrade, + "load_selection", + lambda: {"version": "nixos-test", "label": "test", "channel": "unstable"}, + ) + monkeypatch.setattr(nixos_upgrade, "activate_system", lambda _store, _camera: None) + monkeypatch.setattr(nixos_upgrade, "cleanup_old_generations", lambda: None) + monkeypatch.setattr( + nixos_upgrade, + "command", + lambda args, **_kwargs: commands.append(args), + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 0 + assert statuses == ["starting", "rebooting"] + assert commands == [["systemctl", "reboot"]] + assert json.loads(current_build.read_text())["version"] == "nixos-test" + + +@pytest.mark.unit +def test_run_upgrade_reboot_failure_writes_failed(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + current_build = tmp_path / "current-build.json" + statuses = _capture_status(monkeypatch) + monkeypatch.setattr(nixos_upgrade, "CURRENT_BUILD_FILE", current_build) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 0) + monkeypatch.setattr(nixos_upgrade, "load_selection", dict) + monkeypatch.setattr(nixos_upgrade, "activate_system", lambda _store, _camera: None) + monkeypatch.setattr(nixos_upgrade, "cleanup_old_generations", lambda: None) + + def fail_reboot(_args, **_kwargs): + raise RuntimeError("reboot failed") + + monkeypatch.setattr(nixos_upgrade, "command", fail_reboot) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "rebooting", "failed"] diff --git a/python/tests/test_obj_types_docs.py b/python/tests/test_obj_types_docs.py index 8ef557f7b..73a97eda3 100644 --- a/python/tests/test_obj_types_docs.py +++ b/python/tests/test_obj_types_docs.py @@ -44,9 +44,9 @@ def test_readme_table_matches_obj_types(): # On a checkout that doesn't carry it yet there is nothing to guard; the # check activates once both land on main. pytest.skip("Object type codes table not present in this checkout") - assert codes == set(OBJ_TYPES), ( - "The README 'Object type codes' table is out of sync with OBJ_TYPES." - ) + assert codes == set( + OBJ_TYPES + ), "The README 'Object type codes' table is out of sync with OBJ_TYPES." @pytest.mark.unit @@ -55,6 +55,6 @@ def test_default_config_object_types_match_obj_types(): # type = "show everything"). It must list exactly the OBJ_TYPES codes, so a # type added to OBJ_TYPES can't silently be off by default. config = json.loads(_DEFAULT_CONFIG.read_text()) - assert set(config["filter.object_types"]) == set(OBJ_TYPES), ( - "default_config.json 'filter.object_types' is out of sync with OBJ_TYPES." - ) + assert set(config["filter.object_types"]) == set( + OBJ_TYPES + ), "default_config.json 'filter.object_types' is out of sync with OBJ_TYPES." diff --git a/python/tests/test_polar_alignment.py b/python/tests/test_polar_alignment.py index 62d8ca1c6..6ecff6c17 100644 --- a/python/tests/test_polar_alignment.py +++ b/python/tests/test_polar_alignment.py @@ -318,6 +318,7 @@ def test_j2000_pole_axis_recovered_through_pipeline(self): # axis RA near the pole is in the gimbal zone — relaxed tolerance assert abs((ax_ra - exp_ax_ra + 180) % 360 - 180) < 10 / 3600 + @pytest.mark.unit class TestCorrectionTarget: def test_scope_at_axis_lands_on_pole(self): @@ -348,10 +349,9 @@ def test_far_from_axis_target_dec0(self): ra_t, dec_t, roll_t = correction_target( ax_ra, ax_dec, (ra2, dec2, roll2), LAT_NH, LST ) - assert ra_t == pytest.approx(100.338436, abs=1e-3) - assert dec_t == pytest.approx( 1.537097, abs=1e-3) - assert roll_t == pytest.approx( -1.818094, abs=1e-3) - + assert ra_t == pytest.approx(100.338436, abs=1e-3) + assert dec_t == pytest.approx(1.537097, abs=1e-3) + assert roll_t == pytest.approx(-1.818094, abs=1e-3) def test_aligned_axis_is_identity(self): ra_t, dec_t, roll_t = correction_target( diff --git a/python/tests/test_sd_notify.py b/python/tests/test_sd_notify.py new file mode 100644 index 000000000..c4251f8f0 --- /dev/null +++ b/python/tests/test_sd_notify.py @@ -0,0 +1,30 @@ +import socket + +import pytest + +from PiFinder.utils import sd_notify + + +@pytest.mark.unit +class TestSdNotify: + def test_noop_without_notify_socket(self, monkeypatch): + # Development runs / tests: no systemd, no socket — must be silent. + monkeypatch.delenv("NOTIFY_SOCKET", raising=False) + sd_notify("READY=1") # must not raise + + def test_sends_state_to_socket(self, monkeypatch, tmp_path): + sock_path = str(tmp_path / "notify.sock") + server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + server.bind(sock_path) + server.settimeout(2) + try: + monkeypatch.setenv("NOTIFY_SOCKET", sock_path) + sd_notify("READY=1") + assert server.recv(64) == b"READY=1" + finally: + server.close() + + def test_broken_socket_is_swallowed(self, monkeypatch, tmp_path): + # Socket path set but nothing listening: failure must not propagate. + monkeypatch.setenv("NOTIFY_SOCKET", str(tmp_path / "gone.sock")) + sd_notify("READY=1") # must not raise diff --git a/python/tests/test_server_coordinates.py b/python/tests/test_server_coordinates.py new file mode 100644 index 000000000..4b4864d83 --- /dev/null +++ b/python/tests/test_server_coordinates.py @@ -0,0 +1,40 @@ +"""Unit tests for web location coordinate parsing. + +The web location forms let users type coordinates, altitude and error. In a +comma-decimal browser locale a ```` yields an empty value +for a period-formatted number (and vice-versa), which silently blocked the +save. ``parse_coordinate`` accepts either separator so the server never sees a +stray comma; these tests lock that in and guard the missing/garbage cases that +previously raised an uncaught 500. +""" + +import pytest + +from PiFinder.server import parse_coordinate + + +@pytest.mark.unit +@pytest.mark.parametrize( + "raw, expected", + [ + ("51.3", 51.3), + ("51,3", 51.3), + ("51", 51.0), + (" -3,2 ", -3.2), + ("0", 0.0), + ], +) +def test_parse_coordinate_accepts_both_separators(raw, expected): + assert parse_coordinate(raw, "Latitude") == expected + + +@pytest.mark.unit +def test_parse_coordinate_missing_raises_value_error(): + with pytest.raises(ValueError): + parse_coordinate(None, "Latitude") + + +@pytest.mark.unit +def test_parse_coordinate_garbage_raises_value_error(): + with pytest.raises(ValueError): + parse_coordinate("not-a-number", "Longitude") diff --git a/python/tests/test_software.py b/python/tests/test_software.py index 2aefc08dd..f27746674 100644 --- a/python/tests/test_software.py +++ b/python/tests/test_software.py @@ -1,15 +1,24 @@ -from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch import pytest import requests +# Installs the _() gettext builtin the UI modules rely on; must precede ui imports. +import PiFinder.i18n # noqa: F401 from PiFinder.ui.software import ( - update_needed, - _strip_markdown, - _fetch_migration_config, + UISoftware, + UPDATE_MANIFEST_URL, + _annotate_trunk_entries, + _entry_detail, + _entry_row_parts, _fetch_update_manifest, - _migration_version_info_from_manifest, - _UNLOCK_SEQUENCE, + _format_age, + _load_cached_manifest, + _parse_manifest, + _save_cached_manifest, + _strip_markdown, + update_needed, ) @@ -46,15 +55,6 @@ def test_unknown_returns_true(self): assert update_needed("2.4.0", "Unknown") is True -@pytest.mark.unit -class TestUnlockSequence: - def test_sequence_length(self): - assert len(_UNLOCK_SEQUENCE) == 7 - - def test_sequence_content(self): - assert _UNLOCK_SEQUENCE == ["square"] * 7 - - @pytest.mark.unit class TestStripMarkdown: def test_removes_headings(self): @@ -99,189 +99,505 @@ def _mock_invalid_json_response(status_code=200): @pytest.mark.unit -class TestFetchMigrationConfig: +class TestFetchUpdateManifest: @patch("PiFinder.ui.software.requests.get") - def test_returns_dict_when_gate_open_and_url_set(self, mock_get): - payload = {"nixos_for_everyone": True, "nixos_url": _NIXOS_URL} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_migration_config() == payload + def test_parses_manifest_channels(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = { + "schema": 1, + "channels": { + "stable": [ + { + "kind": "release", + "label": "v3.1.0", + "title": "PiFinder v3.1.0", + "version": "3.1.0", + "store_path": "/nix/store/aaa-nixos-system-pifinder", + "available": True, + } + ], + "beta": [], + "unstable": [ + { + "kind": "trunk", + "label": "nixos-abc1234", + "title": "nixos branch", + "version": "nixos-abc1234", + "store_path": "/nix/store/bbb-nixos-system-pifinder", + "available": True, + }, + { + "kind": "pr", + "label": "PR#42-def5678", + "title": "Fix star matching algorithm", + "version": "PR#42-def5678", + "store_path": "/nix/store/ccc-nixos-system-pifinder", + "available": True, + }, + ], + }, + } + mock_get.return_value = mock_resp - @patch("PiFinder.ui.software.requests.get") - def test_returns_dict_when_gate_closed(self, mock_get): - # Gate check is the caller's job; fetch just parses the JSON. - payload = {"nixos_for_everyone": False} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_migration_config() == payload + channels = _fetch_update_manifest() - @patch("PiFinder.ui.software.requests.get") - def test_returns_dict_without_url(self, mock_get): - # The tarball comes from the manifest now, so the gate no longer needs a - # nixos_url — only the nixos_for_everyone flag matters to the caller. - payload = {"nixos_for_everyone": True} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_migration_config() == payload + assert channels["stable"][0]["ref"] == "/nix/store/aaa-nixos-system-pifinder" + assert channels["stable"][0]["channel"] == "stable" + assert channels["unstable"][0]["is_trunk"] is True + assert channels["unstable"][1]["label"] == "PR#42-def5678" + mock_get.assert_called_once_with(UPDATE_MANIFEST_URL, timeout=10) @patch("PiFinder.ui.software.requests.get") - def test_returns_none_on_http_error(self, mock_get): - mock_get.return_value = _mock_json_response( - {"nixos_for_everyone": True, "nixos_url": _NIXOS_URL}, status_code=404 - ) - assert _fetch_migration_config() is None + def test_unavailable_manifest_entry_has_no_ref(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = { + "schema": 1, + "channels": { + "stable": [], + "beta": [], + "unstable": [ + { + "kind": "trunk", + "label": "main", + "title": "main branch", + "version": "main", + "store_path": None, + "available": False, + "reason": "no build", + } + ], + }, + } + mock_get.return_value = mock_resp - @patch("PiFinder.ui.software.requests.get") - def test_returns_none_on_connection_error(self, mock_get): - mock_get.side_effect = requests.exceptions.ConnectionError - assert _fetch_migration_config() is None + channels = _fetch_update_manifest() - @patch("PiFinder.ui.software.requests.get") - def test_returns_none_on_timeout(self, mock_get): - mock_get.side_effect = requests.exceptions.Timeout - assert _fetch_migration_config() is None + entry = channels["unstable"][0] + assert entry["ref"] is None + assert entry["unavailable"] is True + assert entry["subtitle"] == "main branch (no build)" @patch("PiFinder.ui.software.requests.get") - def test_returns_none_on_malformed_json(self, mock_get): - mock_get.return_value = _mock_invalid_json_response() - assert _fetch_migration_config() is None + def test_invalid_store_path_is_unavailable(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = { + "schema": 1, + "channels": { + "stable": [], + "beta": [], + "unstable": [ + { + "kind": "pr", + "label": "PR#42-abcdef0", + "title": "Bad build", + "version": "PR#42-abcdef0", + "store_path": "not-a-store-path", + "available": True, + } + ], + }, + } + mock_get.return_value = mock_resp + + channels = _fetch_update_manifest() + + entry = channels["unstable"][0] + assert entry["ref"] is None + assert entry["unavailable"] is True + assert entry["subtitle"] == "Bad build (invalid build)" @patch("PiFinder.ui.software.requests.get") - def test_returns_none_when_payload_is_not_object(self, mock_get): - mock_get.return_value = _mock_json_response(["nixos_for_everyone"]) - assert _fetch_migration_config() is None - - -def _migration_entry(version="3.0.0", available=True, with_urls=True): - entry = {"version": version, "available": available} - if with_urls: - base = f"https://example.invalid/releases/download/v{version}" - entry["migration_url"] = f"{base}/pifinder-migration-v{version}.tar.zst" - entry["migration_sha256_url"] = ( - f"{base}/pifinder-migration-v{version}.tar.zst.sha256" - ) - return entry + def test_rejects_unknown_schema(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"schema": 99, "channels": {}} + mock_get.return_value = mock_resp + with pytest.raises(ValueError): + _fetch_update_manifest() -def _manifest(stable=None, beta=None, unstable=None): - return { - "channels": { - "stable": stable or [], - "beta": beta or [], - "unstable": unstable or [], - } + +@pytest.mark.unit +def test_unstable_list_hides_exact_running_build(): + # The running build is hidden from unstable by store-path identity; a + # rebuilt PR (same number, new store path) is a real upgrade and stays. + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["unstable"] + ui._channel_index = 0 + ui._software_version = "PR#1-abcdef0" + ui._channels = { + "unstable": [ + { + "label": "nixos-trunk", + "version": "nixos-trunk", + "is_trunk": True, + "ref": "/nix/store/bbb-trunk", + }, + { + "label": "PR#1-abcdef0", + "version": "PR#1-abcdef0", + "ref": "/nix/store/aaa-running", + }, + ] } + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-running", + ): + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["nixos-trunk"] + @pytest.mark.unit -class TestFetchUpdateManifest: - @patch("PiFinder.ui.software.requests.get") - def test_returns_dict(self, mock_get): - payload = {"channels": {}} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_update_manifest() == payload +def test_stable_list_filters_current_build_by_store_path(): + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["stable"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "stable": [ + {"label": "v3.0.0", "version": "3.0.0", "ref": "/nix/store/aaa-current"}, + {"label": "v3.1.0", "version": "3.1.0", "ref": "/nix/store/bbb-next"}, + ] + } - @patch("PiFinder.ui.software.requests.get") - def test_none_on_http_error(self, mock_get): - mock_get.return_value = _mock_json_response({}, status_code=500) - assert _fetch_update_manifest() is None + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-current", + ): + ui._refresh_version_list() - @patch("PiFinder.ui.software.requests.get") - def test_none_on_malformed_json(self, mock_get): - mock_get.return_value = _mock_invalid_json_response() - assert _fetch_update_manifest() is None + assert [entry["label"] for entry in ui._version_list] == ["v3.1.0"] -def _mock_head_response(size_bytes=None, status_code=200): - resp = MagicMock() - resp.status_code = status_code - resp.headers = {} if size_bytes is None else {"Content-Length": str(size_bytes)} - return resp +@pytest.mark.unit +def test_recut_release_with_same_version_stays_visible(): + # A re-cut release reuses its version/label but is a different store path + # — it must be offered as an upgrade, not hidden by a version-string match. + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["beta"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "beta": [ + {"label": "v3.0.0-beta", "version": "3.0.0", "ref": "/nix/store/bbb-recut"}, + ] + } + + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-old-build", + ): + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["v3.0.0-beta"] -# Selection also HEADs the chosen tarball for its size, so requests.head is -# stubbed throughout (never hit the network from tests). @pytest.mark.unit -@patch( - "PiFinder.ui.software.requests.head", - side_effect=requests.exceptions.ConnectionError, -) -class TestMigrationVersionInfoFromManifest: - @patch("PiFinder.ui.software.requests.get") - def test_prefers_stable(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest( - stable=[_migration_entry("3.0.0")], - beta=[_migration_entry("3.1.0-beta")], - unstable=[_migration_entry("nixos-abc")], - ) - ) - info = _migration_version_info_from_manifest() - assert info["version"] == "3.0.0" - assert info["type"] == "upgrade" - assert info["migration_url"].endswith("pifinder-migration-v3.0.0.tar.zst") - assert info["migration_sha256_url"].endswith(".sha256") +def test_unknown_current_build_hides_nothing(): + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["stable"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "stable": [ + {"label": "v3.0.0", "version": "3.0.0", "ref": "/nix/store/aaa"}, + ] + } - @patch("PiFinder.ui.software.requests.get") - def test_includes_size_when_head_succeeds(self, mock_get, mock_head): - mock_get.return_value = _mock_json_response( - _manifest(stable=[_migration_entry("3.0.0")]) - ) - mock_head.side_effect = None - mock_head.return_value = _mock_head_response(size_bytes=300 * 1024 * 1024) - info = _migration_version_info_from_manifest() - assert info["migration_size_mb"] == 300 + with patch("PiFinder.ui.software._current_store_path", return_value=None): + ui._refresh_version_list() - @patch("PiFinder.ui.software.requests.get") - def test_omits_size_when_head_fails(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest(stable=[_migration_entry("3.0.0")]) - ) - info = _migration_version_info_from_manifest() - assert "migration_size_mb" not in info + assert [entry["label"] for entry in ui._version_list] == ["v3.0.0"] - @patch("PiFinder.ui.software.requests.get") - def test_falls_back_to_beta_when_stable_empty(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest(beta=[_migration_entry("3.1.0-beta")]) - ) - assert _migration_version_info_from_manifest()["version"] == "3.1.0-beta" - @patch("PiFinder.ui.software.requests.get") - def test_falls_back_to_unstable_last(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest(unstable=[_migration_entry("nixos-abc")]) +@pytest.mark.unit +def test_unavailable_version_has_no_install_option(): + ui = UISoftware.__new__(UISoftware) + ui._phase = "browse" + ui._focus = "list" + ui._list_index = 0 + ui._version_list = [ + { + "label": "main", + "version": "main", + "subtitle": "main branch (no build)", + "unavailable": True, + } + ] + + ui.key_right() + + assert ui._phase == "confirm" + assert ui._confirm_options == ["Cancel"] + + +def _iso_ago(**delta) -> str: + return (datetime.now(timezone.utc) - timedelta(**delta)).isoformat() + + +@pytest.mark.unit +class TestFormatAge: + def test_minutes(self): + assert _format_age(_iso_ago(minutes=5)) == "5m ago" + + def test_hours(self): + assert _format_age(_iso_ago(hours=3, minutes=10)) == "3h ago" + + def test_days(self): + assert _format_age(_iso_ago(days=2, hours=1)) == "2d ago" + + def test_future_timestamp_clamps_to_zero(self): + assert _format_age(_iso_ago(minutes=-5)) == "0m ago" + + def test_none(self): + assert _format_age(None) is None + + def test_garbage(self): + assert _format_age("not-a-date") is None + + +@pytest.mark.unit +class TestEntryRowParts: + def test_pr_row_leads_with_bare_number(self): + prefix, text = _entry_row_parts( + { + "kind": "pr", + "number": 534, + "label": "PR#534-2692406", + "title": "feat(catalog): add observable asteroids", + } ) - assert _migration_version_info_from_manifest()["version"] == "nixos-abc" + assert prefix == "534 " + assert text == "feat(catalog): add observable asteroids" + + def test_trunk_row_shows_branch_name(self): + prefix, text = _entry_row_parts( + { + "kind": "trunk", + "is_trunk": True, + "label": "nixos-d1657e6", + "source_ref": "nixos", + } + ) + assert prefix == "• " + assert text == "nixos" - @patch("PiFinder.ui.software.requests.get") - def test_skips_unavailable_entries(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest( - stable=[_migration_entry("3.0.0", available=False)], - beta=[_migration_entry("3.1.0-beta")], - ) + def test_release_row_shows_label(self): + prefix, text = _entry_row_parts( + {"kind": "release", "label": "v3.0.0-beta", "title": "PiFinder v3.0.0-beta"} ) - assert _migration_version_info_from_manifest()["version"] == "3.1.0-beta" + assert prefix == "" + assert text == "v3.0.0-beta" - @patch("PiFinder.ui.software.requests.get") - def test_skips_entries_without_migration_tarball(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest( - stable=[_migration_entry("3.0.0", with_urls=False)], - beta=[_migration_entry("3.1.0-beta")], - ) + +@pytest.mark.unit +class TestEntryDetail: + def test_unavailable_reason_wins_over_age(self): + entry = { + "unavailable": True, + "subtitle": "Broken build (not in cache)", + "built_at": _iso_ago(minutes=5), + } + assert _entry_detail(entry) == "Broken build (not in cache)" + + def test_build_age_and_short_hash_when_available(self): + entry = { + "subtitle": "Some title", + "built_at": _iso_ago(minutes=12), + "source_sha": "2692406bff684a56a2acce1febffb015aad72038", + } + assert _entry_detail(entry) == "built 12m ago · 2692406" + + def test_build_age_without_hash(self): + entry = {"subtitle": "Some title", "built_at": _iso_ago(minutes=12)} + assert _entry_detail(entry) == "built 12m ago" + + def test_hash_without_build_age(self): + entry = {"subtitle": "Some title", "source_sha": "d1657e66b6ca4e14"} + assert _entry_detail(entry) == "d1657e6" + + def test_falls_back_to_subtitle_without_build_info(self): + assert _entry_detail({"subtitle": "Some title"}) == "Some title" + + +@pytest.mark.unit +class TestTrunkNixosMarker: + def _manifest_with_trunk(self, **extra): + trunk = { + "kind": "trunk", + "label": "main-abc1234", + "title": "main branch", + "version": "main-abc1234", + "source_repo": "brickbots/PiFinder", + "source_ref": "main", + "store_path": "/nix/store/aaa-nixos-system-pifinder", + "available": True, + } + trunk.update(extra) + return {"schema": 1, "channels": {"unstable": [trunk]}} + + def test_parse_drops_trunk_marked_non_nixos(self): + channels = _parse_manifest(self._manifest_with_trunk(nixos_branch=False)) + assert channels["unstable"] == [] + + def test_parse_keeps_trunk_marked_nixos(self): + channels = _parse_manifest(self._manifest_with_trunk(nixos_branch=True)) + assert channels["unstable"][0]["is_trunk"] is True + + def test_parse_keeps_unannotated_trunk(self): + channels = _parse_manifest(self._manifest_with_trunk()) + assert len(channels["unstable"]) == 1 + + @patch("PiFinder.ui.software.requests.head") + def test_annotate_marks_nixos_branch(self, mock_head): + mock_head.return_value = MagicMock(status_code=200) + manifest = self._manifest_with_trunk() + _annotate_trunk_entries(manifest) + assert manifest["channels"]["unstable"][0]["nixos_branch"] is True + url = mock_head.call_args[0][0] + assert url == ( + "https://raw.githubusercontent.com/brickbots/PiFinder/main/flake.nix" ) - assert _migration_version_info_from_manifest()["version"] == "3.1.0-beta" - @patch("PiFinder.ui.software.requests.get") - def test_none_when_no_migration_entries(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response(_manifest()) - assert _migration_version_info_from_manifest() is None + @patch("PiFinder.ui.software.requests.head") + def test_annotate_marks_non_nixos_branch(self, mock_head): + mock_head.return_value = MagicMock(status_code=404) + manifest = self._manifest_with_trunk() + _annotate_trunk_entries(manifest) + assert manifest["channels"]["unstable"][0]["nixos_branch"] is False - @patch("PiFinder.ui.software.requests.get") - def test_none_on_network_error(self, mock_get, _mock_head): - mock_get.side_effect = requests.exceptions.ConnectionError - assert _migration_version_info_from_manifest() is None + @patch("PiFinder.ui.software.requests.head") + def test_annotate_leaves_entry_alone_on_network_error(self, mock_head): + mock_head.side_effect = requests.exceptions.ConnectionError + manifest = self._manifest_with_trunk() + _annotate_trunk_entries(manifest) + assert "nixos_branch" not in manifest["channels"]["unstable"][0] - @patch("PiFinder.ui.software.requests.get") - def test_none_when_channels_missing(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response({"schema": 1}) - assert _migration_version_info_from_manifest() is None + +@pytest.mark.unit +class TestManifestCache: + def test_round_trip(self, tmp_path): + manifest = {"schema": 1, "channels": {"stable": [], "beta": [], "unstable": []}} + cache = tmp_path / "update_manifest.json" + with patch("PiFinder.ui.software.MANIFEST_CACHE_PATH", cache): + _save_cached_manifest(manifest) + assert _load_cached_manifest() == manifest + + def test_missing_cache_returns_none(self, tmp_path): + cache = tmp_path / "update_manifest.json" + with patch("PiFinder.ui.software.MANIFEST_CACHE_PATH", cache): + assert _load_cached_manifest() is None + + def test_wrong_schema_returns_none(self, tmp_path): + cache = tmp_path / "update_manifest.json" + cache.write_text('{"schema": 99}') + with patch("PiFinder.ui.software.MANIFEST_CACHE_PATH", cache): + assert _load_cached_manifest() is None + + def test_corrupt_cache_returns_none(self, tmp_path): + cache = tmp_path / "update_manifest.json" + cache.write_text("{nope") + with patch("PiFinder.ui.software.MANIFEST_CACHE_PATH", cache): + assert _load_cached_manifest() is None + + +@pytest.mark.unit +class TestManualRefresh: + def _ui(self, phase): + ui = UISoftware.__new__(UISoftware) + ui._phase = phase + ui._key_buffer = [] + ui._elipsis_count = 30 + return ui + + def test_square_refetches_in_browse(self): + ui = self._ui("browse") + with patch.object(UISoftware, "_start_refresh") as mock_start: + ui.key_square() + mock_start.assert_called_once() + assert ui._phase == "browse" + + def test_square_retries_from_offline_via_loading(self): + ui = self._ui("offline") + with patch.object(UISoftware, "_start_refresh") as mock_start: + ui.key_square() + mock_start.assert_called_once() + assert ui._phase == "loading" + + def test_square_does_not_refetch_in_confirm(self): + ui = self._ui("confirm") + with patch.object(UISoftware, "_start_refresh") as mock_start: + ui.key_square() + mock_start.assert_not_called() + + +@pytest.mark.unit +class TestListRollbackTargets: + def _targets(self, value): + ui = UISoftware.__new__(UISoftware) + with patch("PiFinder.ui.software.sys_utils") as mock_sys: + mock_sys.list_rollback_targets.return_value = value + return ui._list_rollback_targets() + + def test_valid_entries_pass_through(self): + entries = [{"label": "gen 42", "ref": "/nix/store/aaa"}] + assert self._targets(entries) == entries + + def test_non_dict_and_unlabeled_entries_dropped(self): + entries = [ + "garbage", + {"ref": "/nix/store/aaa"}, + {"label": 7}, + {"label": "gen 42"}, + ] + assert self._targets(entries) == [{"label": "gen 42"}] + + def test_non_iterable_result_yields_empty(self): + assert self._targets(None) == [] + + +@pytest.mark.unit +class TestConsumeRefreshResult: + def _ui(self, phase): + ui = UISoftware.__new__(UISoftware) + ui._phase = phase + ui._checking = True + ui._check_failed = False + ui._refresh_result = None + return ui + + def test_success_moves_loading_to_browse(self): + ui = self._ui("loading") + ui._refresh_result = ("ok", {"stable": [], "beta": [], "unstable": []}) + with patch.object(UISoftware, "_apply_manifest") as mock_apply: + ui._consume_refresh_result() + assert ui._phase == "browse" + assert ui._checking is False + assert ui._check_failed is False + mock_apply.assert_called_once() + + def test_failure_without_cache_or_rollback_goes_offline(self): + ui = self._ui("loading") + ui._refresh_result = ("error", None) + with patch.object(UISoftware, "_list_rollback_targets", return_value=[]): + ui._consume_refresh_result() + assert ui._phase == "offline" + assert ui._checking is False + + def test_failure_with_cached_list_only_flags(self): + ui = self._ui("browse") + ui._refresh_result = ("error", None) + ui._consume_refresh_result() + assert ui._phase == "browse" + assert ui._check_failed is True + + def test_no_result_is_a_noop(self): + ui = self._ui("browse") + ui._consume_refresh_result() + assert ui._checking is True diff --git a/python/tests/test_solver_shmem.py b/python/tests/test_solver_shmem.py index 0a536da4a..e54bb2043 100644 --- a/python/tests/test_solver_shmem.py +++ b/python/tests/test_solver_shmem.py @@ -1,11 +1,16 @@ -"""Regression test for stale cedar_detect shared-memory cleanup. +"""Regression tests for cedar_detect shared-memory recovery on restart. -A solver process that is killed leaks its POSIX shmem segment; the next -PFCedarDetectClient must clear it at startup instead of dying on -FileExistsError on every solve. +All cedar clients share one hard-coded segment name, so a second client on +the same host (e.g. an offline analysis script) can leave the server's +cached fd pointing at its own frozen image — the live solver then "solves" +that frame forever. A solver restart must always recover: clear whatever +segment exists at startup, and force the server to reopen the fresh one on +the first request. A killed solver similarly leaks its segment; the next +client must clear it instead of dying on FileExistsError on every solve. """ from multiprocessing import shared_memory +from types import SimpleNamespace import pytest @@ -38,3 +43,31 @@ def test_clear_stale_shmem_unlinks_leaked_segment(monkeypatch): stray.unlink() except FileNotFoundError: pass + + +@pytest.mark.unit +def test_alloc_shmem_requests_reopen_on_fresh_segment(monkeypatch): + # Restart recovery: the first allocation after startup must return True + # so the request sets reopen_shmem and the server drops a cached fd that + # may point at another client's (possibly unlinked) segment. Upstream's + # _alloc_shmem returns False here — only PFCedarDetectClient's override + # guarantees a restart always resynchronizes solver and server. + name = "/cedar_detect_image_pftest_fresh" + monkeypatch.setattr( + "tetra3.cedar_detect_client.shared_memory", + SimpleNamespace( + SharedMemory=lambda _name, create=False, size=0: ( + shared_memory.SharedMemory(name, create=create, size=size) + ) + ), + ) + + client = object.__new__(PFCedarDetectClient) + client._shmem = None + client._shmem_size = 0 + try: + assert client._alloc_shmem(16) is True # fresh segment → reopen + assert client._alloc_shmem(16) is False # unchanged → no reopen + assert client._alloc_shmem(32) is True # resized → reopen + finally: + client._del_shmem() diff --git a/python/tests/test_solver_sqm.py b/python/tests/test_solver_sqm.py new file mode 100644 index 000000000..51ce1bc82 --- /dev/null +++ b/python/tests/test_solver_sqm.py @@ -0,0 +1,78 @@ +import numpy as np +import pytest + +from PiFinder.sqm.camera_profiles import get_camera_profile + +# solver pulls in tetra3/cedar; skip these helper tests if it can't import. +solver = pytest.importorskip("PiFinder.solver") + + +@pytest.mark.unit +class TestExtractRawPhotometryImage: + def test_bayer_green_channel_half_resolution(self): + profile = get_camera_profile("imx462") # SRGGB12 + # 4x4 RGGB mosaic: R at (0,0), G at (0,1)&(1,0), B at (1,1) + raw = np.zeros((4, 4), dtype=np.uint16) + raw[0::2, 1::2] = 100 # G site 1 + raw[1::2, 0::2] = 120 # G site 2 + raw[0::2, 0::2] = 999 # R (must be ignored) + raw[1::2, 1::2] = 999 # B (must be ignored) + green = solver._extract_raw_photometry_image(raw, profile) + assert green.shape == (2, 2) + assert np.allclose(green, 110.0) # mean of the two green sites + + def test_mono_passthrough(self): + profile = get_camera_profile("imx296") # R10 mono + raw = np.arange(16, dtype=np.uint16).reshape(4, 4) + out = solver._extract_raw_photometry_image(raw, profile) + assert out.shape == (4, 4) + assert np.allclose(out, raw.astype(np.float32)) + + def test_none_and_bad_shapes(self): + profile = get_camera_profile("imx462") + assert solver._extract_raw_photometry_image(None, profile) is None + assert solver._extract_raw_photometry_image(np.zeros(5), profile) is None + + +@pytest.mark.unit +class TestScaleSolutionCentroids: + def test_scales_matched_centroids(self): + sol = { + "FOV": 10.0, + "matched_centroids": [[100.0, 200.0], [50.0, 60.0]], + "matched_stars": [[1, 2, 5.0]], + "matched_catID": [42], + } + scaled = solver._scale_solution_centroids(sol, 490.0 / 512.0) + expected = np.array([[100.0, 200.0], [50.0, 60.0]]) * (490.0 / 512.0) + assert np.allclose(scaled["matched_centroids"], expected) + # original untouched; other fields preserved + assert sol["matched_centroids"] == [[100.0, 200.0], [50.0, 60.0]] + assert scaled["matched_catID"] == [42] + assert scaled["FOV"] == 10.0 + + +@pytest.mark.unit +class TestApplySqmCorrect: + def test_zero_delta_is_recorded_but_not_applied(self): + details = {"sqm_altitude_corrected": 18.5} + v, d = solver._apply_sqm_correct(18.3, details, 0.0) + assert v == 18.3 + assert d["sqm_altitude_corrected"] == 18.5 + assert d["sqm_correct_delta"] == 0.0 + + def test_delta_shifts_value_and_altitude_corrected(self): + details = {"sqm_altitude_corrected": 18.5} + v, d = solver._apply_sqm_correct(18.3, details, 0.25) + assert v == pytest.approx(18.55) + assert d["sqm_altitude_corrected"] == pytest.approx(18.75) + assert d["sqm_correct_delta"] == 0.25 + + def test_none_value_survives(self): + v, d = solver._apply_sqm_correct(None, {}, 0.25) + assert v is None + assert d["sqm_correct_delta"] == 0.25 + + def test_negative_delta(self): + v, _d = solver._apply_sqm_correct(18.3, {}, -0.4) + assert v == pytest.approx(17.9) diff --git a/python/tests/test_sqm.py b/python/tests/test_sqm.py index 7b9f610e9..b9ed65dc3 100644 --- a/python/tests/test_sqm.py +++ b/python/tests/test_sqm.py @@ -11,6 +11,7 @@ detect_camera_type, ) from PiFinder.sqm.save_sweep_metadata import save_sweep_metadata +from PiFinder.sqm.wings import WingEstimator @pytest.mark.unit @@ -55,7 +56,7 @@ def test_extinction_increases_toward_horizon(self): for i in range(len(extinctions) - 1): assert ( extinctions[i] < extinctions[i + 1] - ), f"Extinction at {altitudes[i]}° should be less than at {altitudes[i+1]}°" + ), f"Extinction at {altitudes[i]}° should be less than at {altitudes[i + 1]}°" def test_extinction_minimum_is_at_zenith(self): """Test that zenith (90°) has zero extinction (ASTAP convention)""" @@ -161,7 +162,7 @@ def test_airmass_increases_toward_horizon(self): for i in range(len(airmasses) - 1): assert airmasses[i] < airmasses[i + 1], ( f"Airmass at {altitudes[i]}° ({airmasses[i]:.3f}) should be less than " - f"at {altitudes[i+1]}° ({airmasses[i+1]:.3f})" + f"at {altitudes[i + 1]}° ({airmasses[i + 1]:.3f})" ) @@ -442,26 +443,20 @@ def test_mzero_single_star(self): assert len(mzeros) == 1 assert mzeros[0] == pytest.approx(expected, abs=0.001) - def test_mzero_flux_weighted_mean(self): - """Test that mzero uses flux-weighted mean (brighter stars weighted more).""" + def test_mzero_median(self): + """mzero is the median over stars: one aberrant bright star can't drag it.""" sqm = SQM() - # Two stars: one bright (high flux), one dim (low flux) - # The bright star's mzero should dominate - fluxes = [10000.0, 100.0] # 100x difference - mags = [4.0, 8.0] + # Three consistent stars plus one bright outlier (e.g. near-saturated): + # a flux-weighted mean would be pulled toward the outlier, the median isn't. + fluxes = [1000.0, 1000.0, 1000.0, 50000.0] + mags = [6.5, 6.5, 6.5, 1.0] # outlier's personal mzero = 1.0 + 2.5*log10(50000) mzero, mzeros = sqm._calculate_mzero(fluxes, mags) - # Individual mzeros - mzero_bright = 4.0 + 2.5 * np.log10(10000) # = 14.0 - mzero_dim = 8.0 + 2.5 * np.log10(100) # = 13.0 - - # Flux-weighted: (14.0*10000 + 13.0*100) / (10000+100) ≈ 13.99 - expected_weighted = (mzero_bright * 10000 + mzero_dim * 100) / (10000 + 100) - - assert mzero == pytest.approx(expected_weighted, abs=0.001) - assert mzeros[0] == pytest.approx(mzero_bright, abs=0.001) - assert mzeros[1] == pytest.approx(mzero_dim, abs=0.001) + mzero_typical = 6.5 + 2.5 * np.log10(1000) # = 14.0 + assert mzero == pytest.approx(mzero_typical, abs=0.001) + assert mzeros[0] == pytest.approx(mzero_typical, abs=0.001) + assert mzeros[3] == pytest.approx(1.0 + 2.5 * np.log10(50000), abs=0.001) def test_mzero_skips_negative_flux(self): """Test that stars with negative/zero flux are skipped.""" @@ -561,12 +556,34 @@ def test_no_overlap_at_threshold(self): @pytest.mark.unit class TestNoiseFloorEstimation: - """Unit tests for adaptive noise floor estimation.""" + """Unit tests for adaptive noise floor estimation. + + Uses a synthetic registered profile (bias 6, read 1.5, dark 0) so the + numeric expectations are self-contained rather than tied to a real sensor. + """ + + @pytest.fixture(autouse=True) + def _test_profile(self, monkeypatch): + from PiFinder.sqm.camera_profiles import CAMERA_PROFILES + + monkeypatch.setitem( + CAMERA_PROFILES, + "testcam", + CameraProfile( + format="L", + raw_size=(512, 512), + analog_gain=1.0, + bit_depth=8, + bias_offset=6.0, + read_noise_adu=1.5, + dark_current_rate=0.0, + ), + ) def test_temporal_noise_calculation(self): """Test temporal noise = read_noise + dark_current * exposure.""" - estimator = NoiseFloorEstimator(camera_type="imx296_processed") - # imx296_processed has read_noise=1.5, dark_current=0.0 + estimator = NoiseFloorEstimator(camera_type="testcam") + # testcam has read_noise=1.5, dark_current=0.0 noise = estimator._estimate_temporal_noise(exposure_sec=1.0) @@ -575,8 +592,8 @@ def test_temporal_noise_calculation(self): def test_noise_floor_uses_theory_when_dark_pixels_below_bias(self): """Test that theory is used when dark pixels are impossibly low.""" - estimator = NoiseFloorEstimator(camera_type="imx296_processed") - # bias_offset for imx296_processed is 6.0 + estimator = NoiseFloorEstimator(camera_type="testcam") + # bias_offset for testcam is 6.0 # Create image with all pixels below bias offset (impossible in reality) image = np.full((100, 100), 3.0, dtype=np.float32) @@ -589,7 +606,7 @@ def test_noise_floor_uses_theory_when_dark_pixels_below_bias(self): def test_noise_floor_uses_measured_when_valid(self): """Test that measured dark pixels are used when valid.""" np.random.seed(42) - estimator = NoiseFloorEstimator(camera_type="imx296_processed") + estimator = NoiseFloorEstimator(camera_type="testcam") # bias_offset=6.0, read_noise=1.5, dark_current=0.0 # theoretical_floor = 6.0 + 1.5 = 7.5 @@ -607,7 +624,7 @@ def test_noise_floor_uses_measured_when_valid(self): def test_noise_floor_clamped_to_bias_offset(self): """Test that noise floor is never below bias offset.""" - estimator = NoiseFloorEstimator(camera_type="imx296_processed") + estimator = NoiseFloorEstimator(camera_type="testcam") # Even with weird inputs, should never go below bias image = np.full((100, 100), 100.0, dtype=np.float32) @@ -618,7 +635,7 @@ def test_noise_floor_clamped_to_bias_offset(self): def test_history_smoothing_after_multiple_estimates(self): """Test that history smoothing kicks in after 5+ estimates.""" - estimator = NoiseFloorEstimator(camera_type="imx296_processed") + estimator = NoiseFloorEstimator(camera_type="testcam") # First 4 estimates - no smoothing yet for i in range(4): @@ -637,7 +654,7 @@ def test_history_smoothing_after_multiple_estimates(self): def test_update_with_zero_sec_sample(self): """Test zero-second sample updates profile gradually.""" np.random.seed(42) - estimator = NoiseFloorEstimator(camera_type="imx296_processed") + estimator = NoiseFloorEstimator(camera_type="testcam") original_bias = estimator.profile.bias_offset # Need 3 samples before profile updates @@ -653,7 +670,7 @@ def test_update_with_zero_sec_sample(self): def test_validate_estimate_too_close_to_median(self): """Test validation fails when noise floor is too close to image median.""" - estimator = NoiseFloorEstimator(camera_type="imx296_processed") + estimator = NoiseFloorEstimator(camera_type="testcam") # Image where darkest pixels are close to median (uniform image) # This simulates a situation with no stars/sky gradient @@ -666,7 +683,7 @@ def test_validate_estimate_too_close_to_median(self): # Need noise floor > median * 0.8 to trigger this # Create image where dark pixels are above theoretical floor image2 = np.full((100, 100), 8.0, dtype=np.float32) - estimator2 = NoiseFloorEstimator(camera_type="imx296_processed") + estimator2 = NoiseFloorEstimator(camera_type="testcam") _, details2 = estimator2.estimate_noise_floor(image2, exposure_sec=0.5) @@ -679,7 +696,7 @@ def test_validate_estimate_too_close_to_median(self): def test_get_statistics(self): """Test get_statistics returns expected data.""" np.random.seed(42) - estimator = NoiseFloorEstimator(camera_type="imx296_processed") + estimator = NoiseFloorEstimator(camera_type="testcam") # Do a few estimates for _ in range(3): @@ -688,7 +705,7 @@ def test_get_statistics(self): stats = estimator.get_statistics() - assert stats["camera_type"] == "imx296_processed" + assert stats["camera_type"] == "testcam" assert stats["n_estimates"] == 3 assert stats["n_history_samples"] == 3 assert "dark_pixel_mean" in stats @@ -698,7 +715,7 @@ def test_get_statistics(self): def test_reset_clears_state(self): """Test reset clears all history and statistics.""" np.random.seed(42) - estimator = NoiseFloorEstimator(camera_type="imx296_processed") + estimator = NoiseFloorEstimator(camera_type="testcam") # Build up some state for _ in range(5): @@ -723,7 +740,7 @@ def test_save_and_load_calibration(self, tmp_path, monkeypatch): # Create PiFinder_data directory (tmp_path / "PiFinder_data").mkdir() - estimator = NoiseFloorEstimator(camera_type="imx296_processed") + estimator = NoiseFloorEstimator(camera_type="testcam") # Save calibration with new values result = estimator.save_calibration( @@ -737,7 +754,7 @@ def test_save_and_load_calibration(self, tmp_path, monkeypatch): assert estimator.profile.dark_current_rate == 0.5 # Create new estimator - should load the saved calibration - estimator2 = NoiseFloorEstimator(camera_type="imx296_processed") + estimator2 = NoiseFloorEstimator(camera_type="testcam") # Should have loaded the saved values (not the defaults) assert estimator2.profile.bias_offset == 25.0 @@ -920,14 +937,6 @@ def test_get_known_camera_profile(self): assert profile.bit_depth == 10 assert profile.analog_gain == 15.0 - def test_get_processed_camera_profile(self): - """Test getting processed (8-bit) camera profiles.""" - profile = get_camera_profile("imx296_processed") - - assert isinstance(profile, CameraProfile) - assert profile.bit_depth == 8 - assert profile.format == "L" - def test_get_all_known_profiles(self): """Test that all documented camera types are accessible.""" camera_types = [ @@ -935,10 +944,6 @@ def test_get_all_known_profiles(self): "imx462", "imx290", "hq", - "imx296_processed", - "imx462_processed", - "imx290_processed", - "hq_processed", ] for camera_type in camera_types: @@ -1254,3 +1259,348 @@ def test_nonexistent_directory_raises_error(self, tmp_path): observer_lat=50.85, observer_lon=4.35, ) + + +def _mock_solution(mags, catids=None, fov=10.0): + n = len(mags) + centroids = np.array([[80 + 60 * i, 80 + 60 * i] for i in range(n)]) + stars = [[45.0 + 0.1 * i, 30.0 + 0.1 * i, m] for i, m in enumerate(mags)] + sol = {"FOV": fov, "matched_centroids": centroids, "matched_stars": stars} + if catids is not None: + sol["matched_catID"] = list(catids) + return sol, centroids + + +def _image_with_stars(centroids, base=1000, star=5000, size=512): + np.random.seed(0) + image = np.random.randint(base - 50, base + 50, (size, size), dtype=np.uint16) + for row, col in centroids: + image[row - 2 : row + 3, col - 2 : col + 3] += star + return image + + +@pytest.mark.unit +class TestPedestalOverride: + def test_pedestal_override_used(self): + sqm = SQM() + mags = [5.0, 6.0, 7.0] + sol, centroids = _mock_solution(mags) + image = _image_with_stars(centroids) + _, details = sqm.calculate( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + pedestal_override=123.0, + ) + assert details["pedestal"] == 123.0 + assert details["pedestal_source"] == "per_frame_estimate" + + def test_pedestal_defaults_to_profile(self): + sqm = SQM("imx462") + mags = [5.0, 6.0, 7.0] + sol, centroids = _mock_solution(mags) + image = _image_with_stars(centroids) + _, details = sqm.calculate( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + ) + assert details["pedestal"] == sqm.profile.bias_offset + + +@pytest.mark.unit +class TestPixelsPerSide: + def test_explicit_pixels_scales_solid_angle(self): + sqm = SQM() + sqm._calc_field_parameters(10.0, pixels_per_side=490) + assert sqm.pixels_total == 490**2 + + def test_calculate_infers_pixels_from_image(self): + sqm = SQM() + mags = [5.0, 6.0, 7.0] + sol, centroids = _mock_solution(mags) + image = _image_with_stars(centroids, size=490) + _, details = sqm.calculate( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + ) + assert details["pixels_per_side"] == 490 + + +@pytest.mark.unit +class TestColorCorrection: + def test_color_term_brightens_red_stars(self): + """mag_eff = V - T*(B-V): a red star's effective mag drops by T*(B-V).""" + from PiFinder.sqm import color_index + + # Monkeypatch the B-V lookup so the test is catalog-independent. + orig = color_index.get_bv + color_index.get_bv = lambda ids: np.array([0.0, 1.0, 0.5], dtype=np.float32) + try: + sqm = SQM() + mags = [5.0, 5.0, 5.0] + sol, centroids = _mock_solution(mags, catids=[1, 2, 3]) + image = _image_with_stars(centroids) + # Without colour correction + _, d0 = sqm.calculate( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + color_coefficient=0.0, + ) + # With T=0.8 + _, d1 = sqm.calculate( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + color_coefficient=0.8, + ) + assert d0["n_color_corrected"] == 0 + assert d1["n_color_corrected"] == 3 + # star_mags stays V; the effective mag change lowers mzero for red stars + assert d1["mzero"] < d0["mzero"] + finally: + color_index.get_bv = orig + + def test_missing_bv_falls_back_to_v(self): + from PiFinder.sqm import color_index + + orig = color_index.get_bv + color_index.get_bv = lambda ids: np.array([np.nan, 1.0], dtype=np.float32) + try: + sqm = SQM() + sol, centroids = _mock_solution([5.0, 6.0], catids=[1, 2]) + image = _image_with_stars(centroids) + _, d = sqm.calculate( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + color_coefficient=0.8, + ) + assert d["n_color_corrected"] == 1 # only the star with finite B-V + finally: + color_index.get_bv = orig + + +@pytest.mark.unit +class TestColorCoefficientProfiles: + def test_bare_color_sensors_have_positive_coefficient(self): + assert get_camera_profile("imx462").color_coefficient == pytest.approx(0.8) + assert get_camera_profile("imx290").color_coefficient == pytest.approx(0.8) + + def test_ir_cut_sensor_has_zero_coefficient(self): + assert get_camera_profile("hq").color_coefficient == 0.0 + + def test_detect_imx462_key(self): + assert detect_camera_type("imx462") == "imx462" + assert detect_camera_type("imx290") == "imx462" + + +def _wing_star_frame(scale=1.0, size=220, bg=500.0, noise=2.0, seed=1): + """Frame with 6 well-separated synthetic stars: Gaussian core (70% of + flux, sigma 1.5) + wide halo (30%, sigma 5). Returns (image, centroids, + true enclosed fraction within r=5).""" + rng = np.random.default_rng(seed) + image = bg + rng.normal(0, noise, (size, size)) + centroids = [ + (55.0, 55.0), + (55.0, 165.0), + (165.0, 55.0), + (165.0, 165.0), + (110.0, 55.0), + (110.0, 165.0), + ] + total = 50000.0 * scale + core_flux, halo_flux = 0.7 * total, 0.3 * total + sig_c, sig_h = 1.5, 5.0 + amp_c = core_flux / (2 * np.pi * sig_c**2) + amp_h = halo_flux / (2 * np.pi * sig_h**2) + yy, xx = np.mgrid[0:size, 0:size] + for cy, cx in centroids: + r2 = (yy - cy) ** 2 + (xx - cx) ** 2 + image += amp_c * np.exp(-r2 / (2 * sig_c**2)) + image += amp_h * np.exp(-r2 / (2 * sig_h**2)) + # analytic enclosed fraction within r=5 + enc = lambda r, s: 1 - np.exp(-(r**2) / (2 * s**2)) # noqa: E731 + f_true = (core_flux * enc(5, sig_c) + halo_flux * enc(5, sig_h)) / total + return image, centroids, f_true + + +@pytest.mark.unit +class TestDerotateCentroids: + """The camera process rotates the solve image for the display; SQM must + counter-rotate star positions onto the raw frame. Validate against PIL's + actual rotation for every quarter turn and an arbitrary angle.""" + + def _frame_with_star(self, size=200, pos=(60.0, 140.0)): + from PIL import Image as PILImage + + img = np.zeros((size, size), dtype=np.float64) + iy, ix = int(pos[0]), int(pos[1]) + img[iy - 1 : iy + 2, ix - 1 : ix + 2] = 100.0 + img[iy, ix] = 255.0 + return PILImage.fromarray(img) + + @pytest.mark.parametrize("angle", [0, 90, 180, 270, 30]) + def test_round_trip_matches_pil(self, angle): + from PiFinder.solver import _derotate_centroids + + size = 200 + raw_pos = (60.0, 140.0) + rotated = np.asarray(self._frame_with_star(size, raw_pos).rotate(angle)) + # find the star in the rotated (solve) image + ry, rx = np.unravel_index(np.argmax(rotated), rotated.shape) + back = _derotate_centroids(np.array([[float(ry), float(rx)]]), angle, size)[0] + assert abs(back[0] - raw_pos[0]) <= 1.5 + assert abs(back[1] - raw_pos[1]) <= 1.5 + + +@pytest.mark.unit +class TestWingEstimator: + def test_measures_enclosed_fraction(self): + image, centroids, f_true = _wing_star_frame() + est = WingEstimator() + f = est.add_frame(image, centroids, saturation_threshold=1e9) + assert f is not None + assert abs(f - f_true) < 0.10 + + def test_correction_after_conditioning(self): + image, centroids, f_true = _wing_star_frame() + est = WingEstimator(min_samples=3) + assert est.correction() == 0.0 + assert not est.is_conditioned + for _ in range(3): + est.add_frame(image, centroids, saturation_threshold=1e9) + assert est.is_conditioned + expected = -2.5 * np.log10(f_true) + assert abs(est.correction() - expected) < 0.15 + assert est.correction() > 0 # missing flux -> positive mzero correction + + def test_faint_stars_rejected(self): + image, centroids, _ = _wing_star_frame(scale=0.002) + est = WingEstimator() + assert est.add_frame(image, centroids, saturation_threshold=1e9) is None + assert est.correction() == 0.0 + + def test_saturated_cores_skipped(self): + image, centroids, _ = _wing_star_frame() + est = WingEstimator() + # threshold below the star peaks -> every core is "saturated" + assert est.add_frame(image, centroids, saturation_threshold=600.0) is None + + def test_reset(self): + image, centroids, _ = _wing_star_frame() + est = WingEstimator(min_samples=1) + est.add_frame(image, centroids, saturation_threshold=1e9) + assert est.is_conditioned + est.reset() + assert not est.is_conditioned + assert est.correction() == 0.0 + + +@pytest.mark.unit +class TestMzeroCorrection: + def test_correction_shifts_sqm(self): + sqm = SQM() + mags = [5.0, 6.0, 7.0] + sol, centroids = _mock_solution(mags) + image = _image_with_stars(centroids) + kwargs = dict( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + ) + v0, d0 = sqm.calculate(**kwargs) + v1, d1 = sqm.calculate(mzero_correction=0.5, **kwargs) + assert v0 is not None and v1 is not None + assert abs((v1 - v0) - 0.5) < 1e-9 + assert d0["mzero_correction"] == 0.0 + assert d1["mzero_correction"] == 0.5 + + +@pytest.mark.unit +class TestBVClamp: + def test_very_red_star_clamped(self, monkeypatch): + """B-V beyond the fitted range is clamped before the color term.""" + from PiFinder.sqm import color_index + from PiFinder.sqm.sqm import BV_CLAMP_MAX + + sqm = SQM("imx462") # color_coefficient = 0.8 + mags = [5.0, 6.0, 7.0] + sol, centroids = _mock_solution(mags, catids=[1, 2, 3]) + image = _image_with_stars(centroids) + + def run(bv_value): + monkeypatch.setattr( + color_index, + "get_bv", + lambda ids: np.full(len(list(ids)), bv_value, dtype=np.float32), + ) + v, _ = sqm.calculate( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + ) + return v + + # B-V = 3.0 must act exactly like B-V = BV_CLAMP_MAX + assert run(3.0) == pytest.approx(run(BV_CLAMP_MAX), abs=1e-6) + # ...and differently from an unclamped mid-range value + assert abs(run(3.0) - run(0.5)) > 0.1 + + +@pytest.mark.unit +class TestBandOffset: + def test_band_offset_applied(self, monkeypatch): + sqm = SQM("imx462") # sqm_band_offset = 0.43 + mags = [5.0, 6.0, 7.0] + sol, centroids = _mock_solution(mags) + image = _image_with_stars(centroids) + kwargs = dict( + centroids=[], + solution=sol, + image=image, + exposure_sec=0.5, + altitude_deg=90.0, + saturation_threshold=65000, + color_coefficient=0.0, + ) + v1, d1 = sqm.calculate(**kwargs) + assert d1["sqm_band_offset"] == pytest.approx(0.61) + # zeroing the profile offset must shift the result by exactly 0.61. + # get_camera_profile returns the shared profile object, so use + # monkeypatch to restore it after the test. + monkeypatch.setattr(sqm.profile, "sqm_band_offset", 0.0) + v0, _ = sqm.calculate(**kwargs) + assert (v1 - v0) == pytest.approx(0.61, abs=1e-9) + + def test_band_offset_values(self): + assert get_camera_profile("imx462").sqm_band_offset == pytest.approx(0.61) + assert get_camera_profile("imx290").sqm_band_offset == pytest.approx(0.61) + assert get_camera_profile("hq").sqm_band_offset == pytest.approx(0.60) + assert get_camera_profile("imx296").sqm_band_offset == pytest.approx(-0.10) diff --git a/python/tests/test_state_datetime.py b/python/tests/test_state_datetime.py index 350e42f31..f92d77983 100644 --- a/python/tests/test_state_datetime.py +++ b/python/tests/test_state_datetime.py @@ -49,7 +49,10 @@ def test_naive_input_is_interpreted_as_utc(frozen_clock): shared_state = SharedStateObj() # Note: linter will refuse naive datetimes, we have to trick it # to get past it, make an 'acceptable' datetime then nuke the tzinfo - shared_state.set_datetime(datetime.datetime(2024, 6, 28, 11, 0, 0, tzinfo=UTC).replace(tzinfo=None), force=True) + shared_state.set_datetime( + datetime.datetime(2024, 6, 28, 11, 0, 0, tzinfo=UTC).replace(tzinfo=None), + force=True, + ) stored = shared_state.utc_datetime() assert stored.utcoffset() == datetime.timedelta(0) diff --git a/python/tests/test_sys_utils.py b/python/tests/test_sys_utils.py index ce9b1f1b2..02e1e33b1 100644 --- a/python/tests/test_sys_utils.py +++ b/python/tests/test_sys_utils.py @@ -106,6 +106,29 @@ def test_rewrite_hosts_ignores_commented_line(): assert "# 127.0.1.1 oldname\n" in result assert result.endswith("127.0.1.1\tpf-rich\n") + @pytest.mark.unit + def test_upgrade_progress_missing_status_failed_service(tmp_path, monkeypatch): + monkeypatch.setattr(sys_utils, "UPGRADE_STATUS_FILE", tmp_path / "missing") + monkeypatch.setattr(sys_utils, "_upgrade_service_state", lambda: "failed") + + assert sys_utils.get_upgrade_progress()["phase"] == "failed" + + @pytest.mark.unit + def test_upgrade_progress_stale_downloading_failed_service(tmp_path, monkeypatch): + status = tmp_path / "upgrade-status" + status.write_text("downloading 1/10 paths") + monkeypatch.setattr(sys_utils, "UPGRADE_STATUS_FILE", status) + monkeypatch.setattr(sys_utils, "_upgrade_service_state", lambda: "failed") + + assert sys_utils.get_upgrade_progress()["phase"] == "failed" + + @pytest.mark.unit + def test_upgrade_progress_missing_status_active_service(tmp_path, monkeypatch): + monkeypatch.setattr(sys_utils, "UPGRADE_STATUS_FILE", tmp_path / "missing") + monkeypatch.setattr(sys_utils, "_upgrade_service_state", lambda: "active") + + assert sys_utils.get_upgrade_progress()["phase"] == "starting" + -except ImportError: +except (ImportError, ValueError): pass diff --git a/python/tests/test_ui_modules.py b/python/tests/test_ui_modules.py index 8fd784a60..ca8d1cf63 100644 --- a/python/tests/test_ui_modules.py +++ b/python/tests/test_ui_modules.py @@ -53,7 +53,7 @@ import pkgutil import queue import shutil -from typing import Iterator, cast +from typing import Iterator from unittest import mock import pytest @@ -87,8 +87,7 @@ from PiFinder.ui.dateentry import UIDateEntry from PiFinder.ui.sqm_calibration import UISQMCalibration from PiFinder.ui.sqm_sweep import UISQMSweep -from PiFinder.ui.sqm_correction import UISQMCorrection -from PiFinder.ui.software import UIMigrationConfirm, UIMigrationProgress +from PiFinder.ui.sqm_correct import UISQMCorrect # --------------------------------------------------------------------------- # @@ -122,8 +121,11 @@ # UIModule subclasses that are intentionally *not* exercised, with the reason. # Keeps the completeness guard (test_all_ui_modules_covered) honest. _COVERAGE_SKIP: dict[str, str] = { - # (UISQMCorrection is covered via the dynamic fixtures) - "UIReleaseNotes": "fetches markdown via HTTP in active(); needs a network mock", + "UIReleaseNotes": ( + "Pushed onto the stack by UISoftware's Notes action with a " + "notes-payload item_definition; not reachable from the menu tree " + "and needs live update-channel state to construct." + ), } # Bound on the auto-sweep so a handler that keeps pushing modules @@ -184,9 +186,7 @@ def _node_id(node) -> str: "UIDateEntry", "UISQMCalibration", "UISQMSweep", - "UISQMCorrection", - "UIMigrationConfirm", - "UIMigrationProgress", + "UISQMCorrect", ] @@ -222,30 +222,12 @@ def _build_dynamic_item_definition(spec_id: str, sample_object) -> dict: if spec_id == "UISQMSweep": # sqm.py:302 return {"name": "SQM Sweep", "class": UISQMSweep, "label": "sqm_sweep"} - if spec_id == "UISQMCorrection": - # No launch site in the tree; best-effort fixture - # (constructs from sqm()). + if spec_id == "UISQMCorrect": + # sqm.py:_launch_correct return { - "name": "SQM Correction", - "class": UISQMCorrection, - "label": "sqm_correction", - } - if spec_id == "UIMigrationConfirm": - # Pushed by UISoftware.key_square() after a 7x-square unlock. - return { - "name": "Confirm Migration", - "class": UIMigrationConfirm, - "version_info": {"version": "2.5.0"}, - "current_version": "2.4.0", - "label": "migration_confirm", - } - if spec_id == "UIMigrationProgress": - # Pushed by UIMigrationConfirm after the user confirms. - return { - "name": "Migration Progress", - "class": UIMigrationProgress, - "version_info": {"version": "2.5.0"}, - "label": "migration_progress", + "name": "SQM Correct", + "class": UISQMCorrect, + "label": "sqm_correct", } raise KeyError(spec_id) # pragma: no cover @@ -259,7 +241,11 @@ def _all_uimodule_subclasses() -> set[str]: def _recurse(cls): for sub in cls.__subclasses__(): - found.add(sub.__name__) + # Only classes that live in the UI package count — test helpers + # subclassing UIModule elsewhere (e.g. test_battery_titlebar_icon's + # _BareModule) must not trip the coverage guard. + if sub.__module__.startswith("PiFinder.ui"): + found.add(sub.__name__) _recurse(sub) _recurse(UIModule) @@ -439,7 +425,7 @@ def camera_image(): @pytest.fixture(scope="session") -def catalogs(_no_comet_download) -> Iterator[Catalogs]: +def catalogs(_sandbox_data_dir, _no_comet_download) -> Iterator[Catalogs]: """Build the real catalogs once from the bundled DB. Teardown stops the perpetual catalog timers. The planet and comet catalogs @@ -570,10 +556,7 @@ def _sweep_stack(menu_manager: MenuManager, seen: set) -> None: """ count = 0 while count < _MAX_SWEEP_MODULES: - # MenuManager.stack is annotated list[type[UIModule]] upstream - # but holds instances; cast so the sweep sees them - # as the UIModule instances they are. - pending = [cast(UIModule, m) for m in menu_manager.stack if id(m) not in seen] + pending = [m for m in menu_manager.stack if id(m) not in seen] if not pending: break for module in pending: diff --git a/python/tests/website/conftest.py b/python/tests/website/conftest.py index 3f6e7aec1..d66b9566d 100644 --- a/python/tests/website/conftest.py +++ b/python/tests/website/conftest.py @@ -13,6 +13,9 @@ def _create_local_driver(browser: str): """Create a local WebDriver instance for the given browser.""" if browser == "chrome": options = ChromeOptions() + chrome_binary = os.environ.get("CHROME_BINARY") + if chrome_binary: + options.binary_location = chrome_binary options.add_argument("--headless") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") @@ -36,6 +39,9 @@ def _create_grid_driver(selenium_grid_url: str, browser: str): """Create a remote WebDriver via Selenium Grid.""" if browser == "chrome": options = ChromeOptions() + chrome_binary = os.environ.get("CHROME_BINARY") + if chrome_binary: + options.binary_location = chrome_binary options.add_argument("--headless") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") diff --git a/python/tetra3 b/python/tetra3 deleted file mode 120000 index 866d074d4..000000000 --- a/python/tetra3 +++ /dev/null @@ -1 +0,0 @@ -PiFinder/tetra3/tetra3 \ No newline at end of file diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 000000000..5f6b87bce --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,1791 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" +resolution-markers = [ + "sys_platform == 'linux'", + "sys_platform == 'darwin'", +] +supported-markers = [ + "sys_platform == 'linux'", + "sys_platform == 'darwin'", +] + +[manifest] + +[[manifest.dependency-metadata]] +name = "cedar-solve" +version = "0.5.1" +requires-dist = ["numpy", "pillow", "scipy"] + +[[manifest.dependency-metadata]] +name = "python-libinput" +version = "0.3.0a0" +requires-dist = ["cffi"] + +[[manifest.dependency-metadata]] +name = "python-prctl" +version = "1.8.1" + +[[package]] +name = "adafruit-blinka" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-circuitpython-typing", marker = "sys_platform == 'linux'" }, + { name = "adafruit-platformdetect", marker = "sys_platform == 'linux'" }, + { name = "adafruit-pureio", marker = "sys_platform == 'linux'" }, + { name = "binho-host-adapter", marker = "sys_platform == 'linux'" }, + { name = "pyftdi", marker = "sys_platform == 'linux'" }, + { name = "sysv-ipc", marker = "platform_machine != 'mips' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/43/1addb059d8e589799571718f4c6f7456a3112681c9f92442939d06f67605/adafruit_blinka-9.1.0.tar.gz", hash = "sha256:6d17122358d5f9c4a550eae4f78207ac4c239662236bb37fc646b9f4166e3248", size = 929886, upload-time = "2026-04-21T18:29:55.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/97/17dac675981730d29b2d583de2aa0a9c2963d6c63f6bdc7eebf2711914ef/adafruit_blinka-9.1.0-py3-none-any.whl", hash = "sha256:6f617d4ebb7c2e14dfe1259c63f21df4a77d1b12d5efd92cf71ae4bf34d21b91", size = 1059892, upload-time = "2026-04-21T18:29:54.24Z" }, +] + +[[package]] +name = "adafruit-circuitpython-bno055" +version = "5.4.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-busdevice", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-register", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/bc/ce0935061f77f7f48e1ecf35111cafeadbb8e7dbd450ab05e0d235fb8010/adafruit_circuitpython_bno055-5.4.22.tar.gz", hash = "sha256:8f67c4f24d9d01eaf1ede1ee2f1e04038bbe4227ac60e968bc34a18bb5d2ca98", size = 2191414, upload-time = "2026-04-23T20:55:31.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/d5/4d500c4f5b0ad8d643d9dd1b22e3b45b5e33beff4357b2473611d966f40e/adafruit_circuitpython_bno055-5.4.22-py3-none-any.whl", hash = "sha256:4240371dbffd501b7f6863819b85d0524edcb7f83f3c17b4176ab5d4fb2bd2ff", size = 10724, upload-time = "2026-04-23T20:55:30.989Z" }, +] + +[[package]] +name = "adafruit-circuitpython-busdevice" +version = "5.2.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-typing", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/c0/f789bfc16d2e7eed23171f264b961ceb25314ba92d733be5bd47a4ecb23e/adafruit_circuitpython_busdevice-5.2.17.tar.gz", hash = "sha256:01887ba0056d3635536f0bf1e580a2969c67fc2c4c7b42a4093bcf7a3308bc9b", size = 24423, upload-time = "2026-04-23T21:18:13.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/0d/66a4e0fbd7b35107f7dee04fed890f77b83d1da9dd1f7474af2ed21700ea/adafruit_circuitpython_busdevice-5.2.17-py3-none-any.whl", hash = "sha256:5a834fbe0b88b07d20494bec566815da154aa4b1b668e2e665277b34b3578e44", size = 7494, upload-time = "2026-04-23T21:18:12.284Z" }, +] + +[[package]] +name = "adafruit-circuitpython-connectionmanager" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/29/1653838bc0e5c6119fa2b03edb58b592a2475e7cf99810064a99e5eb8994/adafruit_circuitpython_connectionmanager-3.1.8.tar.gz", hash = "sha256:ce7436d62ac26312fbd2fc7d8f70ab0582a7c7807d7033ae5bd5cb53e4f66f3b", size = 33828, upload-time = "2026-04-23T21:18:42.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/43/929d17e5dbe0773e3a3c728b12cf1a777ada32639764b09365a1b56703c0/adafruit_circuitpython_connectionmanager-3.1.8-py3-none-any.whl", hash = "sha256:f93e27874a840f728b5cdbb1bcf0aee4e75ed1c0ba46b4562606ac3ac3ea2cca", size = 7755, upload-time = "2026-04-23T21:18:40.984Z" }, +] + +[[package]] +name = "adafruit-circuitpython-register" +version = "1.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-busdevice", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-typing", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/68/9eea7a41e92a8c641b3b457925001d5b0d15161f54e2c55effb36f715915/adafruit_circuitpython_register-1.11.3.tar.gz", hash = "sha256:4da69922e5f4fed9842dd90bc2e58848e2bc3ea959cbec788d4fb4b5d41021ae", size = 31696, upload-time = "2026-04-23T21:24:47.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/12/e23f35b7b16d39295c0c63765b1be0342ae33991bb64974a86fcb29a7142/adafruit_circuitpython_register-1.11.3-py3-none-any.whl", hash = "sha256:83b5e9ad1b7afb35330a549ef62fb3ce5cf8e13128ef52fb6eb13f3cd8f2cca6", size = 19009, upload-time = "2026-04-23T21:24:46.961Z" }, +] + +[[package]] +name = "adafruit-circuitpython-requests" +version = "4.1.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-connectionmanager", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/5c/cb31dd0a6e56a92bd9bf672539ba6102204bc443fe9af9a5f8e893b99169/adafruit_circuitpython_requests-4.1.17.tar.gz", hash = "sha256:7259976be340324d34da1ba6f4b935430b46ceece2e5c1632387a24e6f94e9a3", size = 67777, upload-time = "2026-04-23T21:24:48.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/8c/15a2de09cc3c30793336cf79798948768611463ddfb2b2669f51569228fb/adafruit_circuitpython_requests-4.1.17-py3-none-any.whl", hash = "sha256:4c205188a052f52b3bb8ab4af97798d7d56ae3701857d31f03b164f029fae44f", size = 10841, upload-time = "2026-04-23T21:24:47.522Z" }, +] + +[[package]] +name = "adafruit-circuitpython-typing" +version = "1.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-busdevice", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-requests", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/a2/40a3440aed2375371507af668570b68523ee01db9c25c47ce5a05883170e/adafruit_circuitpython_typing-1.12.3.tar.gz", hash = "sha256:63f196f834e47842bcd4cf8c37aaa0c61e1aeb5d07f056c875fc3016cda91a12", size = 25603, upload-time = "2025-10-27T18:17:38.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/a1/578a03ba2bce0809b4e30974b47958963c9efe67b9fe74e7dbcdbbd45318/adafruit_circuitpython_typing-1.12.3-py3-none-any.whl", hash = "sha256:f6d0a02150e1e4efb5a2c2945b88d948809fdb465875f39947108b8467c986d9", size = 11014, upload-time = "2025-10-27T18:17:37.771Z" }, +] + +[[package]] +name = "adafruit-extended-bus" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/1c/26ccaf2d7a3fd3d16504173df591973b9f1de5520b0b4d81af038b9e4ed3/adafruit-extended-bus-1.0.2.tar.gz", hash = "sha256:f34e3c114e274e5aa475673794019af1ab438df6e45141e16201b6a8c4bea3ea", size = 18966, upload-time = "2021-06-14T15:03:48.711Z" } + +[[package]] +name = "adafruit-platformdetect" +version = "3.88.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/92/3d991a9e322855be20c2df771b632ed81f1640b43a9969524765da23f4af/adafruit_platformdetect-3.88.0.tar.gz", hash = "sha256:dc2188ddb348bfd2a02a9533263294cc0f1762bd9f6b3b20866547d98820cd71", size = 49558, upload-time = "2026-02-24T19:01:07.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/c4/32572c051f1554d73633a802447321bff2a2332ef4210d47de807afd26c7/adafruit_platformdetect-3.88.0-py3-none-any.whl", hash = "sha256:69e694d80d551c6cb8e39f731e6ee0de1f135e64cffee0e2a665b1f9579c10d7", size = 26964, upload-time = "2026-02-24T19:01:05.646Z" }, +] + +[[package]] +name = "adafruit-pureio" +version = "1.1.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/b7/f1672435116822079bbdab42163f9e6424769b7db778873d95d18c085230/Adafruit_PureIO-1.1.11.tar.gz", hash = "sha256:c4cfbb365731942d1f1092a116f47dfdae0aef18c5b27f1072b5824ad5ea8c7c", size = 35511, upload-time = "2023-05-25T19:01:34.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/9d/28e9d12f36e13c5f2acba3098187b0e931290ecd1d8df924391b5ad2db19/Adafruit_PureIO-1.1.11-py3-none-any.whl", hash = "sha256:281ab2099372cc0decc26326918996cbf21b8eed694ec4764d51eefa029d324e", size = 10678, upload-time = "2023-05-25T19:01:32.397Z" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "av" +version = "17.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/e3/477fa20578c284abeda08d91b63ee9abaebc93445d8feeb989d3d444bae1/av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3", size = 4288546, upload-time = "2026-06-07T05:52:55.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/87/8036b5c781bc3639ea04ef42d4e26da253bd4bd4311d8705b6a1c8824047/av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41", size = 22460847, upload-time = "2026-06-07T05:52:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/dfdf6fc7b17814b50d0aa9e7a7e37b87be91be3890f44b0d525433cd1fd1/av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9", size = 18159115, upload-time = "2026-06-07T05:52:06.64Z" }, + { url = "https://files.pythonhosted.org/packages/ad/13/64f6c466471cea225b8b2f4cdc51a571f8a286984b55a08d169b932fda5d/av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847", size = 33224427, upload-time = "2026-06-07T05:52:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/77/43/96b35170bf2e64e00a41748c6400ff73232dc0fc62ded283679fb07c7fe0/av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f", size = 35370183, upload-time = "2026-06-07T05:52:11.959Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b3/8e8b4b6498731bfbd88e8399a756543f8088f1bd33d08eab678b5aebe728/av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702", size = 24459265, upload-time = "2026-06-07T05:52:14.66Z" }, + { url = "https://files.pythonhosted.org/packages/14/ac/ceb84b7553db21f1143d817245c560d9267168e1e58b1a8eeae2b62c4d04/av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f", size = 34283709, upload-time = "2026-06-07T05:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/4115fd84148c9a1cf365096694be6ac882fd3cd3cdb7a2f35e71fecf1631/av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0", size = 25397573, upload-time = "2026-06-07T05:52:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/92e52d5ed0e0b84d9d93e52b4338c2713d8a44082b8696e6516fdae7c4e4/av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e", size = 36451495, upload-time = "2026-06-07T05:52:22.581Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "binho-host-adapter" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyserial", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/36/29b7b896e83e195fac6d64ccff95c0f24a18ee86e7437a22e60e0331d90a/binho-host-adapter-0.1.6.tar.gz", hash = "sha256:1e6da7a84e208c13b5f489066f05774bff1d593d0f5bf1ca149c2b8e83eae856", size = 10068, upload-time = "2020-06-04T19:38:11.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/6b/0f13486003aea3eb349c2946b7ec9753e7558b78e35d22c938062a96959c/binho_host_adapter-0.1.6-py3-none-any.whl", hash = "sha256:f71ca176c1e2fc1a5dce128beb286da217555c6c7c805f2ed282a6f3507ec277", size = 10540, upload-time = "2020-06-04T19:38:10.612Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cbor2" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/af/473c241e41c142ea06ebef8d1f660fa6ff928fb97210e7bec8ee5974f8cd/cbor2-6.1.2.tar.gz", hash = "sha256:6b43037a66947dee5af0abb1a4c3a13b3abac5a4a3f32f9771efbbcd030fd909", size = 86760, upload-time = "2026-06-02T19:01:29.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/dc/bc045c8f36317e4e5f7a60d94b36833139909fc32e3a65f44bc61a36def0/cbor2-6.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f1aa38c422d87ea61849b2a823b10b64053fb4da8763f19ac78ea9a69d682b2a", size = 408846, upload-time = "2026-06-02T19:00:55.476Z" }, + { url = "https://files.pythonhosted.org/packages/2b/36/d66f5f0dd98ecbdcfc7da1fbd423f7b3782a27719f0062a560476f00b334/cbor2-6.1.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ff7d0bd8ff432832338a8d2430aee34f8a082342480ff537c0ba90e2b8ff7894", size = 454624, upload-time = "2026-06-02T19:00:56.744Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/4884b9cf03db14dc5007825d5d1bf8678a75c49d4268d8e0c1c6e9580104/cbor2-6.1.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c1eedf3290d88a5f663bd8b4b8f0f0e2103d0594c293fa5f4e62e53100972309", size = 466585, upload-time = "2026-06-02T19:00:58.209Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/36a15beb3915f56a79d6e9213c6d40c0f5cb90cd3462923f555d78068847/cbor2-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3049b04bddf9a5a2d0e5bb25dccdaf4552fcaf607b404e249d4f78f010fcc7d0", size = 521678, upload-time = "2026-06-02T19:00:59.524Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3f/e899313371ebeb7a191d751de97ccd8242abc24bbc9d8e2c58e04475cfb0/cbor2-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96eb687a62040401668f06a85de8f47361ef44574de1493899e0ec678109fc04", size = 534044, upload-time = "2026-06-02T19:01:00.875Z" }, +] + +[[package]] +name = "cedar-solve" +version = "0.5.1" +source = { git = "https://github.com/smroid/cedar-solve?rev=d8ff1d857a363c88917fd8e126ab90e24b1cfbcc#d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "(implementation_name != 'PyPy' and sys_platform == 'darwin') or (implementation_name != 'PyPy' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-inspect", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + +[[package]] +name = "dbus-python" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/24/63118050c7dd7be04b1ccd60eab53fef00abe844442e1b6dec92dae505d6/dbus-python-1.4.0.tar.gz", hash = "sha256:991666e498f60dbf3e49b8b7678f5559b8a65034fdf61aae62cdecdb7d89c770", size = 232490, upload-time = "2025-03-13T19:57:54.212Z" } + +[[package]] +name = "evdev" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "itsdangerous", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-babel" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pytz", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/1a/4c65e3b90bda699a637bfb7fb96818b0a9bbff7636ea91aade67f6020a31/flask_babel-4.0.0.tar.gz", hash = "sha256:dbeab4027a3f4a87678a11686496e98e1492eb793cbdd77ab50f4e9a2602a593", size = 10178, upload-time = "2023-10-02T01:10:49.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/c2/e0ab5abe37882e118482884f2ec660cd06da644ddfbceccf5f88f546b574/flask_babel-4.0.0-py3-none-any.whl", hash = "sha256:638194cf91f8b301380f36d70e2034c77ee25b98cb5d80a1626820df9a6d4625", size = 9602, upload-time = "2023-10-02T01:10:48.58Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "gpsdclient" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/85/9bfbc7ea5dd5c61f43ad048efe10d0a5a2d8ffd82143329fa380771221b8/gpsdclient-1.3.2.tar.gz", hash = "sha256:70a496550a9747dff5e0e50b3c95a6e1dcab9d842860997e95120767e2060a7a", size = 7619, upload-time = "2023-01-09T11:29:17.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/e9/f8a624fbbe177da2274e8d37d08fabde8269e8fead25b22deda94c3caf88/gpsdclient-1.3.2-py3-none-any.whl", hash = "sha256:35a7f781ae69a04f2d80278a6ae94564e524efaf061646c0a9bbb6ba4ffbcac8", size = 7934, upload-time = "2023-01-09T11:29:16.461Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h3" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/1c/12f1e2842d6493de4dd8244538c30a556712e9a6b25c5151a0e0e522a67e/h3-4.5.0.tar.gz", hash = "sha256:a1e279a1674fc799445c710e35bc4b1b388a406c881d8b5e59a9b8bebeb5bb43", size = 180838, upload-time = "2026-05-30T00:59:24.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a9/bb36156db3a1f9eebb27de9c72d1229c69a643bc5bf9f59cbc05a8b0a634/h3-4.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44f9eee75985ecf06af82cfbce5fe7a0fd1cae73bee53d15155fe8fdb165578a", size = 843578, upload-time = "2026-05-30T00:59:07.415Z" }, + { url = "https://files.pythonhosted.org/packages/00/d0/4256f2515f8dd1a322e95a7a5f4174ecc405098f8b217d1d29767989c171/h3-4.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8fe70eef1c122e7465f3b9c57f793fa1a6885cf067be3a83423c0f30c0d80c", size = 1007119, upload-time = "2026-05-30T00:59:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/a60d26681ac540788c4ef656960084c9cbf4c24657f7e7347f07e97ba27f/h3-4.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df23f9ff0a9ff9c6195f48ebc8fb8fc6d50c2025ec37649991749d5282a2950f", size = 1059523, upload-time = "2026-05-30T00:59:09.854Z" }, + { url = "https://files.pythonhosted.org/packages/de/76/6e2eab23667a6ee153e3c369fb6fb793d4b09c81030495da989e8e5bf66d/h3-4.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e8af93363b9b14fe1797a2557b22bb158b1be7696f145ea8ee6f8b9315860fa", size = 1069134, upload-time = "2026-05-30T00:59:11.235Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jplephem" +version = "2.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/8b/a50514f000fcd0207cd281370b0db66e7712a5db9f96b77a0301a7205f96/jplephem-2.24.tar.gz", hash = "sha256:354fe1adae022264ab46f18afb6af26211277cfd7b3ef90400755fcabe93bc11", size = 45289, upload-time = "2026-01-23T21:03:01.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/3f/b9d5739352badc11ca637c8f72525d519458622936bc3313ddefdc7dee96/jplephem-2.24-py3-none-any.whl", hash = "sha256:2de15608a0f13010a71a0a8af8765646d5884402006dac0dd7639d7db13629ac", size = 49585, upload-time = "2026-01-23T21:03:00.079Z" }, +] + +[[package]] +name = "json5" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb", size = 52656, upload-time = "2026-03-27T22:50:48.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jsonschema-specifications", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "rpds-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "libarchive-c" +version = "5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/23/e72434d5457c24113e0c22605cbf7dd806a2561294a335047f5aa8ddc1ca/libarchive_c-5.3.tar.gz", hash = "sha256:5ddb42f1a245c927e7686545da77159859d5d4c6d00163c59daff4df314dae82", size = 54349, upload-time = "2025-05-22T08:08:04.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/3f/ff00c588ebd7eae46a9d6223389f5ae28a3af4b6d975c0f2a6d86b1342b9/libarchive_c-5.3-py3-none-any.whl", hash = "sha256:651550a6ec39266b78f81414140a1e04776c935e72dfc70f1d7c8e0a3672ffba", size = 17035, upload-time = "2025-05-22T08:08:03.045Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, +] + +[[package]] +name = "luma-core" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbor2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "smbus2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/a3/0abb456daf2279483579bed6cf2a7305f93f56ab89f0f238f206fffce303/luma_core-2.5.3.tar.gz", hash = "sha256:ecfb1c12fc32f8ee6cff0f613804b2609387c17547f739d002649f2e6d56ec2f", size = 105745, upload-time = "2025-12-16T21:56:28.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/de/eb014859db3b59eaa35b157451121fbd8cffb96da8f4f52b4fa223fe0bc7/luma_core-2.5.3-py3-none-any.whl", hash = "sha256:ad466acb7bc805ad87cf1ed591d1d0588c3fa9900cba338d4eebf02a4226b95c", size = 72744, upload-time = "2025-12-16T21:56:26.277Z" }, +] + +[[package]] +name = "luma-emulator" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "luma-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pygame", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/ea/4962e87863341c70996b02ab3387c902b7308c153251373765d78c19ef9e/luma_emulator-1.7.0.tar.gz", hash = "sha256:0f4bc1d528fe4f4aa4a6f98c8f7120b915bba1878f67899067ba88e98250b444", size = 879307, upload-time = "2026-02-01T17:14:46.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/1b/98b23ed86658b0134fefcbeea986943acfc368f3e37b5b691219edefb3eb/luma_emulator-1.7.0-py2.py3-none-any.whl", hash = "sha256:0accb342e12441bdb602c5daa8a1d54a93e09eba8f0c4be2093d7ab066dfa151", size = 27129, upload-time = "2026-02-01T17:14:45.358Z" }, +] + +[[package]] +name = "luma-lcd" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "luma-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/8f/75cf0bf8c97c3d13766d3b6bb86be4835f9f7c79dff20f89fdfb4ea23440/luma_lcd-2.13.0.tar.gz", hash = "sha256:e814dd3f4c12fe6febe5ce85b98362834b3396bea108fa70f9325f44ec3226f8", size = 25330324, upload-time = "2026-02-01T17:05:44.817Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/f6/c3a7e043d4cbc0af443a5a54c13b15b0b3632bc559996599b8bcd82e9477/luma_lcd-2.13.0-py2.py3-none-any.whl", hash = "sha256:a4a3483d87b9608ce64e3cb767547ef3334fc5b3f26a3821c5462240c1a10feb", size = 34810, upload-time = "2026-02-01T17:05:43.376Z" }, +] + +[[package]] +name = "luma-oled" +version = "3.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "luma-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/36/cad8c85b0206ffbbbb7d2609fdb376666521a503837b6e853a4600f09d5f/luma_oled-3.15.0.tar.gz", hash = "sha256:16925fe668f484803df0683add800b19e5dd7316a1d64eb06ec2ae817473901e", size = 20220114, upload-time = "2026-03-07T14:25:42.169Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/17/0c5addb4e42b3494a11384344f1899e4f2b9b98c64c0285cb426963af255/luma_oled-3.15.0-py3-none-any.whl", hash = "sha256:2928d9465ab71b1cd8538c6aec2d51c0fc61a42a5bd27b51b0e6fdd80bc0fd39", size = 33829, upload-time = "2026-03-07T14:25:40.071Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "librt", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, + { name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, +] + +[[package]] +name = "numpy-quaternion" +version = "2024.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/a0/dad368bca6ef25e2c242fe9a774ee46143d2ab186c521fdc5342e95291a4/numpy_quaternion-2024.0.13.tar.gz", hash = "sha256:e155853fefdfb972b4674f47c30ddb12c825f3ab135a2ea14c67472905c49fd1", size = 66645, upload-time = "2025-11-24T18:51:56.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/60/135ab1c887344d4f1e70a4025733b2e9e19f2349ac21106b713911620a40/numpy_quaternion-2024.0.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0cd3f2256debaffd8407d1829c1ced71d3b910e75245152d1be673fcb7f23f08", size = 86875, upload-time = "2025-11-24T18:51:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/4f/0b/26ba2dbfe74da3a956828dee249483fdb90d0f1b08b2a55872ff20e12736/numpy_quaternion-2024.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:070df97e1ad59d6a41b4759ddba53e214488de63c948f144c3c61c9374a64f8d", size = 61668, upload-time = "2025-11-24T18:51:57.82Z" }, + { url = "https://files.pythonhosted.org/packages/af/2b/bb67708f88beea90bddc74229ac7eaa1d9c38c7415179e16a6cba013f5b3/numpy_quaternion-2024.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87f062c9258baa5c00ee3dc8c90553314668551d8893d500e101eae25fc4826f", size = 55901, upload-time = "2025-11-24T18:51:50.39Z" }, + { url = "https://files.pythonhosted.org/packages/fe/21/562f81ebae486f6068c12a2be7523c08c2a21110ed0773a1d38088248109/numpy_quaternion-2024.0.13-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b5596e429f3c15d736f23380c7d054903e44fdd4d07a2b9af6f75ec6c9acfe4c", size = 190859, upload-time = "2025-11-24T18:51:47.196Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/3fe4f7957d6d79d508ff99a62951034bd19956f4d2c98ada26d5575ff3cc/numpy_quaternion-2024.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1771afd3abe8477adc0af1fe7b2542eaebf31fafa64a134059e21f8b606d2f0e", size = 185263, upload-time = "2025-11-24T18:51:38.218Z" }, + { url = "https://files.pythonhosted.org/packages/77/77/77e84011dbe4bb08504e45052d7177efeed90cc66139922b6de467a9a4a3/numpy_quaternion-2024.0.13-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:eb438f4d9ab1fd1697f884be0840944d0c55dd934065811473237451b96ec2e2", size = 87811, upload-time = "2025-11-24T18:51:39.625Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3b/343695fc743d2a0814ef9a221a52733c576abe08d17f3795e5b84bfa8355/numpy_quaternion-2024.0.13-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e0a299e25b6d874b4cd169afd0cf4b703a4ddc8817513eb425fa5a6488001116", size = 62102, upload-time = "2025-11-24T18:51:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c5/6a105c9a4ffbe3ec51cc45ed916f0a0d9aae35dc9a8dfd29e8cd66122043/numpy_quaternion-2024.0.13-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:41a84c60e50533f833c536475dc49bce4e53a5c100c639cf503f37216b49c8f8", size = 56447, upload-time = "2025-11-24T18:51:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/61/1b/17612dc0517f30ea2679de936975a5c6c827e299ddcbb2b7cc9471b3de10/numpy_quaternion-2024.0.13-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:af4ee46bd834a822c200fb1dc8dc15bee8fa97e2286227fdd0acf243e6974fb9", size = 196462, upload-time = "2025-11-24T18:51:36.13Z" }, + { url = "https://files.pythonhosted.org/packages/ef/08/57ea9c58700211ef7ecbb30ee3bb076c9f9a4f12595f270b4cf5ea2da1e9/numpy_quaternion-2024.0.13-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77a6c9c10de8636cf3b7706045ef21edfc09746f73d4adb883b525ddcb19823a", size = 193631, upload-time = "2025-11-24T18:52:09.318Z" }, +] + +[[package]] +name = "openexr" +version = "3.4.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/ca/b7aa434bcde0e7222683415ee15121fdd5cf9b0eb6c34f81d83603cc36ae/openexr-3.4.12.tar.gz", hash = "sha256:877da800b30146e5e29851da2a80147883244966f5b2e932e04f1f1a06ff4fc7", size = 25610803, upload-time = "2026-05-25T02:08:10.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/dc/be431b0b11551b3700c539e8b1296475accdfec2c665b9fe708fddf7b27d/openexr-3.4.12-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:3031ed47d3a579d3fa6998d0a6dad22012ce9fd6a33485fa5339ca4a079d0665", size = 2159902, upload-time = "2026-05-25T02:07:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/01/3c/6b520d85f0e37ca01e88601e744472f4768652748a4e7c9f2933c1ba914b/openexr-3.4.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:553f4267221490e846b7f63edcca807fac1757cd242a49063f048fd0e757029c", size = 1023864, upload-time = "2026-05-25T02:07:28.398Z" }, + { url = "https://files.pythonhosted.org/packages/49/43/93762fdef22afb648748910b2c7a4ce58b04a1f3e5d4bdb7fd071b32617b/openexr-3.4.12-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4dc03aa88a57d47c49780bee40c4f0febabf8ed150e2bd60e55f3ee6bea62493", size = 1167448, upload-time = "2026-05-25T02:07:30.381Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f6/36f53b26114955df4c996abb96edb7fc6112fa1db52e30daf1c64b6f9ab1/openexr-3.4.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8be14c4794f4ad19112fbbed7767c5bf6216435f27cb42a499cdab0f3d26562", size = 2159502, upload-time = "2026-05-25T02:07:33.944Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, +] + +[[package]] +name = "pandas-stubs" +version = "3.0.3.260530" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/aa/c41a8a0ff86fd85dbb3ec0c1f3fa488ca64a8b5f82654ae1b07d84acefe5/pandas_stubs-3.0.3.260530.tar.gz", hash = "sha256:d1efe47b2e5a312c047d7feabec5cb7a55365747983420077e9fcbe9ab74f714", size = 113183, upload-time = "2026-05-30T17:47:40.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e0/99ec5b02203c4e9ce878bc63d8caa06ac1f891e4d63bded9a5ced70fcb4f/pandas_stubs-3.0.3.260530-py3-none-any.whl", hash = "sha256:a6277eb1c8cebf48d9b2413fcd2e9a6b4ff479c934a223c29eacbc3058c4cb55", size = 173780, upload-time = "2026-05-30T17:47:39.13Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "picamera2" +version = "0.3.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "libarchive-c", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "openexr", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pidng", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "piexif", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "python-prctl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "simplejpeg", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "videodev2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/9a/1e4a8cb27098735b8d6bf1d68e6ed3e2ca758c078fdebb3728334d3381a8/picamera2-0.3.36.tar.gz", hash = "sha256:3add10c8e5613234f39f271c90b886306e6fa4a64c99196a2451c308bd278d70", size = 109041, upload-time = "2026-05-06T14:51:25.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/e9/484a810cfd4564df7fbf632925971a2d12be8867866aa539f3136ec1cea4/picamera2-0.3.36-py3-none-any.whl", hash = "sha256:99c2b97a65e5739ce68743b79e627b1bbb9024d91bc5e3915b98cfd0dcbec1a0", size = 129664, upload-time = "2026-05-06T14:51:24.521Z" }, +] + +[[package]] +name = "pidng" +version = "4.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/65/2670c465c8a63a23eb3a5e5547262e247e1aa2d3889a0a6781da9109d5f7/pidng-4.0.9.tar.gz", hash = "sha256:560eb008086f8a715fd9e1ab998817a7d4c8500a7f161b9ce6af5ab27501f82c", size = 21907, upload-time = "2022-05-06T19:09:32.093Z" } + +[[package]] +name = "piexif" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/84/a3f25cec7d0922bf60be8000c9739d28d24b6896717f44cc4cfb843b1487/piexif-1.1.3.zip", hash = "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b", size = 1011134, upload-time = "2019-07-01T15:29:23.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/d8/6f63147dd73373d051c5eb049ecd841207f898f50a5a1d4378594178f6cf/piexif-1.1.3-py2.py3-none-any.whl", hash = "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6", size = 20691, upload-time = "2019-07-01T15:43:20.907Z" }, +] + +[[package]] +name = "pifinder" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-bno055", marker = "sys_platform == 'linux'" }, + { name = "adafruit-extended-bus", marker = "sys_platform == 'linux'" }, + { name = "aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "av", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "cedar-solve", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "dataclasses-json", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "dbus-python", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "flask-babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "gpsdclient", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "json5", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "libarchive-c", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "luma-lcd", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "luma-oled", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "numpy-quaternion", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "picamera2", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "pidng", marker = "sys_platform == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydeepskylog", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyerfa", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pygobject", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "python-libinput", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "python-pam", marker = "sys_platform == 'linux'" }, + { name = "python-prctl", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "pytz", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "rpi-gpio", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "rpi-hardware-pwm", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "scikit-learn", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sh", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "simplejpeg", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "skyfield", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "smbus2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "spidev", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "timezonefinder", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "videodev2", marker = "sys_platform == 'linux'" }, + { name = "waitress", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "luma-emulator", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pandas-stubs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyhotkey", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "pynput", marker = "(platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux')" }, + { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "selenium", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "types-aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "types-pynput", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "types-pytz", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "types-tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "types-waitress", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "xlrd", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] + +[package.metadata] +requires-dist = [ + { name = "adafruit-blinka", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-bno055", marker = "sys_platform == 'linux'" }, + { name = "adafruit-extended-bus", marker = "sys_platform == 'linux'" }, + { name = "aiofiles" }, + { name = "av" }, + { name = "cedar-solve", git = "https://github.com/smroid/cedar-solve?rev=d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" }, + { name = "dataclasses-json" }, + { name = "dbus-python", marker = "platform_machine == 'aarch64'" }, + { name = "flask" }, + { name = "flask-babel" }, + { name = "gpsdclient" }, + { name = "grpcio" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "libarchive-c" }, + { name = "luma-lcd" }, + { name = "luma-oled" }, + { name = "numpy" }, + { name = "numpy-quaternion" }, + { name = "pandas" }, + { name = "picamera2", marker = "platform_machine == 'aarch64'" }, + { name = "pidng", marker = "sys_platform == 'linux'" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pydeepskylog" }, + { name = "pyerfa" }, + { name = "pygobject", marker = "platform_machine == 'aarch64'" }, + { name = "pyjwt" }, + { name = "python-libinput", marker = "platform_machine == 'aarch64'", specifier = "==0.3.0a0" }, + { name = "python-pam", marker = "sys_platform == 'linux'" }, + { name = "python-prctl", marker = "platform_machine == 'aarch64'" }, + { name = "pytz" }, + { name = "requests" }, + { name = "rpi-gpio", marker = "platform_machine == 'aarch64'" }, + { name = "rpi-hardware-pwm", marker = "platform_machine == 'aarch64'" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sh", specifier = ">=1.14,<2" }, + { name = "simplejpeg" }, + { name = "skyfield" }, + { name = "smbus2" }, + { name = "spidev", marker = "platform_machine == 'aarch64'" }, + { name = "timezonefinder" }, + { name = "tqdm" }, + { name = "videodev2", marker = "sys_platform == 'linux'" }, + { name = "waitress" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "luma-emulator" }, + { name = "mypy" }, + { name = "pandas-stubs" }, + { name = "pyhotkey", marker = "platform_machine == 'aarch64'" }, + { name = "pynput", marker = "platform_machine == 'aarch64'" }, + { name = "pytest" }, + { name = "selenium" }, + { name = "types-aiofiles" }, + { name = "types-pynput" }, + { name = "types-pytz" }, + { name = "types-requests" }, + { name = "types-tqdm" }, + { name = "types-waitress" }, + { name = "xlrd", specifier = ">=2.0.1" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pycairo" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydeepskylog" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/ed/ea27d8e554cce16ba402aa349251c9d1bbc269efa93a9546a389b9ea1e4e/pydeepskylog-1.6.tar.gz", hash = "sha256:ddeae6d004817cfb50d5c9e0cecaa4654ae72a82cd201bbc5350fa880e1e3e61", size = 37915, upload-time = "2025-07-30T15:01:59.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/00/e7f11ab824ef760fe22c93012b90631d8772c7af3a58d22f15d0b383f51e/pydeepskylog-1.6-py3-none-any.whl", hash = "sha256:f4fa53b1f980a61846a3e79fa8d4134f809703d9ac346dc98bfc49091ee237f9", size = 39814, upload-time = "2025-07-30T15:01:58.864Z" }, +] + +[[package]] +name = "pyerfa" +version = "2.0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/39/63cc8291b0cf324ae710df41527faf7d331bce573899199d926b3e492260/pyerfa-2.0.1.5.tar.gz", hash = "sha256:17d6b24fe4846c65d5e7d8c362dcb08199dc63b30a236aedd73875cc83e1f6c0", size = 818430, upload-time = "2024-11-11T15:22:30.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/d9/3448a57cb5bd19950de6d6ab08bd8fbb3df60baa71726de91d73d76c481b/pyerfa-2.0.1.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b282d7c60c4c47cf629c484c17ac504fcb04abd7b3f4dfcf53ee042afc3a5944", size = 341818, upload-time = "2024-11-11T15:22:16.467Z" }, + { url = "https://files.pythonhosted.org/packages/11/4a/31a363370478b63c6289a34743f2ba2d3ae1bd8223e004d18ab28fb92385/pyerfa-2.0.1.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be1aeb70390dd03a34faf96749d5cabc58437410b4aab7213c512323932427df", size = 329370, upload-time = "2024-11-11T15:22:17.829Z" }, + { url = "https://files.pythonhosted.org/packages/cb/96/b6210fc624123c8ae13e1eecb68fb75e3f3adff216d95eee1c7b05843e3e/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0603e8e1b839327d586c8a627cdc634b795e18b007d84f0cda5500a0908254e", size = 692794, upload-time = "2024-11-11T15:22:19.429Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e0/050018d855d26d3c0b4a7d1b2ed692be758ce276d8289e2a2b44ba1014a5/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e43c7194e3242083f2350b46c09fd4bf8ba1bcc0ebd1460b98fc47fe2389906", size = 738711, upload-time = "2024-11-11T15:22:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f5/ff91ee77308793ae32fa1e1de95e9edd4551456dd888b4e87c5938657ca5/pyerfa-2.0.1.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:07b80cd70701f5d066b1ac8cce406682cfcd667a1186ec7d7ade597239a6021d", size = 722966, upload-time = "2024-11-11T15:22:21.905Z" }, +] + +[[package]] +name = "pyftdi" +version = "0.57.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyserial", marker = "sys_platform == 'linux'" }, + { name = "pyusb", marker = "sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/de/260694fa63dab6629c6ba7c2315de64dbd766eb761198b61fba96cbe7ea4/pyftdi-0.57.2-py3-none-any.whl", hash = "sha256:dec3acdc262594d8b1850a6aee608b861c2973f90011faf5cccae3107d3c67a4", size = 146319, upload-time = "2026-06-02T16:14:37.818Z" }, +] + +[[package]] +name = "pygame" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/cc/08bba60f00541f62aaa252ce0cfbd60aebd04616c0b9574f755b583e45ae/pygame-2.6.1.tar.gz", hash = "sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f", size = 14808125, upload-time = "2024-09-29T13:41:34.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/91/718acf3e2a9d08a6ddcc96bd02a6f63c99ee7ba14afeaff2a51c987df0b9/pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2", size = 13090765, upload-time = "2024-09-29T14:27:02.377Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/9cb315de851a7682d9c7568a41ea042ee98d668cb8deadc1dafcab6116f0/pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171", size = 12381704, upload-time = "2024-09-29T14:27:10.228Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8f/617a1196e31ae3b46be6949fbaa95b8c93ce15e0544266198c2266cc1b4d/pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b", size = 13581091, upload-time = "2024-09-29T11:30:27.653Z" }, + { url = "https://files.pythonhosted.org/packages/3b/87/2851a564e40a2dad353f1c6e143465d445dab18a95281f9ea458b94f3608/pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b", size = 14273844, upload-time = "2024-09-29T11:40:04.138Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/aa23aa2e70bcba42c989c02e7228273c30f3b44b9b264abb93eaeff43ad7/pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c", size = 13951197, upload-time = "2024-09-29T11:40:06.785Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pygobject" +version = "3.56.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/61/978c5fbca34f10a90df362502fbb5a005637909e7b5a0e9212349ea9d010/pygobject-3.56.3.tar.gz", hash = "sha256:12760e4a0e3d04b6eb95e06f7a27e362c826d567ea613373a92c003b6c70d2d6", size = 1411853, upload-time = "2026-05-08T20:46:39.904Z" } + +[[package]] +name = "pyhotkey" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pynput", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/0b/f61560ed6cb554b5973b1902419adf616ed678781e40d7c0de2f4600593f/PyHotKey-1.5.2.tar.gz", hash = "sha256:39b579c038e7850c26aa67cc1f917d5546c9d973ce60ab991fd386a1d49d1ab4", size = 17993, upload-time = "2024-08-01T05:40:25.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f3/9033d43dce32e075430a78d2c2d96fb1f81b16159f459723e3e5f818c3fb/PyHotKey-1.5.2-py3-none-any.whl", hash = "sha256:9a353ac6cd8385038dcf7142df10cf113f8541bc3128e8349b08b051f79d9983", size = 19890, upload-time = "2024-08-01T05:40:23.563Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pynput" +version = "1.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "(sys_platform == 'darwin' and 'linux' in sys_platform) or (sys_platform == 'linux' and 'linux' in sys_platform)" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "(sys_platform == 'darwin' and 'linux' in sys_platform) or (sys_platform == 'linux' and 'linux' in sys_platform)" }, + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/1d/fdef3fdc9dc8dedc65898c8ad0e8922a914bb89c5308887e45f9aafaec36/pynput-1.7.7-py2.py3-none-any.whl", hash = "sha256:afc43f651684c98818de048abc76adf9f2d3d797083cb07c1f82be764a2d44cb", size = 90243, upload-time = "2024-05-10T13:30:04.238Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-coretext", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/6e/8e928d5e3025529ed92c6eb5fd88a5e6e485cc6df945c541f29b4af7f2c6/pyobjc_framework_applicationservices-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8749290f796e6cca341d443769b79329dde5d157bcc4413c1f7fdb68ea4a8e48", size = 32782, upload-time = "2026-06-19T16:05:40.284Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/c7b5a31777fe2ce7c07b9c16941ff4fbf0a150bf755164d96228d32ccb4f/pyobjc_framework_applicationservices-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9ee11677fbd6a0987234814c7dde88ffd11242e8c1f76952e6654ea07f2370ac", size = 33048, upload-time = "2026-06-19T16:05:41.308Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/8c/154e8f34923b24aade64a20eca2b759f8f67e109654308103080751f246f/pyobjc_framework_coretext-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5c5a3c6e2d905a17efb15572dad97ce582feeab5c3b92537015445e0e0bb46de", size = 30116, upload-time = "2026-06-19T16:09:52.219Z" }, + { url = "https://files.pythonhosted.org/packages/01/61/f53458c8f7fe74008e342946eca1fa82b777b284d4e13d8bd2e3e5724cab/pyobjc_framework_coretext-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5c979058c77df8cd3dac5fd7db4c484f9886fbe09e2687bfaf269a856f631f78", size = 30659, upload-time = "2026-06-19T16:09:53.034Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, +] + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-libinput" +version = "0.3.0a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/26/9db7619dd90e5575ece0f029630099bc9be53eaa84b37aa54232bfe012bb/python-libinput-0.3.0a0.tar.gz", hash = "sha256:7e3d3c9786aaa79bf2f14601648581b4624b692d3f0d9199902d0d0834219302", size = 28441, upload-time = "2018-03-19T16:53:01.086Z" } + +[[package]] +name = "python-pam" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/879f1c849e886b783239b8a4710daac73535ba2cfcf672ee4548543e3a74/python-pam-2.0.2.tar.gz", hash = "sha256:97235235ba9b82dbae8068d1099508455949b275f77273ca22fdbd8b1fb5d950", size = 11439, upload-time = "2022-03-18T00:32:09.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/2d/9fbb3bd686a474d76fbd0b79abdcc016f3da760b1d1c2048bf4c611a4939/python_pam-2.0.2-py3-none-any.whl", hash = "sha256:4ac51dd8953ac59aa45505882b565eef6a22e0423dcf25d63369902080416c20", size = 10658, upload-time = "2022-03-18T00:32:07.802Z" }, +] + +[[package]] +name = "python-prctl" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/99/be5393cfe9c16376b4f515d90a68b11f1840143ac1890e9008bc176cf6a6/python-prctl-1.8.1.tar.gz", hash = "sha256:b4ca9a25a7d4f1ace4fffd1f3a2e64ef5208fe05f929f3edd5e27081ca7e67ce", size = 28033, upload-time = "2020-11-02T19:30:25.257Z" } + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pyusb" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/6b/ce3727395e52b7b76dfcf0c665e37d223b680b9becc60710d4bc08b7b7cb/pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e", size = 77281, upload-time = "2025-01-08T23:45:01.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/b8/27e6312e86408a44fe16bd28ee12dd98608b39f7e7e57884a24e8f29b573/pyusb-1.3.1-py3-none-any.whl", hash = "sha256:bf9b754557af4717fe80c2b07cc2b923a9151f5c08d17bdb5345dac09d6a0430", size = 58465, upload-time = "2025-01-08T23:45:00.029Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "rpds-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "charset-normalizer", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, +] + +[[package]] +name = "rpi-gpio" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/0f/10b524a12b3445af1c607c27b2f5ed122ef55756e29942900e5c950735f2/RPi.GPIO-0.7.1.tar.gz", hash = "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70", size = 29090, upload-time = "2022-02-06T15:15:06.022Z" } + +[[package]] +name = "rpi-hardware-pwm" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/32/ecd3e230a806c7894a13780a1c7d614f0d316d85cde7a2256626e2af2c45/rpi_hardware_pwm-0.3.1.tar.gz", hash = "sha256:dcb2627ab1248a9c532c86e013914416c55f11bd70976dd6ec6ecfd1109b0fe8", size = 5261, upload-time = "2026-02-09T17:10:41.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/17/c8d4d2efa5bb1af38644a6202c26e4d990d30cf6124ace32f33e7c488e9e/rpi_hardware_pwm-0.3.1-py3-none-any.whl", hash = "sha256:ad0f7f3e8ec83dd76a552cff92ea1dbb1bf773210316519ea66e3e85d3ac9ae0", size = 5112, upload-time = "2026-02-09T17:10:41.003Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "threadpoolctl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, +] + +[[package]] +name = "selenium" +version = "4.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "trio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "trio-websocket", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "urllib3", extra = ["socks"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "websocket-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/48/486aa67320f27452e9f551b8608f1a59ce7091c8fe7ebc9f4eba274775d4/selenium-4.45.0.tar.gz", hash = "sha256:563f0c4102f112df1cda30d46ce6d177b2e4a7a3d4b0756902d5dc84d3a8a365", size = 1005503, upload-time = "2026-06-16T04:43:57.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/8a/6ff6beb9c7c6cc642f628df9328a8b6637f86602eff8d28e70b5d4e8bca7/selenium-4.45.0-py3-none-any.whl", hash = "sha256:1fd9d0dc08192b2f8100e264ed720f83b05d2dd3a7feff673df04e0c7580df4b", size = 9536616, upload-time = "2026-06-16T04:43:55.968Z" }, +] + +[[package]] +name = "sgp4" +version = "2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/d0/fc467010d17742321f73b16a71acac88439a88f2b166641942a6566c9b2a/sgp4-2.25.tar.gz", hash = "sha256:e19edc6dcc25d69fb8fde0a267b8f0c44d7e915c7bcbeacf5d3a8b595baf0674", size = 181016, upload-time = "2025-08-04T18:02:33.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/0f/daf4a70829be7c1550b914c98b3abbd15404d00899835432ae8d4a9be502/sgp4-2.25-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c4d4eab0f2c94aad3a0ab0bedd59f2137484af5480a3b40df8e4ab5a1fbc6b86", size = 162974, upload-time = "2025-08-04T18:02:02.816Z" }, + { url = "https://files.pythonhosted.org/packages/27/88/af20e342590c3ede18cc8dc6a1e1da708f576e1a97dcb69e2870e739ae21/sgp4-2.25-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2822ca25f3724694bfced16cad8b3018678bee47fa3baf4eea20876d0e35ad33", size = 161957, upload-time = "2025-08-04T18:02:03.835Z" }, + { url = "https://files.pythonhosted.org/packages/6e/14/81f0df0cc39bdc95336a6f5834c84a6e5f79b5e728918cb9dadff3278017/sgp4-2.25-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7beca36492eb6d20ef15eeedd9520b8af4fa0cbaaae46a9269d5a2e7c8e56e46", size = 236195, upload-time = "2025-08-04T18:02:05.121Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a7/3740791f656d9b7ad78da7c0d9f6f842a18642fead2d26b2d69fb701892e/sgp4-2.25-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e9dfd18cacf6bfb1faad29c89a6cec98a642558f805851080dea9c394520db2", size = 232992, upload-time = "2025-08-04T18:02:06.086Z" }, + { url = "https://files.pythonhosted.org/packages/62/45/0e35398ef8d4b07ecfa9f7f680e183b2b6af9215a56af34f9e621c29b495/sgp4-2.25-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5789b7add136362684dfcbf0862919f8c3018f74ab11a05a9964edd5fdd4d2a7", size = 235584, upload-time = "2025-08-04T18:02:07.152Z" }, + { url = "https://files.pythonhosted.org/packages/3a/47/8231e3d4a88341316ec8d0eb98d3a8a972477d8b038555259522735a8371/sgp4-2.25-py3-none-any.whl", hash = "sha256:4f39ecf6c2663109fed04adfe9982815ac83893271b521d92d5b186820f8c78e", size = 137376, upload-time = "2026-04-27T18:29:23.71Z" }, +] + +[[package]] +name = "sh" +version = "1.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/09/89c28aaf2a49f226fef8587c90c6386bd2cc03a0295bc4ff7fc6ee43c01d/sh-1.14.3.tar.gz", hash = "sha256:e4045b6c732d9ce75d571c79f5ac2234edd9ae4f5fa9d59b09705082bdca18c7", size = 62851, upload-time = "2022-07-18T07:17:50.947Z" } + +[[package]] +name = "simplejpeg" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/64/da60f0ba80570f9a36c9b6e055f4364bda2c547715296d5773d2ea6d5a60/simplejpeg-1.9.0.tar.gz", hash = "sha256:5ac7d9489eeb812c2e7ea5c283994a29d9fefdfe5ed7b86c09d485e0dd366689", size = 3965764, upload-time = "2025-10-10T10:58:08.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/32/c2d5baa4af82551feae9082d1800c7c7e96586f67292dad4e1442298ad34/simplejpeg-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52b4e8e0d68caa3e0962415daff12df2911df36a697e53a75878a45e9e34e9ad", size = 423518, upload-time = "2025-10-10T10:57:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/84/97/6a4018d4c1c980d9f4c48c29d3d6bfaeb18444dd8e82997246c9950fb79a/simplejpeg-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:475d1932f50264d63dbc752678b5a6629ed8c6b0f5edfbe4e9cd7881d5f8a1f1", size = 400574, upload-time = "2025-10-10T10:57:46.475Z" }, + { url = "https://files.pythonhosted.org/packages/88/8b/d8ca384f1362371d61690d7460d3ae4cec4a5a25d9eb06cd15623de3725a/simplejpeg-1.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0c375130f73bb08229a3ded392d84ee2d916b3e87e7ec5d2ac4e47b7144346a", size = 448142, upload-time = "2025-10-10T10:57:47.894Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0a/58d6d8e997ee01486cfcfd4406a74638f2f63bb65122694b10411dadf1d5/simplejpeg-1.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d00feb1cc0348aba0a41db6dbda4db468db92099b1b3d473159e6f68aa990795", size = 406252, upload-time = "2025-10-10T10:57:49.158Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "skyfield" +version = "1.54" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jplephem", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sgp4", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/8c/98bf5d9042218580fc10c4ba0c51b9af26bc73b614ce64341c0dfad39074/skyfield-1.54.tar.gz", hash = "sha256:bf8b79d6dbbe1add0327aca485d6388bb6a13cab70528d015913a9b07a1d6903", size = 346829, upload-time = "2026-01-18T19:16:15.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/8a/f196038b2bea40c372d900803dac0d5e4eab578cb05b92ff7172ced4c1cf/skyfield-1.54-py3-none-any.whl", hash = "sha256:c9b313185448963ea7fa4cf8e4298ba028b179b80ebd4c5675497519f21c04a2", size = 370380, upload-time = "2026-01-18T19:16:13.806Z" }, +] + +[[package]] +name = "smbus2" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/37/b3f7b501502c4915ba3819d1dc277bf3f5fae4a9d067caa4f502aaddd889/smbus2-0.6.1.tar.gz", hash = "sha256:2b043372abf8f6029a632c3aab36b641c5d5872b1cbad599fc68e17ac4fd90a5", size = 17274, upload-time = "2026-04-09T20:37:54.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/f2/c78a68bd739ac8fc608747cff73a4db3b19f3135658ed4e64374f6425cbf/smbus2-0.6.1-py2.py3-none-any.whl", hash = "sha256:650feeb27ca0ed58b07db4c10201c2a662c41305b7bf6e5fab9d888056f48180", size = 11767, upload-time = "2026-04-09T20:37:53.728Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "spidev" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/87/039b6eeea781598015b538691bc174cc0bf77df9d4d2d3b8bf9245c0de8c/spidev-3.8.tar.gz", hash = "sha256:2bc02fb8c6312d519ebf1f4331067427c0921d3f77b8bcaf05189a2e8b8382c0", size = 13893, upload-time = "2025-09-15T18:56:20.672Z" } + +[[package]] +name = "sysv-ipc" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/5e/59208c6dd05ebc6f46ce2023c4fc01ffe814a1967d21b35d312c7e6ffeae/sysv_ipc-1.2.0.tar.gz", hash = "sha256:ef96ab33bb62e4d14142f0be0524dcc0c3c70c96442df2fc773c67b7c7514199", size = 102810, upload-time = "2026-01-09T14:05:02.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/2d/2e4f55201cca54666c08468538348be4af16a52c7296bdd038a303e7be9f/sysv_ipc-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:977f0e313c2e663000f0c316682ea2c3f6d2f86bbbdb1bcd274fea244a211df0", size = 72727, upload-time = "2026-01-09T14:04:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6a/e04914984503317dd2481d6ff5fa9ab85e70960b79514309b0bcb0ef08d8/sysv_ipc-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b40e147277a954c41f94207dfab402bfa8371198c191b826d833b40c5e83e9", size = 73643, upload-time = "2026-01-09T14:04:29.234Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/6f9aacbbf4c71ddce08f645bd67fa4223573a3191fd938acc926ca2b94c4/sysv_ipc-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83ff789f67477dc09424f674e1eb9195d8edd9b4044c3d5833d1a252d49034fc", size = 71319, upload-time = "2026-01-09T14:04:30.059Z" }, + { url = "https://files.pythonhosted.org/packages/34/21/0127cb9ecbc281c5b5a79d4be7a61e2d35442f72baaa1594e089dbe9206a/sysv_ipc-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fc541299c3af8351abff804e287a0c203338c140f70ee70855f46a1710cc0ff7", size = 71575, upload-time = "2026-01-09T14:04:32.011Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "timezonefinder" +version = "8.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "flatbuffers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "h3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/f2/77f407fac773a72e18e91657896fdec9b61ed4e31b35adf7270d8f5f71b0/timezonefinder-8.2.4.tar.gz", hash = "sha256:d80fae37adf1497729cc3e69826c22f3b2fec16db07932bf389b6ae545400b42", size = 54286323, upload-time = "2026-05-01T12:47:22.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/7b/422744a1ac2a5a2bec21f0d17f927a5324ed3e0c442c64337d265a266a0f/timezonefinder-8.2.4-cp311-abi3-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c824271053f0e3ad0700a2c504d609317c8c288655d8e48e1f382d0da094e94c", size = 54286013, upload-time = "2026-05-01T12:47:09.99Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e4/9b5c948bc657420fa7d0a86acc1489f977e4d459b088a957c0e046b2897f/timezonefinder-8.2.4-cp311-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97e9336391be6e10ca85f2ccdd36491c2aa2611e5265561de0f2e3b1652c2a68", size = 54285876, upload-time = "2026-05-01T12:47:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8a/4fc4538471cc34ca5aab758d0e372afedb93428170c18da1e5706a9e1119/timezonefinder-8.2.4-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:54f3a8cae6715bf2f6a4c1a31189dc709d2fbabc5671147d5cb461455d6c6f39", size = 54287989, upload-time = "2026-05-01T12:47:17.903Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "outcome", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sortedcontainers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "trio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "wsproto", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + +[[package]] +name = "types-aiofiles" +version = "25.1.0.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/42/f5b9b90162d2196f016b87228d6bf43f2c2c0c6501bfd5415001b3eb68bb/types_aiofiles-25.1.0.20260518.tar.gz", hash = "sha256:c0c95eb78755d4fa7b397d4f0332c632714dd7cd0d17f49b96e31d4d7a8d8c76", size = 14891, upload-time = "2026-05-18T06:05:27.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/3d/7a9ed9faafeae3aa3b5bc22fa5b979ff9cf3c83ecbe919b58eae07795b8c/types_aiofiles-25.1.0.20260518-py3-none-any.whl", hash = "sha256:f776bdfb4bec17f743d9ef042e61edf03bdcc7821fc08556fba9b63d873fdea9", size = 14377, upload-time = "2026-05-18T06:05:26.871Z" }, +] + +[[package]] +name = "types-pynput" +version = "1.8.1.20260603" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/fb/71a9c1ad440a7f1c6c2bd3a8ffb889f6983a4a5b1f782804c3015b60fac6/types_pynput-1.8.1.20260603.tar.gz", hash = "sha256:c22690e389ce6ae5ca19ae496176d5ebac507aa742bfcdf34cb530c8c1ba9450", size = 12215, upload-time = "2026-06-03T06:42:02.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/50/5a7b71dd98fdecc0133133c72581acf637faaafc1e244ae6d39a8d7592fe/types_pynput-1.8.1.20260603-py3-none-any.whl", hash = "sha256:59a73c1c9d51f74a20aa57d26278e4db1e8501c609177182beca378eab016802", size = 12302, upload-time = "2026-06-03T06:42:01.545Z" }, +] + +[[package]] +name = "types-pytz" +version = "2026.2.0.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/d9/9fa4019d2235bd374293e1fd4153879b28b6ae1d2bae98addd352c9713f2/types_pytz-2026.2.0.20260518.tar.gz", hash = "sha256:e5d254329e9c4e91f0781b22c43a4bb2d10bb044d97b24c4b05d45567b0eae16", size = 10871, upload-time = "2026-05-18T06:02:45.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/89/41e80670779a223d8bc8bc83019a619988cfa5c432cedac5cec23884fbc4/types_pytz-2026.2.0.20260518-py3-none-any.whl", hash = "sha256:3a12eaa38f476bd650902a9c9bb442f03f3c7dee2be5c5848bce61bd708d205a", size = 10125, upload-time = "2026-05-18T06:02:44.968Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, +] + +[[package]] +name = "types-tqdm" +version = "4.68.0.20260608" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/e0/3facccb1ff69970c73fca7a8028286c233d4c1312c475a65fb3d896f56d9/types_tqdm-4.68.0.20260608.tar.gz", hash = "sha256:e1dfddf8770fbc30ecaf95ae57c286397831235064308f7dfc2b1d6684a76107", size = 18470, upload-time = "2026-06-08T06:26:06.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/e8/61d95bfd49d1609fb8e8c5e06f4a094183411988a6f448873f5de6602499/types_tqdm-4.68.0.20260608-py3-none-any.whl", hash = "sha256:450a6e7e9e9b604928968927c414b32970e40091213c4180e1ed470905b13eff", size = 24858, upload-time = "2026-06-08T06:26:05.741Z" }, +] + +[[package]] +name = "types-waitress" +version = "3.0.1.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/38/45d06b9fa7d2834f8e06f4704352e18763d3303ab8921d39c888007f33c1/types_waitress-3.0.1.20260508.tar.gz", hash = "sha256:3f9389096f1504a459064fbb02be53ad4326200a78dedae532967d3c13eb8d89", size = 14468, upload-time = "2026-05-08T04:49:31.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/44/d2e166e59677b4c99e4c49a33a7f23210f012e5188bd176bdcc40dc35395/types_waitress-3.0.1.20260508-py3-none-any.whl", hash = "sha256:b34c6dbb5da568eea4a883f9492574233c8fb0c793b91aa2fe0b847ab871252a", size = 17492, upload-time = "2026-05-08T04:49:29.556Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] + +[[package]] +name = "videodev2" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/82/ffdba8838b1f24b83268863a8f66fe9334d7f28a5b9c368f9c48f7516e69/videodev2-0.0.4.tar.gz", hash = "sha256:c34ba70491d148c23a08cbacd8efabeb413cff5baa943a7548ac4abd1eb19e2a", size = 50108, upload-time = "2025-07-23T10:18:51.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/30/4982441a03860ab8f656702d8a2c13d0cf6f56d65bfb78fe288028dcb473/videodev2-0.0.4-py3-none-any.whl", hash = "sha256:d35f7ab39ddb06d50fec96a99bfc8d5b8b525bc7ea03788259d386393f1a64ba", size = 49923, upload-time = "2025-07-23T10:18:50.378Z" }, +] + +[[package]] +name = "waitress" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] diff --git a/python/views/location_form.html b/python/views/location_form.html index 4e171e6ac..9ad76b0d6 100644 --- a/python/views/location_form.html +++ b/python/views/location_form.html @@ -17,52 +17,52 @@
- +
- +
- +
- +
diff --git a/python/views/locations.html b/python/views/locations.html index ce026b92e..176adfcc2 100644 --- a/python/views/locations.html +++ b/python/views/locations.html @@ -127,52 +127,52 @@

{{ _('Edit Location') }}

- +
- +
- +
- +
@@ -206,6 +206,12 @@

{{ _('Confirm Delete') }}

{% block scripts %}