diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..3550a30f2 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/scripts/publish_manifest.sh b/.github/scripts/publish_manifest.sh new file mode 100755 index 000000000..a6cbfb2ce --- /dev/null +++ b/.github/scripts/publish_manifest.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Update update-manifest.json on the metadata-only `nixos-manifest` branch. +# +# The branch holds only that one JSON file; it carries no source tree. The job +# here is just: read the current manifest, let the updater rewrite its entry, +# and push. Concurrency-safe: a git ref update is compare-and-swap, so if a +# concurrent writer lands first our push is rejected, and we re-fetch the new +# tip, re-apply this run's entry onto it, and retry. +# +# Usage: +# publish_manifest.sh "" +# The updater argv contains the literal token @MANIFEST@, replaced with the +# manifest path on each attempt. It must be idempotent (replaces its own entry). +set -euo pipefail + +BRANCH="nixos-manifest" +COMMIT_MSG="$1" +shift + +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" + +WORKTREE="$(mktemp -d)" +trap 'git worktree remove --force "$WORKTREE" >/dev/null 2>&1 || true' EXIT +git worktree add --detach "$WORKTREE" >/dev/null +MANIFEST="$WORKTREE/update-manifest.json" + +for attempt in 1 2 3 4 5; do + git fetch origin "$BRANCH" >/dev/null 2>&1 || true + + if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then + # reset --hard so a retry after a rejected push starts from the true tip, + # not the stale entry from the previous attempt (which would otherwise + # silently drop the concurrent writer's change). + git -C "$WORKTREE" checkout -q -B "$BRANCH" "refs/remotes/origin/$BRANCH" + git -C "$WORKTREE" reset -q --hard "refs/remotes/origin/$BRANCH" + else + # Branch does not exist yet: start it empty. + git -C "$WORKTREE" checkout -q --orphan "$BRANCH" + git -C "$WORKTREE" rm -rfq --cached . >/dev/null 2>&1 || true + fi + + cmd=() + for arg in "$@"; do + cmd+=( "${arg/@MANIFEST@/$MANIFEST}" ) + done + "${cmd[@]}" + + git -C "$WORKTREE" add update-manifest.json + if git -C "$WORKTREE" diff --staged --quiet; then + echo "Manifest unchanged" + exit 0 + fi + + git -C "$WORKTREE" commit -q -m "$COMMIT_MSG" + if git -C "$WORKTREE" push origin "HEAD:$BRANCH" 2>/dev/null; then + echo "Manifest published (attempt $attempt)" + exit 0 + fi + + echo "Push rejected by a concurrent update; retrying ($attempt/5)" + sleep $((attempt * 2)) +done + +echo "Failed to publish manifest after 5 attempts" >&2 +exit 1 diff --git a/.github/scripts/update_manifest.py b/.github/scripts/update_manifest.py new file mode 100644 index 000000000..97ff73929 --- /dev/null +++ b/.github/scripts/update_manifest.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Update the generated PiFinder software update manifest.""" + +import argparse +import json +import re +from datetime import datetime, timezone +from pathlib import Path + + +STORE_PATH_RE = re.compile(r"^/nix/store/[a-z0-9]+-[A-Za-z0-9._+=?,-]+$") +EMPTY_MANIFEST = { + "schema": 1, + "generated_at": None, + "channels": { + "stable": [], + "beta": [], + "unstable": [], + }, +} + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def load_manifest(path: Path) -> dict: + if not path.exists(): + return json.loads(json.dumps(EMPTY_MANIFEST)) + with path.open() as f: + data = json.load(f) + if data.get("schema") != 1: + raise SystemExit(f"unsupported manifest schema in {path}") + channels = data.setdefault("channels", {}) + for name in ("stable", "beta", "unstable"): + channels.setdefault(name, []) + return data + + +def save_manifest(path: Path, manifest: dict) -> None: + manifest["generated_at"] = now_iso() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + json.dump(manifest, f, indent=2, sort_keys=True) + f.write("\n") + + +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: + entry["store_path"] = None + entry["available"] = False + entry.setdefault("reason", "no build") + return entry + + +def replace_entry(entries: list[dict], predicate, entry: dict) -> list[dict]: + return [item for item in entries if not predicate(item)] + [entry] + + +def sort_unstable(entries: list[dict]) -> list[dict]: + def key(item: dict) -> tuple[int, int, str]: + if item.get("kind") == "trunk": + return (0, 0, item.get("source_ref", "")) + if item.get("kind") == "pr": + return (1, -int(item.get("number") or 0), item.get("label", "")) + return (2, 0, item.get("label", "")) + + return sorted(entries, key=key) + + +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] + + if args.pr_number: + number = int(args.pr_number) + entry = { + "kind": "pr", + "number": number, + "label": f"PR#{number}-{short_sha}", + "title": args.pr_title or f"PR #{number}", + "notes": args.pr_body or None, + "source_repo": args.head_repo, + "source_ref": args.head_ref, + "source_sha": args.head_sha, + "version": args.version or f"PR#{number}-{short_sha}", + "store_path": store_path, + } + set_available(entry) + channels["unstable"] = replace_entry( + channels["unstable"], + lambda item: item.get("kind") == "pr" + and int(item.get("number") or 0) == number, + entry, + ) + else: + entry = { + "kind": "trunk", + "label": args.version or f"{args.ref_name}-{short_sha}", + "title": f"{args.ref_name} branch", + "notes": None, + "source_repo": args.repository, + "source_ref": args.ref_name, + "source_sha": args.sha, + "version": args.version or f"{args.ref_name}-{short_sha}", + "store_path": store_path, + } + set_available(entry) + channels["unstable"] = replace_entry( + channels["unstable"], + lambda item: item.get("kind") == "trunk" + and item.get("source_repo") == args.repository + and item.get("source_ref") == args.ref_name, + entry, + ) + + channels["unstable"] = sort_unstable(channels["unstable"]) + save_manifest(args.manifest, manifest) + + +def update_release(args: argparse.Namespace) -> None: + manifest = load_manifest(args.manifest) + channel = "beta" if args.release_type == "beta" else "stable" + entry = { + "kind": "release", + "label": args.tag, + "title": args.title or f"PiFinder {args.tag}", + "notes": args.notes or None, + "source_repo": args.repository, + "source_ref": args.tag, + "source_sha": args.sha, + "version": args.version, + "store_path": args.store_path or None, + } + set_available(entry) + manifest["channels"][channel] = replace_entry( + manifest["channels"][channel], + lambda item: item.get("kind") == "release" and item.get("label") == args.tag, + entry, + ) + save_manifest(args.manifest, manifest) + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser() + sub = root.add_subparsers(dest="command", required=True) + + build = sub.add_parser("build") + build.add_argument("--manifest", type=Path, required=True) + build.add_argument("--repository", required=True) + build.add_argument("--ref-name", required=True) + build.add_argument("--sha", required=True) + build.add_argument("--store-path", required=True) + build.add_argument("--version", required=True) + build.add_argument("--pr-number") + build.add_argument("--pr-title") + build.add_argument("--pr-body") + build.add_argument("--head-repo") + build.add_argument("--head-ref") + build.add_argument("--head-sha") + build.set_defaults(func=update_build) + + release = sub.add_parser("release") + release.add_argument("--manifest", type=Path, required=True) + release.add_argument("--repository", required=True) + release.add_argument("--sha", required=True) + release.add_argument("--tag", required=True) + 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("--title") + release.add_argument("--notes") + release.set_defaults(func=update_release) + + return root + + +def main() -> None: + args = parser().parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/nixos-pr-build.yml b/.github/workflows/nixos-pr-build.yml new file mode 100644 index 000000000..19e74b444 --- /dev/null +++ b/.github/workflows/nixos-pr-build.yml @@ -0,0 +1,204 @@ +name: Build PiFinder NixOS (testable PRs) + +# Builds testable NixOS PRs — including those from contributor forks — and +# publishes the result so devices can install it. +# +# `pull_request_target` runs in the BASE repo's trusted context, so the job has +# the real ATTIC_TOKEN and a read-write GITHUB_TOKEN even for fork PRs. The +# contributor's code is checked out explicitly (head SHA) and built. This is +# only reached after a maintainer applies the `testable` (or `preview`) label — +# that label is the security boundary: it runs contributor code on the +# self-hosted aarch64 runner with the cache push token, so review the diff +# before labeling, and re-review on each new push to a labeled PR. +on: + pull_request_target: + types: [labeled, synchronize, opened] + +concurrency: + group: nixos-build-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + +jobs: + # Try the Pi5 native aarch64 runner first (fast). + build-native: + if: | + contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.labels.*.name, 'testable') + runs-on: [self-hosted, aarch64] + timeout-minutes: 30 + outputs: + success: ${{ steps.build.outcome == 'success' }} + store_path: ${{ steps.push.outputs.store_path }} + steps: + # Build the contributor's code. persist-credentials:false so the + # GITHUB_TOKEN is not left in the fork checkout's git config. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + persist-credentials: false + + - 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 substituter (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 NixOS system closure + id: build + run: | + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + -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') + attic push pifinder:pifinder "$STORE_PATH" + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + # Wait up to ~15 min for the native builder, then decide on the hosted fallback. + native-wait: + if: | + contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.labels.*.name, 'testable') + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + need_emulated: ${{ steps.wait.outputs.need_emulated }} + 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_emulated=false" >> "$GITHUB_OUTPUT" + exit 0 + elif [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then + echo "need_emulated=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "Native build not done after 15 min, falling back to emulated" + echo "need_emulated=true" >> "$GITHUB_OUTPUT" + + # Fallback on a free hosted arm64 runner (native aarch64, no QEMU). + build-emulated: + needs: native-wait + if: needs.native-wait.outputs.need_emulated == 'true' + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + outputs: + store_path: ${{ steps.push.outputs.store_path }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + persist-credentials: false + + - 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:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE= + + - name: Attic login for push + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + if [ -z "$ATTIC_TOKEN" ]; then + echo "No ATTIC_TOKEN — pull-only via the public substituter" + exit 0 + fi + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - name: Build NixOS system closure + run: | + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + -L --no-link + + - name: Push to Attic + id: push + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --json | jq -r '.[].outputs.out') + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + if [ -n "$ATTIC_TOKEN" ]; then + attic push pifinder:pifinder "$STORE_PATH" + else + echo "No ATTIC_TOKEN — skipping push; build is verify-only" + fi + + # Stamp the PR's build into the metadata-only nixos-manifest branch. Runs the + # TRUSTED scripts from the base branch (default checkout), never the fork's, + # since this step holds the write token. + update-manifest: + needs: [build-native, build-emulated] + if: | + always() && + (needs.build-native.result == 'success' || needs.build-emulated.result == 'success') + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: manifest-write + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - name: Update generated manifest branch + env: + STORE_PATH: ${{ needs.build-native.outputs.store_path || needs.build-emulated.outputs.store_path }} + GH_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + PR_NUMBER="$(jq -r '.pull_request.number // ""' "$GITHUB_EVENT_PATH")" + PR_TITLE="$(jq -r '.pull_request.title // ""' "$GITHUB_EVENT_PATH")" + PR_BODY="$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH")" + HEAD_REPO="$(jq -r '.pull_request.head.repo.full_name // ""' "$GITHUB_EVENT_PATH")" + HEAD_REF="$(jq -r '.pull_request.head.ref // ""' "$GITHUB_EVENT_PATH")" + HEAD_SHA="$(jq -r '.pull_request.head.sha // ""' "$GITHUB_EVENT_PATH")" + SHORT_SHA="${HEAD_SHA:0:7}" + VERSION="PR#${PR_NUMBER}-${SHORT_SHA}" + + bash .github/scripts/publish_manifest.sh \ + "chore: update build manifest [skip ci]" \ + python3 .github/scripts/update_manifest.py build \ + --manifest @MANIFEST@ \ + --repository "$GH_REPOSITORY" \ + --ref-name "$HEAD_REF" \ + --sha "$HEAD_SHA" \ + --store-path "$STORE_PATH" \ + --version "$VERSION" \ + --pr-number "$PR_NUMBER" \ + --pr-title "$PR_TITLE" \ + --pr-body "$PR_BODY" \ + --head-repo "$HEAD_REPO" \ + --head-ref "$HEAD_REF" \ + --head-sha "$HEAD_SHA" diff --git a/.gitignore b/.gitignore index 7e9319394..126bfb8e0 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 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..bab81acd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,3 +194,35 @@ Tests use pytest with custom markers for different test types. The smoke tests p - **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 6e37815b7..4df6a906f 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -10,6 +10,7 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - [Equipment](./docs/ax/equipment/CONTEXT.md) — models the user's telescopes and eyepieces; supplies the active optics that drive magnification, true field of view, and object-image orientation. - [UI](./docs/ax/ui/CONTEXT.md) — the on-device menu system: menu tree, screen modules, the navigation stack and key dispatch, marking menus. - [Camera](./docs/ax/camera/CONTEXT.md) — captures frames and decides exposure: the three exposure regimes, the auto-exposure controllers, and zero-match recovery. +- [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 @@ -28,3 +29,4 @@ Companion architecture docs live next to each `CONTEXT.md`: - [`docs/ax/equipment.md`](./docs/ax/equipment.md) - [`docs/ax/ui.md`](./docs/ax/ui.md) - [`docs/ax/camera.md`](./docs/ax/camera.md) +- [`docs/ax/nixos.md`](./docs/ax/nixos.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 7de409688..717f9e55b 100644 --- a/default_config.json +++ b/default_config.json @@ -179,6 +179,7 @@ "active_eyepiece_index": 0 }, "imu_threshold_scale": 1, + "software_unstable_unlocked": 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/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..02f6cb3eb --- /dev/null +++ b/docs/ax/nixos/CONTEXT.md @@ -0,0 +1,77 @@ +# 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 (`software.py` `GITHUB_REPO`). +_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, build stamping, 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 to a **Build stamp** and installs that store path. + +**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. + +**Generation rollback**: +The instance-local revert to the previous NixOS generation. Triggered automatically by the **watchdog** on a boot failure (once), or manually. Bounded — local generations are pruned to two, so it reaches only one step back. + +**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` QEMU fallback. Fork-side infrastructure through Phase 1. + +**Build stamp**: +`update-manifest.json` — the generated channel manifest 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. The device reads this raw JSON file instead of calling the GitHub API. + +`pifinder-build.json` — legacy/source-local build metadata. CI should not commit it back to source branches for dev builds; source branches stay at the real source commit, while generated install metadata goes to the manifest branch. +_Avoid_: "manifest", "build manifest". 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/README.md b/docs/ax/nixos/adr/README.md new file mode 100644 index 000000000..e5ae706f8 --- /dev/null +++ b/docs/ax/nixos/adr/README.md @@ -0,0 +1,8 @@ +# 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) 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..5e89f8162 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,45 @@ 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. Rather than editing files on it, +you build an image and install it over the air through the update channels (see +`Beta Testing`_), or cut a release. -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. +Install dependencies with Nix +............................. -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. +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. Then, from the repository root, drop into the dev shell: -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. +.. code-block:: -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! + nix develop -Install python dependencies -........................... +This gives you a shell with 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. -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 repo also ships an ``.envrc`` (``use flake``). If you use +`direnv `_, run ``direnv allow`` once and the shell loads — +and unloads — automatically as you enter and leave the directory. -.. code-block:: - - pip install -r requirements.txt - pip install -r requirements_dev.txt +You still need to fetch the Tetra3 submodule once; see +`Install the Tetra3/Cedar solver`_ below. Hipparcos catalog @@ -341,76 +319,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. - -NOX -.... +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. -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. +Linting and formatting +...................... -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. +`Ruff `_ handles both. From ``python/``: -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 +415,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 +465,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 +487,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 ............................................. diff --git a/docs/source/images/user_guide/polar_align_adjust_docs.png b/docs/source/images/user_guide/polar_align_adjust_docs.png index e3114f305..6c940c3da 100644 Binary files a/docs/source/images/user_guide/polar_align_adjust_docs.png and b/docs/source/images/user_guide/polar_align_adjust_docs.png differ diff --git a/docs/source/images/user_guide/polar_align_marking_menu_docs.png b/docs/source/images/user_guide/polar_align_marking_menu_docs.png index 1494e554a..d98603b7e 100644 Binary files a/docs/source/images/user_guide/polar_align_marking_menu_docs.png and b/docs/source/images/user_guide/polar_align_marking_menu_docs.png differ 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..0b182732e --- /dev/null +++ b/flake.nix @@ -0,0 +1,406 @@ +{ + 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/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, ... }: + let + catalog-images = pkgs.stdenv.mkDerivation { + pname = "pifinder-catalog-images"; + version = "1.0"; + src = pkgs.fetchurl { + url = "https://files.miker.be/public/pifinder/catalog_images.tar.zst"; + hash = "sha256-20YOmO2qy2W27nIFV4Aqibu0MLip4gymHrfe411+VNg="; + }; + nativeBuildInputs = [ pkgs.zstd ]; + unpackPhase = "tar xf $src"; + installPhase = "mv catalog_images $out"; + }; + in { + sdImage.populateRootCommands = '' + mkdir -p ./files/home/pifinder/PiFinder_data + cp -r ${catalog-images} ./files/home/pifinder/PiFinder_data/catalog_images + chmod -R u+w ./files/home/pifinder/PiFinder_data/catalog_images + ''; + 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 + ''; + 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 + ''; + + 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 = let + pkgs = import nixpkgs { + system = "x86_64-linux"; + 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.ruff pkgs.uv cedar-detect ]; + shellHook = '' + export PYTHONPATH="${pkgs.libcamera}/lib/python3.13/site-packages:$PYTHONPATH" + ''; + }; + }; +} diff --git a/nixos-migration.html b/nixos-migration.html new file mode 100644 index 000000000..e3772db6b --- /dev/null +++ b/nixos-migration.html @@ -0,0 +1,398 @@ + + + + + +PiFinder · NixOS Migration — the tarball pointer + + + + + + + +
+

PiFinder · maintainer note

+

nixos migration + how the device finds its tarball +

+

+ The migration branch carries a hidden key combo on the SOFTWARE screen + that lets testers convert a PiFinder in place from Raspberry Pi OS to NixOS. + The device downloads, verifies, and unpacks a single tarball. This page is about one question only: + which tarball URL does it use, and who controls that? +

+
+ + +  7× SQUARE +
+
+ +
+ + +
+
01
+

What it is

+
+
+

+ A one-time, in-place OS replacement: Raspberry Pi OS → NixOS, on Pi 4 + hardware, without pulling the SD card. A tester opens SOFTWARE and presses + SQUARE seven times; the UI shows an irreversible confirmation, then + downloads the NixOS tarball, checks its sha256, builds an initramfs, and reboots + into it to repartition and extract. +

+

+ The checksum is mandatorystart_nixos_migration() refuses to run + without it — so a bad download aborts before the point of no return and is simply retried. +

+
+
+
+
+
+ SOFTWARE
+ NixOS 3.0.0
+ 292 MB

+ IRREVERSIBLE
+ press OK to migrate +
+
OK
+
+
+
128×128 OLED · night-vision red
+
+
+
+ + +
+
02
+

The pointer — JSON leads, hardcoded is the only fallback

+

+ Every time the SOFTWARE screen runs its check, the app fetches a small gate file from the + brickbots release branch: + raw.githubusercontent.com/brickbots/PiFinder/release/migration_gate.json + (MIGRATION_GATE_URL). The decision is binary: +

+
    +
  • No gate JSON (the state today, and until switchover) → use the + hardcoded fallback baked into software.py + (_MIGRATION_DEFAULTS). This is the only fallback.
  • +
  • Gate JSON present → use its nixos_url. If that URL is broken, the + download / sha256 step fails and the UI shows an error — no second guess, + no probing.
  • +
+ + + + + SOFTWARE · 7× SQUARE + tester unlock + + + + fetch migration_gate.json + brickbots / release + + + + JSON present? + 200 + nixos_url + + + + NO + + hardcoded fallback + _MIGRATION_DEFAULTS · mrosseel tarball + + + + YES + + use JSON nixos_url + brickbots release + + + + + + confirm + verify + sha256 + + + migrate ▸ + + + + sha256 fail / broken url → error · retry + + + + + + + + +
+ + +
+
03
+

Before vs after the switchover

+

+ Because the gate file simply does not exist on brickbots yet, the device always takes the + fallback today — which is exactly what we want for testers. The same code flips to the official + release the moment that JSON appears. Nothing in the firmware changes between these two states. +

+
+
+ Before · today +

No JSON ⇒ fallback

+

brickbots gate file returns 404. The 7× unlock + resolves the hardcoded default — testers download from the fork.

+ github.com/mrosseel/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst + _MIGRATION_DEFAULTS +
+
+ After · release +

JSON ⇒ release

+

Publish migration_gate.json on brickbots and the live JSON + leads — every device points at the official release on its next check.

+ github.com/brickbots/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst + migration_gate.json · nixos_url +
+
+
+ + +
+
04
+

The gate flag is about audience, not the URL

+

+ nixos_for_everyone never changes which tarball is used — it only decides who + gets offered the migration: +

+
+
false +

Testers only

+

Migration is reachable solely through the hidden 7× SQUARE combo. The + unlock still resolves a URL (JSON if present, else fallback).

+
+
true +

Auto-offered to everyone

+

The public SOFTWARE check itself triggers the migration prompt — no secret + combo needed. Flip this only when NixOS is ready for general availability.

+
+
+
+ + +
+
05
+

Fixing a broken release — edit the JSON, not the firmware

+

+ Ship a release with a bad URL or checksum? You do not need a new software build. The + gate JSON is fetched live and leads, so correcting nixos_url (and re-uploading the + tarball + .sha256 sidecar) propagates to every device on its next SOFTWARE check — + within the ~5 minute GitHub raw CDN cache window. A failed attempt aborts safely on the checksum + and the user just retries once the JSON is fixed. +

+
+ the one rule +

Keep the gate JSON reachable — it is the recovery channel. + The hardcoded fallback is only the cold-start net for when no JSON exists at all; it is not a + hotfix lever. You are stuck pointing at old URLs only if the JSON is unreachable and the + baked-in default is also broken — the single case that would need a firmware release.

+
+
+ + +
+
06
+

Two different journeys: not-migrated vs already-migrated

+
+
+

Not migrated yet

+

A Raspberry Pi OS device uses everything above — the gate + pointer and the one-time migration tarball. This is the only path the + migration branch implements.

+
+
+

Already on NixOS

+

Once converted, a device no longer touches the migration tarball. Ongoing + releases (3.0 → 3.1 → 3.2 …) arrive through the separate NixOS + update channel — out of scope for this page.

+
+
+
+ + +
+
A
+

Switchover checklist

+

When NixOS is ready for general availability, publish to brickbots and + make the shipped code fork-free:

+
# on brickbots/PiFinder, release branch
+publish  pifinder-nixos-vX.Y.Z.tar.zst  + .sha256 sidecar
+publish  migration_gate.json  { "nixos_for_everyone": false, "nixos_url": "…brickbots…" }
+
+# in software.py — flip the fallback so no fork URL ships
+_MIGRATION_DEFAULTS["nixos_url"] = "…/brickbots/…tar.zst"
+
+# already pointing at brickbots — no change needed
+MIGRATION_GATE_URL          
+version.txt update check     
+

After this, the fallback and the release point at the same + place, so the fallback is effectively never used — but it keeps the firmware self-sufficient if the JSON + is ever unreachable.

+
+ +
+ +
+

Maintainer reference for the migration branch · supersedes move-to-brickbots.txt. + Source of truth is the code: python/PiFinder/ui/software.py + (_MIGRATION_DEFAULTS, _fetch_migration_config, _build_version_info) + and migration_gate.json.

+
+ + + + + diff --git a/nixos/RELEASE.md b/nixos/RELEASE.md new file mode 100644 index 000000000..b8817a00a --- /dev/null +++ b/nixos/RELEASE.md @@ -0,0 +1,170 @@ +# 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()` first reads +`/var/lib/pifinder/current-build.json`, written by the updater when a selected +manifest entry is installed. The source-tree `pifinder-build.json` is only a +legacy fallback and should not be used as the channel source. + +## 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. write temporary pifinder-build.json in the workspace: │ + │ { "version": "3.0.0", "store_path": "" } │ + │ 3. nix build .#…toplevel → store path A │ + │ (JSON inside A: version=3.0.0, store_path="") │ + │ 4. nix build .#images.pifinder → SD image embedding A│ + │ 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..c6428306e --- /dev/null +++ b/nixos/device.nix @@ -0,0 +1,290 @@ +{ 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; [ + vim + htop + e2fsprogs + dosfstools + parted + file + curl + ]; + + # --------------------------------------------------------------------------- + # Binary substituters — Pi downloads pre-built paths, never compiles. + # Two Attic caches on cache.pifinder.eu (ADR 0004): pifinder-release (retained + # release closures) and pifinder (dev/nightly). The first-boot download below + # pulls whichever closure pifinder-build.json points at. + # --------------------------------------------------------------------------- + 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" ]; + unitConfig.ConditionPathExists = "/var/lib/pifinder/first-boot-target"; + 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 + + # Try fetching latest store path from GitHub, fall back to baked-in file + BUILD_JSON_URL="https://raw.githubusercontent.com/mrosseel/PiFinder/nixos/pifinder-build.json" + STORE_PATH="" + if REMOTE_JSON=$(curl -sf --max-time 15 "$BUILD_JSON_URL" 2>/dev/null); then + STORE_PATH=$(echo "$REMOTE_JSON" | jq -r '.store_path // empty') + if [ -n "$STORE_PATH" ]; then + echo "Using store path from GitHub: $STORE_PATH" + fi + fi + if [ -z "$STORE_PATH" ] || [[ "$STORE_PATH" != /nix/store/* ]]; then + echo "Remote fetch failed or invalid, falling back to baked-in 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" + + echo "Configuring bootloader..." + "$STORE_PATH/bin/switch-to-configuration" boot + + echo "Removing first-boot trigger..." + rm /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..e08cba327 --- /dev/null +++ b/nixos/hardware.nix @@ -0,0 +1,140 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.pifinder; + + # Camera driver name mapping + cameraDriver = { + imx296 = "imx296"; + imx462 = "imx290"; # imx462 uses imx290 driver + 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 GPS on /dev/ttyAMA1 + uart3Dtbo = compileOverlay "uart3" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &uart3 { status = "okay"; }; + ''; + + # I2C1 (ARM bus) — nixos-hardware overlay is bypassed by our mkForce DTB package + i2c1Dtbo = compileOverlay "i2c1" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &i2c1 { + status = "okay"; + clock-frequency = <${toString cfg.i2cFrequency}>; + }; + ''; + + # PWM on GPIO 13 (PWM channel 1) for keypad backlight + # GPIO 13 = PWM0_1 when ALT0 (function 4) + pwmDtbo = compileOverlay "pwm" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &gpio { + pwm_pin13: pwm_pin13 { + brcm,pins = <13>; + brcm,function = <4>; /* ALT0 = PWM0_1 */ + }; + }; + &pwm { + status = "okay"; + pinctrl-names = "default"; + pinctrl-0 = <&pwm_pin13>; + }; + ''; + + # Camera overlay from kernel's DTB overlays directory + cameraDtbo = "${config.boot.kernelPackages.kernel}/dtbs/overlays/${cameraDriver}.dtbo"; +in { + options.pifinder = { + cameraType = lib.mkOption { + type = lib.types.enum [ "imx296" "imx462" "imx477" ]; + default = "imx462"; + description = "Camera sensor type for PiFinder"; + }; + i2cFrequency = lib.mkOption { + type = lib.types.int; + default = 10000; + description = "I2C1 bus clock frequency in Hz (10 kHz for BNO055 IMU)"; + }; + }; + + config = { + # Only include RPi 4B device tree (not CM4 variants) + hardware.deviceTree.filter = "*rpi-4-b.dtb"; + # Explicit DTB name so extlinux uses FDT instead of FDTDIR + # (DTBs are in broadcom/ subdirectory, FDTDIR doesn't descend into it) + hardware.deviceTree.name = "broadcom/bcm2711-rpi-4-b.dtb"; + + # I2C enabled (loads i2c-dev module, creates i2c group) + hardware.i2c.enable = true; + + # 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/*rpi-4-b.dtb; do + fdtoverlay -i "$dtb" \ + -o "$out/broadcom/$(basename $dtb)" \ + ${i2c1Dtbo} ${spi0Dtbo} ${uart3Dtbo} ${pwmDtbo} ${cameraDtbo} + 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" + KERNEL=="ttyAMA1", GROUP="dialout", MODE="0660" + # 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..b74d3c7bf --- /dev/null +++ b/nixos/networking.nix @@ -0,0 +1,189 @@ +{ 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 + allowedTCPPorts = [ 80 ]; # PiFinder web UI (other ports via service openFirewall) + }; + }; + + # 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"; + }; + + # AP fallback / persistence. The PiFinder-AP profile has autoconnect disabled, + # so NetworkManager joins a known client network when one is reachable and + # never self-starts the AP. This service makes the AP a reliable fallback: it + # brings the AP up when no client connects within a grace period (so a device + # with a saved but-unreachable network is still reachable), and when the user + # has forced AP mode in the UI (persisted to PiFinder_data/wifi_mode). + systemd.services.pifinder-wifi-fallback = { + description = "Bring up PiFinder AP when no WiFi client is connected"; + 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 + + # Give NetworkManager up to 45s to join a known client network. + for _ in $(seq 1 45); do + if nmcli -t -f TYPE,STATE device | grep -q '^wifi:connected'; 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"; + }; + }; + + # --------------------------------------------------------------------------- + # 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; + 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 binds whatever interfaces are up when it starts. A PiFinder has both + # the wlan0 AP (up fast) and the DHCP'd LAN end0 (up slow); avahi frequently + # starts before the LAN and then never re-binds it, leaving the unit + # unreachable as .local over ethernet. Re-scan avahi whenever + # NetworkManager activates a connection so it always reflects current links. + # NetworkManager must not manage the hostname, or it resets it to the static + # "pifinder" (networking.hostName) and undoes pifinder-hostname's value. + networking.networkmanager.settings.main."hostname-mode" = "none"; + + networking.networkmanager.dispatcherScripts = [{ + source = pkgs.writeShellScript "avahi-rescan-on-net" '' + case "$2" in + up|connectivity-change) + # Re-scan avahi onto the now-up link (it misses the slow DHCP'd LAN at + # boot). NixOS bakes host-name= into avahi's config, so the + # restart reverts the published name to "pifinder" — re-assert the + # user hostname (system + avahi runtime) afterwards; that sticks. + f=/home/pifinder/PiFinder_data/hostname + name="" + if [ -s "$f" ]; then + name=$(cat "$f") + /run/current-system/sw/bin/hostname "$name" 2>/dev/null || true + fi + ${pkgs.systemd}/bin/systemctl try-restart avahi-daemon.service || true + [ -n "$name" ] && /run/current-system/sw/bin/avahi-set-host-name "$name" 2>/dev/null || true + ;; + esac + ''; + }]; +} diff --git a/nixos/pkgs/boot-splash.c b/nixos/pkgs/boot-splash.c new file mode 100644 index 000000000..4d2db8b78 --- /dev/null +++ b/nixos/pkgs/boot-splash.c @@ -0,0 +1,336 @@ +/* + * 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, 128x128 SSD1351 OLED + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define WIDTH 128 +#define HEIGHT 128 +#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 */ +#include "welcome_image.h" + +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[WIDTH * HEIGHT]; +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); +} + +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 ssd1351_cmd(uint8_t cmd) { + gpio_set(&dc_req, 0); + spi_write(&cmd, 1); +} + +static void ssd1351_data(const uint8_t *data, size_t len) { + gpio_set(&dc_req, 1); + spi_write(data, len); +} + +static void ssd1351_init(void) { + uint8_t d; + + /* Hardware reset */ + gpio_set(&rst_req, 1); + msleep(10); + gpio_set(&rst_req, 0); + msleep(10); + gpio_set(&rst_req, 1); + msleep(10); + + ssd1351_cmd(0xFD); d = 0x12; ssd1351_data(&d, 1); /* Unlock */ + ssd1351_cmd(0xFD); d = 0xB1; ssd1351_data(&d, 1); /* Unlock commands */ + ssd1351_cmd(0xAE); /* Display off */ + ssd1351_cmd(0xB3); d = 0xF1; ssd1351_data(&d, 1); /* Clock divider */ + ssd1351_cmd(0xCA); d = 0x7F; ssd1351_data(&d, 1); /* Mux ratio */ + + uint8_t col[2] = {0x00, 0x7F}; + ssd1351_cmd(0x15); ssd1351_data(col, 2); /* Column address */ + uint8_t row[2] = {0x00, 0x7F}; + ssd1351_cmd(0x75); ssd1351_data(row, 2); /* Row address */ + + ssd1351_cmd(0xA0); d = 0x74; ssd1351_data(&d, 1); /* BGR, 65k color */ + ssd1351_cmd(0xA1); d = 0x00; ssd1351_data(&d, 1); /* Start line */ + ssd1351_cmd(0xA2); d = 0x00; ssd1351_data(&d, 1); /* Display offset */ + ssd1351_cmd(0xB5); d = 0x00; ssd1351_data(&d, 1); /* GPIO */ + ssd1351_cmd(0xAB); d = 0x01; ssd1351_data(&d, 1); /* Function select */ + ssd1351_cmd(0xB1); d = 0x32; ssd1351_data(&d, 1); /* Precharge */ + + uint8_t vsl[3] = {0xA0, 0xB5, 0x55}; + ssd1351_cmd(0xB4); ssd1351_data(vsl, 3); /* VSL */ + + ssd1351_cmd(0xBE); d = 0x05; ssd1351_data(&d, 1); /* VCOMH */ + ssd1351_cmd(0xC7); d = 0x0F; ssd1351_data(&d, 1); /* Master contrast */ + ssd1351_cmd(0xB6); d = 0x01; ssd1351_data(&d, 1); /* Precharge2 */ + ssd1351_cmd(0xA6); /* Normal display */ + + uint8_t contrast[3] = {0xFF, 0xFF, 0xFF}; + ssd1351_cmd(0xC1); ssd1351_data(contrast, 3); /* Contrast */ +} + +static void ssd1351_flush(void) { + uint8_t col[2] = {0x00, 0x7F}; + ssd1351_cmd(0x15); ssd1351_data(col, 2); + uint8_t row[2] = {0x00, 0x7F}; + ssd1351_cmd(0x75); ssd1351_data(row, 2); + ssd1351_cmd(0x5C); /* Write RAM */ + + uint8_t buf[WIDTH * HEIGHT * 2]; + for (int i = 0; i < WIDTH * HEIGHT; i++) { + buf[i * 2] = framebuf[i] >> 8; + buf[i * 2 + 1] = framebuf[i] & 0xFF; + } + ssd1351_data(buf, sizeof(buf)); +} + +static void draw_scanner(int pos, int scanner_width) { + /* Copy welcome image to framebuffer */ + memcpy(framebuf, welcome_image, sizeof(framebuf)); + + /* Draw Knight Rider scanner at bottom (last 4 rows) */ + int y_start = HEIGHT - 4; + int center = pos; + + for (int x = 0; x < WIDTH; 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 < HEIGHT; y++) { + framebuf[y * WIDTH + x] = color; + } + } + + ssd1351_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) { + /* Copy welcome image to framebuffer */ + memcpy(framebuf, welcome_image, sizeof(framebuf)); + + /* Progress bar across the bottom 4 rows, filling left-to-right. + * Filled portion bright red, remaining track dim red. */ + int y_start = HEIGHT - 4; + int fill = pct * WIDTH / 100; + + for (int x = 0; x < WIDTH; x++) { + uint16_t color = (x < fill) ? COL_RED : COL_DKRED; + for (int y = y_start; y < HEIGHT; y++) { + framebuf[y * WIDTH + x] = color; + } + } + + ssd1351_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; + + 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) { + memcpy(framebuf, welcome_image, sizeof(framebuf)); + ssd1351_flush(); +} + +int main(int argc, char *argv[]) { + int static_mode = 0; + int progress_mode = 0; + const char *progress_path = PROGRESS_FILE_DEFAULT; + + 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]; + } + } + } + + signal(SIGTERM, signal_handler); + signal(SIGINT, signal_handler); + + if (hw_init() < 0) { + fprintf(stderr, "Hardware init failed\n"); + hw_cleanup(); + return 1; + } + + /* Turn on display */ + ssd1351_cmd(0xAF); + + if (static_mode) { + /* Static mode: show image once and exit */ + show_static_image(); + 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 >= WIDTH - scanner_width/2) { + pos = WIDTH - 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..d1104ced8 --- /dev/null +++ b/nixos/pkgs/pifinder-src.nix @@ -0,0 +1,99 @@ +{ pkgs, python ? pkgs.python313 }: +let + tetra3-src = pkgs.fetchFromGitHub { + owner = "smroid"; + repo = "cedar-solve"; + rev = "cded265ca1c41e4e526f91e06d3c7ef99bc37288"; + hash = "sha256-eJtBuBmsElEojXLYfYy3gQ/s2+8qjyvOYAqROe4sNO0="; + }; + + # 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/ + ''; + }; + + # tetra3/cedar-solve solver — pinned rev, changes only on a submodule bump. + # Built as its own derivation (examples/tests/docs trimmed, bytecode + # pre-compiled) and symlinked into pifinder-src, so a routine code change no + # longer rewrites these ~15MB of stable files. cedar_detect_pb2 ships in the + # cedar-solve repo, so the symlinked tree is import-complete. + tetra3 = pkgs.stdenv.mkDerivation { + pname = "pifinder-tetra3"; + version = "cedar-solve"; + src = tetra3-src; + nativeBuildInputs = [ python ]; + phases = [ "installPhase" ]; + installPhase = '' + mkdir -p $out + cp -r --no-preserve=mode $src/* $out/ + rm -rf $out/examples $out/tests $out/docs + python3 -m compileall -q $out || true + ''; + }; + + # 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/migration_source $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), fonts (~31MB) or tetra3 (~15MB). See ADR 0001. + rm -rf $out/astro_data + ln -s ${astro-data} $out/astro_data + rm -rf $out/fonts + ln -s ${fonts} $out/fonts + + # tetra3/cedar-solve is a git submodule (empty in the Nix source). Drop the + # stub before compiling so the dangling python/tetra3 symlink is skipped, + # then symlink the pre-built solver in afterwards — symlinking before + # compileall would make it try to write .pyc into the read-only store path. + rm -rf $out/python/PiFinder/tetra3 + + # Pre-compile .pyc bytecode so Python skips compilation at runtime + chmod -R u+w $out/python + python3 -m compileall -q $out/python + + ln -s ${tetra3} $out/python/PiFinder/tetra3 + ''; +} 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.nix b/nixos/pkgs/uv-python.nix new file mode 100644 index 000000000..348251d93 --- /dev/null +++ b/nixos/pkgs/uv-python.nix @@ -0,0 +1,183 @@ +{ 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 = []; }; + }); + + 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 = []; }; + }); + + # 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..a0dab8f58 --- /dev/null +++ b/nixos/services.nix @@ -0,0 +1,593 @@ +{ 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.)"; + }; + }; + + 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 + ]; + + + + # --------------------------------------------------------------------------- + # Binary substituters — Pi downloads pre-built paths, never compiles. + # Two Attic caches on cache.pifinder.eu (ADR 0004): + # 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 channel 1 (GPIO 13) if not already exported + if [ ! -d /sys/class/pwm/pwmchip0/pwm1 ]; then + echo 1 > /sys/class/pwm/pwmchip0/export || true + sleep 0.5 + fi + # sysfs doesn't support chgrp, so make files world-writable + chmod 0666 /sys/class/pwm/pwmchip0/export /sys/class/pwm/pwmchip0/unexport + if [ -d /sys/class/pwm/pwmchip0/pwm1 ]; then + chmod 0666 /sys/class/pwm/pwmchip0/pwm1/{enable,period,duty_cycle,polarity} + 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 = { + Type = "simple"; + 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 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 + # --------------------------------------------------------------------------- + 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 ]; + script = '' + set -euo pipefail + REBOOT_MARKER="/var/tmp/pifinder-watchdog-rebooted" + + if [ -f "$REBOOT_MARKER" ]; then + echo "Watchdog already rebooted once. Not retrying." + rm -f "$REBOOT_MARKER" + exit 0 + fi + + echo "Watchdog: 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)" + exit 0 + fi + fi + sleep 5 + done + + echo "ERROR: pifinder.service failed. Rolling back..." + touch "$REBOOT_MARKER" + PREV_GEN=$(ls -d /nix/var/nix/profiles/system-*-link 2>/dev/null | sort -t- -k2 -n | tail -2 | head -1) + if [ -n "$PREV_GEN" ]; then + # Reset profile so the rolled-back generation becomes the current one + nix-env -p /nix/var/nix/profiles/system --set "$(readlink -f "$PREV_GEN")" + "$PREV_GEN/bin/switch-to-configuration" switch || true + fi + 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 — kept identical to upstream pi_config_files/gpsd.conf so + # the Debian and NixOS images present the same operator-visible config. + # DEVICES opens the on-board UART GPS at startup; 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/ttyAMA1" + GPSD_OPTIONS="" + 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 UART GPS on boot (ttyAMA1 from uart3 overlay, not auto-detected by udev) + # This runs after gpsd.socket is ready, adding the UART device to gpsd + systemd.services.gpsd-add-uart = { + description = "Add UART GPS to gpsd"; + after = [ "gpsd.socket" "dev-ttyAMA1.device" ]; + requires = [ "gpsd.socket" ]; + wantedBy = [ "multi-user.target" ]; + # BindsTo ensures this stops if ttyAMA1 disappears (though it shouldn't) + bindsTo = [ "dev-ttyAMA1.device" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.gpsd}/sbin/gpsdctl add /dev/ttyAMA1"; + ExecStop = "${pkgs.gpsd}/sbin/gpsdctl remove /dev/ttyAMA1"; + }; + }; + + # --------------------------------------------------------------------------- + # 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"; + "map to guest" = "never"; + }; + PiFinder_data = { + path = "/home/pifinder/PiFinder_data"; + browseable = "yes"; + "read only" = "no"; + "valid users" = "pifinder"; + }; + }; + }; + }; # config +} diff --git a/pifinder-build.json b/pifinder-build.json new file mode 100644 index 000000000..fbf81d364 --- /dev/null +++ b/pifinder-build.json @@ -0,0 +1,4 @@ +{ + "store_path": "/nix/store/nckyrwais9yl03jh04kq87bgy8fisaw3-nixos-system-pifinder-25.11.20260209.2db38e0", + "version": "nixos-4a37f0a" +} 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..33a1d0a6b 100644 --- a/python/PiFinder/auto_exposure.py +++ b/python/PiFinder/auto_exposure.py @@ -211,7 +211,7 @@ 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" ) @@ -304,7 +304,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 +315,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/camera_interface.py b/python/PiFinder/camera_interface.py index 52a94ef3e..3ecb6a3f7 100644 --- a/python/PiFinder/camera_interface.py +++ b/python/PiFinder/camera_interface.py @@ -694,7 +694,7 @@ 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", + 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/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 01abb4573..24030fa47 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 56e90d0b4..fb57624b1 100644 --- a/python/PiFinder/comets.py +++ b/python/PiFinder/comets.py @@ -252,9 +252,13 @@ 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,) - helio_pos = helio_pos[:, np.newaxis] + # Skyfield's propagate() lays the result out as (3, #orbits, #times) but + # sets output_shape = (3,) + t1.shape, so a batched orbit only reshapes + # cleanly when the target time is itself shaped (#orbits, 1); a scalar time + # raises "cannot reshape array of size 3N into shape (3,)" (skyfield >= + # 1.46). Give every comet the same target time as an (N, 1) column. + t_batched = sf_utils.ts.tt_jd(np.full((len(comets_df), 1), t.tt)) + helio_pos = kepler._at(t_batched)[0][:, :, 0] # Sun and observer are single 3-vectors relative to the solar-system # barycentre; broadcast them across all comets. topocentric = observer 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/displays.py b/python/PiFinder/displays.py index 9825e8fa1..5bd2566f5 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 @@ -12,7 +13,9 @@ from PiFinder.ssd1333_device import ssd1333 from PiFinder.ui.fonts import Fonts +from PiFinder.keyboard_interface import KeyboardInterface +logger = logging.getLogger("Display") ColorMask = namedtuple("ColorMask", ["mask", "mode"]) RED_RGB: ColorMask = ColorMask(np.array([1, 0, 0]), "RGB") @@ -69,6 +72,75 @@ def __init__(self): def set_brightness(self, brightness: int) -> None: return None + def set_keyboard_queue(self, q) -> None: + pass + + +# Pygame key → PiFinder keycode mapping (mirrors keyboard_local.py) +_PYGAME_KEY_MAP: dict[int, int] = {} + + +def _build_key_map(pg) -> dict[int, int]: + if _PYGAME_KEY_MAP: + return _PYGAME_KEY_MAP + KI = KeyboardInterface + m = { + pg.K_LEFT: KI.LEFT, + pg.K_UP: KI.UP, + pg.K_DOWN: KI.DOWN, + pg.K_RIGHT: KI.RIGHT, + pg.K_q: KI.PLUS, + pg.K_a: KI.MINUS, + pg.K_z: KI.SQUARE, + pg.K_w: KI.ALT_PLUS, + pg.K_s: KI.ALT_MINUS, + pg.K_d: KI.ALT_LEFT, + pg.K_r: KI.ALT_UP, + pg.K_f: KI.ALT_DOWN, + pg.K_g: KI.ALT_RIGHT, + pg.K_e: KI.ALT_0, + pg.K_j: KI.LNG_LEFT, + pg.K_i: KI.LNG_UP, + pg.K_k: KI.LNG_DOWN, + pg.K_l: KI.LNG_RIGHT, + pg.K_m: KI.LNG_SQUARE, + pg.K_0: 0, + pg.K_1: 1, + pg.K_2: 2, + pg.K_3: 3, + pg.K_4: 4, + pg.K_5: 5, + pg.K_6: 6, + pg.K_7: 7, + pg.K_8: 8, + pg.K_9: 9, + } + _PYGAME_KEY_MAP.update(m) + return _PYGAME_KEY_MAP + + +def _patch_pygame_keyboard(display_obj): + """Replace luma's _abort on the pygame device to capture keyboard events.""" + device = display_obj.device + pg = device._pygame + key_map = _build_key_map(pg) + + def _abort_with_keys(): + for event in pg.event.get(): + if event.type == pg.QUIT: + return True + if event.type == pg.KEYDOWN: + if event.key == pg.K_ESCAPE: + return True + q = display_obj._keyboard_queue + if q is not None: + keycode = key_map.get(event.key) + if keycode is not None: + q.put(keycode) + return False + + device._abort = _abort_with_keys + class DisplayPygame_128(DisplayBase): resolution = (128, 128) @@ -76,7 +148,7 @@ class DisplayPygame_128(DisplayBase): def __init__(self): from luma.emulator.device import pygame - # init display (SPI hardware) + self._keyboard_queue = None pygame = pygame( width=128, height=128, @@ -87,8 +159,12 @@ def __init__(self): frame_rate=60, ) self.device = pygame + _patch_pygame_keyboard(self) super().__init__() + def set_keyboard_queue(self, q) -> None: + self._keyboard_queue = q + class Layout320: """Shared 320x240 layout profile for the ST7789 LCD. @@ -118,6 +194,7 @@ class DisplayPygame_320(Layout320, DisplayBase): def __init__(self): from luma.emulator.device import pygame + self._keyboard_queue = None pygame = pygame( width=self.resolution[0], height=self.resolution[1], @@ -126,8 +203,12 @@ def __init__(self): frame_rate=60, ) self.device = pygame + _patch_pygame_keyboard(self) super().__init__() + def set_keyboard_queue(self, q) -> None: + self._keyboard_queue = q + class DisplaySSD1351(DisplayBase): resolution = (128, 128) 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 f0bb7da40..35fbdb802 100644 --- a/python/PiFinder/gps_ubx_parser.py +++ b/python/PiFinder/gps_ubx_parser.py @@ -159,7 +159,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.""" 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/integrator.py b/python/PiFinder/integrator.py index 5e3cbd68a..9b5166e09 100644 --- a/python/PiFinder/integrator.py +++ b/python/PiFinder/integrator.py @@ -290,9 +290,9 @@ def _advance_with_imu( was below the deadband. """ q_x2imu = imu.quat - assert isinstance( - q_x2imu, quaternion.quaternion - ), "Expecting quaternion.quaternion type" + assert isinstance(q_x2imu, quaternion.quaternion), ( + "Expecting quaternion.quaternion type" + ) angle_moved = qt.get_quat_angular_diff(estimate.imu_anchor, q_x2imu) if angle_moved <= IMU_MOVED_ANG_THRESHOLD: diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index ed8cac352..9ed5c0eb8 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 @@ -969,11 +971,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" @@ -984,13 +981,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()) @@ -1176,4 +1173,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..77f065d7e 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: """ @@ -87,9 +88,9 @@ def apply_config(self): def start(self, initial_queue: Optional[Queue] = None): assert self._proc is None, "You should only start once!" - assert ( - len(self._queues) >= 1 - ), "No queues in use. You should have requested at least one queue." + assert len(self._queues) >= 1, ( + "No queues in use. You should have requested at least one queue." + ) # Create the main-process queue BEFORE starting the sink so the sink # receives it in its queue list and monitors it. @@ -186,11 +187,11 @@ def configurer(queue: Queue): import os assert queue is not None, "You passed a None to configurer! You cannot do that" - assert isinstance( - queue, multiprocessing.queues.Queue - ), "That's not a Queue! You have to pass a queue" + assert isinstance(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 47a117852..f37da8064 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/nixos_upgrade.py b/python/PiFinder/nixos_upgrade.py new file mode 100644 index 000000000..420c6e82c --- /dev/null +++ b/python/PiFinder/nixos_upgrade.py @@ -0,0 +1,494 @@ +"""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.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") + +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() + + +def cache_has_path(store_path: str, cache: str, timeout: int = 20) -> bool: + try: + result = command( + ["nix", "path-info", "--store", cache, store_path], + check=False, + timeout=timeout, + ) + except (subprocess.TimeoutExpired, OSError): + return False + return result.returncode == 0 + + +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 store_path_available(store_path: str) -> bool: + return path_exists(store_path) or any(cache_has_path(store_path, c) for c in CACHES) + + +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 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(): + command([str(specialisation / "bin/switch-to-configuration"), "boot"]) + set_extlinux_default(camera) + return + + 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: + if not store_path_available(store_path): + selected_unavailable = True + write_status("unavailable") + terminal = True + raise UnavailableError( + f"{store_path} is no longer on configured caches" + ) + 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 19489e682..04e546a84 100644 --- a/python/PiFinder/plot.py +++ b/python/PiFinder/plot.py @@ -9,7 +9,6 @@ import os import datetime import numpy as np -import pandas from pathlib import Path from PiFinder import utils from PIL import Image, ImageDraw, ImageChops @@ -38,6 +37,8 @@ def _load_raw_stars(): if _RAW_STARS_DF is not None: return _RAW_STARS_DF + import pandas + dat_path = Path(utils.astro_data_dir, "hip_main.dat") cache_dir = Path(utils.data_dir, "cache") pkl_path = cache_dir / "hip_main.pkl" @@ -189,6 +190,8 @@ def radec_to_xy(self, ra: float, dec: float) -> tuple[float, float]: """ # Skyfield needs a DataFrame to build the Star; rotate/screen-space # math is scalar numpy/python after that point. + import pandas + marker_df = pandas.DataFrame( { "ra_hours": [Angle(degrees=ra)._hours], @@ -223,6 +226,8 @@ def plot_markers(self, marker_list): if not marker_list: return ret_image + import pandas + # Skyfield needs a DataFrame to build Star objects. Build the # smallest one possible and drop pandas after that point; the per- # frame rotate/screen-space/visibility work below runs in numpy. @@ -312,6 +317,8 @@ def project_vertices(self, vertices): vertices: list of [ra_deg, dec_deg] pairs. Returns list of (x, y) screen tuples. """ + import pandas + rows = [(Angle(degrees=ra)._hours, dec) for ra, dec in vertices] df = pandas.DataFrame(rows, columns=["ra_hours", "dec_degrees"]) df["epoch_year"] = 1991.25 @@ -426,9 +433,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 d55d718b9..f6a12f8f2 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -99,7 +99,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() @@ -199,12 +199,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: @@ -242,7 +238,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, @@ -520,7 +516,14 @@ 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")) + return app.jinja_env.get_template("network.html").render( + title=_("Network"), + net=self.network, + show_new_form=0, + status_message=_( + "Network settings updated. You may need to reconnect." + ), + ) @app.route("/tools/pwchange", methods=["POST"]) @auth_required @@ -678,7 +681,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") @@ -725,13 +728,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") @@ -1013,23 +1016,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}) @@ -1037,29 +1033,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 34f5c040a..9e6181677 100644 --- a/python/PiFinder/solver.py +++ b/python/PiFinder/solver.py @@ -437,7 +437,10 @@ 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, which avoids depending on the inner/outer + # tetra3_dir layout (the submodule nests the package at tetra3/tetra3). + t3 = tetra3.Tetra3("default_database") align_ra = 0 align_dec = 0 last_solve_attempt: float = 0.0 diff --git a/python/PiFinder/splash.py b/python/PiFinder/splash.py index fc2a55ce8..a351e955f 100644 --- a/python/PiFinder/splash.py +++ b/python/PiFinder/splash.py @@ -33,8 +33,9 @@ def show_splash(): screen_draw = ImageDraw.Draw(welcome_image) # Display version and Wifi mode - with open(os.path.join(root_dir, "version.txt"), "r") as ver_f: - version = "v" + ver_f.read() + from PiFinder import utils + + 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/sqm.ipynb b/python/PiFinder/sqm/sqm.ipynb index 490d27c57..cd956e870 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,11 +72,11 @@ } ], "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", + "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", @@ -82,9 +84,10 @@ "\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", @@ -92,11 +95,10 @@ "import PiFinder.tetra3.tetra3 as tetra3\n", "from PiFinder.tetra3.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(str(tetra3_path / \"data/default_database.npz\"))\n", "\n", "logger.info(\"Starting Solver Loop\")\n", "# Start cedar detect server\n", @@ -160,26 +162,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 +199,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 +207,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 +540,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 +596,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 +611,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 +639,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 +652,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 +692,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 +737,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 +810,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 +927,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 +1076,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 +1104,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 +1134,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 +1177,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 +1368,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 +1413,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 +1485,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 +1498,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 +1614,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 +2250,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 +2263,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 +2352,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..5cfadae1a 100644 --- a/python/PiFinder/sqm/sqm.py +++ b/python/PiFinder/sqm/sqm.py @@ -309,7 +309,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: @@ -457,7 +457,7 @@ def calculate( 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: diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index 3c29105d5..956ebaa12 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -17,7 +17,6 @@ from typing import Optional from dataclasses import dataclass, asdict import json -from timezonefinder import TimezoneFinder logger = logging.getLogger("SharedState") @@ -306,7 +305,9 @@ def __init__(self) -> None: self.__cam_raw = None # Are we prepared to do alt/az math # We need gps lock and datetime - self.__tz_finder = TimezoneFinder() + # Constructed lazily on first location set — the timezonefinder + # import and its dataset load are slow and not needed at boot. + self.__tz_finder = None self.__current_ui_state = None def serialize(self, output_file): @@ -398,6 +399,10 @@ def set_location(self, v): # if value is not none, set the timezone # before saving the value if v: + if self.__tz_finder is None: + from timezonefinder import TimezoneFinder + + self.__tz_finder = TimezoneFinder() v.timezone = self.__tz_finder.timezone_at(lat=v.lat, lng=v.lon) self.__location = v diff --git a/python/PiFinder/sys_utils.py b/python/PiFinder/sys_utils.py index 48411dff4..d8c2c07c2 100644 --- a/python/PiFinder/sys_utils.py +++ b/python/PiFinder/sys_utils.py @@ -1,547 +1,770 @@ -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 datetime +import subprocess +import logging +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 __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() + 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 +# --------------------------------------------------------------------------- + + +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): - """ - Immediately deletes a wifi network + The list is not cached: changes made outside this process (the AP/CLI + switch, another tool, a repair) are reflected on the next read. """ - 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.""" 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() + for conn in self._client.get_connections(): + if conn.get_id() == AP_CONNECTION_NAME: + s_wifi = conn.get_setting_wireless() + if s_wifi: + 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 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. - - Backs up: - config.json - observations.db - obslist/* - """ +# --------------------------------------------------------------------------- +# System control (systemctl subprocess + D-Bus for reboot/shutdown) +# --------------------------------------------------------------------------- - remove_backup() - _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/*"), - ) +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"]) - 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 +# --------------------------------------------------------------------------- -def restart_pifinder() -> None: - """ - Uses systemctl to restart the PiFinder - service - """ - logger.info("SYS: Restarting PiFinder") - sh.sudo("systemctl", "restart", "pifinder") +UPGRADE_STATE_IDLE = "idle" +UPGRADE_STATE_RUNNING = "running" +UPGRADE_STATE_SUCCESS = "success" +UPGRADE_STATE_FAILED = "failed" +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) + + _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 + date = datetime.datetime.fromtimestamp(mtime).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"): + 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" | "" + 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 == "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}") + 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) + +def get_camera_type() -> list[str]: try: - # Read the current config - with open("/etc/default/gpsd", "r") as f: - lines = f.readlines() + with open(CAMERA_TYPE_FILE) as f: + return [f.read().strip()] + except FileNotFoundError: + return ["imx462"] - # 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) +def switch_cam_imx477() -> None: + logger.info("SYS: Switching cam to imx477") + switch_camera("imx477") - # Copy the temp file to the actual location with sudo - sh.sudo("cp", "/tmp/gpsd.conf", "/etc/default/gpsd") - # Restart GPSD service - sh.sudo("systemctl", "restart", "gpsd") +def switch_cam_imx296() -> None: + logger.info("SYS: Switching cam to imx296") + switch_camera("imx296") - logger.info("SYS: GPSD configuration updated and service restarted") - except Exception as e: - logger.error(f"SYS: Error updating GPSD config: {e}") - raise +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', '?')}") - - with open(MIGRATION_PROGRESS_FILE, "w") as f: - json.dump({"percent": 0, "status": "Starting..."}, f) + logger.info("SYS: GPSD baud rate %d — managed by NixOS configuration", baud_rate) + return False - def _log_output(line): - logger.info(f"SYS: migration: {line.strip()}") - 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}") - - 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 get_migration_progress() -> Dict[str, Any]: - """ - Read current migration progress from the progress file. - """ - try: - with open(MIGRATION_PROGRESS_FILE, "r") as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return {} +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 + ) diff --git a/python/PiFinder/sys_utils_base.py b/python/PiFinder/sys_utils_base.py new file mode 100644 index 000000000..1650cb11c --- /dev/null +++ b/python/PiFinder/sys_utils_base.py @@ -0,0 +1,170 @@ +""" +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") + subprocess.run(["sudo", "systemctl", "restart", "pifinder"]) diff --git a/python/PiFinder/sys_utils_fake.py b/python/PiFinder/sys_utils_fake.py index 6816860d0..1edcd6e4d 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,26 +99,38 @@ 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) diff --git a/python/PiFinder/tetra3 b/python/PiFinder/tetra3 index 38c3f48f5..cded265ca 160000 --- a/python/PiFinder/tetra3 +++ b/python/PiFinder/tetra3 @@ -1 +1 @@ -Subproject commit 38c3f48f57d1005e9b65cbb26136f9f13ec0a1b0 +Subproject commit cded265ca1c41e4e526f91e06d3c7ef99bc37288 diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index 4a52dc2d7..3a58a23f6 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 @@ -120,7 +120,7 @@ class UIModule: def __init__( self, - display_class: Type[DisplayBase], + display_class: DisplayBase, camera_image, shared_state, command_queues, @@ -354,9 +354,7 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: title_max_chars = max(1, title_max_px // self.fonts.bold.width) if len(title_text) > title_max_chars: title_text = title_text[: title_max_chars - 1] + "…" - self.draw.text( - (6, title_y), title_text, font=self.fonts.bold.font, fill=fg - ) + self.draw.text((6, title_y), title_text, font=self.fonts.bold.font, fill=fg) imu = self.shared_state.imu() moving = True if imu and imu.quat and imu.moving else False @@ -387,6 +385,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 diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 9726efbed..ac937420c 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -198,21 +198,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: @@ -224,9 +210,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: @@ -242,9 +225,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 fa25f0dad..d5ff0b20a 100644 --- a/python/PiFinder/ui/console.py +++ b/python/PiFinder/ui/console.py @@ -36,7 +36,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__), "..", "..", "..") ) @@ -94,6 +94,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 diff --git a/python/PiFinder/ui/menu_manager.py b/python/PiFinder/ui/menu_manager.py index 12f155ce3..8e94ca19b 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/preview.py b/python/PiFinder/ui/preview.py index 5815f760a..b7e07b06e 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -22,6 +22,7 @@ from PiFinder.ui.ui_utils import outline_text sys.path.append(str(utils.tetra3_dir)) +sys.path.append(str(utils.tetra3_dir / "tetra3")) # 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 diff --git a/python/PiFinder/ui/software.py b/python/PiFinder/ui/software.py index c95fcbc42..eee502879 100644 --- a/python/PiFinder/ui/software.py +++ b/python/PiFinder/ui/software.py @@ -1,554 +1,786 @@ #!/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 logging -import time -from typing import Any, Optional, TYPE_CHECKING +import re +from typing import Dict, List, Optional import requests from PiFinder import utils from PiFinder.ui.base import UIModule -from PiFinder.ui.ui_utils import TextLayouter - -if TYPE_CHECKING: - - def _(a) -> Any: - return a - +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 = ( + 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 - -_MIGRATION_VERSION_INFO = { - "version": "3.0.0", - "type": "upgrade", - "migration_size_mb": 292, - "migration_url": "https://github.com/mrosseel/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst", - "migration_sha256_url": "https://github.com/mrosseel/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst.sha256", -} - - -def _fetch_migration_config() -> Optional[dict]: - """Fetch and parse the remote migration config JSON. - Returns the parsed dict if it contains a usable `nixos_url`; None on - network error, non-200 response, malformed JSON, or missing url. - The `nixos_for_everyone` gate is enforced by the caller. - """ - try: - res = requests.get(MIGRATION_GATE_URL, timeout=REQUEST_TIMEOUT) - except requests.exceptions.RequestException: +def _entry_from_manifest(item: dict, channel: str) -> Optional[dict]: + label = item.get("label") + if not isinstance(label, str) or not label: return None - if res.status_code != 200: - return None - try: - data = res.json() - except ValueError: - return None - if not isinstance(data, dict): - return None - if not data.get("nixos_url"): - return None - return data - -def update_needed(current_version: str, repo_version: str) -> bool: + 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, + "channel": channel, + } + 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_update_manifest() -> dict[str, list[dict]]: """ - Returns true if an update is available - - Update is available if semvar of repo_version is > current_version - Also returns True on error to allow be biased towards allowing - updates if issues + Fetch CI-generated update metadata. + Raises RequestException for network failures so the caller can show offline. """ - try: - _tmp_split = current_version.split(".") - current_version_compare = ( - int(_tmp_split[0]), - int(_tmp_split[1]), - int(_tmp_split[2]), - ) - - _tmp_split = repo_version.split(".") - repo_version_compare = ( - int(_tmp_split[0]), - int(_tmp_split[1]), - int(_tmp_split[2]), - ) - - # tuples compare in significance from first to last element - return repo_version_compare > current_version_compare - - except Exception: - return True + 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") + + channels: dict[str, list[dict]] = {} + manifest_channels = manifest.get("channels", {}) + if not isinstance(manifest_channels, dict): + raise ValueError("invalid update 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 + entry = _entry_from_manifest(item, channel) + if entry is not None: + entries.append(entry) + channels[channel] = entries + + return channels 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: self-contained — uses the hardcoded URLs and does not - # require the remote migration_gate.json to exist. - self._trigger_migration(dict(_MIGRATION_VERSION_INFO)) - - 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_unlocked = self.config_object.get_option( + "software_unstable_unlocked" ) + self._unstable_entries: List[dict] = [] + self._square_count = 0 - 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"): - version_info = dict(_MIGRATION_VERSION_INFO) - nixos_url = config["nixos_url"] - version_info["migration_url"] = nixos_url - version_info["migration_sha256_url"] = f"{nixos_url}.sha256" - 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._phase = "loading" + 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 + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + + def _fetch_channels(self): + # 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. 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" + rollback = sys_utils.list_rollback_targets() + except Exception as e: # never let rollback listing break the screen + logger.warning("Could not list rollback targets: %s", e) + rollback = [] + + try: + manifest_channels = _fetch_update_manifest() + except (requests.exceptions.RequestException, ValueError) as e: + logger.warning("Software update check failed (offline/invalid?): %s", e) + if not rollback: + self._phase = "offline" + return + # Network channels are unavailable, but we can still offer Rollback. + 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" return - if res.status_code == 200: - self._release_version = res.text[:-1] - else: - self._release_version = "Unknown" + self._manifest_channels = manifest_channels + self._channels = { + "stable": manifest_channels.get("stable", []), + "beta": manifest_channels.get("beta", []), + } - def update_software(self): - self.message(_("Updating..."), 10) - if sys_utils.update_software(): - self.message(_("Ok! Restarting"), 10) - sys_utils.restart_system() - else: - self.message(_("Error on Upd"), 3) + if self._unstable_unlocked: + self._unstable_entries = manifest_channels.get("unstable", []) + self._channels["unstable"] = self._unstable_entries - def update(self, force=False): - self.clear_screen() - draw_pos = self.display_class.titlebar_height + 2 - self.draw.text( - (0, draw_pos), - _("Wifi Mode: {mode}").format(mode=self._wifi_mode), - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - draw_pos += self.fonts.base.height + 4 + if rollback: + self._channels["rollback"] = rollback - self.draw.text( - (0, draw_pos), - _("Current Version"), - font=self.fonts.bold.font, - fill=self.colors.get(128), - ) - draw_pos += self.fonts.bold.height - 3 + # Try to find subtitle for current version from fetched entries + self._software_subtitle = self._find_current_subtitle() - self.draw.text( - (10, draw_pos), - f"{self._software_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), - ) - draw_pos += self.fonts.bold.height + 3 + self._channel_names = list(self._channels.keys()) + self._channel_index = 0 + self._refresh_version_list() + self._phase = "browse" - self.draw.text( - (0, draw_pos), - _("Release Version"), - font=self.fonts.bold.font, - fill=self.colors.get(128), - ) - draw_pos += self.fonts.bold.height - 3 + def _find_current_subtitle(self) -> Optional[str]: + """Find a subtitle for the current version. - self.draw.text( - (10, draw_pos), - f"{self._release_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), - ) + Checks fetched channel entries first. + """ + for entries in self._channels.values(): + for entry in entries: + if entry.get("version") == self._software_version: + return entry.get("subtitle") - # 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 + return None - if self._wifi_mode != "Client": - self.draw.text( - (10, msg_top), - _("WiFi must be"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - self.draw.text( - (10, msg_bottom), - _("client mode"), - font=self.fonts.large.font, - fill=self.colors.get(255), + def _refresh_version_list(self): + if not self._channel_names: + self._version_list = [] + return + channel = self._channel_names[self._channel_index] + entries = self._channels.get(channel, []) + if channel in ("unstable", "rollback"): + self._version_list = entries + else: + self._version_list = [ + e for e in entries if e.get("version") != self._software_version + ] + 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.screen_update() + return self._scrollers[key] - 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, - fill=self.colors.get(255), - ) - 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), - ) - self._elipsis_count += 1 - if self._elipsis_count > 39: - self._elipsis_count = 0 - return self.screen_update() + # ------------------------------------------------------------------ + # Drawing helpers + # ------------------------------------------------------------------ - if not update_needed( - self._software_version.strip(), self._release_version.strip() - ): - self.draw.text( - (10, msg_top), - _("No Update"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - self.draw.text( - (10, msg_bottom), - _("needed"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - return self.screen_update() + def _draw_separator(self, y): + self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) - # If we are here, go for update! - self._go_for_update = True + 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( (10, 90), - _("Update Now"), + _("Checking for"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( (10, 105), - _("Cancel"), + _("updates{elipsis}").format(elipsis=dots), font=self.fonts.large.font, fill=self.colors.get(255), ) - if self._option_select == "Update": - ind_pos = msg_top - else: - ind_pos = msg_bottom + 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( - (0, ind_pos), - self._RIGHT_ARROW, + (10, 90), + _("WiFi must be"), font=self.fonts.large.font, fill=self.colors.get(255), ) - - 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() - y = self.display_class.titlebar_height + 2 - self.draw.text( - (0, y), - _("Major Upgrade"), - font=self.fonts.bold.font, + (10, 105), + _("client mode"), + font=self.fonts.large.font, fill=self.colors.get(255), ) - y += 14 - self.draw.text( - (5, y), - f"{self._current_version} -> {self._target_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), + 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, ) - y += 16 - - # Separator - self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) - y += 4 - + ver_scroller.draw((0, y)) self.draw.text( - (0, y), - _("IRREVERSIBLE"), - font=self.fonts.bold.font, + (10, 90), + _("No internet -"), + font=self.fonts.large.font, fill=self.colors.get(255), ) - 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), + (10, 105), + _("check WiFi"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - y += 11 - self.draw.text( - (0, y), - _("Power + WiFi req"), - font=self.fonts.base.font, - fill=self.colors.get(128), + def _draw_browse(self): + y = self.display_class.titlebar_height + 2 + + # 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, ) - y += 11 + 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 - if not self._version_info.get( - "migration_sha256_url" - ) and not self._version_info.get("migration_sha256"): + # Channel selector + channel_name = ( + self._channel_names[self._channel_index].capitalize() + if self._channel_names + else "---" + ) + if self._focus == "channel": self.draw.text( (0, y), - _("No checksum avail."), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, y), + channel_name, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + else: + self.draw.text( + (10, y), + channel_name, font=self.fonts.base.font, fill=self.colors.get(128), ) - y += 11 + y += 14 - y += 5 + self._draw_separator(y) + y += 4 - # Options - for i, label in enumerate(self._options): - oy = y + i * 12 + # Version list + if not self._version_list: self.draw.text( - (10, oy), - label, - font=self.fonts.bold.font, - fill=self.colors.get(255), + (10, y + 10), + _("No versions"), + font=self.fonts.base.font, + fill=self.colors.get(128), ) - if i == self._option_index: + self.draw.text( + (10, y + 22), + _("available"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + return + + label_width = self.fonts.base.line_length - 2 + 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] + label = entry["label"] + subtitle = entry.get("subtitle", "") + + if self._focus == "list" and idx == self._list_index: + if current_y + 24 > 128: + break self.draw.text( - (0, oy), + (0, current_y), self._RIGHT_ARROW, font=self.fonts.bold.font, fill=self.colors.get(255), ) + scroller = self._get_scroller( + "browse_label", + label, + self.fonts.bold, + self.colors.get(255), + label_width, + ) + scroller.draw((10, current_y)) + current_y += 12 + if subtitle: + sub_scroller = self._get_scroller( + "browse_sub", + subtitle, + self.fonts.base, + self.colors.get(128), + label_width, + ) + sub_scroller.draw((10, current_y)) + current_y += 12 + else: + if current_y + 12 > 128: + 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"• {label}"[:label_width], + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + else: + self.draw.text( + (10, current_y), + label[:label_width], + font=self.fonts.base.font, + fill=self.colors.get(192), + ) + current_y += 12 + + def _draw_confirm(self): + y = self.display_class.titlebar_height + 2 - return self.screen_update() + self.draw.text( + (0, y), + _("Update to:"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + y += 14 - def key_up(self): - self._option_index = (self._option_index - 1) % len(self._options) + label_width = self.fonts.base.line_length + version_label = ( + self._selected_version.get("version") or self._selected_version["label"] + ) + scroller = self._get_scroller( + "confirm_label", + version_label, + self.fonts.bold, + self.colors.get(255), + label_width, + ) + scroller.draw((0, y)) + y += 12 - def key_down(self): - self._option_index = (self._option_index + 1) % len(self._options) + 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 - def key_left(self): - return True + self._draw_separator(y) + y += 4 - 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, - } + 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 + self.draw.text( + (10, y), + self._fail_reason or _("Update failed!"), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += 20 + for label in ("Retry", "Cancel"): + if self._fail_option == label: + self.draw.text( + (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 + # ------------------------------------------------------------------ + # Main update loop + # ------------------------------------------------------------------ -class UIMigrationProgress(UIModule): - """ - Migration download and preparation progress screen. - Triggers the actual migration via sys_utils. - """ + def update(self, force=False): + self.clear_screen() - __title__ = "UPGRADE" + if self._phase == "upgrading": + self._draw_upgrading() + 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 == "failed": + self._draw_failed() + return self.screen_update() - def active(self): - super().active() - if not self._started: - self._started = True - self._start_migration() + if self._wifi_mode != "Client": + self._draw_wifi_warning() + 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']}" + if self._phase == "loading": + if self._elipsis_count > 30: + self._fetch_channels() + # phase is now "browse" or "offline", fall through + else: + self._draw_loading() + return self.screen_update() + + if self._phase == "offline": + self._draw_offline() + return self.screen_update() + + if self._phase == "browse": + self._draw_browse() + elif self._phase == "confirm": + self._draw_confirm() + + return self.screen_update() + + # ------------------------------------------------------------------ + # Key handlers + # ------------------------------------------------------------------ + + def _reset_unlock(self): + self._square_count = 0 + + def key_up(self): + self._reset_unlock() + 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): + self._reset_unlock() + 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): + self._reset_unlock() + if self._phase == "upgrading": + return + if self._phase == "failed": + if self._fail_option == "Retry": + self._phase = "confirm" + 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): + self._reset_unlock() + if self._phase == "upgrading": + return False + if self._phase == "confirm": + self._phase = "browse" + return False + return True + + def key_square(self): + self._square_count += 1 + if self._square_count >= 7 and not self._unstable_unlocked: + self._unstable_unlocked = True + self.config_object.set_option("software_unstable_unlocked", True) + self._unstable_entries = self._manifest_channels.get("unstable", []) + self._channels["unstable"] = self._unstable_entries + self._channel_names = list(self._channels.keys()) + self.message(_("Unstable\nunlocked"), 1) + + def key_number(self, number): + self._square_count = 0 + + # ------------------------------------------------------------------ + # 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 longer 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"): + self._fail_reason = ( + _("Version no longer available") + if phase == "unavailable" + else _("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), ) @@ -556,17 +788,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] @@ -576,44 +812,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)) + y += bar_h + 6 - return self.screen_update() - - def key_up(self): - self._status_layout.previous() - - 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, @@ -626,38 +864,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..f2203fa99 100644 --- a/python/PiFinder/ui/sqm.py +++ b/python/PiFinder/ui/sqm.py @@ -193,7 +193,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( diff --git a/python/PiFinder/ui/status.py b/python/PiFinder/ui/status.py index 97099f123..c59f5cb4b 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"] = "--" @@ -158,18 +152,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/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..00f6fced8 100644 --- a/python/PiFinder/utils.py +++ b/python/PiFinder/utils.py @@ -8,6 +8,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 +17,70 @@ 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" +tetra3_dir = pifinder_dir / "python/PiFinder/tetra3" data_dir = Path(Path.home(), "PiFinder_data") pifinder_db = astro_data_dir / "pifinder_objects.db" observations_db = data_dir / "observations.db" +build_json = pifinder_dir / "pifinder-build.json" +current_build_json = Path("/var/lib/pifinder/current-build.json") + + +def get_version() -> str: + for source in (current_build_json, build_json): + try: + with open(source, "r") as f: + version = json.load(f).get("version") + if version: + return version + except (FileNotFoundError, IOError, json.JSONDecodeError): + pass + return "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): @@ -168,22 +228,15 @@ def serialize_solution(solution) -> str: 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", - ) - - 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: + return importlib.import_module("PiFinder.sys_utils") + except Exception: + logger.warning( + "PiFinder.sys_utils failed to import; falling back to the no-op " + "fake. WiFi/AP/hostname/reboot controls will not work.", + exc_info=True, + ) + return importlib.import_module("PiFinder.sys_utils_fake") def get_os_info(): 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 7430f3c0f..43f4beaf4 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,4 +1,110 @@ +[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", + # Web / RPC + "grpcio", + "protobuf", + "flask", + "flask-babel", + "waitress", + "requests", + "pyjwt", + "aiofiles", + "json5", + "jsonschema", + "libarchive-c", + "tqdm", + # System / IPC bindings + "pygobject", + "dbus-python", + "av", + "smbus2", + "spidev", + # sh 2.x changed the API; PiFinder targets the 1.x interface. + "sh>=1.14,<2", + "gpsdclient", + "dataclasses-json", + "pydeepskylog", + "python-pam", + # 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", + # Display + "luma-oled", + "luma-lcd", + # Hardware / camera + "rpi-gpio", + "rpi-hardware-pwm", + "adafruit-blinka", + "adafruit-circuitpython-bno055", + "picamera2", + "pidng", + "simplejpeg", + "python-prctl", + "videodev2", +] + +[dependency-groups] +dev = [ + "pytest", + "mypy", + "luma-emulator", + "pyhotkey", + "selenium", + # pandas reads the Steinicke .xls catalog through xlrd (test_steinicke_parsing). + "xlrd>=2.0.1", +] + +[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'"] + +# 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 = [] + [tool.ruff] +builtins = ["_"] + # Exclude a variety of commonly ignored directories. exclude = [ ".bzr", @@ -26,19 +132,16 @@ exclude = [ "dist", "node_modules", "site-packages", - "venv", "tetra3", + "venv", ] # 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 @@ -57,6 +160,9 @@ unfixable = [] # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +[tool.ruff.lint.per-file-ignores] +"*.ipynb" = ["E402", "F841"] + [tool.ruff.format] # Like Black, use double quotes for strings. quote-style = "double" @@ -124,21 +230,46 @@ module = [ '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 +# The tetra3 / cedar-solve solver is a vendored git submodule. Treat it as an +# opaque dependency: 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.*', 'PiFinder.tetra3', 'PiFinder.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 b900b6805..000000000 --- a/python/requirements_dev.txt +++ /dev/null @@ -1,16 +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 -# 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/scripts/nixos_migration_calc.py b/python/scripts/nixos_migration_calc.py index d3500bdc7..8a39186ec 100755 --- a/python/scripts/nixos_migration_calc.py +++ b/python/scripts/nixos_migration_calc.py @@ -219,10 +219,7 @@ def main(): 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" initramfs renderer supported: {'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( diff --git a/python/tests/test_calc_utils.py b/python/tests/test_calc_utils.py index 48a9900ba..abf8c432d 100644 --- a/python/tests/test_calc_utils.py +++ b/python/tests/test_calc_utils.py @@ -297,9 +297,9 @@ def test_accuracy_vs_skyfield_within_floor(self): sep_arcsec = _angular_sep_arcsec(f_alt, f_az, s_alt, s_az) # 0.5 deg = 1800 arcsec; comfortably above the observed ~0.3 deg # floor while still tight enough to catch a sign-flip or unit bug. - assert ( - sep_arcsec < 1800.0 - ), f"FastAltAz deviated {sep_arcsec:.0f}'' at ra={ra}, dec={dec}" + assert sep_arcsec < 1800.0, ( + f"FastAltAz deviated {sep_arcsec:.0f}'' at ra={ra}, dec={dec}" + ) def test_lst_advances_with_time(self): """A 1-hour datetime delta should advance LST by ~15.04 deg (sidereal @@ -339,9 +339,9 @@ def test_matches_skyfield_with_refraction(self): # Empirically ~14'' median, well within 60''. 120'' tolerance # leaves headroom for the small refraction-model difference # between erfa and skyfield's adopted standard atmosphere. - assert ( - sep_arcsec < 120.0 - ), f"erfa apparent deviated {sep_arcsec:.1f}'' at ra={ra}, dec={dec}" + assert sep_arcsec < 120.0, ( + f"erfa apparent deviated {sep_arcsec:.1f}'' at ra={ra}, dec={dec}" + ) def test_matches_skyfield_without_refraction(self): sf = self._sf_with_location() @@ -351,9 +351,9 @@ def test_matches_skyfield_without_refraction(self): sep_arcsec = _angular_sep_arcsec(e_alt, e_az, s_alt, s_az) # No refraction-model disagreement here -- the residual is pure # precession/nutation/aberration math, identical at sub-arcsec. - assert ( - sep_arcsec < 30.0 - ), f"erfa no-atmos deviated {sep_arcsec:.1f}'' at ra={ra}, dec={dec}" + assert sep_arcsec < 30.0, ( + f"erfa no-atmos deviated {sep_arcsec:.1f}'' at ra={ra}, dec={dec}" + ) def test_atmos_flag_lifts_altitude(self): """atmos=True should produce an apparent altitude >= the geometric diff --git a/python/tests/test_cat_images.py b/python/tests/test_cat_images.py index df6901e1f..4b663dffe 100644 --- a/python/tests/test_cat_images.py +++ b/python/tests/test_cat_images.py @@ -59,9 +59,9 @@ def test_orthogonality(self): for fx, fy in [(1, 1), (-1, 1), (1, -1), (-1, -1)]: (nx, ny), (ex, ey) = cardinal_vectors(angle, fx, fy) dot = nx * ex + ny * ey - assert dot == pytest.approx( - 0, abs=1e-10 - ), f"Not orthogonal at angle={angle}, fx={fx}, fy={fy}" + assert dot == pytest.approx(0, abs=1e-10), ( + f"Not orthogonal at angle={angle}, fx={fx}, fy={fy}" + ) def test_unit_length(self): """N and E vectors should have unit length.""" @@ -139,9 +139,9 @@ def test_pa90_aligns_with_east(self): farthest[1] / math.hypot(*farthest), ) dot = abs(direction[0] * ex + direction[1] * ey) - assert dot == pytest.approx( - 1.0, abs=0.02 - ), f"PA=90 major axis not along East at image_rotate={rot}" + assert dot == pytest.approx(1.0, abs=0.02), ( + f"PA=90 major axis not along East at image_rotate={rot}" + ) def test_pa0_aligns_with_north(self): """PA=0 major axis must align with the North vector from cardinal_vectors.""" @@ -158,9 +158,9 @@ def test_pa0_aligns_with_north(self): ) # Should be parallel to North (same or opposite direction) dot = abs(direction[0] * nx + direction[1] * ny) - assert dot == pytest.approx( - 1.0, abs=0.02 - ), f"PA=0 major axis not along North at image_rotate={rot}" + assert dot == pytest.approx(1.0, abs=0.02), ( + f"PA=0 major axis not along North at image_rotate={rot}" + ) def test_flip_mirrors_x(self): """fx=-1 mirrors all points horizontally around cx.""" diff --git a/python/tests/test_catalog_data.py b/python/tests/test_catalog_data.py index 7478cef13..cba090571 100644 --- a/python/tests/test_catalog_data.py +++ b/python/tests/test_catalog_data.py @@ -43,9 +43,9 @@ def test_object_counts(): expected_catalogs = list(catalog_counts.keys()) missing_catalogs = set(expected_catalogs) - set(actual_catalogs) extra_catalogs = set(actual_catalogs) - set(expected_catalogs) - assert ( - not missing_catalogs and not extra_catalogs - ), f"Catalog mismatch. Missing catalogs: {sorted(missing_catalogs)}. Extra catalogs: {sorted(extra_catalogs)}" + assert not missing_catalogs and not extra_catalogs, ( + f"Catalog mismatch. Missing catalogs: {sorted(missing_catalogs)}. Extra catalogs: {sorted(extra_catalogs)}" + ) # Catalog Counts for catalog_code, count in catalog_counts.items(): @@ -104,20 +104,20 @@ def check_messier_objects(): # Validate M45 coordinates (Pleiades) # Expected: RA=56.85°, Dec=+24.117° - assert coords_are_close( - m45_obj["ra"], 56.85 - ), f"M45 RA should be ~56.85°, got {m45_obj['ra']}" - assert coords_are_close( - m45_obj["dec"], 24.117 - ), f"M45 Dec should be ~24.117°, got {m45_obj['dec']}" + assert coords_are_close(m45_obj["ra"], 56.85), ( + f"M45 RA should be ~56.85°, got {m45_obj['ra']}" + ) + assert coords_are_close(m45_obj["dec"], 24.117), ( + f"M45 Dec should be ~24.117°, got {m45_obj['dec']}" + ) # Validate M45 object type and constellation - assert ( - m45_obj["obj_type"] == "OC" - ), f"M45 should be type 'OC' (open cluster), got '{m45_obj['obj_type']}'" - assert ( - m45_obj["const"] == "Tau" - ), f"M45 should be in Taurus (Tau), got '{m45_obj['const']}'" + assert m45_obj["obj_type"] == "OC", ( + f"M45 should be type 'OC' (open cluster), got '{m45_obj['obj_type']}'" + ) + assert m45_obj["const"] == "Tau", ( + f"M45 should be in Taurus (Tau), got '{m45_obj['const']}'" + ) # Test M40 - Winnecke 4 (should have been added by post-processing) m40_catalog_obj = db.get_catalog_object_by_sequence("M", 40) @@ -128,20 +128,20 @@ def check_messier_objects(): # Validate M40 coordinates (Winnecke 4) # Expected: RA=185.552°, Dec=+58.083° - assert coords_are_close( - m40_obj["ra"], 185.552 - ), f"M40 RA should be ~185.552°, got {m40_obj['ra']}" - assert coords_are_close( - m40_obj["dec"], 58.083 - ), f"M40 Dec should be ~58.083°, got {m40_obj['dec']}" + assert coords_are_close(m40_obj["ra"], 185.552), ( + f"M40 RA should be ~185.552°, got {m40_obj['ra']}" + ) + assert coords_are_close(m40_obj["dec"], 58.083), ( + f"M40 Dec should be ~58.083°, got {m40_obj['dec']}" + ) # Validate M40 object type and constellation - assert ( - m40_obj["obj_type"] == "D*" - ), f"M40 should be type 'D*' (double star), got '{m40_obj['obj_type']}'" - assert ( - m40_obj["const"] == "UMa" - ), f"M40 should be in Ursa Major (UMa), got '{m40_obj['const']}'" + assert m40_obj["obj_type"] == "D*", ( + f"M40 should be type 'D*' (double star), got '{m40_obj['obj_type']}'" + ) + assert m40_obj["const"] == "UMa", ( + f"M40 should be in Ursa Major (UMa), got '{m40_obj['const']}'" + ) def check_ngc_objects(): @@ -221,32 +221,32 @@ def check_ngc_objects(): # Get object from database catalog_obj = db.get_catalog_object_by_sequence("NGC", ngc_num) - assert ( - catalog_obj is not None - ), f"NGC {ngc_num} ({name}) should exist in catalog" + assert catalog_obj is not None, ( + f"NGC {ngc_num} ({name}) should exist in catalog" + ) obj = db.get_object_by_id(catalog_obj["object_id"]) assert obj is not None, f"NGC {ngc_num} ({name}) object should exist" # Check coordinates (allow 0.1 degree tolerance for coordinate precision) - assert coords_are_close( - obj["ra"], test_obj["ra"], tolerance=0.1 - ), f"NGC {ngc_num} ({name}) RA should be ~{test_obj['ra']}°, got {obj['ra']}°" + assert coords_are_close(obj["ra"], test_obj["ra"], tolerance=0.1), ( + f"NGC {ngc_num} ({name}) RA should be ~{test_obj['ra']}°, got {obj['ra']}°" + ) - assert coords_are_close( - obj["dec"], test_obj["dec"], tolerance=0.1 - ), f"NGC {ngc_num} ({name}) Dec should be ~{test_obj['dec']}°, got {obj['dec']}°" + assert coords_are_close(obj["dec"], test_obj["dec"], tolerance=0.1), ( + f"NGC {ngc_num} ({name}) Dec should be ~{test_obj['dec']}°, got {obj['dec']}°" + ) # Check object type - assert ( - obj["obj_type"] == test_obj["obj_type"] - ), f"NGC {ngc_num} ({name}) should be type '{test_obj['obj_type']}', got '{obj['obj_type']}'" + assert obj["obj_type"] == test_obj["obj_type"], ( + f"NGC {ngc_num} ({name}) should be type '{test_obj['obj_type']}', got '{obj['obj_type']}'" + ) # Check constellation (if provided) if test_obj["const"]: - assert ( - obj["const"] == test_obj["const"] - ), f"NGC {ngc_num} ({name}) should be in {test_obj['const']}, got '{obj['const']}'" + assert obj["const"] == test_obj["const"], ( + f"NGC {ngc_num} ({name}) should be in {test_obj['const']}, got '{obj['const']}'" + ) print( f"✓ NGC {ngc_num} ({name}): RA={obj['ra']:.3f}°, Dec={obj['dec']:.3f}°, Type={obj['obj_type']}, Const={obj['const']}" @@ -317,24 +317,24 @@ def check_ic_objects(): assert obj is not None, f"IC {ic_num} ({name}) object should exist" # Check coordinates (allow 0.1 degree tolerance for coordinate precision) - assert coords_are_close( - obj["ra"], test_obj["ra"], tolerance=0.1 - ), f"IC {ic_num} ({name}) RA should be ~{test_obj['ra']}°, got {obj['ra']}°" + assert coords_are_close(obj["ra"], test_obj["ra"], tolerance=0.1), ( + f"IC {ic_num} ({name}) RA should be ~{test_obj['ra']}°, got {obj['ra']}°" + ) - assert coords_are_close( - obj["dec"], test_obj["dec"], tolerance=0.1 - ), f"IC {ic_num} ({name}) Dec should be ~{test_obj['dec']}°, got {obj['dec']}°" + assert coords_are_close(obj["dec"], test_obj["dec"], tolerance=0.1), ( + f"IC {ic_num} ({name}) Dec should be ~{test_obj['dec']}°, got {obj['dec']}°" + ) # Check object type - assert ( - obj["obj_type"] == test_obj["obj_type"] - ), f"IC {ic_num} ({name}) should be type '{test_obj['obj_type']}', got '{obj['obj_type']}'" + assert obj["obj_type"] == test_obj["obj_type"], ( + f"IC {ic_num} ({name}) should be type '{test_obj['obj_type']}', got '{obj['obj_type']}'" + ) # Check constellation (if provided) if test_obj["const"]: - assert ( - obj["const"] == test_obj["const"] - ), f"IC {ic_num} ({name}) should be in {test_obj['const']}, got '{obj['const']}'" + assert obj["const"] == test_obj["const"], ( + f"IC {ic_num} ({name}) should be in {test_obj['const']}, got '{obj['const']}'" + ) print( f"✓ IC {ic_num} ({name}): RA={obj['ra']:.3f}°, Dec={obj['dec']:.3f}°, Type={obj['obj_type']}, Const={obj['const']}" @@ -413,9 +413,9 @@ def on_complete(objects): # Verify results assert loaded_count == 100, f"Expected 100 objects, got {loaded_count}" - assert ( - len(loaded_objects) == 100 - ), f"Expected 100 loaded objects, got {len(loaded_objects)}" + assert len(loaded_objects) == 100, ( + f"Expected 100 loaded objects, got {len(loaded_objects)}" + ) # Verify objects have details loaded for obj in loaded_objects[:10]: # Check first 10 diff --git a/python/tests/test_imu_dead_reckoning_equivalence.py b/python/tests/test_imu_dead_reckoning_equivalence.py index 5ba389c64..4aa9863f6 100644 --- a/python/tests/test_imu_dead_reckoning_equivalence.py +++ b/python/tests/test_imu_dead_reckoning_equivalence.py @@ -50,15 +50,15 @@ def assert_radec_close(new_pt, old_pt, abs_tol=1e-9): """Compare two RaDecRoll outputs; RA wrap handled via modulo 2pi.""" assert new_pt is not None and old_pt is not None ra_diff = (new_pt.ra - old_pt.ra + np.pi) % (2 * np.pi) - np.pi - assert ra_diff == pytest.approx( - 0.0, abs=abs_tol - ), f"ra: new={new_pt.ra} old={old_pt.ra}" - assert new_pt.dec == pytest.approx( - old_pt.dec, abs=abs_tol - ), f"dec: new={new_pt.dec} old={old_pt.dec}" - assert new_pt.roll == pytest.approx( - old_pt.roll, abs=abs_tol - ), f"roll: new={new_pt.roll} old={old_pt.roll}" + assert ra_diff == pytest.approx(0.0, abs=abs_tol), ( + f"ra: new={new_pt.ra} old={old_pt.ra}" + ) + assert new_pt.dec == pytest.approx(old_pt.dec, abs=abs_tol), ( + f"dec: new={new_pt.dec} old={old_pt.dec}" + ) + assert new_pt.roll == pytest.approx(old_pt.roll, abs=abs_tol), ( + f"roll: new={new_pt.roll} old={old_pt.roll}" + ) def derive_aligned( diff --git a/python/tests/test_integrator_drift.py b/python/tests/test_integrator_drift.py index 47db75e1e..58867f43f 100644 --- a/python/tests/test_integrator_drift.py +++ b/python/tests/test_integrator_drift.py @@ -358,12 +358,12 @@ def test_stationary_drift(self): # Stationary scope with 1 arcsec noise: drift should be tiny # Baseline: ~0 arcsec (noise below measurement precision) - assert ( - mean_error < 5 - ), f"Stationary mean drift {mean_error:.1f} arcsec exceeds 5 arcsec threshold" - assert ( - max_error < 10 - ), f"Stationary max drift {max_error:.1f} arcsec exceeds 10 arcsec threshold" + assert mean_error < 5, ( + f"Stationary mean drift {mean_error:.1f} arcsec exceeds 5 arcsec threshold" + ) + assert max_error < 10, ( + f"Stationary max drift {max_error:.1f} arcsec exceeds 10 arcsec threshold" + ) def test_slew_tracking_accuracy(self): """ @@ -380,9 +380,9 @@ def test_slew_tracking_accuracy(self): # With 5 arcsec/s drift and 3s solve intervals, accumulated drift # between solves is bounded. Baseline: mean ~6, max ~13 arcsec. - assert ( - mean_error < 15 - ), f"Slew mean drift {mean_error:.1f} arcsec exceeds 15 arcsec threshold" + assert mean_error < 15, ( + f"Slew mean drift {mean_error:.1f} arcsec exceeds 15 arcsec threshold" + ) # Verify errors don't grow without bound across the session if len(errors) >= 20: diff --git a/python/tests/test_nixos_upgrade.py b/python/tests/test_nixos_upgrade.py new file mode 100644 index 000000000..489c61118 --- /dev/null +++ b/python/tests/test_nixos_upgrade.py @@ -0,0 +1,261 @@ +import json + +import pytest + +from PiFinder import nixos_upgrade + + +STORE = "/nix/store/abc123-nixos-system-pifinder" + + +@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_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, "store_path_available", lambda _store: False) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "unavailable"] + + +@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, "store_path_available", lambda _store: True) + + 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_software.py b/python/tests/test_software.py index ba73e33d3..69ec38771 100644 --- a/python/tests/test_software.py +++ b/python/tests/test_software.py @@ -1,58 +1,14 @@ -from unittest.mock import patch, MagicMock - import pytest -import requests +from unittest.mock import MagicMock, patch from PiFinder.ui.software import ( - update_needed, + _fetch_update_manifest, _strip_markdown, - _fetch_migration_config, - _UNLOCK_SEQUENCE, + UPDATE_MANIFEST_URL, + UISoftware, ) -_NIXOS_URL = "https://example.invalid/pifinder-nixos.tar.zst" - - -@pytest.mark.unit -class TestUpdateNeeded: - def test_newer_version_available(self): - assert update_needed("2.3.0", "2.4.0") is True - - def test_same_version(self): - assert update_needed("2.4.0", "2.4.0") is False - - def test_older_version(self): - assert update_needed("2.5.0", "2.4.0") is False - - def test_major_version_bump(self): - assert update_needed("1.9.9", "2.0.0") is True - - def test_patch_bump(self): - assert update_needed("2.4.0", "2.4.1") is True - - def test_garbage_input_returns_true(self): - assert update_needed("garbage", "2.4.0") is True - - def test_empty_string_returns_true(self): - assert update_needed("", "") is True - - def test_partial_version_returns_true(self): - assert update_needed("2.4", "2.5.0") is True - - 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): @@ -82,70 +38,183 @@ def test_multiline(self): assert "**" not in result -def _mock_json_response(payload, status_code=200): - resp = MagicMock() - resp.status_code = status_code - resp.json.return_value = payload - return resp - - -def _mock_invalid_json_response(status_code=200): - resp = MagicMock() - resp.status_code = status_code - resp.json.side_effect = ValueError("not json") - return resp - - @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 + + channels = _fetch_update_manifest() + + 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_dict_when_gate_closed_but_url_set(self, mock_get): - # Gate check is the caller's job; fetch only requires nixos_url. - payload = {"nixos_for_everyone": False, "nixos_url": _NIXOS_URL} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_migration_config() == payload + 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 + + channels = _fetch_update_manifest() + + 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_when_url_missing(self, mock_get): - mock_get.return_value = _mock_json_response({"nixos_for_everyone": True}) - 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_url_empty(self, mock_get): - mock_get.return_value = _mock_json_response( - {"nixos_for_everyone": True, "nixos_url": ""} - ) - assert _fetch_migration_config() is None + 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 - @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 + with pytest.raises(ValueError): + _fetch_update_manifest() - @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 - @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 +@pytest.mark.unit +def test_unstable_list_keeps_current_trunk_entry_visible(): + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["unstable"] + ui._channel_index = 0 + ui._software_version = "nixos-current" + ui._channels = { + "unstable": [ + {"label": "nixos-current", "version": "nixos-current", "is_trunk": True}, + {"label": "PR#1-abcdef0", "version": "PR#1-abcdef0"}, + ] + } + + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == [ + "nixos-current", + "PR#1-abcdef0", + ] - @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 - @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 +@pytest.mark.unit +def test_stable_list_filters_current_version(): + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["stable"] + ui._channel_index = 0 + ui._software_version = "nixos-current" + ui._channels = { + "stable": [ + {"label": "nixos-current", "version": "nixos-current"}, + {"label": "nixos-next", "version": "nixos-next"}, + ] + } + + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["nixos-next"] + + +@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"] diff --git a/python/tests/test_sqm.py b/python/tests/test_sqm.py index 7b9f610e9..bf0529d2f 100644 --- a/python/tests/test_sqm.py +++ b/python/tests/test_sqm.py @@ -53,9 +53,9 @@ def test_extinction_increases_toward_horizon(self): # Extinction should increase monotonically as altitude decreases 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]}°" + assert extinctions[i] < extinctions[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 +161,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})" ) 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_sys_utils_fake.py b/python/tests/test_sys_utils_fake.py index 17972cad0..647fca1f5 100644 --- a/python/tests/test_sys_utils_fake.py +++ b/python/tests/test_sys_utils_fake.py @@ -31,9 +31,9 @@ def test_backup_contains_expected_files(self): # Check that files follow expected path structure expected_prefix = "home/pifinder/PiFinder_data/" for filename in file_list: - assert filename.startswith( - expected_prefix - ), f"File {filename} doesn't have expected prefix" + assert filename.startswith(expected_prefix), ( + f"File {filename} doesn't have expected prefix" + ) def test_backup_removes_existing_backup(self): """Test that backup_userdata removes existing backup before creating new one""" diff --git a/python/tests/test_ui_modules.py b/python/tests/test_ui_modules.py index e94a5400f..c6b2cac55 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 @@ -88,7 +88,6 @@ 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 # --------------------------------------------------------------------------- # @@ -122,8 +121,7 @@ # 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", + # (none currently -- UISQMCorrection is covered via the dynamic fixtures) } # Bound on the auto-sweep so a handler that keeps pushing modules @@ -185,8 +183,6 @@ def _node_id(node) -> str: "UISQMCalibration", "UISQMSweep", "UISQMCorrection", - "UIMigrationConfirm", - "UIMigrationProgress", ] @@ -230,23 +226,6 @@ def _build_dynamic_item_definition(spec_id: str, sample_object) -> dict: "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", - } raise KeyError(spec_id) # pragma: no cover @@ -570,10 +549,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/tests/website/test_web_api_direct.py b/python/tests/website/test_web_api_direct.py index 2ecfd7ce4..92b3e1964 100644 --- a/python/tests/website/test_web_api_direct.py +++ b/python/tests/website/test_web_api_direct.py @@ -85,12 +85,12 @@ def test_key_callback_button(driver, button): json={"button": button}, cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200 for button '{button}', got {response.status_code}: {response.text}" - assert ( - response.json().get("message") == "success" - ), f"Unexpected response for button '{button}': {response.json()}" + assert response.status_code == 200, ( + f"Expected 200 for button '{button}', got {response.status_code}: {response.text}" + ) + assert response.json().get("message") == "success", ( + f"Unexpected response for button '{button}': {response.json()}" + ) # ── /image ──────────────────────────────────────────────────────────────────── @@ -101,9 +101,9 @@ def test_image_endpoint_returns_png(driver): """/image must return 200 with image/png content without authentication.""" response = requests.get(f"{get_homepage_url()}/image") assert response.status_code == 200, f"Expected 200, got {response.status_code}" - assert "image/png" in response.headers.get( - "Content-Type", "" - ), f"Expected image/png, got {response.headers.get('Content-Type')}" + assert "image/png" in response.headers.get("Content-Type", ""), ( + f"Expected image/png, got {response.headers.get('Content-Type')}" + ) assert len(response.content) > 0, "Image response body must not be empty" @@ -118,9 +118,9 @@ def test_api_current_selection_returns_json(driver): f"{get_homepage_url()}/api/current-selection", cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) data = response.json() assert isinstance(data, dict), f"Expected a JSON object, got {type(data)}" @@ -137,9 +137,9 @@ def test_logs_stream_returns_json(driver): params={"position": 0}, cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) data = response.json() assert "logs" in data, f"Missing 'logs' key in response: {data}" assert "position" in data, f"Missing 'position' key in response: {data}" @@ -157,9 +157,9 @@ def test_logs_configs_returns_json(driver): f"{get_homepage_url()}/logs/configs", cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) data = response.json() assert "configs" in data, f"Missing 'configs' key in response: {data}" assert isinstance(data["configs"], list), "'configs' must be a list" @@ -177,13 +177,13 @@ def test_logs_switch_config_rejects_invalid_filename(driver): data={"logconf_file": "evil.json"}, cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) data = response.json() - assert ( - data.get("status") == "error" - ), f"Expected error status for invalid filename, got: {data}" + assert data.get("status") == "error", ( + f"Expected error status for invalid filename, got: {data}" + ) @pytest.mark.web @@ -195,13 +195,13 @@ def test_logs_switch_config_rejects_nonexistent_file(driver): data={"logconf_file": "logconf_nonexistent_xyzzy.json"}, cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) data = response.json() - assert ( - data.get("status") == "error" - ), f"Expected error status for missing file, got: {data}" + assert data.get("status") == "error", ( + f"Expected error status for missing file, got: {data}" + ) # ── /logs/upload_config ─────────────────────────────────────────────────────── @@ -216,13 +216,13 @@ def test_logs_upload_config_rejects_bad_filename(driver): files={"config_file": ("bad_name.json", b"{}", "application/json")}, cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) data = response.json() - assert ( - data.get("status") == "error" - ), f"Expected error status for bad filename, got: {data}" + assert data.get("status") == "error", ( + f"Expected error status for bad filename, got: {data}" + ) @pytest.mark.web @@ -233,13 +233,13 @@ def test_logs_upload_config_rejects_missing_file(driver): f"{get_homepage_url()}/logs/upload_config", cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) data = response.json() - assert ( - data.get("status") == "error" - ), f"Expected error status when no file provided, got: {data}" + assert data.get("status") == "error", ( + f"Expected error status when no file provided, got: {data}" + ) # ── /tools/restore ──────────────────────────────────────────────────────────── @@ -255,12 +255,12 @@ def test_tools_restore_renders_restart_page(driver): files={"backup_file": ("PiFinder_backup.zip", zip_bytes, "application/zip")}, cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" - assert ( - "Restarting PiFinder" in response.text - ), "Expected restart_pifinder.html content in response" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) + assert "Restarting PiFinder" in response.text, ( + "Expected restart_pifinder.html content in response" + ) # ── /logs/download ──────────────────────────────────────────────────────────── @@ -274,12 +274,12 @@ def test_logs_download_returns_zip(driver): f"{get_homepage_url()}/logs/download", cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" - assert "zip" in response.headers.get( - "Content-Type", "" - ), f"Expected zip content-type, got {response.headers.get('Content-Type')}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) + assert "zip" in response.headers.get("Content-Type", ""), ( + f"Expected zip content-type, got {response.headers.get('Content-Type')}" + ) assert len(response.content) > 0, "Zip response body must not be empty" @@ -294,12 +294,12 @@ def test_tools_backup_returns_zip(driver): f"{get_homepage_url()}/tools/backup", cookies=cookies, ) - assert ( - response.status_code == 200 - ), f"Expected 200, got {response.status_code}: {response.text[:200]}" - assert "zip" in response.headers.get( - "Content-Type", "" - ), f"Expected zip content-type, got {response.headers.get('Content-Type')}" + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text[:200]}" + ) + assert "zip" in response.headers.get("Content-Type", ""), ( + f"Expected zip content-type, got {response.headers.get('Content-Type')}" + ) assert len(response.content) > 0, "Backup zip body must not be empty" @@ -313,9 +313,9 @@ def test_system_restart_requires_auth(driver): f"{get_homepage_url()}/system/restart", allow_redirects=False, ) - assert ( - response.status_code in (302, 401) - ), f"Expected redirect for unauthenticated /system/restart, got {response.status_code}" + assert response.status_code in (302, 401), ( + f"Expected redirect for unauthenticated /system/restart, got {response.status_code}" + ) @pytest.mark.web @@ -325,9 +325,9 @@ def test_system_restart_pifinder_requires_auth(driver): f"{get_homepage_url()}/system/restart_pifinder", allow_redirects=False, ) - assert ( - response.status_code in (302, 401) - ), f"Expected redirect for unauthenticated /system/restart_pifinder, got {response.status_code}" + assert response.status_code in (302, 401), ( + f"Expected redirect for unauthenticated /system/restart_pifinder, got {response.status_code}" + ) # ── /gps/update ─────────────────────────────────────────────────────────────── @@ -343,9 +343,9 @@ def test_gps_update_redirects_to_home(driver): cookies=cookies, allow_redirects=False, ) - assert ( - response.status_code == 302 - ), f"Expected 302 redirect, got {response.status_code}: {response.text[:200]}" + assert response.status_code == 302, ( + f"Expected 302 redirect, got {response.status_code}: {response.text[:200]}" + ) # ── auth guards ─────────────────────────────────────────────────────────────── diff --git a/python/tests/website/test_web_equipment.py b/python/tests/website/test_web_equipment.py index 4bd764085..cfcb23f43 100644 --- a/python/tests/website/test_web_equipment.py +++ b/python/tests/website/test_web_equipment.py @@ -158,9 +158,9 @@ def test_equipment_instruments_table_structure(driver): ] for expected_header in expected_headers: - assert any( - expected_header in header for header in header_texts - ), f"Missing instruments table header: {expected_header}" + assert any(expected_header in header for header in header_texts), ( + f"Missing instruments table header: {expected_header}" + ) # Verify table body exists table_body = instruments_table.find_element(By.TAG_NAME, "tbody") @@ -201,9 +201,9 @@ def test_equipment_eyepieces_table_structure(driver): ] for expected_header in expected_headers: - assert any( - expected_header in header for header in header_texts - ), f"Missing eyepieces table header: {expected_header}" + assert any(expected_header in header for header in header_texts), ( + f"Missing eyepieces table header: {expected_header}" + ) # Verify table body exists table_body = eyepieces_table.find_element(By.TAG_NAME, "tbody") @@ -276,9 +276,9 @@ def test_equipment_add_instrument_functionality(driver): test_instrument_row_index = i break - assert ( - test_instrument_found - ), f"Test instrument '{test_instrument['name']}' not found in instruments table" + assert test_instrument_found, ( + f"Test instrument '{test_instrument['name']}' not found in instruments table" + ) # Now delete the test instrument to clean up. delete_link = rows[test_instrument_row_index].find_element( @@ -310,7 +310,9 @@ def test_equipment_add_instrument_functionality(driver): test_instrument_still_found = True break - assert not test_instrument_still_found, f"Test instrument '{test_instrument['name']}' still found in table after deletion" + assert not test_instrument_still_found, ( + f"Test instrument '{test_instrument['name']}' still found in table after deletion" + ) @pytest.mark.web @@ -378,9 +380,9 @@ def test_equipment_add_eyepiece_functionality(driver): test_eyepiece_row_index = i break - assert ( - test_eyepiece_found - ), f"Test eyepiece '{test_eyepiece['name']}' not found in eyepieces table" + assert test_eyepiece_found, ( + f"Test eyepiece '{test_eyepiece['name']}' not found in eyepieces table" + ) # Now delete the test eyepiece to clean up. delete_link = rows[test_eyepiece_row_index].find_element( @@ -412,9 +414,9 @@ def test_equipment_add_eyepiece_functionality(driver): test_eyepiece_still_found = True break - assert ( - not test_eyepiece_still_found - ), f"Test eyepiece '{test_eyepiece['name']}' still found in table after deletion" + assert not test_eyepiece_still_found, ( + f"Test eyepiece '{test_eyepiece['name']}' still found in table after deletion" + ) @pytest.mark.web @@ -482,7 +484,9 @@ def test_equipment_select_active_instrument(driver): target_is_active = True break - assert target_is_active, f"Instrument '{target_instrument_name}' should be marked as active after selection" + assert target_is_active, ( + f"Instrument '{target_instrument_name}' should be marked as active after selection" + ) @pytest.mark.web @@ -550,9 +554,9 @@ def test_equipment_select_active_eyepiece(driver): target_is_active = True break - assert ( - target_is_active - ), f"Eyepiece '{target_eyepiece_name}' should be marked as active after selection" + assert target_is_active, ( + f"Eyepiece '{target_eyepiece_name}' should be marked as active after selection" + ) def _login_to_equipment(driver): diff --git a/python/tests/website/test_web_interface.py b/python/tests/website/test_web_interface.py index 9fcb45fec..2e15911f5 100644 --- a/python/tests/website/test_web_interface.py +++ b/python/tests/website/test_web_interface.py @@ -39,9 +39,9 @@ def test_webpage_loads_and_displays_image(driver): # Assert that at least one visual element is present visual_elements_count = len(images) + len(canvas_elements) + len(video_elements) - assert ( - visual_elements_count > 0 - ), "No images, canvas, or video elements found on the page" + assert visual_elements_count > 0, ( + "No images, canvas, or video elements found on the page" + ) # If there are img elements, verify at least one has a src attribute if images: @@ -107,9 +107,9 @@ def test_software_version_element_present(driver): # Look for Software Version text body_text = driver.find_element(By.TAG_NAME, "body").text - assert ( - "Software Version" in body_text - ), "Software Version information not found on the page" + assert "Software Version" in body_text, ( + "Software Version information not found on the page" + ) @pytest.mark.web @@ -132,6 +132,6 @@ def test_all_main_elements_present(driver): # Verify the table has the expected number of rows (4 main sections) rows = table.find_elements(By.TAG_NAME, "tr") - assert ( - len(rows) >= 4 - ), f"Expected at least 4 rows in status table, found {len(rows)}" + assert len(rows) >= 4, ( + f"Expected at least 4 rows in status table, found {len(rows)}" + ) diff --git a/python/tests/website/test_web_locations.py b/python/tests/website/test_web_locations.py index 5d35190b6..3e7452edc 100644 --- a/python/tests/website/test_web_locations.py +++ b/python/tests/website/test_web_locations.py @@ -157,9 +157,9 @@ def test_locations_table_present(driver): "Actions", ] for expected_header in expected_headers: - assert any( - expected_header in header for header in header_texts - ), f"Missing header: {expected_header}" + assert any(expected_header in header for header in header_texts), ( + f"Missing header: {expected_header}" + ) # Verify table body exists table_body = driver.find_element(By.TAG_NAME, "tbody") @@ -457,9 +457,9 @@ def test_locations_add_dms_location(driver): break # Verify the location exists - assert ( - found_test_location_4 is not None - ), "Test location 4 was not found in the table" + assert found_test_location_4 is not None, ( + "Test location 4 was not found in the table" + ) # Verify coordinates are approximately correct (DMS 35°41'22"N 139°41'30"E should convert to ~35.689444, 139.691667) expected_lat = 35.689444 # 35 + 41/60 + 22/3600 @@ -469,20 +469,20 @@ def test_locations_add_dms_location(driver): actual_lon = float(found_test_location_4["longitude"]) # Allow small tolerance for DMS conversion - assert ( - abs(actual_lat - expected_lat) < 0.000001 - ), f"Latitude mismatch: expected ~{expected_lat}, got {actual_lat}" - assert ( - abs(actual_lon - expected_lon) < 0.000001 - ), f"Longitude mismatch: expected ~{expected_lon}, got {actual_lon}" + assert abs(actual_lat - expected_lat) < 0.000001, ( + f"Latitude mismatch: expected ~{expected_lat}, got {actual_lat}" + ) + assert abs(actual_lon - expected_lon) < 0.000001, ( + f"Longitude mismatch: expected ~{expected_lon}, got {actual_lon}" + ) # Verify altitude and error - assert ( - found_test_location_4["altitude"] == "40.0m" - ), f"Altitude mismatch: expected 40.0m, got {found_test_location_4['altitude']}" - assert ( - found_test_location_4["error"] == "12.0m" - ), f"Error mismatch: expected 12.0m, got {found_test_location_4['error']}" + assert found_test_location_4["altitude"] == "40.0m", ( + f"Altitude mismatch: expected 40.0m, got {found_test_location_4['altitude']}" + ) + assert found_test_location_4["error"] == "12.0m", ( + f"Error mismatch: expected 12.0m, got {found_test_location_4['error']}" + ) @pytest.mark.web @@ -612,15 +612,15 @@ def test_locations_add_remote(driver): break # Assert that the specific location was added - assert ( - specific_location_found - ), f"Location '{location_name}' not found in locations table after remote save" + assert specific_location_found, ( + f"Location '{location_name}' not found in locations table after remote save" + ) # Verify the location was saved with a source recorded (GPS, WEB, fakeGPS, etc.) assert found_location_data is not None, "Location data should not be None" - assert found_location_data[ - "source" - ], f"Expected a non-empty source, got: {found_location_data['source']}" + assert found_location_data["source"], ( + f"Expected a non-empty source, got: {found_location_data['source']}" + ) # Log the found location for debugging/verification # Successfully found location: {found_location_data} @@ -636,9 +636,9 @@ def test_locations_add_remote(driver): location_row_index = i break - assert ( - location_row_index is not None - ), f"Could not find row index for location '{location_name}'" + assert location_row_index is not None, ( + f"Could not find row index for location '{location_name}'" + ) # Click the delete button for this location (uses loop.index0 which is the row index) delete_button_selector = f"a[href='#delete-modal-{location_row_index}']" @@ -695,9 +695,9 @@ def test_locations_add_remote(driver): break # Assert that the location was successfully deleted - assert ( - not location_still_exists - ), f"Location '{location_name}' still exists in table after deletion" + assert not location_still_exists, ( + f"Location '{location_name}' still exists in table after deletion" + ) @pytest.mark.web @@ -717,9 +717,9 @@ def test_locations_default_switching(driver): existing_locations = table_body.find_elements(By.TAG_NAME, "tr") # Check that there are at least two locations before proceeding - assert ( - len(existing_locations) >= 2 - ), f"Need at least 2 locations to test default switching, found {len(existing_locations)}" + assert len(existing_locations) >= 2, ( + f"Need at least 2 locations to test default switching, found {len(existing_locations)}" + ) current_default_index = None current_default_name = None @@ -846,17 +846,17 @@ def get_location_info(locations): elif location_name == current_default_name and has_star: old_default_lost_star = False # Old default still has star (bad) else: - assert ( - False - ), f"Table row {i, row} does not have enough cells to verify default status" + assert False, ( + f"Table row {i, row} does not have enough cells to verify default status" + ) # Assert the switch worked correctly - assert ( - new_default_has_star - ), f"Location '{non_default_name}' should now have the star (be default)" - assert ( - old_default_lost_star - ), f"Location '{current_default_name}' should no longer have the star" + assert new_default_has_star, ( + f"Location '{non_default_name}' should now have the star (be default)" + ) + assert old_default_lost_star, ( + f"Location '{current_default_name}' should no longer have the star" + ) # Step 3: Switch back to the original default location # Find the row index of the original default location in the updated table @@ -872,9 +872,9 @@ def get_location_info(locations): original_default_new_index = i break - assert ( - original_default_new_index is not None - ), f"Could not find original default location '{current_default_name}' in updated table" + assert original_default_new_index is not None, ( + f"Could not find original default location '{current_default_name}' in updated table" + ) # Click to make the original location default again restore_default_button = driver.find_element( @@ -925,10 +925,12 @@ def get_location_info(locations): new_default_lost_star = False # Still has star (bad) # Assert we're back to original state - assert original_restored, f"Original default location '{current_default_name}' should have the star restored" - assert ( - new_default_lost_star - ), f"Location '{non_default_name}' should no longer have the star" + assert original_restored, ( + f"Original default location '{current_default_name}' should have the star restored" + ) + assert new_default_lost_star, ( + f"Location '{non_default_name}' should no longer have the star" + ) def _login_to_interface(driver): diff --git a/python/tests/website/test_web_logs.py b/python/tests/website/test_web_logs.py index bbd828a87..41ed27fad 100644 --- a/python/tests/website/test_web_logs.py +++ b/python/tests/website/test_web_logs.py @@ -285,12 +285,12 @@ def test_logs_config_select_reflects_available_files(driver): # Every config returned by the API must appear in the dropdown for cfg in expected_configs: - assert ( - cfg["file"] in option_values - ), f"Missing file value in dropdown: {cfg['file']}" - assert ( - cfg["name"] in option_texts - ), f"Missing display name in dropdown: {cfg['name']}" + assert cfg["file"] in option_values, ( + f"Missing file value in dropdown: {cfg['file']}" + ) + assert cfg["name"] in option_texts, ( + f"Missing display name in dropdown: {cfg['name']}" + ) # The active config (if any) must be the selected option active_configs = [cfg for cfg in expected_configs if cfg["active"]] @@ -428,9 +428,9 @@ def test_log_level_colors(driver): # Verify that at least one expected color is present valid_colors_found = colors_found & expected_colors - assert ( - len(valid_colors_found) > 0 - ), f"No expected colors found. Found: {colors_found}, Expected: {expected_colors}" + assert len(valid_colors_found) > 0, ( + f"No expected colors found. Found: {colors_found}, Expected: {expected_colors}" + ) @pytest.mark.web diff --git a/python/tests/website/test_web_network.py b/python/tests/website/test_web_network.py index 9adb5caa1..1f9a54d1a 100644 --- a/python/tests/website/test_web_network.py +++ b/python/tests/website/test_web_network.py @@ -90,26 +90,26 @@ def test_network_settings_form_elements(driver, window_size, viewport_name): if not option_texts: option_texts = [option.get_attribute("innerHTML").strip() for option in options] - assert "Access Point" in " ".join( - option_texts - ), f"Access Point option not found in: {option_texts}" - assert "Client" in " ".join( - option_texts - ), f"Client option not found in: {option_texts}" + assert "Access Point" in " ".join(option_texts), ( + f"Access Point option not found in: {option_texts}" + ) + assert "Client" in " ".join(option_texts), ( + f"Client option not found in: {option_texts}" + ) # Check AP Network Name input ap_name_input = driver.find_element(By.ID, "ap_name") assert ap_name_input is not None, "AP Network Name input not found" - assert ( - ap_name_input.get_attribute("name") == "ap_name" - ), "AP name input has wrong name attribute" + assert ap_name_input.get_attribute("name") == "ap_name", ( + "AP name input has wrong name attribute" + ) # Check Host Name input host_name_input = driver.find_element(By.ID, "host_name") assert host_name_input is not None, "Host Name input not found" - assert ( - host_name_input.get_attribute("name") == "host_name" - ), "Host name input has wrong name attribute" + assert host_name_input.get_attribute("name") == "host_name", ( + "Host name input has wrong name attribute" + ) # Check Update and Restart button restart_button = driver.find_element(By.CSS_SELECTOR, "a[href='#modal_restart']") @@ -129,9 +129,9 @@ def test_network_wifi_networks_section(driver, window_size, viewport_name): _login_to_network(driver) # Check for WiFi networks section header - assert ( - "Wifi Networks" in driver.page_source - ), "WiFi Networks section header not found" + assert "Wifi Networks" in driver.page_source, ( + "WiFi Networks section header not found" + ) # Check for add network button (floating action button) add_button = driver.find_element(By.CSS_SELECTOR, "a[href*='add_new=1']") @@ -168,22 +168,22 @@ def test_network_add_form_elements(driver, window_size, viewport_name): # Check SSID input ssid_input = driver.find_element(By.ID, "ssid") assert ssid_input is not None, "SSID input not found" - assert ( - ssid_input.get_attribute("name") == "ssid" - ), "SSID input has wrong name attribute" - assert ( - ssid_input.get_attribute("placeholder") == "SSID" - ), "SSID input has wrong placeholder" + assert ssid_input.get_attribute("name") == "ssid", ( + "SSID input has wrong name attribute" + ) + assert ssid_input.get_attribute("placeholder") == "SSID", ( + "SSID input has wrong placeholder" + ) # Check Password input password_input = driver.find_element(By.ID, "password") assert password_input is not None, "Password input not found" - assert ( - password_input.get_attribute("name") == "psk" - ), "Password input has wrong name attribute" - assert ( - password_input.get_attribute("pattern") == ".{8,}" - ), "Password input missing validation pattern" + assert password_input.get_attribute("name") == "psk", ( + "Password input has wrong name attribute" + ) + assert password_input.get_attribute("pattern") == ".{8,}", ( + "Password input missing validation pattern" + ) # Check for helper text on password field helper_text = driver.find_element( @@ -259,11 +259,9 @@ def test_network_form_structure_comprehensive(driver): # Check main form structure main_form = driver.find_element(By.ID, "network_form") - assert main_form.get_attribute( - "action" - ).endswith( - "/network/update" - ), f"Form action should end with '/network/update', got: {main_form.get_attribute('action')}" + assert main_form.get_attribute("action").endswith("/network/update"), ( + f"Form action should end with '/network/update', got: {main_form.get_attribute('action')}" + ) assert main_form.get_attribute("method") == "post" # Verify all input fields have proper labels @@ -332,12 +330,12 @@ def test_network_add_form_submission(driver): ) # Verify that the form submission was successful by checking we're back on the network page - assert ( - "Network Settings" in driver.page_source - ), "Not on network settings page after form submission" - assert driver.current_url.rstrip("/").endswith( - "/network" - ), "URL not correct after form submission" + assert "Network Settings" in driver.page_source, ( + "Not on network settings page after form submission" + ) + assert driver.current_url.rstrip("/").endswith("/network"), ( + "URL not correct after form submission" + ) # Note: In the test environment, network persistence is not enabled, # so we only verify that the form submission worked correctly by @@ -385,9 +383,9 @@ def test_network_update_and_restart_flow(driver): # Verify we're on the restart page with expected content assert "Restarting System" in driver.page_source, "Not redirected to restart page" - assert ( - "This will take approximately 45 seconds" in driver.page_source - ), "Restart page doesn't show expected content" + assert "This will take approximately 45 seconds" in driver.page_source, ( + "Restart page doesn't show expected content" + ) # Verify the progress bar is present progress_bar = driver.find_element(By.CSS_SELECTOR, ".progress") @@ -401,15 +399,15 @@ def test_network_update_and_restart_flow(driver): ) # Verify we've been redirected to the home page - assert driver.current_url.endswith( - "/" - ), f"Not redirected to home page, current URL: {driver.current_url}" + assert driver.current_url.endswith("/"), ( + f"Not redirected to home page, current URL: {driver.current_url}" + ) # Verify we're on the home page (no login required) # The home page should contain navigation or typical home content, not login form - assert ( - "password" not in driver.page_source.lower() - ), "Redirected to login page instead of home page" + assert "password" not in driver.page_source.lower(), ( + "Redirected to login page instead of home page" + ) def _login_to_network(driver): diff --git a/python/tests/website/test_web_observations.py b/python/tests/website/test_web_observations.py index 40fdcb9d2..4ece2408e 100644 --- a/python/tests/website/test_web_observations.py +++ b/python/tests/website/test_web_observations.py @@ -265,7 +265,9 @@ def test_session_detail_page_content(driver): assert any( required_header.lower() in header_text.lower() for header_text in header_texts - ), f"Header '{required_header}' not found in detail table headers: {header_texts}" + ), ( + f"Header '{required_header}' not found in detail table headers: {header_texts}" + ) else: # No data rows to click - this is acceptable for empty database @@ -300,16 +302,16 @@ def test_session_detail_table_structure(driver): detail_rows = detail_table.find_elements(By.TAG_NAME, "tr") # Should have at least header row - assert ( - len(detail_rows) >= 1 - ), "Detail table should have at least one row for headers" + assert len(detail_rows) >= 1, ( + "Detail table should have at least one row for headers" + ) # Check that first row contains header cells first_row = detail_rows[0] headers = first_row.find_elements(By.TAG_NAME, "th") - assert ( - len(headers) == 4 - ), f"Expected 4 header cells (Time, Catalog, Sequence, Notes), found {len(headers)}" + assert len(headers) == 4, ( + f"Expected 4 header cells (Time, Catalog, Sequence, Notes), found {len(headers)}" + ) else: # No data rows to click - this is acceptable for empty database @@ -351,23 +353,23 @@ def test_observations_list_download(driver): ) # Verify the response - assert ( - response.status_code == 200 - ), f"Download request failed with status {response.status_code}" + assert response.status_code == 200, ( + f"Download request failed with status {response.status_code}" + ) # Check content type header - assert "text/tsv" in response.headers.get( - "Content-Type", "" - ), "Expected TSV content type" + assert "text/tsv" in response.headers.get("Content-Type", ""), ( + "Expected TSV content type" + ) # Check content disposition header (should indicate file download) content_disposition = response.headers.get("Content-Disposition", "") - assert ( - "attachment" in content_disposition - ), "Expected attachment in Content-Disposition header" - assert ( - "observations.tsv" in content_disposition - ), "Expected observations.tsv filename" + assert "attachment" in content_disposition, ( + "Expected attachment in Content-Disposition header" + ) + assert "observations.tsv" in content_disposition, ( + "Expected observations.tsv filename" + ) # Verify file content is not empty and looks like TSV file_content = response.text.strip() @@ -430,23 +432,23 @@ def test_observation_detail_download(driver): response = requests.get(href, cookies=cookies) # Verify the response - assert ( - response.status_code == 200 - ), f"Session download request failed with status {response.status_code}" + assert response.status_code == 200, ( + f"Session download request failed with status {response.status_code}" + ) # Check content type header - assert "text/tsv" in response.headers.get( - "Content-Type", "" - ), "Expected TSV content type" + assert "text/tsv" in response.headers.get("Content-Type", ""), ( + "Expected TSV content type" + ) # Check content disposition header (should indicate file download with session ID) content_disposition = response.headers.get("Content-Disposition", "") - assert ( - "attachment" in content_disposition - ), "Expected attachment in Content-Disposition header" - assert ( - f"observations_{session_id}.tsv" in content_disposition - ), f"Expected observations_{session_id}.tsv filename" + assert "attachment" in content_disposition, ( + "Expected attachment in Content-Disposition header" + ) + assert f"observations_{session_id}.tsv" in content_disposition, ( + f"Expected observations_{session_id}.tsv filename" + ) # Verify file content is not empty and looks like TSV file_content = response.text.strip() diff --git a/python/tests/website/test_web_remote.py b/python/tests/website/test_web_remote.py index 297a736a1..3413f471e 100644 --- a/python/tests/website/test_web_remote.py +++ b/python/tests/website/test_web_remote.py @@ -203,14 +203,14 @@ def test_remote_keyboard_elements_present(driver, window_size, viewport_name): # Check each expected button is present for display_text, code in expected_buttons.items(): - assert ( - display_text in button_texts - ), f"Button '{display_text}' not found on remote page" + assert display_text in button_texts, ( + f"Button '{display_text}' not found on remote page" + ) # Verify we have at least the expected number of buttons (13 main buttons + special buttons) - assert ( - len(remote_buttons) >= 13 - ), f"Expected at least 13 remote buttons, found {len(remote_buttons)}" + assert len(remote_buttons) >= 13, ( + f"Expected at least 13 remote buttons, found {len(remote_buttons)}" + ) @pytest.mark.parametrize( diff --git a/python/tests/website/test_web_remote_settings.py b/python/tests/website/test_web_remote_settings.py index c29ebc9f7..3be7ed717 100644 --- a/python/tests/website/test_web_remote_settings.py +++ b/python/tests/website/test_web_remote_settings.py @@ -144,9 +144,9 @@ def test_settings_language_has_english_default(driver): # The first language entry should represent English. # The exact label depends on the translation config (e.g. "Language: en"). first_item = result.get("current_item", "") - assert ( - "en" in first_item.lower() or "english" in first_item.lower() - ), f"Expected first Language entry to be English, got: {first_item!r}" + assert "en" in first_item.lower() or "english" in first_item.lower(), ( + f"Expected first Language entry to be English, got: {first_item!r}" + ) press_keys(driver, "ZL") # back to root diff --git a/python/tests/website/test_web_tools.py b/python/tests/website/test_web_tools.py index 354f4d5f3..75a14e157 100644 --- a/python/tests/website/test_web_tools.py +++ b/python/tests/website/test_web_tools.py @@ -242,9 +242,9 @@ def test_change_password_functionality(driver): # Check for success message or verify we're still on tools page # The exact behavior depends on implementation - could show success message or redirect - assert ( - "/tools" in driver.current_url - ), "Should remain on or return to tools page after password change" + assert "/tools" in driver.current_url, ( + "Should remain on or return to tools page after password change" + ) @pytest.mark.web @@ -308,9 +308,9 @@ def test_download_user_data_functionality(driver): "Server error during backup download - path configuration issue in test environment" ) else: - assert ( - response.status_code == 200 - ), f"Download request failed with status {response.status_code}" + assert response.status_code == 200, ( + f"Download request failed with status {response.status_code}" + ) # Check that we got some content assert len(response.content) > 0, "Downloaded file appears to be empty" @@ -381,9 +381,9 @@ def test_complete_download_and_upload_workflow(driver): "Server error during backup download - path configuration issue in test environment" ) - assert ( - response.status_code == 200 - ), f"Download request failed with status {response.status_code}" + assert response.status_code == 200, ( + f"Download request failed with status {response.status_code}" + ) assert len(response.content) > 0, "Downloaded file appears to be empty" # Step 2: Upload and restore the downloaded data @@ -422,9 +422,9 @@ def test_complete_download_and_upload_workflow(driver): WebDriverWait(driver, 15).until(lambda d: "/tools" in d.current_url) # Verify we're still on tools page - assert ( - "/tools" in driver.current_url - ), "Upload should complete and return to tools page" + assert "/tools" in driver.current_url, ( + "Upload should complete and return to tools page" + ) finally: # Clean up the temporary file diff --git a/python/tests/website/web_test_utils.py b/python/tests/website/web_test_utils.py index f65022acd..c58da960c 100644 --- a/python/tests/website/web_test_utils.py +++ b/python/tests/website/web_test_utils.py @@ -251,34 +251,34 @@ def recursive_dict_compare(actual, expected): Recursively compare expected dict values with actual response data """ for key, expected_value in expected.items(): - assert ( - key in actual - ), f"Expected key '{key}' not found in response. Available keys: {list(actual.keys())}" + assert key in actual, ( + f"Expected key '{key}' not found in response. Available keys: {list(actual.keys())}" + ) actual_value = actual[key] if isinstance(expected_value, dict): - assert isinstance( - actual_value, dict - ), f"Expected '{key}' to be a dict, but got {type(actual_value)}" + assert isinstance(actual_value, dict), ( + f"Expected '{key}' to be a dict, but got {type(actual_value)}" + ) recursive_dict_compare(actual_value, expected_value) elif isinstance(expected_value, list): - assert isinstance( - actual_value, list - ), f"Expected '{key}' to be a list, but got {type(actual_value)}" - assert ( - len(actual_value) == len(expected_value) - ), f"Expected '{key}' list length {len(expected_value)}, got {len(actual_value)}" + assert isinstance(actual_value, list), ( + f"Expected '{key}' to be a list, but got {type(actual_value)}" + ) + assert len(actual_value) == len(expected_value), ( + f"Expected '{key}' list length {len(expected_value)}, got {len(actual_value)}" + ) for i, (actual_item, expected_item) in enumerate( zip(actual_value, expected_value) ): if isinstance(expected_item, dict): recursive_dict_compare(actual_item, expected_item) else: - assert ( - actual_item == expected_item - ), f"Expected '{key}[{i}]' to be {expected_item}, got {actual_item}" + assert actual_item == expected_item, ( + f"Expected '{key}[{i}]' to be {expected_item}, got {actual_item}" + ) else: - assert ( - actual_value == expected_value - ), f"Expected '{key}' to be {expected_value}, got {actual_value}" + assert actual_value == expected_value, ( + f"Expected '{key}' to be {expected_value}, got {actual_value}" + ) diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 000000000..455d2cf7a --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,1541 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" +resolution-markers = [ + "sys_platform == 'linux'", +] +supported-markers = [ + "sys_platform == 'linux'", +] + +[manifest] + +[[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-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/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/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/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 = "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 == '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/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/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 == 'linux'" }, + { name = "typing-inspect", marker = "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 == 'linux'" }, + { name = "click", marker = "sys_platform == 'linux'" }, + { name = "itsdangerous", marker = "sys_platform == 'linux'" }, + { name = "jinja2", marker = "sys_platform == 'linux'" }, + { name = "markupsafe", marker = "sys_platform == 'linux'" }, + { name = "werkzeug", marker = "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 == 'linux'" }, + { name = "flask", marker = "sys_platform == 'linux'" }, + { name = "jinja2", marker = "sys_platform == 'linux'" }, + { name = "pytz", marker = "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 == '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/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/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 == '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 == '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 == 'linux'" }, + { name = "jsonschema-specifications", marker = "sys_platform == 'linux'" }, + { name = "referencing", marker = "sys_platform == 'linux'" }, + { name = "rpds-py", marker = "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 == '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/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 == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'linux'" }, + { name = "smbus2", marker = "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 == 'linux'" }, + { name = "pygame", marker = "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 == '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 == '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/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/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 == '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 == 'linux'" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'linux'" }, + { name = "mypy-extensions", marker = "sys_platform == 'linux'" }, + { name = "pathspec", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "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/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/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/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 == 'linux'" }, + { name = "scipy", marker = "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/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/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 == '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/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/3e/39/0292fd1daeae5aa3cabbf3b22795b85f04fe6e000bd9bf2eb9d187fcbf47/openexr-3.4.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8301110402427ea48073ab11556873c0ba3dc8d1d6fdc79fd77a7738a50eeb11", size = 1298585, upload-time = "2026-05-25T02:07:32.199Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/fb/b6/680a71029fcd76bcc64b29af96ea192a4a409345e2ee825c804985237012/openexr-3.4.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8feae6511257a2a33aa067c247a06b82e31bbbadfafe82a14ab5a7288a93b16b", size = 2338623, upload-time = "2026-05-25T02:07:35.575Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs", marker = "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 == 'linux'" }, + { name = "python-dateutil", marker = "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/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/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 = "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 == 'linux'" }, + { name = "jsonschema", marker = "sys_platform == 'linux'" }, + { name = "libarchive-c", marker = "sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'linux'" }, + { name = "openexr", marker = "sys_platform == 'linux'" }, + { name = "pidng", marker = "sys_platform == 'linux'" }, + { name = "piexif", marker = "sys_platform == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'linux'" }, + { name = "python-prctl", marker = "sys_platform == 'linux'" }, + { name = "simplejpeg", marker = "sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'linux'" }, + { name = "videodev2", marker = "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 == '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 = "aiofiles", marker = "sys_platform == 'linux'" }, + { name = "av", marker = "sys_platform == 'linux'" }, + { name = "dataclasses-json", marker = "sys_platform == 'linux'" }, + { name = "dbus-python", marker = "sys_platform == 'linux'" }, + { name = "flask", marker = "sys_platform == 'linux'" }, + { name = "flask-babel", marker = "sys_platform == 'linux'" }, + { name = "gpsdclient", marker = "sys_platform == 'linux'" }, + { name = "grpcio", marker = "sys_platform == 'linux'" }, + { name = "json5", marker = "sys_platform == 'linux'" }, + { name = "jsonschema", marker = "sys_platform == 'linux'" }, + { name = "libarchive-c", marker = "sys_platform == 'linux'" }, + { name = "luma-lcd", marker = "sys_platform == 'linux'" }, + { name = "luma-oled", marker = "sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'linux'" }, + { name = "numpy-quaternion", marker = "sys_platform == 'linux'" }, + { name = "pandas", marker = "sys_platform == 'linux'" }, + { name = "picamera2", marker = "sys_platform == 'linux'" }, + { name = "pidng", marker = "sys_platform == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'linux'" }, + { name = "protobuf", marker = "sys_platform == 'linux'" }, + { name = "pydeepskylog", marker = "sys_platform == 'linux'" }, + { name = "pyerfa", marker = "sys_platform == 'linux'" }, + { name = "pygobject", marker = "sys_platform == 'linux'" }, + { name = "pyjwt", marker = "sys_platform == 'linux'" }, + { name = "python-libinput", marker = "sys_platform == 'linux'" }, + { name = "python-pam", marker = "sys_platform == 'linux'" }, + { name = "python-prctl", marker = "sys_platform == 'linux'" }, + { name = "pytz", marker = "sys_platform == 'linux'" }, + { name = "requests", marker = "sys_platform == 'linux'" }, + { name = "rpi-gpio", marker = "sys_platform == 'linux'" }, + { name = "rpi-hardware-pwm", marker = "sys_platform == 'linux'" }, + { name = "scikit-learn", marker = "sys_platform == 'linux'" }, + { name = "scipy", marker = "sys_platform == 'linux'" }, + { name = "sh", marker = "sys_platform == 'linux'" }, + { name = "simplejpeg", marker = "sys_platform == 'linux'" }, + { name = "skyfield", marker = "sys_platform == 'linux'" }, + { name = "smbus2", marker = "sys_platform == 'linux'" }, + { name = "spidev", marker = "sys_platform == 'linux'" }, + { name = "timezonefinder", marker = "sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'linux'" }, + { name = "videodev2", marker = "sys_platform == 'linux'" }, + { name = "waitress", marker = "sys_platform == 'linux'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "luma-emulator", marker = "sys_platform == 'linux'" }, + { name = "mypy", marker = "sys_platform == 'linux'" }, + { name = "pyhotkey", marker = "sys_platform == 'linux'" }, + { name = "pytest", marker = "sys_platform == 'linux'" }, + { name = "selenium", marker = "sys_platform == 'linux'" }, + { name = "xlrd", marker = "sys_platform == 'linux'" }, +] + +[package.metadata] +requires-dist = [ + { name = "adafruit-blinka" }, + { name = "adafruit-circuitpython-bno055" }, + { name = "aiofiles" }, + { name = "av" }, + { name = "dataclasses-json" }, + { name = "dbus-python" }, + { 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" }, + { name = "pidng" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pydeepskylog" }, + { name = "pyerfa" }, + { name = "pygobject" }, + { name = "pyjwt" }, + { name = "python-libinput", specifier = "==0.3.0a0" }, + { name = "python-pam" }, + { name = "python-prctl" }, + { name = "pytz" }, + { name = "requests" }, + { name = "rpi-gpio" }, + { name = "rpi-hardware-pwm" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sh", specifier = ">=1.14,<2" }, + { name = "simplejpeg" }, + { name = "skyfield" }, + { name = "smbus2" }, + { name = "spidev" }, + { name = "timezonefinder" }, + { name = "tqdm" }, + { name = "videodev2" }, + { name = "waitress" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "luma-emulator" }, + { name = "mypy" }, + { name = "pyhotkey" }, + { name = "pytest" }, + { name = "selenium" }, + { 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/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/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/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 == '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 == '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/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/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 == '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 == '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 == 'linux' and 'linux' in sys_platform" }, + { name = "python-xlib", marker = "sys_platform == 'linux' and 'linux' in sys_platform" }, + { name = "six", marker = "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 = "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 == 'linux'" }, + { name = "packaging", marker = "sys_platform == 'linux'" }, + { name = "pluggy", marker = "sys_platform == 'linux'" }, + { name = "pygments", marker = "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 == '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 == '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 == '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 == 'linux'" }, + { name = "rpds-py", marker = "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 == 'linux'" }, + { name = "charset-normalizer", marker = "sys_platform == 'linux'" }, + { name = "idna", marker = "sys_platform == 'linux'" }, + { name = "urllib3", marker = "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/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/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 == 'linux'" }, + { name = "narwhals", marker = "sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'linux'" }, + { name = "scipy", marker = "sys_platform == 'linux'" }, + { name = "threadpoolctl", marker = "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/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 == '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/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/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 == 'linux'" }, + { name = "trio", marker = "sys_platform == 'linux'" }, + { name = "trio-websocket", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux'" }, + { name = "urllib3", extra = ["socks"], marker = "sys_platform == 'linux'" }, + { name = "websocket-client", marker = "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/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 == '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/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 == 'linux'" }, + { name = "jplephem", marker = "sys_platform == 'linux'" }, + { name = "numpy", marker = "sys_platform == 'linux'" }, + { name = "sgp4", marker = "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 == 'linux'" }, + { name = "flatbuffers", marker = "sys_platform == 'linux'" }, + { name = "h3", marker = "sys_platform == 'linux'" }, + { name = "numpy", marker = "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 == 'linux'" }, + { name = "idna", marker = "sys_platform == 'linux'" }, + { name = "outcome", marker = "sys_platform == 'linux'" }, + { name = "sniffio", marker = "sys_platform == 'linux'" }, + { name = "sortedcontainers", marker = "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 == 'linux'" }, + { name = "trio", marker = "sys_platform == 'linux'" }, + { name = "wsproto", marker = "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 = "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 == 'linux'" }, + { name = "typing-extensions", marker = "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 == '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 == '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 == '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/network.html b/python/views/network.html index 637332961..bc6122201 100644 --- a/python/views/network.html +++ b/python/views/network.html @@ -4,6 +4,9 @@
{{ _('Network Settings') }}
+ {% if status_message %} +

{{ status_message }}

+ {% endif %}
diff --git a/scripts/generate-dependencies-md.sh b/scripts/generate-dependencies-md.sh new file mode 100755 index 000000000..859d0a4da --- /dev/null +++ b/scripts/generate-dependencies-md.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Generates python/DEPENDENCIES.md from the nix devShell environment. +# Run from repo root: nix develop --command ./scripts/generate-dependencies-md.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT="$REPO_ROOT/python/DEPENDENCIES.md" + +python3 << 'PYEOF' > "$OUTPUT" +import importlib.metadata +from datetime import date + +pkgs = sorted( + ((d.name, d.version) for d in importlib.metadata.distributions()), + key=lambda x: x[0].lower(), +) + +# Dev-only packages (from pyproject.toml [dependency-groups].dev) +dev_only = {"pytest", "mypy", "mypy_extensions", "luma.emulator", "PyHotKey", + "pynput", "python-xlib", "pygame", "pathspec", "pluggy", "iniconfig"} + +# Build/infra packages not relevant to PiFinder +infra = {"pip", "flit_core", "virtualenv", "distlib", "filelock", "platformdirs", + "packaging", "setuptools"} + +prod = [(n, v) for n, v in pkgs if n not in dev_only and n not in infra] +dev = [(n, v) for n, v in pkgs if n in dev_only] + +print(f"""\ +> **Auto-generated** from the Nix development shell on {date.today()}. +> Do not edit manually — regenerate with: +> ``` +> nix develop --command ./scripts/generate-dependencies-md.sh +> ``` + +> **Note:** These dependencies are declared in `python/pyproject.toml`, pinned in +> `python/uv.lock`, and realized into the Nix store via uv2nix. Some packages +> require system libraries or hardware (SPI, I2C, GPIO) only available on the +> Raspberry Pi. + +# Python Dependencies + +Python {'.'.join(str(x) for x in __import__('sys').version_info[:3])} + +## Runtime + +| Package | Version | +|---------|---------|""") + +for name, ver in prod: + print(f"| {name} | {ver} |") + +print(f""" +## Development only + +| Package | Version | +|---------|---------|""") + +for name, ver in dev: + print(f"| {name} | {ver} |") +PYEOF + +echo "Generated $OUTPUT" diff --git a/switch-ap.sh b/switch-ap.sh deleted file mode 100755 index 7d527bf58..000000000 --- a/switch-ap.sh +++ /dev/null @@ -1,8 +0,0 @@ -#! /usr/bin/bash -cp /etc/dhcpcd.conf.ap /etc/dhcpcd.conf -systemctl enable dnsmasq -systemctl enable hostapd -echo -n "AP" > /home/pifinder/PiFinder/wifi_status.txt -#systemctl start dnsmasq -#systemctl start hostapd -#systemctl restart dhcpcd diff --git a/switch-cli.sh b/switch-cli.sh deleted file mode 100755 index f802f4cc6..000000000 --- a/switch-cli.sh +++ /dev/null @@ -1,8 +0,0 @@ -#! /usr/bin/bash -#systemctl stop dnsmasq -#systemctl stop hostapd -cp /etc/dhcpcd.conf.sta /etc/dhcpcd.conf -systemctl disable dnsmasq -systemctl disable hostapd -#systemctl restart dhcpcd -echo -n "Client" > /home/pifinder/PiFinder/wifi_status.txt diff --git a/upd.json b/upd.json new file mode 100644 index 000000000..20c718545 --- /dev/null +++ b/upd.json @@ -0,0 +1,6 @@ +{ + "message": "ci(nixos): simplify publish_manifest.sh (drop one-time collapse)", + "content": "IyEvdXNyL2Jpbi9lbnYgYmFzaAojIFVwZGF0ZSB1cGRhdGUtbWFuaWZlc3QuanNvbiBvbiB0aGUgbWV0YWRhdGEtb25seSBgbml4b3MtbWFuaWZlc3RgIGJyYW5jaC4KIwojIFRoZSBicmFuY2ggaG9sZHMgb25seSB0aGF0IG9uZSBKU09OIGZpbGU7IGl0IGNhcnJpZXMgbm8gc291cmNlIHRyZWUuIFRoZSBqb2IKIyBoZXJlIGlzIGp1c3Q6IHJlYWQgdGhlIGN1cnJlbnQgbWFuaWZlc3QsIGxldCB0aGUgdXBkYXRlciByZXdyaXRlIGl0cyBlbnRyeSwKIyBhbmQgcHVzaC4gQ29uY3VycmVuY3ktc2FmZTogYSBnaXQgcmVmIHVwZGF0ZSBpcyBjb21wYXJlLWFuZC1zd2FwLCBzbyBpZiBhCiMgY29uY3VycmVudCB3cml0ZXIgbGFuZHMgZmlyc3Qgb3VyIHB1c2ggaXMgcmVqZWN0ZWQsIGFuZCB3ZSByZS1mZXRjaCB0aGUgbmV3CiMgdGlwLCByZS1hcHBseSB0aGlzIHJ1bidzIGVudHJ5IG9udG8gaXQsIGFuZCByZXRyeS4KIwojIFVzYWdlOgojICAgcHVibGlzaF9tYW5pZmVzdC5zaCAiPGNvbW1pdCBtZXNzYWdlPiIgPHVwZGF0ZXIgYXJndi4uLj4KIyBUaGUgdXBkYXRlciBhcmd2IGNvbnRhaW5zIHRoZSBsaXRlcmFsIHRva2VuIEBNQU5JRkVTVEAsIHJlcGxhY2VkIHdpdGggdGhlCiMgbWFuaWZlc3QgcGF0aCBvbiBlYWNoIGF0dGVtcHQuIEl0IG11c3QgYmUgaWRlbXBvdGVudCAocmVwbGFjZXMgaXRzIG93biBlbnRyeSkuCnNldCAtZXVvIHBpcGVmYWlsCgpCUkFOQ0g9Im5peG9zLW1hbmlmZXN0IgpDT01NSVRfTVNHPSIkMSIKc2hpZnQKCmdpdCBjb25maWcgdXNlci5uYW1lICJnaXRodWItYWN0aW9uc1tib3RdIgpnaXQgY29uZmlnIHVzZXIuZW1haWwgImdpdGh1Yi1hY3Rpb25zW2JvdF1AdXNlcnMubm9yZXBseS5naXRodWIuY29tIgoKV09SS1RSRUU9IiQobWt0ZW1wIC1kKSIKdHJhcCAnZ2l0IHdvcmt0cmVlIHJlbW92ZSAtLWZvcmNlICIkV09SS1RSRUUiID4vZGV2L251bGwgMj4mMSB8fCB0cnVlJyBFWElUCmdpdCB3b3JrdHJlZSBhZGQgLS1kZXRhY2ggIiRXT1JLVFJFRSIgPi9kZXYvbnVsbApNQU5JRkVTVD0iJFdPUktUUkVFL3VwZGF0ZS1tYW5pZmVzdC5qc29uIgoKZm9yIGF0dGVtcHQgaW4gMSAyIDMgNCA1OyBkbwogIGdpdCBmZXRjaCBvcmlnaW4gIiRCUkFOQ0giID4vZGV2L251bGwgMj4mMSB8fCB0cnVlCgogIGlmIGdpdCBzaG93LXJlZiAtLXZlcmlmeSAtLXF1aWV0ICJyZWZzL3JlbW90ZXMvb3JpZ2luLyRCUkFOQ0giOyB0aGVuCiAgICAjIHJlc2V0IC0taGFyZCBzbyBhIHJldHJ5IGFmdGVyIGEgcmVqZWN0ZWQgcHVzaCBzdGFydHMgZnJvbSB0aGUgdHJ1ZSB0aXAsCiAgICAjIG5vdCB0aGUgc3RhbGUgZW50cnkgZnJvbSB0aGUgcHJldmlvdXMgYXR0ZW1wdCAod2hpY2ggd291bGQgb3RoZXJ3aXNlCiAgICAjIHNpbGVudGx5IGRyb3AgdGhlIGNvbmN1cnJlbnQgd3JpdGVyJ3MgY2hhbmdlKS4KICAgIGdpdCAtQyAiJFdPUktUUkVFIiBjaGVja291dCAtcSAtQiAiJEJSQU5DSCIgInJlZnMvcmVtb3Rlcy9vcmlnaW4vJEJSQU5DSCIKICAgIGdpdCAtQyAiJFdPUktUUkVFIiByZXNldCAtcSAtLWhhcmQgInJlZnMvcmVtb3Rlcy9vcmlnaW4vJEJSQU5DSCIKICBlbHNlCiAgICAjIEJyYW5jaCBkb2VzIG5vdCBleGlzdCB5ZXQ6IHN0YXJ0IGl0IGVtcHR5LgogICAgZ2l0IC1DICIkV09SS1RSRUUiIGNoZWNrb3V0IC1xIC0tb3JwaGFuICIkQlJBTkNIIgogICAgZ2l0IC1DICIkV09SS1RSRUUiIHJtIC1yZnEgLS1jYWNoZWQgLiA+L2Rldi9udWxsIDI+JjEgfHwgdHJ1ZQogIGZpCgogIGNtZD0oKQogIGZvciBhcmcgaW4gIiRAIjsgZG8KICAgIGNtZCs9KCAiJHthcmcvQE1BTklGRVNUQC8kTUFOSUZFU1R9IiApCiAgZG9uZQogICIke2NtZFtAXX0iCgogIGdpdCAtQyAiJFdPUktUUkVFIiBhZGQgdXBkYXRlLW1hbmlmZXN0Lmpzb24KICBpZiBnaXQgLUMgIiRXT1JLVFJFRSIgZGlmZiAtLXN0YWdlZCAtLXF1aWV0OyB0aGVuCiAgICBlY2hvICJNYW5pZmVzdCB1bmNoYW5nZWQiCiAgICBleGl0IDAKICBmaQoKICBnaXQgLUMgIiRXT1JLVFJFRSIgY29tbWl0IC1xIC1tICIkQ09NTUlUX01TRyIKICBpZiBnaXQgLUMgIiRXT1JLVFJFRSIgcHVzaCBvcmlnaW4gIkhFQUQ6JEJSQU5DSCIgMj4vZGV2L251bGw7IHRoZW4KICAgIGVjaG8gIk1hbmlmZXN0IHB1Ymxpc2hlZCAoYXR0ZW1wdCAkYXR0ZW1wdCkiCiAgICBleGl0IDAKICBmaQoKICBlY2hvICJQdXNoIHJlamVjdGVkIGJ5IGEgY29uY3VycmVudCB1cGRhdGU7IHJldHJ5aW5nICgkYXR0ZW1wdC81KSIKICBzbGVlcCAkKChhdHRlbXB0ICogMikpCmRvbmUKCmVjaG8gIkZhaWxlZCB0byBwdWJsaXNoIG1hbmlmZXN0IGFmdGVyIDUgYXR0ZW1wdHMiID4mMgpleGl0IDEK", + "branch": "ci-nixos-testable-pr-builds", + "sha": "a4b2c72776c7efa1faac1fda8767a1a83d1e06ec" +} diff --git a/version.txt b/version.txt deleted file mode 100644 index e70b4523a..000000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -2.6.0