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 old mode 100644 new mode 100755 diff --git a/.github/scripts/update_manifest.py b/.github/scripts/update_manifest.py index 97ff73929..8cad8630e 100644 --- a/.github/scripts/update_manifest.py +++ b/.github/scripts/update_manifest.py @@ -61,7 +61,11 @@ def set_available(entry: dict) -> dict: def replace_entry(entries: list[dict], predicate, entry: dict) -> list[dict]: - return [item for item in entries if not predicate(item)] + [entry] + # Newest first: consumers (release channels in the update UI, and the + # migration target lookup) take the head of the list as the current entry. + # The unstable channel is re-sorted after this, so prepending is neutral + # there. + return [entry] + [item for item in entries if not predicate(item)] def sort_unstable(entries: list[dict]) -> list[dict]: @@ -140,6 +144,8 @@ def update_release(args: argparse.Namespace) -> None: "source_sha": args.sha, "version": args.version, "store_path": args.store_path or None, + "migration_url": args.migration_url or None, + "migration_sha256_url": args.migration_sha256_url or None, } set_available(entry) manifest["channels"][channel] = replace_entry( @@ -177,6 +183,8 @@ def parser() -> argparse.ArgumentParser: release.add_argument("--version", required=True) release.add_argument("--release-type", choices=("stable", "beta"), required=True) release.add_argument("--store-path", required=True) + release.add_argument("--migration-url") + release.add_argument("--migration-sha256-url") release.add_argument("--title") release.add_argument("--notes") release.set_defaults(func=update_release) diff --git a/.github/workflows/nixos-pr-build.yml b/.github/workflows/nixos-pr-build.yml index c8c4c181c..19e74b444 100644 --- a/.github/workflows/nixos-pr-build.yml +++ b/.github/workflows/nixos-pr-build.yml @@ -121,7 +121,7 @@ jobs: extra-conf: | extra-system-features = big-parallel extra-substituters = https://cache.pifinder.eu/pifinder - extra-trusted-public-keys = pifinder:VkemNaMqXDcsYlpONItSvOOcBIa1vEfnpyqdetr3gck= + extra-trusted-public-keys = pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE= - name: Attic login for push env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..df87b7859 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,379 @@ +name: Release NixOS image + +# Builds the PiFinder NixOS SD image and migration tarball and publishes them as +# a GitHub (pre-)release, and records the closure in the nixos-manifest branch. +# +# Runners are native aarch64 (no QEMU): the Pi5 self-hosted runner is tried +# first, with the free GitHub-hosted `ubuntu-24.04-arm` runner as a fallback if +# the Pi is offline/slow — mirroring nixos-pr-build.yml. The arch-sensitive +# `nix build` runs there; the image is handed off as an artifact and the +# arch-independent packaging + publishing happens on ubuntu-latest. +on: + workflow_dispatch: + inputs: + version: + description: 'Version (e.g., 2.5.0)' + required: true + notes: + description: 'Release notes' + required: true + type: + description: 'Release type' + type: choice + options: + - stable + - beta + default: stable + source_branch: + description: 'Source branch (default: main, use release/X.Y for hotfixes)' + required: false + default: 'main' + source_repository: + description: 'Source repo to build (blank = this repo; e.g. mrosseel/PiFinder to build a fork)' + required: false + default: '' + +jobs: + # Native aarch64 build on the Pi5 self-hosted runner (preferred, fast). + build-native: + runs-on: [self-hosted, aarch64] + timeout-minutes: 90 + outputs: + store_path: ${{ steps.push.outputs.store_path }} + source_sha: ${{ steps.sha.outputs.source_sha }} + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.source_repository || github.repository }} + ref: ${{ inputs.source_branch }} + persist-credentials: false + + - name: Record source SHA + id: sha + run: echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Ensure nix is on PATH (self-hosted runner) + run: | + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + + - name: Setup Attic caches (cache.pifinder.eu) + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - name: Build and push release closure to Attic + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + -L --json | jq -r '.[].outputs.out') + # Stable → retained pifinder-release (GC off; devices resolve it months + # later). Beta → dev pifinder cache (finite retention), so prereleases + # stay transient. + if [[ "${{ inputs.type }}" == "beta" ]]; then + attic push pifinder:pifinder "$STORE_PATH" + else + attic push pifinder:pifinder-release "$STORE_PATH" + fi + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + - name: Build SD image + run: | + nix build .#images.pifinder -L -o result-sd + + - name: Upload SD image + uses: actions/upload-artifact@v4 + with: + name: sdimage-native + path: result-sd/sd-image/*.img.zst + retention-days: 1 + + # The migration tarball is cut from the MINIMAL migration system, not the + # full image — it resolves the full system from the update manifest at + # first boot (ADR 0004 / 0003). + - name: Build migration image + run: | + nix build .#images.pifinder-migration -L -o result-mig + + - name: Upload migration image + uses: actions/upload-artifact@v4 + with: + name: migimage-native + path: result-mig/sd-image/*.img.zst + retention-days: 1 + + # Poll the native builder for ~15 min, then decide whether to fall back. + native-wait: + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + need_fallback: ${{ steps.wait.outputs.need_fallback }} + steps: + - name: Wait for native build + id: wait + env: + GH_TOKEN: ${{ github.token }} + run: | + for i in $(seq 1 30); do + sleep 30 + RESULT=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \ + --jq '.jobs[] | select(.name == "build-native") | .conclusion // "pending"' 2>/dev/null || echo "pending") + echo "Check $i/30: build-native=$RESULT" + if [ "$RESULT" = "success" ]; then + echo "need_fallback=false" >> "$GITHUB_OUTPUT" + exit 0 + elif [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then + echo "need_fallback=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "Native build not done after 15 min, falling back to hosted" + echo "need_fallback=true" >> "$GITHUB_OUTPUT" + + # Fallback on a free GitHub-hosted arm64 runner (native aarch64, no QEMU). + build-hosted: + needs: native-wait + if: needs.native-wait.outputs.need_fallback == 'true' + runs-on: ubuntu-24.04-arm + timeout-minutes: 120 + outputs: + store_path: ${{ steps.push.outputs.store_path }} + source_sha: ${{ steps.sha.outputs.source_sha }} + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ inputs.source_repository || github.repository }} + ref: ${{ inputs.source_branch }} + persist-credentials: false + + - name: Record source SHA + id: sha + run: echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: false + extra-conf: | + extra-system-features = big-parallel + extra-substituters = https://cache.pifinder.eu/pifinder + extra-trusted-public-keys = pifinder:VkemNaMqXDcsYlpONItSvOOcBIa1vEfnpyqdetr3gck= + + - name: Setup Attic caches (cache.pifinder.eu) + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + run: | + nix profile install nixpkgs#attic-client + attic login pifinder https://cache.pifinder.eu "$ATTIC_TOKEN" + attic use pifinder:pifinder + + - name: Build and push release closure to Attic + id: push + run: | + STORE_PATH=$(nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + -L --json | jq -r '.[].outputs.out') + if [[ "${{ inputs.type }}" == "beta" ]]; then + attic push pifinder:pifinder "$STORE_PATH" + else + attic push pifinder:pifinder-release "$STORE_PATH" + fi + echo "store_path=$STORE_PATH" >> "$GITHUB_OUTPUT" + + - name: Build SD image + run: | + nix build .#images.pifinder -L -o result-sd + + - name: Upload SD image + uses: actions/upload-artifact@v4 + with: + name: sdimage-hosted + path: result-sd/sd-image/*.img.zst + retention-days: 1 + + # Minimal migration system — see the native job's comment (ADR 0004). + - name: Build migration image + run: | + nix build .#images.pifinder-migration -L -o result-mig + + - name: Upload migration image + uses: actions/upload-artifact@v4 + with: + name: migimage-hosted + path: result-mig/sd-image/*.img.zst + retention-days: 1 + + # Arch-independent: package the migration tarball + checksums and publish. Runs + # on whichever build succeeded (native preferred). + release: + needs: [build-native, native-wait, build-hosted] + if: | + always() && + (needs.build-native.result == 'success' || needs.build-hosted.result == 'success') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # Base repo (this repo, not the fork source): supplies the TRUSTED manifest + # scripts and the `origin` the manifest branch is pushed to. Keeps default + # credentials for that push. + - name: Checkout base repo + uses: actions/checkout@v4 + + # Download native and hosted images to separate dirs. If both builds ran + # (native slow → fallback fired) their images share a filename, so merging + # them would corrupt the archive — never merge; the assemble step prefers + # native. + - name: Download native SD image + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: sdimage-native + path: img-native + + - name: Download hosted SD image + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: sdimage-hosted + path: img-hosted + + - name: Download native migration image + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: migimage-native + path: mig-native + + - name: Download hosted migration image + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: migimage-hosted + path: mig-hosted + + - name: Compute tag + run: | + TAG="v${{ inputs.version }}" + [[ "${{ inputs.type }}" == "beta" ]] && TAG="${TAG}-beta" + echo "TAG=$TAG" >> "$GITHUB_ENV" + + - name: Assemble image + migration tarball + checksums + run: | + set -euo pipefail + # Only some dirs may exist (downloads are continue-on-error); create + # all so find can't fail under pipefail. Prefer the native builder's + # artifacts (listed first); fall back to hosted. + mkdir -p img-native img-hosted mig-native mig-hosted + IMAGE_SRC=$(find img-native img-hosted -type f -name '*.img.zst' -print -quit) + MIG_SRC=$(find mig-native mig-hosted -type f -name '*.img.zst' -print -quit) + if [ -z "$IMAGE_SRC" ] || [ -z "$MIG_SRC" ]; then + echo "Missing artifact: sd=[$IMAGE_SRC] migration=[$MIG_SRC]" >&2 + exit 1 + fi + mkdir -p release + cp "$IMAGE_SRC" "release/pifinder-${TAG}.img.zst" + + # The migration tarball is cut from the MINIMAL migration image — it + # resolves the full system from the update manifest at first boot, so + # its content never goes stale (ADR 0004 / 0003). + rm -f /tmp/pifinder-mig.img + zstd -d "$MIG_SRC" -o /tmp/pifinder-mig.img + + LOOP=$(sudo losetup --find --show --partscan /tmp/pifinder-mig.img) + sudo mkdir -p /mnt/boot /mnt/root + sudo mount "${LOOP}p1" /mnt/boot + sudo mount "${LOOP}p2" /mnt/root + + mkdir -p /tmp/tarball-staging + sudo cp -a /mnt/boot /tmp/tarball-staging/boot + sudo cp -a /mnt/root /tmp/tarball-staging/rootfs + + sudo umount /mnt/boot /mnt/root + sudo losetup -d "$LOOP" + rm -f /tmp/pifinder-mig.img + + sudo tar -C /tmp/tarball-staging -cf - \ + --exclude='*/lost+found' \ + boot rootfs | zstd -T0 -19 -o "release/pifinder-migration-${TAG}.tar.zst" + sudo rm -rf /tmp/tarball-staging + + # Never-again guard (2026-07-05 field failure): during migration the + # tarball is copied into RAM, so a 2GB board can only hold ~1.4GB. + # Refuse to publish a release that cannot migrate 2GB boards. + MIG_MB=$(( $(stat -c%s "release/pifinder-migration-${TAG}.tar.zst") / 1048576 )) + BUDGET_MB=800 + echo "migration tarball: ${MIG_MB}MB (budget ${BUDGET_MB}MB)" + if [ "${MIG_MB}" -gt "${BUDGET_MB}" ]; then + echo "::error::migration tarball ${MIG_MB}MB exceeds ${BUDGET_MB}MB budget — shrink the migration closure (check linux-firmware and friends)" + exit 1 + fi + + # Checksum sidecars. The in-app upgrade (PiFinder.sys_utils) fetches + # .sha256 and refuses to flash without a matching digest; + # it reads the first whitespace-delimited token, so sha256sum output is + # the expected format. + ( cd release + for f in pifinder-*.img.zst pifinder-migration-*.tar.zst; do + sha256sum "$f" > "$f.sha256" + done ) + + - name: Tag release source commit + # Only when building this repo. A fork source_repository has a different + # `origin` we can't push to with GITHUB_TOKEN; the GitHub Release below + # still publishes the artifacts to this repo. + if: (inputs.source_repository || github.repository) == github.repository + env: + SOURCE_SHA: ${{ needs.build-native.outputs.source_sha || needs.build-hosted.outputs.source_sha }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # The base checkout is at the workflow's ref, not the built source — + # fetch and tag the commit the build job actually checked out. + git fetch --depth=1 origin "$SOURCE_SHA" + git tag "$TAG" "$SOURCE_SHA" + git push origin "$TAG" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.TAG }} + name: PiFinder ${{ env.TAG }} + body: ${{ inputs.notes }} + prerelease: ${{ inputs.type == 'beta' }} + files: | + release/pifinder-*.zst + release/pifinder-*.sha256 + + - name: Update generated manifest branch + # Records this release in the metadata-only nixos-manifest branch so + # devices discover it (upgrade uses store_path; migration uses the tarball + # URL). Pushes to this repo's origin via the base checkout above, so it + # runs for fork-source builds too. source_sha is the built source commit; + # the tarball URL points at this repo's release assets. + env: + STORE_PATH: ${{ needs.build-native.outputs.store_path || needs.build-hosted.outputs.store_path }} + SOURCE_SHA: ${{ needs.build-native.outputs.source_sha || needs.build-hosted.outputs.source_sha }} + RELEASE_NOTES: ${{ inputs.notes }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + MIGRATION_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/pifinder-migration-${TAG}.tar.zst" + + bash .github/scripts/publish_manifest.sh \ + "chore: update release manifest [skip ci]" \ + python3 .github/scripts/update_manifest.py release \ + --manifest @MANIFEST@ \ + --repository "${{ inputs.source_repository || github.repository }}" \ + --sha "$SOURCE_SHA" \ + --tag "$TAG" \ + --version "${{ inputs.version }}" \ + --release-type "${{ inputs.type }}" \ + --store-path "$STORE_PATH" \ + --migration-url "$MIGRATION_URL" \ + --migration-sha256-url "${MIGRATION_URL}.sha256" \ + --title "PiFinder $TAG" \ + --notes "$RELEASE_NOTES" diff --git a/.gitignore b/.gitignore index 7e9319394..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/.gitmodules b/.gitmodules deleted file mode 100644 index bbd1b94ab..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "python/PiFinder/tetra3"] - path = python/PiFinder/tetra3 - url = https://github.com/smroid/cedar-solve diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a538aa17..317fee59c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,27 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks repos: - - repo: https://github.com/saltstack/mirrors-nox - rev: 'v2022.11.21' # Use the sha / tag you want to point at + - repo: local hooks: - - id: nox - files: ^.*\.py$ - args: - - -f - - python/noxfile.py - - -s - - type_hints - - smoke_tests - - -- + - id: ruff-lint + name: ruff lint + entry: bash -c 'cd python && ruff check' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: ruff-format + name: ruff format check + entry: bash -c 'cd python && ruff format --check' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: mypy + name: mypy type check + entry: bash -c 'cd python && mypy .' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: smoke-tests + name: smoke tests + entry: bash -c 'cd python && pytest -m smoke' + language: system + files: ^python/.*\.py$ + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index b0b0d15d2..81efeaa69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,6 +191,39 @@ Tests use pytest with custom markers for different test types. The smoke tests p - **Linting:** Ruff with Python 3.9 target, Black-compatible formatting - **Type Checking:** MyPy with gradual typing adoption - **Code Style:** 88-character line length, double quotes, space indentation +- **Comments:** describe what the code does now, not how it changed. No "previously / no longer / used to / moved from" — history lives in git/jj, not in comments. - **I18n Support:** Babel integration for multi-language UI The codebase follows modern Python practices with type hints, comprehensive testing, and automated code quality checks integrated into the development workflow. + +## NixOS Development + +**CRITICAL: Never run `nix build` or `nix eval` on Pi 4 targets.** The Pi 4 lacks sufficient resources and will hang/crash. Always build on pi5.local (GitHub Actions runner), push to Attic, then trigger the upgrade service: +```bash +# Build on pi5 +ssh pi5.local 'nix build --no-link --print-out-paths github:mrosseel/PiFinder/nixos#nixosConfigurations.pifinder.config.system.build.toplevel' +# Push to Attic (so Pi can download signed paths) +ssh pi5.local 'attic push pifinder:pifinder ' +# Trigger upgrade on target Pi (downloads from Attic, activates, reboots) +ssh pifinder@ 'echo "" > /run/pifinder/upgrade-ref && sudo systemctl start --no-block pifinder-upgrade.service' +# Monitor progress +ssh pifinder@ 'cat /run/pifinder/upgrade-status' +``` + +**Netboot deployment (dev Pi on proxnix NFS):** +```bash +./deploy-image-to-nfs.sh # Build and deploy to NFS +``` + +**Power control (Shelly plug via Home Assistant):** +```bash +~/.local/bin/pifinder-power-off.sh # Turn off PiFinder +~/.local/bin/pifinder-power-on.sh # Turn on PiFinder +``` + +**Check Pi status:** +```bash +ssh pifinder@192.168.5.146 # SSH to netboot Pi +systemctl status pifinder # Check service status +journalctl -u pifinder -f # Follow service logs +``` diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 1f77e97b7..c29997468 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -12,6 +12,7 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - [Camera](./docs/ax/camera/CONTEXT.md) — captures frames and decides exposure: the three exposure regimes, the auto-exposure controllers, and zero-match recovery. - [Battery](./docs/ax/battery/CONTEXT.md) — reads battery voltage and charge state from the rev-4 BQ25895 charger and publishes `BatteryState`; read-only telemetry, gated on hardware presence. - [Sound](./docs/ax/sound/CONTEXT.md) — turns named events into short **earcons** on the rev-4 passive buzzer (hardware PWM ch0, GPIO12); best-effort, fire-and-forget feedback, gated on hardware presence. +- [NixOS](./docs/ax/nixos/CONTEXT.md) — how a NixOS PiFinder is built, published, and updated over the air: the Attic cache, the stable/beta/unstable channels, and the on-device upgrade flow. Cross-cutting infrastructure, not a runtime slice. ## Relationships @@ -29,6 +30,7 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - **Sound → shutdown**: the shutdown chokepoint (`callbacks.shutdown`) plays `SHUTDOWN` and waits its catalog duration + margin **before** triggering the GPIO14 power latch (see [ADR 0007](./docs/adr/0007-gpio-poweroff-latch.md)), so the cue isn't cut off by power-down. Companion architecture docs live next to each `CONTEXT.md`: +- [`docs/ax/nixos.md`](./docs/ax/nixos.md) - [`docs/ax/catalog.md`](./docs/ax/catalog.md) - [`docs/ax/positioning.md`](./docs/ax/positioning.md) - [`docs/ax/sqm.md`](./docs/ax/sqm.md) diff --git a/bin/cedar-detect-server-aarch64 b/bin/cedar-detect-server-aarch64 deleted file mode 100755 index 7b44b89b7..000000000 Binary files a/bin/cedar-detect-server-aarch64 and /dev/null differ diff --git a/bin/cedar-detect-server-arm64 b/bin/cedar-detect-server-arm64 deleted file mode 100755 index ea792437f..000000000 Binary files a/bin/cedar-detect-server-arm64 and /dev/null differ diff --git a/default_config.json b/default_config.json index 7684bff0f..b96f16caa 100644 --- a/default_config.json +++ b/default_config.json @@ -180,6 +180,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..25bb5bd02 --- /dev/null +++ b/docs/ax/nixos/CONTEXT.md @@ -0,0 +1,98 @@ +# NixOS + +How a NixOS PiFinder system is built, published, and updated over the air — the binary cache, the release/channel metadata, and the on-device upgrade flow. Distinct from the Raspbian→NixOS one-time **Migration**, which this context feeds but does not own. + +## Language + +### Repositories and their roles + +**Upstream**: +The canonical public PiFinder repo, `brickbots/PiFinder`. The to-be home of releases, update channels, and (eventually) build infrastructure. On-device update channels already read from here (the **update manifest** on the `nixos-manifest` branch). +_Avoid_: "the main repo", bare "brickbots" in prose, "official". + +**Fork**: +`mrosseel/PiFinder` (git remote `origin`). Where NixOS is developed (the `nixos` branch) and, through the transition, where every NixOS artifact is produced — CI builds, the Attic cache, the update manifest, release tags, and the migration tarball. +_Avoid_: "my repo", bare "mrosseel", "the staging repo" used interchangeably with branch names. + +**Trunk**: +The branch that holds the live NixOS development tip and feeds the "unstable" channel's non-PR entry. Today that is `nixos` **on the Fork**; in the steady state it is `main` on the Upstream. The branch is a single switch (`TRUNK_BRANCH`), not hard-coded to `main`. +_Avoid_: conflating "trunk" with the literal branch name `main`. + +### Transition + +**Phase 1**: +Release/channel metadata and the migration gate live on the **Upstream**; the **Attic cache** and the **pi5 runner** (build infrastructure) stay on the **Fork**. A Pi reads release metadata from the Upstream but substitutes store paths from the Fork's cache. + +**Phase 2**: +Everything — builds, cache, releases, channels — is hosted by the **Upstream**. The Fork reverts to an ordinary contributor fork. + +**In-between phase**: +The current state: no NixOS artifacts exist on the Upstream yet (PR #379 not merged), so all three channels are temporarily sourced from the **Fork** (its releases for stable/beta, its `nixos` trunk + testable PRs for unstable) via a single switch, purely so they can be exercised for testing. Reverts to Upstream/`main` at upstreaming. + +### Channels + +The on-device UI offers three update **channels**, each mapped to one stage of the branch promotion flow (testable PRs → `main` → `release`). Choosing a channel and a version resolves through the **update manifest** to a store path and installs it. + +**stable**: +The production channel — official release entries in the generated manifest. The default for ordinary users. +_Avoid_: "release channel" (the *branch* is `release`; the *channel* is "stable"). + +**beta**: +The integration channel — GitHub **prereleases** (the `prerelease` flag), cut deliberately from `main`. Curated like stable (notes, explicit semver `vX.Y.Z-beta`, the version gate) and pushed to the *retained* cache, so beta builds reinstall durably too. Ceremonial, not continuous. +_Avoid_: naming the channel "prerelease" — it is "beta"; prerelease is its mechanism. + +**unstable**: +The bleeding-edge channel — the live `main`/**trunk** head plus open PRs carrying the `testable` label, each installable at its own head. The `main` entry is rendered more prominently to set it apart from the per-PR rows. Hidden until unlocked (7× square). +_Avoid_: "preview", "nightly". + +**As-is vs to-be:** channel *sourcing* matches the current code (stable/beta = Releases split on the prerelease flag; unstable = `main` head + testable PRs). The only deltas are cosmetic and transitional: render the unstable `main` entry more prominently than PR rows, and — until upstreaming — read from the Fork's `nixos` trunk (see In-between phase). + +### Rollback + +**Rollback**: +Returning a device — or the fleet — to a known-good build after a bad one ships. Guaranteed for **stable** and **beta** — both are Releases whose closures live in the retained `pifinder-release` cache; only **unstable** (`main` head / PR) builds may be GC'd from the short-retention dev cache. + +**Watchdog**: +The on-device boot guardian. It health-checks every boot of a not-yet-**confirmed** generation (a **trial**) and performs a **generation rollback** when the trial fails — capturing evidence and telling the operator on screen. It never rolls back a confirmed generation, but it still *reports*: a confirmed generation whose app fails gets an on-screen advisory naming the **recovery hold** (see [NixOS ADR 0005](./adr/0005-self-arming-watchdog-confirmed-generations.md)). + +**Trial**: +The probation boot of a generation that has not yet proven itself on this device. Every boot of an unconfirmed generation is a trial, regardless of which build installed it. A passed trial **confirms** the generation. +_Avoid_: "first boot" as a synonym — a trial can recur (e.g. after a crash before the health check completed). + +**Health check**: +What a trial must pass: the app *itself* declares that its UI is live (readiness announced from the first drawn frame), and then stays up briefly. A merely-running process is not healthy — a build that starts but never turns the screen on must fail its trial. Outside supervised boots (development runs), the readiness announcement is a harmless no-op. +_Avoid_: equating "healthy" with "the service started". + +**Confirmed generation**: +A generation that has passed a trial on this device, recorded locally. Confirmed generations are never auto-rolled-back — later failures are for the user-driven recovery ladder (Rollback channel, **recovery hold**, SSH), not the watchdog. +_Avoid_: "committed" (overloaded with VCS meaning). + +**Recovery hold**: +The user gesture that enters **recovery mode**: hold the square-equivalent input while powering on, and keep holding until RECOVERY appears. Detected only during a short boot window (so it can never fire mid-observation). Deliberately a single input: v4's joystick makes multi-key chords physically impossible, and a square-equivalent will always exist. +_Avoid_: "recovery chord" (one input, not a chord). + +**Recovery mode**: +The interactive rescue environment the **recovery hold** boots into: the update screen alone (no camera/solver/positioning), offering the local generation overview — marking the generation that was about to boot and each **confirmed generation** — plus the normal internet channels. Choosing a generation is *sticky* (it becomes the boot default); an internet pick installs through the ordinary upgrade flow and faces a **trial** like any install. If recovery mode itself fails its **health check**, the device falls back to a blind **generation rollback** to the newest confirmed generation. Never touches user data. +_Avoid_: "safe mode" (nothing about the broken build is run "safely" — recovery either works or falls through), "factory reset" (nothing is wiped). + +**Generation rollback**: +The instance-local revert to an earlier NixOS generation. Triggered automatically by the **watchdog** when a **trial** fails, or manually. Bounded — local generations are pruned to three (the running one plus two rollback targets, surfaced in the Software screen's Rollback channel). + +**Reinstall an older build**: +The durable rollback path: pick a prior version and install it — the device substitutes its prebuilt closure (it never compiles; the upgrade is `nix build … --max-jobs 0`), so the only requirement is that the closure still lives in a reachable cache. For **stable** and **beta** that is guaranteed (their closures live in the never-GC'd `pifinder-release` cache), so any past release reinstalls forever; **unstable** closures may be GC'd from the short-retention cache, at which point that exact build is un-installable until CI rebuilds and re-pushes it. Survives generation pruning and covers boots-but-misbehaves bugs the watchdog cannot catch. + +**Yank**: +A release-level rollback — demoting a buggy official Release (to draft/prerelease, or superseding it) so it leaves the **stable** channel for *new* installs. There is no fleet-wide auto-revert: a device already on a yanked build surfaces an **advisory** (a status/notification that its version is withdrawn) prompting the user to choose the latest, who then recovers by **reinstalling an older build**. User-initiated, never automatic. + +### Build and cache + +**Attic cache**: +The binary cache at `cache.pifinder.eu`, with two namespaces: `pifinder` (dev builds, short retention) and `pifinder-release` (tagged releases, GC-disabled). Every Pi substitutes signed store paths from here. Hosted on the Fork's side through Phase 1. +_Avoid_: "cachix" (an earlier/alternative cache; the current one is Attic — see [NixOS ADR 0001](./adr/0001-attic-binary-cache.md)). + +**pi5 runner**: +The self-hosted aarch64 GitHub Actions runner that builds NixOS systems natively, with a hosted `ubuntu-*-arm` runner (also native aarch64 — no emulation) as fallback. Fork-side infrastructure through Phase 1. + +**Update manifest**: +`update-manifest.json` — the generated channel listing published on a metadata-only branch (`nixos-manifest` during the fork transition). It maps releases, trunk builds, and testable PR builds to signed Nix `store_path`s in the Attic cache (and, for releases, to the migration tarball). The device reads this raw JSON file instead of calling the GitHub API; it is the single mapping between versions and store paths. +_Avoid_: bare "manifest" (say *update* manifest), "build stamp" (a retired concept: `pifinder-build.json` is gone — a device's identity lives in one file, seeded at image build and rewritten by every upgrade). diff --git a/docs/ax/nixos/HANDOVER.md b/docs/ax/nixos/HANDOVER.md new file mode 100644 index 000000000..1d678b5c0 --- /dev/null +++ b/docs/ax/nixos/HANDOVER.md @@ -0,0 +1,56 @@ +# NixOS Handover + +Current state as of `2026-06-24`: + +- Latest pushed commit on `mrosseel/PiFinder:nixos` is `210f2c08` (`feat(nixos): drive update channels from manifest`). +- The branch is clean locally in the current worktree. +- GitHub Actions run `28119693140` is in progress for that push. + +## What changed + +- The PiFinder software UI no longer queries the GitHub REST API at runtime. +- Device channel data now comes from one generated raw JSON file: + - `https://raw.githubusercontent.com/mrosseel/PiFinder/nixos-manifest/update-manifest.json` +- The manifest contains: + - stable release entries + - beta prerelease entries + - unstable trunk + testable PR entries +- Old stamp-commit behavior on the source branch was removed. +- CI now updates the metadata-only `nixos-manifest` branch instead of committing `pifinder-build.json` back onto `nixos`. + +## Important files + +- [`python/PiFinder/ui/software.py`](../../python/PiFinder/ui/software.py) +- [`python/tests/test_software.py`](../../python/tests/test_software.py) +- [`.github/scripts/update_manifest.py`](../../.github/scripts/update_manifest.py) +- [`.github/workflows/build.yml`](../../.github/workflows/build.yml) +- [`.github/workflows/release.yml`](../../.github/workflows/release.yml) +- [`docs/ax/nixos/CONTEXT.md`](./CONTEXT.md) +- [`nixos/RELEASE.md`](../../nixos/RELEASE.md) + +## Verified locally + +- `nix develop path:. -c bash -lc 'cd python && pytest -m unit tests/test_software.py -q'` +- `nix develop path:. -c bash -lc 'cd python && mypy PiFinder/ui/software.py'` +- `python3 -m py_compile .github/scripts/update_manifest.py` + +The focused software tests pass in the Nix Python 3.13 environment. + +## Device state + +- On the real PiFinder, `/home/pifinder/PiFinder` is a root-owned symlink into `/nix/store`. +- The running `pifinder.service` uses the store-backed source tree, not writable local source. +- `/var/lib/pifinder/current-build.json` reflects the installed build after updates. + +## Current risk + +- The manifest workflow is new and needs CI confirmation. +- Build-native and update-manifest passed in CI on the last run before this handover. +- Release workflow still writes a temporary `pifinder-build.json` in the workspace for build stamping inside the job, but it no longer commits that file back to the source branch. + +## If you continue + +1. Watch run `28119693140` to completion. +2. Verify `nixos-manifest` exists and contains `update-manifest.json`. +3. If CI fails, look first at the `update-manifest` job and the release workflow step that pushes the metadata branch. +4. If the device still shows no PRs, inspect the manifest contents, not the GitHub API, because runtime no longer calls GitHub REST. diff --git a/docs/ax/nixos/adr/0001-attic-binary-cache.md b/docs/ax/nixos/adr/0001-attic-binary-cache.md new file mode 100644 index 000000000..3a26fd2ce --- /dev/null +++ b/docs/ax/nixos/adr/0001-attic-binary-cache.md @@ -0,0 +1,95 @@ +# Self-hosted Attic for NixOS binary distribution + +A NixOS PiFinder runs from pre-built binaries in `/nix/store/`; updates work by +atomically swapping the running system for a new closure of pre-built binaries. +Distributing those binaries requires a **binary cache** — a server that hands +them out on demand, since recompiling from source on a Pi is not viable (Rust +crates alone take hours). We will self-host the [Attic](https://github.com/zhaofengli/attic) +binary cache at `cache.pifinder.eu`, backed by SQLite and local disk initially, +with Cloudflare R2 as the eventual chunk store. Attic is a small Rust server +that adds **content-defined chunking (FastCDC)** on top of the standard Nix +substituter protocol: every NAR is sliced into variable-size chunks by byte +content and identical chunks are stored exactly once. This dedup is +**server-side** — it shrinks storage across releases, and because `attic push` +chunks on the runner it makes the **CI upload** proportional to actual changes. +It does **not** delta the device download: Attic serves whole NARs over the +standard binary-cache protocol, so a device fetches the full (compressed) NAR of +every store path whose hash changed — not a chunk-delta against the previous +version of that path. The saving devices get is **path-level**: the 90–95% of a +closure that is unchanged between releases (identical store hashes) is not +refetched at all, so an update pulls only the changed paths' NARs — on the order +of tens of MB for a 1.5 GB closure, but each of those in full. True client-side +chunk-delta downloads need a casync/desync-style client that keeps a local chunk +store; the standard Nix client — and therefore Attic, harmonia, and nix-casync +used as substituters — does not do this. (That client-delta property is exactly +why desync is used for the out-of-closure astro-data blobs; see the data-blob +distribution notes.) + +## Considered Options + +- **Stay on cachix.org indefinitely.** Rejected: SaaS quota caps (storage tier, + push throttling) become a planning concern as the system closure grows; ships + full NARs per closure (no chunking), so every update transfers the full + closure even when 5% of bytes changed; per-cache pricing scales linearly with + closure count. +- **Magic Nix Cache (DetSys) alone.** Rejected: backed by GitHub Actions Cache + (~10 GB per repo, ephemeral, HTTP-418 rate-limited under sustained traffic — + already broke a `type-check` job once). Useful for CI runner-local caching, + not for distributing binaries to end-user devices, which it cannot do at all. +- **nix-casync.** Same content-defined-chunking idea, predates Attic, but + distributed as a standalone tool rather than a hosted server; would need to + assemble the server side ourselves. Attic delivers the same dedup story as a + complete package. +- **harmonia.** Newer self-hosted alternative; simpler than Attic but no + chunking. Loses the headline bandwidth-and-storage saving. + +## Consequences + +- **Operational ownership:** PiFinder takes on a small piece of infrastructure + (one VPS, one Rust binary, one SQLite file, one Caddy reverse-proxy with + Let's Encrypt). Sized at Hetzner CX22 / €4 month for the foreseeable future; + SQLite handles millions of chunks before PostgreSQL becomes necessary. Backup + story is "snapshot the SQLite file and the chunk directory" — same pattern as + a typical small VPS service. +- **Egress economics:** Cloudflare R2 charges zero egress, which matters when + distributing updates to a globally-dispersed PiFinder fleet. Self-hosted on a + Hetzner VPS the egress is also effectively free at typical hobby volumes. + Either way, the bandwidth question stops being a recurring concern. +- **CI publish step:** `build.yml`'s `cachix-action` step is replaced by an + `attic push` step using a long-lived JWT minted by `atticadm make-token + --push pifinder`, stored as `secrets.ATTIC_TOKEN`. Chunking happens on the + runner; the server only ingests new chunks. Push payload is proportional to + actual changes, not to closure size. +- **Device pull side:** `services.nix` declares `cache.pifinder.eu` as a + substituter alongside `cache.nixos.org`, with the Attic public key in + `trusted-public-keys`. The existing on-device upgrade flow + (`pifinder-upgrade.service`, `nix build "$STORE_PATH" --max-jobs 0`) is + unchanged — the new substituter is transparent. Users see the same + "downloading N/M" progress in the menu, just with smaller N. +- **Failure model:** Nix tries substituters in order and falls through. If + `cache.pifinder.eu` is unreachable, the device falls through to + `cache.nixos.org` for any path that exists there. The "Attic outage = bricked + PiFinders" scenario does not exist for paths nixpkgs already publishes; only + locally-built paths (kernel with our overlays, `cedar-detect-server`, Python + wheels) are at risk during an outage, and those are cached locally on devices + that previously updated successfully. +- **Migration tarball stays self-contained.** The boot-from-tarball path + (`pifinder-nixos-v3.0.0.tar.zst` on the GitHub release) is independent of the + cache and remains the way a stock Debian PiFinder bootstraps into NixOS. A + later refinement could ship a smaller tarball that pulls the bulk of the + closure from Attic on first boot, but that is out of scope for this ADR. +- **No retirement of cachix.org is mandated here.** Whether to keep cachix.org + as a fallback or drop it after Attic is proven is a separate operational + decision; the substituter list can carry both indefinitely with no penalty + beyond the cachix subscription cost. +- **Two caches, split by retention.** The server hosts two Attic caches: + `pifinder` (dev/nightly builds from `build.yml`, short retention — these churn + on every push) and `pifinder-release` (tagged release closures from + `release.yml`, garbage collection disabled). The split exists because Attic + retention is per-cache, not per-path: a device may upgrade to a release months + after it was cut, so its closure must never be GC'd, while dev builds should + not accumulate forever. Chunk dedup is global across caches on the same + server, so storing a release closure separately costs only its genuinely-new + chunks. Each cache has its own signing key; devices trust both, plus + `cache.nixos.org`. (Originally a single `pifinder` cache; this followed once + releases started flowing through Attic instead of cachix.) diff --git a/docs/ax/nixos/adr/0002-update-channels-and-rollback.md b/docs/ax/nixos/adr/0002-update-channels-and-rollback.md new file mode 100644 index 000000000..f0b92a641 --- /dev/null +++ b/docs/ax/nixos/adr/0002-update-channels-and-rollback.md @@ -0,0 +1,16 @@ +# Update channels stay Release-based (stable/beta) over a live main+PR unstable; rollback via reinstall + passive yank + +The three on-device update **channels** map onto the git promotion flow (testable PRs → `main` → `release`) and resolve through a **build stamp** (`pifinder-build.json`) to a store path: **stable** = official GitHub Releases (non-prerelease, `≥ MIN_NIXOS_VERSION`); **beta** = GitHub **prereleases** cut from `main`; **unstable** = the live `main`/trunk head plus open `testable`-labeled PRs (the `main` entry rendered more prominently than the PR rows). stable and beta are ceremonial Releases — both curated (notes, explicit semver, the version gate) and pushed to the *retained* cache; unstable tracks ref heads continuously and is hidden until unlocked. + +A bad build is recovered three ways, never by fleet-wide auto-revert: the **watchdog** reverts to the previous NixOS generation on a boot failure (once); a user can **reinstall any older build** by selecting it (the device only substitutes the prebuilt closure — `nix build … --max-jobs 0`, it never compiles); and a bad official release is **yanked** (demoted/superseded so it leaves the channel for new installs) plus a device **advisory** prompting affected units to choose the latest. + +## Considered options + +- **beta = live `main` head (continuous), rejected.** Briefly chosen, then reverted: GitHub's prerelease flag is built in and keeps beta symmetric with stable (notes, semver, gate). Decisively, a prerelease lands in the *retained* `pifinder-release` cache, so beta gets durable rollback; a `main`-head beta would sit in the short-retention cache and lose it. Continuous "every merge" delivery is unstable's job — `main` head lives there — not beta's. +- **Fully uniform branch-head channels (stable = `release` head), rejected.** Drops release notes, explicit versioning, the gate, and the SD-image/migration assets, and would need `build.yml` to stamp `release`. +- **Active kill-switch for yank, rejected for now.** Passive yank + advisory avoids a server-side bad-builds list and device polling/auto-revert; revisit only if the field shows the "already-running unit that never opens the update screen" gap is real. + +## Consequences + +- **Rollback is guaranteed for stable and beta** — both are Releases whose closures live in the never-GC'd `pifinder-release` cache ([0001](./0001-attic-binary-cache.md)), and the device never builds. Only **unstable** (`main`-head / PR) closures may be GC'd from the short-retention cache and become un-installable until CI rebuilds and re-pushes them. +- Channel *sourcing* matches the current code; the only deltas are cosmetic (render the unstable `main` entry more prominently than PR rows) and transitional (read from the Fork's `nixos` trunk until the NixOS line is upstreamed). diff --git a/docs/ax/nixos/adr/0003-migration-tarball-rides-latest-stable.md b/docs/ax/nixos/adr/0003-migration-tarball-rides-latest-stable.md new file mode 100644 index 000000000..e71ad352e --- /dev/null +++ b/docs/ax/nixos/adr/0003-migration-tarball-rides-latest-stable.md @@ -0,0 +1,20 @@ +# Migration tarball resolves its full system from the update manifest at first boot (rides latest stable), instead of pinning a closure + +> **Amended by [0004](./0004-migration-tarball-published-per-release.md):** publication is a per-(pre)release asset, not a built-once file. The resolve-at-first-boot mechanism below is unchanged. + +The migration tarball is the **minimal** bootable system; on first boot it downloads the **full** PiFinder system from the binary cache and switches to it. We will have first-boot resolve that store path from the **same update manifest the on-device updater reads** (`brickbots/PiFinder@nixos-manifest:update-manifest.json`, see [0002](./0002-update-channels-and-rollback.md)) — taking the newest entry in the best available channel, **stable → beta → unstable trunk** — rather than baking a fixed store path into the tarball. Because **stable** holds only Releases, whose closures live in the never-GC'd `pifinder-release` cache ([0001](./0001-attic-binary-cache.md)), a resolved stable path can't be garbage-collected out from under a published tarball. The tarball therefore never goes stale, is built **once** (not per Release), and every new migrator gets the *current* system rather than the snapshot the tarball was cut from. + +The trap this avoids: a baked/pinned closure couples the tarball's lifetime (months — until the next cut) to a single cache entry's lifetime. Pin into the short-retention `pifinder` cache and the closure is GC'd while the tarball is still the published download → first-boot fails. Pin into the retained `pifinder-release` cache and every intermediate test build piles up there forever (it is never GC'd) → the retained cache fills with dead closures, the exact thing "persist only the last version" is meant to prevent. Resolving *latest stable* at boot escapes both horns: nothing per-tarball is pinned, test churn stays in the self-cleaning unstable cache, and the only durable closures are deliberate Releases. + +## Considered options + +- **Bake the full closure into the tarball (self-contained), rejected.** Drops the cache dependency entirely and can never rot, but bloats the download (full system vs minimal) and freezes the migrated version to the tarball's build date. Resolving latest-stable keeps the tarball minimal and current. +- **Bake a pinned store path + push it to `pifinder-release`, rejected.** Durable, but every iteration pushes another full closure into the never-GC'd cache, and the tarball must be rebuilt and re-uploaded every Release. Directly conflicts with "only the last version persists." +- **Bake a pinned store path served from the short-retention `pifinder` cache, rejected.** Small and self-cleaning, but the pinned closure is GC'd (~90 days) and the still-published tarball breaks. + +## Consequences + +- First-boot reads the **update manifest**, not `pifinder-build.json` (which it previously fetched from `mrosseel/PiFinder@nixos`). First-boot and the updater now share one source of truth. +- Resolution order is **stable → beta → unstable trunk → baked-in `first-boot-target`**. The baked-in path survives only as a last-ditch fallback when the manifest can't be fetched. +- **Transitional:** with `stable`/`beta` empty until the first Release is cut, migration currently resolves the **unstable trunk**, which is the **`nixos` branch** (`source_ref == "nixos"`), not `main` — the NixOS line isn't upstream yet ([0002](./0002-update-channels-and-rollback.md)). Drop the `source_ref == "nixos"` guard once `nixos` becomes the mainline trunk. +- The tarball is built **once** and only rebuilt when the minimal migration system itself changes — never per Release. A "cut stable release" flow (build full system → push closure to `pifinder-release` → stamp a `stable` entry) is what makes new closures reachable; the tarball just rides whatever that produces. diff --git a/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md b/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md new file mode 100644 index 000000000..d11706ff1 --- /dev/null +++ b/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md @@ -0,0 +1,14 @@ +# Migration tarball is published as a (pre)release asset, not a built-once file (amends 0003) + +The tarball keeps [0003](./0003-migration-tarball-rides-latest-stable.md)'s *mechanism* — minimal system (`images.pifinder-migration`), full system resolved from the update manifest at first boot, so the tarball's content never goes stale — but its *publication* moves from "built once at a fixed URL" to an asset (with `.sha256` sidecar) on every stable/beta Release, referenced per-entry as `migration_url` in the manifest. Releases are visible, versioned, documented, and in the canonical place; a replace-in-place file on a private file server is none of those, and un-auditable after an incident. + +## Considered options + +- **Built-once evergreen tarball at a fixed URL (0003's original), rejected.** Minimal asset churn, but the artifact lives outside the release record: no version, no notes, no checksum ceremony, silent replace-in-place. The Raspbian-side updater would also need a hardcoded URL instead of reading the manifest like everything else. +- **Per-release *full-system* tarball (shipped briefly in the first release.yml), rejected.** ~1.4 GB per release forever, and it freezes the migrated system to the tarball's build date — the exact staleness trap 0003 exists to avoid. + +## Consequences + +- `release.yml` must tar `images.pifinder-migration`, **not** `images.pifinder` (the full SD image remains a separate asset for fresh flashes). +- Two-stage resolution: the Raspbian device picks a *tarball* (first manifest entry with a `migration_url`, stable → beta → unstable); first boot then picks the *closure* from the manifest — the authoritative version decision. Any reasonably recent tarball yields the same end state. +- Asset cost per release drops to the minimal system's size; old tarballs remain downloadable alongside their releases. diff --git a/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md b/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md new file mode 100644 index 000000000..486c4f1b8 --- /dev/null +++ b/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md @@ -0,0 +1,17 @@ +# Boot watchdog is self-arming via a confirmed-generations ledger; confirmed generations are never auto-rolled-back + +A generation is a **trial** until it passes one boot health check, which **confirms** it permanently on that device (recorded in a device-local ledger). Any boot of an *unconfirmed* generation is a trial — regardless of which build installed it — so recovery never depends on the previous system's code: the version-skew hole where an older build (unaware of trial markers) installs a broken new one and leaves it unprotected, which is exactly how the first v3.0.0-beta crash-looped with no rollback on 2026-07-03. Confirmed generations are never auto-rolled-back: a transient failure in the field must not cause a surprise downgrade. + +## Considered options + +- **Marker-armed only (upgrade writes a trial marker; no marker → nothing to watch), rejected.** The original design. Protection depends on the *installing* build knowing to arm the marker — any device upgrading from an older build gets one unprotected hop, proven in the field. +- **Auto-rollback on any repeated boot failure (no confirmation concept), rejected.** Self-heals late-life breakage, but a confirmed build failing transiently (cold night, flaky SD read) would be silently downgraded — the failure mode the trial/confirm split exists to prevent. + +## Consequences + +- Persistent breakage of an already-confirmed generation does **not** self-heal — deliberate. It is covered by the explicit recovery ladder instead: the Software screen's Rollback channel, the power-on **recovery hold** into recovery mode (rollback without any working UI, see [0006](./0006-recovery-mode-reuses-update-screen.md)), and SSH. +- The upgrade-written trial marker survives as a *hint* (it names the exact pre-upgrade system, camera specialisation included); the ledger is what's load-bearing. Rollback targets are chosen newest-first from the profile, preferring confirmed generations. +- Ledger loss is benign: a healthy generation simply re-confirms on its next boot; an unhealthy one gets a rollback it genuinely needs. +- A failing trial with no rollback target at all (first-ever install) shows an on-screen failure message and stays up for rescue instead of boot-looping. +- "Never touches a confirmed generation" means never *acts on* — the watchdog still *reports*: a confirmed generation whose app fails gets an on-screen advisory naming the recovery hold, so the escape hatch reveals itself exactly when needed and never clutters a healthy boot. +- Known floor: a generation that dies before userspace (kernel/initrd) boot-loops — the watchdog never runs, and neither NixOS nor extlinux offers boot counting. Chosen future fix (backlogged): **U-Boot `bootcount`/`altbootcmd`** — the trial's confirm step resets the counter; exceeding the limit boots the previous generation's entry. Preferred over Raspberry Pi firmware `tryboot`, which works at the config.txt/partition level and would force an A/B boot-chain redesign; bootcount preserves the single-extlinux generation model. diff --git a/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md b/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md new file mode 100644 index 000000000..1bab15257 --- /dev/null +++ b/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md @@ -0,0 +1,14 @@ +# Recovery mode is the update screen in isolation, with a blind-rollback fallback — not a separate minimal tool + +The recovery hold boots a stripped app entry running only the update screen (display + keypad; no camera/solver/positioning), so rescue gets the generation overview and the internet channels from the same code, screen, and install path users already know — one machinery, one set of bugs. Because that screen runs inside the possibly-broken generation, it is a *rung*, not the ladder: if recovery mode fails its own health check, the device falls back to a blind generation rollback to the newest confirmed generation with an on-screen message ([0005](./0005-self-arming-watchdog-confirmed-generations.md)). + +## Considered options + +- **Standalone minimal recovery tool (own renderer, own generation lister), rejected.** Smaller failure domain, but duplicates the generation list, manifest fetch, install trigger, and keypad/display handling — a second UI that rots separately from the real one, for a rung the blind fallback already backstops. +- **Blind rollback only (no interactive mode), rejected.** Works when everything is broken, but forces the newest-confirmed choice on the user; the "it's broken" scenario often wants *a specific* known-good version or a fresh install from a channel. + +## Consequences + +- Selection semantics are **sticky**: choosing a generation sets the boot default (a telescope user in recovery means "go back until I say otherwise"), never a one-shot boot. Internet picks go through the ordinary upgrade flow and face a normal trial. +- Recovery mode inherits the update screen's offline behavior: no network degrades to "rollback only". +- The app's readiness signal (health check) pulls double duty: it is also what decides whether recovery mode itself is alive or the blind fallback fires. diff --git a/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md b/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md new file mode 100644 index 000000000..e509faf4c --- /dev/null +++ b/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md @@ -0,0 +1,13 @@ +# extlinux and kernels live on the ext4 root; the FAT partition is firmware-only + +A custom U-Boot (`CONFIG_CMD_SYSBOOT`) reads `extlinux.conf` and the per-generation kernels from `/boot` **on the ext4 root** (`mmc 0:2`); the FAT partition carries only what the GPU firmware itself must parse — `config.txt`, `start*.elf`, U-Boot, DTBs — written once at install and never touched at runtime. NixOS mutates `/boot` on every generation switch (upgrade, rollback, camera specialisation, watchdog auto-rollback, `set-extlinux-default`), and those rewrites must be crash-safe: ext4 renames are atomic, FAT's are not — a power cut mid-rewrite on FAT can corrupt the one file the boot chain needs, which is unacceptable for a bootloader the watchdog rewrites unattended. + +## Considered options + +- **Everything on FAT (Raspbian convention: firmware loads the kernel directly), rejected.** No atomic rename (crash-unsafe generation switches), no symlinks, and per-generation kernels+initrds (~50MB each, `configurationLimit` of them plus specialisation entries) outgrow a 256MB firmware partition. +- **extlinux on FAT, kernels on ext4, rejected.** Splits one logical unit (the boot menu and what it points at) across filesystems and still leaves the menu rewrite non-atomic. + +## Consequences + +- The partition most fragile to corruption (FAT, which the GPU bootloader must parse) is also the one never written after install. +- The tarball layout follows: its top-level `boot/` is the firmware payload, while `rootfs/boot` is a **populated** directory — anything consuming the tarball must not assume Raspbian's everything-in-FAT layout. The migration initramfs did exactly that (empty-`/boot` assumption + extlinux-on-FAT verifications) and failed the first real migration against this layout (2026-07-05); it now stages the firmware payload aside and verifies each partition for what its boot-chain stage actually needs. diff --git a/docs/ax/nixos/adr/README.md b/docs/ax/nixos/adr/README.md new file mode 100644 index 000000000..b8c6cd6fa --- /dev/null +++ b/docs/ax/nixos/adr/README.md @@ -0,0 +1,9 @@ +# NixOS ADRs + +Architecture-decision records for the **NixOS** context (NixOS build, binary cache, update channels, on-device upgrade/rollback). Numbered locally — `0001`, `0002`, … — independent of the repo-root `docs/adr/`. + +**Why a separate namespace.** These decisions are fork-only (`mrosseel/PiFinder`, the NixOS line) and have no counterpart upstream (`brickbots/PiFinder`). The root `docs/adr/` is shared with upstream and is merged on every sync; putting a fork ADR there means picking a number that will collide with the next upstream ADR, and a rename on the fork is undone/duplicated by the next merge. A context-local namespace keeps fork deploy decisions collision-proof until the NixOS line becomes upstream mainline, at which point these fold into the shared sequence. + +- [0001 — Self-hosted Attic for NixOS binary distribution](./0001-attic-binary-cache.md) +- [0002 — Update channels stay Release-based (stable/beta) over a live main+PR unstable; rollback via reinstall + passive yank](./0002-update-channels-and-rollback.md) +- [0003 — Migration tarball resolves its full system from the update manifest at first boot (rides latest stable), instead of pinning a closure](./0003-migration-tarball-rides-latest-stable.md) 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/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..71564a047 --- /dev/null +++ b/flake.nix @@ -0,0 +1,411 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixos-hardware.url = "github:NixOS/nixos-hardware"; + + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, nixos-hardware, pyproject-nix, uv2nix, pyproject-build-systems, ... }: let + # Flake inputs the python-env module needs, passed via specialArgs. + pythonInputs = { inherit pyproject-nix uv2nix pyproject-build-systems; }; + # Headless config shared by all profiles + headlessModule = { lib, ... }: { + services.xserver.enable = false; + security.polkit.enable = true; + fonts.fontconfig.enable = false; + documentation.enable = false; + documentation.man.enable = false; + documentation.nixos.enable = false; + xdg.portal.enable = false; + services.pipewire.enable = false; + services.pulseaudio.enable = false; + boot.initrd.availableKernelModules = lib.mkForce [ "mmc_block" "usbhid" "usb_storage" "vc4" ]; + }; + + # Shared modules for all PiFinder configurations + commonModules = [ + nixos-hardware.nixosModules.raspberry-pi-4 + ./nixos/hardware.nix + ./nixos/networking.nix + ./nixos/services.nix + ./nixos/python-env.nix + headlessModule + ]; + + # Migration profile — minimal bootable system, full config fetched on first boot + migrationModules = [ + nixos-hardware.nixosModules.raspberry-pi-4 + ./nixos/hardware.nix + ./nixos/networking.nix + ./nixos/wifi-fallback-minimal.nix + ./nixos/device.nix + headlessModule + ]; + + mkPifinderSystem = { includeSDImage ? false }: nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + specialArgs = pythonInputs; + modules = commonModules ++ [ + { pifinder.devMode = false; } + # Camera specialisations — base is imx462 (default), specialisations for others + ({ ... }: { + specialisation = { + imx296.configuration = { pifinder.cameraType = "imx296"; }; + imx477.configuration = { pifinder.cameraType = "imx477"; }; + }; + }) + ({ lib, ... }: { + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" ]; + boot.loader.timeout = 0; + }) + ] ++ nixpkgs.lib.optionals includeSDImage [ + "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix" + ({ config, pkgs, lib, ... }: { + # Catalog images (~5GB compressed) are not baked into the SD image: the + # app fetches per-object images on demand from the CDN (get_images.py) + # and renders a placeholder when one is absent. Shipping only the empty + # data dir keeps the image slim and the build fast. + # + # current-build.json seeds the device's identity with its own store + # path; human version labels come from the update manifest (which maps + # store paths to versions), and every upgrade rewrites this file. + sdImage.populateRootCommands = '' + mkdir -p ./files/home/pifinder/PiFinder_data + mkdir -p ./files/var/lib/pifinder + printf '{"store_path": "%s"}\n' "${config.system.build.toplevel}" \ + > ./files/var/lib/pifinder/current-build.json + ''; + sdImage.populateFirmwareCommands = lib.mkForce '' + (cd ${pkgs.raspberrypifw}/share/raspberrypi/boot && cp bootcode.bin fixup*.dat start*.elf $NIX_BUILD_TOP/firmware/) + + cp ${configTxt} firmware/config.txt + + # Pi3 files + cp ${pkgs.ubootRaspberryPi3_64bit}/u-boot.bin firmware/u-boot-rpi3.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-2-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b-plus.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-cm3.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2-w.dtb firmware/ + + # Pi4 files + cp ${ubootSD}/u-boot.bin firmware/u-boot-rpi4.bin + cp ${pkgs.raspberrypi-armstubs}/armstub8-gic.bin firmware/armstub8-gic.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-4-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-400.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4s.dtb firmware/ + ''; + }) + ] ++ nixpkgs.lib.optionals (!includeSDImage) [ + # Minimal filesystem stub for closure builds (CI) + ({ lib, ... }: { + fileSystems."/" = { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + }; + fileSystems."/boot/firmware" = { + device = "/dev/disk/by-label/FIRMWARE"; + fsType = "vfat"; + }; + }) + ]; + }; + + mkPifinderMigration = { includeSDImage ? false }: nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + modules = migrationModules ++ [ + { pifinder.devMode = false; } + ({ lib, ... }: { + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" ]; + boot.loader.timeout = 0; + }) + ] ++ nixpkgs.lib.optionals includeSDImage [ + "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix" + ({ config, pkgs, lib, ... }: { + sdImage.populateRootCommands = '' + mkdir -p ./files/home/pifinder/PiFinder_data + mkdir -p ./files/var/lib/pifinder + # ADR 0003: last-ditch fallback for first-boot resolution when the + # update manifest is unreachable. The manifest is the primary + # source; this file is otherwise ignored and removed on success. + # (The old closure-based tarball builder used to write it; the + # image-based pipeline must bake it.) + echo "${(mkPifinderSystem {}).config.system.build.toplevel}" \ + > ./files/var/lib/pifinder/first-boot-target + ''; + sdImage.populateFirmwareCommands = lib.mkForce '' + (cd ${pkgs.raspberrypifw}/share/raspberrypi/boot && cp bootcode.bin fixup*.dat start*.elf $NIX_BUILD_TOP/firmware/) + + cp ${configTxt} firmware/config.txt + + # Pi3 files + cp ${pkgs.ubootRaspberryPi3_64bit}/u-boot.bin firmware/u-boot-rpi3.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-2-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-3-b-plus.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-cm3.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2710-rpi-zero-2-w.dtb firmware/ + + # Pi4 files + cp ${ubootSD}/u-boot.bin firmware/u-boot-rpi4.bin + cp ${pkgs.raspberrypi-armstubs}/armstub8-gic.bin firmware/armstub8-gic.bin + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-4-b.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-400.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4.dtb firmware/ + cp ${pkgs.raspberrypifw}/share/raspberrypi/boot/bcm2711-rpi-cm4s.dtb firmware/ + ''; + }) + ] ++ nixpkgs.lib.optionals (!includeSDImage) [ + ({ lib, ... }: { + fileSystems."/" = { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + }; + fileSystems."/boot/firmware" = { + device = "/dev/disk/by-label/FIRMWARE"; + fsType = "vfat"; + }; + }) + ]; + }; + + # Netboot configuration — NFS root, DHCP network in initrd + mkPifinderNetboot = nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + specialArgs = pythonInputs; + modules = commonModules ++ [ + { pifinder.devMode = true; } + { pifinder.cameraType = nixpkgs.lib.mkDefault "imx477"; } # HQ camera for netboot dev + # Camera specialisations for netboot (base is imx477) + ({ ... }: { + specialisation = { + imx296.configuration = { pifinder.cameraType = "imx296"; }; + imx462.configuration = { pifinder.cameraType = "imx462"; }; + }; + }) + ({ lib, pkgs, ... }: + let + boot-splash = import ./nixos/pkgs/boot-splash.nix { inherit pkgs; }; + in { + # Static passwd/group — NFS can't run activation scripts + users.mutableUsers = false; + # DNS for netboot (udhcpc doesn't configure resolvconf properly) + networking.nameservers = [ "192.168.5.1" "8.8.8.8" ]; + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" "nfs" ]; + boot.initrd.supportedFilesystems = [ "nfs" ]; + # Add SPI kernel module for early OLED splash + boot.initrd.kernelModules = [ "spi_bcm2835" ]; + # Override the minimal module list from commonModules — add network drivers + # Note: genet (RPi4 ethernet) is built into the kernel, not a module + boot.initrd.availableKernelModules = lib.mkForce [ + "mmc_block" "usbhid" "usb_storage" "vc4" + ]; + # Add boot-splash to initrd + boot.initrd.extraUtilsCommands = '' + copy_bin_and_libs ${boot-splash}/bin/boot-splash + ''; + # Disable predictable interface names so eth0 works + boot.kernelParams = [ "net.ifnames=0" "biosdevname=0" ]; + boot.initrd.network = { + enable = true; + }; + # Show static splash, then configure network + boot.initrd.postDeviceCommands = '' + # Create device nodes for SPI OLED + mkdir -p /dev + mknod -m 666 /dev/spidev0.0 c 153 0 2>/dev/null || true + mknod -m 666 /dev/gpiochip0 c 254 0 2>/dev/null || true + + # Show static splash image (--static flag = display once and exit) + boot-splash --static || true + # Wait for interface to appear (up to 30 seconds) + echo "Waiting for eth0..." + for i in $(seq 1 60); do + if ip link show eth0 >/dev/null 2>&1; then + echo "eth0 found after $i attempts" + break + fi + sleep 0.5 + done + + ip link set eth0 up + + # Wait for link carrier (cable connected) + echo "Waiting for link carrier..." + for i in $(seq 1 20); do + if [ "$(cat /sys/class/net/eth0/carrier 2>/dev/null)" = "1" ]; then + echo "Link up after $i attempts" + break + fi + sleep 0.5 + done + + # DHCP with retries + echo "Starting DHCP..." + for attempt in 1 2 3; do + if udhcpc -i eth0 -t 5 -T 3 -n -q -s /etc/udhcpc.script; then + echo "DHCP succeeded on attempt $attempt" + break + fi + echo "DHCP attempt $attempt failed, retrying..." + sleep 2 + done + + # Verify we got an IP + if ip addr show eth0 | grep -q "inet "; then + echo "Network configured:" + ip addr show eth0 + else + echo "WARNING: No IP address on eth0!" + ip addr show eth0 + fi + ''; + # NFS root filesystem - NFSv4 with disabled caching for Nix compatibility + fileSystems."/" = { + device = "192.168.5.12:/srv/nfs/pifinder"; + fsType = "nfs"; + options = [ "vers=4" "noac" "actimeo=0" ]; + }; + # Dummy /boot — not used for netboot but NixOS requires it + fileSystems."/boot" = { + device = "none"; + fsType = "tmpfs"; + neededForBoot = false; + }; + }) + ]; + }; + # Custom u-boot variants + pkgsAarch64 = import nixpkgs { system = "aarch64-linux"; }; + # SD boot: skip PCI/USB/net probe, go straight to mmc extlinux + ubootSD = pkgsAarch64.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_CMD_PXE=y + CONFIG_CMD_SYSBOOT=y + CONFIG_BOOTDELAY=0 + CONFIG_PREBOOT="" + CONFIG_BOOTCOMMAND="sysboot mmc 0:2 any 0x02400000 /boot/extlinux/extlinux.conf" + CONFIG_PCI=n + CONFIG_USB=n + CONFIG_CMD_USB=n + CONFIG_CMD_PCI=n + CONFIG_USB_KEYBOARD=n + CONFIG_BCMGENET=n + ''; + }; + # Netboot: PCI + DHCP + PXE + ubootNetboot = pkgsAarch64.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_BOOTCOMMAND="pci enum; dhcp; pxe get; pxe boot" + ''; + }; + + configTxt = pkgsAarch64.writeText "config.txt" '' + [pi3] + kernel=u-boot-rpi3.bin + + [pi02] + kernel=u-boot-rpi3.bin + + [pi4] + kernel=u-boot-rpi4.bin + enable_gic=1 + armstub=armstub8-gic.bin + + disable_overscan=1 + arm_boost=1 + + [cm4] + otg_mode=1 + + [all] + arm_64bit=1 + enable_uart=1 + avoid_warnings=1 + ''; + + 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/migration_gate.json b/migration_gate.json deleted file mode 100644 index 703d93f21..000000000 --- a/migration_gate.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "nixos_for_everyone": false, - "nixos_url": "https://github.com/mrosseel/PiFinder/releases/download/v3.0.0-migration/pifinder-nixos-v3.0.0.tar.zst" -} diff --git a/migration_source/v1.x.x.sh b/migration_source/v1.x.x.sh deleted file mode 100644 index e17ee2c65..000000000 --- a/migration_source/v1.x.x.sh +++ /dev/null @@ -1,48 +0,0 @@ -# GPSD -sudo apt install -y gpsd -sudo dpkg-reconfigure -plow gpsd -sudo cp ~/PiFinder/pi_config_files/gpsd.conf /etc/default/gpsd - -# PWM -sudo sed -zi '/dtoverlay=pwm,pin=13,func=4\n/!s/$/\ndtoverlay=pwm,pin=13,func=4\n/' /boot/config.txt - -# Uart for GPS -sudo sed -zi '/dtoverlay=uart3\n/!s/$/\ndtoverlay=uart3\n/' /boot/config.txt - -# Migrate DB -if [ -f "/home/pifinder/PiFinder/astro_data/observations.db" ] -then - echo "Migrating astro_data DB" - python -c "from PiFinder import setup;setup.create_logging_tables();" - sqlite3 < /home/pifinder/PiFinder/migrate_db.sql - rm /home/pifinder/PiFinder/astro_data/observations.db -fi - -# Migrate Config files -if ! [ -f "/home/pifinder/PiFinder_data/config.json" ] -then - echo "Migrating config.json" - mv /home/pifinder/PiFinder/config.json /home/pifinder/PiFinder_data/config.json -fi - -# Adjust service definition -sudo systemctl disable pifinder -sudo rm /etc/systemd/system/pifinder.service -sudo cp /home/pifinder/PiFinder/pi_config_files/pifinder.service /lib/systemd/system/pifinder.service -sudo systemctl daemon-reload -sudo systemctl enable pifinder - -# add PiFinder_splash if not already in place -if ! [ -f "/lib/systemd/system/pifinder_spash.service" ] -then - sudo cp /home/pifinder/PiFinder/pi_config_files/pifinder_splash.service /lib/systemd/system/pifinder_splash.service - sudo systemctl daemon-reload - sudo systemctl enable pifinder_splash -fi - -# open permissisons on wpa_supplicant file so we can adjust network config -sudo chmod 666 /etc/wpa_supplicant/wpa_supplicant.conf - -# DONE -echo "Post Update Complete" - diff --git a/migration_source/v2.1.0.sh b/migration_source/v2.1.0.sh deleted file mode 100644 index b066c1875..000000000 --- a/migration_source/v2.1.0.sh +++ /dev/null @@ -1,7 +0,0 @@ -# swap tetra3 submodule -git submodule sync -git submodule update --init --recursive - -# Set up symlink -ln -s /home/pifinder/PiFinder/python/PiFinder/tetra3/tetra3 /home/pifinder/PiFinder/python/tetra3 - diff --git a/migration_source/v2.2.1.sh b/migration_source/v2.2.1.sh deleted file mode 100644 index a2c5a38e9..000000000 --- a/migration_source/v2.2.1.sh +++ /dev/null @@ -1,6 +0,0 @@ -# install lib input -sudo apt install -y libinput10 - -# Add PiFinder user to input group -sudo usermod -G input -a "pifinder" - diff --git a/migration_source/v2.2.2.sh b/migration_source/v2.2.2.sh deleted file mode 100644 index 3aa07b7f1..000000000 --- a/migration_source/v2.2.2.sh +++ /dev/null @@ -1,7 +0,0 @@ -# Enable usb-host on usb-c port - -#Add it to the dw2 line if it exist -sudo sed -zi "s/dtoverlay=dwc2\n/dtoverlay=dwc2,dr_mode=host\n/" /boot/config.txt - -#Add the line if it does not exist -sudo sed -zi '/dtoverlay=dwc2,dr_mode=host\n/!s/$/\ndtoverlay=dwc2,dr_mode=host\n/' /boot/config.txt diff --git a/migration_source/v2.4.0.sh b/migration_source/v2.4.0.sh deleted file mode 100644 index 949219087..000000000 --- a/migration_source/v2.4.0.sh +++ /dev/null @@ -1,4 +0,0 @@ -#Add and enable cedar-detect as system process -sudo cp /home/pifinder/PiFinder/pi_config_files/cedar_detect.service /lib/systemd/system/cedar_detect.service -sudo systemctl daemon-reload -sudo systemctl enable cedar_detect diff --git a/migration_source/v2.6.0.sh b/migration_source/v2.6.0.sh deleted file mode 100644 index 65b771c00..000000000 --- a/migration_source/v2.6.0.sh +++ /dev/null @@ -1,6 +0,0 @@ -# Clear stale flop_image=true on the shipped "Generic Dobsonian" default. -# flip/flop are now applied to the object-detail image; a Dobsonian needs -# neither flag, so repair any persisted config that froze the bad default. -# Idempotent and version-gated by pifinder_post_update.sh. See -# docs/adr/0003-object-image-orientation.md. -python /home/pifinder/PiFinder/python/PiFinder/migrations/v2_6_0_dob_flop.py /home/pifinder/PiFinder_data/config.json diff --git a/nixos/RELEASE.md b/nixos/RELEASE.md new file mode 100644 index 000000000..5f45ecba6 --- /dev/null +++ b/nixos/RELEASE.md @@ -0,0 +1,171 @@ +# NixOS Release Process + +How PiFinder NixOS builds are versioned, published, and updated on devices. + +> Not to be confused with the repo-root `RELEASE.md`, which is hand-written release notes for a specific version. This file documents the plumbing. + +## Single Source Of Truth + +``` +update-manifest.json (committed to the metadata-only nixos-manifest branch) + │ + └─ channels[] + ├─ "version": "3.0.0" ← what the device displays + └─ "store_path": "/nix/store/…" ← what the device installs +``` + +Source branches stay source-only. CI writes generated install metadata to the +manifest branch after successful builds and releases. The device fetches the raw +manifest JSON; it does not call the GitHub API and it does not probe branch-head +`pifinder-build.json` files. + +At runtime, `python/PiFinder/utils.py::get_version()` reads +`/var/lib/pifinder/current-build.json` — the device's single identity file. +The image builder seeds it with the system's own store path; the updater +rewrites it (with version/label/channel) on every install. Human version +labels come from the update manifest, which maps store paths to versions. +(`pifinder-build.json` is retired.) + +## Artifacts + +| Artifact | Where | Purpose | +| ------------------------------ | --------------------------------- | -------------------------------------- | +| Release closure on Attic | `cache.pifinder.eu/pifinder-release` | What the device upgrade pulls (retained) | +| `update-manifest.json` | `nixos-manifest` branch | Tells the channel checker what's live | +| Git tag `vX.Y.Z` | GitHub | Marks a release commit | +| GitHub Release | GitHub Releases | Carries the SD image + tarball | +| `pifinder-vX.Y.Z.img.zst` | GitHub Release asset | SD card image for fresh installs | +| `pifinder-migration-vX.Y.Z.tar.zst` | GitHub Release asset | Tarball for in-place migration | + +## Binary caches + +Two self-hosted Attic caches on `cache.pifinder.eu` (ADR 0004): + +| Cache | Pushed by | Retention | Holds | +| ------------------ | -------------------------- | ---------------- | ------------------------------ | +| `pifinder-release` | `release.yml` | never GC'd | tagged release closures | +| `pifinder` | `build.yml` | short (dev GC) | dev + nightly branch builds | + +Release closures go to `pifinder-release` so a device upgrading long after a +release still resolves the store path; dev builds churn through `pifinder`. +Devices subscribe to both (`nixos/services.nix`), release cache first, with +`cache.nixos.org` as the fall-through for upstream paths. `cachix.org` is no +longer used. + +Both caches are declared server-side in nixos-config +(`machines/general-server/attic-service.nix`). To prune the dev cache later, set +retention **per-cache** (`attic cache configure local:pifinder --retention-period +`), never globally — a global retention would also evict `pifinder-release`. + +## Who Writes `update-manifest.json` + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ CI dev build (.github/workflows/build.yml :: update-manifest) │ +│ After a successful build, commits to nixos-manifest only: │ +│ PR → channel=unstable, kind=pr, store_path= │ +│ else→ channel=unstable, kind=trunk, store_path= │ +├──────────────────────────────────────────────────────────────────┤ +│ Release workflow (.github/workflows/release.yml) │ +│ workflow_dispatch with `version: 3.0.0`. Builds, tags, │ +│ publishes the GitHub Release, then updates stable/beta in the │ +│ manifest branch. │ +└──────────────────────────────────────────────────────────────────┘ +``` + +That's it. Generated metadata never lands on the source branch. + +## Update channels + +`python/PiFinder/ui/software.py` (Software-update menu) discovers what to offer: + +| Channel | Source | +| ------- | ---------------------------------------------------------------------- | +| stable | `update-manifest.json` release entries (`kind=release`) | +| beta | `update-manifest.json` prerelease entries (`kind=release`) | +| unstable | `update-manifest.json` trunk + PR entries | + +For each candidate, it reads `version` (to display) and `store_path` (to +install). Entries with `available=false` or invalid store paths are visible but +not installable. + +## Release flow + +``` + workflow_dispatch (Release) + inputs: version=3.0.0, type=stable|beta, source_branch=main, notes=… + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ 1. checkout source_branch │ + │ 2. nix build .#…toplevel → store path A │ + │ 3. nix build .#images.pifinder → SD image embedding A│ + │ (image seeds /var/lib/pifinder/current-build.json │ + │ with store path A; labels resolve via the manifest) │ + │ 5. extract migration tarball from SD image │ + │ 6. attic push A → pifinder-release (retained) │ + │ 7. tag v3.0.0 (or v3.0.0-beta) │ + │ 8. create GitHub Release with SD image + tarball │ + │ 9. update nixos-manifest with store_path A │ + └─────────────────────────────────────────────────────────────┘ +``` + +SD image, tarball, Attic (`pifinder-release`) closure, and manifest entry all +agree on store path A. Devices display `3.0.0`. Channel checker sees `3.0.0` +pointing at A. + +## Dev build flow + +``` + push / testable PR build + │ + ▼ + ┌─────────────────────────────────────────┐ + │ build.yml │ + │ 1. nix build closure (native + emulated) │ + │ 2. attic push → pifinder (dev cache) │ + │ 3. update-manifest job: │ + │ version = "-" or PR │ + │ commit update-manifest.json only │ + │ on nixos-manifest │ + │ 4. (nixos branch only) build migration tarball, │ + │ upload to GitHub Release │ + └─────────────────────────────────────────┘ +``` + +A device installed from the manifest reports the exact manifest version selected. +There is no one-commit lag and no source-branch stamp commit. + +## Cutting a release + +1. Make sure `source_branch` (usually `main` or `nixos`) is at the commit you want to release. +2. GitHub → Actions → **Release** → Run workflow. +3. Inputs: + - `version`: semver only, no `v` prefix — e.g. `3.0.0` + - `notes`: markdown body for the GitHub Release + - `type`: `stable` or `beta` (beta tags as `vX.Y.Z-beta` and marks the release as prerelease) + - `source_branch`: branch to release from (default `main`) +4. Workflow runs end-to-end (~30–45 min). +5. Verify the GitHub Release has both `pifinder-vX.Y.Z.img.zst` and `pifinder-migration-vX.Y.Z.tar.zst`. +6. If the release should hide older entries, update the manifest generator policy + or prune the manifest branch in a follow-up change. + +## Hotfix release + +Use `source_branch=release/X.Y` (long-lived hotfix branches). The release +workflow builds and tags that source branch, then writes install metadata to +`nixos-manifest`. + +## Files of interest + +| File | Role | +| ------------------------------------- | ------------------------------------------ | +| `.github/scripts/update_manifest.py` | Manifest merge/update helper | +| `update-manifest.json` | Generated JSON on `nixos-manifest` | +| `python/PiFinder/utils.py` | `get_version()` reader | +| `python/PiFinder/ui/software.py` | Manifest-driven channel update UI | +| `nixos/pkgs/pifinder-src.nix` | Copies the source tree into the store path | +| `nixos/services.nix` | Symlinks `/home/pifinder/PiFinder` → store path | +| `nixos/device.nix` | `BUILD_JSON_URL` for nightly channel check | +| `.github/workflows/build.yml` | Dev builds + manifest update | +| `.github/workflows/release.yml` | Manual release dispatcher | diff --git a/nixos/brickbots-attic-setup.md b/nixos/brickbots-attic-setup.md new file mode 100644 index 000000000..a43567345 --- /dev/null +++ b/nixos/brickbots-attic-setup.md @@ -0,0 +1,74 @@ +# Giving brickbots/PiFinder access to the Attic cache + +The NixOS CI builds substitute from the self-hosted Attic cache +`cache.pifinder.eu/pifinder` (ADR 0004). There are two levels of access: + +| Access | Needs a token? | Who | Status | +| ------ | -------------- | --- | ------ | +| **Pull** (download prebuilt paths) | No — public, via the cache's public key | everyone, incl. fork PRs | ✅ already wired in the workflows | +| **Push** (upload build results) | **Yes** — `ATTIC_TOKEN` secret | trusted (non-fork) runs only | ⬇️ optional, set up below | + +**Pull already works with no setup.** The workflows configure the public +substituter directly: + +``` +extra-substituters = https://cache.pifinder.eu/pifinder +extra-trusted-public-keys = pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE= +``` + +So brickbots PR builds (and the hosted `ubuntu-24.04-arm` runner) download from +the cache without any secret. GitHub never exposes secrets to **fork** PRs, which +is why push is gated and pull must be tokenless. + +You only need the steps below if you want **brickbots' own CI builds** (pushes to +its `main`/branches, or maintainer-triggered runs) to **upload** their results so +the shared cache stays warm. + +## 1. Mint a push token (mrosseel — cache admin) + +On the Attic server (the cache lives in `nixos-config`, +`machines/general-server/attic-service.nix`): + +```bash +# Scope the token to the `pifinder` cache: pull + push, 1-year validity. +atticd-atticadm make-token \ + --sub "brickbots-ci" \ + --validity "1y" \ + --pull "pifinder" \ + --push "pifinder" +``` + +This prints a JWT. Treat it as a secret. Scope it to **only** the `pifinder` +cache (not `pifinder-release`) so a leak can't poison release closures. + +## 2. Add it as a repo secret (brickbots — maintainer) + +In **github.com/brickbots/PiFinder**: + +1. **Settings → Secrets and variables → Actions → New repository secret** +2. Name: `ATTIC_TOKEN` +3. Value: the JWT from step 1 +4. Save. + +(Use an **organization** secret instead if more than one repo needs it.) + +## 3. That's it + +The workflows already do the right thing once the secret exists: + +- **With `ATTIC_TOKEN`** (brickbots' own branch pushes / trusted runs): the + `Attic login for push` step logs in and the `Push to Attic` step uploads. +- **Without it** (fork PRs): those steps no-op; the build still pulls from the + public cache and is verify-only. + +No workflow edits are required on the brickbots side — the logic keys off whether +the secret is present. + +## Security notes + +- The token is exposed only to non-fork runs, so external contributors' fork PRs + can never push, even after this is set up. +- Rotate by minting a new token and updating the secret; revoke the old one on + the Attic server. +- Keep push scoped to `pifinder` (dev cache). Release closures go to + `pifinder-release` via the separate, mrosseel-only release workflow. diff --git a/nixos/device.nix b/nixos/device.nix new file mode 100644 index 000000000..bdf30d539 --- /dev/null +++ b/nixos/device.nix @@ -0,0 +1,323 @@ +{ config, lib, pkgs, ... }: +let + boot-splash = import ./pkgs/boot-splash.nix { inherit pkgs; }; +in { + options.pifinder = { + devMode = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable development mode (NFS netboot support, etc.)"; + }; + }; + + config = { + # --------------------------------------------------------------------------- + # Minimal system packages for migration troubleshooting + # --------------------------------------------------------------------------- + environment.systemPackages = with pkgs; [ + # nano over vim: 40MB smaller on a size-critical image (2GB boards must + # hold the whole tarball in RAM during migration) + nano + htop + e2fsprogs + dosfstools + parted + file + curl + ]; + + + # --------------------------------------------------------------------------- + # Binary substituters — Pi downloads pre-built paths, never compiles. + # Two Attic caches on cache.pifinder.eu (NixOS ADR 0001): pifinder-release + # (retained release closures) and pifinder (dev/nightly). The first-boot + # download below resolves its target from the update manifest's best available + # channel (NixOS ADR 0003). + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.pifinder.eu/pifinder-release" + "https://cache.pifinder.eu/pifinder" + "https://cache.nixos.org" + ]; + trusted-public-keys = [ + # Attic cache signing keys (same values as nixos/services.nix); pifinder + # restored to the original 8UU after the cutover rotation stranded the + # fleet. Real keys — never ship a placeholder; invalid base64 aborts nix. + "pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE=" + "pifinder-release:WG/Fw1cIX7YpwfWrbWTP5eCzn3bz6AaicW5qKxLKpoM=" + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + ]; + }; + + # Don't pull nixpkgs source into closure (~186 MB) + nix.channel.enable = false; + nix.registry = lib.mkForce {}; + nix.nixPath = lib.mkForce []; + + # nixos-rebuild-ng pulls in Python 3.13 (~110 MB) — not needed for migration + system.disableInstallerTools = true; + + # Perl is included by default (~59 MB) — not needed for migration + environment.defaultPackages = lib.mkForce []; + + # Strip NetworkManager VPN plugins (openconnect/stoken/gtk3 deps) + networking.networkmanager.plugins = lib.mkForce []; + + # --------------------------------------------------------------------------- + # SD card optimizations + # --------------------------------------------------------------------------- + boot.loader.generic-extlinux-compatible.configurationLimit = 2; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 3d"; + }; + nix.settings.auto-optimise-store = true; + + boot.tmp.useTmpfs = true; + boot.tmp.tmpfsSize = "200M"; + + services.journald.extraConfig = '' + Storage=volatile + RuntimeMaxUse=50M + ''; + + zramSwap = { + enable = true; + memoryPercent = 50; + }; + + fileSystems."/" = lib.mkDefault { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + options = [ "noatime" "nodiratime" ]; + }; + + # --------------------------------------------------------------------------- + # Nix DB registration (first boot after migration) + # --------------------------------------------------------------------------- + systemd.services.nix-path-registration = { + description = "Load Nix store path registration from migration"; + after = [ "local-fs.target" ]; + before = [ "nix-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "/nix-path-registration"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix coreutils ]; + script = '' + nix-store --load-db < /nix-path-registration + rm /nix-path-registration + ''; + }; + + # --------------------------------------------------------------------------- + # First boot: download full PiFinder system from the binary cache and switch + # --------------------------------------------------------------------------- + systemd.services.pifinder-first-boot = { + description = "Download full PiFinder NixOS system from the binary cache"; + # time-sync.target ordering pairs with the explicit clock-wait in the + # script below — the Pi has no RTC, and TLS to the binary cache fails + # while the clock is still in the past. + after = [ "network-online.target" "time-sync.target" "nix-path-registration.service" "nix-daemon.service" ]; + wants = [ "time-sync.target" ]; + requires = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + # No existence condition: the manifest is the primary source (ADR 0003) and + # needs no local file. The baked first-boot-target, when present, is only + # the offline fallback — the old ConditionPathExists on it silently skipped + # the whole service when the tarball pipeline stopped baking the file. + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + TimeoutStartSec = "30min"; + }; + path = with pkgs; [ nix coreutils systemd curl jq gnugrep ]; + script = '' + set -euo pipefail + + # Real-progress splash on the OLED, fed via a progress file (0-100). + PROGRESS_FILE=/run/pifinder-boot-progress + echo 0 > "$PROGRESS_FILE" + ${boot-splash}/bin/boot-splash --progress "$PROGRESS_FILE" & + SPLASH_PID=$! + trap 'kill $SPLASH_PID 2>/dev/null || true' EXIT + + # Resolve the full system from the update manifest — the same file the + # on-device updater reads (NixOS ADR 0003). Migration rides the newest entry + # in the best available channel: stable, then beta, then the unstable trunk. + # Stable holds only releases, whose closures live in the retained + # pifinder-release cache, so a resolved stable path can't be GC'd out from + # under a published tarball. Falls back to the baked-in target if the + # manifest can't be fetched. + MANIFEST_URL="https://raw.githubusercontent.com/brickbots/PiFinder/nixos-manifest/update-manifest.json" + STORE_PATH="" + if MANIFEST_JSON=$(curl -sf --max-time 15 "$MANIFEST_URL" 2>/dev/null); then + # jq comma-stream encodes the priority order; first available, valid path + # wins. TEMPORARY: the unstable trunk is pinned to source_ref "nixos" + # because the NixOS line still lives on the nixos branch, not main. Drop + # the source_ref guard once nixos becomes the mainline trunk (ADR 0003). + STORE_PATH=$(printf '%s\n' "$MANIFEST_JSON" | jq -r ' + [ ( .channels.stable[]?, + .channels.beta[]?, + (.channels.unstable[]? | select(.kind == "trunk" and .source_ref == "nixos")) ) + | select(.available == true and ((.store_path // "") | startswith("/nix/store/"))) + | .store_path ] | .[0] // empty' 2>/dev/null) + [ -n "$STORE_PATH" ] && echo "Resolved full system from manifest: $STORE_PATH" + fi + if [ -z "$STORE_PATH" ] || [[ "$STORE_PATH" != /nix/store/* ]]; then + echo "Manifest unavailable or empty, falling back to baked-in target" + [ -f /var/lib/pifinder/first-boot-target ] && \ + STORE_PATH=$(cat /var/lib/pifinder/first-boot-target) + fi + if [ -z "$STORE_PATH" ] || [[ "$STORE_PATH" != /nix/store/* ]]; then + echo "ERROR: No valid store path found" + exit 1 + fi + + # The Pi has no RTC: at cold boot the clock starts in the past, so TLS + # validation against the binary cache fails ("certificate is not yet + # valid") and the download aborts. Wait for timesyncd to fix the clock. + echo "Waiting for clock synchronization..." + for _ in $(seq 1 120); do + [ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)" = yes ] && break + [ -e /run/systemd/timesync/synchronized ] && break + sleep 1 + done + echo "Clock: $(date -u)" + + # First-boot fetches the whole system, so per-path byte sizing would mean + # tens of thousands of cache lookups — too slow. Count the paths to fetch + # (one dry-run, timeout-bounded so it can't hang) and show a path-count + # percentage on the splash. set +e keeps it advisory — never aborts. + echo "Computing download size..." + set +e + TOTAL_PATHS=$(timeout 120 nix-store --realise --dry-run "$STORE_PATH" 2>&1 | grep -c '^ /nix/store/') + [ "$TOTAL_PATHS" -gt 0 ] 2>/dev/null || TOTAL_PATHS=0 + set -e + echo "Downloading full PiFinder system: $STORE_PATH ($TOTAL_PATHS paths)" + + COPIED=0 + nix build "$STORE_PATH" --max-jobs 0 2>&1 | while IFS= read -r line; do + echo "$line" + case "$line" in + *"copying path "*) + COPIED=$((COPIED + 1)) + [ "$TOTAL_PATHS" -gt 0 ] && echo "$((COPIED * 100 / TOTAL_PATHS))" > "$PROGRESS_FILE" + ;; + esac + done + echo 100 > "$PROGRESS_FILE" + + echo "Setting system profile..." + nix-env -p /nix/var/nix/profiles/system --set "$STORE_PATH" + + # Record the device identity: the update UI hides the running build by + # store path (current-build.json), and every later upgrade rewrites this + # file. Label/version/channel come from the manifest entry we resolved; + # a fallback-target install records just the store path. + IDENTITY=$(printf '%s\n' "''${MANIFEST_JSON:-}" | jq -c --arg sp "$STORE_PATH" ' + [ .channels | to_entries[] | .key as $ch | .value[]? + | select(.store_path == $sp) + | {channel: $ch, label: .label, version: .version, store_path: .store_path} ] + | .[0] // empty' 2>/dev/null) + [ -n "$IDENTITY" ] || IDENTITY=$(jq -nc --arg sp "$STORE_PATH" '{store_path: $sp}') + printf '%s\n' "$IDENTITY" > /var/lib/pifinder/current-build.json + + echo "Configuring bootloader..." + "$STORE_PATH/bin/switch-to-configuration" boot + + echo "Removing first-boot trigger..." + rm -f /var/lib/pifinder/first-boot-target + + echo "Cleaning up migration closure..." + nix-env --delete-generations +2 -p /nix/var/nix/profiles/system || true + nix-collect-garbage || true + + echo "Rebooting into full PiFinder system..." + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # Polkit rules for NetworkManager control + # --------------------------------------------------------------------------- + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if (subject.user == "pifinder") { + if (action.id.indexOf("org.freedesktop.NetworkManager") == 0) { + return polkit.Result.YES; + } + if (action.id == "org.freedesktop.login1.reboot" || + action.id == "org.freedesktop.login1.reboot-multiple-sessions" || + action.id == "org.freedesktop.login1.power-off" || + action.id == "org.freedesktop.login1.power-off-multiple-sessions") { + return polkit.Result.YES; + } + } + }); + ''; + + # --------------------------------------------------------------------------- + # Sudoers — minimal for migration + # --------------------------------------------------------------------------- + security.sudo.extraRules = [{ + users = [ "pifinder" ]; + commands = [ + { command = "/run/current-system/sw/bin/shutdown -r now"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/shutdown now"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/hostname *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/avahi-set-host-name *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder-first-boot.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder*"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl status *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; } + ]; + }]; + + # --------------------------------------------------------------------------- + # Early boot splash + # --------------------------------------------------------------------------- + systemd.services.boot-splash = { + description = "Early boot splash screen"; + wantedBy = [ "sysinit.target" ]; + after = [ "systemd-modules-load.service" ]; + wants = [ "systemd-modules-load.service" ]; + unitConfig.DefaultDependencies = false; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "boot-splash-wait" '' + for i in $(seq 1 40); do + [ -e /dev/spidev0.0 ] && exec ${boot-splash}/bin/boot-splash --static + sleep 0.25 + done + echo "SPI device never appeared" >&2 + exit 1 + ''; + }; + }; + + # --------------------------------------------------------------------------- + # SSH access + # --------------------------------------------------------------------------- + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = true; + PermitRootLogin = "no"; + }; + }; + + # NetworkManager-wait-online adds ~10s to boot but is needed for + # pifinder-first-boot to have internet. The first-boot script also has + # its own connectivity retry loop as a fallback. + systemd.services.NetworkManager-wait-online.serviceConfig.TimeoutStartSec = "30s"; + + system.stateVersion = "24.11"; + }; # config +} diff --git a/nixos/hardware.nix b/nixos/hardware.nix new file mode 100644 index 000000000..51eb07d3b --- /dev/null +++ b/nixos/hardware.nix @@ -0,0 +1,179 @@ +{ 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 the on-board GPS (published as /dev/gpsuart by udev) + 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: GPIO 13 (channel 1) keypad backlight + GPIO 12 (channel 0) rev-4 + # buzzer earcons. Both ALT0 (function 4): GPIO12 = PWM0_0, GPIO13 = PWM0_1. + # Muxing GPIO12 unconditionally is safe — rev-3 boards leave it unconnected + # and the sound process only spawns when the rev-4 charger is detected. + pwmDtbo = compileOverlay "pwm" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &gpio { + pwm_pins: pwm_pins { + brcm,pins = <12 13>; + brcm,function = <4 4>; /* ALT0 = PWM0_0, PWM0_1 */ + }; + }; + &pwm { + status = "okay"; + pinctrl-names = "default"; + pinctrl-0 = <&pwm_pins>; + }; + ''; + + # Rev-4 power-off latch (ADR 0007 on main): driving GPIO14 low trips the + # LTC2954 and cuts power. The kernel's gpio-poweroff handler runs strictly + # after filesystems are down, and only on a real power-off (reboot takes a + # different path). Active-low; the board's hardware pull-up on GPIO14 holds + # power on until the handler fires. Deliberately unconditional across + # revisions: on rev-3 GPIO14-low does nothing electrically — the only effect + # is a cosmetic kernel WARN + ~3s wait at halt. + gpioPoweroffDtbo = compileOverlay "gpio-poweroff" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &{/} { + power_ctrl: power_ctrl { + compatible = "gpio-poweroff"; + gpios = <&gpio 14 1>; /* GPIO_ACTIVE_LOW */ + timeout-ms = <3000>; + }; + }; + ''; + + # Camera overlay from kernel's DTB overlays directory + cameraDtbo = "${config.boot.kernelPackages.kernel}/dtbs/overlays/${cameraDriver}.dtbo"; +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"; + + # Firmware: the nixos-hardware Pi 4 module enables the full redistributable + # set — linux-firmware alone is ~723MB uncompressed, 40% of the migration + # tarball, for hardware this board doesn't have. The Pi 4 needs only the + # Broadcom wifi/BT blobs (~10MB). Boot firmware (start.elf etc.) is + # separate and unaffected (populateFirmwareCommands). + hardware.enableRedistributableFirmware = lib.mkForce false; + hardware.firmware = [ pkgs.raspberrypiWirelessFirmware ]; + + # I2C enabled (loads i2c-dev module, creates i2c group) + hardware.i2c.enable = true; + + # GPIO14 is the rev-4 power-off kill line (gpio-poweroff overlay above) — + # its default function is UART0 TXD, so nothing may drive serial console + # bytes onto it (ADR 0007). No console= kernel param points there, and this + # keeps a getty from ever claiming the port. + systemd.services."serial-getty@ttyAMA0".enable = false; + + # Apply all DT overlays via fdtoverlay, bypassing NixOS apply_overlays.py + # which rejects RPi camera overlays due to compatible string mismatch + # (overlays declare "brcm,bcm2835" but kernel DTBs use "brcm,bcm2711") + hardware.deviceTree.package = let + kernelDtbs = config.hardware.deviceTree.dtbSource; + in lib.mkForce (pkgs.runCommand "device-tree-with-overlays" { + nativeBuildInputs = [ pkgs.dtc ]; + } '' + mkdir -p $out/broadcom + for dtb in ${kernelDtbs}/broadcom/*rpi-4-b.dtb; do + fdtoverlay -i "$dtb" \ + -o "$out/broadcom/$(basename $dtb)" \ + ${i2c1Dtbo} ${spi0Dtbo} ${uart3Dtbo} ${pwmDtbo} ${gpioPoweroffDtbo} ${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" + # On-board GPS UART (uart3, fe201600 on BCM2711): the kernel's ttyAMA + # numbering is not stable across versions, so match the DT node and + # publish a fixed name for gpsd to open. + SUBSYSTEM=="tty", KERNELS=="fe201600.serial", SYMLINK+="gpsuart", GROUP="dialout", MODE="0660", TAG+="systemd", ENV{SYSTEMD_WANTS}+="gpsd-add-uart.service" + # DMA heap for libcamera/picamera2 (CMA memory allocation) + SUBSYSTEM=="dma_heap", GROUP="video", MODE="0660" + ''; + + # Deterministic root password (sha-512 crypt of "solveit"), enforced on + # every activation — unlike initialPassword, which only applies at account + # creation and drifts if changed at runtime. Test-device convenience; the + # hash lives in the world-readable store, which is fine for a known cred. + users.users.root.hashedPassword = + "$6$caME5a7TbhnPfrV2$sXHx/OuQCaRkjCG/Lba8vxL5R8.SgD72YHKWzHwDVj9CfDgz1xJ766ht0VCB18Q/igzceaoQM8fwgYNj2ygap/"; + users.users.pifinder = { + isNormalUser = true; + # MUST stay initialPassword (not hashedPassword): the web UI changes this + # password at runtime via `sudo chpasswd` (sys_utils.change_password). + # initialPassword applies only at account creation, so that change + # persists; hashedPassword would re-enforce "solveit" on every activation + # and silently revert the user's password on the next upgrade. + initialPassword = "solveit"; + extraGroups = [ "spi" "i2c" "gpio" "dialout" "video" "networkmanager" "systemd-journal" "input" "kmem" ]; + }; + users.groups = { + spi = {}; + i2c = {}; + gpio = {}; + }; + }; +} diff --git a/nixos/networking.nix b/nixos/networking.nix new file mode 100644 index 000000000..04a9a3ed1 --- /dev/null +++ b/nixos/networking.nix @@ -0,0 +1,154 @@ +{ 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"; + }; + + # The PiFinder-AP profile has autoconnect disabled, so NetworkManager joins + # a known client network when one is reachable and never self-starts the AP. + # AP-as-fallback policy (wired > wifi client > AP) is enforced by + # pifinder-net-policy in services.nix (full system) or by + # wifi-fallback-minimal.nix (migration image, no Python env) — this shared + # module deliberately carries no fallback service so the two can differ. + + # --------------------------------------------------------------------------- + # Avahi/mDNS for hostname discovery (.local). It lives in this shared + # networking module on purpose: BOTH the running system (commonModules) and + # the migration build (migrationModules) import networking.nix, whereas + # services.nix and device.nix are each only in one of those — so avahi must + # not live in either alone or one system ends up with no mDNS at all. + # --------------------------------------------------------------------------- + services.avahi = { + enable = true; + 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..045d64b6b --- /dev/null +++ b/nixos/pkgs/boot-splash.c @@ -0,0 +1,441 @@ +/* + * 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(); +} + +/* Classic 5x7 bitmap font, A-Z + space + '!'. Each glyph is 5 column bytes, + * bit 0 = top row. Enough for the watchdog's failure screen; night-vision red + * like everything else on this display. */ +static const uint8_t font5x7[28][5] = { + {0x7E, 0x11, 0x11, 0x11, 0x7E}, /* A */ + {0x7F, 0x49, 0x49, 0x49, 0x36}, /* B */ + {0x3E, 0x41, 0x41, 0x41, 0x22}, /* C */ + {0x7F, 0x41, 0x41, 0x22, 0x1C}, /* D */ + {0x7F, 0x49, 0x49, 0x49, 0x41}, /* E */ + {0x7F, 0x09, 0x09, 0x09, 0x01}, /* F */ + {0x3E, 0x41, 0x49, 0x49, 0x7A}, /* G */ + {0x7F, 0x08, 0x08, 0x08, 0x7F}, /* H */ + {0x00, 0x41, 0x7F, 0x41, 0x00}, /* I */ + {0x20, 0x40, 0x41, 0x3F, 0x01}, /* J */ + {0x7F, 0x08, 0x14, 0x22, 0x41}, /* K */ + {0x7F, 0x40, 0x40, 0x40, 0x40}, /* L */ + {0x7F, 0x02, 0x0C, 0x02, 0x7F}, /* M */ + {0x7F, 0x04, 0x08, 0x10, 0x7F}, /* N */ + {0x3E, 0x41, 0x41, 0x41, 0x3E}, /* O */ + {0x7F, 0x09, 0x09, 0x09, 0x06}, /* P */ + {0x3E, 0x41, 0x51, 0x21, 0x5E}, /* Q */ + {0x7F, 0x09, 0x19, 0x29, 0x46}, /* R */ + {0x46, 0x49, 0x49, 0x49, 0x31}, /* S */ + {0x01, 0x01, 0x7F, 0x01, 0x01}, /* T */ + {0x3F, 0x40, 0x40, 0x40, 0x3F}, /* U */ + {0x1F, 0x20, 0x40, 0x20, 0x1F}, /* V */ + {0x3F, 0x40, 0x38, 0x40, 0x3F}, /* W */ + {0x63, 0x14, 0x08, 0x14, 0x63}, /* X */ + {0x07, 0x08, 0x70, 0x08, 0x07}, /* Y */ + {0x61, 0x51, 0x49, 0x45, 0x43}, /* Z */ + {0x00, 0x00, 0x00, 0x00, 0x00}, /* space */ + {0x00, 0x00, 0x5F, 0x00, 0x00}, /* ! */ +}; + +static const uint8_t *glyph_for(char c) { + if (c >= 'a' && c <= 'z') c -= 32; + if (c >= 'A' && c <= 'Z') return font5x7[c - 'A']; + if (c == '!') return font5x7[27]; + return font5x7[26]; /* everything else renders as space */ +} + +static void draw_text_centered(int y, const char *s, int scale, uint16_t color) { + int len = (int)strlen(s); + int char_w = 6 * scale; /* 5 columns + 1 spacing */ + int x0 = (WIDTH - len * char_w) / 2; + if (x0 < 0) x0 = 0; + + for (int i = 0; i < len; i++) { + const uint8_t *g = glyph_for(s[i]); + for (int col = 0; col < 5; col++) { + for (int row = 0; row < 7; row++) { + if (!(g[col] >> row & 1)) + continue; + for (int sy = 0; sy < scale; sy++) { + for (int sx = 0; sx < scale; sx++) { + int px = x0 + i * char_w + col * scale + sx; + int py = y + row * scale + sy; + if (px >= 0 && px < WIDTH && py >= 0 && py < HEIGHT) + framebuf[py * WIDTH + px] = color; + } + } + } + } + } +} + +/* Generic message screen: centered lines on black, night-vision red. Lines + * short enough for the big font are drawn at 2x, longer ones at 1x. Used by + * pifinder-watchdog for the update-failure screen; usable by any boot-time + * service that needs to talk to the operator without the app running. */ +static void draw_message(char *const lines[], int nlines) { + memset(framebuf, 0, sizeof(framebuf)); + + /* Pick a scale per line and total the height (7*scale + 5px gap each). */ + int scales[16]; + int total_h = 0; + if (nlines > 16) nlines = 16; + for (int i = 0; i < nlines; i++) { + scales[i] = ((int)strlen(lines[i]) * 12 <= WIDTH) ? 2 : 1; + total_h += 7 * scales[i] + 5; + } + + int y = (HEIGHT - total_h) / 2; + if (y < 0) y = 0; + for (int i = 0; i < nlines; i++) { + draw_text_centered(y, lines[i], scales[i], COL_RED); + y += 7 * scales[i] + 5; + } + 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; + char **message_lines = NULL; + int message_nlines = 0; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--static") == 0) { + static_mode = 1; + } else if (strcmp(argv[i], "--progress") == 0) { + progress_mode = 1; + /* Optional next arg overrides the progress file path */ + if (i + 1 < argc && argv[i + 1][0] != '-') { + progress_path = argv[++i]; + } + } else if (strcmp(argv[i], "--message") == 0) { + /* All remaining args are message lines */ + message_lines = &argv[i + 1]; + message_nlines = argc - i - 1; + break; + } + } + + signal(SIGTERM, signal_handler); + signal(SIGINT, signal_handler); + + 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 (message_nlines > 0) { + /* Message mode: render the lines once and exit, leaving them shown */ + draw_message(message_lines, message_nlines); + hw_cleanup(); + return 0; + } + + if (progress_mode) { + /* Progress mode: render a real bar from the progress file until 100% + * or until signalled. Only flush when the value changes. */ + int last = -1; + while (running) { + int pct = read_progress(progress_path); + if (pct < 0) pct = 0; + if (pct != last) { + draw_progress(pct); + last = pct; + } + if (pct >= 100) break; + msleep(100); + } + hw_cleanup(); + return 0; + } + + /* Animation mode: Knight Rider scanner */ + int pos = 0; + int dir = 1; + int scanner_width = 20; + + while (running) { + draw_scanner(pos, scanner_width); + + pos += dir * 4; /* Speed */ + if (pos >= 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..4ca6346a9 --- /dev/null +++ b/nixos/pkgs/pifinder-src.nix @@ -0,0 +1,65 @@ +{ pkgs, python ? pkgs.python313 }: +let + # Stable astro data — catalogs, star patterns, ephemeris (~193MB, rarely changes) + # hip_main.dat is now committed to astro_data/ upstream, so cp -r picks it up. + astro-data = pkgs.stdenv.mkDerivation { + pname = "pifinder-astro-data"; + version = "1.0"; + src = ../../astro_data; + phases = [ "installPhase" ]; + installPhase = '' + mkdir -p $out + cp -r $src/* $out/ + ''; + }; + + # UI fonts — ~31MB, effectively never change. Own derivation + symlink so they + # are distributed once and not rewritten on every code change. + fonts = pkgs.stdenv.mkDerivation { + pname = "pifinder-fonts"; + version = "1.0"; + src = ../../fonts; + phases = [ "installPhase" ]; + installPhase = '' + mkdir -p $out + cp -r $src/* $out/ + ''; + }; + +in +pkgs.stdenv.mkDerivation { + pname = "pifinder-src"; + version = "0.0.1"; + src = ../..; + + nativeBuildInputs = [ python ]; + phases = [ "installPhase" ]; + + installPhase = '' + mkdir -p $out + + # Copy everything except build artifacts and non-runtime directories + cp -r --no-preserve=mode $src/* $out/ || true + + # Remove directories not needed at runtime + rm -rf $out/.git $out/.github $out/nixos $out/result* $out/.venv + rm -rf $out/case $out/docs $out/gerbers $out/kicad + rm -rf $out/pi_config_files $out/scripts + rm -rf $out/bin + + # Strip doc photos from images/ but keep welcome.png (used at runtime) + find $out/images -type f ! -name 'welcome.png' -delete + + # Bulky, stable inputs live in their own derivations and are symlinked in, + # so a code change rewrites only the (small) code path — not astro-data + # (~193MB) or fonts (~31MB). See ADR 0001. + rm -rf $out/astro_data + ln -s ${astro-data} $out/astro_data + rm -rf $out/fonts + ln -s ${fonts} $out/fonts + + # Pre-compile .pyc bytecode so Python skips compilation at runtime + chmod -R u+w $out/python + python3 -m compileall -q $out/python + ''; +} diff --git a/nixos/pkgs/rpi-gpio-pi-detect.patch b/nixos/pkgs/rpi-gpio-pi-detect.patch new file mode 100644 index 000000000..eebe60114 --- /dev/null +++ b/nixos/pkgs/rpi-gpio-pi-detect.patch @@ -0,0 +1,29 @@ +Make RPi.GPIO board detection fall back to /proc/device-tree/model. + +get_rpi_info() reads the board revision from +/proc/device-tree/system/linux,revision or the /proc/cpuinfo "Revision" +line. On a NixOS Pi 4 (mainline device tree, arm64) neither is present, so +the C module init aborts with "This module can only be run on a Raspberry +Pi!" and any importer (adafruit-blinka -> board -> RPi.GPIO) crashes. Fall +back to the model string, which is always present, and synthesise a Pi 4 +Model B revision code so detection succeeds. + +--- a/source/cpuinfo.c ++++ b/source/cpuinfo.c +@@ -66,6 +66,16 @@ + else + return -1; + fclose(fp); ++ if (!found) { ++ FILE *mp; ++ if ((mp = fopen("/proc/device-tree/model", "r"))) { ++ if (fgets(buffer, sizeof(buffer), mp) && strstr(buffer, "Raspberry Pi")) { ++ found = 1; ++ strcpy(revision, "c03111"); ++ } ++ fclose(mp); ++ } ++ } + + if (!found) + return -1; diff --git a/nixos/pkgs/uv-python.nix b/nixos/pkgs/uv-python.nix new file mode 100644 index 000000000..1994e5ab6 --- /dev/null +++ b/nixos/pkgs/uv-python.nix @@ -0,0 +1,192 @@ +{ 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 = []; }; + }); + + # cedar-solve declares the setuptools.build_meta backend but ships no + # setuptools in its build env, so the sdist build fails with "No module + # named 'setuptools'" — provide the backend like the other legacy packages. + cedar-solve = prev.cedar-solve.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + rpi-gpio = prev.rpi-gpio.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + # RPi.GPIO's C module init aborts with "This module can only be run on a + # Raspberry Pi!" when the board revision is in neither the device tree + # nor /proc/cpuinfo — the case on a mainline-DT arm64 NixOS Pi 4. Without + # this every importer (adafruit-blinka -> board -> RPi.GPIO) crashes and + # the whole app crash-loops. Patch in a /proc/device-tree/model fallback. + postPatch = + (old.postPatch or "") + + '' + patch -p1 < ${./rpi-gpio-pi-detect.patch} + ''; + }); + + # picamera2 installs from a py3-none-any wheel (no source patchPhase to + # hook), so patch the installed module in $out. It imports its DRM (pykms) + # and Qt preview backends unconditionally; the headless device has neither, + # so `import picamera2` dies on a missing 'pykms' and the camera process + # crash-loops. PiFinder only uses NullPreview, so make those optional. + picamera2 = prev.picamera2.overrideAttrs (old: { + postInstall = + (old.postInstall or "") + + '' + f=$(find "$out" -path '*/picamera2/previews/__init__.py' | head -1) + if [ -z "$f" ]; then + echo "picamera2: previews/__init__.py not found under $out" >&2 + exit 1 + fi + echo "picamera2: patching $f" + patch "$f" < ${./picamera2-optional-previews.patch} + ''; + }); + + # No aarch64 wheel, so it builds from sdist on the Pi (fine on x86 via wheel). + timezonefinder = prev.timezonefinder.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + sh = prev.sh.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + spidev = prev.spidev.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + # 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..31a701b44 --- /dev/null +++ b/nixos/services.nix @@ -0,0 +1,744 @@ +{ 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 (NixOS ADR 0001): + # pifinder-release — tagged release closures, never garbage-collected, so a + # device upgrading long after a release still resolves it. + # pifinder — dev/nightly builds, short retention. + # cache.nixos.org serves everything not built locally. + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.pifinder.eu/pifinder-release" + "https://cache.pifinder.eu/pifinder" + "https://cache.nixos.org" + ]; + trusted-public-keys = [ + # Attic cache signing keys. pifinder is the original 8UU key: the S3 + # cutover briefly rotated it (Vkem), but nothing deployed trusted the new + # key so the whole fleet was stranded — the cache and this config were + # restored to 8UU. pifinder-release was minted fresh with the cutover (no + # device trusted a release key before). Real keys — never swap one for a + # placeholder; invalid base64 aborts every nix op and bricks upgrades. + "pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE=" + "pifinder-release:WG/Fw1cIX7YpwfWrbWTP5eCzn3bz6AaicW5qKxLKpoM=" + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + ]; + }; + + # --------------------------------------------------------------------------- + # SD card optimizations + # --------------------------------------------------------------------------- + + # Keep 2 generations max in bootloader + boot.loader.generic-extlinux-compatible.configurationLimit = 2; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 3d"; + }; + # Disable store optimization on NFS (hard links cause issues) + nix.settings.auto-optimise-store = !cfg.devMode; + + boot.tmp.useTmpfs = true; + boot.tmp.tmpfsSize = "200M"; + + services.journald.extraConfig = '' + Storage=volatile + RuntimeMaxUse=50M + ''; + + zramSwap = { + enable = true; + memoryPercent = 50; + }; + + fileSystems."/" = lib.mkDefault { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + options = [ "noatime" "nodiratime" ]; + }; + + # --------------------------------------------------------------------------- + # Tmpfiles — runtime directory for upgrade ref file + # --------------------------------------------------------------------------- + systemd.tmpfiles.rules = [ + "d /run/pifinder 0755 pifinder users -" + ]; + + # --------------------------------------------------------------------------- + # PWM permissions setup for keypad backlight + # --------------------------------------------------------------------------- + systemd.services.pwm-permissions = { + description = "Set PWM sysfs permissions for pifinder"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + # Export PWM channels: 1 (GPIO 13, keypad backlight) and 0 (GPIO 12, + # rev-4 buzzer — harmless no-op wiring on rev-3). + for ch in 0 1; do + if [ ! -d /sys/class/pwm/pwmchip0/pwm$ch ]; then + echo $ch > /sys/class/pwm/pwmchip0/export || true + sleep 0.5 + fi + done + # sysfs doesn't support chgrp, so make files world-writable + chmod 0666 /sys/class/pwm/pwmchip0/export /sys/class/pwm/pwmchip0/unexport + for ch in 0 1; do + if [ -d /sys/class/pwm/pwmchip0/pwm$ch ]; then + chmod 0666 /sys/class/pwm/pwmchip0/pwm$ch/{enable,period,duty_cycle,polarity} + fi + done + # Red PWR LED — the app turns it off for night vision (sys_utils + # set_power_led writes these directly, no sudo). + if [ -d /sys/class/leds/PWR ]; then + chmod 0666 /sys/class/leds/PWR/trigger /sys/class/leds/PWR/brightness + fi + ''; + }; + + # --------------------------------------------------------------------------- + # Nix DB registration (first boot after migration) + # --------------------------------------------------------------------------- + # The migration tarball includes /nix-path-registration with store path data. + # Load it into the Nix DB so nix-store and nixos-rebuild work correctly. + systemd.services.nix-path-registration = { + description = "Load Nix store path registration from migration"; + after = [ "local-fs.target" ]; + before = [ "nix-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "/nix-path-registration"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix coreutils ]; + script = '' + nix-store --load-db < /nix-path-registration + rm /nix-path-registration + ''; + }; + + # --------------------------------------------------------------------------- + # Repair /nix/store ownership before NetworkManager starts + # --------------------------------------------------------------------------- + # NetworkManager (like other security-sensitive plugin loaders) silently + # refuses to load any plugin file not owned by root. Tarball-based migration + # and single-user nix imports can leave /nix/store paths owned by a non-root + # uid; NM then drops its wifi device plugin entirely — wlan0 shows as + # "unmanaged", WIFI-HW as "missing", and no wifi client connection ever comes + # up. Normalise ownership back to root before NM reads its plugins. Idempotent + # and cheap on a clean store (early-exits without touching the ro mount). + systemd.services.fix-nix-store-ownership = { + description = "Normalise /nix/store ownership to root (NM rejects non-root plugins)"; + after = [ "local-fs.target" ]; + before = [ "NetworkManager.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ util-linux findutils coreutils ]; + script = '' + set -u + if [ -z "$(find /nix/store -mindepth 1 -maxdepth 1 ! -uid 0 -print -quit)" ] \ + && [ "$(stat -c %u /nix/var/nix/db)" = 0 ]; then + exit 0 + fi + echo "normalising non-root /nix/store ownership" + # /nix/store is a read-only bind mount of the same device as /. The + # remount MUST carry "bind" so it flips only this mount's per-mount + # ro flag; a plain "remount,ro" would flip the shared superblock and + # take / (and /nix/var) read-only with it. + remounted=0 + if findmnt -no OPTIONS /nix/store | grep -qw ro; then + if mount -o remount,bind,rw /nix/store; then + remounted=1 + else + echo "WARNING: could not remount /nix/store rw; skipping repair" + exit 0 + fi + fi + find /nix/store -mindepth 1 -maxdepth 1 ! -uid 0 -exec chown -R 0:0 {} + || true + chown 0:0 /nix/var/nix/db || true + if [ "$remounted" = 1 ]; then + mount -o remount,bind,ro /nix/store || true + fi + echo "store ownership normalised" + ''; + }; + + # --------------------------------------------------------------------------- + # PiFinder source + data directory setup + # --------------------------------------------------------------------------- + system.activationScripts.pifinder-home = lib.stringAfter [ "users" ] '' + # Create writable data directory + mkdir -p /home/pifinder/PiFinder_data + chown pifinder:users /home/pifinder/PiFinder_data + + # Symlink immutable source tree from Nix store + # Database is opened read-only, so no need for writable copy + PFHOME=/home/pifinder/PiFinder + + # Remove existing directory (not symlink) to allow symlink creation + if [ -e "$PFHOME" ] && [ ! -L "$PFHOME" ]; then + rm -rf "$PFHOME" + fi + + # Create symlink to immutable Nix store path + ln -sfT ${pifinder-src} "$PFHOME" + ''; + + # --------------------------------------------------------------------------- + # Sudoers — pifinder user can start upgrade and restart services + # --------------------------------------------------------------------------- + # Polkit rules for pifinder user (D-Bus hostname changes, NetworkManager) + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if (subject.user == "pifinder") { + // Allow hostname changes via systemd-hostnamed + if (action.id == "org.freedesktop.hostname1.set-static-hostname" || + action.id == "org.freedesktop.hostname1.set-hostname") { + return polkit.Result.YES; + } + // Allow NetworkManager control + if (action.id.indexOf("org.freedesktop.NetworkManager") == 0) { + return polkit.Result.YES; + } + // Allow reboot/shutdown via D-Bus (logind) + if (action.id == "org.freedesktop.login1.reboot" || + action.id == "org.freedesktop.login1.reboot-multiple-sessions" || + action.id == "org.freedesktop.login1.power-off" || + action.id == "org.freedesktop.login1.power-off-multiple-sessions") { + return polkit.Result.YES; + } + } + }); + ''; + + security.sudo.extraRules = [{ + users = [ "pifinder" ]; + commands = [ + { command = "/run/current-system/sw/bin/systemctl start --no-block pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl start pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl reset-failed pifinder-upgrade.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl stop pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl start pifinder.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/systemctl restart avahi-daemon.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/avahi-set-host-name *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/shutdown -r now"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/shutdown now"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/chpasswd"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/hostname *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/pifinder-switch-camera imx296"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/pifinder-switch-camera imx462"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/pifinder-switch-camera imx477"; options = [ "NOPASSWD" ]; } + ]; + }]; + + # --------------------------------------------------------------------------- + # Cedar Detect star detection gRPC server + # --------------------------------------------------------------------------- + systemd.services.cedar-detect = { + description = "Cedar Detect Star Detection Server"; + after = [ "basic.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "idle"; + User = "pifinder"; + ExecStart = "${cedar-detect}/bin/cedar-detect-server --port 50551"; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + + # --------------------------------------------------------------------------- + # Early boot splash — show static welcome image, pifinder overwrites when ready + # --------------------------------------------------------------------------- + systemd.services.boot-splash = { + description = "Early boot splash screen"; + wantedBy = [ "sysinit.target" ]; + after = [ "systemd-modules-load.service" ]; + wants = [ "systemd-modules-load.service" ]; + unitConfig.DefaultDependencies = false; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "boot-splash-wait" '' + for i in $(seq 1 40); do + [ -e /dev/spidev0.0 ] && exec ${boot-splash}/bin/boot-splash --static + sleep 0.25 + done + echo "SPI device never appeared" >&2 + exit 1 + ''; + }; + }; + + # --------------------------------------------------------------------------- + # Main PiFinder application + # --------------------------------------------------------------------------- + systemd.services.pifinder = { + description = "PiFinder"; + after = [ "basic.target" "cedar-detect.service" "gpsd.socket" ]; + wants = [ "cedar-detect.service" "gpsd.socket" ]; + wantedBy = [ "multi-user.target" ]; + path = let + # Runtime paths not in the nix store — symlinks resolve at boot, not build time + wrapperBins = pkgs.runCommand "wrapper-bins" {} '' + mkdir -p $out + ln -s /run/wrappers/bin $out/bin + ''; + systemBins = pkgs.runCommand "system-bins" {} '' + mkdir -p $out + ln -s /run/current-system/sw/bin $out/bin + ''; + in [ wrapperBins systemBins pkgs.gpsd ]; + environment = { + PIFINDER_HOME = "/home/pifinder/PiFinder"; + PIFINDER_DATA = "/home/pifinder/PiFinder_data"; + GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib.out # Use .out to get the main package with typelibs, not glib-bin + pkgs.gobject-introspection + ]; + # libcamera Python bindings for picamera2 + PYTHONPATH = "${pkgs.libcamera}/lib/python3.13/site-packages"; + # libcamera IPA modules path + LIBCAMERA_IPA_MODULE_PATH = "${pkgs.libcamera}/lib/libcamera"; + }; + serviceConfig = { + # The app sends READY=1 once the UI is constructed and drawing + # (utils.sd_notify in main.py). "active" therefore means "the screen is + # live", which is what the boot watchdog's health check keys off — a + # build that starts but never turns the screen on times out, restarts, + # and fails its trial. + Type = "notify"; + # Cold start on a Pi is ~30-60s (imports dominate); leave ample slack. + TimeoutStartSec = 180; + User = "pifinder"; + Group = "users"; + WorkingDirectory = "/home/pifinder/PiFinder/python"; + ExecStart = "${pifinderPythonEnv}/bin/python -m PiFinder.main"; + # Allow binding to privileged ports (80 for web UI) + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + Restart = "on-failure"; + RestartSec = 5; + }; + }; + + # --------------------------------------------------------------------------- + # PiFinder Network Policy + # --------------------------------------------------------------------------- + # Enforces connectivity priority wired > wifi client > AP via libnm + # (PiFinder/net_policy.py). Event-driven on NetworkManager state changes; + # brings the AP up only as an offline fallback and periodically drops an + # idle AP so NM can rejoin a client network. The migration image, which has + # no Python env, uses wifi-fallback-minimal.nix instead. + systemd.services.pifinder-net-policy = { + description = "PiFinder network policy (wired > wifi client > AP)"; + after = [ "NetworkManager.service" ]; + wants = [ "NetworkManager.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ pkgs.iw ]; + environment = { + PIFINDER_DATA = "/home/pifinder/PiFinder_data"; + GI_TYPELIB_PATH = lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib.out + pkgs.gobject-introspection + ]; + }; + serviceConfig = { + WorkingDirectory = "/home/pifinder/PiFinder/python"; + ExecStart = "${pifinderPythonEnv}/bin/python -m PiFinder.net_policy"; + Restart = "always"; + RestartSec = 5; + }; + }; + + # --------------------------------------------------------------------------- + # PiFinder NixOS Upgrade + # --------------------------------------------------------------------------- + # Downloads from binary caches, sets profile, updates bootloader, reboots. + # No live switch-to-configuration — avoids killing running services. + # The pifinder-watchdog handles rollback if the new generation fails to boot. + systemd.services.pifinder-upgrade = { + description = "PiFinder NixOS Upgrade"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + WorkingDirectory = "/home/pifinder/PiFinder/python"; + ExecStart = "${pifinderPythonEnv}/bin/python -m PiFinder.nixos_upgrade --default-camera ${cfg.cameraType}"; + }; + path = with pkgs; [ nix systemd coreutils set-extlinux-default ]; + }; + + # --------------------------------------------------------------------------- + # PiFinder Boot Health Watchdog — self-arming trial/commit + # --------------------------------------------------------------------------- + # A generation is on probation until it has passed a health check once + # (recorded in confirmed-generations). Any boot of an UNCONFIRMED generation + # is a trial — whether or not the (possibly older, marker-unaware) system + # that installed it armed the trial marker. Protection never depends on the + # previous build's code. + # - confirmed generation -> never roll back, so a transient failure in + # the field can't cause a surprise downgrade + # - trial gen healthy -> confirm it + # - trial gen unhealthy -> capture the journal to PiFinder_data (journald + # is volatile to spare the SD card; a failed boot is the one moment worth + # a write), leave a notice the app shows after reboot, show the failure + # splash, roll back (marker hint first, else newest other generation), + # reboot. With no rollback target at all, stay up for rescue instead of + # boot-looping. + systemd.services.pifinder-watchdog = { + description = "PiFinder Boot Health Watchdog"; + after = [ "multi-user.target" "pifinder.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix systemd coreutils jq gnugrep boot-splash ]; + script = '' + set -euo pipefail + MARKER=/var/lib/pifinder/trial-generation.json + CONFIRMED=/var/lib/pifinder/confirmed-generations + DATA=/home/pifinder/PiFinder_data + CURRENT=$(readlink -f /run/current-system) + + is_confirmed() { + [ -f "$CONFIRMED" ] && grep -qxF "$1" "$CONFIRMED" + } + + if is_confirmed "$CURRENT"; then + # Stale marker from an aborted/rolled-back upgrade attempt is harmless + # here but must not survive to a later boot. + rm -f "$MARKER" + # Never ACT on a confirmed generation — but still REPORT (ADR 0005): + # if the app can't start, the frozen splash gets replaced by an + # advisory naming the recovery hold, so the escape hatch reveals + # itself exactly when it is needed. + echo "Generation already confirmed — report-only watch." + for i in $(seq 1 24); do + if systemctl is-active --quiet pifinder.service; then + exit 0 + fi + sleep 5 + done + echo "Confirmed generation's app is not starting — showing recovery advisory (no action taken)." + # The crash-looping app redraws its boot console between restarts, so + # re-assert the advisory periodically (bounded — 30 min, then leave + # the last draw standing) while bailing out if the app recovers. + for i in $(seq 1 60); do + if systemctl is-active --quiet pifinder.service; then + exit 0 + fi + boot-splash --message "PIFINDER" "FAILED TO START" "HOLD SQUARE" "AT POWER ON" "FOR RECOVERY" || true + sleep 30 + done + exit 0 + fi + + echo "Trial boot of unconfirmed generation $CURRENT: waiting up to 120s for pifinder.service..." + for i in $(seq 1 24); do + if systemctl is-active --quiet pifinder.service; then + # Verify it stays running (not crash-looping) + UPTIME=$(systemctl show pifinder.service --property=ExecMainStartTimestamp --value) + START_EPOCH=$(date -d "$UPTIME" +%s 2>/dev/null || echo 0) + NOW_EPOCH=$(date +%s) + RUNNING_FOR=$((NOW_EPOCH - START_EPOCH)) + if [ "$RUNNING_FOR" -ge 15 ]; then + echo "pifinder.service healthy (running ''${RUNNING_FOR}s) — confirming generation." + mkdir -p "$(dirname "$CONFIRMED")" + echo "$CURRENT" >> "$CONFIRMED" + rm -f "$MARKER" + exit 0 + fi + fi + sleep 5 + done + + # ----- unhealthy: pick a rollback target ------------------------------ + # Marker hint (exact pre-upgrade system, specialisation included) first; + # otherwise walk the profile, newest first, skipping any generation that + # boots into this same failed build (directly or via a specialisation) + # and preferring confirmed generations. + TARGET="" + if [ -f "$MARKER" ]; then + HINT=$(jq -r '.previous // empty' "$MARKER" 2>/dev/null || true) + if [ -n "$HINT" ] && [ -e "$HINT" ] && [ "$HINT" != "$CURRENT" ]; then + TARGET="$HINT" + fi + fi + if [ -z "$TARGET" ]; then + FALLBACK="" + for GEN in $(ls -d /nix/var/nix/profiles/system-*-link 2>/dev/null | sort -t- -k2 -rn); do + G=$(readlink -f "$GEN") + [ "$G" = "$CURRENT" ] && continue + SKIP=0 + for S in "$G"/specialisation/*/; do + [ -e "$S" ] || continue + [ "$(readlink -f "$S")" = "$CURRENT" ] && SKIP=1 && break + done + [ "$SKIP" = 1 ] && continue + if is_confirmed "$G"; then + TARGET="$G" + break + fi + [ -z "$FALLBACK" ] && FALLBACK="$G" + done + [ -z "$TARGET" ] && TARGET="$FALLBACK" + fi + + # ----- capture evidence ------------------------------------------------ + echo "ERROR: trial generation unhealthy. Capturing evidence..." + TS=$(date +%Y%m%d-%H%M%S) + mkdir -p "$DATA" + journalctl -b > "$DATA/failed-boot-$TS.log" || true + jq -n --arg failed "$CURRENT" --arg reverted_to "''${TARGET:-none}" --arg at "$TS" \ + '{failed: $failed, reverted_to: $reverted_to, at: $at}' \ + > "$DATA/upgrade_failed.json" || true + chown pifinder:users "$DATA/failed-boot-$TS.log" "$DATA/upgrade_failed.json" 2>/dev/null || true + + # Stop the crash-looping app so the display is free for the failure + # message (and so the reboot is clean). + systemctl stop pifinder.service || true + + if [ -z "$TARGET" ]; then + echo "FATAL: no rollback target exists — staying up for rescue (SSH) instead of boot-looping." + boot-splash --message "UPDATE" "FAILED" "NO ROLLBACK" "USE SSH OR REFLASH" "HOLD SQ AT POWER ON" "FOR RECOVERY" || true + exit 1 + fi + + boot-splash --message "UPDATE" "FAILED" "ROLLING BACK" "PLEASE WAIT" "HOLD SQ AT POWER ON" "FOR RECOVERY" || true + + echo "Rolling back to $TARGET and rebooting..." + rm -f "$MARKER" + # current-build.json was written for the (now failed) generation before + # its reboot; left in place it makes the rolled-back system misreport + # its identity (and the update UI mis-hide entries). Remove it — version + # display falls back to the baked build metadata. + rm -f /var/lib/pifinder/current-build.json + nix-env -p /nix/var/nix/profiles/system --set "$TARGET" + "$TARGET/bin/switch-to-configuration" boot || true + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # GPSD for GPS receiver - full USB hotplug support + # --------------------------------------------------------------------------- + # Don't use services.gpsd module - it doesn't support hotplug. + # Instead, use gpsd's own systemd units with socket activation. + + # Install gpsd's udev rules (25-gpsd.rules) for USB GPS auto-detection + # Includes u-blox 5/6/7/8/9 and many other GPS receivers + services.udev.packages = [ pkgs.gpsd ]; + + # Install gpsd's systemd units (gpsd.service, gpsd.socket, gpsdctl@.service) + systemd.packages = [ pkgs.gpsd ]; + + # Enable socket activation - gpsd starts when something connects to port 2947 + systemd.sockets.gpsd = { + wantedBy = [ "sockets.target" ]; + }; + + # /etc/default/gpsd — same shape as upstream pi_config_files/gpsd.conf. + # DEVICES opens the on-board UART GPS at startup via its stable udev name + # (see hardware.nix — ttyAMA numbering shifts between kernels); USBAUTO lets + # udev hotplug USB GPSes via gpsdctl. GPSD_SOCKET is intentionally omitted — + # gpsd's default (/var/run/gpsd.sock) is already what we want. + environment.etc."default/gpsd".text = '' + DEVICES="/dev/gpsuart" + GPSD_OPTIONS="" + USBAUTO="true" + ''; + + # Ensure gpsd user/group exist (normally created by services.gpsd module) + users.users.gpsd = { + isSystemUser = true; + group = "gpsd"; + description = "GPSD daemon user"; + }; + users.groups.gpsd = {}; + + # Add the on-board UART GPS to gpsd (uart3 overlay, published as + # /dev/gpsuart by udev — platform UARTs are not auto-detected the way USB + # GPSes are). Started by udev via SYSTEMD_WANTS when the device appears + # (see hardware.nix), so a unit without an on-board GPS never starts it + # and USB-only setups still work through USBAUTO hotplug alone. + systemd.services.gpsd-add-uart = { + description = "Add UART GPS to gpsd"; + after = [ "gpsd.socket" "dev-gpsuart.device" ]; + requires = [ "gpsd.socket" ]; + # BindsTo ensures this stops if the GPS UART disappears + bindsTo = [ "dev-gpsuart.device" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.gpsd}/sbin/gpsdctl add /dev/gpsuart"; + ExecStop = "${pkgs.gpsd}/sbin/gpsdctl remove /dev/gpsuart"; + }; + }; + + # --------------------------------------------------------------------------- + # PAM service for PiFinder web UI password verification + # --------------------------------------------------------------------------- + security.pam.services.pifinder = { + # Auth-only: no account/session management (avoids setuid and pam_lastlog2 errors) + allowNullPassword = false; + unixAuth = true; + setLoginUid = false; + updateWtmp = false; + }; + + # --------------------------------------------------------------------------- + # Samba for file sharing (observation data, backups) + # --------------------------------------------------------------------------- + system.stateVersion = "24.11"; + + # --------------------------------------------------------------------------- + # SSH access + # --------------------------------------------------------------------------- + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = true; + PermitRootLogin = "no"; + }; + }; + + # Avahi/mDNS + the PiFinder custom-hostname service live in nixos/device.nix + # (single owner — this block used to be duplicated here and there). + + # Don't block boot waiting for network — NM still works, just async + systemd.services.NetworkManager-wait-online.enable = false; + + services.samba = { + enable = true; + openFirewall = true; + settings = { + global = { + workgroup = "WORKGROUP"; + security = "user"; + "map to guest" = "never"; + }; + PiFinder_data = { + path = "/home/pifinder/PiFinder_data"; + browseable = "yes"; + "read only" = "no"; + "valid users" = "pifinder"; + }; + }; + }; + }; # config +} diff --git a/nixos/wifi-fallback-minimal.nix b/nixos/wifi-fallback-minimal.nix new file mode 100644 index 000000000..5982feae2 --- /dev/null +++ b/nixos/wifi-fallback-minimal.nix @@ -0,0 +1,55 @@ +# Minimal AP fallback for the migration image, which has no Python +# environment. The full system runs pifinder-net-policy (libnm daemon, +# services.nix) instead; this is a stripped-down shell version of the same +# priority — wired > wifi client > AP — good enough for the short-lived +# bootstrap system whose only job is staying reachable until first boot. +{ pkgs, ... }: +{ + systemd.services.pifinder-wifi-fallback = { + description = "Bring up PiFinder AP when offline (migration image)"; + after = [ "NetworkManager.service" ]; + wants = [ "NetworkManager.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ pkgs.networkmanager pkgs.coreutils pkgs.gnugrep ]; + serviceConfig.Type = "oneshot"; + script = '' + modefile=/home/pifinder/PiFinder_data/wifi_mode + + if [ -r "$modefile" ] && [ "$(cat "$modefile")" = "AP" ]; then + nmcli connection up PiFinder-AP || true + exit 0 + fi + + # Wired connectivity is sufficient — never start the AP over it. + eth_up() { + nmcli -t -f TYPE,STATE device | grep -q '^ethernet:connected' + } + # A wifi CLIENT connection. Matching the device state alone would + # count the AP itself as "connected" and make the AP sticky. + wifi_client_up() { + nmcli -t -f TYPE,NAME connection show --active \ + | grep '^802-11-wireless:' \ + | grep -qvx '802-11-wireless:PiFinder-AP' + } + + # Give NetworkManager a grace period to land on something better. + for _ in $(seq 1 45); do + if eth_up || wifi_client_up; then + exit 0 + fi + sleep 1 + done + + nmcli connection up PiFinder-AP || true + ''; + }; + + systemd.timers.pifinder-wifi-fallback = { + description = "Periodically ensure WiFi falls back to AP when offline"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "30s"; + OnUnitActiveSec = "120s"; + }; + }; +} diff --git a/python/DEPENDENCIES.md b/python/DEPENDENCIES.md new file mode 100644 index 000000000..4cd998f32 --- /dev/null +++ b/python/DEPENDENCIES.md @@ -0,0 +1,105 @@ +> **Auto-generated** from the Nix development shell on 2026-02-13. +> Do not edit manually — regenerate with: +> ``` +> nix develop --command ./scripts/generate-dependencies-md.sh +> ``` + +> **Note:** These dependencies are managed by Nix (`nixos/pkgs/python-packages.nix`). +> The versions listed here reflect the nixpkgs pin used by the flake and are +> **not necessarily installable via pip**. Some packages require system libraries +> or hardware (SPI, I2C, GPIO) only available on the Raspberry Pi. + +# Python Dependencies + +Python 3.13.11 + +## Runtime + +| Package | Version | +|---------|---------| +| aiofiles | 24.1.0 | +| attrs | 25.3.0 | +| av | 16.0.1 | +| bottle | 0.13.4 | +| cbor2 | 5.7.0 | +| certifi | 2025.7.14 | +| cffi | 2.0.0 | +| charset-normalizer | 3.4.3 | +| cheroot | 10.0.1 | +| dataclasses-json | 0.6.7 | +| dbus-python | 1.4.0 | +| Deprecated | 1.2.18 | +| evdev | 1.9.2 | +| flatbuffers | 25.9.23 | +| gpsdclient | 1.3.2 | +| grpcio | 1.76.0 | +| h3 | 4.3.1 | +| idna | 3.11 | +| jaraco.functools | 4.2.1 | +| joblib | 1.5.1 | +| jplephem | 2.23 | +| json5 | 0.12.0 | +| jsonpath-ng | 1.7.0 | +| jsonschema | 4.25.0 | +| jsonschema-specifications | 2025.4.1 | +| libarchive-c | 5.3 | +| luma.core | 2.4.2 | +| luma.lcd | 2.11.0 | +| luma.oled | 3.13.0 | +| lz4 | 4.4.4 | +| marshmallow | 3.26.2 | +| more-itertools | 10.7.0 | +| numpy | 2.3.4 | +| pandas | 2.3.1 | +| pillow | 12.1.0 | +| ply | 3.11 | +| protobuf | 6.33.1 | +| psutil | 7.1.2 | +| pycairo | 1.28.0 | +| pycparser | 2.23 | +| pydeepskylog | 1.6 | +| pyftdi | 0.57.1 | +| Pygments | 2.19.2 | +| PyGObject | 3.54.5 | +| PyJWT | 2.10.1 | +| pyserial | 3.5 | +| python-dateutil | 2.9.0.post0 | +| python-libinput | 0.3.0a0 | +| python-pam | 2.0.2 | +| pytz | 2025.2 | +| pyusb | 1.3.1 | +| referencing | 0.36.2 | +| requests | 2.32.5 | +| rpds-py | 0.25.0 | +| scikit-learn | 1.7.1 | +| scipy | 1.16.3 | +| sgp4 | 2.25 | +| sh | 1.14.3 | +| six | 1.17.0 | +| skyfield | 1.53 | +| smbus2 | 0.5.0 | +| spidev | 3.8 | +| threadpoolctl | 3.6.0 | +| timezonefinder | 8.1.0 | +| tqdm | 4.67.1 | +| typing_extensions | 4.15.0 | +| typing_inspect | 0.9.0 | +| tzdata | 2025.2 | +| urllib3 | 2.5.0 | +| wrapt | 1.17.2 | + +## Development only + +| Package | Version | +|---------|---------| +| iniconfig | 2.1.0 | +| luma.emulator | 1.5.0 | +| mypy | 1.17.1 | +| mypy_extensions | 1.1.0 | +| pathspec | 0.12.1 | +| pluggy | 1.6.0 | +| pygame | 2.6.1 | +| PyHotKey | 1.5.2 | +| pynput | 1.8.1 | +| pytest | 8.4.2 | +| python-xlib | 0.33 | diff --git a/python/PiFinder/audit_images.py b/python/PiFinder/audit_images.py index ef37fdb70..e34664ce5 100644 --- a/python/PiFinder/audit_images.py +++ b/python/PiFinder/audit_images.py @@ -44,8 +44,8 @@ def check_object_image(catalog_object): aka_rec = conn.execute( f""" SELECT common_name from names - where catalog = "{catalog_object['catalog']}" - and sequence = "{catalog_object['sequence']}" + where catalog = "{catalog_object["catalog"]}" + and sequence = "{catalog_object["sequence"]}" and common_name like "NGC%" """ ).fetchone() diff --git a/python/PiFinder/auto_exposure.py b/python/PiFinder/auto_exposure.py index 0ac4149c7..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 fa03e8a17..c3a3ffa52 100644 --- a/python/PiFinder/camera_interface.py +++ b/python/PiFinder/camera_interface.py @@ -693,7 +693,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 7fcaa5bae..130988f90 100644 --- a/python/PiFinder/catalog_imports/main.py +++ b/python/PiFinder/catalog_imports/main.py @@ -130,6 +130,14 @@ def main(): conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") conn.execute("PRAGMA journal_mode = DELETE") + # Finalize database for read-only deployment (NixOS) + logging.info("Finalizing database for read-only deployment...") + conn, _ = objects_db.get_conn_cursor() + conn.execute("PRAGMA journal_mode = DELETE") # Required for read-only FS + conn.execute("VACUUM") # Compact database + conn.commit() + logging.info("Database finalization complete") + if __name__ == "__main__": main() diff --git a/python/PiFinder/catalog_imports/specialized_loaders.py b/python/PiFinder/catalog_imports/specialized_loaders.py index e8d68aee1..61fbfdcd5 100644 --- a/python/PiFinder/catalog_imports/specialized_loaders.py +++ b/python/PiFinder/catalog_imports/specialized_loaders.py @@ -612,7 +612,7 @@ def expand(name): for additional in parts[1:]: if additional.isdigit(): # If the additional part is a number, add it directly - expanded_list.append(f"{base_part[:-len(additional)]}{additional}") + expanded_list.append(f"{base_part[: -len(additional)]}{additional}") else: expanded_list.append(additional) else: diff --git a/python/PiFinder/catalog_imports/wds_loader.py b/python/PiFinder/catalog_imports/wds_loader.py index 395f35ec0..983f2f5e8 100644 --- a/python/PiFinder/catalog_imports/wds_loader.py +++ b/python/PiFinder/catalog_imports/wds_loader.py @@ -263,7 +263,7 @@ def handle_multiples(key, values) -> dict: coord_2000 = entry["Coordinates_2000"] coord_arcsec = entry["Coordinates_Arcsec"] logging.error( - f"Empty or invalid RA/DEC detected for WDS object at line {i+1}" + f"Empty or invalid RA/DEC detected for WDS object at line {i + 1}" ) logging.error(f" Coordinates_2000: '{coord_2000}'") logging.error(f" Coordinates_Arcsec: '{coord_arcsec}'") @@ -273,7 +273,7 @@ def handle_multiples(key, values) -> dict: ) logging.error(f" Final RA: {entry['ra']}, DEC: {entry['dec']}") raise ValueError( - f"Invalid RA/DEC coordinates for WDS object at line {i+1}: RA={entry['ra']}, DEC={entry['dec']}" + f"Invalid RA/DEC coordinates for WDS object at line {i + 1}: RA={entry['ra']}, DEC={entry['dec']}" ) # make a dictionary of WDS objects to group duplicates diff --git a/python/PiFinder/comets.py b/python/PiFinder/comets.py index 4621f32a1..ed884f67b 100644 --- a/python/PiFinder/comets.py +++ b/python/PiFinder/comets.py @@ -251,9 +251,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 9d64be2ca..e27892ba7 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.py b/python/PiFinder/gps_ubx.py index dc56bf07e..953014952 100644 --- a/python/PiFinder/gps_ubx.py +++ b/python/PiFinder/gps_ubx.py @@ -32,14 +32,18 @@ async def process_messages( elif msg_class == "NAV-SVINFO" and not got_sat_update: # Fallback satellite info if NAV-SAT not available if "nSat" in msg: - # uSat is also in the message but contains stale info sats_seen = msg["nSat"] + sats_used = msg["uSat"] sats[0] = sats_seen + sats[1] = sats_used gps_queue.put(("satellites", tuple(sats))) - logger.debug("Number of sats (SVINFO) seen: %i", sats_seen) + logger.debug( + "Number of sats (SVINFO) seen: %i, used: %i", sats_seen, sats_used + ) elif msg_class == "NAV-SAT": # Preferred satellite info source - not seen in the current pifinder gps versions + got_sat_update = True sats_seen = msg["nSat"] sats_used = sum( 1 for sat in msg.get("satellites", []) if sat.get("used", False) @@ -95,6 +99,9 @@ async def process_messages( logger.debug(f"TIMEGPS message does not qualify: {msg}") elif msg_class == "NAV-PVT": + if "numSV" in msg: + sats[1] = msg["numSV"] + gps_queue.put(("satellites", tuple(sats))) if all(k in msg for k in ["lat", "lon", "altHAE", "hAcc", "vAcc"]): if not gps_locked and msg["hAcc"] < MAX_GPS_ERROR: gps_locked = True diff --git a/python/PiFinder/gps_ubx_parser.py b/python/PiFinder/gps_ubx_parser.py index f0bb7da40..b6cb24643 100644 --- a/python/PiFinder/gps_ubx_parser.py +++ b/python/PiFinder/gps_ubx_parser.py @@ -14,6 +14,15 @@ logger = logging.getLogger("GPS.parser") +# u-blox quality indicator (qualityInd / flags bits 0-2) scale: 0 no signal, +# 1 searching, 2 signal acquired, 3 signal detected but unusable, 4 code +# locked, 5-7 code and carrier locked. At 1 the receiver is still searching +# and any reported C/N0 is only an estimate for a candidate — counting those +# makes the seen count start near the channel count and sink; counting only +# code-locked (>= 4) makes it flap to zero during marginal re-acquisition. +# "Signal acquired" is the honest definition of a satellite being seen. +QUALITY_SIGNAL_ACQUIRED = 2 + class UBXClass(IntEnum): NAV = 0x01 @@ -159,7 +168,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.""" @@ -312,28 +321,25 @@ def _parse_nav_sat(self, data: bytes) -> dict: gnssId = data[offset] svId = data[offset + 1] cno = data[offset + 2] - elev = data[offset + 3] - azim = int.from_bytes(data[offset + 4 : offset + 6], "little") - flags = data[ - offset + 8 - ] # Warning this is a 4 byte field of flags, we're only using the first byte - # lowest 3 bits are a quality indicator and according to - # https://portal.u-blox.com/s/question/0D52p000097B0bFCAS/interpretation-of-signal-quality-indicator-in-ubxnavsat - # the 0-7 values from an ordered scale. So taking 3 as the threshold below.q - satellites.append( - { - "id": svId, - "system": gnssId, - "signal": cno, - "elevation": elev, - "azimuth": azim, - "used": (flags & 0x07) > 3, # lowest 3 bits are used for the status - "flags": flags & 0x07, - } - ) + elev = int.from_bytes(data[offset + 3 : offset + 4], "little", signed=True) + azim = int.from_bytes(data[offset + 4 : offset + 6], "little", signed=True) + flags = data[offset + 8] # X4 bitfield, only the first byte is needed here: + # bits 0-2 are the quality indicator, bit 3 is svUsed + if cno > 0 and (flags & 0x07) >= QUALITY_SIGNAL_ACQUIRED: + satellites.append( + { + "id": svId, + "system": gnssId, + "signal": cno, + "elevation": elev, + "azimuth": azim, + "used": bool(flags & 0x08), + "quality": flags & 0x07, + } + ) result = { "class": "NAV-SAT", - "nSat": sum(1 for sat in satellites), + "nSat": len(satellites), "satellites": satellites, } logger.debug(f"NAV-SAT result: {result}") @@ -358,18 +364,19 @@ def _parse_nav_svinfo(self, data: bytes) -> dict: logger.warning(f"SVINFO: Message truncated at satellite {i}") break - svid = data[offset] - flags = data[offset + 1] - quality = data[offset + 2] - cno = data[offset + 3] - elev = data[offset + 4] - azim = int.from_bytes(data[offset + 6 : offset + 8], "little") + # Repeated block layout: chn, svid, flags, quality, cno, elev, azim, prRes + svid = data[offset + 1] + flags = data[offset + 2] + quality = data[offset + 3] + cno = data[offset + 4] + elev = int.from_bytes(data[offset + 5 : offset + 6], "little", signed=True) + azim = int.from_bytes(data[offset + 6 : offset + 8], "little", signed=True) is_used = bool(flags & 0x01) if is_used: used_sats += 1 - if cno > 0: + if cno > 0 and quality >= QUALITY_SIGNAL_ACQUIRED: satellites.append( { "id": svid, 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 b8fe1d9fd..18ea26a6b 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 @@ -448,6 +450,19 @@ def main( console.update() logger.info("Starting ....") + # One-shot notice from the boot watchdog: a failed upgrade was + # auto-rolled-back to this (previous) generation. + upgrade_failed_notice = utils.data_dir / "upgrade_failed.json" + if upgrade_failed_notice.exists(): + console.write("!! Update failed") + console.write("!! Rolled back") + console.update() + logger.warning("Previous upgrade failed; watchdog rolled back") + try: + upgrade_failed_notice.unlink() + except OSError: + pass + # spawn gps service.... console.write(" GPS") console.update() @@ -654,6 +669,12 @@ def main( logger.info(" Event Loop") console.update() + # Everything is constructed and the display is live: declare readiness + # to systemd. This is the health signal the boot watchdog keys off — + # a build that dies before this line never reports READY and fails its + # trial. No-op outside systemd (development runs). + utils.sd_notify("READY=1") + # Stop profiling (uncomment to analyze startup performance) # stop_profiling(profiler, startup_profile_start) @@ -1057,11 +1078,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" @@ -1072,13 +1088,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()) @@ -1282,4 +1298,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/net_policy.py b/python/PiFinder/net_policy.py new file mode 100644 index 000000000..4a40ee656 --- /dev/null +++ b/python/PiFinder/net_policy.py @@ -0,0 +1,207 @@ +"""Network policy daemon: wired -> wifi client -> access point. + +Event-driven via NetworkManager's GObject API (libnm) — the same API +sys_utils uses — plus a slow tick for the time-based rules. Replaces the +nmcli-parsing shell fallback, whose "wifi connected" check matched the AP +itself, so once the AP was up nothing ever retried the client network. + +The decision logic lives in net_policy_core (pure, unit-tested); this module +only observes NetworkManager and executes the returned actions. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import time +from pathlib import Path +from typing import Optional + +import gi + +gi.require_version("NM", "1.0") +from gi.repository import GLib, NM # noqa: E402 + +from PiFinder.net_policy_core import ( # noqa: E402 + AP_DOWN, + AP_UP, + PolicyState, + Snapshot, + decide, +) + +logger = logging.getLogger("NetPolicy") + +AP_CONNECTION_NAME = "PiFinder-AP" +WIFI_MODE_FILE = ( + Path(os.environ.get("PIFINDER_DATA", "/home/pifinder/PiFinder_data")) + / "wifi_mode" +) + +# Safety-net re-evaluation cadence; NM signals are the primary trigger. +TICK_SECONDS = 10 +# Collapse bursts of NM signals into one evaluation. +DEBOUNCE_SECONDS = 2 + + +def _read_forced_ap() -> bool: + try: + return WIFI_MODE_FILE.read_text().strip() == "AP" + except OSError: + return False + + +def _count_ap_stations(iface: str) -> int: + """Associated stations on the AP interface, via `iw` (libnm has no API + for AP client lists). Errs toward "occupied" so a counting failure never + causes the retry logic to yank a possibly-used AP.""" + try: + out = subprocess.run( + ["iw", "dev", iface, "station", "dump"], + capture_output=True, + text=True, + timeout=10, + ).stdout + except (OSError, subprocess.SubprocessError): + return 1 + return sum(1 for line in out.splitlines() if line.startswith("Station")) + + +class NetPolicyDaemon: + def __init__(self) -> None: + self._client = NM.Client.new(None) + self._state = PolicyState() + self._debounce_id: Optional[int] = None + + self._client.connect("notify::active-connections", self._on_change) + self._client.connect("device-added", self._on_device_added) + self._client.connect("device-removed", self._on_change) + for dev in self._client.get_devices(): + self._hook_device(dev) + + GLib.timeout_add_seconds(TICK_SECONDS, self._on_tick) + self._evaluate() + + # -- NM signal plumbing -------------------------------------------------- + + def _hook_device(self, dev: NM.Device) -> None: + dev.connect("state-changed", self._on_change) + + def _on_device_added(self, _client, dev) -> None: + self._hook_device(dev) + self._schedule_evaluate() + + def _on_change(self, *_args) -> None: + self._schedule_evaluate() + + def _on_tick(self) -> bool: + self._evaluate() + return True # keep the tick alive + + def _schedule_evaluate(self) -> None: + if self._debounce_id is not None: + GLib.source_remove(self._debounce_id) + self._debounce_id = GLib.timeout_add_seconds( + DEBOUNCE_SECONDS, self._debounced_evaluate + ) + + def _debounced_evaluate(self) -> bool: + self._debounce_id = None + self._evaluate() + return False # one-shot + + # -- state observation --------------------------------------------------- + + def _wifi_iface(self) -> Optional[str]: + for dev in self._client.get_devices(): + if dev.get_device_type() == NM.DeviceType.WIFI: + return dev.get_iface() + return None + + def _snapshot(self) -> Snapshot: + eth_connected = False + wifi_client_active = False + ap_active = False + + for ac in self._client.get_active_connections(): + if ac.get_state() != NM.ActiveConnectionState.ACTIVATED: + continue + conn_type = ac.get_connection_type() + if conn_type == "802-3-ethernet": + eth_connected = True + elif conn_type == "802-11-wireless": + if ac.get_id() == AP_CONNECTION_NAME: + ap_active = True + else: + wifi_client_active = True + + ap_stations = 0 + if ap_active: + iface = self._wifi_iface() + ap_stations = _count_ap_stations(iface) if iface else 1 + + return Snapshot( + forced_ap=_read_forced_ap(), + eth_connected=eth_connected, + wifi_client_active=wifi_client_active, + ap_active=ap_active, + ap_stations=ap_stations, + ) + + # -- actions --------------------------------------------------------------- + + def _evaluate(self) -> None: + snap = self._snapshot() + action = decide(snap, self._state, time.monotonic()) + if action == AP_UP: + logger.info("no connectivity after grace period — bringing AP up") + self._ap_up() + elif action == AP_DOWN: + reason = "wired connectivity present" if snap.eth_connected else ( + "idle AP — retrying client network" + ) + logger.info("%s — bringing AP down", reason) + self._ap_down() + + def _ap_up(self) -> None: + conn = None + for c in self._client.get_connections(): + if c.get_id() == AP_CONNECTION_NAME: + conn = c + break + if conn is None: + logger.error("AP connection %r not found", AP_CONNECTION_NAME) + return + self._client.activate_connection_async( + conn, None, None, None, self._on_action_done, "activate" + ) + + def _ap_down(self) -> None: + for ac in self._client.get_active_connections(): + if ac.get_id() == AP_CONNECTION_NAME: + self._client.deactivate_connection_async( + ac, None, self._on_action_done, "deactivate" + ) + return + + def _on_action_done(self, client, result, verb) -> None: + try: + if verb == "activate": + client.activate_connection_finish(result) + else: + client.deactivate_connection_finish(result) + except GLib.Error as e: + logger.warning("AP %s failed: %s", verb, e.message) + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, format="%(name)s: %(message)s" + ) + NetPolicyDaemon() + GLib.MainLoop().run() + + +if __name__ == "__main__": + main() diff --git a/python/PiFinder/net_policy_core.py b/python/PiFinder/net_policy_core.py new file mode 100644 index 000000000..5b59547bd --- /dev/null +++ b/python/PiFinder/net_policy_core.py @@ -0,0 +1,95 @@ +"""Decision core for the PiFinder network policy daemon. + +Pure logic with no NetworkManager dependency, so it is unit-testable on any +machine. The daemon (net_policy.py) feeds it snapshots of the current network +state and executes the actions it returns. + +Connectivity priority: wired -> wifi client -> access point. The AP is a +fallback for reaching an otherwise-offline device, never a preference: with a +cable plugged in or a client network joined, the AP stays down. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +# How long NetworkManager gets to join a known client network before the AP +# comes up. Also applies after an idle-AP retry drops the AP. +GRACE_SECONDS = 45 + +# While the AP is up with nobody connected to it, drop it this often so NM can +# rescan and rejoin a client network that has come into range. Without this +# the AP is sticky: an active AP counts as "wifi connected", so nothing ever +# retries the client network. +CLIENT_RETRY_SECONDS = 300 + +AP_UP = "ap_up" +AP_DOWN = "ap_down" + + +@dataclass +class Snapshot: + """Point-in-time network state, as observed by the daemon.""" + + forced_ap: bool # operator forced AP mode via PiFinder_data/wifi_mode + eth_connected: bool # an ethernet connection is activated + wifi_client_active: bool # a non-AP wifi connection is activated + ap_active: bool # the PiFinder-AP connection is activated + ap_stations: int # clients associated to the AP (0 when ap inactive) + + +@dataclass +class PolicyState: + """Timing state carried between decisions.""" + + disconnected_since: Optional[float] = None + last_client_retry: Optional[float] = None + + +def decide(snap: Snapshot, state: PolicyState, now: float) -> Optional[str]: + """Return the action to take (AP_UP / AP_DOWN / None), updating `state`. + + `now` is a monotonic timestamp supplied by the caller. + """ + if snap.forced_ap: + state.disconnected_since = None + return None if snap.ap_active else AP_UP + + if snap.eth_connected: + # Wired connectivity is sufficient: the device is reachable and + # online. Dropping the AP frees the radio so NM autoconnects to a + # client network whenever one appears. + state.disconnected_since = None + return AP_DOWN if snap.ap_active else None + + if snap.wifi_client_active: + state.disconnected_since = None + return None + + if snap.ap_active: + # Offline fallback is serving. Periodically drop an *idle* AP so NM + # can retry the client network; the grace path below restores the AP + # if nothing joins. Never yanks the AP away from a connected user. + if state.last_client_retry is None: + state.last_client_retry = now + return None + if ( + snap.ap_stations == 0 + and now - state.last_client_retry >= CLIENT_RETRY_SECONDS + ): + state.last_client_retry = now + state.disconnected_since = now + return AP_DOWN + return None + + # Fully disconnected: give NM a grace period to join a known client + # network, then bring up the AP. + if state.disconnected_since is None: + state.disconnected_since = now + return None + if now - state.disconnected_since >= GRACE_SECONDS: + state.disconnected_since = None + state.last_client_retry = now + return AP_UP + return None diff --git a/python/PiFinder/nixos_upgrade.py b/python/PiFinder/nixos_upgrade.py new file mode 100644 index 000000000..f5eb06568 --- /dev/null +++ b/python/PiFinder/nixos_upgrade.py @@ -0,0 +1,567 @@ +"""NixOS upgrade runner for PiFinder. + +This module is intentionally small and standard-library only. It is launched by +systemd as root, writes the status file consumed by the UI, and guarantees a +terminal status for every non-reboot exit. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import re +import subprocess +import urllib.error +import urllib.request +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +logger = logging.getLogger("PiFinder.nixos_upgrade") + +RUN_DIR = Path("/run/pifinder") +UPGRADE_REF_FILE = RUN_DIR / "upgrade-ref" +UPGRADE_SELECTION_FILE = RUN_DIR / "upgrade-selection.json" +UPGRADE_STATUS_FILE = RUN_DIR / "upgrade-status" +UPGRADE_LOG_FILE = RUN_DIR / "upgrade-nix.log" +CURRENT_BUILD_FILE = Path("/var/lib/pifinder/current-build.json") +CAMERA_TYPE_FILE = Path("/var/lib/pifinder/camera-type") +# Arms pifinder-watchdog: present = the next boot is a trial of an unproven +# generation (roll back on failure); absent = committed system, never touched. +TRIAL_MARKER_FILE = Path("/var/lib/pifinder/trial-generation.json") + +RELEASE_CACHE = "https://cache.pifinder.eu/pifinder-release" +DEV_CACHE = "https://cache.pifinder.eu/pifinder" +CACHES = (DEV_CACHE, RELEASE_CACHE) + +STORE_PATH_RE = re.compile(r"/nix/store/[a-z0-9]+-[A-Za-z0-9._+=?,-]+") + +# nix's --dry-run prints e.g. "(0.0 KiB download, 894.9 MiB unpacked)". Attic +# narinfos carry no compressed FileSize, so the unpacked figure is the only +# whole-download size nix can report; we use it as the progress denominator. +_UNPACKED_RE = re.compile(r"([\d.]+)\s+(B|KiB|MiB|GiB|TiB)\s+unpacked") +_SIZE_UNITS = {"B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3, "TiB": 1024**4} + + +def parse_unpacked_total(dry_output: str) -> int: + m = _UNPACKED_RE.search(dry_output) + if not m: + return 0 + return int(float(m.group(1)) * _SIZE_UNITS[m.group(2)]) + + +class UpgradeError(RuntimeError): + """Generic upgrade failure.""" + + +class UnavailableError(UpgradeError): + """Selected store path is no longer available from configured caches.""" + + +@dataclass(frozen=True) +class ProgressEvent: + action: str + activity_id: int + activity_type: int | None + path: str | None + done: int | None = None + expected: int | None = None + + +@dataclass(frozen=True) +class DownloadEstimate: + paths: tuple[str, ...] + # nix's dry-run "unpacked" byte total (0 if unknown). Per-path byte progress + # streams live from the build's internal-json, so we keep no size map here. + total_bytes: int = 0 + + @property + def path_count(self) -> int: + return len(self.paths) + + +def write_status(status: str, status_file: Path = UPGRADE_STATUS_FILE) -> None: + status_file.parent.mkdir(parents=True, exist_ok=True) + status_file.write_text(status) + + +def valid_store_path(ref: str) -> bool: + return bool(STORE_PATH_RE.fullmatch(ref)) + + +def parse_store_paths(text: str) -> tuple[str, ...]: + return tuple(dict.fromkeys(STORE_PATH_RE.findall(text))) + + +def parse_progress_event(line: str) -> ProgressEvent | None: + if not line.startswith("@nix "): + return None + try: + payload = json.loads(line[5:]) + except json.JSONDecodeError: + return None + + action = payload.get("action") + activity_id = payload.get("id") + if not isinstance(activity_id, int): + return None + + # resProgress (type 105): fields = [done, expected, running, failed]. Used + # for smooth within-path byte progress (summed over copyPath activities). + if action == "result" and payload.get("type") == 105: + fields = payload.get("fields") + if ( + isinstance(fields, list) + and len(fields) >= 2 + and isinstance(fields[0], int) + and isinstance(fields[1], int) + ): + return ProgressEvent( + "result", activity_id, None, None, fields[0], fields[1] + ) + return None + + if action not in ("start", "stop"): + return None + + activity_type = payload.get("type") + if activity_type is not None and not isinstance(activity_type, int): + activity_type = None + + path = None + for value in payload.values(): + if isinstance(value, str): + match = STORE_PATH_RE.search(value) + if match: + path = match.group(0) + break + + return ProgressEvent(action, activity_id, activity_type, path) + + +def command( + args: list[str], + *, + check: bool = True, + timeout: int | None = None, + input_text: str | None = None, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + args, + input=input_text, + capture_output=True, + text=True, + timeout=timeout, + ) + if check and result.returncode != 0: + raise UpgradeError( + f"{args[0]} failed rc={result.returncode}: {result.stderr.strip()}" + ) + return result + + +def path_exists(path: str) -> bool: + return Path(path).exists() + + +# Availability of a build on the binary caches, kept distinct on purpose: a +# cache we cannot reach must never be reported as "the build is gone". +AVAILABLE = "available" +ABSENT = "absent" +UNREACHABLE = "unreachable" + + +def _narinfo_url(store_path: str, cache: str) -> str: + digest = Path(store_path).name.split("-", 1)[0] + return f"{cache.rstrip('/')}/{digest}.narinfo" + + +def classify_store_path( + store_path: str, caches: Iterable[str] = CACHES, timeout: int = 15 +) -> str: + """Decide whether a build is downloadable, gone, or simply unreachable. + + Probes each cache's narinfo over HTTPS so a network failure is never + mistaken for a deleted build: + - AVAILABLE already in the local store, or a cache serves the narinfo + - ABSENT every cache answered and at least one returned 404 — the + build really is gone + - UNREACHABLE no cache could be reached (offline / DNS / cache down), so + availability is unknown and the upgrade should be retried + """ + if path_exists(store_path): + return AVAILABLE + + saw_404 = False + unreachable = False + for cache in caches: + url = _narinfo_url(store_path, cache) + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + if resp.status == 200: + return AVAILABLE + except urllib.error.HTTPError as exc: + if exc.code == 404: + saw_404 = True + else: + # 5xx and friends mean a troubled cache, not a deleted build. + unreachable = True + except (urllib.error.URLError, OSError, TimeoutError): + unreachable = True + + if unreachable: + return UNREACHABLE + return ABSENT if saw_404 else UNREACHABLE + + +def fetch_cache_public_keys( + caches: Iterable[str] = CACHES, timeout: int = 15 +) -> list[str]: + """Fetch each cache's current signing key from its anonymous Attic + cache-config endpoint, so the upgrade trusts whatever key the cache uses + *now*. This makes a cache signing-key rotation invisible to devices — they + can never be stranded by a key change — while signature verification stays + on (verified against the freshly-fetched key, over the same HTTPS trust + boundary as the cache we already pull from). Best-effort: a cache we cannot + reach contributes no key and we fall back to the device's configured keys. + """ + keys: list[str] = [] + for cache in caches: + base, _, name = cache.rstrip("/").rpartition("/") + url = f"{base}/_api/v1/cache-config/{name}" + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + key = json.load(resp).get("public_key") + if key: + keys.append(key) + except Exception as exc: # network / JSON errors are non-fatal + logger.warning("could not fetch cache key from %s: %s", url, exc) + return keys + + +def estimate_download(store_path: str) -> DownloadEstimate: + """Best-effort delta estimate: which paths nix will fetch, plus nix's own + "unpacked" byte total from a dry-run. We deliberately do NOT query per-path + sizes — the real byte progress streams live from the build's internal-json. + An empty estimate must never block the actual build. + """ + try: + dry_result = command( + ["nix-store", "--realise", "--dry-run", store_path], + check=False, + timeout=120, + ) + dry = f"{dry_result.stdout}\n{dry_result.stderr}" + except (subprocess.TimeoutExpired, OSError): + return DownloadEstimate(()) + return DownloadEstimate(parse_store_paths(dry), parse_unpacked_total(dry)) + + +def _short_pkg(path: str | None) -> str: + """A screen-friendly package name from a store path: drop the + /nix/store/- prefix and trim, e.g. + '/nix/store/xxx-python3-3.13.11' -> 'python3-3.13.11'.""" + if not path: + return "" + return path.rsplit("/", 1)[-1].split("-", 1)[-1][:22] + + +class _DownloadProgress: + """Best-effort download progress from nix's internal-json stream. + + Numerator = running sum of bytes copied across copyPath activities (their + resProgress events); denominator = nix's dry-run "unpacked" total. So the + bar moves *within* a path, not only when one finishes — and it names the + package being copied. Status writes are throttled (the stream emits hundreds + of thousands of events). Best-effort throughout: run_build wraps feed() so a + bug here can never abort the upgrade. + """ + + def __init__(self, total_bytes: int, total_paths: int, status_file: Path): + self.total_bytes = total_bytes + self.use_bytes = total_bytes > 0 + self.total_paths = total_paths + self.status_file = status_file + self._active: dict[int, str] = {} # copyPath id -> short label + self._done: dict[int, int] = {} # copyPath id -> bytes copied + self._expected: dict[int, int] = {} # copyPath id -> expected bytes + self._bytes = 0 + self._paths_seen = 0 + self._paths_done = 0 + self._label = "" + self._last_written = -1 + # Only rewrite the status file every ~0.5% of the total (or 1 MiB). + self._step = max(1 << 20, total_bytes // 200) if self.use_bytes else 0 + + def feed(self, line: str) -> None: + event = parse_progress_event(line) + if event is None: + return + if event.action == "result": + self._on_progress(event) + elif event.activity_type == 100: + if event.action == "start": + self._on_start(event) + elif event.action == "stop": + self._on_stop(event) + + def _on_start(self, event: ProgressEvent) -> None: + self._active[event.activity_id] = _short_pkg(event.path) + self._paths_seen += 1 + self._label = self._active[event.activity_id] or self._label + if not self.use_bytes: + self._write_paths() + + def _on_progress(self, event: ProgressEvent) -> None: + aid = event.activity_id + if aid not in self._active: # only copyPath activities we track + return + self._bytes += (event.done or 0) - self._done.get(aid, 0) + self._done[aid] = event.done or 0 + if event.expected: + self._expected[aid] = event.expected + if self.use_bytes: + pct = min(self._bytes, self.total_bytes) + if pct - self._last_written >= self._step: + self._write_bytes() + + def _on_stop(self, event: ProgressEvent) -> None: + aid = event.activity_id + if aid not in self._active: + return + label = self._active.pop(aid) + self._paths_done += 1 + if self.use_bytes: + full = self._expected.get(aid, self._done.get(aid, 0)) + self._bytes += full - self._done.get(aid, 0) + self._done[aid] = full + # show something still in flight, else the path that just finished + self._label = next(iter(self._active.values()), label) or self._label + self._write_bytes() + else: + self._write_paths() + + def _write_bytes(self) -> None: + pct = min(self._bytes, self.total_bytes) + self._last_written = pct + msg = f"downloading {pct}/{self.total_bytes}" + if self._label: + msg += f" {self._label}" + write_status(msg, self.status_file) + + def _write_paths(self) -> None: + denom = self.total_paths or self._paths_seen + write_status(f"downloading {self._paths_done}/{denom} paths", self.status_file) + + +def run_build( + store_path: str, + estimate: DownloadEstimate, + *, + status_file: Path = UPGRADE_STATUS_FILE, + log_file: Path = UPGRADE_LOG_FILE, +) -> int: + if estimate.total_bytes > 0: + write_status(f"downloading 0/{estimate.total_bytes}", status_file) + else: + write_status(f"downloading 0/{estimate.path_count} paths", status_file) + + # Trust the cache's current signing key(s), fetched from the cache itself, + # so a key rotation can never strand this device mid-upgrade. This ADDS to + # the trusted set (verification stays on) — it is not a require-sigs bypass. + build_args = [ + "nix", + "--log-format", + "internal-json", + "build", + store_path, + "--max-jobs", + "0", + "--no-link", + ] + cache_keys = fetch_cache_public_keys() + if cache_keys: + build_args += ["--option", "extra-trusted-public-keys", " ".join(cache_keys)] + + progress = _DownloadProgress(estimate.total_bytes, estimate.path_count, status_file) + tail: deque[str] = deque(maxlen=40) + + process = subprocess.Popen( + build_args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert process.stdout is not None + for line in process.stdout: + tail.append(line.rstrip()) + # Progress is a nice-to-have: never let an accounting bug stall the + # stream (which would deadlock the build) or abort the upgrade. + try: + progress.feed(line) + except Exception: + logger.debug("progress tracking error", exc_info=True) + return_code = process.wait() + + # Persist only a short tail for diagnostics — not the ~800k-line stream. + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + log_file.write_text("\n".join(tail) + "\n") + except OSError: + logger.debug("could not write upgrade log tail", exc_info=True) + + if return_code != 0: + logger.error("nix build failed rc=%s; tail=%s", return_code, list(tail)) + return return_code + + +def load_selection(selection_file: Path = UPGRADE_SELECTION_FILE) -> dict: + try: + with selection_file.open() as f: + data = json.load(f) + if isinstance(data, dict): + return data + except (FileNotFoundError, OSError, json.JSONDecodeError): + pass + return {} + + +def persist_current_build(store_path: str, selection: dict) -> None: + CURRENT_BUILD_FILE.parent.mkdir(parents=True, exist_ok=True) + data = { + "store_path": store_path, + "version": selection.get("version") or selection.get("label") or store_path, + "label": selection.get("label"), + "channel": selection.get("channel"), + } + CURRENT_BUILD_FILE.write_text(json.dumps(data, sort_keys=True) + "\n") + + +def arm_trial_marker(boot_target: Path) -> None: + """Arm the boot-health watchdog for the next boot. + + Records the currently-running (known-good) system and the generation the + next boot is expected to run, so pifinder-watchdog can roll back if that + generation fails its first boot. The watchdog deletes the marker once the + new generation proves healthy (commit); no marker means a committed + system, which is never auto-rolled-back. + + Best-effort: a marker failure must not block the upgrade — it only means + this upgrade proceeds without the automatic safety net. + """ + try: + previous = Path("/run/current-system").resolve() + new = boot_target.resolve() + TRIAL_MARKER_FILE.parent.mkdir(parents=True, exist_ok=True) + TRIAL_MARKER_FILE.write_text( + json.dumps({"previous": str(previous), "new": str(new)}, sort_keys=True) + + "\n" + ) + except OSError as exc: + logger.warning("could not arm trial marker: %s", exc) + + +def activate_system(store_path: str, default_camera: str) -> None: + write_status("activating") + command(["nix-env", "-p", "/nix/var/nix/profiles/system", "--set", store_path]) + + try: + camera = CAMERA_TYPE_FILE.read_text().strip() + except OSError: + camera = default_camera + + if camera and camera != default_camera: + specialisation = Path(store_path) / "specialisation" / camera + if specialisation.is_dir(): + # The specialisation has its own toplevel — arm the watchdog with + # what will actually be running after reboot. + arm_trial_marker(specialisation) + command([str(specialisation / "bin/switch-to-configuration"), "boot"]) + set_extlinux_default(camera) + return + + arm_trial_marker(Path(store_path)) + command([str(Path(store_path) / "bin/switch-to-configuration"), "boot"]) + set_extlinux_default(default_camera) + + +def set_extlinux_default(camera: str) -> None: + """Point the extlinux DEFAULT at the selected camera's boot entry. + + Device-tree overlays load only at boot, and the generic-extlinux builder + rewrites DEFAULT to the base camera on every activation — so without this an + upgrade would reboot into the base camera's DTB regardless of the device's + chosen camera. Best-effort: the helper leaves a bootable DEFAULT in place if + the entry is missing, so a hiccup here never blocks the upgrade. + """ + command(["set-extlinux-default", camera], check=False) + + +def cleanup_old_generations() -> None: + # Keep the 3 newest generations: current + 2 rollback targets (surfaced in + # the Software screen's Rollback channel). + command( + ["nix-env", "--delete-generations", "+3", "-p", "/nix/var/nix/profiles/system"], + check=False, + ) + command(["nix-collect-garbage"], check=False) + + +def run_upgrade(ref_file: Path, default_camera: str) -> int: + terminal = False + selected_unavailable = False + try: + write_status("starting") + store_path = ref_file.read_text().strip() + if not valid_store_path(store_path): + raise UpgradeError(f"invalid store path: {store_path!r}") + + estimate = estimate_download(store_path) + build_rc = run_build(store_path, estimate) + if build_rc != 0: + availability = classify_store_path(store_path) + if availability == ABSENT: + selected_unavailable = True + write_status("unavailable") + terminal = True + raise UnavailableError( + f"{store_path} is no longer on configured caches" + ) + if availability == UNREACHABLE: + # Couldn't reach the caches to download — a connection problem, + # not a missing build. Retryable, so don't claim it's gone. + write_status("connfail") + terminal = True + raise UpgradeError(f"caches unreachable for {store_path}") + raise UpgradeError(f"nix build failed rc={build_rc}") + + selection = load_selection() + activate_system(store_path, default_camera) + persist_current_build(store_path, selection) + cleanup_old_generations() + write_status("rebooting") + command(["systemctl", "reboot"]) + terminal = True + return 0 + except UnavailableError: + return 1 + except Exception as exc: + logger.exception("upgrade failed: %s", exc) + if not terminal and not selected_unavailable: + write_status("failed") + return 1 + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--ref-file", type=Path, default=UPGRADE_REF_FILE) + parser.add_argument("--default-camera", default="imx462") + args = parser.parse_args(list(argv) if argv is not None else None) + logging.basicConfig(level=logging.INFO) + return run_upgrade(args.ref_file, args.default_camera) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/PiFinder/plot.py b/python/PiFinder/plot.py index 265ae122c..7cf1094cb 100644 --- a/python/PiFinder/plot.py +++ b/python/PiFinder/plot.py @@ -426,9 +426,9 @@ def render_starfield_pil( # Keep edges where at least one endpoint is on-screen. start_on = (sx_pos > 0) & (sx_pos < W) & (sy_pos > 0) & (sy_pos < H) end_on = (ex_pos > 0) & (ex_pos < W) & (ey_pos > 0) & (ey_pos < H) - for i in np.flatnonzero(start_on | end_on): + for edge_i in np.flatnonzero(start_on | end_on): idraw.line( - [sx_pos[i], sy_pos[i], ex_pos[i], ey_pos[i]], + [sx_pos[edge_i], sy_pos[edge_i], ex_pos[edge_i], ey_pos[edge_i]], fill=constellation_brightness, ) diff --git a/python/PiFinder/server.py b/python/PiFinder/server.py index cc943e178..f1ec67d8f 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -100,7 +100,7 @@ def __init__( shared_state=None, is_debug=False, ): - self.version_txt = f"{utils.pifinder_dir}/version.txt" + self._software_version = utils.get_version() self.keyboard_queue = keyboard_queue or multiprocessing.Queue() self.ui_queue = ui_queue or multiprocessing.Queue() self.gps_queue = gps_queue or multiprocessing.Queue() @@ -201,12 +201,8 @@ def send_css(filename): def home(): # logger.debug("/ called") # Get version info - software_version = "Unknown" - try: - with open(self.version_txt, "r") as ver_f: - software_version = ver_f.read() - except (FileNotFoundError, IOError) as e: - logger.warning(f"Could not read version file: {str(e)}") + + software_version = self._software_version # Try to update GPS state try: @@ -244,7 +240,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, @@ -522,7 +518,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 @@ -680,7 +683,7 @@ def equipment_import(): try: cfg.equipment.eyepieces.index(new_eyepiece) except ValueError: - cfg.equipment.eyepieces.add_eyepiece(new_eyepiece) + cfg.equipment.eyepieces.append(new_eyepiece) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -727,13 +730,13 @@ def equipment_add_eyepiece(eyepiece_id: int): ) if eyepiece_id >= 0: - cfg.equipment.update_eyepiece(eyepiece_id, eyepiece) + cfg.equipment.eyepieces[eyepiece_id] = eyepiece else: try: index = cfg.equipment.telescopes.index(eyepiece) - cfg.equipment.update_eyepiece(index, eyepiece) + cfg.equipment.eyepieces[index] = eyepiece except ValueError: - cfg.equipment.add_eyepiece(eyepiece) + cfg.equipment.eyepieces.append(eyepiece) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -1014,23 +1017,16 @@ def remove_file(response): @app.route("/logs/configs") @auth_required def list_log_configs(): - """Return all available logconf_*.json files with display names.""" - import glob - + """Return all available logconf_*.json presets with display names.""" + active = utils.active_logconf_name() configs = [] - active = ( - os.path.realpath("pifinder_logconf.json") - if os.path.exists("pifinder_logconf.json") - else None - ) - for path in sorted(glob.glob("logconf_*.json")): - stem = path[len("logconf_") : -len(".json")] - display = stem.replace("_", " ").title() + for name in utils.available_logconfs(): + stem = name[len("logconf_") : -len(".json")] configs.append( { - "file": path, - "name": display, - "active": os.path.realpath(path) == active, + "file": name, + "name": stem.replace("_", " ").title(), + "active": name == active, } ) return jsonify({"configs": configs}) @@ -1038,29 +1034,15 @@ def list_log_configs(): @app.route("/logs/switch_config", methods=["POST"]) @auth_required def switch_log_config(): - """Atomically repoint pifinder_logconf.json to the chosen config, then restart.""" + """Persist the chosen log config to the data dir, then restart.""" logconf_file = request.form.get("logconf_file", "").strip() - if ( - not logconf_file - or not logconf_file.startswith("logconf_") - or not logconf_file.endswith(".json") - ): + try: + utils.set_active_logconf(logconf_file) + logger.info("Switched log config to %s", logconf_file) + except (ValueError, FileNotFoundError): return jsonify( {"status": "error", "message": "Invalid log config file name"} ) - if not os.path.exists(logconf_file): - return jsonify( - { - "status": "error", - "message": f"Log config file not found: {logconf_file}", - } - ) - try: - link = "pifinder_logconf.json" - tmp = link + ".tmp" - os.symlink(logconf_file, tmp) - os.replace(tmp, link) - logger.info("Switched log config to %s", logconf_file) except Exception as e: logger.error("Failed to switch log config: %s", e) return jsonify({"status": "error", "message": str(e)}) diff --git a/python/PiFinder/solver.py b/python/PiFinder/solver.py index b3ce6f242..54a51aae0 100644 --- a/python/PiFinder/solver.py +++ b/python/PiFinder/solver.py @@ -13,7 +13,6 @@ import numpy as np import time import logging -import sys from time import perf_counter as precision_timestamp import os import platform @@ -41,7 +40,6 @@ SuccessfulSolve, ) -sys.path.append(str(utils.tetra3_dir)) import tetra3 from tetra3 import cedar_detect_client @@ -438,7 +436,9 @@ def solver( ): MultiprocLogging.configurer(log_queue) logger.debug("Starting Solver") - t3 = tetra3.Tetra3(str(utils.tetra3_dir / "data" / "default_database.npz")) + # Load tetra3's bundled pattern database by name; tetra3 resolves it from + # its own package data dir (shipped inside the cedar-solve wheel). + t3 = tetra3.Tetra3("default_database") align_ra = 0 align_dec = 0 last_solve_attempt: float = 0.0 diff --git a/python/PiFinder/splash.py b/python/PiFinder/splash.py index 9346ebef2..2de2988eb 100644 --- a/python/PiFinder/splash.py +++ b/python/PiFinder/splash.py @@ -13,7 +13,7 @@ import os from PIL import Image, ImageDraw from PiFinder import displays -from PiFinder import hardware_detect +from PiFinder import hardware_detect, utils import numpy as np @@ -46,8 +46,7 @@ def show_splash(): screen_draw = ImageDraw.Draw(welcome_image) # Display version and Wifi mode in a top banner spanning the panel width - with open(os.path.join(root_dir, "version.txt"), "r") as ver_f: - version = "v" + ver_f.read() + version = utils.get_version() with open(os.path.join(root_dir, "wifi_status.txt"), "r") as wifi_f: wifi_mode = wifi_f.read() diff --git a/python/PiFinder/sqm/sqm.ipynb b/python/PiFinder/sqm/sqm.ipynb index 490d27c57..8d37e393d 100644 --- a/python/PiFinder/sqm/sqm.ipynb +++ b/python/PiFinder/sqm/sqm.ipynb @@ -32,9 +32,11 @@ "import logging as logger\n", "from pathlib import Path\n", "import matplotlib.pyplot as plt\n", + "\n", "%matplotlib inline\n", "import pprint\n", - "pp = pprint.PrettyPrinter(depth=5)\n" + "\n", + "pp = pprint.PrettyPrinter(depth=5)" ] }, { @@ -70,33 +72,28 @@ } ], "source": [ - "os.chdir('/Users/mike/dev/amateur_astro/myPiFinder/wt-sqm/python')\n", + "os.chdir(\"/Users/mike/dev/amateur_astro/myPiFinder/wt-sqm/python\")\n", "cwd = Path(os.getcwd())\n", "print(cwd)\n", - "tetra3_path = cwd / \"PiFinder/tetra3/tetra3\"\n", - "root_path = cwd / '..'\n", - "\n", - "# Add it only once if it's not already there\n", - "if str(tetra3_path) not in sys.path:\n", - " sys.path.append(str(tetra3_path))\n", + "root_path = cwd / \"..\"\n", "\n", "# Silence tetra3 DEBUG output BEFORE importing tetra3\n", "import logging\n", + "\n", "logging.basicConfig(level=logging.WARNING)\n", - "logging.getLogger('tetra3.Tetra3').setLevel(logging.WARNING)\n", - "logging.getLogger('Solver').setLevel(logging.WARNING)\n", + "logging.getLogger(\"tetra3.Tetra3\").setLevel(logging.WARNING)\n", + "logging.getLogger(\"Solver\").setLevel(logging.WARNING)\n", "\n", "# Now try importing\n", "\n", "\n", - "import PiFinder.tetra3.tetra3 as tetra3\n", - "from PiFinder.tetra3.tetra3 import cedar_detect_client\n", + "import tetra3\n", + "from tetra3 import cedar_detect_client\n", "from PiFinder import utils\n", + "\n", "os_detail, platform, arch = utils.get_os_info()\n", "\n", - "t3 = tetra3.Tetra3(\n", - " str(tetra3_path / \"data/default_database.npz\")\n", - ")\n", + "t3 = tetra3.Tetra3(\"default_database\")\n", "\n", "logger.info(\"Starting Solver Loop\")\n", "# Start cedar detect server\n", @@ -160,26 +157,26 @@ "outputs": [], "source": [ "images = {\n", - " 'sqm1833.png': {'realsqm': 18.33},\n", - " 'sqm1837.png': {'realsqm': 18.37},\n", - " 'sqm1845.png': {'realsqm': 18.45},\n", - " 'sqm1855.png': {'realsqm': 18.55},\n", - " 'sqm1860.png': {'realsqm': 18.60},\n", - " 'sqm1870.png': {'realsqm': 18.70},\n", - " 'sqm1980.png': {'realsqm': 19.80},\n", - " 'sqm2000_0.8-4.png': {'realsqm': 20.00},\n", - " 'sqm2000_0.8-3.png': {'realsqm': 20.00},\n", - " 'sqm1818_raw_new_0.2.png': {'realsqm': 18.18}, \n", - " 'sqm1818_raw_new_1.png': {'realsqm': 18.18}\n", + " \"sqm1833.png\": {\"realsqm\": 18.33},\n", + " \"sqm1837.png\": {\"realsqm\": 18.37},\n", + " \"sqm1845.png\": {\"realsqm\": 18.45},\n", + " \"sqm1855.png\": {\"realsqm\": 18.55},\n", + " \"sqm1860.png\": {\"realsqm\": 18.60},\n", + " \"sqm1870.png\": {\"realsqm\": 18.70},\n", + " \"sqm1980.png\": {\"realsqm\": 19.80},\n", + " \"sqm2000_0.8-4.png\": {\"realsqm\": 20.00},\n", + " \"sqm2000_0.8-3.png\": {\"realsqm\": 20.00},\n", + " \"sqm1818_raw_new_0.2.png\": {\"realsqm\": 18.18},\n", + " \"sqm1818_raw_new_1.png\": {\"realsqm\": 18.18},\n", "}\n", "\n", "#\n", "# {\n", - "# 'sqmbla.png' : {'realsqm': 18.44, \n", + "# 'sqmbla.png' : {'realsqm': 18.44,\n", "#\n", "#\n", - "#images = {'sqm1833.png': images['sqm1833.png']}\n", - "#images = {'sqm1837.png': images['sqm1837.png']}" + "# images = {'sqm1833.png': images['sqm1833.png']}\n", + "# images = {'sqm1837.png': images['sqm1837.png']}" ] }, { @@ -197,7 +194,7 @@ "metadata": {}, "outputs": [], "source": [ - "def load_image(current_image, image_path = Path('../test_images/')):\n", + "def load_image(current_image, image_path=Path(\"../test_images/\")):\n", " img = Image.open(image_path / current_image)\n", " rgb_np_image = np.asarray(img, dtype=np.uint8)\n", " np_image = rgb_np_image[:, :, 0] # Takes just the red values\n", @@ -205,16 +202,18 @@ " # np_image = ((stretched - stretched.min()) * (255.0/(stretched.max() - stretched.min()))).astype(np.uint8)\n", " return np_image, img\n", "\n", + "\n", "def show_image(image):\n", - " plt.imshow(image, cmap='gray')\n", + " plt.imshow(image, cmap=\"gray\")\n", " plt.title(\"Test image\")\n", " plt.colorbar()\n", - " plt.show() \n", + " plt.show()\n", + "\n", "\n", "# To use just one specific method:\n", "def percentile_stretch(image, name, low=5, high=99):\n", " p_low, p_high = np.percentile(image, (low, high))\n", - " plt.imshow(image, cmap='gray', vmin=p_low, vmax=p_high)\n", + " plt.imshow(image, cmap=\"gray\", vmin=p_low, vmax=p_high)\n", " plt.title(name)\n", " plt.colorbar()\n", " plt.show()" @@ -536,7 +535,7 @@ "for filename in images:\n", " print(f\"{filename}\")\n", " np_image, image = load_image(filename)\n", - " images[filename]['np_image'] = np_image\n", + " images[filename][\"np_image\"] = np_image\n", " show_image(np_image)\n", " percentile_stretch(np_image, filename)" ] @@ -592,10 +591,10 @@ " fov_max_error=4.0,\n", " match_max_error=0.005,\n", " return_matches=True,\n", - " target_pixel=(128,128),\n", + " target_pixel=(128, 128),\n", " solve_timeout=1000,\n", " )\n", - " \n", + "\n", " if \"matched_centroids\" in solution:\n", " # Don't clutter printed solution with these fields.\n", " # del solution['matched_centroids']\n", @@ -607,13 +606,16 @@ " del solution[\"cache_hit_fraction\"]\n", " return centroids, solution\n", "\n", - "for key, value in images.items(): \n", - " centroids, solution = detect(value['np_image'])\n", - " value['centroids'] = centroids # Store ALL detected centroids\n", - " value['matched_stars'] = solution['matched_stars']\n", - " value['matched_centroids'] = solution['matched_centroids']\n", - " value['fov'] = solution['FOV']\n", - " print(f\"For {key}, there are {len(value['matched_stars'])} matched_stars and {len(centroids)} total centroids\")" + "\n", + "for key, value in images.items():\n", + " centroids, solution = detect(value[\"np_image\"])\n", + " value[\"centroids\"] = centroids # Store ALL detected centroids\n", + " value[\"matched_stars\"] = solution[\"matched_stars\"]\n", + " value[\"matched_centroids\"] = solution[\"matched_centroids\"]\n", + " value[\"fov\"] = solution[\"FOV\"]\n", + " print(\n", + " f\"For {key}, there are {len(value['matched_stars'])} matched_stars and {len(centroids)} total centroids\"\n", + " )" ] }, { @@ -632,11 +634,11 @@ "outputs": [], "source": [ "def enhance_centroids(value: dict):\n", - " matched_centroids = value['matched_centroids']\n", - " matched_stars = value['matched_stars']\n", + " matched_centroids = value[\"matched_centroids\"]\n", + " matched_stars = value[\"matched_stars\"]\n", " xymags = []\n", " for centr, stars in zip(matched_centroids, matched_stars):\n", - " xymags.append([*centr,*stars])\n", + " xymags.append([*centr, *stars])\n", " xymags = np.array(xymags)\n", " xymags_sorted = xymags[xymags[:, 4].argsort()]\n", " # pixel_x, pixel_y - sorted\n", @@ -645,16 +647,16 @@ " matched_stars_s = [[x[2], x[3], x[4]] for x in xymags_sorted]\n", " # pixel_x, pixel_y, mag - sorted\n", " matched = [[x[0], x[1], x[4]] for x in xymags_sorted]\n", - " value['matched_centroids'] = matched_centroids_s\n", - " value['matched_stars'] = matched_stars_s\n", - " value['matched'] = matched\n", + " value[\"matched_centroids\"] = matched_centroids_s\n", + " value[\"matched_stars\"] = matched_stars_s\n", + " value[\"matched\"] = matched\n", " return value\n", - " \n", + "\n", + "\n", "for key, value in images.items():\n", " images[key] = enhance_centroids(value)\n", "\n", - "#pp.pprint(images)\n", - "\n" + "# pp.pprint(images)" ] }, { @@ -685,15 +687,16 @@ "source": [ "radius = 4\n", "plt.title(f\"circles with radius {radius}\")\n", - "plt.imshow(np.log1p(np_image), cmap='gray')\n", + "plt.imshow(np.log1p(np_image), cmap=\"gray\")\n", "plt.colorbar()\n", "# Add circles\n", "for i, (y, x) in enumerate(centroids):\n", - " circle = plt.Circle((x, y), radius, fill=False, color='red')\n", + " circle = plt.Circle((x, y), radius, fill=False, color=\"red\")\n", " plt.gca().add_artist(circle)\n", - " # Add number annotation\n", - " plt.annotate(str(i), (x, y), color='yellow', fontsize=8, \n", - " ha='right', va='top') # ha/va center the text on the point\n", + " # Add number annotation\n", + " plt.annotate(\n", + " str(i), (x, y), color=\"yellow\", fontsize=8, ha=\"right\", va=\"top\"\n", + " ) # ha/va center the text on the point\n", "plt.show()" ] }, @@ -729,31 +732,38 @@ "def histogram(image):\n", " # Method 1: Using PIL's built-in histogram\n", " hist = image.histogram()\n", - " \n", + "\n", " # Method 2: Better visualization with matplotlib\n", " np_image = np.array(image)\n", - " \n", + "\n", " plt.figure(figsize=(10, 6))\n", " plt.hist(np_image.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - " plt.xlabel('Pixel Value')\n", - " plt.ylabel('Frequency')\n", - " plt.title('Image Histogram')\n", + " plt.xlabel(\"Pixel Value\")\n", + " plt.ylabel(\"Frequency\")\n", + " plt.title(\"Image Histogram\")\n", " plt.grid(True, alpha=0.2)\n", - " \n", + "\n", " # Optional: Add vertical line for mean\n", " mean_val = np_image.mean()\n", - " plt.axvline(mean_val, color='r', linestyle='dashed', alpha=0.5, \n", - " label=f'Mean: {mean_val:.1f}')\n", + " plt.axvline(\n", + " mean_val,\n", + " color=\"r\",\n", + " linestyle=\"dashed\",\n", + " alpha=0.5,\n", + " label=f\"Mean: {mean_val:.1f}\",\n", + " )\n", " plt.legend()\n", - " \n", + "\n", " plt.show()\n", - " \n", + "\n", " # Print some statistics\n", " print(f\"Min: {np_image.min()}\")\n", " print(f\"Max: {np_image.max()}\")\n", " print(f\"Mean: {np_image.mean():.2f}\")\n", " print(f\"Median: {np.median(np_image):.2f}\")\n", " print(f\"Std Dev: {np_image.std():.2f}\")\n", + "\n", + "\n", "histogram(image)" ] }, @@ -795,19 +805,21 @@ "\n", "plt.subplot(121)\n", "plt.hist(np_array.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - "plt.title('Original Histogram')\n", - "plt.xlabel('Pixel Value')\n", - "plt.ylabel('Frequency')\n", + "plt.title(\"Original Histogram\")\n", + "plt.xlabel(\"Pixel Value\")\n", + "plt.ylabel(\"Frequency\")\n", "\n", "# Linear stretch (normalize to 0-255)\n", "stretched = np_array.astype(float)\n", - "stretched = ((stretched - stretched.min()) * (255.0/(stretched.max() - stretched.min()))).astype(np.uint8)\n", + "stretched = (\n", + " (stretched - stretched.min()) * (255.0 / (stretched.max() - stretched.min()))\n", + ").astype(np.uint8)\n", "\n", "plt.subplot(122)\n", "plt.hist(stretched.ravel(), bins=256, range=(0, 256), density=True, alpha=0.75)\n", - "plt.title('Stretched Histogram')\n", - "plt.xlabel('Pixel Value')\n", - "plt.ylabel('Frequency')\n", + "plt.title(\"Stretched Histogram\")\n", + "plt.xlabel(\"Pixel Value\")\n", + "plt.ylabel(\"Frequency\")\n", "\n", "plt.tight_layout()\n", "plt.show()\n", @@ -910,46 +922,52 @@ "\n", "# Parameters for local background measurement\n", "APERTURE_RADIUS = 5 # Star flux aperture (pixels)\n", - "ANNULUS_INNER = 6 # Inner radius of background annulus (pixels)\n", - "ANNULUS_OUTER = 14 # Outer radius of background annulus (pixels)\n", - "ALTITUDE = 90 # Zenith for now (no extinction correction until we have real altitude)\n", - "PEDESTAL = 0 # No pedestal correction for now\n", + "ANNULUS_INNER = 6 # Inner radius of background annulus (pixels)\n", + "ANNULUS_OUTER = 14 # Outer radius of background annulus (pixels)\n", + "ALTITUDE = 90 # Zenith for now (no extinction correction until we have real altitude)\n", + "PEDESTAL = 0 # No pedestal correction for now\n", "\n", "print(\"Production SQM Implementation Results (Local Annulus Backgrounds)\")\n", "print(\"=\" * 100)\n", - "print(f\"{'Image':<25} {'Expected':<12} {'Calculated':<12} {'Error':<12} {'Error %':<12}\")\n", + "print(\n", + " f\"{'Image':<25} {'Expected':<12} {'Calculated':<12} {'Error':<12} {'Error %':<12}\"\n", + ")\n", "print(\"-\" * 100)\n", "\n", "for key, value in images.items():\n", " # Build solution dict from the existing data\n", " solution = {\n", - " 'FOV': value['fov'],\n", - " 'matched_centroids': value['matched_centroids'],\n", - " 'matched_stars': value['matched_stars']\n", + " \"FOV\": value[\"fov\"],\n", + " \"matched_centroids\": value[\"matched_centroids\"],\n", + " \"matched_stars\": value[\"matched_stars\"],\n", " }\n", - " \n", + "\n", " # Calculate SQM using local annulus backgrounds\n", " sqm_val, details = sqm.calculate(\n", - " centroids=value['centroids'],\n", + " centroids=value[\"centroids\"],\n", " solution=solution,\n", - " image=value['np_image'], \n", + " image=value[\"np_image\"],\n", " altitude_deg=ALTITUDE,\n", " aperture_radius=APERTURE_RADIUS,\n", " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", - " pedestal=PEDESTAL\n", + " pedestal=PEDESTAL,\n", " )\n", - " \n", + "\n", " if sqm_val is not None:\n", - " value['sqm_calculated'] = sqm_val\n", - " value['sqm_details'] = details\n", - " \n", - " expected = value['realsqm']\n", + " value[\"sqm_calculated\"] = sqm_val\n", + " value[\"sqm_details\"] = details\n", + "\n", + " expected = value[\"realsqm\"]\n", " calc_err = sqm_val - expected\n", " err_pct = 100 * calc_err / expected\n", - " \n", - " print(f\"{key:<25} {expected:>10.2f} {sqm_val:>10.2f} {calc_err:>10.2f} {err_pct:>10.1f}%\")\n", - " print(f\"{'':>25} mzero={details['mzero']:>6.2f}, bg={details['background_per_pixel']:>6.1f} ADU/px, {details['n_matched_stars']} stars\")\n", + "\n", + " print(\n", + " f\"{key:<25} {expected:>10.2f} {sqm_val:>10.2f} {calc_err:>10.2f} {err_pct:>10.1f}%\"\n", + " )\n", + " print(\n", + " f\"{'':>25} mzero={details['mzero']:>6.2f}, bg={details['background_per_pixel']:>6.1f} ADU/px, {details['n_matched_stars']} stars\"\n", + " )\n", " else:\n", " print(f\"{key:<25} FAILED\")\n", "\n", @@ -1053,17 +1071,17 @@ "\n", "# Define all test images\n", "all_images = {\n", - " 'sqm1833.png': {'realsqm': 18.33},\n", - " 'sqm1837.png': {'realsqm': 18.37},\n", - " 'sqm1845.png': {'realsqm': 18.45},\n", - " 'sqm1855.png': {'realsqm': 18.55},\n", - " 'sqm1860.png': {'realsqm': 18.60},\n", - " 'sqm1870.png': {'realsqm': 18.70},\n", - " 'sqm1980.png': {'realsqm': 19.80},\n", - " 'sqm2000_0.8-4.png': {'realsqm': 20.00},\n", - " 'sqm2000_0.8-3.png': {'realsqm': 20.00},\n", - " 'sqm1818_raw_new_0.2.png': {'realsqm': 18.18}, \n", - " 'sqm1818_raw_new_1.png': {'realsqm': 18.18}\n", + " \"sqm1833.png\": {\"realsqm\": 18.33},\n", + " \"sqm1837.png\": {\"realsqm\": 18.37},\n", + " \"sqm1845.png\": {\"realsqm\": 18.45},\n", + " \"sqm1855.png\": {\"realsqm\": 18.55},\n", + " \"sqm1860.png\": {\"realsqm\": 18.60},\n", + " \"sqm1870.png\": {\"realsqm\": 18.70},\n", + " \"sqm1980.png\": {\"realsqm\": 19.80},\n", + " \"sqm2000_0.8-4.png\": {\"realsqm\": 20.00},\n", + " \"sqm2000_0.8-3.png\": {\"realsqm\": 20.00},\n", + " \"sqm1818_raw_new_0.2.png\": {\"realsqm\": 18.18},\n", + " \"sqm1818_raw_new_1.png\": {\"realsqm\": 18.18},\n", "}\n", "\n", "# Parameters for local annulus background\n", @@ -1081,25 +1099,27 @@ "\n", "for filename, info in all_images.items():\n", " print(f\"\\nProcessing {filename}...\")\n", - " \n", + "\n", " # Load image\n", " np_image, _ = load_image(filename)\n", - " \n", + "\n", " # Detect stars and solve\n", " centroids, solution = detect(np_image)\n", - " \n", + "\n", " # Check if solve succeeded\n", - " if 'matched_centroids' not in solution or len(solution['matched_centroids']) == 0:\n", + " if \"matched_centroids\" not in solution or len(solution[\"matched_centroids\"]) == 0:\n", " print(\" ❌ Failed to solve\")\n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': None,\n", - " 'error': None,\n", - " 'status': 'SOLVE_FAILED'\n", - " })\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": None,\n", + " \"error\": None,\n", + " \"status\": \"SOLVE_FAILED\",\n", + " }\n", + " )\n", " continue\n", - " \n", + "\n", " # Calculate SQM\n", " sqm_val, details = sqm.calculate(\n", " centroids=centroids,\n", @@ -1109,33 +1129,41 @@ " aperture_radius=APERTURE_RADIUS,\n", " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", - " pedestal=PEDESTAL\n", + " pedestal=PEDESTAL,\n", " )\n", - " \n", + "\n", " if sqm_val is not None:\n", - " error = sqm_val - info['realsqm']\n", - " print(f\" ✓ SQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {error:+.2f})\")\n", - " print(f\" mzero={details['mzero']:.2f}, stars={details['n_matched_stars']}, centroids={details['n_centroids']}\")\n", - " \n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': sqm_val,\n", - " 'error': error,\n", - " 'mzero': details['mzero'],\n", - " 'n_stars': details['n_matched_stars'],\n", - " 'n_centroids': details['n_centroids'],\n", - " 'status': 'OK'\n", - " })\n", + " error = sqm_val - info[\"realsqm\"]\n", + " print(\n", + " f\" ✓ SQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {error:+.2f})\"\n", + " )\n", + " print(\n", + " f\" mzero={details['mzero']:.2f}, stars={details['n_matched_stars']}, centroids={details['n_centroids']}\"\n", + " )\n", + "\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": sqm_val,\n", + " \"error\": error,\n", + " \"mzero\": details[\"mzero\"],\n", + " \"n_stars\": details[\"n_matched_stars\"],\n", + " \"n_centroids\": details[\"n_centroids\"],\n", + " \"status\": \"OK\",\n", + " }\n", + " )\n", " else:\n", " print(\" ❌ Failed to calculate SQM\")\n", - " results_summary.append({\n", - " 'filename': filename,\n", - " 'expected': info['realsqm'],\n", - " 'calculated': None,\n", - " 'error': None,\n", - " 'status': 'CALC_FAILED'\n", - " })\n", + " results_summary.append(\n", + " {\n", + " \"filename\": filename,\n", + " \"expected\": info[\"realsqm\"],\n", + " \"calculated\": None,\n", + " \"error\": None,\n", + " \"status\": \"CALC_FAILED\",\n", + " }\n", + " )\n", "\n", "print(\"\\n\" + \"=\" * 100)\n", "print(\"SUMMARY\")\n", @@ -1144,22 +1172,28 @@ "print(\"-\" * 100)\n", "\n", "for result in results_summary:\n", - " if result['status'] == 'OK':\n", - " print(f\"{result['filename']:<30} {result['expected']:>10.2f} {result['calculated']:>10.2f} {result['error']:>10.2f} {result['status']:<15}\")\n", + " if result[\"status\"] == \"OK\":\n", + " print(\n", + " f\"{result['filename']:<30} {result['expected']:>10.2f} {result['calculated']:>10.2f} {result['error']:>10.2f} {result['status']:<15}\"\n", + " )\n", " else:\n", - " print(f\"{result['filename']:<30} {result['expected']:>10.2f} {'---':>10} {'---':>10} {result['status']:<15}\")\n", + " print(\n", + " f\"{result['filename']:<30} {result['expected']:>10.2f} {'---':>10} {'---':>10} {result['status']:<15}\"\n", + " )\n", "\n", "# Calculate statistics for successful measurements\n", - "successful = [r for r in results_summary if r['status'] == 'OK']\n", + "successful = [r for r in results_summary if r[\"status\"] == \"OK\"]\n", "if successful:\n", - " errors = [r['error'] for r in successful]\n", + " errors = [r[\"error\"] for r in successful]\n", " print(\"\\n\" + \"=\" * 100)\n", " print(\"STATISTICS\")\n", " print(\"=\" * 100)\n", " print(f\"Successful measurements: {len(successful)}/{len(results_summary)}\")\n", " print(f\"Mean error: {np.mean(errors):+.2f} mag/arcsec²\")\n", " print(f\"Std dev: {np.std(errors):.2f} mag/arcsec²\")\n", - " print(f\"RMS error: {np.sqrt(np.mean(np.array(errors)**2)):.2f} mag/arcsec²\")\n", + " print(\n", + " f\"RMS error: {np.sqrt(np.mean(np.array(errors) ** 2)):.2f} mag/arcsec²\"\n", + " )\n", " print(f\"Max error: {np.max(np.abs(errors)):.2f} mag/arcsec²\")" ] }, @@ -1329,36 +1363,40 @@ "from matplotlib.gridspec import GridSpec\n", "from scipy import stats\n", "\n", + "\n", "def sigma_clip_mean(data, sigma=2.0, max_iter=3):\n", " \"\"\"Calculate mean after sigma clipping outliers. Returns mean, std, and mask matching input size.\"\"\"\n", " data = np.array(data)\n", " original_indices = np.arange(len(data))\n", " mask = np.ones(len(data), dtype=bool)\n", - " \n", + "\n", " current_data = data.copy()\n", " current_indices = original_indices.copy()\n", - " \n", + "\n", " for _ in range(max_iter):\n", " mean = np.mean(current_data)\n", " std = np.std(current_data)\n", " keep = np.abs(current_data - mean) < sigma * std\n", - " \n", + "\n", " if np.sum(keep) == len(current_data):\n", " break\n", - " \n", + "\n", " current_data = current_data[keep]\n", " current_indices = current_indices[keep]\n", - " \n", + "\n", " # Create mask for original array\n", " final_mask = np.zeros(len(data), dtype=bool)\n", " final_mask[current_indices] = True\n", - " \n", + "\n", " return np.mean(current_data), np.std(current_data), final_mask\n", "\n", - "def detect_aperture_overlaps(star_centroids, aperture_radius, annulus_inner, annulus_outer):\n", + "\n", + "def detect_aperture_overlaps(\n", + " star_centroids, aperture_radius, annulus_inner, annulus_outer\n", + "):\n", " \"\"\"\n", " Detect overlapping apertures and annuli between star pairs.\n", - " \n", + "\n", " Returns list of overlaps with format:\n", " {\n", " 'star1_idx': int,\n", @@ -1370,61 +1408,68 @@ " \"\"\"\n", " overlaps = []\n", " n_stars = len(star_centroids)\n", - " \n", + "\n", " for i in range(n_stars):\n", - " for j in range(i+1, n_stars):\n", + " for j in range(i + 1, n_stars):\n", " x1, y1 = star_centroids[i]\n", " x2, y2 = star_centroids[j]\n", - " distance = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)\n", - " \n", + " distance = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)\n", + "\n", " # Check different overlap types\n", " if distance < 2 * aperture_radius:\n", " # CRITICAL: Aperture-aperture overlap (star flux contamination)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'CRITICAL',\n", - " 'description': f'Aperture overlap (d={distance:.1f}px < {2*aperture_radius}px)'\n", - " })\n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"CRITICAL\",\n", + " \"description\": f\"Aperture overlap (d={distance:.1f}px < {2 * aperture_radius}px)\",\n", + " }\n", + " )\n", " elif distance < aperture_radius + annulus_outer:\n", " # HIGH: Aperture inside another star's annulus (background contamination)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'HIGH',\n", - " 'description': f'Aperture-annulus overlap (d={distance:.1f}px < {aperture_radius + annulus_outer}px)'\n", - " })\n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"HIGH\",\n", + " \"description\": f\"Aperture-annulus overlap (d={distance:.1f}px < {aperture_radius + annulus_outer}px)\",\n", + " }\n", + " )\n", " elif distance < 2 * annulus_outer:\n", " # MEDIUM: Annulus-annulus overlap (less critical)\n", - " overlaps.append({\n", - " 'star1_idx': i,\n", - " 'star2_idx': j,\n", - " 'distance': distance,\n", - " 'type': 'MEDIUM',\n", - " 'description': f'Annulus overlap (d={distance:.1f}px < {2*annulus_outer}px)'\n", - " })\n", - " \n", + " overlaps.append(\n", + " {\n", + " \"star1_idx\": i,\n", + " \"star2_idx\": j,\n", + " \"distance\": distance,\n", + " \"type\": \"MEDIUM\",\n", + " \"description\": f\"Annulus overlap (d={distance:.1f}px < {2 * annulus_outer}px)\",\n", + " }\n", + " )\n", + "\n", " return overlaps\n", "\n", + "\n", "# Process each image with full diagnostics\n", "for filename, info in all_images.items():\n", - " print(f\"\\n{'='*100}\")\n", + " print(f\"\\n{'=' * 100}\")\n", " print(f\"Processing: {filename}\")\n", " print(f\"Expected SQM: {info['realsqm']:.2f} mag/arcsec²\")\n", - " print(f\"{'='*100}\\n\")\n", - " \n", + " print(f\"{'=' * 100}\\n\")\n", + "\n", " # Load image\n", " np_image, _ = load_image(filename)\n", - " \n", + "\n", " # Detect stars and solve\n", " centroids, solution = detect(np_image)\n", - " \n", - " if 'matched_centroids' not in solution or len(solution['matched_centroids']) == 0:\n", + "\n", + " if \"matched_centroids\" not in solution or len(solution[\"matched_centroids\"]) == 0:\n", " print(f\"❌ Failed to solve {filename}\\n\")\n", " continue\n", - " \n", + "\n", " # Calculate SQM WITHOUT overlap correction\n", " sqm_val, details = sqm.calculate(\n", " centroids=centroids,\n", @@ -1435,9 +1480,9 @@ " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", " pedestal=PEDESTAL,\n", - " correct_overlaps=False\n", + " correct_overlaps=False,\n", " )\n", - " \n", + "\n", " # Calculate SQM WITH overlap correction\n", " sqm_val_corrected, details_corrected = sqm.calculate(\n", " centroids=centroids,\n", @@ -1448,90 +1493,102 @@ " annulus_inner_radius=ANNULUS_INNER,\n", " annulus_outer_radius=ANNULUS_OUTER,\n", " pedestal=PEDESTAL,\n", - " correct_overlaps=True\n", + " correct_overlaps=True,\n", " )\n", - " \n", + "\n", " if sqm_val is None:\n", " print(f\"❌ Failed to calculate SQM for {filename}\\n\")\n", " continue\n", - " \n", + "\n", " # Extract details (use non-corrected for visualization, but we have both)\n", - " star_centroids = np.array(details['star_centroids'])\n", - " star_mags = details['star_mags']\n", - " star_fluxes = details['star_fluxes']\n", - " star_mzeros = details['star_mzeros']\n", - " star_local_bgs = details.get('star_local_backgrounds', [None] * len(star_mags))\n", - " \n", + " star_centroids = np.array(details[\"star_centroids\"])\n", + " star_mags = details[\"star_mags\"]\n", + " star_fluxes = details[\"star_fluxes\"]\n", + " star_mzeros = details[\"star_mzeros\"]\n", + " star_local_bgs = details.get(\"star_local_backgrounds\", [None] * len(star_mags))\n", + "\n", " # ========== APERTURE OVERLAP DETECTION ==========\n", - " overlaps = detect_aperture_overlaps(star_centroids, APERTURE_RADIUS, ANNULUS_INNER, ANNULUS_OUTER)\n", - " \n", + " overlaps = detect_aperture_overlaps(\n", + " star_centroids, APERTURE_RADIUS, ANNULUS_INNER, ANNULUS_OUTER\n", + " )\n", + "\n", " # Build set of stars affected by overlaps\n", " overlapping_stars = set()\n", " for overlap in overlaps:\n", - " overlapping_stars.add(overlap['star1_idx'])\n", - " overlapping_stars.add(overlap['star2_idx'])\n", - " \n", + " overlapping_stars.add(overlap[\"star1_idx\"])\n", + " overlapping_stars.add(overlap[\"star2_idx\"])\n", + "\n", " # Categorize overlaps by severity\n", - " critical_overlaps = [o for o in overlaps if o['type'] == 'CRITICAL']\n", - " high_overlaps = [o for o in overlaps if o['type'] == 'HIGH']\n", - " medium_overlaps = [o for o in overlaps if o['type'] == 'MEDIUM']\n", - " \n", + " critical_overlaps = [o for o in overlaps if o[\"type\"] == \"CRITICAL\"]\n", + " high_overlaps = [o for o in overlaps if o[\"type\"] == \"HIGH\"]\n", + " medium_overlaps = [o for o in overlaps if o[\"type\"] == \"MEDIUM\"]\n", + "\n", " # Print overlap summary\n", " if overlaps:\n", " print(f\"⚠️ OVERLAPS DETECTED: {len(overlaps)} total\")\n", " print(f\" CRITICAL (aperture-aperture): {len(critical_overlaps)}\")\n", " print(f\" HIGH (aperture-annulus): {len(high_overlaps)}\")\n", " print(f\" MEDIUM (annulus-annulus): {len(medium_overlaps)}\")\n", - " print(f\" Stars affected: {len(overlapping_stars)}/{len(star_centroids)} ({100*len(overlapping_stars)/len(star_centroids):.0f}%)\")\n", + " print(\n", + " f\" Stars affected: {len(overlapping_stars)}/{len(star_centroids)} ({100 * len(overlapping_stars) / len(star_centroids):.0f}%)\"\n", + " )\n", " print()\n", " else:\n", " print(\"✓ No aperture overlaps detected\\n\")\n", - " \n", + "\n", " # Calculate alternative mzero methods - filter for valid stars (flux > 0 and mzero not None)\n", - " valid_indices = [i for i in range(len(star_fluxes)) \n", - " if star_fluxes[i] > 0 and star_mzeros[i] is not None]\n", + " valid_indices = [\n", + " i\n", + " for i in range(len(star_fluxes))\n", + " if star_fluxes[i] > 0 and star_mzeros[i] is not None\n", + " ]\n", " valid_mzeros = np.array([star_mzeros[i] for i in valid_indices])\n", " valid_mags = np.array([star_mags[i] for i in valid_indices])\n", " valid_fluxes = np.array([star_fluxes[i] for i in valid_indices])\n", - " \n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " mzero_mean = np.mean(valid_mzeros)\n", " mzero_median = np.median(valid_mzeros)\n", " mzero_std = np.std(valid_mzeros)\n", - " \n", + "\n", " # Sigma clipping\n", " if len(valid_mzeros) >= 3:\n", - " mzero_sigclip, mzero_sigclip_std, sigclip_mask = sigma_clip_mean(valid_mzeros, sigma=2.0)\n", + " mzero_sigclip, mzero_sigclip_std, sigclip_mask = sigma_clip_mean(\n", + " valid_mzeros, sigma=2.0\n", + " )\n", " n_clipped = len(valid_mzeros) - np.sum(sigclip_mask)\n", " else:\n", " mzero_sigclip = mzero_mean\n", " mzero_sigclip_std = mzero_std\n", " n_clipped = 0\n", " sigclip_mask = np.ones(len(valid_mzeros), dtype=bool)\n", - " \n", + "\n", " # Trendline correction methods\n", " if len(valid_mzeros) >= 3:\n", " # Method 1: Trendline on all valid stars\n", - " slope_all, intercept_all, r_value_all, _, _ = stats.linregress(valid_mags, valid_mzeros)\n", + " slope_all, intercept_all, r_value_all, _, _ = stats.linregress(\n", + " valid_mags, valid_mzeros\n", + " )\n", " # Evaluate trend at median magnitude\n", " median_mag = np.median(valid_mags)\n", " mzero_trend = slope_all * median_mag + intercept_all\n", - " \n", + "\n", " # Calculate residuals for quality metric\n", " predicted_all = slope_all * valid_mags + intercept_all\n", " residuals_all = valid_mzeros - predicted_all\n", " trend_rms_all = np.sqrt(np.mean(residuals_all**2))\n", - " \n", + "\n", " # Method 2: Sigma clip THEN fit trendline\n", " clipped_mags = valid_mags[sigclip_mask]\n", " clipped_mzeros = valid_mzeros[sigclip_mask]\n", - " \n", + "\n", " if len(clipped_mzeros) >= 3:\n", - " slope_clip, intercept_clip, r_value_clip, _, _ = stats.linregress(clipped_mags, clipped_mzeros)\n", + " slope_clip, intercept_clip, r_value_clip, _, _ = stats.linregress(\n", + " clipped_mags, clipped_mzeros\n", + " )\n", " median_mag_clip = np.median(clipped_mags)\n", " mzero_trend_sigclip = slope_clip * median_mag_clip + intercept_clip\n", - " \n", + "\n", " predicted_clip = slope_clip * clipped_mags + intercept_clip\n", " residuals_clip = clipped_mzeros - predicted_clip\n", " trend_rms_clip = np.sqrt(np.mean(residuals_clip**2))\n", @@ -1552,398 +1609,632 @@ " r_value_clip = 0\n", " trend_rms_all = mzero_std\n", " trend_rms_clip = mzero_sigclip_std\n", - " \n", + "\n", " # Calculate SQM with alternative methods\n", - " bg_flux_density = details['background_flux_density']\n", - " extinction = details['extinction_correction']\n", - " \n", + " bg_flux_density = details[\"background_flux_density\"]\n", + " extinction = details[\"extinction_correction\"]\n", + "\n", " sqm_median = mzero_median - 2.5 * np.log10(bg_flux_density) + extinction\n", " sqm_sigclip = mzero_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", " sqm_trend = mzero_trend - 2.5 * np.log10(bg_flux_density) + extinction\n", - " sqm_trend_sigclip = mzero_trend_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", + " sqm_trend_sigclip = (\n", + " mzero_trend_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", + " )\n", " else:\n", - " mzero_mean = mzero_median = mzero_sigclip = mzero_trend = mzero_trend_sigclip = None\n", + " mzero_mean = mzero_median = mzero_sigclip = mzero_trend = (\n", + " mzero_trend_sigclip\n", + " ) = None\n", " sqm_median = sqm_sigclip = sqm_trend = sqm_trend_sigclip = None\n", " n_clipped = 0\n", " slope_all = slope_clip = 0\n", " r_value_all = r_value_clip = 0\n", - " \n", + "\n", " # Create comprehensive figure with 4x3 grid\n", " fig = plt.figure(figsize=(24, 16))\n", " gs = GridSpec(4, 3, figure=fig, hspace=0.35, wspace=0.3)\n", - " \n", + "\n", " # ========== Panel 1: Image with apertures (spans 2x2) ==========\n", " ax1 = fig.add_subplot(gs[0:2, 0:2])\n", - " \n", + "\n", " # Display image with log stretch\n", " vmin, vmax = np.percentile(np_image, [1, 99.5])\n", - " im = ax1.imshow(np_image, cmap='gray', vmin=vmin, vmax=vmax, origin='lower')\n", - " \n", + " im = ax1.imshow(np_image, cmap=\"gray\", vmin=vmin, vmax=vmax, origin=\"lower\")\n", + "\n", " # Draw connecting lines for overlaps FIRST (so they appear behind circles)\n", " for overlap in overlaps:\n", - " x1, y1 = star_centroids[overlap['star1_idx']]\n", - " x2, y2 = star_centroids[overlap['star2_idx']]\n", - " \n", + " x1, y1 = star_centroids[overlap[\"star1_idx\"]]\n", + " x2, y2 = star_centroids[overlap[\"star2_idx\"]]\n", + "\n", " # Color by severity\n", - " if overlap['type'] == 'CRITICAL':\n", - " line_color = 'red'\n", - " elif overlap['type'] == 'HIGH':\n", - " line_color = 'orange'\n", + " if overlap[\"type\"] == \"CRITICAL\":\n", + " line_color = \"red\"\n", + " elif overlap[\"type\"] == \"HIGH\":\n", + " line_color = \"orange\"\n", " else:\n", - " line_color = 'yellow'\n", - " \n", - " ax1.plot([x1, x2], [y1, y2], color=line_color, linestyle=':', linewidth=2, alpha=0.7)\n", - " \n", + " line_color = \"yellow\"\n", + "\n", + " ax1.plot(\n", + " [x1, x2], [y1, y2], color=line_color, linestyle=\":\", linewidth=2, alpha=0.7\n", + " )\n", + "\n", " # Draw apertures on matched stars\n", - " for i, (centroid, flux, mag, local_bg) in enumerate(zip(star_centroids, star_fluxes, star_mags, star_local_bgs)):\n", + " for i, (centroid, flux, mag, local_bg) in enumerate(\n", + " zip(star_centroids, star_fluxes, star_mags, star_local_bgs)\n", + " ):\n", " x, y = centroid\n", - " \n", + "\n", " # Color code by flux status, outlier detection, and overlap\n", " is_outlier = False\n", " if flux > 0 and mzero_mean is not None and len(star_mzeros) > i:\n", " mzero_val = star_mzeros[i]\n", " is_outlier = abs(mzero_val - mzero_mean) > 2.0 * mzero_std\n", - " \n", + "\n", " is_overlapping = i in overlapping_stars\n", - " \n", + "\n", " if flux <= 0:\n", - " color = 'red'\n", + " color = \"red\"\n", " alpha = 0.8\n", " elif is_overlapping:\n", - " color = 'magenta' # Magenta for overlapping stars\n", + " color = \"magenta\" # Magenta for overlapping stars\n", " alpha = 0.8\n", " elif is_outlier:\n", - " color = 'orange'\n", + " color = \"orange\"\n", " alpha = 0.7\n", " else:\n", - " color = 'lime'\n", + " color = \"lime\"\n", " alpha = 0.6\n", - " \n", + "\n", " # Draw aperture circle (solid)\n", - " circle = mpatches.Circle((x, y), APERTURE_RADIUS, \n", - " fill=False, edgecolor=color, linewidth=2, alpha=alpha)\n", + " circle = mpatches.Circle(\n", + " (x, y),\n", + " APERTURE_RADIUS,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=2,\n", + " alpha=alpha,\n", + " )\n", " ax1.add_patch(circle)\n", - " \n", + "\n", " # Draw annulus inner (dashed)\n", - " annulus_inner = mpatches.Circle((x, y), ANNULUS_INNER,\n", - " fill=False, edgecolor=color, linewidth=1, \n", - " linestyle='--', alpha=0.4)\n", + " annulus_inner = mpatches.Circle(\n", + " (x, y),\n", + " ANNULUS_INNER,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " alpha=0.4,\n", + " )\n", " ax1.add_patch(annulus_inner)\n", - " \n", + "\n", " # Draw annulus outer (dashed)\n", - " annulus_outer_circle = mpatches.Circle((x, y), ANNULUS_OUTER,\n", - " fill=False, edgecolor=color, linewidth=1, \n", - " linestyle='--', alpha=0.4)\n", + " annulus_outer_circle = mpatches.Circle(\n", + " (x, y),\n", + " ANNULUS_OUTER,\n", + " fill=False,\n", + " edgecolor=color,\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " alpha=0.4,\n", + " )\n", " ax1.add_patch(annulus_outer_circle)\n", - " \n", + "\n", " # Label star\n", - " label_text = f'{i}\\nm={mag:.1f}\\nf={flux:.0f}\\nbg={local_bg:.0f}' if local_bg else f'{i}\\nm={mag:.1f}'\n", - " ax1.text(x + ANNULUS_OUTER + 3, y, label_text,\n", - " color=color, fontsize=7, va='center', weight='bold',\n", - " bbox=dict(boxstyle='round,pad=0.3', facecolor='black', alpha=0.5))\n", - " \n", - " ax1.set_title(f'{filename}\\nSQM: {sqm_val:.2f} (expected: {info[\"realsqm\"]:.2f}, error: {sqm_val - info[\"realsqm\"]:+.2f})',\n", - " fontsize=14, weight='bold')\n", - " ax1.set_xlabel('X (pixels)', fontsize=11)\n", - " ax1.set_ylabel('Y (pixels)', fontsize=11)\n", - " plt.colorbar(im, ax=ax1, label='ADU')\n", - " \n", + " label_text = (\n", + " f\"{i}\\nm={mag:.1f}\\nf={flux:.0f}\\nbg={local_bg:.0f}\"\n", + " if local_bg\n", + " else f\"{i}\\nm={mag:.1f}\"\n", + " )\n", + " ax1.text(\n", + " x + ANNULUS_OUTER + 3,\n", + " y,\n", + " label_text,\n", + " color=color,\n", + " fontsize=7,\n", + " va=\"center\",\n", + " weight=\"bold\",\n", + " bbox=dict(boxstyle=\"round,pad=0.3\", facecolor=\"black\", alpha=0.5),\n", + " )\n", + "\n", + " ax1.set_title(\n", + " f\"{filename}\\nSQM: {sqm_val:.2f} (expected: {info['realsqm']:.2f}, error: {sqm_val - info['realsqm']:+.2f})\",\n", + " fontsize=14,\n", + " weight=\"bold\",\n", + " )\n", + " ax1.set_xlabel(\"X (pixels)\", fontsize=11)\n", + " ax1.set_ylabel(\"Y (pixels)\", fontsize=11)\n", + " plt.colorbar(im, ax=ax1, label=\"ADU\")\n", + "\n", " # Legend\n", " legend_elements = [\n", - " mpatches.Patch(color='lime', label='Valid star'),\n", - " mpatches.Patch(color='magenta', label='Overlapping'),\n", - " mpatches.Patch(color='orange', label='Outlier (|Δmzero| > 2σ)'),\n", - " mpatches.Patch(color='red', label='Bad flux (≤ 0)'),\n", - " mpatches.Circle((0, 0), 1, fill=False, edgecolor='white', linewidth=2, label=f'Aperture (r={APERTURE_RADIUS}px)'),\n", - " mpatches.Circle((0, 0), 1, fill=False, edgecolor='white', linewidth=1, linestyle='--', label=f'Annulus ({ANNULUS_INNER}-{ANNULUS_OUTER}px)')\n", + " mpatches.Patch(color=\"lime\", label=\"Valid star\"),\n", + " mpatches.Patch(color=\"magenta\", label=\"Overlapping\"),\n", + " mpatches.Patch(color=\"orange\", label=\"Outlier (|Δmzero| > 2σ)\"),\n", + " mpatches.Patch(color=\"red\", label=\"Bad flux (≤ 0)\"),\n", + " mpatches.Circle(\n", + " (0, 0),\n", + " 1,\n", + " fill=False,\n", + " edgecolor=\"white\",\n", + " linewidth=2,\n", + " label=f\"Aperture (r={APERTURE_RADIUS}px)\",\n", + " ),\n", + " mpatches.Circle(\n", + " (0, 0),\n", + " 1,\n", + " fill=False,\n", + " edgecolor=\"white\",\n", + " linewidth=1,\n", + " linestyle=\"--\",\n", + " label=f\"Annulus ({ANNULUS_INNER}-{ANNULUS_OUTER}px)\",\n", + " ),\n", " ]\n", - " ax1.legend(handles=legend_elements, loc='upper right', fontsize=9)\n", - " \n", + " ax1.legend(handles=legend_elements, loc=\"upper right\", fontsize=9)\n", + "\n", " # ========== Panel 2: Per-Star Statistics Table ==========\n", " ax2 = fig.add_subplot(gs[0:2, 2])\n", - " ax2.axis('off')\n", - " \n", + " ax2.axis(\"off\")\n", + "\n", " # Build table data\n", - " table_data = [['#', 'Mag', 'Flux\\n(ADU)', 'Bg\\n(ADU)', 'mzero', 'Δmz', 'OK']]\n", - " for i, (mag, flux, local_bg, mzero) in enumerate(zip(star_mags, star_fluxes, star_local_bgs, star_mzeros)):\n", - " status = '✓' if flux > 0 else '✗'\n", + " table_data = [[\"#\", \"Mag\", \"Flux\\n(ADU)\", \"Bg\\n(ADU)\", \"mzero\", \"Δmz\", \"OK\"]]\n", + " for i, (mag, flux, local_bg, mzero) in enumerate(\n", + " zip(star_mags, star_fluxes, star_local_bgs, star_mzeros)\n", + " ):\n", + " status = \"✓\" if flux > 0 else \"✗\"\n", " delta_mzero = (mzero - mzero_mean) if (flux > 0 and mzero_mean) else None\n", - " \n", - " table_data.append([\n", - " f'{i}',\n", - " f'{mag:.2f}',\n", - " f'{flux:.0f}',\n", - " f'{local_bg:.0f}' if local_bg is not None else 'N/A',\n", - " f'{mzero:.2f}' if flux > 0 else 'N/A',\n", - " f'{delta_mzero:+.2f}' if delta_mzero is not None else 'N/A',\n", - " status\n", - " ])\n", - " \n", + "\n", + " table_data.append(\n", + " [\n", + " f\"{i}\",\n", + " f\"{mag:.2f}\",\n", + " f\"{flux:.0f}\",\n", + " f\"{local_bg:.0f}\" if local_bg is not None else \"N/A\",\n", + " f\"{mzero:.2f}\" if flux > 0 else \"N/A\",\n", + " f\"{delta_mzero:+.2f}\" if delta_mzero is not None else \"N/A\",\n", + " status,\n", + " ]\n", + " )\n", + "\n", " # Create table\n", - " table = ax2.table(cellText=table_data, cellLoc='center', loc='center',\n", - " colWidths=[0.08, 0.12, 0.15, 0.12, 0.12, 0.10, 0.08])\n", + " table = ax2.table(\n", + " cellText=table_data,\n", + " cellLoc=\"center\",\n", + " loc=\"center\",\n", + " colWidths=[0.08, 0.12, 0.15, 0.12, 0.12, 0.10, 0.08],\n", + " )\n", " table.auto_set_font_size(False)\n", " table.set_fontsize(7)\n", " table.scale(1, 1.8)\n", - " \n", + "\n", " # Style header row\n", " for i in range(7):\n", - " table[(0, i)].set_facecolor('#4CAF50')\n", - " table[(0, i)].set_text_props(weight='bold', color='white')\n", - " \n", + " table[(0, i)].set_facecolor(\"#4CAF50\")\n", + " table[(0, i)].set_text_props(weight=\"bold\", color=\"white\")\n", + "\n", " # Color code rows\n", " for i in range(1, len(table_data)):\n", - " flux = star_fluxes[i-1]\n", - " is_overlapping = (i-1) in overlapping_stars\n", - " \n", + " flux = star_fluxes[i - 1]\n", + " is_overlapping = (i - 1) in overlapping_stars\n", + "\n", " if flux <= 0:\n", - " color = '#FFCDD2' # Red\n", + " color = \"#FFCDD2\" # Red\n", " elif is_overlapping:\n", - " color = '#F8BBD0' # Magenta/pink\n", - " elif i-1 < len(star_mzeros) and mzero_mean is not None:\n", - " delta = abs(star_mzeros[i-1] - mzero_mean)\n", + " color = \"#F8BBD0\" # Magenta/pink\n", + " elif i - 1 < len(star_mzeros) and mzero_mean is not None:\n", + " delta = abs(star_mzeros[i - 1] - mzero_mean)\n", " if delta > 2.0 * mzero_std:\n", - " color = '#FFE0B2' # Orange\n", + " color = \"#FFE0B2\" # Orange\n", " elif delta > 1.0 * mzero_std:\n", - " color = '#FFF9C4' # Yellow\n", + " color = \"#FFF9C4\" # Yellow\n", " else:\n", - " color = '#E8F5E9' # Green\n", + " color = \"#E8F5E9\" # Green\n", " else:\n", - " color = 'white'\n", - " \n", + " color = \"white\"\n", + "\n", " for j in range(7):\n", " table[(i, j)].set_facecolor(color)\n", - " \n", - " ax2.set_title('Per-Star Breakdown\\n(Δmz = deviation from mean)', fontsize=11, weight='bold', pad=20)\n", - " \n", + "\n", + " ax2.set_title(\n", + " \"Per-Star Breakdown\\n(Δmz = deviation from mean)\",\n", + " fontsize=11,\n", + " weight=\"bold\",\n", + " pad=20,\n", + " )\n", + "\n", " # ========== Panel 3: mzero Values vs Magnitude with Trendlines ==========\n", " ax3 = fig.add_subplot(gs[2, 0])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Scatter plot of individual mzero values\n", - " colors = ['red' if not sigclip_mask[i] else 'blue' for i in range(len(valid_mzeros))]\n", - " ax3.scatter(valid_mags, valid_mzeros, s=80, alpha=0.7, c=colors, edgecolors='black', linewidths=1, label='Stars', zorder=3)\n", - " \n", + " colors = [\n", + " \"red\" if not sigclip_mask[i] else \"blue\" for i in range(len(valid_mzeros))\n", + " ]\n", + " ax3.scatter(\n", + " valid_mags,\n", + " valid_mzeros,\n", + " s=80,\n", + " alpha=0.7,\n", + " c=colors,\n", + " edgecolors=\"black\",\n", + " linewidths=1,\n", + " label=\"Stars\",\n", + " zorder=3,\n", + " )\n", + "\n", " # Horizontal lines for different methods\n", - " ax3.axhline(mzero_mean, color='blue', linestyle='-', linewidth=2, label=f'Mean: {mzero_mean:.3f}', alpha=0.6)\n", - " ax3.axhline(mzero_median, color='green', linestyle='--', linewidth=2, label=f'Median: {mzero_median:.3f}', alpha=0.6)\n", - " \n", + " ax3.axhline(\n", + " mzero_mean,\n", + " color=\"blue\",\n", + " linestyle=\"-\",\n", + " linewidth=2,\n", + " label=f\"Mean: {mzero_mean:.3f}\",\n", + " alpha=0.6,\n", + " )\n", + " ax3.axhline(\n", + " mzero_median,\n", + " color=\"green\",\n", + " linestyle=\"--\",\n", + " linewidth=2,\n", + " label=f\"Median: {mzero_median:.3f}\",\n", + " alpha=0.6,\n", + " )\n", + "\n", " # Trendlines\n", " if len(valid_mzeros) >= 3:\n", " mag_range = np.array([valid_mags.min(), valid_mags.max()])\n", - " \n", + "\n", " # All stars trend\n", " trend_line_all = slope_all * mag_range + intercept_all\n", - " ax3.plot(mag_range, trend_line_all, 'purple', linestyle='-.', linewidth=2.5, \n", - " label=f'Trend (all): R²={r_value_all**2:.3f}', zorder=2)\n", - " \n", + " ax3.plot(\n", + " mag_range,\n", + " trend_line_all,\n", + " \"purple\",\n", + " linestyle=\"-.\",\n", + " linewidth=2.5,\n", + " label=f\"Trend (all): R²={r_value_all**2:.3f}\",\n", + " zorder=2,\n", + " )\n", + "\n", " # Sigma-clipped trend\n", " if n_clipped > 0:\n", " trend_line_clip = slope_clip * mag_range + intercept_clip\n", - " ax3.plot(mag_range, trend_line_clip, 'red', linestyle=':', linewidth=2.5, \n", - " label=f'Trend (σ-clip): R²={r_value_clip**2:.3f}', zorder=2)\n", - " \n", + " ax3.plot(\n", + " mag_range,\n", + " trend_line_clip,\n", + " \"red\",\n", + " linestyle=\":\",\n", + " linewidth=2.5,\n", + " label=f\"Trend (σ-clip): R²={r_value_clip**2:.3f}\",\n", + " zorder=2,\n", + " )\n", + "\n", " # Mark median magnitude\n", - " ax3.axvline(np.median(valid_mags), color='gray', linestyle='--', linewidth=1, alpha=0.5, zorder=1)\n", - " \n", + " ax3.axvline(\n", + " np.median(valid_mags),\n", + " color=\"gray\",\n", + " linestyle=\"--\",\n", + " linewidth=1,\n", + " alpha=0.5,\n", + " zorder=1,\n", + " )\n", + "\n", " # Std deviation bands\n", - " ax3.axhspan(mzero_mean - mzero_std, mzero_mean + mzero_std, alpha=0.15, color='blue', zorder=0)\n", - " \n", - " ax3.set_xlabel('Catalog Magnitude', fontsize=10)\n", - " ax3.set_ylabel('mzero', fontsize=10)\n", - " ax3.set_title(f'mzero vs Magnitude\\nσ = {mzero_std:.3f}, Trend slope = {slope_all:.4f}', fontsize=10, weight='bold')\n", - " ax3.legend(fontsize=7, loc='best')\n", + " ax3.axhspan(\n", + " mzero_mean - mzero_std,\n", + " mzero_mean + mzero_std,\n", + " alpha=0.15,\n", + " color=\"blue\",\n", + " zorder=0,\n", + " )\n", + "\n", + " ax3.set_xlabel(\"Catalog Magnitude\", fontsize=10)\n", + " ax3.set_ylabel(\"mzero\", fontsize=10)\n", + " ax3.set_title(\n", + " f\"mzero vs Magnitude\\nσ = {mzero_std:.3f}, Trend slope = {slope_all:.4f}\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax3.legend(fontsize=7, loc=\"best\")\n", " ax3.grid(True, alpha=0.3)\n", " ax3.invert_xaxis() # Brighter stars on right\n", " else:\n", - " ax3.text(0.5, 0.5, 'No valid stars', transform=ax3.transAxes, ha='center', va='center')\n", - " \n", + " ax3.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax3.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 4: mzero Values vs Flux ==========\n", " ax4 = fig.add_subplot(gs[2, 1])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Scatter plot of mzero vs log(flux)\n", " log_fluxes = np.log10(valid_fluxes)\n", - " colors = ['red' if not sigclip_mask[i] else 'blue' for i in range(len(valid_mzeros))]\n", - " ax4.scatter(log_fluxes, valid_mzeros, s=80, alpha=0.7, c=colors, edgecolors='black', linewidths=1)\n", - " \n", + " colors = [\n", + " \"red\" if not sigclip_mask[i] else \"blue\" for i in range(len(valid_mzeros))\n", + " ]\n", + " ax4.scatter(\n", + " log_fluxes,\n", + " valid_mzeros,\n", + " s=80,\n", + " alpha=0.7,\n", + " c=colors,\n", + " edgecolors=\"black\",\n", + " linewidths=1,\n", + " )\n", + "\n", " # Horizontal lines\n", - " ax4.axhline(mzero_mean, color='blue', linestyle='-', linewidth=2, label='Mean', alpha=0.6)\n", - " ax4.axhline(mzero_median, color='green', linestyle='--', linewidth=2, label='Median', alpha=0.6)\n", - " \n", + " ax4.axhline(\n", + " mzero_mean,\n", + " color=\"blue\",\n", + " linestyle=\"-\",\n", + " linewidth=2,\n", + " label=\"Mean\",\n", + " alpha=0.6,\n", + " )\n", + " ax4.axhline(\n", + " mzero_median,\n", + " color=\"green\",\n", + " linestyle=\"--\",\n", + " linewidth=2,\n", + " label=\"Median\",\n", + " alpha=0.6,\n", + " )\n", + "\n", " # Check for trend with flux\n", " if len(valid_mzeros) >= 3:\n", - " slope_flux, intercept_flux, r_value_flux, _, _ = stats.linregress(log_fluxes, valid_mzeros)\n", + " slope_flux, intercept_flux, r_value_flux, _, _ = stats.linregress(\n", + " log_fluxes, valid_mzeros\n", + " )\n", " if abs(r_value_flux) > 0.3: # Significant correlation\n", " x_fit = np.array([log_fluxes.min(), log_fluxes.max()])\n", " y_fit = slope_flux * x_fit + intercept_flux\n", - " ax4.plot(x_fit, y_fit, 'orange', linestyle=':', linewidth=2, \n", - " label=f'Flux trend: R²={r_value_flux**2:.3f}')\n", - " \n", - " ax4.set_xlabel('log₁₀(Flux [ADU])', fontsize=10)\n", - " ax4.set_ylabel('mzero', fontsize=10)\n", - " ax4.set_title('mzero vs Flux\\n(Should be flat if aperture correct)', fontsize=10, weight='bold')\n", - " ax4.legend(fontsize=8, loc='best')\n", + " ax4.plot(\n", + " x_fit,\n", + " y_fit,\n", + " \"orange\",\n", + " linestyle=\":\",\n", + " linewidth=2,\n", + " label=f\"Flux trend: R²={r_value_flux**2:.3f}\",\n", + " )\n", + "\n", + " ax4.set_xlabel(\"log₁₀(Flux [ADU])\", fontsize=10)\n", + " ax4.set_ylabel(\"mzero\", fontsize=10)\n", + " ax4.set_title(\n", + " \"mzero vs Flux\\n(Should be flat if aperture correct)\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax4.legend(fontsize=8, loc=\"best\")\n", " ax4.grid(True, alpha=0.3)\n", " else:\n", - " ax4.text(0.5, 0.5, 'No valid stars', transform=ax4.transAxes, ha='center', va='center')\n", - " \n", + " ax4.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax4.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 5: mzero Distribution Histogram ==========\n", " ax5 = fig.add_subplot(gs[2, 2])\n", - " \n", + "\n", " if len(valid_mzeros) > 0:\n", " # Histogram\n", - " ax5.hist(valid_mzeros, bins=min(15, len(valid_mzeros)), \n", - " color='steelblue', alpha=0.7, edgecolor='black')\n", - " \n", + " ax5.hist(\n", + " valid_mzeros,\n", + " bins=min(15, len(valid_mzeros)),\n", + " color=\"steelblue\",\n", + " alpha=0.7,\n", + " edgecolor=\"black\",\n", + " )\n", + "\n", " # Mark different estimators\n", - " ax5.axvline(mzero_mean, color='blue', linestyle='-', linewidth=2, label='Mean')\n", - " ax5.axvline(mzero_median, color='green', linestyle='--', linewidth=2, label='Median')\n", + " ax5.axvline(mzero_mean, color=\"blue\", linestyle=\"-\", linewidth=2, label=\"Mean\")\n", + " ax5.axvline(\n", + " mzero_median, color=\"green\", linestyle=\"--\", linewidth=2, label=\"Median\"\n", + " )\n", " if n_clipped > 0:\n", - " ax5.axvline(mzero_sigclip, color='red', linestyle='-.', linewidth=2, label='σ-clip')\n", + " ax5.axvline(\n", + " mzero_sigclip, color=\"red\", linestyle=\"-.\", linewidth=2, label=\"σ-clip\"\n", + " )\n", " if len(valid_mzeros) >= 3:\n", - " ax5.axvline(mzero_trend, color='purple', linestyle=':', linewidth=2, label='Trend')\n", - " \n", - " ax5.set_xlabel('mzero', fontsize=10)\n", - " ax5.set_ylabel('Count', fontsize=10)\n", - " ax5.set_title(f'mzero Distribution\\nRange: [{np.min(valid_mzeros):.2f}, {np.max(valid_mzeros):.2f}]', \n", - " fontsize=10, weight='bold')\n", + " ax5.axvline(\n", + " mzero_trend, color=\"purple\", linestyle=\":\", linewidth=2, label=\"Trend\"\n", + " )\n", + "\n", + " ax5.set_xlabel(\"mzero\", fontsize=10)\n", + " ax5.set_ylabel(\"Count\", fontsize=10)\n", + " ax5.set_title(\n", + " f\"mzero Distribution\\nRange: [{np.min(valid_mzeros):.2f}, {np.max(valid_mzeros):.2f}]\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", " ax5.legend(fontsize=8)\n", - " ax5.grid(True, alpha=0.3, axis='y')\n", + " ax5.grid(True, alpha=0.3, axis=\"y\")\n", " else:\n", - " ax5.text(0.5, 0.5, 'No valid stars', transform=ax5.transAxes, ha='center', va='center')\n", - " \n", + " ax5.text(\n", + " 0.5,\n", + " 0.5,\n", + " \"No valid stars\",\n", + " transform=ax5.transAxes,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " )\n", + "\n", " # ========== Panel 6: SQM Comparison Table with Overlap Correction ==========\n", " ax6 = fig.add_subplot(gs[3, 0])\n", - " ax6.axis('off')\n", - " \n", + " ax6.axis(\"off\")\n", + "\n", " # Compare different methods INCLUDING overlap-corrected\n", - " comparison_data = [['Method', 'mzero', 'SQM', 'Error', 'Note']]\n", - " \n", + " comparison_data = [[\"Method\", \"mzero\", \"SQM\", \"Error\", \"Note\"]]\n", + "\n", " if mzero_mean is not None:\n", - " comparison_data.append([\n", - " 'Mean',\n", - " f'{mzero_mean:.3f}',\n", - " f'{sqm_val:.2f}',\n", - " f'{sqm_val - info[\"realsqm\"]:+.2f}',\n", - " '← Current'\n", - " ])\n", - " comparison_data.append([\n", - " 'Median',\n", - " f'{mzero_median:.3f}',\n", - " f'{sqm_median:.2f}',\n", - " f'{sqm_median - info[\"realsqm\"]:+.2f}',\n", - " ''\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"Mean\",\n", + " f\"{mzero_mean:.3f}\",\n", + " f\"{sqm_val:.2f}\",\n", + " f\"{sqm_val - info['realsqm']:+.2f}\",\n", + " \"← Current\",\n", + " ]\n", + " )\n", + " comparison_data.append(\n", + " [\n", + " \"Median\",\n", + " f\"{mzero_median:.3f}\",\n", + " f\"{sqm_median:.2f}\",\n", + " f\"{sqm_median - info['realsqm']:+.2f}\",\n", + " \"\",\n", + " ]\n", + " )\n", " if n_clipped > 0:\n", - " comparison_data.append([\n", - " 'σ-clipped',\n", - " f'{mzero_sigclip:.3f}',\n", - " f'{sqm_sigclip:.2f}',\n", - " f'{sqm_sigclip - info[\"realsqm\"]:+.2f}',\n", - " f'-{n_clipped} star'\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"σ-clipped\",\n", + " f\"{mzero_sigclip:.3f}\",\n", + " f\"{sqm_sigclip:.2f}\",\n", + " f\"{sqm_sigclip - info['realsqm']:+.2f}\",\n", + " f\"-{n_clipped} star\",\n", + " ]\n", + " )\n", " if len(valid_mzeros) >= 3:\n", - " comparison_data.append([\n", - " 'Trend (all)',\n", - " f'{mzero_trend:.3f}',\n", - " f'{sqm_trend:.2f}',\n", - " f'{sqm_trend - info[\"realsqm\"]:+.2f}',\n", - " f'R²={r_value_all**2:.2f}'\n", - " ])\n", + " comparison_data.append(\n", + " [\n", + " \"Trend (all)\",\n", + " f\"{mzero_trend:.3f}\",\n", + " f\"{sqm_trend:.2f}\",\n", + " f\"{sqm_trend - info['realsqm']:+.2f}\",\n", + " f\"R²={r_value_all**2:.2f}\",\n", + " ]\n", + " )\n", " if n_clipped > 0:\n", - " comparison_data.append([\n", - " 'Trend+σ-clip',\n", - " f'{mzero_trend_sigclip:.3f}',\n", - " f'{sqm_trend_sigclip:.2f}',\n", - " f'{sqm_trend_sigclip - info[\"realsqm\"]:+.2f}',\n", - " f'R²={r_value_clip**2:.2f}'\n", - " ])\n", - " \n", + " comparison_data.append(\n", + " [\n", + " \"Trend+σ-clip\",\n", + " f\"{mzero_trend_sigclip:.3f}\",\n", + " f\"{sqm_trend_sigclip:.2f}\",\n", + " f\"{sqm_trend_sigclip - info['realsqm']:+.2f}\",\n", + " f\"R²={r_value_clip**2:.2f}\",\n", + " ]\n", + " )\n", + "\n", " # Add overlap-corrected result\n", - " if sqm_val_corrected is not None and details_corrected.get('n_stars_excluded_overlaps', 0) > 0:\n", - " n_excl = details_corrected['n_stars_excluded_overlaps']\n", - " comparison_data.append([\n", - " 'Overlap-corrected',\n", - " f'{details_corrected[\"mzero\"]:.3f}',\n", - " f'{sqm_val_corrected:.2f}',\n", - " f'{sqm_val_corrected - info[\"realsqm\"]:+.2f}',\n", - " f'-{n_excl} overlap'\n", - " ])\n", - " \n", - " comparison_data.append([\n", - " 'Expected',\n", - " '—',\n", - " f'{info[\"realsqm\"]:.2f}',\n", - " '0.00',\n", - " 'Target'\n", - " ])\n", - " \n", - " comp_table = ax6.table(cellText=comparison_data, cellLoc='center', loc='center',\n", - " colWidths=[0.23, 0.18, 0.15, 0.15, 0.29])\n", + " if (\n", + " sqm_val_corrected is not None\n", + " and details_corrected.get(\"n_stars_excluded_overlaps\", 0) > 0\n", + " ):\n", + " n_excl = details_corrected[\"n_stars_excluded_overlaps\"]\n", + " comparison_data.append(\n", + " [\n", + " \"Overlap-corrected\",\n", + " f\"{details_corrected['mzero']:.3f}\",\n", + " f\"{sqm_val_corrected:.2f}\",\n", + " f\"{sqm_val_corrected - info['realsqm']:+.2f}\",\n", + " f\"-{n_excl} overlap\",\n", + " ]\n", + " )\n", + "\n", + " comparison_data.append(\n", + " [\"Expected\", \"—\", f\"{info['realsqm']:.2f}\", \"0.00\", \"Target\"]\n", + " )\n", + "\n", + " comp_table = ax6.table(\n", + " cellText=comparison_data,\n", + " cellLoc=\"center\",\n", + " loc=\"center\",\n", + " colWidths=[0.23, 0.18, 0.15, 0.15, 0.29],\n", + " )\n", " comp_table.auto_set_font_size(False)\n", " comp_table.set_fontsize(8)\n", " comp_table.scale(1, 2.2)\n", - " \n", + "\n", " # Style header\n", " for i in range(5):\n", - " comp_table[(0, i)].set_facecolor('#2196F3')\n", - " comp_table[(0, i)].set_text_props(weight='bold', color='white')\n", - " \n", + " comp_table[(0, i)].set_facecolor(\"#2196F3\")\n", + " comp_table[(0, i)].set_text_props(weight=\"bold\", color=\"white\")\n", + "\n", " # Highlight best method\n", " if len(comparison_data) > 2:\n", " errors = [abs(float(row[3])) for row in comparison_data[1:-1]]\n", " best_idx = np.argmin(errors) + 1\n", " for j in range(5):\n", - " comp_table[(best_idx, j)].set_facecolor('#C8E6C9')\n", - " \n", - " ax6.set_title('mzero Method Comparison', fontsize=11, weight='bold', pad=20)\n", - " \n", + " comp_table[(best_idx, j)].set_facecolor(\"#C8E6C9\")\n", + "\n", + " ax6.set_title(\"mzero Method Comparison\", fontsize=11, weight=\"bold\", pad=20)\n", + "\n", " # ========== Panel 7: Background Annuli ==========\n", " ax7 = fig.add_subplot(gs[3, 1])\n", - " \n", + "\n", " # Create visualization showing annulus regions\n", " height, width = np_image.shape\n", " y, x = np.ogrid[:height, :width]\n", " annulus_mask_img = np.zeros((height, width), dtype=bool)\n", " for centroid in star_centroids:\n", " cx, cy = centroid\n", - " dist_sq = (x - cx)**2 + (y - cy)**2\n", + " dist_sq = (x - cx) ** 2 + (y - cy) ** 2\n", " star_annulus = (dist_sq > ANNULUS_INNER**2) & (dist_sq <= ANNULUS_OUTER**2)\n", " annulus_mask_img |= star_annulus\n", - " \n", + "\n", " annulus_display = np.where(annulus_mask_img, np_image, np.nan)\n", - " ax7.imshow(annulus_display, cmap='viridis', vmin=vmin, vmax=vmax, origin='lower')\n", - " ax7.set_title(f'Background Annuli\\n(median={details[\"background_per_pixel\"]:.1f} ADU)', \n", - " fontsize=10, weight='bold')\n", - " ax7.set_xlabel('X (pixels)', fontsize=9)\n", - " ax7.set_ylabel('Y (pixels)', fontsize=9)\n", - " \n", + " ax7.imshow(annulus_display, cmap=\"viridis\", vmin=vmin, vmax=vmax, origin=\"lower\")\n", + " ax7.set_title(\n", + " f\"Background Annuli\\n(median={details['background_per_pixel']:.1f} ADU)\",\n", + " fontsize=10,\n", + " weight=\"bold\",\n", + " )\n", + " ax7.set_xlabel(\"X (pixels)\", fontsize=9)\n", + " ax7.set_ylabel(\"Y (pixels)\", fontsize=9)\n", + "\n", " # ========== Panel 8: Calculation Summary with Overlap Info ==========\n", " ax8 = fig.add_subplot(gs[3, 2])\n", - " ax8.axis('off')\n", - " \n", + " ax8.axis(\"off\")\n", + "\n", " # Find best method\n", " if mzero_mean is not None:\n", - " methods = ['Mean', 'Median', 'σ-clip', 'Trend', 'Trend+σ-clip', 'Overlap-corrected']\n", - " sqm_values = [sqm_val, sqm_median, sqm_sigclip, sqm_trend, sqm_trend_sigclip, sqm_val_corrected]\n", - " errors = [abs(sqm - info['realsqm']) for sqm in sqm_values if sqm is not None]\n", + " methods = [\n", + " \"Mean\",\n", + " \"Median\",\n", + " \"σ-clip\",\n", + " \"Trend\",\n", + " \"Trend+σ-clip\",\n", + " \"Overlap-corrected\",\n", + " ]\n", + " sqm_values = [\n", + " sqm_val,\n", + " sqm_median,\n", + " sqm_sigclip,\n", + " sqm_trend,\n", + " sqm_trend_sigclip,\n", + " sqm_val_corrected,\n", + " ]\n", + " errors = [abs(sqm - info[\"realsqm\"]) for sqm in sqm_values if sqm is not None]\n", " valid_methods = [m for m, sqm in zip(methods, sqm_values) if sqm is not None]\n", " if errors:\n", " best_method = valid_methods[np.argmin(errors)]\n", " else:\n", - " best_method = 'Mean'\n", + " best_method = \"Mean\"\n", " else:\n", - " best_method = 'N/A'\n", - " \n", - " corrected_str = f\"{sqm_val_corrected:.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", - " error_str = f\"{sqm_val_corrected - info['realsqm']:+.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", + " best_method = \"N/A\"\n", + "\n", + " corrected_str = (\n", + " f\"{sqm_val_corrected:.2f}\" if sqm_val_corrected is not None else \"N/A\"\n", + " )\n", + " error_str = (\n", + " f\"{sqm_val_corrected - info['realsqm']:+.2f}\"\n", + " if sqm_val_corrected is not None\n", + " else \"N/A\"\n", + " )\n", "\n", " summary_text = f\"\"\"CALCULATION SUMMARY\n", - "{'='*35}\n", + "{\"=\" * 35}\n", "\n", - "Stars: {details['n_matched_stars']} matched\n", - " {details['n_centroids']} total centroids\n", + "Stars: {details[\"n_matched_stars\"]} matched\n", + " {details[\"n_centroids\"]} total centroids\n", "\n", "OVERLAPS: {len(overlaps)} total\n", " CRITICAL: {len(critical_overlaps)}\n", @@ -1954,7 +2245,7 @@ "Background: Local annuli\n", " Aperture: {APERTURE_RADIUS} px\n", " Annulus: {ANNULUS_INNER}-{ANNULUS_OUTER} px\n", - " Sky: {details['background_per_pixel']:.2f} ADU/px\n", + " Sky: {details[\"background_per_pixel\"]:.2f} ADU/px\n", "\n", "mzero Statistics:\n", " Mean: {mzero_mean:.3f} ± {mzero_std:.3f}\n", @@ -1967,60 +2258,86 @@ "Trend Analysis:\n", " Slope: {slope_all:.4f} mag/mag\n", " R²: {r_value_all**2:.4f}\n", - " Sig? {'YES' if abs(r_value_all) > 0.5 else 'NO'}\n", + " Sig? {\"YES\" if abs(r_value_all) > 0.5 else \"NO\"}\n", "\n", "SQM Results:\n", " Without overlap correction:\n", " Current: {sqm_val:.2f} mag/arcsec²\n", - " Error: {sqm_val - info['realsqm']:+.2f}\n", + " Error: {sqm_val - info[\"realsqm\"]:+.2f}\n", " \n", " With overlap correction:\n", " Corrected: {corrected_str} mag/arcsec²\n", " Error: {error_str}\n", - " Excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)} stars\n", + " Excluded: {details_corrected.get(\"n_stars_excluded_overlaps\", 0)} stars\n", "\n", - "Expected: {info['realsqm']:.2f}\n", + "Expected: {info[\"realsqm\"]:.2f}\n", "\n", "Best Method: {best_method}\n", "\"\"\"\n", - " \n", - " ax8.text(0.05, 0.95, summary_text, \n", - " transform=ax8.transAxes, fontsize=7.5, \n", - " verticalalignment='top', fontfamily='monospace',\n", - " bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))\n", - " \n", + "\n", + " ax8.text(\n", + " 0.05,\n", + " 0.95,\n", + " summary_text,\n", + " transform=ax8.transAxes,\n", + " fontsize=7.5,\n", + " verticalalignment=\"top\",\n", + " fontfamily=\"monospace\",\n", + " bbox=dict(boxstyle=\"round\", facecolor=\"lightyellow\", alpha=0.8),\n", + " )\n", + "\n", " plt.tight_layout()\n", " plt.show()\n", - " \n", + "\n", " # Print detailed overlap information\n", " if overlaps:\n", " print(\"\\nDETAILED OVERLAP INFORMATION:\")\n", - " print(f\"{'='*100}\")\n", + " print(f\"{'=' * 100}\")\n", " for overlap in overlaps:\n", - " i, j = overlap['star1_idx'], overlap['star2_idx']\n", + " i, j = overlap[\"star1_idx\"], overlap[\"star2_idx\"]\n", " print(f\" [{overlap['type']:8}] Stars {i} ↔ {j}: {overlap['description']}\")\n", - " print(f\" Star {i}: mag={star_mags[i]:.2f}, flux={star_fluxes[i]:.0f} ADU\")\n", - " print(f\" Star {j}: mag={star_mags[j]:.2f}, flux={star_fluxes[j]:.0f} ADU\")\n", - " print(f\"{'='*100}\\n\")\n", - " \n", + " print(\n", + " f\" Star {i}: mag={star_mags[i]:.2f}, flux={star_fluxes[i]:.0f} ADU\"\n", + " )\n", + " print(\n", + " f\" Star {j}: mag={star_mags[j]:.2f}, flux={star_fluxes[j]:.0f} ADU\"\n", + " )\n", + " print(f\"{'=' * 100}\\n\")\n", + "\n", " # Print summary\n", " print(f\"\\n✓ Processed {filename}\")\n", " print(\"\\n WITHOUT overlap correction:\")\n", - " print(f\" SQM (mean): {sqm_val:.2f} (error: {sqm_val - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (median): {sqm_median:.2f} (error: {sqm_median - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (σ-clipped): {sqm_sigclip:.2f} (error: {sqm_sigclip - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (trend): {sqm_trend:.2f} (error: {sqm_trend - info['realsqm']:+.2f})\")\n", - " print(f\" SQM (trend+σ-clip): {sqm_trend_sigclip:.2f} (error: {sqm_trend_sigclip - info['realsqm']:+.2f})\")\n", - " \n", + " print(\n", + " f\" SQM (mean): {sqm_val:.2f} (error: {sqm_val - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (median): {sqm_median:.2f} (error: {sqm_median - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (σ-clipped): {sqm_sigclip:.2f} (error: {sqm_sigclip - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (trend): {sqm_trend:.2f} (error: {sqm_trend - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" SQM (trend+σ-clip): {sqm_trend_sigclip:.2f} (error: {sqm_trend_sigclip - info['realsqm']:+.2f})\"\n", + " )\n", + "\n", " if sqm_val_corrected is not None:\n", " print(\"\\n WITH overlap correction:\")\n", - " print(f\" SQM (overlap-corr): {sqm_val_corrected:.2f} (error: {sqm_val_corrected - info['realsqm']:+.2f})\")\n", - " print(f\" Stars excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)}/{details_corrected.get('n_matched_stars_original', 0)}\")\n", - " print(f\" Improvement: {(sqm_val_corrected - sqm_val):+.2f} mag/arcsec²\")\n", - " \n", + " print(\n", + " f\" SQM (overlap-corr): {sqm_val_corrected:.2f} (error: {sqm_val_corrected - info['realsqm']:+.2f})\"\n", + " )\n", + " print(\n", + " f\" Stars excluded: {details_corrected.get('n_stars_excluded_overlaps', 0)}/{details_corrected.get('n_matched_stars_original', 0)}\"\n", + " )\n", + " print(\n", + " f\" Improvement: {(sqm_val_corrected - sqm_val):+.2f} mag/arcsec²\"\n", + " )\n", + "\n", " print(f\"\\n Trend: slope={slope_all:.4f}, R²={r_value_all**2:.4f}\")\n", " print(f\" Best method: {best_method}\")\n", - " \n", + "\n", " # Flag issues\n", " issues = []\n", " if any(f <= 0 for f in star_fluxes):\n", @@ -2030,19 +2347,23 @@ " issues.append(f\"⚠️ High mzero scatter: {mzero_std:.3f}\")\n", " if abs(r_value_all) > 0.5:\n", " issues.append(f\"⚠️ Significant magnitude trend: R²={r_value_all**2:.3f}\")\n", - " if abs(sqm_val - info['realsqm']) > 0.5:\n", - " issues.append(f\"⚠️ Large error (no corr): {sqm_val - info['realsqm']:+.2f} mag/arcsec²\")\n", + " if abs(sqm_val - info[\"realsqm\"]) > 0.5:\n", + " issues.append(\n", + " f\"⚠️ Large error (no corr): {sqm_val - info['realsqm']:+.2f} mag/arcsec²\"\n", + " )\n", " if n_clipped > 0:\n", " issues.append(f\"ℹ️ {n_clipped} outliers removed by σ-clipping\")\n", " if overlaps:\n", - " issues.append(f\"⚠️ {len(overlaps)} aperture overlaps detected ({len(overlapping_stars)} stars affected)\")\n", - " \n", + " issues.append(\n", + " f\"⚠️ {len(overlaps)} aperture overlaps detected ({len(overlapping_stars)} stars affected)\"\n", + " )\n", + "\n", " if issues:\n", " print(\"\\n Notes:\")\n", " for issue in issues:\n", " print(f\" {issue}\")\n", - " \n", - " print(\"\")\n" + "\n", + " print(\"\")" ] }, { diff --git a/python/PiFinder/sqm/sqm.py b/python/PiFinder/sqm/sqm.py index d5426a020..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/sys_utils.py b/python/PiFinder/sys_utils.py index a3094dc08..7cc55d105 100644 --- a/python/PiFinder/sys_utils.py +++ b/python/PiFinder/sys_utils.py @@ -1,571 +1,791 @@ -import glob -import json +""" +NixOS system utilities for PiFinder. + +Uses: +- NetworkManager GLib bindings (gi.repository.NM) for WiFi management +- python-pam for password verification +- D-Bus for hostname/reboot/shutdown +- stdlib zipfile for backup/restore +- NixOS specialisations for camera switching +- systemd service for software updates +""" + +import os import re -from typing import Dict, Any +import json +import subprocess +import logging +from PiFinder import timez +from pathlib import Path +from typing import Optional + +import dbus import pam -import requests -import sh -from sh import wpa_cli, unzip, passwd +import gi -import socket -from PiFinder import utils -import logging +gi.require_version("NM", "1.0") +from gi.repository import GLib, NM # noqa: E402 + +from PiFinder.sys_utils_base import ( # noqa: E402 + NetworkBase, + BACKUP_PATH, # noqa: F401 + remove_backup, # noqa: F401 + backup_userdata, # noqa: F401 + restore_userdata, # noqa: F401 + restart_pifinder, # noqa: F401 +) + +AP_CONNECTION_NAME = "PiFinder-AP" + +logger = logging.getLogger("SysUtils.NixOS") + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- -BACKUP_PATH = "/home/pifinder/PiFinder_data/PiFinder_backup.zip" -logger = logging.getLogger("SysUtils") +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 + + +def _nm_client() -> NM.Client: + """Create a NetworkManager client (synchronous).""" + return NM.Client.new(None) -class Network: +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") - def set_host_name(self, hostname) -> None: + _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: 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 + # Local time for display, via the tz-aware timez helper (DTZ) + date = timez.utc_from_timestamp(mtime).astimezone().strftime("%d %b %H:%M") + targets.append( + ( + generation, + { + "ref": str(store_path), + "label": label, + "version": label, + "notes": None, + "subtitle": f"gen {generation} · {date}", + "channel": "rollback", + }, + ) + ) + targets.sort(key=lambda t: t[0], reverse=True) + return [entry for _generation, entry in targets] -def change_password(username, current_password, new_password): - """ - Changes the PiFinder User password +def get_upgrade_state() -> str: + """Poll upgrade status file written by the upgrade service.""" + try: + status = UPGRADE_STATUS_FILE.read_text().strip() + except FileNotFoundError: + # Service hasn't written status yet — check if it's still starting + svc = _upgrade_service_state() + if svc in ("activating", "active"): + return UPGRADE_STATE_RUNNING + if svc == "failed": + return UPGRADE_STATE_FAILED + return UPGRADE_STATE_IDLE + + if status == "success": + return UPGRADE_STATE_SUCCESS + elif status in ("failed", "unavailable", "connfail"): + return UPGRADE_STATE_FAILED + elif status.startswith("downloading") or status in ( + "starting", + "activating", + "rebooting", + ): + return UPGRADE_STATE_RUNNING + return UPGRADE_STATE_IDLE + + +def get_upgrade_progress() -> dict: + """Return structured upgrade progress for UI display. + + Returns dict with keys: + phase: "starting" | "downloading" | "activating" | "rebooting" + | "success" | "failed" | "unavailable" | "connfail" | "" + done: int (downloaded so far, in `unit`) + total: int (total to download, in `unit`) + unit: "bytes" | "paths" + percent: int (0-100) + + The download status line is "downloading /" in bytes; + a trailing " paths" marks the fallback where byte sizes were not + available and the figures are path counts instead. """ - result = passwd( - username, - _in=f"{current_password}\n{new_password}\n{new_password}\n", - _ok_code=(0, 10), + empty = { + "phase": "", + "done": 0, + "total": 0, + "unit": "bytes", + "percent": 0, + "item": "", + } + try: + raw = UPGRADE_STATUS_FILE.read_text().strip() + except FileNotFoundError: + svc = _upgrade_service_state() + if svc in ("activating", "active"): + return {**empty, "phase": "starting"} + if svc == "failed": + return {**empty, "phase": "failed"} + return empty + + svc = _upgrade_service_state() + if raw in ("starting", "activating") or raw.startswith("downloading "): + if svc in ("failed", "inactive"): + return {**empty, "phase": "failed"} + + if raw.startswith("downloading "): + body = raw[len("downloading ") :].strip() + unit = "bytes" + if body.endswith(" paths"): + unit = "paths" + body = body[: -len(" paths")].strip() + # body is "/" optionally followed by " " + nums, _sep, item = body.partition(" ") + parts = nums.split("/") + try: + done, total = int(parts[0]), int(parts[1]) + pct = int(done * 100 / total) if total > 0 else 0 + pct = max(0, min(100, pct)) + return { + "phase": "downloading", + "done": done, + "total": total, + "unit": unit, + "percent": pct, + "item": item.strip(), + } + except (ValueError, IndexError): + return {**empty, "phase": "downloading"} + if raw == "starting": + return {**empty, "phase": "starting"} + if raw == "activating": + return {**empty, "phase": "activating", "percent": 100} + if raw == "rebooting": + return {**empty, "phase": "rebooting", "percent": 100} + if raw == "success": + return {**empty, "phase": "success", "percent": 100} + if raw == "unavailable": + return {**empty, "phase": "unavailable"} + if raw == "connfail": + return {**empty, "phase": "connfail"} + if raw == "failed": + return {**empty, "phase": "failed"} + return empty + + +def get_upgrade_log_tail(lines: int = 3) -> str: + """Last N lines from upgrade journal for UI display.""" + result = _run( + [ + "journalctl", + "-u", + "pifinder-upgrade.service", + "-n", + str(lines), + "--no-pager", + "-o", + "cat", + ] ) + return result.stdout.strip() if result.returncode == 0 else "" - if result.exit_code == 0: - return True - else: - return False +def update_software(ref: str = "release", selection: Optional[dict] = None) -> bool: + """Start the upgrade service (non-blocking). -def switch_cam_imx477() -> None: - logger.info("SYS: Switching cam to imx477") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx477") + The service downloads, sets the boot profile, and reboots. + UI should poll get_upgrade_progress() for status. + """ + return start_upgrade(ref=ref, selection=selection) -def switch_cam_imx296() -> None: - logger.info("SYS: Switching cam to imx296") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx296") +# --------------------------------------------------------------------------- +# Password management (python-pam + chpasswd) +# --------------------------------------------------------------------------- -def switch_cam_imx462() -> None: - logger.info("SYS: Switching cam to imx462") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx462") +def verify_password(username: str, password: str) -> bool: + """Verify a password against PAM.""" + p = pam.pam() + return p.authenticate(username, password, service="pifinder") -def check_and_sync_gpsd_config(baud_rate: int) -> bool: - """ - Checks if GPSD configuration matches the desired baud rate, - and updates it only if necessary. +def change_password(username: str, current_password: str, new_password: str) -> bool: + """Change the user password via chpasswd.""" + if not verify_password(username, current_password): + return False + result = subprocess.run( + ["sudo", "chpasswd"], + input=f"{username}:{new_password}\n", + capture_output=True, + text=True, + ) + return result.returncode == 0 - Args: - baud_rate: The desired baud rate (9600 or 115200) - Returns: - True if configuration was updated, False if already correct - """ - logger.info(f"SYS: Checking GPSD config for baud rate {baud_rate}") +# --------------------------------------------------------------------------- +# Camera switching (specialisations + reboot) +# --------------------------------------------------------------------------- - try: - # Read current config - with open("/etc/default/gpsd", "r") as f: - content = f.read() - - # Determine expected GPSD_OPTIONS - if baud_rate == 115200: - # NOTE: the space before -s in the next line is really needed - expected_options = 'GPSD_OPTIONS=" -s 115200"' - else: - expected_options = 'GPSD_OPTIONS=""' - - # Check if update is needed - current_match = re.search(r"^GPSD_OPTIONS=.*$", content, re.MULTILINE) - if current_match: - current_options = current_match.group(0) - if current_options == expected_options: - logger.info("SYS: GPSD config already correct, no update needed") - return False - - # Update is needed - logger.info(f"SYS: GPSD config mismatch, updating to {expected_options}") - update_gpsd_config(baud_rate) - return True - - except Exception as e: - logger.error(f"SYS: Error checking/syncing GPSD config: {e}") - return False +CAMERA_TYPE_FILE = "/var/lib/pifinder/camera-type" -def update_gpsd_config(baud_rate: int) -> None: +def switch_camera(cam_type: str) -> None: """ - Updates the GPSD configuration file with the specified baud rate - and restarts the GPSD service. - - Args: - baud_rate: The baud rate to configure (9600 or 115200) + Switch camera via NixOS specialisation. + Requires reboot (dtoverlay change). """ - logger.info(f"SYS: Updating GPSD config with baud rate {baud_rate}") + 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) - try: - # Read the current config - with open("/etc/default/gpsd", "r") as f: - lines = f.readlines() - - # Update GPSD_OPTIONS line - updated_lines = [] - for line in lines: - if line.startswith("GPSD_OPTIONS="): - if baud_rate == 115200: - # NOTE: the space before -s in the next line is really needed - updated_lines.append('GPSD_OPTIONS=" -s 115200"\n') - else: - updated_lines.append('GPSD_OPTIONS=""\n') - else: - updated_lines.append(line) - - # Write the updated config to a temporary file - with open("/tmp/gpsd.conf", "w") as f: - f.writelines(updated_lines) - # Copy the temp file to the actual location with sudo - sh.sudo("cp", "/tmp/gpsd.conf", "/etc/default/gpsd") - - # Restart GPSD service - sh.sudo("systemctl", "restart", "gpsd") - - logger.info("SYS: GPSD configuration updated and service restarted") +def get_camera_type() -> list[str]: + try: + with open(CAMERA_TYPE_FILE) as f: + return [f.read().strip()] + except FileNotFoundError: + return ["imx462"] - except Exception as e: - logger.error(f"SYS: Error updating GPSD config: {e}") - raise +def switch_cam_imx477() -> None: + logger.info("SYS: Switching cam to imx477") + switch_camera("imx477") -# Raspberry Pi red power LED. It is a plain gpio-led (on/off only, not -# dimmable), so the brightness file is effectively a boolean. -PWR_LED_PATH = "/sys/class/leds/PWR" +def switch_cam_imx296() -> None: + logger.info("SYS: Switching cam to imx296") + switch_camera("imx296") -def set_power_led(on: bool) -> None: - """ - Turn the Raspberry Pi's red PWR LED on or off. - The LED is not dimmable, so this is strictly on/off. We set the kernel - trigger to "none" first, otherwise the firmware's "default-on" trigger - keeps re-asserting the LED, then write the brightness directly. Uses - passwordless sudo, like the other privileged helpers in this module. - """ - value = "1" if on else "0" - sh.sudo( - "sh", - "-c", - f"echo none > {PWR_LED_PATH}/trigger; " - f"echo {value} > {PWR_LED_PATH}/brightness", - ) - logger.info("SYS: Power LED %s", "on" if on else "off") +def switch_cam_imx462() -> None: + logger.info("SYS: Switching cam to imx462") + switch_camera("imx462") # --------------------------------------------------------------------------- -# NixOS migration +# GPSD config (declarative on NixOS — no-ops) # --------------------------------------------------------------------------- -MIGRATION_PROGRESS_FILE = "/tmp/nixos_migration_progress" -MIGRATION_SCRIPT = "/home/pifinder/PiFinder/python/scripts/nixos_migration.sh" - -def _fetch_migration_sha256(version_info: dict) -> str: - """Fetch SHA256 from sidecar URL, falling back to hardcoded value.""" - sha256_url = version_info.get("migration_sha256_url", "") - if sha256_url: - try: - resp = requests.get(sha256_url, timeout=15) - if resp.status_code == 200: - sha256 = resp.text.strip().split()[0] - logger.info(f"SYS: Fetched migration SHA256: {sha256[:16]}...") - return sha256 - logger.warning(f"SYS: SHA256 fetch returned {resp.status_code}") - except requests.exceptions.RequestException as e: - logger.warning(f"SYS: Failed to fetch SHA256: {e}") - - sha256 = version_info.get("migration_sha256", "") - if sha256: - logger.info("SYS: Using hardcoded migration SHA256") - return sha256 - - -def start_nixos_migration(version_info: dict) -> None: +def check_and_sync_gpsd_config(baud_rate: int) -> bool: """ - Start the NixOS migration process in the background. - - Raises ValueError if migration_url or a migration SHA256 cannot be - obtained — an in-place OS replacement must not run without checksum - verification. + On NixOS, GPSD config is managed declaratively via services.nix. + This is a no-op. """ - url = version_info.get("migration_url", "") - if not url: - raise ValueError("Missing migration_url") - sha256 = _fetch_migration_sha256(version_info) - if not sha256: - raise ValueError( - "No migration SHA256 available (neither migration_sha256_url nor " - "migration_sha256 produced a value); refusing to migrate without " - "checksum verification" - ) - display_class = str(version_info.get("display_class", "")) - display_resolution_value = version_info.get("display_resolution", "") - if isinstance(display_resolution_value, (list, tuple)): - display_resolution = "x".join(str(part) for part in display_resolution_value) - else: - display_resolution = str(display_resolution_value) + logger.info("SYS: GPSD baud rate %d — managed by NixOS configuration", baud_rate) + return False - 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) +def update_gpsd_config(baud_rate: int) -> None: + """On NixOS, GPSD configuration is declarative. This is a no-op.""" + logger.info( + "SYS: GPSD config is managed declaratively on NixOS (baud=%d)", baud_rate + ) - def _log_output(line): - logger.info(f"SYS: migration: {line.strip()}") - def _log_error(line): - logger.error(f"SYS: migration: {line.strip()}") +# Raspberry Pi red power LED — a plain gpio-led (on/off only, not dimmable). +PWR_LED_PATH = Path("/sys/class/leds/PWR") - 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 set_power_led(on: bool) -> None: + """Turn the Raspberry Pi's red PWR LED on or off. -def get_migration_progress() -> Dict[str, Any]: + The kernel trigger is set to "none" first, otherwise the firmware's + "default-on" trigger keeps re-asserting the LED. Direct sysfs writes — + pwm-permissions (services.nix) makes these files user-writable at boot, + so no sudo is needed. A missing LED (dev box, other SBC) raises OSError, + which the caller treats as non-fatal. """ - 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 {} + (PWR_LED_PATH / "trigger").write_text("none") + (PWR_LED_PATH / "brightness").write_text("1" if on else "0") diff --git a/python/PiFinder/sys_utils_base.py b/python/PiFinder/sys_utils_base.py new file mode 100644 index 000000000..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 77ca71504..9f6952e58 100644 --- a/python/PiFinder/sys_utils_fake.py +++ b/python/PiFinder/sys_utils_fake.py @@ -1,244 +1,91 @@ -import socket import logging -import os -import zipfile -import tempfile - -# For testing, use a directory structure that mimics the production setup -# but in a writable location. The server serves from /home/pifinder/PiFinder_data -# so we need to create a backup file that can be served from there. -# Since we can't write to /home/pifinder as a regular user, we'll use the current -# user's directory structure that mirrors the production layout. -_pifinder_data_dir = os.path.expanduser("~/PiFinder_data") -os.makedirs(_pifinder_data_dir, exist_ok=True) -BACKUP_PATH = os.path.join(_pifinder_data_dir, "PiFinder_backup.zip") + +from PiFinder.sys_utils_base import ( + NetworkBase, + BACKUP_PATH, +) logger = logging.getLogger("SysUtils.Fake") -class Network: +class Network(NetworkBase): """ - Provides wifi network info + Fake network for testing/development. """ def __init__(self): - pass + self._wifi_mode = "Client" + self._wifi_networks: list = [] - def populate_wifi_networks(self): - """ - Parses wpa_supplicant.conf to get current config - """ + def populate_wifi_networks(self) -> None: pass - def get_wifi_networks(self): - return "" - - def delete_wifi_network(self, network_id): - """ - Immediately deletes a wifi network - """ + def delete_wifi_network(self, network_id) -> None: pass - def add_wifi_network(self, ssid, key_mgmt, psk=None): - """ - Add a wifi network - """ + def add_wifi_network(self, ssid, key_mgmt, psk=None) -> None: pass - def get_ap_name(self): + def get_ap_name(self) -> str: return "UNKN" - def set_ap_name(self, ap_name): + def set_ap_name(self, ap_name: str) -> None: pass - def get_host_name(self): - return socket.gethostname() - - def get_connected_ssid(self): - """ - Returns the SSID of the connected wifi network or - None if not connected or in AP mode - """ - return "UNKN" - - def set_host_name(self, hostname): - if hostname == self.get_host_name(): - return - - def wifi_mode(self): + def get_connected_ssid(self) -> str: return "UNKN" - def set_wifi_mode(self, mode): + def set_host_name(self, hostname: str) -> None: pass - def local_ip(self): - return "NONE" + def _go_ap(self) -> None: + logger.info("SYS: Fake switching to AP") + def _go_client(self) -> None: + logger.info("SYS: Fake switching to Client") -def remove_backup(): - """ - Removes backup file - """ - try: - if os.path.exists(BACKUP_PATH): - os.remove(BACKUP_PATH) - except OSError: - pass +def remove_backup() -> None: + pass -def backup_userdata(): - """ - Back up userdata to a single zip file for later - restore. Returns the path to the zip file. - - Backs up: - config.json - observations.db - obslist/* - """ - remove_backup() - - # Use actual files from ~/PiFinder_data directory - source_dir = _pifinder_data_dir - - # Create zip file with actual user data - with zipfile.ZipFile(BACKUP_PATH, "w", zipfile.ZIP_DEFLATED) as zipf: - # Add config.json if it exists - config_path = os.path.join(source_dir, "config.json") - if os.path.exists(config_path): - zipf.write(config_path, "home/pifinder/PiFinder_data/config.json") - - # Add observations.db if it exists - db_path = os.path.join(source_dir, "observations.db") - if os.path.exists(db_path): - zipf.write(db_path, "home/pifinder/PiFinder_data/observations.db") - - # Add all files from obslists directory if it exists - obslists_dir = os.path.join(source_dir, "obslists") - if os.path.exists(obslists_dir): - for filename in os.listdir(obslists_dir): - file_path = os.path.join(obslists_dir, filename) - if os.path.isfile(file_path): - zipf.write( - file_path, f"home/pifinder/PiFinder_data/obslists/{filename}" - ) +def backup_userdata() -> str: return BACKUP_PATH -def restore_userdata(zip_path): - """ - Compliment to backup_userdata - "restores" userdata +def restore_userdata(zip_path) -> None: + pass - For the fake version, this compares the zip contents - with the current ~/PiFinder_data contents and throws - an exception if they don't match. - """ - import zipfile - import filecmp - - if not os.path.exists(zip_path): - raise FileNotFoundError(f"Backup file not found: {zip_path}") - - # Extract zip to temporary directory for comparison - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(zip_path, "r") as zipf: - # Extract all files - zipf.extractall(temp_dir) - - # Compare extracted files with actual files in ~/PiFinder_data - extracted_base = os.path.join(temp_dir, "home", "pifinder", "PiFinder_data") - actual_base = _pifinder_data_dir - - if not os.path.exists(extracted_base): - raise ValueError( - "Invalid backup file: missing expected directory structure" - ) - - # Check each file that should exist - files_to_check = ["config.json", "observations.db"] - - for filename in files_to_check: - extracted_file = os.path.join(extracted_base, filename) - actual_file = os.path.join(actual_base, filename) - - # If file exists in backup but not in actual directory - if os.path.exists(extracted_file) and not os.path.exists(actual_file): - raise ValueError( - f"Backup contains {filename} but it doesn't exist in {actual_base}" - ) - - # If file exists in both, compare contents - if os.path.exists(extracted_file) and os.path.exists(actual_file): - if not filecmp.cmp(extracted_file, actual_file, shallow=False): - raise ValueError( - f"Backup file {filename} differs from current version in {actual_base}" - ) - - # Check obslists directory - extracted_obslists = os.path.join(extracted_base, "obslists") - actual_obslists = os.path.join(actual_base, "obslists") - - if os.path.exists(extracted_obslists): - if not os.path.exists(actual_obslists): - raise ValueError( - "Backup contains obslists directory but it doesn't exist in current data" - ) - - # Compare each file in obslists - for filename in os.listdir(extracted_obslists): - extracted_obslist = os.path.join(extracted_obslists, filename) - actual_obslist = os.path.join(actual_obslists, filename) - - if os.path.isfile(extracted_obslist): - if not os.path.exists(actual_obslist): - raise ValueError( - f"Backup contains obslist {filename} but it doesn't exist in current obslists" - ) - - if not filecmp.cmp( - extracted_obslist, actual_obslist, shallow=False - ): - raise ValueError( - f"Backup obslist {filename} differs from current version" - ) - - # If we get here, all files match - logger.info("Restore validation successful: backup contents match current data") - return True - - -def shutdown(): - """ - shuts down the Pi - """ + +def shutdown() -> None: logger.info("SYS: Initiating Shutdown") - return True -def update_software(): - """ - Uses systemctl to git pull and then restart - service - """ - logger.info("SYS: Running update") +def update_software(ref: str = "release", selection=None): + logger.info("SYS: Running update (ref=%s)", ref) return True -def restart_pifinder(): - """ - Uses systemctl to restart the PiFinder - service - """ +def list_rollback_targets() -> list: + return [] + + +def get_upgrade_progress() -> dict: + return { + "phase": "", + "done": 0, + "total": 0, + "unit": "bytes", + "percent": 0, + "item": "", + } + + +def restart_pifinder() -> None: logger.info("SYS: Restarting PiFinder") - return True -def restart_system(): - """ - Restarts the system - """ +def restart_system() -> None: logger.info("SYS: Initiating System Restart") @@ -252,29 +99,41 @@ def go_wifi_cli(): return True +def get_wifi_mode() -> str: + return "Client" + + def verify_password(username, password): - """ - Checks the provided password against the provided user - password - """ return True def change_password(username, current_password, new_password): - """ - Changes the PiFinder User password - """ return False +def get_camera_type() -> list[str]: + return ["imx462"] + + def switch_cam_imx477() -> None: logger.info("SYS: Switching cam to imx477") - logger.info('sh.sudo("python", "-m", "PiFinder.switch_camera", "imx477")') def switch_cam_imx296() -> None: logger.info("SYS: Switching cam to imx296") - logger.info('sh.sudo("python", "-m", "PiFinder.switch_camera", "imx296")') + + +def switch_cam_imx462() -> None: + logger.info("SYS: Switching cam to imx462") + + +def check_and_sync_gpsd_config(baud_rate: int) -> bool: + logger.info("SYS: Checking GPSD config for baud rate %d (fake)", baud_rate) + return False + + +def update_gpsd_config(baud_rate: int) -> None: + logger.info("SYS: Updating GPSD config with baud rate %d (fake)", baud_rate) def set_power_led(on: bool) -> None: diff --git a/python/PiFinder/tetra3 b/python/PiFinder/tetra3 deleted file mode 160000 index 38c3f48f5..000000000 --- a/python/PiFinder/tetra3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 38c3f48f57d1005e9b65cbb26136f9f13ec0a1b0 diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index da51395a6..e1db55fff 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 @@ -131,7 +131,7 @@ class UIModule: def __init__( self, - display_class: Type[DisplayBase], + display_class: DisplayBase, camera_image, shared_state, command_queues, @@ -482,6 +482,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 6b48ddda6..d7966e6fd 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -211,21 +211,7 @@ def switch_cam_imx462(ui_module: UIModule) -> None: def get_camera_type(ui_module: UIModule) -> list[str]: - cam_id = "000" - - # read config.txt into a list - with open("/boot/config.txt", "r") as boot_in: - boot_lines = list(boot_in) - - # Look for the line without a comment... - for line in boot_lines: - if line.startswith("dtoverlay=imx"): - cam_id = line[10:16] - # imx462 uses imx290 driver - if cam_id == "imx290": - cam_id = "imx462" - - return [cam_id] + return sys_utils.get_camera_type() def switch_language(ui_module: UIModule) -> None: @@ -237,9 +223,6 @@ def switch_language(ui_module: UIModule) -> None: ) lang.install() logger.info("Switch Language: %s", iso2_code) - if iso2_code == "zh": - # Chinese requires a new font, so we have to restart - restart_pifinder(ui_module) def go_wifi_ap(ui_module: UIModule) -> None: @@ -255,9 +238,15 @@ def go_wifi_cli(ui_module: UIModule) -> None: def get_wifi_mode(ui_module: UIModule) -> list[str]: - wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" - with open(wifi_txt, "r") as wfs: - return [wfs.read()] + # Report the live mode from NetworkManager (as the web UI does), not the + # static wifi_status.txt — that file is written once at setup and never + # tracks reality, so it showed "Client" while the device was on the AP. + try: + return [sys_utils.get_wifi_mode()] + except Exception: + wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" + with open(wifi_txt, "r") as wfs: + return [wfs.read()] def set_location(ui_module: UIModule) -> None: diff --git a/python/PiFinder/ui/console.py b/python/PiFinder/ui/console.py index db20086a4..6ac097223 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 f6b6c993d..9ecd79aee 100644 --- a/python/PiFinder/ui/menu_manager.py +++ b/python/PiFinder/ui/menu_manager.py @@ -126,7 +126,7 @@ def __init__( self._stack_anim_counter: float = 0 self._stack_anim_direction: int = 0 - self.stack: list[type[UIModule]] = [] + self.stack: list[UIModule] = [] self.add_to_stack(menu_structure.pifinder_menu) self.marking_menu_stack: list[MarkingMenu] = [] @@ -150,7 +150,7 @@ def __init__( def screengrab(self): self.ss_count += 1 - filename = f"{self.stack[-1].__uuid__}_{self.ss_count :0>3}_{self.stack[-1].title.replace('/','-')}" + filename = f"{self.stack[-1].__uuid__}_{self.ss_count:0>3}_{self.stack[-1].title.replace('/', '-')}" ss_imagepath = self.ss_path + f"/{filename}.png" ss = self.shared_state.screen().copy() ss.save(ss_imagepath) @@ -159,9 +159,9 @@ def screengrab(self): def remove_from_stack(self) -> None: if len(self.stack) > 1: self._stack_top_image = self.stack[-1].screen.copy() - self.stack[-1].inactive() # type: ignore[call-arg] + self.stack[-1].inactive() self.stack.pop() - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() self._stack_anim_counter = time.time() + self.config_object.get_option( "menu_anim_speed", 0 ) @@ -195,7 +195,7 @@ def add_to_stack(self, item: dict) -> None: item dict """ if item.get("state") is not None: - self.stack[-1].inactive() # type: ignore[call-arg] + self.stack[-1].inactive() self.stack.append(item["state"]) else: self.stack.append( @@ -215,7 +215,7 @@ def add_to_stack(self, item: dict) -> None: if item.get("stateful", False): item["state"] = self.stack[-1] - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() if len(self.stack) > 1: self._stack_anim_counter = time.time() + self.config_object.get_option( "menu_anim_speed", 0 @@ -223,7 +223,7 @@ def add_to_stack(self, item: dict) -> None: self._stack_anim_direction = -1 def message(self, message: str, timeout: float) -> None: - self.stack[-1].message(message, timeout) # type: ignore[arg-type] + self.stack[-1].message(message, timeout) def jump_to_label(self, label: str) -> None: # to prevent many recent/object UI modules @@ -235,7 +235,7 @@ def jump_to_label(self, label: str) -> None: for stack_index, ui_module in enumerate(self.stack): if ui_module.item_definition.get("label", "") == label: self.stack = self.stack[: stack_index + 1] - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() return # either this is not a special case, or we didn't find # the label already in the stack @@ -290,7 +290,7 @@ def update(self) -> None: return # Business as usual, update the module at the top of the stack - self.stack[-1].update() # type: ignore[call-arg] + self.stack[-1].update() # are we animating? if self._stack_anim_counter > time.time(): diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index 5815f760a..48d1e8bb8 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -8,21 +8,18 @@ strip and star selectors on the images. """ -import sys import time from collections import deque import numpy as np from PIL import Image, ImageChops -from PiFinder import focus, utils +from PiFinder import focus from PiFinder.ui.camera_render import resize_for_display from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder.ui.base import UIModule from PiFinder.ui.ui_utils import outline_text -sys.path.append(str(utils.tetra3_dir)) - # Focus indicator tuning (see docs/ax/ui/CONTEXT.md "Focus indicator" and # docs/adr/0005-focus-hfd-self-contained-in-ui.md). Starting values -- adjust # on real hardware. @@ -45,8 +42,6 @@ class UIPreview(UIModule): - from PiFinder import tetra3 - __title__ = "CAMERA" __help_name__ = "camera" _STAR_ICON = "\uf005" # NerdFont star icon (Font Awesome solid) diff --git a/python/PiFinder/ui/software.py b/python/PiFinder/ui/software.py index c95fcbc42..bdadf0d8b 100644 --- a/python/PiFinder/ui/software.py +++ b/python/PiFinder/ui/software.py @@ -1,554 +1,823 @@ #!/usr/bin/python # -*- coding:utf-8 -*- """ -This module contains the UI Module classes for -software updates and NixOS migration. +UI modules for software updates, channel selection, and release notes. + +Channels: + - stable: release entries from update-manifest.json + - beta: prerelease entries from update-manifest.json + - unstable: trunk + testable PR entries from update-manifest.json """ +import json import logging -import time -from typing import Any, Optional, TYPE_CHECKING +import re +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: - return None - if res.status_code != 200: - return None - try: - data = res.json() - except ValueError: - return None - if not isinstance(data, dict): +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 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. + """ + 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 + + +def _current_store_path() -> Optional[str]: + """Store path of the running build, or None when unknown. + + Read from current-build.json (written by the upgrade service). The store + path — not the version string — is a build's identity: a re-cut release + keeps its version/label but is a different system. """ try: - _tmp_split = current_version.split(".") - current_version_compare = ( - int(_tmp_split[0]), - int(_tmp_split[1]), - int(_tmp_split[2]), - ) + with open(utils.current_build_json) as f: + return json.load(f).get("store_path") or None + except (OSError, ValueError): + return None - _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 +def _hide_current_build( + entries: List[dict], current_ref: Optional[str] +) -> List[dict]: + """Hide only the exact running build, matched by store path. - except Exception: - return True + A same-version entry with a different store path (e.g. a re-cut release) + is a real upgrade and must stay visible. When the current build is + unknown, nothing is hidden — offering the running build is harmless, + hiding a real upgrade is not. + """ + if not current_ref: + return list(entries) + return [e for e in entries if e.get("ref") != current_ref] 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 running build. - self.draw.text( - (10, draw_pos), - f"{self._release_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), - ) + Matches by store path (the build's identity) first, falling back to + the version string when the current store path is unknown. + """ + current_ref = _current_store_path() + for entries in self._channels.values(): + for entry in entries: + if current_ref and entry.get("ref") == current_ref: + return entry.get("subtitle") + if not current_ref and entry.get("version") == self._software_version: + return entry.get("subtitle") - # 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 == "rollback": + self._version_list = entries + else: + self._version_list = _hide_current_build(entries, _current_store_path()) + 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), + ) + self.draw.text( + (10, 105), + _("client mode"), 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() + def _draw_offline(self): y = self.display_class.titlebar_height + 2 - + ver_scroller = self._get_scroller( + "offline_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) self.draw.text( - (0, y), - _("Major Upgrade"), - font=self.fonts.bold.font, + (10, 90), + _("No internet -"), + 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), + (10, 105), + _("check WiFi"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - y += 16 - # Separator - self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) - y += 4 + def _draw_browse(self): + y = self.display_class.titlebar_height + 2 - self.draw.text( - (0, y), - _("IRREVERSIBLE"), - font=self.fonts.bold.font, - fill=self.colors.get(255), + # Current version + ver_scroller = self._get_scroller( + "browse_cur_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, ) + ver_scroller.draw((0, y)) y += 12 + if self._software_subtitle: + sub_scroller = self._get_scroller( + "browse_cur_sub", + self._software_subtitle, + self.fonts.base, + self.colors.get(128), + self.fonts.base.line_length, + ) + sub_scroller.draw((0, y)) + y += 12 + else: + y += 2 - 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), + # Channel selector + channel_name = ( + self._channel_names[self._channel_index].capitalize() + if self._channel_names + else "---" ) - y += 11 + if self._focus == "channel": + self.draw.text( + (0, y), + 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 += 14 + + self._draw_separator(y) + y += 4 + + # Version list + if not self._version_list: + self.draw.text( + (10, y + 10), + _("No versions"), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + 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, 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 self.draw.text( (0, y), - _("Power + WiFi req"), + _("Update to:"), font=self.fonts.base.font, fill=self.colors.get(128), ) - y += 11 + y += 14 - if not self._version_info.get( - "migration_sha256_url" - ) and not self._version_info.get("migration_sha256"): - self.draw.text( - (0, y), - _("No checksum avail."), - font=self.fonts.base.font, - fill=self.colors.get(128), + 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 + + 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, ) - y += 11 + sub_scroller.draw((0, y)) + y += 14 - y += 5 + self._draw_separator(y) + y += 4 - # Options - for i, label in enumerate(self._options): - oy = y + i * 12 + for i, opt in enumerate(self._confirm_options): + item_y = y + i * 12 + if i == self._confirm_index: + self.draw.text( + (0, item_y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, item_y), + _(opt), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + else: + self.draw.text( + (10, item_y), + _(opt), + font=self.fonts.base.font, + fill=self.colors.get(192), + ) + + def _draw_failed(self): + y = self.display_class.titlebar_height + 20 + reason = self._fail_reason or _("Update failed!") + for line in reason.split("\n"): self.draw.text( - (10, oy), - label, + (10, y), + line, font=self.fonts.bold.font, fill=self.colors.get(255), ) - if i == self._option_index: + y += 14 + y += 6 + for label in ("Retry", "Cancel"): + if self._fail_option == label: self.draw.text( - (0, oy), + (0, y), self._RIGHT_ARROW, font=self.fonts.bold.font, fill=self.colors.get(255), ) + self.draw.text( + (10, y), + _(label), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += 12 - return self.screen_update() + # ------------------------------------------------------------------ + # Main update loop + # ------------------------------------------------------------------ - def key_up(self): - self._option_index = (self._option_index - 1) % len(self._options) + def update(self, force=False): + self.clear_screen() - def key_down(self): - self._option_index = (self._option_index + 1) % len(self._options) + if self._phase == "upgrading": + self._draw_upgrading() + return self.screen_update() - def key_left(self): - return True + if self._phase == "failed": + self._draw_failed() + return self.screen_update() - def key_right(self): - if self._options[self._option_index] == _("Cancel"): - self.remove_from_stack() - elif self._options[self._option_index] == _("Confirm"): - self.add_to_stack( - { - "class": UIMigrationProgress, - "version_info": self._version_info, - } - ) + if self._wifi_mode != "Client": + self._draw_wifi_warning() + return self.screen_update() + 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() -class UIMigrationProgress(UIModule): - """ - Migration download and preparation progress screen. - Triggers the actual migration via sys_utils. - """ + if self._phase == "offline": + self._draw_offline() + return self.screen_update() - __title__ = "UPGRADE" + if self._phase == "browse": + self._draw_browse() + elif self._phase == "confirm": + self._draw_confirm() - 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, - ) + return self.screen_update() - def active(self): - super().active() - if not self._started: - self._started = True - self._start_migration() + # ------------------------------------------------------------------ + # Key handlers + # ------------------------------------------------------------------ - 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']}" + 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": + # Re-enters update_software() → "upgrading" phase, so Retry + # reuses the same progress UI as the first attempt. + self.update_software() + else: + self.remove_from_stack() + elif self._phase == "browse": + if self._focus == "channel" and self._channel_names: + self._channel_index = (self._channel_index + 1) % len( + self._channel_names ) - self._status = _("Not supported") - return - sys_utils.start_nixos_migration(version_info) - except AttributeError: - logger.error("sys_utils.start_nixos_migration not available") - self._status = _("Not supported") - self._status_layout.set_text(self._status) - self._terminal_failure = True - except Exception as e: - logger.error(f"Migration failed to start: {e}") - self._status = _("Failed: ") + str(e) - self._status_layout.set_text(self._status) - self._terminal_failure = True + self._refresh_version_list() + elif self._focus == "list" and self._version_list: + self._selected_version = self._version_list[self._list_index] + self._confirm_options = [] + if not self._selected_version.get("unavailable"): + self._confirm_options.append("Install") + if self._selected_version.get("notes"): + self._confirm_options.append("Notes") + self._confirm_options.append("Cancel") + self._confirm_index = 0 + self._phase = "confirm" + elif self._phase == "confirm": + opt = self._confirm_options[self._confirm_index] + if opt == "Install": + self.update_software() + elif opt == "Notes": + notes = self._selected_version.get("notes") + if notes: + self.add_to_stack({"class": UIReleaseNotes, "notes_text": notes}) + elif opt == "Cancel": + self._phase = "browse" - def update(self, force=False): - time.sleep(1 / 30) + def key_left(self): + 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\nlonger available") + self._fail_option = "Cancel" + return + self._phase = "upgrading" self.clear_screen() + self._draw_upgrading() + self.screen_update() + + ref = self._selected_version.get("ref") or "release" + selection = { + "ref": ref, + "label": self._selected_version.get("label"), + "version": self._selected_version.get("version"), + "channel": self._selected_version.get("channel"), + } + if not sys_utils.update_software(ref=ref, selection=selection): + self._phase = "failed" + self._fail_option = "Retry" + + def _draw_upgrading(self): y = self.display_class.titlebar_height + 2 - # Try to read progress from sys_utils. AttributeError happens when - # running against sys_utils_fake (no migration support); the helper - # itself swallows OS/JSON errors and returns {}. - try: - progress = sys_utils.get_migration_progress() - except AttributeError: - progress = None - if progress: - try: - self._progress = int(progress.get("percent", self._progress)) - except (TypeError, ValueError): - pass # bad/missing percent — keep prior value - new_status = progress.get("status", self._status) - if isinstance(new_status, str) and new_status != self._status: - self._status = new_status - self._status_layout.set_text(self._status) + progress = sys_utils.get_upgrade_progress() + phase = progress["phase"] + pct = progress["percent"] + done = progress["done"] + total = progress["total"] + unit = progress.get("unit", "bytes") + + if phase in ("failed", "unavailable", "connfail"): + if phase == "unavailable": + self._fail_reason = _("Version no\nlonger available") + elif phase == "connfail": + self._fail_reason = _("Can't reach\nupdate server") + else: + self._fail_reason = _("Update failed!") + self._phase = "failed" + self._fail_option = "Retry" + return + + # Title + if phase == "rebooting": + label = _("Rebooting...") + elif phase == "activating": + label = _("Activating...") + elif phase == "starting": + label = _("Preparing...") + else: + label = _("Downloading...") self.draw.text( (0, y), - _("System Upgrade"), + label, font=self.fonts.bold.font, fill=self.colors.get(255), ) @@ -556,17 +825,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 +849,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 +901,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 838b69364..c2c104648 100644 --- a/python/PiFinder/ui/status.py +++ b/python/PiFinder/ui/status.py @@ -49,10 +49,6 @@ def __init__(self, *args, **kwargs): "CPU TMP": "--", } - with open(f"{utils.pifinder_dir}/wifi_status.txt", "r") as wfs: - wifi_mode = wfs.read() - self.status_dict["WIFI"] = "Client" if wifi_mode == "Client" else "AP" - self.last_temp_time = 0 self.last_IP_time = 0 self.net = sys_utils.Network() @@ -105,16 +101,14 @@ def update_status_dict(self): self.status_dict["RA/DEC"] = "--/--" else: hh, mm, _ = calc_utils.ra_to_hms(aligned.RA) - self.status_dict["RA/DEC"] = ( - f"{hh:02.0f}h{mm:02.0f}m/{aligned.Dec :.2f}" - ) + self.status_dict["RA/DEC"] = f"{hh:02.0f}h{mm:02.0f}m/{aligned.Dec:.2f}" # AZ/ALT if solution.Az is None or solution.Alt is None: self.status_dict["AZ/ALT"] = "--/--" else: self.status_dict["AZ/ALT"] = ( - f"{solution.Az : >6.2f}/{solution.Alt : >6.2f}" + f"{solution.Az: >6.2f}/{solution.Alt: >6.2f}" ) imu = self.shared_state.imu() @@ -125,10 +119,10 @@ def update_status_dict(self): mtext = "Moving" else: mtext = "Static" - self.status_dict["IMU"] = f"{mtext : >11}" + " " + str(imu.status) + self.status_dict["IMU"] = f"{mtext: >11}" + " " + str(imu.status) - self.status_dict["IMU qw,qx"] = f"{imu.quat.w:>.2f},{imu.quat.x : >.2f}" - self.status_dict["IMU qy,qz"] = f"{imu.quat.y:>.2f},{imu.quat.z : >.2f}" + self.status_dict["IMU qw,qx"] = f"{imu.quat.w:>.2f},{imu.quat.x: >.2f}" + self.status_dict["IMU qy,qz"] = f"{imu.quat.y:>.2f},{imu.quat.z: >.2f}" else: self.status_dict["IMU"] = "--" self.status_dict["IMU qw,qx"] = "--" @@ -161,18 +155,17 @@ def update_status_dict(self): try: with open("/sys/class/thermal/thermal_zone0/temp", "r") as f: raw_temp = int(f.read().strip()) - self.status_dict["CPU TMP"] = f"{raw_temp / 1000 : >13.1f}" + self.status_dict["CPU TMP"] = f"{raw_temp / 1000: >13.1f}" except FileNotFoundError: self.status_dict["CPU TMP"] = "Error" if time.time() - self.last_IP_time > 20: self.last_IP_time = time.time() - # IP address + # Live network state: WIFI radio mode, the reachable IP, and the + # active-uplink label (Ethernet when wired, else SSID / AP name). + self.status_dict["WIFI"] = self.net.wifi_mode() self.status_dict["IP"] = self.net.local_ip() - if self.net.wifi_mode() == "AP": - self.status_dict["SSID"] = self.net.get_ap_name() - else: - self.status_dict["SSID"] = self.net.get_connected_ssid() + self.status_dict["SSID"] = self.net.get_active_label() def update(self, force=False): self.update_status_dict() diff --git a/python/PiFinder/ui/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..62e391a80 100644 --- a/python/PiFinder/utils.py +++ b/python/PiFinder/utils.py @@ -1,6 +1,7 @@ import os import errno import fcntl +import socket import time import logging import json @@ -8,6 +9,8 @@ from typing import Optional import importlib +logger = logging.getLogger("Utils") + home_dir = Path.home() # Repo root, anchored on this file (python/PiFinder/utils.py) so paths @@ -15,12 +18,104 @@ pifinder_dir = Path(__file__).resolve().parents[2] assert (pifinder_dir / "astro_data").is_dir(), f"repo root not at {pifinder_dir}" astro_data_dir = pifinder_dir / "astro_data" -tetra3_dir = pifinder_dir / "python/PiFinder/tetra3/tetra3" data_dir = Path(Path.home(), "PiFinder_data") pifinder_db = astro_data_dir / "pifinder_objects.db" observations_db = data_dir / "observations.db" +# The device's single identity file: seeded with the store path at image +# build, rewritten (with version/label/channel) by every upgrade. Human +# version labels come from the update manifest, which maps store paths to +# versions — the retired pifinder-build.json duplicated that mapping. +current_build_json = Path("/var/lib/pifinder/current-build.json") + + +def sd_notify(state: str) -> None: + """Send a systemd service notification (e.g. "READY=1"). + + The app's readiness signal is what the boot watchdog's health check keys + off (pifinder.service is Type=notify). Outside systemd — development + runs, tests — NOTIFY_SOCKET is unset and this is a silent no-op; a + notification failure must never be able to break the app itself. + """ + addr = os.environ.get("NOTIFY_SOCKET") + if not addr: + return + if addr.startswith("@"): + # Abstract-namespace socket (leading @ in the env var) + addr = "\0" + addr[1:] + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: + sock.connect(addr) + sock.sendall(state.encode()) + except OSError as e: + logger.warning("sd_notify failed (harmless outside systemd): %s", e) + + +def get_version() -> str: + """Best available version string for the running build. + + The upgrade service writes an explicit version; a freshly-flashed image + only knows its store path, so fall back to its hash prefix (the update + screen upgrades that to a proper label once the manifest is fetched). + """ + try: + with open(current_build_json, "r") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return "Unknown" + version = data.get("version") + if version: + return version + store_path = data.get("store_path") or "" + name = store_path.rsplit("/", 1)[-1] + if name: + return name.split("-", 1)[0][:8] + 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 +263,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 ad0f02e62..401c11c62 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,4 +1,141 @@ +[project] +name = "pifinder" +version = "0.0.0" +description = "PiFinder runtime dependencies (resolved by uv, realized into the Nix store via uv2nix)" +requires-python = ">=3.13,<3.14" +dependencies = [ + # Scientific / astronomy core + "numpy", + "numpy-quaternion", + "pyerfa", + "scipy", + "scikit-learn", + "pillow", + "pandas", + "skyfield", + "timezonefinder", + "pytz", + # Plate solver (the `tetra3` import package). Pinned to a cedar-solve rev via + # [tool.uv.sources]; its stale numpy<2/Pillow<9 caps are relaxed in + # [tool.uv.dependency-metadata] so it resolves against the env's numpy 2.x. + "cedar-solve", + # Web / RPC + "grpcio", + "protobuf", + "flask", + "flask-babel", + "waitress", + "requests", + "pyjwt", + "aiofiles", + "json5", + "jsonschema", + "libarchive-c", + "tqdm", + # System / IPC bindings + "pygobject; platform_machine == 'aarch64'", # libnm bindings — device-only (dev uses sys_utils_fake) + "dbus-python; platform_machine == 'aarch64'", + "av", + "smbus2", + "spidev; platform_machine == 'aarch64'", + # sh 2.x changed the API; PiFinder targets the 1.x interface. + "sh>=1.14,<2", + "gpsdclient", + "dataclasses-json", + "pydeepskylog", + "python-pam", + # 0.3.0a0 (prerelease) is the version that builds on 3.13; its setup.py + # and .so lookups are patched in the uv2nix override (see uv-python.nix). + "python-libinput==0.3.0a0; platform_machine == 'aarch64'", + # Display + "luma-oled", + "luma-lcd", + # Hardware / camera + "rpi-gpio; platform_machine == 'aarch64'", + "rpi-hardware-pwm; platform_machine == 'aarch64'", + "adafruit-blinka", + "adafruit-circuitpython-bno055", + "picamera2; platform_machine == 'aarch64'", + "pidng", + "simplejpeg", + "python-prctl; platform_machine == 'aarch64'", + "videodev2", +] + +[dependency-groups] +dev = [ + "pytest", + "mypy", + "luma-emulator", + # pyhotkey -> pynput -> evdev (sdist, kernel headers): only buildable where + # the uv2nix override supplies headers (the device). Dev keyboard input on + # x86 uses the pygame path instead (docs/adr/0004-pygame-keyboard...). + "pyhotkey; platform_machine == 'aarch64'", + "selenium", + # pandas reads the Steinicke .xls catalog through xlrd (test_steinicke_parsing). + "xlrd>=2.0.1", + # mypy type stubs (main tracks these in requirements_dev.txt; here they + # ride the uv dev group) + "types-pytz", + # pynput drags evdev (sdist, kernel headers) — keep it off x86 dev boxes + "pynput; platform_machine == 'aarch64'", + "types-pynput", + "types-requests", + "types-aiofiles", + "types-tqdm", + "pandas-stubs", + "types-waitress", +] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +# Dependency-only (virtual) workspace — nothing here is installed into the env +# (the PiFinder source is deployed separately via pkgs/pifinder-src.nix). Declare +# an empty package set so uv2nix's build of the root produces an empty wheel +# instead of tripping setuptools flat-layout discovery on views/locale/PiFinder. +[tool.setuptools] +packages = [] + +[tool.uv] +# Dependency-only workspace: the PiFinder source itself is deployed separately +# (pkgs/pifinder-src.nix), so uv must not try to build/install this root project. +package = false +# Deployment + dev are both Linux (aarch64 Pi, x86_64 dev); keep the lock focused. +environments = ["sys_platform == 'linux'"] + +# Plate solver source: pinned cedar-solve rev (smroid upstream for now; point this +# at our fork here when we need to carry patches). +[tool.uv.sources] +cedar-solve = { git = "https://github.com/smroid/cedar-solve", rev = "d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" } + +# python-libinput's setup.py imports the removed `imp` module, so uv cannot +# execute it to discover metadata. Declare it statically; the actual build is +# patched in the uv2nix override. +[[tool.uv.dependency-metadata]] +name = "python-libinput" +version = "0.3.0a0" +requires-dist = ["cffi"] + +# python-prctl's setup.py aborts without libcap headers present; it has no +# runtime Python deps. libcap is supplied by the uv2nix build override. +[[tool.uv.dependency-metadata]] +name = "python-prctl" +version = "1.8.1" +requires-dist = [] + +# cedar-solve's published metadata caps numpy<2 / Pillow<9, but those bounds are +# stale: it runs against the env's numpy 2.x / Pillow 12 (as it did when vendored +# as a git submodule). Override so uv resolves it without dragging the env back. +[[tool.uv.dependency-metadata]] +name = "cedar-solve" +version = "0.5.1" +requires-dist = ["numpy", "pillow", "scipy"] + [tool.ruff] +builtins = ["_"] + # Exclude a variety of commonly ignored directories. exclude = [ ".bzr", @@ -27,18 +164,14 @@ exclude = [ "node_modules", "site-packages", "venv", - "tetra3", ] # Same as Black. line-length = 88 indent-width = 4 -# Assume Python 3.9 -target-version = "py39" - -# _ is the i18n/gettext builtin injected at runtime -builtins = ["_"] +# Assume Python 3.13 +target-version = "py313" [tool.ruff.lint] # Enable preview mode, allow os.env changes before imports @@ -60,6 +193,7 @@ unfixable = [] dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.lint.per-file-ignores] +"*.ipynb" = ["E402", "F841"] "PiFinder/timez.py" = ["DTZ"] [tool.ruff.format] @@ -90,7 +224,7 @@ docstring-code-format = false docstring-code-line-length = "dynamic" [tool.mypy] -exclude = "venv|tetra3|conftest" +exclude = "venv|conftest" # Start off with these warn_unused_configs = true warn_redundant_casts = true @@ -123,28 +257,53 @@ extra_checks = true [[tool.mypy.overrides]] module = [ 'board', - 'adafruit_bno055', 'adafruit_bus_device.*', + 'adafruit_bno055', 'scipy.*', 'luma.*', 'skyfield.*', 'sh.*', 'sklearn.*', - 'pam.*', 'PyHotKey.*', - 'PiFinder.tetra3.*', - 'quaternion', - 'tetra3.*', 'grpc', 'ceder_detect_pb2', 'RPi.*', 'picamera2', 'bottle', 'libinput', + 'pytz', + 'aiofiles', + 'requests', + 'tqdm', + 'pandas', + 'rpi_hardware_pwm', + 'gpsdclient', + 'timezonefinder', + 'pydeepskylog.*', + 'dbus', + 'pam', + 'pam.*', + 'quaternion', + 'gi', + 'gi.*', + 'pynput.*', + 'waitress', + 'google.*', + 'cedar_detect_pb2', + 'cedar_detect_pb2_grpc', ] ignore_missing_imports = true ignore_errors = true +# tetra3 / cedar-solve is an external (untyped) dependency. Treat it as opaque: +# skip following its imports so mypy never analyses it or its transitive deps +# (protobuf, grpc stubs, …). Without this, `mypy --install-types` tries to +# pip-install those stubs, which fails in the pip-less Nix dev env. +[[tool.mypy.overrides]] +module = ['tetra3', 'tetra3.*'] +follow_imports = "skip" +ignore_missing_imports = true + [tool.pytest.ini_options] pythonpath = ["."] testpaths = [ diff --git a/python/requirements.txt b/python/requirements.txt deleted file mode 100644 index 796057bdf..000000000 --- a/python/requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -adafruit-blinka==8.12.0 -adafruit-circuitpython-bno055 -cheroot==10.0.0 -Flask==3.0.3 -flask-babel==4.0.0 -waitress==3.0.1 -dataclasses_json==0.6.7 -gpsdclient==1.3.2 -grpcio==1.64.1 -json5==0.9.25 -luma.oled==3.12.0 -luma.lcd==2.11.0 -numpy==1.26.4 -numpy-quaternion==2023.0.4 -pam==0.2.0 -pandas==2.0.3 -pillow==10.4.0 -pydeepskylog==1.6 -pyerfa==2.0.1.5 -pyjwt==2.8.0 -python-libinput==0.3.0a0 -pytz==2022.7.1 -requests==2.28.2 -rpi-hardware-pwm==0.1.4 -scipy -scikit-learn==1.2.2 -sh==1.14.3 -skyfield==1.45 -timezonefinder==6.1.9 -tqdm==4.65.0 -protobuf==4.25.2 -aiofiles==24.1.0 diff --git a/python/requirements_dev.txt b/python/requirements_dev.txt deleted file mode 100644 index 7e864ae70..000000000 --- a/python/requirements_dev.txt +++ /dev/null @@ -1,24 +0,0 @@ -# dev requirements -luma.emulator==1.5.0 -PyHotKey==1.5.2 -ruff==0.4.8 -nox==2024.4.15 -mypy==1.10.0 -pytest==8.2.2 -pygame==2.6.1 -pre-commit==3.7.1 -Babel==2.16.0 -xlrd==2.0.2 -selenium==4.15.0 -types-pytz==2022.7.1 -pynput -types-pynput -types-requests==2.28.2 -types-aiofiles -types-tqdm==4.65.0 -pandas-stubs -types-waitress -# Test-only: drift guard for the .pifinder JSON Schema (test_pifinder_schema.py) -jsonschema==4.23.0 -# Pin to avoid pyobjc 12.0 which has macOS 15 build issues -pyobjc-framework-Quartz==11.1; sys_platform == "darwin" diff --git a/python/scripts/migration_progress b/python/scripts/migration_progress deleted file mode 100755 index 4d87f1758..000000000 Binary files a/python/scripts/migration_progress and /dev/null differ diff --git a/python/scripts/migration_progress.c b/python/scripts/migration_progress.c deleted file mode 100644 index 3b0a37319..000000000 --- a/python/scripts/migration_progress.c +++ /dev/null @@ -1,579 +0,0 @@ -/* - * migration_progress - OLED progress display for PiFinder initramfs - * - * Drives supported SPI OLED displays to show migration progress. - * Designed to be statically compiled and included in the initramfs. - * - * Usage: migration_progress - * percent: 0-100 - * message: status text (max ~20 chars fits on screen) - * - * Examples: - * migration_progress 0 1 22 "Starting..." - * migration_progress 45 10 22 "Moving data" - * migration_progress 100 22 22 "Complete!" - * - * Hardware: SPI0.0, DC=GPIO24, RST=GPIO25, BGR565 - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define MAX_WIDTH 176 -#define MAX_HEIGHT 176 -#define SPI_DEVICE "/dev/spidev0.0" -#define SPI_SPEED 40000000 -#define GPIO_DC 24 -#define GPIO_RST 25 - -/* BGR565 colors */ -#define COL_BLACK 0x0000 -#define COL_WHITE 0xFFFF -#define COL_RED 0x001F /* BGR565: blue=0, green=0, red=31 */ -#define COL_GREEN 0x07E0 -#define COL_DKGRAY 0x4208 -#define COL_DKRED 0x0010 - -static int spi_fd = -1; -static int gpio_fd = -1; -static struct gpio_v2_line_request dc_req; -static struct gpio_v2_line_request rst_req; -static uint16_t framebuf[MAX_WIDTH * MAX_HEIGHT]; - -enum oled_controller { - CTRL_SSD1351, - CTRL_SSD1333, -}; - -static enum oled_controller controller = CTRL_SSD1351; -static int display_width = 128; -static int display_height = 128; - -/* 5x7 bitmap font - ASCII 32-126 */ -static const uint8_t font5x7[][5] = { - {0x00,0x00,0x00,0x00,0x00}, /* space */ - {0x00,0x00,0x5F,0x00,0x00}, /* ! */ - {0x00,0x07,0x00,0x07,0x00}, /* " */ - {0x14,0x7F,0x14,0x7F,0x14}, /* # */ - {0x24,0x2A,0x7F,0x2A,0x12}, /* $ */ - {0x23,0x13,0x08,0x64,0x62}, /* % */ - {0x36,0x49,0x55,0x22,0x50}, /* & */ - {0x00,0x05,0x03,0x00,0x00}, /* ' */ - {0x00,0x1C,0x22,0x41,0x00}, /* ( */ - {0x00,0x41,0x22,0x1C,0x00}, /* ) */ - {0x08,0x2A,0x1C,0x2A,0x08}, /* * */ - {0x08,0x08,0x3E,0x08,0x08}, /* + */ - {0x00,0x50,0x30,0x00,0x00}, /* , */ - {0x08,0x08,0x08,0x08,0x08}, /* - */ - {0x00,0x60,0x60,0x00,0x00}, /* . */ - {0x20,0x10,0x08,0x04,0x02}, /* / */ - {0x3E,0x51,0x49,0x45,0x3E}, /* 0 */ - {0x00,0x42,0x7F,0x40,0x00}, /* 1 */ - {0x42,0x61,0x51,0x49,0x46}, /* 2 */ - {0x21,0x41,0x45,0x4B,0x31}, /* 3 */ - {0x18,0x14,0x12,0x7F,0x10}, /* 4 */ - {0x27,0x45,0x45,0x45,0x39}, /* 5 */ - {0x3C,0x4A,0x49,0x49,0x30}, /* 6 */ - {0x01,0x71,0x09,0x05,0x03}, /* 7 */ - {0x36,0x49,0x49,0x49,0x36}, /* 8 */ - {0x06,0x49,0x49,0x29,0x1E}, /* 9 */ - {0x00,0x36,0x36,0x00,0x00}, /* : */ - {0x00,0x56,0x36,0x00,0x00}, /* ; */ - {0x00,0x08,0x14,0x22,0x41}, /* < */ - {0x14,0x14,0x14,0x14,0x14}, /* = */ - {0x41,0x22,0x14,0x08,0x00}, /* > */ - {0x02,0x01,0x51,0x09,0x06}, /* ? */ - {0x32,0x49,0x79,0x41,0x3E}, /* @ */ - {0x7E,0x11,0x11,0x11,0x7E}, /* A */ - {0x7F,0x49,0x49,0x49,0x36}, /* B */ - {0x3E,0x41,0x41,0x41,0x22}, /* C */ - {0x7F,0x41,0x41,0x22,0x1C}, /* D */ - {0x7F,0x49,0x49,0x49,0x41}, /* E */ - {0x7F,0x09,0x09,0x01,0x01}, /* F */ - {0x3E,0x41,0x41,0x51,0x32}, /* G */ - {0x7F,0x08,0x08,0x08,0x7F}, /* H */ - {0x00,0x41,0x7F,0x41,0x00}, /* I */ - {0x20,0x40,0x41,0x3F,0x01}, /* J */ - {0x7F,0x08,0x14,0x22,0x41}, /* K */ - {0x7F,0x40,0x40,0x40,0x40}, /* L */ - {0x7F,0x02,0x04,0x02,0x7F}, /* M */ - {0x7F,0x04,0x08,0x10,0x7F}, /* N */ - {0x3E,0x41,0x41,0x41,0x3E}, /* O */ - {0x7F,0x09,0x09,0x09,0x06}, /* P */ - {0x3E,0x41,0x51,0x21,0x5E}, /* Q */ - {0x7F,0x09,0x19,0x29,0x46}, /* R */ - {0x46,0x49,0x49,0x49,0x31}, /* S */ - {0x01,0x01,0x7F,0x01,0x01}, /* T */ - {0x3F,0x40,0x40,0x40,0x3F}, /* U */ - {0x1F,0x20,0x40,0x20,0x1F}, /* V */ - {0x7F,0x20,0x18,0x20,0x7F}, /* W */ - {0x63,0x14,0x08,0x14,0x63}, /* X */ - {0x03,0x04,0x78,0x04,0x03}, /* Y */ - {0x61,0x51,0x49,0x45,0x43}, /* Z */ - {0x00,0x00,0x7F,0x41,0x41}, /* [ */ - {0x02,0x04,0x08,0x10,0x20}, /* \ */ - {0x41,0x41,0x7F,0x00,0x00}, /* ] */ - {0x04,0x02,0x01,0x02,0x04}, /* ^ */ - {0x40,0x40,0x40,0x40,0x40}, /* _ */ - {0x00,0x01,0x02,0x04,0x00}, /* ` */ - {0x20,0x54,0x54,0x54,0x78}, /* a */ - {0x7F,0x48,0x44,0x44,0x38}, /* b */ - {0x38,0x44,0x44,0x44,0x20}, /* c */ - {0x38,0x44,0x44,0x48,0x7F}, /* d */ - {0x38,0x54,0x54,0x54,0x18}, /* e */ - {0x08,0x7E,0x09,0x01,0x02}, /* f */ - {0x08,0x14,0x54,0x54,0x3C}, /* g */ - {0x7F,0x08,0x04,0x04,0x78}, /* h */ - {0x00,0x44,0x7D,0x40,0x00}, /* i */ - {0x20,0x40,0x44,0x3D,0x00}, /* j */ - {0x00,0x7F,0x10,0x28,0x44}, /* k */ - {0x00,0x41,0x7F,0x40,0x00}, /* l */ - {0x7C,0x04,0x18,0x04,0x78}, /* m */ - {0x7C,0x08,0x04,0x04,0x78}, /* n */ - {0x38,0x44,0x44,0x44,0x38}, /* o */ - {0x7C,0x14,0x14,0x14,0x08}, /* p */ - {0x08,0x14,0x14,0x18,0x7C}, /* q */ - {0x7C,0x08,0x04,0x04,0x08}, /* r */ - {0x48,0x54,0x54,0x54,0x20}, /* s */ - {0x04,0x3F,0x44,0x40,0x20}, /* t */ - {0x3C,0x40,0x40,0x20,0x7C}, /* u */ - {0x1C,0x20,0x40,0x20,0x1C}, /* v */ - {0x3C,0x40,0x30,0x40,0x3C}, /* w */ - {0x44,0x28,0x10,0x28,0x44}, /* x */ - {0x0C,0x50,0x50,0x50,0x3C}, /* y */ - {0x44,0x64,0x54,0x4C,0x44}, /* z */ - {0x00,0x08,0x36,0x41,0x00}, /* { */ - {0x00,0x00,0x7F,0x00,0x00}, /* | */ - {0x00,0x41,0x36,0x08,0x00}, /* } */ - {0x08,0x08,0x2A,0x1C,0x08}, /* ~ */ -}; - -static void msleep(int ms) -{ - struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L }; - nanosleep(&ts, NULL); -} - -static int gpio_request_line(int chip_fd, int pin, struct gpio_v2_line_request *req) -{ - struct gpio_v2_line_request r = {0}; - r.offsets[0] = pin; - r.num_lines = 1; - r.config.flags = GPIO_V2_LINE_FLAG_OUTPUT; - snprintf(r.consumer, sizeof(r.consumer), "migration"); - - if (ioctl(chip_fd, GPIO_V2_GET_LINE_IOCTL, &r) < 0) { - perror("GPIO_V2_GET_LINE_IOCTL"); - return -1; - } - *req = r; - return 0; -} - -static void gpio_set(struct gpio_v2_line_request *req, int value) -{ - struct gpio_v2_line_values vals = {0}; - vals.bits = value ? 1 : 0; - vals.mask = 1; - ioctl(req->fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals); -} - -static void spi_write(const uint8_t *data, size_t len) -{ - /* Chunk large transfers - SPI driver may limit to 4KB */ - const size_t chunk_size = 4096; - while (len > 0) { - size_t this_len = len > chunk_size ? chunk_size : len; - struct spi_ioc_transfer tr = {0}; - tr.tx_buf = (unsigned long)data; - tr.len = this_len; - tr.speed_hz = SPI_SPEED; - tr.bits_per_word = 8; - ioctl(spi_fd, SPI_IOC_MESSAGE(1), &tr); - data += this_len; - len -= this_len; - } -} - -static void oled_cmd(uint8_t cmd) -{ - gpio_set(&dc_req, 0); - spi_write(&cmd, 1); -} - -static void oled_data(const uint8_t *data, size_t len) -{ - gpio_set(&dc_req, 1); - spi_write(data, len); -} - -static void oled_cmd_data(uint8_t cmd, uint8_t data) -{ - oled_cmd(cmd); - oled_data(&data, 1); -} - -static int display_on = 0; - -static void detect_display(void) -{ - const char *display_class = getenv("MIGRATION_DISPLAY_CLASS"); - const char *display_resolution = getenv("MIGRATION_DISPLAY_RESOLUTION"); - - controller = CTRL_SSD1351; - display_width = 128; - display_height = 128; - - if (display_class && strstr(display_class, "SSD1333")) { - controller = CTRL_SSD1333; - display_width = 176; - display_height = 176; - return; - } - - if (display_resolution && strcmp(display_resolution, "176x176") == 0) { - controller = CTRL_SSD1333; - display_width = 176; - display_height = 176; - } -} - -static void oled_reset(void) -{ - gpio_set(&rst_req, 1); - msleep(10); - gpio_set(&rst_req, 0); - msleep(10); - gpio_set(&rst_req, 1); - msleep(10); -} - -static void oled_common_init(int mux_ratio, uint8_t remap) -{ - oled_reset(); - - /* SSD13xx 65k-color OLED setup. */ - oled_cmd_data(0xFD, 0x12); /* Unlock */ - oled_cmd_data(0xFD, 0xB1); /* Unlock commands */ - - oled_cmd(0xAE); /* Display off */ - oled_cmd_data(0xB3, 0xF1); /* Clock divider */ - oled_cmd_data(0xCA, (uint8_t)mux_ratio); /* Mux ratio */ - - oled_cmd(0x15); /* Column address */ - uint8_t col[2] = {0x00, (uint8_t)(display_width - 1)}; - oled_data(col, 2); - - oled_cmd(0x75); /* Row address */ - uint8_t row[2] = {0x00, (uint8_t)(display_height - 1)}; - oled_data(row, 2); - - oled_cmd_data(0xA0, remap); /* Remap/color depth */ - oled_cmd_data(0xA1, 0x00); /* Start line */ - oled_cmd_data(0xA2, 0x00); /* Display offset */ - oled_cmd_data(0xB5, 0x00); /* GPIO */ - oled_cmd_data(0xAB, 0x01); /* Function select */ - oled_cmd_data(0xB1, 0x32); /* Precharge */ - - oled_cmd(0xB4); /* VSL */ - uint8_t vsl[3] = {0xA0, 0xB5, 0x55}; - oled_data(vsl, 3); - - oled_cmd_data(0xBE, 0x05); /* VCOMH */ - oled_cmd_data(0xC7, 0x0F); /* Master contrast */ - oled_cmd_data(0xB6, 0x01); /* Precharge2 */ - oled_cmd(0xA6); /* Normal display */ - - /* Display ON (0xAF) happens after first framebuffer flush. */ -} - -static void oled_init(void) -{ - if (controller == CTRL_SSD1333) - oled_common_init(0xAF, 0x74); - else - oled_common_init(0x7F, 0x74); -} - -static void oled_flush(void) -{ - /* Set contrast before first frame (matching luma) */ - if (!display_on) { - oled_cmd(0xC1); /* Contrast */ - uint8_t contrast[3] = {0xFF, 0xFF, 0xFF}; - oled_data(contrast, 3); - } - - oled_cmd(0x15); - uint8_t col[2] = {0x00, (uint8_t)(display_width - 1)}; - oled_data(col, 2); - - oled_cmd(0x75); - uint8_t row[2] = {0x00, (uint8_t)(display_height - 1)}; - oled_data(row, 2); - - oled_cmd(0x5C); /* Write RAM */ - - /* Send framebuffer as big-endian 16-bit pixels */ - uint8_t buf[MAX_WIDTH * MAX_HEIGHT * 2]; - int pixels = display_width * display_height; - for (int i = 0; i < pixels; i++) { - buf[i * 2] = framebuf[i] >> 8; - buf[i * 2 + 1] = framebuf[i] & 0xFF; - } - oled_data(buf, (size_t)pixels * 2); - - /* Turn display on after first frame */ - if (!display_on) { - oled_cmd(0xAF); /* Display on */ - display_on = 1; - } -} - -static void fb_clear(uint16_t color) -{ - for (int i = 0; i < display_width * display_height; i++) - framebuf[i] = color; -} - -static void fb_pixel(int x, int y, uint16_t color) -{ - if (x >= 0 && x < display_width && y >= 0 && y < display_height) - framebuf[y * display_width + x] = color; -} - -static void fb_rect(int x, int y, int w, int h, uint16_t color) -{ - for (int j = y; j < y + h && j < display_height; j++) - for (int i = x; i < x + w && i < display_width; i++) - fb_pixel(i, j, color); -} - -static void fb_char(int x, int y, char c, uint16_t color, int scale) -{ - if (c < 32 || c > 126) - c = '?'; - const uint8_t *glyph = font5x7[c - 32]; - for (int col = 0; col < 5; col++) { - uint8_t bits = glyph[col]; - for (int row = 0; row < 7; row++) { - if (bits & (1 << row)) { - for (int sy = 0; sy < scale; sy++) - for (int sx = 0; sx < scale; sx++) - fb_pixel(x + col * scale + sx, - y + row * scale + sy, color); - } - } - } -} - -static void fb_string(int x, int y, const char *s, uint16_t color, int scale) -{ - int cx = x; - while (*s) { - fb_char(cx, y, *s, color, scale); - cx += 6 * scale; /* 5px char + 1px gap */ - s++; - } -} - -/* Center a string horizontally */ -static void fb_string_centered(int y, const char *s, uint16_t color, int scale) -{ - int len = strlen(s); - int px_width = len * 6 * scale - scale; /* subtract trailing gap */ - int x = (display_width - px_width) / 2; - if (x < 0) x = 0; - fb_string(x, y, s, color, scale); -} - -/* Center a string, shrinking the scale (and finally truncating) so it always - * fits the display width. Used for the variable-length stage name. */ -static void fb_string_centered_fit(int y, const char *s, uint16_t color, int max_scale) -{ - int len = strlen(s); - for (int scale = max_scale; scale >= 1; scale--) { - if (len * 6 * scale - scale <= display_width) { - fb_string_centered(y, s, color, scale); - return; - } - } - /* Too long even at scale 1: render a head that fits. */ - int max_chars = (display_width + 1) / 6; - char buf[64]; - if (max_chars > (int)sizeof(buf) - 1) - max_chars = (int)sizeof(buf) - 1; - int n = len < max_chars ? len : max_chars; - memcpy(buf, s, (size_t)n); - buf[n] = '\0'; - fb_string_centered(y, buf, color, 1); -} - -static void draw_progress(int percent, const char *stage, int stage_num, int stage_total) -{ - if (percent < 0) percent = 0; - if (percent > 100) percent = 100; - - fb_clear(COL_BLACK); - - int scale = display_width >= 160 ? 2 : 1; - int margin = display_width >= 160 ? 14 : 10; - int banner_h = display_width >= 160 ? 18 : 12; - int title_y = display_width >= 160 ? 28 : 18; - int subtitle_y = display_width >= 160 ? 55 : 38; - int stage_y = display_width >= 160 ? 76 : 52; - int bar_y = display_width >= 160 ? 94 : 65; - int pct_y = display_width >= 160 ? 116 : 82; - int stage_name_y = display_width >= 160 ? 145 : 105; - int wait_y = display_height - (8 * scale); - - /* Warning banner at top: solid bright bar with black text for contrast */ - fb_rect(0, 0, display_width, banner_h, COL_RED); - fb_string_centered(3, "DO NOT POWER OFF", COL_BLACK, scale); - - /* Title */ - fb_string_centered(title_y, "NixOS", COL_RED, 2); - fb_string_centered(subtitle_y, "Migration", COL_RED, scale); - - /* Stage indicator (e.g., "3/7") */ - if (stage_total > 0) { - char stage_str[32]; - snprintf(stage_str, sizeof(stage_str), "Stage %d/%d", stage_num, stage_total); - fb_string_centered(stage_y, stage_str, COL_DKGRAY, scale); - } - - /* Progress bar */ - int bar_x = margin; - int bar_w = display_width - (margin * 2); - int bar_h = display_width >= 160 ? 16 : 12; - - /* Border */ - fb_rect(bar_x, bar_y, bar_w, 1, COL_DKGRAY); - fb_rect(bar_x, bar_y + bar_h - 1, bar_w, 1, COL_DKGRAY); - fb_rect(bar_x, bar_y, 1, bar_h, COL_DKGRAY); - fb_rect(bar_x + bar_w - 1, bar_y, 1, bar_h, COL_DKGRAY); - - /* Fill */ - int fill_w = (bar_w - 4) * percent / 100; - if (fill_w > 0) - fb_rect(bar_x + 2, bar_y + 2, fill_w, bar_h - 4, COL_RED); - - /* Dark red background for unfilled */ - int unfill_x = bar_x + 2 + fill_w; - int unfill_w = (bar_w - 4) - fill_w; - if (unfill_w > 0) - fb_rect(unfill_x, bar_y + 2, unfill_w, bar_h - 4, COL_DKRED); - - /* Percentage */ - char pct_str[8]; - snprintf(pct_str, sizeof(pct_str), "%d%%", percent); - fb_string_centered(pct_y, pct_str, COL_RED, 2); - - /* Current stage name */ - if (stage && *stage) - fb_string_centered_fit(stage_name_y, stage, COL_RED, scale); - - /* Bottom warning */ - fb_string_centered(wait_y, "Please wait...", COL_DKGRAY, scale); - - oled_flush(); -} - -static int hw_init(void) -{ - /* Open SPI */ - spi_fd = open(SPI_DEVICE, O_RDWR); - if (spi_fd < 0) { - perror("open spi"); - return -1; - } - - uint8_t mode = SPI_MODE_0; - uint8_t bits = 8; - uint32_t speed = SPI_SPEED; - ioctl(spi_fd, SPI_IOC_WR_MODE, &mode); - ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &bits); - ioctl(spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed); - - /* Open GPIO chip */ - gpio_fd = open("/dev/gpiochip0", O_RDWR); - if (gpio_fd < 0) { - perror("open gpiochip0"); - return -1; - } - - if (gpio_request_line(gpio_fd, GPIO_DC, &dc_req) < 0) - return -1; - if (gpio_request_line(gpio_fd, GPIO_RST, &rst_req) < 0) - return -1; - - detect_display(); - oled_init(); - return 0; -} - -static void hw_cleanup(void) -{ - if (dc_req.fd > 0) close(dc_req.fd); - if (rst_req.fd > 0) close(rst_req.fd); - if (gpio_fd >= 0) close(gpio_fd); - if (spi_fd >= 0) close(spi_fd); -} - -/* Persistent mode: initialise the panel once, then redraw in place for each - * " " line read from stdin. - * Because the panel is never reset between frames, the display updates without - * blanking to black. Returns on EOF (writer closed). */ -static void serve_loop(void) -{ - char line[256]; - while (fgets(line, sizeof(line), stdin)) { - int percent = 0, stage_num = 0, stage_total = 0, name_off = 0; - if (sscanf(line, "%d %d %d %n", &percent, &stage_num, &stage_total, - &name_off) < 3) - continue; - char *name = line + name_off; - size_t len = strlen(name); - while (len > 0 && (name[len - 1] == '\n' || name[len - 1] == '\r')) - name[--len] = '\0'; - draw_progress(percent, name, stage_num, stage_total); - } -} - -int main(int argc, char *argv[]) -{ - int serve = (argc >= 2 && strcmp(argv[1], "--serve") == 0); - - if (!serve && argc < 5) { - fprintf(stderr, "Usage: %s --serve\n", argv[0]); - fprintf(stderr, " read ' '\n"); - fprintf(stderr, " lines from stdin and redraw in place (no flicker)\n"); - fprintf(stderr, " or: %s \n", argv[0]); - fprintf(stderr, " draw a single frame and exit\n"); - fprintf(stderr, "\nExample: %s 50 3 7 'Extracting system'\n", argv[0]); - return 1; - } - - if (hw_init() < 0) { - fprintf(stderr, "Hardware init failed\n"); - hw_cleanup(); - return 1; - } - - if (serve) { - serve_loop(); - } else { - draw_progress(atoi(argv[1]), argv[4], atoi(argv[2]), atoi(argv[3])); - } - - hw_cleanup(); - return 0; -} diff --git a/python/scripts/nixos_migration.sh b/python/scripts/nixos_migration.sh deleted file mode 100755 index 740928318..000000000 --- a/python/scripts/nixos_migration.sh +++ /dev/null @@ -1,247 +0,0 @@ -#!/bin/bash -# nixos_migration.sh - Pre-migration: validate, download, stage initramfs -# -# Called by PiFinder app (sys_utils.start_nixos_migration). -# Runs on RPi OS before rebooting into initramfs for the actual migration. -# -# The initramfs will: -# 1. Save WiFi + user backup to RAM -# 2. DD the .img.zst to the SD card -# 3. Expand partition, restore WiFi + user data -# 4. Reboot into NixOS -# -# Usage: nixos_migration.sh [sha256] [progress_file] [display_class] [display_resolution] -# -# Exit codes: -# 0 - Success (initramfs staged, ready to reboot) -# 1 - Pre-flight check failure -# 2 - Download failure -# 3 - Checksum mismatch -# 5 - Initramfs staging failure - -set -euo pipefail - -export PATH="/usr/sbin:/sbin:${PATH}" - -MIGRATION_URL="${1:?Usage: nixos_migration.sh [sha256] [progress_file]}" -MIGRATION_SHA256="${2:-}" -PROGRESS_FILE="${3:-/tmp/nixos_migration_progress}" -DISPLAY_CLASS="${4:-}" -DISPLAY_RESOLUTION="${5:-}" - -trap '_trap_err $LINENO "$BASH_COMMAND"' ERR -_trap_err() { - echo "{\"percent\": 0, \"status\": \"FAILED at line $1: $2\"}" > "${PROGRESS_FILE}" - echo "ERROR at line $1: $2" >&2 -} - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PIFINDER_HOME="/home/pifinder" -TARBALL="${PIFINDER_HOME}/pifinder-nixos-migration.tar.zst" -BOOT_PARTITION="/boot" -INITRAMFS_DIR="/tmp/nixos_initramfs" -PROGRESS_BIN="${SCRIPT_DIR}/migration_progress" -INIT_SCRIPT="${SCRIPT_DIR}/nixos_migration_init.sh" - -progress() { - local pct="$1" - local msg="$2" - echo "{\"percent\": ${pct}, \"status\": \"${msg}\"}" > "${PROGRESS_FILE}" - echo "[${pct}%] ${msg}" -} - -fail() { - local code="$1" - local msg="$2" - progress 0 "FAILED: ${msg}" - echo "ERROR: ${msg}" >&2 - exit "${code}" -} - -# Copy a binary and all its shared library dependencies into the initramfs. -copy_with_libs() { - local bin_path="$1" - local dest="$2" - - cp "${bin_path}" "${dest}/bin/" - - ldd "${bin_path}" 2>/dev/null | grep -oP '/\S+' | while read -r lib; do - local dir - dir=$(dirname "${lib}") - mkdir -p "${dest}${dir}" - cp -n "${lib}" "${dest}${dir}/" 2>/dev/null || true - done -} - -# --- Phase 0: Install required packages --- -progress 0 "Installing dependencies" -for pkg in busybox cpio curl dosfstools e2fsprogs fdisk gzip xz-utils zstd; do - if ! dpkg -s "${pkg}" >/dev/null 2>&1; then - sudo apt-get install -y "${pkg}" || fail 1 "Failed to install ${pkg}" - fi -done - -# --- Phase 1: Pre-flight checks --- -# nixos_migration_calc.py is the single source of truth for whether this -# system is ready to migrate (model, RAM, SD size + layout, free space, -# WiFi mode, supported display). A non-zero exit means all_ok is false. -progress 3 "Running pre-flight checks" - -if ! python3 "${SCRIPT_DIR}/nixos_migration_calc.py" --json \ - --display-class "${DISPLAY_CLASS}" \ - --display-resolution "${DISPLAY_RESOLUTION}" \ - > /tmp/migration_checks.json 2>&1; then - fail 1 "Pre-flight checks failed" -fi - -progress 5 "Pre-flight OK" - -# --- Phase 2: Download image --- -SKIP_DOWNLOAD=false -if [ -f "${TARBALL}" ]; then - if [ -z "${MIGRATION_SHA256}" ]; then - progress 60 "Using cached download (no checksum)" - SKIP_DOWNLOAD=true - else - progress 10 "Verifying existing download" - EXISTING_SHA256=$(sha256sum "${TARBALL}" | awk '{print $1}') - if [ "${EXISTING_SHA256}" = "${MIGRATION_SHA256}" ]; then - progress 60 "Using cached download" - SKIP_DOWNLOAD=true - fi - fi -fi - -if [ "${SKIP_DOWNLOAD}" = false ]; then - progress 10 "Downloading..." - rm -f "${TARBALL}" - - if ! curl -L -f -o "${TARBALL}" \ - --progress-bar \ - "${MIGRATION_URL}" 2>&1 | tr '\r' '\n' | while IFS= read -r line; do - if [[ "$line" =~ ([0-9]+)\.[0-9]% ]]; then - dl_pct="${BASH_REMATCH[1]}" - mapped_pct=$(( 10 + dl_pct * 50 / 100 )) - progress "${mapped_pct}" "Downloading..." - fi - done; then - fail 2 "Download failed" - fi - - # --- Phase 3: Verify checksum --- - if [ -z "${MIGRATION_SHA256}" ]; then - progress 60 "SHA256 not provided, skipping verification" - else - progress 60 "Verifying checksum" - ACTUAL_SHA256=$(sha256sum "${TARBALL}" | awk '{print $1}') - if [ "${ACTUAL_SHA256}" != "${MIGRATION_SHA256}" ]; then - rm -f "${TARBALL}" - fail 3 "Checksum mismatch" - fi - fi -fi - -progress 65 "Download OK" - -# --- Phase 4: Get image size --- -progress 68 "Preparing" - -TARBALL_SIZE=$(stat -c%s "${TARBALL}") - -progress 75 "Tarball: $((TARBALL_SIZE / 1048576))MB" - -# --- Phase 5: Build initramfs --- -progress 78 "Building initramfs" - -rm -rf "${INITRAMFS_DIR}" -mkdir -p "${INITRAMFS_DIR}"/{bin,lib,dev,proc,sys,mnt,tmp} - -# Busybox (provides sh, mount, umount, dd, tar, cp, etc.) -if command -v busybox >/dev/null 2>&1; then - copy_with_libs "$(command -v busybox)" "${INITRAMFS_DIR}" -else - fail 5 "busybox not found" -fi - -# Filesystem tools -for tool in e2fsck resize2fs mke2fs mkfs.vfat sfdisk zstd; do - tool_path=$(command -v "${tool}" 2>/dev/null || true) - if [ -z "${tool_path}" ]; then - fail 5 "${tool} not found — install e2fsprogs dosfstools util-linux zstd" - fi - copy_with_libs "${tool_path}" "${INITRAMFS_DIR}" -done - -# mkfs.ext4 is typically a symlink to mke2fs -ln -sf mke2fs "${INITRAMFS_DIR}/bin/mkfs.ext4" 2>/dev/null || true - -# OLED progress display (static binary, no libs needed) -cp "${PROGRESS_BIN}" "${INITRAMFS_DIR}/bin/" 2>/dev/null || true - -# SPI kernel modules — needed for OLED progress display -# Modules may be compressed (.ko.xz); decompress for insmod in initramfs -KVER=$(uname -r) -KMOD_DIR="/lib/modules/${KVER}/kernel/drivers/spi" -if [ -d "${KMOD_DIR}" ]; then - INITRAMFS_SPI="${INITRAMFS_DIR}/lib/modules" - mkdir -p "${INITRAMFS_SPI}" - for mod in spi-bcm2835 spidev; do - if [ -f "${KMOD_DIR}/${mod}.ko.xz" ]; then - xz -dc "${KMOD_DIR}/${mod}.ko.xz" > "${INITRAMFS_SPI}/${mod}.ko" - elif [ -f "${KMOD_DIR}/${mod}.ko.gz" ]; then - gzip -dc "${KMOD_DIR}/${mod}.ko.gz" > "${INITRAMFS_SPI}/${mod}.ko" - elif [ -f "${KMOD_DIR}/${mod}.ko.zst" ]; then - zstd -dc "${KMOD_DIR}/${mod}.ko.zst" > "${INITRAMFS_SPI}/${mod}.ko" - elif [ -f "${KMOD_DIR}/${mod}.ko" ]; then - cp "${KMOD_DIR}/${mod}.ko" "${INITRAMFS_SPI}/${mod}.ko" - fi - done -fi - -# Dynamic linker — needed for non-busybox tools -LD_PATH=$(find /lib /lib64 /usr/lib -name "ld-linux-*" -type f 2>/dev/null | head -1 || true) -if [ -n "${LD_PATH}" ]; then - mkdir -p "${INITRAMFS_DIR}$(dirname "${LD_PATH}")" - cp "${LD_PATH}" "${INITRAMFS_DIR}${LD_PATH}" -fi - -# Init script -cp "${INIT_SCRIPT}" "${INITRAMFS_DIR}/init" -chmod +x "${INITRAMFS_DIR}/init" - -# Metadata: paths + sizes so init script knows where to find things -cat > "${INITRAMFS_DIR}/migration_meta" </dev/null | gzip > /tmp/nixos_migration_initramfs.gz - -sudo cp /tmp/nixos_migration_initramfs.gz "${BOOT_PARTITION}/initramfs-migration.gz" - -# Migration flag on boot partition (survives root format) -sudo touch "${BOOT_PARTITION}/nixos_migration" - -progress 92 "Configuring boot" - -# --- Phase 7: Configure boot to use migration initramfs --- -if [ -f "${BOOT_PARTITION}/config.txt" ]; then - sudo cp "${BOOT_PARTITION}/config.txt" "${BOOT_PARTITION}/config.txt.premigration" - - echo "initramfs initramfs-migration.gz followkernel" | \ - sudo tee -a "${BOOT_PARTITION}/config.txt" > /dev/null -fi - -progress 100 "Rebooting in 5s..." - -echo "Migration staged. Tarball: ${TARBALL_SIZE} bytes" -echo "Rebooting in 5 seconds..." -sleep 5 -sudo reboot diff --git a/python/scripts/nixos_migration_calc.py b/python/scripts/nixos_migration_calc.py deleted file mode 100755 index d3500bdc7..000000000 --- a/python/scripts/nixos_migration_calc.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -""" -Pre-flight checks for NixOS migration. - -Validates hardware requirements before migration can proceed. -Run on the Pi to verify it meets minimum specs. - -Usage: python3 nixos_migration_calc.py [--json] --display-class CLASS --display-resolution WxH -""" - -import argparse -import json -import platform -import re -import shutil -import subprocess -import sys -from pathlib import Path - -MIN_RAM_MB = 1800 # 2GB Pi reports ~1849MB due to GPU memory reservation -MIN_SD_GB = 16 -REQUIRED_MODEL = "Raspberry Pi 4" -# Must match the initramfs progress renderer, not just the main PiFinder UI. -SUPPORTED_DISPLAYS = { - "DisplaySSD1351": "128x128", - "DisplaySSD1333": "176x176", -} -# The initramfs script hardcodes these paths and unconditionally extends -# partition 2 to fill the disk. Migration only supports the stock layout. -SD_DISK = "/dev/mmcblk0" -EXPECTED_BOOT = "/dev/mmcblk0p1" -EXPECTED_ROOT = "/dev/mmcblk0p2" -EXPECTED_PARTITION_COUNT = 2 - - -def get_model() -> str: - """Read Pi model from device-tree.""" - try: - return Path("/proc/device-tree/model").read_text().rstrip("\x00").strip() - except OSError: - return "Unknown" - - -def get_ram_mb() -> int: - """Get total RAM in MB from /proc/meminfo.""" - try: - text = Path("/proc/meminfo").read_text() - match = re.search(r"MemTotal:\s+(\d+)\s+kB", text) - if match: - return int(match.group(1)) // 1024 - except OSError: - pass - return 0 - - -def get_sd_size_gb() -> float: - """Get SD card size in GB (root device).""" - try: - result = subprocess.run( - ["lsblk", "-b", "-d", "-n", "-o", "SIZE", "/dev/mmcblk0"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return int(result.stdout.strip()) / (1024**3) - except (OSError, ValueError): - pass - return 0.0 - - -def get_free_space_gb(path: str = "/home/pifinder") -> float: - """Get free space in GB at the given path.""" - try: - usage = shutil.disk_usage(path) - return usage.free / (1024**3) - except OSError: - return 0.0 - - -def get_root_source() -> str: - """Device backing the / mount, e.g. /dev/mmcblk0p2.""" - try: - result = subprocess.run( - ["findmnt", "-no", "SOURCE", "/"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return result.stdout.strip() - except OSError: - pass - return "" - - -def get_partition_count(disk: str = SD_DISK) -> int: - """Number of partitions on the SD disk node (excludes the disk itself).""" - try: - result = subprocess.run( - ["lsblk", "-no", "NAME", "-l", disk], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return 0 - lines = [line for line in result.stdout.splitlines() if line.strip()] - return max(len(lines) - 1, 0) - except OSError: - return 0 - - -def get_wifi_mode() -> str: - """Detect WiFi mode.""" - wifi_status = Path("/home/pifinder/PiFinder/wifi_status.txt") - try: - return wifi_status.read_text().strip() - except OSError: - return "Unknown" - - -def normalize_resolution(value: str) -> str: - """Normalize a live UI resolution string to WIDTHxHEIGHT.""" - match = re.fullmatch(r"\s*(\d+)\s*[x,]\s*(\d+)\s*", value) - if not match: - return value.strip() - return f"{int(match.group(1))}x{int(match.group(2))}" - - -def is_pi4() -> bool: - """Check if running on a Raspberry Pi 4.""" - model = get_model() - return REQUIRED_MODEL in model - - -def check_all(display_class: str = "", display_resolution: str = "") -> dict: - """Run all pre-flight checks. Returns dict with results.""" - model = get_model() - ram_mb = get_ram_mb() - sd_gb = get_sd_size_gb() - free_gb = get_free_space_gb() - wifi = get_wifi_mode() - display_class = display_class.strip() or "Unknown" - display_resolution = normalize_resolution(display_resolution) or "Unknown" - display_ok = SUPPORTED_DISPLAYS.get(display_class) == display_resolution - display_ok = display_ok or ( - "SSD1333" in display_class and display_resolution == "176x176" - ) - root_source = get_root_source() - partition_count = get_partition_count() - boot_present = Path(EXPECTED_BOOT).is_block_device() - layout_ok = ( - root_source == EXPECTED_ROOT - and boot_present - and partition_count == EXPECTED_PARTITION_COUNT - ) - - checks = { - "model": model, - "is_pi4": REQUIRED_MODEL in model, - "ram_mb": ram_mb, - "ram_ok": ram_mb >= MIN_RAM_MB, - "sd_gb": round(sd_gb, 1), - "sd_ok": sd_gb >= MIN_SD_GB, - "free_gb": round(free_gb, 1), - "free_ok": free_gb >= 1.5, - "wifi_mode": wifi, - "wifi_ok": wifi == "Client", - "display_class": display_class, - "display_resolution": display_resolution, - "display_ok": display_ok, - "root_source": root_source, - "partition_count": partition_count, - "boot_present": boot_present, - "layout_ok": layout_ok, - "arch": platform.machine(), - } - checks["all_ok"] = all( - [ - checks["is_pi4"], - checks["ram_ok"], - checks["sd_ok"], - checks["free_ok"], - checks["wifi_ok"], - checks["display_ok"], - checks["layout_ok"], - ] - ) - return checks - - -def main(): - parser = argparse.ArgumentParser(description="NixOS migration pre-flight checks") - parser.add_argument("--json", action="store_true", help="Output as JSON") - parser.add_argument( - "--display-class", - default="", - help="Live PiFinder display class name from the running UI", - ) - parser.add_argument( - "--display-resolution", - default="", - help="Live PiFinder logical display resolution as WIDTHxHEIGHT", - ) - args = parser.parse_args() - - checks = check_all(args.display_class, args.display_resolution) - - if args.json: - print(json.dumps(checks, indent=2)) - sys.exit(0 if checks["all_ok"] else 1) - - print(f"Model: {checks['model']}") - print(f" Pi 4: {'OK' if checks['is_pi4'] else 'FAIL'}") - print(f"RAM: {checks['ram_mb']} MB") - print(f" >= {MIN_RAM_MB}MB: {'OK' if checks['ram_ok'] else 'FAIL'}") - print(f"SD Card: {checks['sd_gb']} GB") - print(f" >= {MIN_SD_GB}GB: {'OK' if checks['sd_ok'] else 'FAIL'}") - print(f"Free Space: {checks['free_gb']} GB") - print(f" >= 1.5GB: {'OK' if checks['free_ok'] else 'FAIL'}") - print(f"WiFi Mode: {checks['wifi_mode']}") - print(f" Client: {'OK' if checks['wifi_ok'] else 'FAIL'}") - print(f"Display: {checks['display_class']} {checks['display_resolution']}") - print( - f" initramfs renderer supported: " - f"{'OK' if checks['display_ok'] else 'FAIL'}" - ) - print(f"Root: {checks['root_source'] or 'Unknown'}") - print(f"Partitions: {checks['partition_count']} on {SD_DISK}") - print( - f" stock SD layout ({EXPECTED_BOOT} + {EXPECTED_ROOT}, 2 partitions): " - f"{'OK' if checks['layout_ok'] else 'FAIL'}" - ) - print(f"Arch: {checks['arch']}") - print() - if checks["all_ok"]: - print("All checks PASSED - migration can proceed") - else: - print("Some checks FAILED - migration cannot proceed") - - sys.exit(0 if checks["all_ok"] else 1) - - -if __name__ == "__main__": - main() diff --git a/python/scripts/nixos_migration_init.sh b/python/scripts/nixos_migration_init.sh deleted file mode 100755 index 385f972d9..000000000 --- a/python/scripts/nixos_migration_init.sh +++ /dev/null @@ -1,468 +0,0 @@ -#!/bin/busybox sh -# nixos_migration_init.sh - Initramfs init for NixOS migration -# -# Runs entirely from RAM. Strategy: -# 1. Save WiFi credentials and user backup to RAM -# 2. Copy tarball to RAM, unmount old root -# 3. Format both partitions -# 4. Extract tarball (boot → p1, rootfs → p2) -# 5. Restore WiFi + user data, expand partition -# 6. Reboot into NixOS - -set -e - -# The OLED progress display is driven through a pipe (fd 3). If that process -# ever dies, a write must not take a fatal SIGPIPE and abort the migration — -# the display is best-effort, the migration is not. -trap '' PIPE - -/bin/busybox --install -s /bin 2>/dev/null || true - -mount -t proc proc /proc 2>/dev/null || true -mount -t sysfs sysfs /sys 2>/dev/null || true -mount -t devtmpfs devtmpfs /dev 2>/dev/null || true -mount -t tmpfs tmpfs /tmp 2>/dev/null || true - -# Load SPI modules for OLED progress display -if [ -f /lib/modules/spi-bcm2835.ko ]; then - insmod /lib/modules/spi-bcm2835.ko 2>/dev/null || true - insmod /lib/modules/spidev.ko 2>/dev/null || true - sleep 0.5 -fi - -# Shared lib path for dynamically linked tools (e2fsck, mkfs, etc.) -export LD_LIBRARY_PATH=/lib:/usr/lib:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu - -BOOT_DEV="/dev/mmcblk0p1" -ROOT_DEV="/dev/mmcblk0p2" -SD_DEV="/dev/mmcblk0" -MOUNT_ROOT="/mnt/root" -MOUNT_NEW="/mnt/new" -MOUNT_BOOT="/mnt/boot" -PROGRESS="/bin/migration_progress" - -STAGE_NUM=0 -STAGE_TOTAL=22 -PROGRESS_FIFO="/tmp/migration_progress.fifo" -PROGRESS_READY=0 - -# Drive the OLED with a single long-lived process. It initialises the panel -# once and redraws in place from stdin, so the display never resets to black -# between stages (a fresh process per stage would re-assert the panel's RST -# line and blank it). fd 3 is the persistent writer; keeping it open stops the -# server seeing EOF until the migration is done. -if [ -x "${PROGRESS}" ]; then - rm -f "${PROGRESS_FIFO}" - mkfifo "${PROGRESS_FIFO}" 2>/dev/null || true - "${PROGRESS}" --serve < "${PROGRESS_FIFO}" >/dev/null 2>&1 & - exec 3>"${PROGRESS_FIFO}" - PROGRESS_READY=1 -fi - -show() { - local pct="$1" - local msg="$2" - STAGE_NUM=$((STAGE_NUM + 1)) - echo "[${pct}%] ${msg}" > /dev/console 2>/dev/null || true - echo "[${pct}%] ${msg}" - if [ "${PROGRESS_READY}" -eq 1 ]; then - echo "${pct} ${STAGE_NUM} ${STAGE_TOTAL} ${msg}" >&3 2>/dev/null || true - fi -} - -fail() { - if [ "${PROGRESS_READY}" -eq 1 ]; then - echo "0 0 0 FAILED: $1" >&3 2>/dev/null || true - fi - echo "[FAILED] $1" - echo "MIGRATION FAILED: $1" > /dev/console 2>/dev/null || true - echo "Dropping to shell for debugging..." - exec /bin/sh -} - -if [ -f /migration_meta ]; then - . /migration_meta - export MIGRATION_DISPLAY_CLASS="${DISPLAY_CLASS:-}" - export MIGRATION_DISPLAY_RESOLUTION="${DISPLAY_RESOLUTION:-}" -fi - -show 28 "Migrating..." - -# Wait for SD card device to appear -n=0 -while [ ! -b "${BOOT_DEV}" ] && [ "${n}" -lt 30 ]; do - sleep 1 - n=$((n + 1)) -done -[ ! -b "${BOOT_DEV}" ] && fail "SD card not found after 30s: ${BOOT_DEV}" - -show 30 "Initramfs started" - -# ------------------------------------------------------------------- -# Phase 1: Validate -# ------------------------------------------------------------------- - -# Check migration flag on boot partition -mkdir -p /mnt/bootchk -mount -t vfat -o ro "${BOOT_DEV}" /mnt/bootchk || fail "Cannot mount boot" -if [ ! -f /mnt/bootchk/nixos_migration ]; then - umount /mnt/bootchk - fail "No migration flag — aborting" -fi -umount /mnt/bootchk - -# Read metadata written by pre-migration script -if [ ! -f /migration_meta ]; then - fail "migration_meta not found in initramfs" -fi -. /migration_meta -# Now we have: TARBALL_PATH, TARBALL_SIZE, PIFINDER_DATA_PATH - -# Initial RAM check: tarball + fixed overhead must fit. The exact user-data -# backup size is checked after the old root is mounted, before formatting. -MEM_KB=$(awk '/MemAvailable/ {print $2}' /proc/meminfo) -MEM_MB=$((MEM_KB / 1024)) -TARBALL_SIZE_MB=$((TARBALL_SIZE / 1048576)) -NEEDED_MB=$((TARBALL_SIZE_MB + 150)) -[ "${MEM_MB}" -lt "${NEEDED_MB}" ] && fail "Insufficient RAM: ${MEM_MB}MB available, need ${NEEDED_MB}MB" - -show 31 "Validated: ${MEM_MB}MB" - -# ------------------------------------------------------------------- -# Phase 2: Save WiFi credentials to RAM -# ------------------------------------------------------------------- - -show 33 "Saving WiFi to RAM" - -mkdir -p "${MOUNT_ROOT}" -mount -t ext4 -o ro "${ROOT_DEV}" "${MOUNT_ROOT}" || fail "Cannot mount root" - -mkdir -p /tmp/wifi -WPA_FILE="${MOUNT_ROOT}/etc/wpa_supplicant/wpa_supplicant.conf" -if [ -f "${WPA_FILE}" ]; then - cp "${WPA_FILE}" /tmp/wifi/wpa_supplicant.conf -fi - -NM_SRC="${MOUNT_ROOT}/etc/NetworkManager/system-connections" -if [ -d "${NM_SRC}" ]; then - mkdir -p /tmp/wifi/nm-connections - cp -a "${NM_SRC}/." /tmp/wifi/nm-connections/ 2>/dev/null || true -fi - -# ------------------------------------------------------------------- -# Phase 3: Create user backup in RAM -# ------------------------------------------------------------------- - -show 35 "Creating backup" - -PIFINDER_DATA_ON_ROOT="${MOUNT_ROOT}${PIFINDER_DATA_PATH}" -BACKUP_STAGE="/tmp/backup_stage/PiFinder_data" -rm -rf /tmp/backup_stage -mkdir -p "${BACKUP_STAGE}" - -if [ -d "${PIFINDER_DATA_ON_ROOT}" ]; then - BACKUP_NEED_KB=0 - - # Root-level files are preserved, except pifinder.log which is truncated - # while copying so a large log cannot exhaust initramfs RAM. - for f in "${PIFINDER_DATA_ON_ROOT}"/*; do - if [ -f "$f" ]; then - case "$(basename "$f")" in - pifinder.log) - BACKUP_NEED_KB=$((BACKUP_NEED_KB + 256)) - ;; - *) - FILE_KB=$(du -sk "$f" 2>/dev/null | awk '{print $1}') - BACKUP_NEED_KB=$((BACKUP_NEED_KB + ${FILE_KB:-0})) - ;; - esac - fi - done - if [ -d "${PIFINDER_DATA_ON_ROOT}/obslists" ]; then - OBSLISTS_KB=$(du -sk "${PIFINDER_DATA_ON_ROOT}/obslists" 2>/dev/null | awk '{print $1}') - BACKUP_NEED_KB=$((BACKUP_NEED_KB + ${OBSLISTS_KB:-0})) - fi - - MEM_KB=$(awk '/MemAvailable/ {print $2}' /proc/meminfo) - TARBALL_KB=$((TARBALL_SIZE / 1024)) - # Keep a conservative 150 MiB for tools, page cache, and shell overhead. - NEEDED_KB=$((TARBALL_KB + BACKUP_NEED_KB + 153600)) - [ "${MEM_KB}" -lt "${NEEDED_KB}" ] && fail "Insufficient RAM for backup: $((MEM_KB / 1024))MB available, need $((NEEDED_KB / 1024))MB" - - # Copy root-level files (observations.db, configs, etc.) - for f in "${PIFINDER_DATA_ON_ROOT}"/*; do - if [ -f "$f" ]; then - if [ "$(basename "$f")" = "pifinder.log" ]; then - tail -n 1000 "$f" > "${BACKUP_STAGE}/pifinder.log" 2>/dev/null || true - else - cp "$f" "${BACKUP_STAGE}/" 2>/dev/null || true - fi - fi - done - - # Copy obslists directory - if [ -d "${PIFINDER_DATA_ON_ROOT}/obslists" ]; then - cp -a "${PIFINDER_DATA_ON_ROOT}/obslists" "${BACKUP_STAGE}/obslists" - fi -fi - -# Preserve the pre-migration hostname: Pi OS stores it in /etc/hostname, the NixOS -# image reads it from PiFinder_data/hostname. Bridge them; don't clobber an existing one. -if [ ! -f "${BACKUP_STAGE}/hostname" ] && [ -s "${MOUNT_ROOT}/etc/hostname" ]; then - head -n1 "${MOUNT_ROOT}/etc/hostname" | tr -d '[:space:]' > "${BACKUP_STAGE}/hostname" -fi - -show 38 "Backup created" - -# ------------------------------------------------------------------- -# Phase 4: Copy tarball to RAM, unmount old root -# ------------------------------------------------------------------- - -show 40 "Loading tarball" - -TARBALL_ON_ROOT="${MOUNT_ROOT}${TARBALL_PATH}" -[ ! -f "${TARBALL_ON_ROOT}" ] && { umount "${MOUNT_ROOT}"; fail "Tarball not found: ${TARBALL_PATH}"; } - -cp "${TARBALL_ON_ROOT}" /tmp/migration.tar.zst || fail "Failed to copy tarball to RAM" -umount "${MOUNT_ROOT}" - -show 48 "Tarball loaded to RAM" - -# ------------------------------------------------------------------- -# Phase 5: Expand + format partitions -# ------------------------------------------------------------------- - -show 49 "Expanding partition" - -# Expand partition 2 BEFORE formatting — sfdisk rewrites the MBR and -# blockdev --rereadpt can corrupt a written FAT partition if done after. -echo ", +" | sfdisk -N 2 "${SD_DEV}" --no-reread 2>/dev/null || true -blockdev --rereadpt "${SD_DEV}" 2>/dev/null || true -sleep 1 - -show 50 "Formatting boot" - -mkfs.vfat -F 32 -n FIRMWARE "${BOOT_DEV}" || fail "mkfs.vfat failed" - -show 52 "Formatting root" - -mkfs.ext4 -F -L NIXOS_SD "${ROOT_DEV}" || fail "mkfs.ext4 failed" - -# ------------------------------------------------------------------- -# Phase 6: Extract tarball -# ------------------------------------------------------------------- - -show 55 "Extracting NixOS" - -mkdir -p "${MOUNT_NEW}" -mount -t ext4 "${ROOT_DEV}" "${MOUNT_NEW}" || fail "Cannot mount new root" - -# Extract tarball directly to SD card (ext4 has plenty of space, tmpfs does not) -zstd -d < /tmp/migration.tar.zst | tar xf - -C "${MOUNT_NEW}" || fail "Tarball extraction failed" -rm -f /tmp/migration.tar.zst - -show 60 "Moving rootfs" - -# Move rootfs/ contents up to partition root (same-fs rename, fast) -cd "${MOUNT_NEW}/rootfs" -for item in * .[!.]* ..?*; do - [ -e "$item" ] || continue - mv "$item" "${MOUNT_NEW}/" -done -cd / -rmdir "${MOUNT_NEW}/rootfs" - -# NetworkManager (like other security-sensitive plugin loaders) refuses to load -# any plugin file not owned by root, so a /nix/store with non-root paths baked -# into the tarball silently kills wifi (wlan0 ends up "unmanaged"). Normalise -# store ownership to root now, while the new root is still writable — once -# NixOS boots /nix/store is mounted read-only. (The boot-time -# fix-nix-store-ownership service is the runtime backstop for this.) -chown -R 0:0 "${MOUNT_NEW}/nix/store" "${MOUNT_NEW}/nix/var/nix/db" 2>/dev/null || true - -show 66 "Copying boot" - -mkdir -p "${MOUNT_BOOT}" -mount -t vfat "${BOOT_DEV}" "${MOUNT_BOOT}" || fail "Cannot mount boot" - -# Copy boot files to FAT partition -cd "${MOUNT_NEW}/boot" -for item in *; do - [ -e "$item" ] || continue - if [ -d "$item" ]; then - cp -r "$item" "${MOUNT_BOOT}/$item" - else - cp "$item" "${MOUNT_BOOT}/$item" - fi -done -cd / -sync - -# Verify critical boot files landed -if [ ! -f "${MOUNT_BOOT}/extlinux/extlinux.conf" ]; then - echo "Boot partition contents:" >&2 - ls -lR "${MOUNT_BOOT}" >&2 - fail "extlinux.conf missing from boot partition after copy" -fi - -# Keep boot/ on ext4 — U-Boot reads extlinux.conf from mmc 0:2 (ext4 root) -# FAT partition only needs RPi firmware files (config.txt, u-boot, DTBs) - -# ------------------------------------------------------------------- -# Phase 7: Migrate WiFi -# ------------------------------------------------------------------- - -show 70 "Migrating WiFi" - -NM_DIR="${MOUNT_NEW}/etc/NetworkManager/system-connections" -mkdir -p "${NM_DIR}" - -# NM keyfile helpers — SSID becomes hex bytes (safe for any content), -# PSK gets minimal keyfile escaping (backslash + semicolon), filename -# is sanitized so a hostile or unusual SSID can't escape NM_DIR. -ssid_to_hex() { - printf '%s' "$1" | od -An -tx1 | tr -d ' \n' | sed 's/\(..\)/\1;/g' -} -escape_kf() { - printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -} -sanitize_fn() { - printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' -} - -if [ -f /tmp/wifi/wpa_supplicant.conf ]; then - SSID="" - PSK="" - IN_NET=0 - - while IFS= read -r line; do - line=$(printf '%s' "${line}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') - - case "${line}" in - network=*) - IN_NET=1 - SSID="" - PSK="" - ;; - "}") - if [ "${IN_NET}" = "1" ] && [ -n "${SSID}" ]; then - SSID_HEX=$(ssid_to_hex "${SSID}") - ID_ESC=$(escape_kf "${SSID}") - FN=$(sanitize_fn "${SSID}") - # Guard against empty/dotfile filenames after sanitization - case "${FN}" in - ""|.|..) FN="wifi" ;; - esac - NM_FILE="${NM_DIR}/${FN}.nmconnection" - - if [ -n "${PSK}" ]; then - PSK_ESC=$(escape_kf "${PSK}") - cat > "${NM_FILE}" < "${NM_FILE}" </dev/null || true -fi - -sync - -show 74 "WiFi migrated" - -# ------------------------------------------------------------------- -# Phase 8: Restore user data -# ------------------------------------------------------------------- - -show 76 "Restoring user data" - -mkdir -p "${MOUNT_NEW}/home/pifinder" - -if [ -d /tmp/backup_stage/PiFinder_data ]; then - cp -a /tmp/backup_stage/PiFinder_data "${MOUNT_NEW}/home/pifinder/" -fi - -# pifinder user: UID 1000, GID 100 (users) on NixOS -chown -R 1000:100 "${MOUNT_NEW}/home/pifinder" 2>/dev/null || true - -show 80 "User data restored" - -# ------------------------------------------------------------------- -# Phase 9: Expand partition and finalize -# ------------------------------------------------------------------- - -umount "${MOUNT_BOOT}" 2>/dev/null || true -umount "${MOUNT_NEW}" 2>/dev/null || true - -show 82 "Resizing filesystem" - -e2fsck -f -y "${ROOT_DEV}" 2>/dev/null || true -resize2fs "${ROOT_DEV}" 2>/dev/null || true - -show 92 "Syncing" -sync - -# Final verification: remount boot partition and confirm extlinux.conf survived -show 95 "Verifying boot" -mkdir -p /mnt/bootchk -mount -t vfat -o ro "${BOOT_DEV}" /mnt/bootchk || fail "Cannot remount boot for verification" -if [ ! -f /mnt/bootchk/extlinux/extlinux.conf ]; then - ls -lR /mnt/bootchk > /dev/console 2>&1 || true - umount /mnt/bootchk 2>/dev/null || true - fail "extlinux.conf missing from boot partition before reboot" -fi -umount /mnt/bootchk - -show 100 "Complete" -sleep 3 - -echo "Rebooting into NixOS..." > /dev/console 2>/dev/null || true -reboot -f 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_gps_ubx_parser.py b/python/tests/test_gps_ubx_parser.py new file mode 100644 index 000000000..a803f061f --- /dev/null +++ b/python/tests/test_gps_ubx_parser.py @@ -0,0 +1,102 @@ +import struct + +import pytest + +from PiFinder.gps_ubx_parser import UBXParser + + +def make_svinfo_channel(chn, svid, flags, quality, cno, elev, azim, prres=0): + """Build one 12-byte UBX-NAV-SVINFO repeated block.""" + return struct.pack("= 20: diff --git a/python/tests/test_net_policy.py b/python/tests/test_net_policy.py new file mode 100644 index 000000000..d9f0ecde3 --- /dev/null +++ b/python/tests/test_net_policy.py @@ -0,0 +1,135 @@ +import pytest + +from PiFinder.net_policy_core import ( + AP_DOWN, + AP_UP, + CLIENT_RETRY_SECONDS, + GRACE_SECONDS, + PolicyState, + Snapshot, + decide, +) + + +def _snap(**overrides): + snap = Snapshot( + forced_ap=False, + eth_connected=False, + wifi_client_active=False, + ap_active=False, + ap_stations=0, + ) + for key, value in overrides.items(): + setattr(snap, key, value) + return snap + + +@pytest.mark.unit +class TestForcedAp: + def test_brings_ap_up(self): + assert decide(_snap(forced_ap=True), PolicyState(), 100.0) == AP_UP + + def test_noop_when_already_up(self): + snap = _snap(forced_ap=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + def test_forced_ap_wins_over_ethernet(self): + snap = _snap(forced_ap=True, eth_connected=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + +@pytest.mark.unit +class TestWiredPriority: + def test_ap_dropped_when_wired(self): + snap = _snap(eth_connected=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) == AP_DOWN + + def test_noop_when_wired_and_no_ap(self): + assert decide(_snap(eth_connected=True), PolicyState(), 100.0) is None + + def test_wired_resets_grace_timer(self): + state = PolicyState(disconnected_since=50.0) + decide(_snap(eth_connected=True), state, 100.0) + assert state.disconnected_since is None + + +@pytest.mark.unit +class TestWifiClient: + def test_noop_when_client_connected(self): + snap = _snap(wifi_client_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + def test_client_resets_grace_timer(self): + state = PolicyState(disconnected_since=50.0) + decide(_snap(wifi_client_active=True), state, 100.0) + assert state.disconnected_since is None + + +@pytest.mark.unit +class TestGraceFallback: + def test_no_immediate_ap(self): + state = PolicyState() + assert decide(_snap(), state, 100.0) is None + assert state.disconnected_since == 100.0 + + def test_ap_up_after_grace(self): + state = PolicyState() + decide(_snap(), state, 100.0) + assert decide(_snap(), state, 100.0 + GRACE_SECONDS) == AP_UP + + def test_no_ap_before_grace(self): + state = PolicyState() + decide(_snap(), state, 100.0) + assert decide(_snap(), state, 100.0 + GRACE_SECONDS - 1) is None + + def test_reconnect_within_grace_cancels_fallback(self): + state = PolicyState() + decide(_snap(), state, 100.0) + decide(_snap(wifi_client_active=True), state, 110.0) + # Disconnect again much later: the grace clock must restart. + assert decide(_snap(), state, 500.0) is None + assert state.disconnected_since == 500.0 + + +@pytest.mark.unit +class TestIdleApClientRetry: + def _aged_state(self, now): + """State as it looks after the AP has been up for a while.""" + return PolicyState(last_client_retry=now - CLIENT_RETRY_SECONDS) + + def test_idle_ap_retries_client(self): + now = 1000.0 + snap = _snap(ap_active=True, ap_stations=0) + assert decide(snap, self._aged_state(now), now) == AP_DOWN + + def test_occupied_ap_never_dropped(self): + now = 1000.0 + snap = _snap(ap_active=True, ap_stations=2) + assert decide(snap, self._aged_state(now), now) is None + + def test_no_retry_before_interval(self): + now = 1000.0 + state = PolicyState(last_client_retry=now - CLIENT_RETRY_SECONDS + 5) + assert decide(_snap(ap_active=True), state, now) is None + + def test_first_observation_arms_but_does_not_retry(self): + # An externally-activated AP (e.g. at boot) must not be dropped on + # the daemon's first look; the retry clock starts then. + state = PolicyState() + assert decide(_snap(ap_active=True), state, 1000.0) is None + assert state.last_client_retry == 1000.0 + + def test_retry_then_fallback_restores_ap(self): + # Full cycle: idle AP dropped for a retry, no client appears, + # grace expires, AP comes back. + now = 1000.0 + state = self._aged_state(now) + assert decide(_snap(ap_active=True, ap_stations=0), state, now) == AP_DOWN + assert decide(_snap(), state, now + GRACE_SECONDS) == AP_UP + + def test_retry_then_client_joins(self): + now = 1000.0 + state = self._aged_state(now) + assert decide(_snap(ap_active=True, ap_stations=0), state, now) == AP_DOWN + assert decide(_snap(wifi_client_active=True), state, now + 10) is None + assert state.disconnected_since is None diff --git a/python/tests/test_nixos_upgrade.py b/python/tests/test_nixos_upgrade.py new file mode 100644 index 000000000..4cd344b96 --- /dev/null +++ b/python/tests/test_nixos_upgrade.py @@ -0,0 +1,361 @@ +import json +import urllib.error + +import pytest + +from PiFinder import nixos_upgrade + + +STORE = "/nix/store/abc123-nixos-system-pifinder" + + +class _FakeResp: + def __init__(self, status): + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _fake_urlopen(outcomes): + """Build a urlopen stub that returns/raises one outcome per cache probe. + + Each outcome is either an int HTTP status (-> a response) or an Exception + instance to raise (a 404 HTTPError, a URLError, etc.). + """ + calls = iter(outcomes) + + def _open(url, timeout=None): + outcome = next(calls) + if isinstance(outcome, Exception): + raise outcome + return _FakeResp(outcome) + + return _open + + +def _http_error(code): + return urllib.error.HTTPError( + url="https://cache/abc.narinfo", code=code, msg="x", hdrs=None, fp=None + ) + + +@pytest.mark.unit +def test_valid_store_path_rejects_non_store_refs(): + assert nixos_upgrade.valid_store_path(STORE) + assert not nixos_upgrade.valid_store_path("release") + assert not nixos_upgrade.valid_store_path("/tmp/not-a-store-path") + + +@pytest.mark.unit +def test_parse_progress_event_ignores_malformed_lines(): + assert nixos_upgrade.parse_progress_event("copying path") is None + assert nixos_upgrade.parse_progress_event("@nix {") is None + + +@pytest.mark.unit +def test_parse_progress_event_extracts_copy_path(): + line = ( + '@nix {"action":"start","id":7,"type":100,' + f'"text":"copying path \'{STORE}\' from cache"}}' + ) + event = nixos_upgrade.parse_progress_event(line) + + assert event == nixos_upgrade.ProgressEvent("start", 7, 100, STORE) + + +@pytest.mark.unit +def test_parse_progress_event_extracts_byte_progress(): + line = '@nix {"action":"result","id":3,"type":105,"fields":[1024,4096,1,0]}' + event = nixos_upgrade.parse_progress_event(line) + assert event == nixos_upgrade.ProgressEvent("result", 3, None, None, 1024, 4096) + + +@pytest.mark.unit +def test_download_progress_tracks_bytes_and_label(monkeypatch): + statuses: list[str] = [] + monkeypatch.setattr( + nixos_upgrade, "write_status", lambda s, _f=None: statuses.append(s) + ) + progress = nixos_upgrade._DownloadProgress(10_000_000, 2, None) + progress.feed( + f'@nix {{"action":"start","id":1,"type":100,' + f'"text":"copying path \'{STORE}\' from cache"}}' + ) + progress.feed( + '@nix {"action":"result","id":1,"type":105,"fields":[5000000,8000000,1,0]}' + ) + progress.feed('@nix {"action":"stop","id":1,"type":100}') + + # within-path byte movement, the package label, and never a crash on junk + assert statuses and all(s.startswith("downloading ") for s in statuses) + assert any("nixos-system-pifinder" in s for s in statuses) + for bad in ["garbage", "@nix {oops", ""]: + progress.feed(bad) + + +@pytest.mark.unit +def test_run_build_uses_no_link(monkeypatch, tmp_path): + started = {} + + class FakeStdout: + def __iter__(self): + return iter(()) + + class FakeProcess: + stdout = FakeStdout() + + def wait(self): + return 0 + + def fake_popen(args, **kwargs): + started["args"] = args + return FakeProcess() + + monkeypatch.setattr(nixos_upgrade.subprocess, "Popen", fake_popen) + monkeypatch.setattr(nixos_upgrade, "fetch_cache_public_keys", lambda: []) + + rc = nixos_upgrade.run_build( + STORE, + nixos_upgrade.DownloadEstimate(()), + status_file=tmp_path / "status", + log_file=tmp_path / "log", + ) + + assert rc == 0 + assert "--no-link" in started["args"] + + +@pytest.mark.unit +def test_estimate_download_parses_paths_and_total(monkeypatch): + dry = ( + "these 1 paths will be fetched (0.0 KiB download, 12.5 MiB unpacked):\n" + f" {STORE}\n" + ) + + def fake_command(args, **kwargs): + class Result: + returncode = 0 + stdout = dry + stderr = "" + + return Result() + + monkeypatch.setattr(nixos_upgrade, "command", fake_command) + + estimate = nixos_upgrade.estimate_download(STORE) + + assert estimate.paths == (STORE,) + assert estimate.path_count == 1 + assert estimate.total_bytes == int(12.5 * 1024 * 1024) + + +def _capture_status(monkeypatch): + statuses = [] + monkeypatch.setattr(nixos_upgrade, "write_status", statuses.append) + return statuses + + +@pytest.mark.unit +def test_classify_local_path_is_available(monkeypatch): + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: True) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.AVAILABLE + + +@pytest.mark.unit +def test_classify_cache_hit_is_available(monkeypatch): + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: False) + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen([200])) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.AVAILABLE + + +@pytest.mark.unit +def test_classify_all_404_is_absent(monkeypatch): + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: False) + monkeypatch.setattr( + "urllib.request.urlopen", _fake_urlopen([_http_error(404), _http_error(404)]) + ) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.ABSENT + + +@pytest.mark.unit +def test_classify_connection_error_is_unreachable(monkeypatch): + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: False) + err = urllib.error.URLError("no route to host") + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen([err, err])) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.UNREACHABLE + + +@pytest.mark.unit +def test_classify_partial_unreachable_is_not_absent(monkeypatch): + # One cache says 404, the other can't be reached: the build might still be + # on the unreachable cache, so this must be retryable, not "gone". + monkeypatch.setattr(nixos_upgrade, "path_exists", lambda _p: False) + outcomes = [_http_error(404), urllib.error.URLError("timeout")] + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen(outcomes)) + assert nixos_upgrade.classify_store_path(STORE) == nixos_upgrade.UNREACHABLE + + +@pytest.mark.unit +def test_run_upgrade_invalid_ref_writes_failed(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text("release") + statuses = _capture_status(monkeypatch) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "failed"] + + +@pytest.mark.unit +def test_run_upgrade_unavailable_writes_unavailable(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + statuses = _capture_status(monkeypatch) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 1) + monkeypatch.setattr( + nixos_upgrade, "classify_store_path", lambda _store: nixos_upgrade.ABSENT + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "unavailable"] + + +@pytest.mark.unit +def test_run_upgrade_unreachable_writes_connfail(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + statuses = _capture_status(monkeypatch) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 1) + monkeypatch.setattr( + nixos_upgrade, "classify_store_path", lambda _store: nixos_upgrade.UNREACHABLE + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "connfail"] + + +@pytest.mark.unit +def test_run_upgrade_build_failure_writes_failed(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + statuses = _capture_status(monkeypatch) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 1) + monkeypatch.setattr( + nixos_upgrade, "classify_store_path", lambda _store: nixos_upgrade.AVAILABLE + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "failed"] + + +@pytest.mark.unit +def test_run_upgrade_activation_failure_writes_failed(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + statuses = _capture_status(monkeypatch) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 0) + monkeypatch.setattr(nixos_upgrade, "load_selection", dict) + monkeypatch.setattr( + nixos_upgrade, + "activate_system", + lambda _store, _camera: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "failed"] + + +@pytest.mark.unit +def test_run_upgrade_success_writes_rebooting_and_persists(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + current_build = tmp_path / "current-build.json" + statuses = _capture_status(monkeypatch) + commands = [] + monkeypatch.setattr(nixos_upgrade, "CURRENT_BUILD_FILE", current_build) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 0) + monkeypatch.setattr( + nixos_upgrade, + "load_selection", + lambda: {"version": "nixos-test", "label": "test", "channel": "unstable"}, + ) + monkeypatch.setattr(nixos_upgrade, "activate_system", lambda _store, _camera: None) + monkeypatch.setattr(nixos_upgrade, "cleanup_old_generations", lambda: None) + monkeypatch.setattr( + nixos_upgrade, + "command", + lambda args, **_kwargs: commands.append(args), + ) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 0 + assert statuses == ["starting", "rebooting"] + assert commands == [["systemctl", "reboot"]] + assert json.loads(current_build.read_text())["version"] == "nixos-test" + + +@pytest.mark.unit +def test_run_upgrade_reboot_failure_writes_failed(tmp_path, monkeypatch): + ref_file = tmp_path / "ref" + ref_file.write_text(STORE) + current_build = tmp_path / "current-build.json" + statuses = _capture_status(monkeypatch) + monkeypatch.setattr(nixos_upgrade, "CURRENT_BUILD_FILE", current_build) + monkeypatch.setattr( + nixos_upgrade, + "estimate_download", + lambda _store: nixos_upgrade.DownloadEstimate(()), + ) + monkeypatch.setattr(nixos_upgrade, "run_build", lambda _store, _estimate: 0) + monkeypatch.setattr(nixos_upgrade, "load_selection", dict) + monkeypatch.setattr(nixos_upgrade, "activate_system", lambda _store, _camera: None) + monkeypatch.setattr(nixos_upgrade, "cleanup_old_generations", lambda: None) + + def fail_reboot(_args, **_kwargs): + raise RuntimeError("reboot failed") + + monkeypatch.setattr(nixos_upgrade, "command", fail_reboot) + + rc = nixos_upgrade.run_upgrade(ref_file, "imx462") + + assert rc == 1 + assert statuses == ["starting", "rebooting", "failed"] diff --git a/python/tests/test_sd_notify.py b/python/tests/test_sd_notify.py new file mode 100644 index 000000000..c4251f8f0 --- /dev/null +++ b/python/tests/test_sd_notify.py @@ -0,0 +1,30 @@ +import socket + +import pytest + +from PiFinder.utils import sd_notify + + +@pytest.mark.unit +class TestSdNotify: + def test_noop_without_notify_socket(self, monkeypatch): + # Development runs / tests: no systemd, no socket — must be silent. + monkeypatch.delenv("NOTIFY_SOCKET", raising=False) + sd_notify("READY=1") # must not raise + + def test_sends_state_to_socket(self, monkeypatch, tmp_path): + sock_path = str(tmp_path / "notify.sock") + server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + server.bind(sock_path) + server.settimeout(2) + try: + monkeypatch.setenv("NOTIFY_SOCKET", sock_path) + sd_notify("READY=1") + assert server.recv(64) == b"READY=1" + finally: + server.close() + + def test_broken_socket_is_swallowed(self, monkeypatch, tmp_path): + # Socket path set but nothing listening: failure must not propagate. + monkeypatch.setenv("NOTIFY_SOCKET", str(tmp_path / "gone.sock")) + sd_notify("READY=1") # must not raise diff --git a/python/tests/test_software.py b/python/tests/test_software.py index ba73e33d3..9f2e828f0 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,240 @@ 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_hides_exact_running_build(): + # The running build is hidden from unstable by store-path identity; a + # rebuilt PR (same number, new store path) is a real upgrade and stays. + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["unstable"] + ui._channel_index = 0 + ui._software_version = "PR#1-abcdef0" + ui._channels = { + "unstable": [ + { + "label": "nixos-trunk", + "version": "nixos-trunk", + "is_trunk": True, + "ref": "/nix/store/bbb-trunk", + }, + { + "label": "PR#1-abcdef0", + "version": "PR#1-abcdef0", + "ref": "/nix/store/aaa-running", + }, + ] + } + + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-running", + ): + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["nixos-trunk"] - @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_build_by_store_path(): + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["stable"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "stable": [ + {"label": "v3.0.0", "version": "3.0.0", "ref": "/nix/store/aaa-current"}, + {"label": "v3.1.0", "version": "3.1.0", "ref": "/nix/store/bbb-next"}, + ] + } + + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-current", + ): + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["v3.1.0"] + + +@pytest.mark.unit +def test_recut_release_with_same_version_stays_visible(): + # A re-cut release reuses its version/label but is a different store path + # — it must be offered as an upgrade, not hidden by a version-string match. + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["beta"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "beta": [ + {"label": "v3.0.0-beta", "version": "3.0.0", "ref": "/nix/store/bbb-recut"}, + ] + } + + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-old-build", + ): + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["v3.0.0-beta"] + + +@pytest.mark.unit +def test_unknown_current_build_hides_nothing(): + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["stable"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "stable": [ + {"label": "v3.0.0", "version": "3.0.0", "ref": "/nix/store/aaa"}, + ] + } + + with patch("PiFinder.ui.software._current_store_path", return_value=None): + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["v3.0.0"] + + +@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..b8f85c14a 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,11 @@ # UIModule subclasses that are intentionally *not* exercised, with the reason. # Keeps the completeness guard (test_all_ui_modules_covered) honest. _COVERAGE_SKIP: dict[str, str] = { - # (UISQMCorrection is covered via the dynamic fixtures) - "UIReleaseNotes": "fetches markdown via HTTP in active(); needs a network mock", + "UIReleaseNotes": ( + "Pushed onto the stack by UISoftware's Notes action with a " + "notes-payload item_definition; not reachable from the menu tree " + "and needs live update-channel state to construct." + ), } # Bound on the auto-sweep so a handler that keeps pushing modules @@ -185,8 +187,6 @@ def _node_id(node) -> str: "UISQMCalibration", "UISQMSweep", "UISQMCorrection", - "UIMigrationConfirm", - "UIMigrationProgress", ] @@ -230,23 +230,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 @@ -259,7 +242,11 @@ def _all_uimodule_subclasses() -> set[str]: def _recurse(cls): for sub in cls.__subclasses__(): - found.add(sub.__name__) + # Only classes that live in the UI package count — test helpers + # subclassing UIModule elsewhere (e.g. test_battery_titlebar_icon's + # _BareModule) must not trip the coverage guard. + if sub.__module__.startswith("PiFinder.ui"): + found.add(sub.__name__) _recurse(sub) _recurse(UIModule) @@ -570,10 +557,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 80b2c9800..9c2afeb19 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 63548afa5..5023f45c3 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/tetra3 b/python/tetra3 deleted file mode 120000 index 866d074d4..000000000 --- a/python/tetra3 +++ /dev/null @@ -1 +0,0 @@ -PiFinder/tetra3/tetra3 \ No newline at end of file diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 000000000..efe12dff1 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,1644 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" +resolution-markers = [ + "sys_platform == 'linux'", +] +supported-markers = [ + "sys_platform == 'linux'", +] + +[manifest] + +[[manifest.dependency-metadata]] +name = "cedar-solve" +version = "0.5.1" +requires-dist = ["numpy", "pillow", "scipy"] + +[[manifest.dependency-metadata]] +name = "python-libinput" +version = "0.3.0a0" +requires-dist = ["cffi"] + +[[manifest.dependency-metadata]] +name = "python-prctl" +version = "1.8.1" + +[[package]] +name = "adafruit-blinka" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-circuitpython-typing" }, + { name = "adafruit-platformdetect" }, + { name = "adafruit-pureio" }, + { name = "binho-host-adapter" }, + { name = "pyftdi" }, + { name = "sysv-ipc", marker = "platform_machine != 'mips'" }, +] +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" }, + { name = "adafruit-circuitpython-busdevice" }, + { name = "adafruit-circuitpython-register" }, +] +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" }, + { name = "adafruit-circuitpython-typing" }, +] +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" }, +] +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" }, + { name = "adafruit-circuitpython-busdevice" }, + { name = "adafruit-circuitpython-typing" }, + { name = "typing-extensions" }, +] +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" }, + { name = "adafruit-circuitpython-connectionmanager" }, +] +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" }, + { name = "adafruit-circuitpython-busdevice" }, + { name = "adafruit-circuitpython-requests" }, + { name = "typing-extensions" }, +] +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" }, +] +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 = "cedar-solve" +version = "0.5.1" +source = { git = "https://github.com/smroid/cedar-solve?rev=d8ff1d857a363c88917fd8e126ab90e24b1cfbcc#d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "scipy" }, +] + +[[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'" }, +] +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" }, + { name = "typing-inspect" }, +] +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" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +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" }, + { name = "flask" }, + { name = "jinja2" }, + { name = "pytz" }, +] +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" }, +] +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" }, +] +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" }, +] +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" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +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" }, +] +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" }, + { name = "pillow" }, + { name = "smbus2" }, +] +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" }, + { name = "pygame" }, +] +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" }, +] +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" }, +] +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" }, +] +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" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +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" }, + { name = "scipy" }, +] +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" }, +] +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/e1/f6/36f53b26114955df4c996abb96edb7fc6112fa1db52e30daf1c64b6f9ab1/openexr-3.4.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8be14c4794f4ad19112fbbed7767c5bf6216435f27cb42a499cdab0f3d26562", size = 2159502, upload-time = "2026-05-25T02:07:33.944Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +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" }, + { name = "python-dateutil" }, +] +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 = "pandas-stubs" +version = "3.0.3.260530" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/aa/c41a8a0ff86fd85dbb3ec0c1f3fa488ca64a8b5f82654ae1b07d84acefe5/pandas_stubs-3.0.3.260530.tar.gz", hash = "sha256:d1efe47b2e5a312c047d7feabec5cb7a55365747983420077e9fcbe9ab74f714", size = 113183, upload-time = "2026-05-30T17:47:40.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e0/99ec5b02203c4e9ce878bc63d8caa06ac1f891e4d63bded9a5ced70fcb4f/pandas_stubs-3.0.3.260530-py3-none-any.whl", hash = "sha256:a6277eb1c8cebf48d9b2413fcd2e9a6b4ff479c934a223c29eacbc3058c4cb55", size = 173780, upload-time = "2026-05-30T17:47:39.13Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "picamera2" +version = "0.3.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "jsonschema" }, + { name = "libarchive-c" }, + { name = "numpy" }, + { name = "openexr" }, + { name = "pidng" }, + { name = "piexif" }, + { name = "pillow" }, + { name = "python-prctl" }, + { name = "simplejpeg" }, + { name = "tqdm" }, + { name = "videodev2" }, +] +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" }, +] +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" }, + { name = "adafruit-circuitpython-bno055" }, + { name = "aiofiles" }, + { name = "av" }, + { name = "cedar-solve" }, + { name = "dataclasses-json" }, + { name = "dbus-python", marker = "platform_machine == 'aarch64'" }, + { name = "flask" }, + { name = "flask-babel" }, + { name = "gpsdclient" }, + { name = "grpcio" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "libarchive-c" }, + { name = "luma-lcd" }, + { name = "luma-oled" }, + { name = "numpy" }, + { name = "numpy-quaternion" }, + { name = "pandas" }, + { name = "picamera2", marker = "platform_machine == 'aarch64'" }, + { name = "pidng" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pydeepskylog" }, + { name = "pyerfa" }, + { name = "pygobject", marker = "platform_machine == 'aarch64'" }, + { name = "pyjwt" }, + { name = "python-libinput", marker = "platform_machine == 'aarch64'" }, + { name = "python-pam" }, + { name = "python-prctl", marker = "platform_machine == 'aarch64'" }, + { name = "pytz" }, + { name = "requests" }, + { name = "rpi-gpio", marker = "platform_machine == 'aarch64'" }, + { name = "rpi-hardware-pwm", marker = "platform_machine == 'aarch64'" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sh" }, + { name = "simplejpeg" }, + { name = "skyfield" }, + { name = "smbus2" }, + { name = "spidev", marker = "platform_machine == 'aarch64'" }, + { name = "timezonefinder" }, + { name = "tqdm" }, + { name = "videodev2" }, + { name = "waitress" }, +] + +[package.dev-dependencies] +dev = [ + { name = "luma-emulator" }, + { name = "mypy" }, + { name = "pandas-stubs" }, + { name = "pyhotkey", marker = "platform_machine == 'aarch64'" }, + { name = "pynput", marker = "platform_machine == 'aarch64'" }, + { name = "pytest" }, + { name = "selenium" }, + { name = "types-aiofiles" }, + { name = "types-pynput" }, + { name = "types-pytz" }, + { name = "types-requests" }, + { name = "types-tqdm" }, + { name = "types-waitress" }, + { name = "xlrd" }, +] + +[package.metadata] +requires-dist = [ + { name = "adafruit-blinka" }, + { name = "adafruit-circuitpython-bno055" }, + { name = "aiofiles" }, + { name = "av" }, + { name = "cedar-solve", git = "https://github.com/smroid/cedar-solve?rev=d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" }, + { name = "dataclasses-json" }, + { name = "dbus-python", marker = "platform_machine == 'aarch64'" }, + { name = "flask" }, + { name = "flask-babel" }, + { name = "gpsdclient" }, + { name = "grpcio" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "libarchive-c" }, + { name = "luma-lcd" }, + { name = "luma-oled" }, + { name = "numpy" }, + { name = "numpy-quaternion" }, + { name = "pandas" }, + { name = "picamera2", marker = "platform_machine == 'aarch64'" }, + { name = "pidng" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pydeepskylog" }, + { name = "pyerfa" }, + { name = "pygobject", marker = "platform_machine == 'aarch64'" }, + { name = "pyjwt" }, + { name = "python-libinput", marker = "platform_machine == 'aarch64'", specifier = "==0.3.0a0" }, + { name = "python-pam" }, + { name = "python-prctl", marker = "platform_machine == 'aarch64'" }, + { name = "pytz" }, + { name = "requests" }, + { name = "rpi-gpio", marker = "platform_machine == 'aarch64'" }, + { name = "rpi-hardware-pwm", marker = "platform_machine == 'aarch64'" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sh", specifier = ">=1.14,<2" }, + { name = "simplejpeg" }, + { name = "skyfield" }, + { name = "smbus2" }, + { name = "spidev", marker = "platform_machine == 'aarch64'" }, + { name = "timezonefinder" }, + { name = "tqdm" }, + { name = "videodev2" }, + { name = "waitress" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "luma-emulator" }, + { name = "mypy" }, + { name = "pandas-stubs" }, + { name = "pyhotkey", marker = "platform_machine == 'aarch64'" }, + { name = "pynput", marker = "platform_machine == 'aarch64'" }, + { name = "pytest" }, + { name = "selenium" }, + { name = "types-aiofiles" }, + { name = "types-pynput" }, + { name = "types-pytz" }, + { name = "types-requests" }, + { name = "types-tqdm" }, + { name = "types-waitress" }, + { name = "xlrd", specifier = ">=2.0.1" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/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" }, +] +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" }, +] +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" }, + { name = "pyusb" }, +] +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" }, +] +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" }, +] +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 = "'linux' in sys_platform" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +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" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +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" }, +] +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" }, +] +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" }, +] +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" }, + { name = "rpds-py" }, +] +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" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +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" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +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" }, +] +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" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +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" }, +] +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" }, + { name = "jplephem" }, + { name = "numpy" }, + { name = "sgp4" }, +] +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" }, + { name = "flatbuffers" }, + { name = "h3" }, + { name = "numpy" }, +] +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" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +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" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + +[[package]] +name = "types-aiofiles" +version = "25.1.0.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/42/f5b9b90162d2196f016b87228d6bf43f2c2c0c6501bfd5415001b3eb68bb/types_aiofiles-25.1.0.20260518.tar.gz", hash = "sha256:c0c95eb78755d4fa7b397d4f0332c632714dd7cd0d17f49b96e31d4d7a8d8c76", size = 14891, upload-time = "2026-05-18T06:05:27.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/3d/7a9ed9faafeae3aa3b5bc22fa5b979ff9cf3c83ecbe919b58eae07795b8c/types_aiofiles-25.1.0.20260518-py3-none-any.whl", hash = "sha256:f776bdfb4bec17f743d9ef042e61edf03bdcc7821fc08556fba9b63d873fdea9", size = 14377, upload-time = "2026-05-18T06:05:26.871Z" }, +] + +[[package]] +name = "types-pynput" +version = "1.8.1.20260603" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/fb/71a9c1ad440a7f1c6c2bd3a8ffb889f6983a4a5b1f782804c3015b60fac6/types_pynput-1.8.1.20260603.tar.gz", hash = "sha256:c22690e389ce6ae5ca19ae496176d5ebac507aa742bfcdf34cb530c8c1ba9450", size = 12215, upload-time = "2026-06-03T06:42:02.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/50/5a7b71dd98fdecc0133133c72581acf637faaafc1e244ae6d39a8d7592fe/types_pynput-1.8.1.20260603-py3-none-any.whl", hash = "sha256:59a73c1c9d51f74a20aa57d26278e4db1e8501c609177182beca378eab016802", size = 12302, upload-time = "2026-06-03T06:42:01.545Z" }, +] + +[[package]] +name = "types-pytz" +version = "2026.2.0.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/d9/9fa4019d2235bd374293e1fd4153879b28b6ae1d2bae98addd352c9713f2/types_pytz-2026.2.0.20260518.tar.gz", hash = "sha256:e5d254329e9c4e91f0781b22c43a4bb2d10bb044d97b24c4b05d45567b0eae16", size = 10871, upload-time = "2026-05-18T06:02:45.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/89/41e80670779a223d8bc8bc83019a619988cfa5c432cedac5cec23884fbc4/types_pytz-2026.2.0.20260518-py3-none-any.whl", hash = "sha256:3a12eaa38f476bd650902a9c9bb442f03f3c7dee2be5c5848bce61bd708d205a", size = 10125, upload-time = "2026-05-18T06:02:44.968Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, +] + +[[package]] +name = "types-tqdm" +version = "4.68.0.20260608" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/e0/3facccb1ff69970c73fca7a8028286c233d4c1312c475a65fb3d896f56d9/types_tqdm-4.68.0.20260608.tar.gz", hash = "sha256:e1dfddf8770fbc30ecaf95ae57c286397831235064308f7dfc2b1d6684a76107", size = 18470, upload-time = "2026-06-08T06:26:06.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/e8/61d95bfd49d1609fb8e8c5e06f4a094183411988a6f448873f5de6602499/types_tqdm-4.68.0.20260608-py3-none-any.whl", hash = "sha256:450a6e7e9e9b604928968927c414b32970e40091213c4180e1ed470905b13eff", size = 24858, upload-time = "2026-06-08T06:26:05.741Z" }, +] + +[[package]] +name = "types-waitress" +version = "3.0.1.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/38/45d06b9fa7d2834f8e06f4704352e18763d3303ab8921d39c888007f33c1/types_waitress-3.0.1.20260508.tar.gz", hash = "sha256:3f9389096f1504a459064fbb02be53ad4326200a78dedae532967d3c13eb8d89", size = 14468, upload-time = "2026-05-08T04:49:31.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/44/d2e166e59677b4c99e4c49a33a7f23210f012e5188bd176bdcc40dc35395/types_waitress-3.0.1.20260508-py3-none-any.whl", hash = "sha256:b34c6dbb5da568eea4a883f9492574233c8fb0c793b91aa2fe0b847ab871252a", size = 17492, upload-time = "2026-05-08T04:49:29.556Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +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" }, +] + +[[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" }, +] +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" }, +] +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