diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..c98b77264 --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +use nix +watch_file flake.nix flake.lock shell.nix +watch_file nixos/pkgs/uv-python.nix +watch_file python/pyproject.toml python/uv.lock diff --git a/.github/scripts/publish_manifest.sh b/.github/scripts/publish_manifest.sh old mode 100644 new mode 100755 diff --git a/.github/scripts/update_manifest.py b/.github/scripts/update_manifest.py index 587db850d..0ad72e5ba 100644 --- a/.github/scripts/update_manifest.py +++ b/.github/scripts/update_manifest.py @@ -4,11 +4,19 @@ import argparse import json import re +import urllib.error +import urllib.request from datetime import datetime, timezone from pathlib import Path STORE_PATH_RE = re.compile(r"^/nix/store/[a-z0-9]+-[A-Za-z0-9._+=?,-]+$") + +# The binary caches devices resolve upgrades from. A build is only advertised +# as available once one of these actually serves its narinfo — see set_available. +DEV_CACHE = "https://cache.pifinder.eu/pifinder" +RELEASE_CACHE = "https://cache.pifinder.eu/pifinder-release" +DEFAULT_CACHES = (DEV_CACHE, RELEASE_CACHE) EMPTY_MANIFEST = { "schema": 1, "generated_at": None, @@ -49,14 +57,54 @@ def valid_store_path(value: str | None) -> bool: return isinstance(value, str) and STORE_PATH_RE.fullmatch(value) is not None -def set_available(entry: dict) -> dict: - if valid_store_path(entry.get("store_path")): - entry["available"] = True - entry.pop("reason", None) - else: +def _narinfo_url(store_path: str, cache: str) -> str: + digest = Path(store_path).name.split("-", 1)[0] + return f"{cache.rstrip('/')}/{digest}.narinfo" + + +def cache_serves(store_path: str, caches, timeout: int = 15) -> bool | None: + """Whether the closure is actually downloadable from a cache right now. + + Returns True if any cache serves the narinfo (200); False only if every + cache answered and none had it (a real 404 everywhere); None if a cache + could not be reached, so presence is unknown. Publishing treats anything + other than True as "do not advertise", so a failed push or a down cache can + never advertise a build the device cannot fetch. + """ + saw_404 = False + for cache in caches: + url = _narinfo_url(store_path, cache) + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + if resp.status == 200: + return True + except urllib.error.HTTPError as exc: + if exc.code == 404: + saw_404 = True + else: + return None + except (urllib.error.URLError, OSError, TimeoutError): + return None + return False if saw_404 else None + + +def set_available(entry: dict, caches=DEFAULT_CACHES, verify: bool = True) -> dict: + store_path = entry.get("store_path") + if not valid_store_path(store_path): entry["store_path"] = None entry["available"] = False entry.setdefault("reason", "no build") + return entry + + if verify: + present = cache_serves(store_path, caches) + if present is not True: + entry["available"] = False + entry["reason"] = "not in cache" if present is False else "cache unreachable" + return entry + + entry["available"] = True + entry.pop("reason", None) return entry @@ -79,11 +127,17 @@ def key(item: dict) -> tuple[int, int, str]: return sorted(entries, key=key) +def resolve_verify(args: argparse.Namespace) -> tuple[tuple[str, ...], bool]: + caches = tuple(args.verify_cache) if args.verify_cache else DEFAULT_CACHES + return caches, not args.skip_cache_check + + def update_build(args: argparse.Namespace) -> None: manifest = load_manifest(args.manifest) channels = manifest["channels"] store_path = args.store_path or None short_sha = (args.head_sha or args.sha)[:7] + caches, verify = resolve_verify(args) if args.pr_number: number = int(args.pr_number) @@ -100,7 +154,7 @@ def update_build(args: argparse.Namespace) -> None: "store_path": store_path, "built_at": now_iso(), } - set_available(entry) + set_available(entry, caches, verify) channels["unstable"] = replace_entry( channels["unstable"], lambda item: item.get("kind") == "pr" @@ -120,7 +174,7 @@ def update_build(args: argparse.Namespace) -> None: "store_path": store_path, "built_at": now_iso(), } - set_available(entry) + set_available(entry, caches, verify) channels["unstable"] = replace_entry( channels["unstable"], lambda item: item.get("kind") == "trunk" @@ -135,6 +189,7 @@ def update_build(args: argparse.Namespace) -> None: def update_release(args: argparse.Namespace) -> None: manifest = load_manifest(args.manifest) + caches, verify = resolve_verify(args) channel = "beta" if args.release_type == "beta" else "stable" entry = { "kind": "release", @@ -150,7 +205,7 @@ def update_release(args: argparse.Namespace) -> None: "migration_sha256_url": args.migration_sha256_url or None, "built_at": now_iso(), } - set_available(entry) + set_available(entry, caches, verify) manifest["channels"][channel] = replace_entry( manifest["channels"][channel], lambda item: item.get("kind") == "release" and item.get("label") == args.tag, @@ -159,6 +214,17 @@ def update_release(args: argparse.Namespace) -> None: save_manifest(args.manifest, manifest) +def add_cache_args(sub: argparse.ArgumentParser) -> None: + # A build is advertised as available only if one of these caches actually + # serves its narinfo. Repeatable; defaults to the dev + release caches. + sub.add_argument("--verify-cache", action="append", metavar="URL") + sub.add_argument( + "--skip-cache-check", + action="store_true", + help="advertise on store-path syntax alone (tests / offline only)", + ) + + def parser() -> argparse.ArgumentParser: root = argparse.ArgumentParser() sub = root.add_subparsers(dest="command", required=True) @@ -176,6 +242,7 @@ def parser() -> argparse.ArgumentParser: build.add_argument("--head-repo") build.add_argument("--head-ref") build.add_argument("--head-sha") + add_cache_args(build) build.set_defaults(func=update_build) release = sub.add_parser("release") @@ -190,6 +257,7 @@ def parser() -> argparse.ArgumentParser: release.add_argument("--migration-sha256-url") release.add_argument("--title") release.add_argument("--notes") + add_cache_args(release) release.set_defaults(func=update_release) return root diff --git a/.github/workflows/nox.yml b/.github/workflows/nox.yml index ab38d5621..0eb274e5b 100644 --- a/.github/workflows/nox.yml +++ b/.github/workflows/nox.yml @@ -1,4 +1,9 @@ name: nox +# The nixos branch manages the Python environment with uv (pyproject.toml + +# uv.lock) instead of nox + requirements*.txt, so this runs the same checks +# (ruff, mypy, pytest) through uv. The workflow keeps upstream's "nox" name so +# PR check wiring stays identical across branches. +# # Run on pushes to the long-lived branches and on pull requests. Restricting # `push` to main/release means a push to a feature branch with an open PR only # triggers the `pull_request` run (not also a `push` run), so a PR gets a single @@ -18,9 +23,21 @@ jobs: working-directory: ./python steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 with: - submodules: true - - uses: wntrblm/nox@2024.04.15 - with: - python-versions: "3.9" - - run: nox -s lint format type_hints smoke_tests unit_tests ui_tests + enable-cache: true + # Same ruff pin as upstream main's noxfile, so shared files keep one style. + - name: Lint + run: uvx ruff@0.4.8 check --config "builtins=['_']" . + - name: Format check + run: uvx ruff@0.4.8 format --check . + - name: Sync environment + run: uv sync --frozen + - name: Type check + run: uv run mypy PiFinder + - name: Smoke tests + run: uv run pytest -m smoke + - name: Unit tests + run: uv run pytest -m unit + - name: UI module tests + run: uv run pytest -m integration tests/test_ui_modules.py diff --git a/.gitignore b/.gitignore index b30c9a31f..502146381 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,8 @@ case/my_printer .direnv/ **/.claude/* !**/.claude/skills/ +.agents/ +.codex/ .serena/ astro_data/comets.txt @@ -162,3 +164,7 @@ astro_data/comets.txt test_ubx test_ubx/* python/telemetry_analysis/ + +# nix build result symlinks +result +result-* diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index bbd1b94ab..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "python/PiFinder/tetra3"] - path = python/PiFinder/tetra3 - url = https://github.com/smroid/cedar-solve diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a538aa17..317fee59c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,27 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks repos: - - repo: https://github.com/saltstack/mirrors-nox - rev: 'v2022.11.21' # Use the sha / tag you want to point at + - repo: local hooks: - - id: nox - files: ^.*\.py$ - args: - - -f - - python/noxfile.py - - -s - - type_hints - - smoke_tests - - -- + - id: ruff-lint + name: ruff lint + entry: bash -c 'cd python && ruff check' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: ruff-format + name: ruff format check + entry: bash -c 'cd python && ruff format --check' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: mypy + name: mypy type check + entry: bash -c 'cd python && mypy .' + language: system + files: ^python/.*\.py$ + pass_filenames: false + - id: smoke-tests + name: smoke tests + entry: bash -c 'cd python && pytest -m smoke' + language: system + files: ^python/.*\.py$ + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index b0b0d15d2..a28cfe0a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,65 +22,46 @@ Maintainers can make `fresh` root on `main` automatically per clone (without cha git remote set-head origin main ``` -**Initialise the `tetra3` submodule in every new worktree.** `python/PiFinder/tetra3` is a git submodule (the `cedar-solve`/Tetra3 solver); the importable package is its inner `tetra3/tetra3/` dir, surfaced through the tracked symlink `python/tetra3`. `git worktree add` / `EnterWorktree` does **not** populate submodules, so a fresh worktree starts with an empty submodule dir and a dangling symlink. Any test that imports the solver then fails with `ModuleNotFoundError: No module named 'tetra3'` (or `cedar_detect_pb2`) — this is a missing checkout, **not** a code problem, so don't reach for `PYTHONPATH` hacks. Fix it once per worktree: - -```bash -git submodule update --init python/PiFinder/tetra3 -``` - -mypy also needs this: its config points at `python/PiFinder/tetra3/tetra3`, so `nox -s type_hints` can't run in a worktree until the submodule is initialised. +**No tetra3 submodule on this branch.** Unlike upstream `main`, the solver +(`cedar-solve`/Tetra3) is an ordinary uv dependency (pinned git rev in +`python/pyproject.toml`), so worktrees need no `git submodule` step. ## Development Commands -**Running Python** -Developers may have created virtual environments in directories like ".venv" or "venv". Make sure these virtual -environments are activated before any of the python based tools below. - -**Development workflow uses Nox for task automation:** -```bash -nox -s lint # Code linting with Ruff (auto-fixes issues) -nox -s format # Code formatting with Ruff -nox -s type_hints # Type checking with MyPy -nox -s smoke_tests # Quick functionality validation -nox -s unit_tests # Full unit test suite -nox -s babel # I18n message extraction and compilation -nox -s web_tests # Testing the webserver, see below -``` - -**Direct testing with pytest:** -```bash -pytest -m smoke # Smoke tests for core functionality -pytest -m unit # Unit tests for isolated components -pytest -m integration # End-to-end integration tests -``` +**This branch uses uv (pyproject.toml + uv.lock), not nox/requirements*.txt.** +All Python tooling runs through uv from `python/`: -**Development setup:** ```bash cd python/ -python3.9 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -pip install -r requirements_dev.txt +uv sync --frozen # create/refresh .venv from uv.lock +uvx ruff@0.4.8 check --config "builtins=['_']" . # lint (same pin as CI / upstream noxfile) +uvx ruff@0.4.8 format . # format +uv run mypy PiFinder # type checking +uv run pytest -m smoke # smoke tests +uv run pytest -m unit # unit tests +uv run pytest -m integration tests/test_ui_modules.py # UI module harness ``` -If the .venv dir already exists, you can directly source it and run the app. +CI (`.github/workflows/nox.yml` — keeps the upstream "nox" check name) runs +exactly these commands. -Watch out for .venv directories containing virtual environments, that you need to activate first. +NixOS dev-box note: manylinux wheels (numpy etc.) need `libstdc++`; if imports +fail with `libstdc++.so.6: cannot open shared object file`, run tests with +`LD_LIBRARY_PATH=$(nix build --print-out-paths nixpkgs#stdenv.cc.cc.lib)/lib`. **Running the application:** First start the `cedar-detect-server` which is in `bin` (you need to use `-p 50551`, when invoking it). -Use the correct architecture suffix for cedar-detect-server according to the platform you're running on. +Use the correct architecture suffix for cedar-detect-server according to the platform you're running on. -Development setup has to have run and you should be in .venv virtual environment ```bash cd python/ -python -m PiFinder.main [options] +uv run python -m PiFinder.main [options] ``` Usual startup: ```bash -python3.9 -m PiFinder.main -fh --camera debug --keyboard local -x +uv run python -m PiFinder.main -fh --camera debug --keyboard local -x ``` ## Reference Documentation @@ -191,6 +172,39 @@ Tests use pytest with custom markers for different test types. The smoke tests p - **Linting:** Ruff with Python 3.9 target, Black-compatible formatting - **Type Checking:** MyPy with gradual typing adoption - **Code Style:** 88-character line length, double quotes, space indentation +- **Comments:** describe what the code does now, not how it changed. No "previously / no longer / used to / moved from" — history lives in git/jj, not in comments. - **I18n Support:** Babel integration for multi-language UI The codebase follows modern Python practices with type hints, comprehensive testing, and automated code quality checks integrated into the development workflow. + +## NixOS Development + +**CRITICAL: Never run `nix build` or `nix eval` on Pi 4 targets.** The Pi 4 lacks sufficient resources and will hang/crash. Always build on pi5.local (GitHub Actions runner), push to Attic, then trigger the upgrade service: +```bash +# Build on pi5 +ssh pi5.local 'nix build --no-link --print-out-paths github:mrosseel/PiFinder/nixos#nixosConfigurations.pifinder.config.system.build.toplevel' +# Push to Attic (so Pi can download signed paths) +ssh pi5.local 'attic push pifinder:pifinder ' +# Trigger upgrade on target Pi (downloads from Attic, activates, reboots) +ssh pifinder@ 'echo "" > /run/pifinder/upgrade-ref && sudo systemctl start --no-block pifinder-upgrade.service' +# Monitor progress +ssh pifinder@ 'cat /run/pifinder/upgrade-status' +``` + +**Netboot deployment (dev Pi on proxnix NFS):** +```bash +./deploy-image-to-nfs.sh # Build and deploy to NFS +``` + +**Power control (Shelly plug via Home Assistant):** +```bash +~/.local/bin/pifinder-power-off.sh # Turn off PiFinder +~/.local/bin/pifinder-power-on.sh # Turn on PiFinder +``` + +**Check Pi status:** +```bash +ssh pifinder@192.168.5.146 # SSH to netboot Pi +systemctl status pifinder # Check service status +journalctl -u pifinder -f # Follow service logs +``` diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 28b283de0..2560803d2 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -41,7 +41,6 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - **Bring-up ↛ `hardware_detect`**: unlike `main.py` and `splash.py`, bring-up does **not** derive its panel from the BQ25895 probe. Doing so would make a dead charger indistinguishable from a dead screen on exactly the boards it exists to diagnose. 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..ef4a2b7d6 100644 --- a/default_config.json +++ b/default_config.json @@ -19,6 +19,12 @@ "image_nsew": true, "image_bbox": true, "chart_coord_sys": "horiz", + "obj_chart_crosshair": "pulse", + "obj_chart_crosshair_style": "simple", + "obj_chart_mark_source": "standard", + "obj_chart_crosshair_speed": "2.0", + "obj_chart_lm_mode": "auto", + "obj_chart_lm_fixed": 14.0, "target_pixel": [256, 256], "gps_type": "ublox", "gps_baud_rate": 9600, @@ -29,6 +35,7 @@ "Str", "PL", "CM", + "MP", "RDS" ], "filter.object_types": [ @@ -46,7 +53,8 @@ "*", "?", "Pla", - "CM" + "CM", + "AS" ], "filter.constellations": [ "And", @@ -180,7 +188,9 @@ "active_eyepiece_index": 0 }, "imu_threshold_scale": 1, + "dev_mode": false, "telemetry_record": false, - "telemetry_images": false, - "telemetry_raw_imu": false + "telemetry_raw_imu": false, + "telemetry_sections": ["imu", "sqm", "solve", "target"], + "telemetry_max_session_mb": 1024 } diff --git a/deploy-image-to-nfs.sh b/deploy-image-to-nfs.sh new file mode 100755 index 000000000..1aec63990 --- /dev/null +++ b/deploy-image-to-nfs.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy PiFinder NixOS netboot configuration to proxnix +# +# Builds the pifinder-netboot closure (NFS root baked in), copies the nix store +# closure to NFS, and sets up TFTP with kernel/initrd/firmware for PXE boot. +# +# Boot sequence: Pi firmware → u-boot → extlinux/extlinux.conf (TFTP) → NFS root + +PROXNIX="mike@192.168.5.12" +NFS_ROOT="/srv/nfs/pifinder" +TFTP_ROOT="/srv/tftp" +PI_IP="192.168.5.150" +PI_MAC="e4-5f-01-b7-37-31" # For PXE boot speedup + +# SSH options to prevent timeout during long transfers +SSH_OPTS="-o ServerAliveInterval=30 -o ServerAliveCountMax=10" +export RSYNC_RSH="ssh ${SSH_OPTS}" + +SSH_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrPg9hSgxwg0EECxXSpYi7t3F/w/BgpymlD1uUDedRz mike@nixtop" + +# Password hash for "solveit" +SHADOW_HASH='$6$upbQ1/Jfh7zDiIYW$jPVQdYJCZn/Pe/OIGx89DZm9trIhEJp7Q4LNZsq/5x9csj6U08.P2avebrQIDJCEyD0xipsV6C19Sr5iAbCuv1' + +# ── Helpers ────────────────────────────────────────────────────────────────── + +run_proxnix() { + ssh ${SSH_OPTS} "${PROXNIX}" "bash -euo pipefail -c \"$1\"" +} + +# ── Build netboot closure ──────────────────────────────────────────────────── + +echo "=== Building pifinder-netboot closure ===" +nix build .#nixosConfigurations.pifinder-netboot.config.system.build.toplevel \ + -o result-netboot --system aarch64-linux + +CLOSURE=$(readlink -f result-netboot) +echo "Closure: $CLOSURE" + +# Extract paths from closure +KERNEL=$(readlink -f result-netboot/kernel) +INITRD=$(readlink -f result-netboot/initrd) +DTBS=$(readlink -f result-netboot/dtbs) +INIT_PATH="${CLOSURE}/init" + +KERNEL_NAME=$(basename "$(dirname "$KERNEL")")-Image +INITRD_NAME=$(basename "$(dirname "$INITRD")")-initrd + +echo "Kernel: $KERNEL" +echo "Initrd: $INITRD" +echo "DTBs: $DTBS" +echo "Init: $INIT_PATH" + +# ── Stop TFTP — prevent Pi from netbooting during deploy ───────────────────── + +echo "Stopping TFTP server..." +ssh "${PROXNIX}" "sudo systemctl stop atftpd.service" + +# ── Halt Pi if running — prevent NFS corruption ────────────────────────────── + +if ssh -o ConnectTimeout=3 -o BatchMode=yes "pifinder@${PI_IP}" "echo ok" 2>/dev/null; then + echo "Pi is running — halting..." + ssh "pifinder@${PI_IP}" "echo solveit | sudo -S poweroff" 2>/dev/null || true + echo "Waiting for Pi to go down..." + sleep 3 + while ping -c1 -W1 "${PI_IP}" &>/dev/null; do sleep 1; done + echo "Pi is down" +else + echo "Pi not reachable, proceeding" +fi + +# ── Backup SSH host keys ───────────────────────────────────────────────────── + +echo "Backing up SSH host keys..." +ssh "${PROXNIX}" "sudo cp -a ${NFS_ROOT}/etc/ssh/ssh_host_* /tmp/ 2>/dev/null || true" + +# ── Copy nix store closure to NFS ──────────────────────────────────────────── + +echo "Copying nix store closure to NFS..." +ssh "${PROXNIX}" "sudo mkdir -p ${NFS_ROOT}/nix/store" + +# Get list of store paths and stream via tar (fast, handles duplicates via overwrite) +STORE_PATHS=$(nix path-info -r "$CLOSURE") +TOTAL_PATHS=$(echo "$STORE_PATHS" | wc -l) +echo "Streaming ${TOTAL_PATHS} store paths via tar..." + +# Rsync store paths with -R to preserve directory structure +# shellcheck disable=SC2086 +rsync -avR --rsync-path="sudo rsync" $STORE_PATHS "${PROXNIX}:${NFS_ROOT}/" +echo "Transfer complete" + +# ── Set up NFS root directory structure ────────────────────────────────────── + +echo "Setting up NFS root directory structure..." +ssh "${PROXNIX}" "sudo bash -euo pipefail" << SETUP +# Create standard directories (bin/usr are symlinks, not dirs) +mkdir -p ${NFS_ROOT}/{etc/ssh,home/pifinder/.ssh,root/.ssh,var,tmp,proc,sys,dev,run,boot} +chmod 1777 ${NFS_ROOT}/tmp + +# Symlinks from NixOS system (remove existing dirs/symlinks first) +rm -rf ${NFS_ROOT}/bin ${NFS_ROOT}/usr +ln -sfT ${CLOSURE}/sw/bin ${NFS_ROOT}/bin +ln -sfT ${CLOSURE}/sw ${NFS_ROOT}/usr + +# /etc/static points to the NixOS etc derivation (required for PAM, etc.) +ln -sfT ${CLOSURE}/etc ${NFS_ROOT}/etc/static + +# Critical /etc symlinks that NixOS activation would normally create +rm -rf ${NFS_ROOT}/etc/pam.d 2>/dev/null || true +ln -sfT /etc/static/pam.d ${NFS_ROOT}/etc/pam.d +ln -sfT /etc/static/bashrc ${NFS_ROOT}/etc/bashrc +# passwd/shadow/group are created as real files later (need to be writable for netboot) +rm -f ${NFS_ROOT}/etc/passwd ${NFS_ROOT}/etc/shadow ${NFS_ROOT}/etc/group 2>/dev/null || true +ln -sfT /etc/static/sudoers ${NFS_ROOT}/etc/sudoers 2>/dev/null || true +ln -sfT /etc/static/sudoers.d ${NFS_ROOT}/etc/sudoers.d 2>/dev/null || true +ln -sfT /etc/static/nsswitch.conf ${NFS_ROOT}/etc/nsswitch.conf 2>/dev/null || true +ln -sfT /etc/static/systemd ${NFS_ROOT}/etc/systemd 2>/dev/null || true +ln -sfT /etc/static/polkit-1 ${NFS_ROOT}/etc/polkit-1 2>/dev/null || true + +# Create nix profile symlinks +mkdir -p ${NFS_ROOT}/nix/var/nix/profiles +ln -sfT ${CLOSURE} ${NFS_ROOT}/nix/var/nix/profiles/system +ln -sfT ${CLOSURE} ${NFS_ROOT}/run/current-system 2>/dev/null || true +SETUP + +# ── Restore SSH host keys ──────────────────────────────────────────────────── + +echo "Restoring/generating SSH host keys..." +ssh "${PROXNIX}" "bash -euo pipefail -c ' +if ls /tmp/ssh_host_* >/dev/null 2>&1; then + sudo cp -a /tmp/ssh_host_* ${NFS_ROOT}/etc/ssh/ + echo \"Restored existing host keys\" +else + sudo ssh-keygen -A -f ${NFS_ROOT} + echo \"Generated new host keys\" +fi +'" + +# ── Link NixOS /etc files ──────────────────────────────────────────────────── + +echo "Linking NixOS etc files..." +ssh "${PROXNIX}" "sudo bash -euo pipefail -c ' +ln -sf /etc/static/ssh/sshd_config ${NFS_ROOT}/etc/ssh/sshd_config +ln -sf /etc/static/ssh/ssh_config ${NFS_ROOT}/etc/ssh/ssh_config 2>/dev/null || true +ln -sf /etc/static/ssh/moduli ${NFS_ROOT}/etc/ssh/moduli 2>/dev/null || true +# pam.d already symlinked to /etc/static/pam.d in SETUP block +'" + +# ── Static user files ──────────────────────────────────────────────────────── + +echo "Creating static user files..." + +ssh "${PROXNIX}" "sudo tee ${NFS_ROOT}/etc/passwd > /dev/null" << 'PASSWD' +root:x:0:0:System administrator:/root:/run/current-system/sw/bin/bash +pifinder:x:1000:100::/home/pifinder:/run/current-system/sw/bin/bash +nobody:x:65534:65534:Unprivileged account:/var/empty:/run/current-system/sw/bin/nologin +sshd:x:993:993:SSH daemon user:/var/empty:/run/current-system/sw/bin/nologin +avahi:x:994:994:Avahi daemon user:/var/empty:/run/current-system/sw/bin/nologin +gpsd:x:992:992:GPSD daemon user:/var/empty:/run/current-system/sw/bin/nologin +PASSWD + +ssh "${PROXNIX}" "sudo tee ${NFS_ROOT}/etc/group > /dev/null" << 'GROUP' +root:x:0: +wheel:x:1:pifinder +users:x:100:pifinder +kmem:x:9:pifinder +input:x:174:pifinder +nobody:x:65534: +spi:x:996:pifinder +i2c:x:997:pifinder +gpio:x:998:pifinder +dialout:x:995:pifinder +video:x:994:pifinder +networkmanager:x:993:pifinder +sshd:x:993: +avahi:x:994: +gpsd:x:992: +GROUP + +ssh "${PROXNIX}" "echo 'root:${SHADOW_HASH}:1:::::: +pifinder:${SHADOW_HASH}:1:::::: +nobody:!:1:::::: +sshd:!:1:::::: +avahi:!:1:::::: +gpsd:!:1::::::' | sudo tee ${NFS_ROOT}/etc/shadow > /dev/null" + +run_proxnix "sudo chmod 644 ${NFS_ROOT}/etc/passwd ${NFS_ROOT}/etc/group" +run_proxnix "sudo chmod 640 ${NFS_ROOT}/etc/shadow" + +# ── SSH authorized_keys ────────────────────────────────────────────────────── + +echo "Setting up SSH authorized_keys..." +ssh "${PROXNIX}" "echo '${SSH_PUBKEY}' | sudo tee ${NFS_ROOT}/home/pifinder/.ssh/authorized_keys > /dev/null" +ssh "${PROXNIX}" "echo '${SSH_PUBKEY}' | sudo tee ${NFS_ROOT}/root/.ssh/authorized_keys > /dev/null" +run_proxnix "sudo chown -R 1000:100 ${NFS_ROOT}/home/pifinder" +run_proxnix "sudo chmod 700 ${NFS_ROOT}/home/pifinder/.ssh ${NFS_ROOT}/root/.ssh" +run_proxnix "sudo chmod 600 ${NFS_ROOT}/home/pifinder/.ssh/authorized_keys ${NFS_ROOT}/root/.ssh/authorized_keys" + +# ── PiFinder symlink ───────────────────────────────────────────────────────── + +echo "Setting up PiFinder directory..." +# Find pifinder-src from the current closure (not just any old one in the store) +PFSRC_REL=$(nix path-info -r "$CLOSURE" | grep pifinder-src | head -1) +echo "PiFinder source from closure: $PFSRC_REL" +ssh "${PROXNIX}" "sudo bash -euo pipefail -c ' +PFSRC=\"${NFS_ROOT}${PFSRC_REL}\" +if [ ! -d \"\$PFSRC\" ]; then + echo \"ERROR: pifinder-src not found: \$PFSRC\" + exit 1 +fi +PFHOME=${NFS_ROOT}/home/pifinder/PiFinder + +echo \"PiFinder source: ${PFSRC_REL}\" + +[ -L \"\$PFHOME\" ] && rm \"\$PFHOME\" +[ -d \"\$PFHOME\" ] && rm -rf \"\$PFHOME\" + +ln -sfT \"${PFSRC_REL}\" \"\$PFHOME\" + +mkdir -p ${NFS_ROOT}/home/pifinder/PiFinder_data +chown 1000:100 ${NFS_ROOT}/home/pifinder/PiFinder_data +'" + +# ── Copy firmware to TFTP (from raspberrypi firmware package) ──────────────── + +echo "Copying firmware to TFTP..." +FW_PKG=$(nix build nixpkgs#raspberrypifw --print-out-paths --system aarch64-linux 2>/dev/null) +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}" + +# Copy firmware files +rsync -avz "${FW_PKG}/share/raspberrypi/boot/"*.{elf,dat,bin,dtb} "${PROXNIX}:/tmp/fw/" +ssh "${PROXNIX}" "sudo cp /tmp/fw/* ${TFTP_ROOT}/ && rm -rf /tmp/fw" + +# Copy custom u-boot with network boot priority +UBOOT=$(nix build .#packages.aarch64-linux.uboot-netboot --print-out-paths --system aarch64-linux 2>/dev/null) +echo "Using custom u-boot: $UBOOT" +rsync -avz "${UBOOT}/u-boot.bin" "${PROXNIX}:/tmp/u-boot-rpi4.bin" +ssh "${PROXNIX}" "sudo mv /tmp/u-boot-rpi4.bin ${TFTP_ROOT}/" + +# ── Copy kernel, initrd, DTBs to TFTP ──────────────────────────────────────── + +echo "Copying kernel/initrd/DTBs to TFTP..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/nixos" +rsync -avz "${KERNEL}" "${PROXNIX}:/tmp/${KERNEL_NAME}" +rsync -avz "${INITRD}" "${PROXNIX}:/tmp/${INITRD_NAME}" +ssh "${PROXNIX}" "sudo mv /tmp/${KERNEL_NAME} /tmp/${INITRD_NAME} ${TFTP_ROOT}/nixos/" + +# Copy NixOS-built DTBs (with camera overlay baked in) to dtbs/ subdirectory +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/dtbs" +rsync -avz "${DTBS}/broadcom/" "${PROXNIX}:/tmp/dtbs/" +ssh "${PROXNIX}" "sudo cp /tmp/dtbs/*.dtb ${TFTP_ROOT}/dtbs/ && sudo rm -rf /tmp/dtbs" + +# Copy overlays from kernel package +KERNEL_DIR=$(dirname "$KERNEL") +rsync -avz "${KERNEL_DIR}/dtbs/overlays/" "${PROXNIX}:/tmp/overlays/" +ssh "${PROXNIX}" "sudo rm -rf ${TFTP_ROOT}/overlays && sudo mv /tmp/overlays ${TFTP_ROOT}/" + +# ── Write config.txt for u-boot ────────────────────────────────────────────── + +echo "Writing config.txt..." +ssh "${PROXNIX}" "sudo tee ${TFTP_ROOT}/config.txt > /dev/null" << CONFIG +[pi4] +kernel=u-boot-rpi4.bin +enable_gic=1 +armstub=armstub8-gic.bin + +disable_overscan=1 +arm_boost=1 + +[all] +arm_64bit=1 +enable_uart=1 +avoid_warnings=1 +CONFIG + +# ── Generate extlinux/extlinux.conf ──────────────────────────────────────────── + +echo "Generating extlinux/extlinux.conf..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/extlinux && sudo tee ${TFTP_ROOT}/extlinux/extlinux.conf > /dev/null" << EXTLINUX +TIMEOUT 10 +DEFAULT nixos-default + +LABEL nixos-default + MENU LABEL NixOS - Default + LINUX /nixos/${KERNEL_NAME} + INITRD /nixos/${INITRD_NAME} + FDTDIR /dtbs + APPEND init=${INIT_PATH} ip=dhcp console=ttyS0,115200n8 console=ttyAMA0,115200n8 console=tty0 loglevel=4 +EXTLINUX + +# ── Create pxelinux.cfg for faster MAC-based boot ───────────────────────────── + +echo "Creating pxelinux.cfg/01-${PI_MAC}..." +ssh "${PROXNIX}" "sudo mkdir -p ${TFTP_ROOT}/pxelinux.cfg && sudo ln -sf ../extlinux/extlinux.conf ${TFTP_ROOT}/pxelinux.cfg/01-${PI_MAC}" + +# ── Clean up old artifacts ─────────────────────────────────────────────────── + +echo "Cleaning up old artifacts..." +ssh "${PROXNIX}" "sudo rm -f ${TFTP_ROOT}/cmdline.txt ${TFTP_ROOT}/nixos/patched-initrd 2>/dev/null || true" +ssh "${PROXNIX}" "sudo rm -f /tmp/ssh_host_*" + +# ── Restart TFTP ───────────────────────────────────────────────────────────── + +echo "Restarting TFTP server..." +ssh "${PROXNIX}" "sudo systemctl start atftpd.service" + +# ── Verification ───────────────────────────────────────────────────────────── + +echo "" +echo "==========================================" +echo "VERIFYING DEPLOYMENT CONSISTENCY" +echo "==========================================" +VERIFY_FAILED=0 + +echo -n "Checking u-boot... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/u-boot-rpi4.bin"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking config.txt... " +if ssh "${PROXNIX}" "grep -q 'kernel=u-boot-rpi4.bin' ${TFTP_ROOT}/config.txt"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking extlinux/extlinux.conf... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/extlinux/extlinux.conf"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking kernel... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/nixos/${KERNEL_NAME}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking initrd... " +if ssh "${PROXNIX}" "test -f ${TFTP_ROOT}/nixos/${INITRD_NAME}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking NFS closure... " +if ssh "${PROXNIX}" "test -f ${NFS_ROOT}${INIT_PATH}"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo -n "Checking PiFinder symlink... " +PFSRC_TARGET=$(ssh "${PROXNIX}" "readlink ${NFS_ROOT}/home/pifinder/PiFinder 2>/dev/null || true") +if [ -n "$PFSRC_TARGET" ] && ssh "${PROXNIX}" "test -d ${NFS_ROOT}${PFSRC_TARGET}/python"; then + echo "OK" +else + echo "FAILED" + VERIFY_FAILED=1 +fi + +echo "==========================================" + +if [ $VERIFY_FAILED -eq 1 ]; then + echo "=== DEPLOY FAILED VERIFICATION — DO NOT BOOT ===" + exit 1 +fi + +echo "=== Deploy complete and verified ===" +echo "" +echo "Boot chain: Pi firmware → u-boot → extlinux/extlinux.conf → NFS root" +echo "To boot the Pi: power cycle it" diff --git a/docs/adr/0021-mpc-bright-asteroid-catalog.md b/docs/adr/0021-mpc-bright-asteroid-catalog.md new file mode 100644 index 000000000..cbcddff10 --- /dev/null +++ b/docs/adr/0021-mpc-bright-asteroid-catalog.md @@ -0,0 +1,52 @@ +# MPC annual bright-minor-planet files for the asteroid catalog + +PiFinder needs an asteroid subset small enough for a Raspberry Pi and useful to +visual observers. The complete MPCORB catalog is orders of magnitude larger +than the set that can become visually observable in a given year. An absolute +magnitude (`H`) cut is also insufficient: it can omit intrinsically faint +near-Earth asteroids during a bright close apparition. + +The `MP` dynamic catalog uses the Minor Planet Center's annual +`Ephemerides/Bright//Soft00Bright.txt` file. It is already curated by +observing year, uses the standard MPCORB one-line format supported by the +pinned Skyfield release, contains numbered asteroids, and supplies the `H` and +`G` photometric parameters. + +## Decisions + +- Load the current year's file and opportunistically merge next year's once it + is published. A previous-year file remains a stale-data fallback around New + Year or during network failure. Duplicate asteroid numbers use the newest + packed element epoch. Because a Pi 4 has no RTC, source-year selection waits + for trustworthy GPS time; before that, the UI may identify an already stored + edition from its filename but never chooses a download year from wall time. +- The minor-planet number is the catalog sequence. Observation logs for virtual + objects are keyed by `(catalog, sequence)`, so `MP 4` must remain Vesta across + refreshes and restarts. +- Propagate the small element set locally in vectorized NumPy operations and + compute apparent magnitude with the IAU H-G phase law. Non-finite values and + objects fainter than the named magnitude-15 catalog safety limit are omitted; + the user's ordinary magnitude and altitude filters remain the observing + controls. +- For visible objects, search 550 days for the first upcoming + ecliptic-longitude opposition (or greatest elongation for an interior object) + and the peak magnitude associated with that apparition. Day zero is excluded + from “upcoming” so an event that just passed is not reported as the next one. +- Catalog source updates are transactional: bytes go to a temporary sibling, + are parsed and validated, and atomically replace the active file. The old + objects and their source edition remain visible during download or after failure. + A populated catalog shows a compact determinate or indeterminate progress bar. +- JPL SBDB is a documented replacement candidate if MPC retires the annual + file, not an automatic fallback. Supporting two unrelated runtime formats + would add failure modes without improving normal operation. +- Existing persisted Catalog and Type filters receive `MP` and `AS` through a + one-time config migration. Its marker ensures a later user choice to disable + asteroids remains authoritative. + +## Consequences + +Asteroid calculations stay bounded to a few hundred source rows, stable numbers +make jump-to and logging meaningful, and close NEO apparitions are retained +without downloading the full MPCORB database. Annual elements are two-body +propagated, so they do not claim Horizons-level precision; vectorized results +are regression-tested against Skyfield's per-object MPCORB orbit builder. diff --git a/docs/adr/0023-sqm-per-frame-optical-black.md b/docs/adr/0023-sqm-per-frame-optical-black.md new file mode 100644 index 000000000..752a61099 --- /dev/null +++ b/docs/adr/0023-sqm-per-frame-optical-black.md @@ -0,0 +1,60 @@ +# Per-frame optical black as the IMX290/IMX462 SQM pedestal + +## Decision + +On IMX290/IMX462 the SQM pedestal is the sensor's own shielded optical-black +(OB) rows, measured on every frame, rather than a static profile offset or a +nightly-fitted dark-current model. + +The IMX290/462 already transmits ten front vertical OB rows per frame. A kernel +patch adds a second sensor source pad (`MEDIA_BUS_FMT_SENSOR_DATA`) so those +rows reach the receiver as a separate metadata stream without changing the +1920x1080 image matrix. A libcamera cam-helper unpacks the shielded pixels, +trims the outer 5% each side, averages the central 90% and publishes the result +through `controls::SensorBlackLevels` on the 16-bit scale, tagging it +`{level, level, level, level + 1}`. The one-count sentinel in the fourth +channel distinguishes a measured value from libcamera's static tuning tuple. +`camera_pi` converts a marked value back to native ADU and attaches it to the +radiometer sample as `optical_black_pedestal`. + +A valid marked OB value is the complete per-frame pedestal: it already contains +the bias and the frame's accumulated dark signal. Pedestal precedence is +therefore: valid same-frame OB, then a user-calibrated pedestal, then the +profile `bias_offset`. No dark-current rate is applied on top of OB and no +nightly rate is fitted or persisted for these sensors. + +## Status + +Accepted for IMX462 (and IMX290, same driver). Supersedes the earlier +scheduled-pedestal-probe proposal, which is not implemented: same-frame OB makes +scheduled probe frames unnecessary on these sensors. + +## Rationale + +Sony documents OB as the reference zero for image signals, but documentation +alone did not prove OB dark accumulation matches active-pixel dark accumulation +on this stack. A same-frame cupboard test on mr2 (IMX462, production gain, +requested 30 / reported 29.512) settled it: comparing normal Bayer pixels and +shielded OB rows from identical frames, the active-green and OB +dark-accumulation slopes agreed to well under 1 ADU/s, with active green a fixed +~0.8-1.0 ADU below OB (~0.01 mag). That residual is too small to justify a +per-unit constant from one camera and is left unmodelled. + +A same-frame measurement strictly dominates a once-per-night model: it tracks +bias and accumulated dark signal live, with no user action. The rolling +radiometer median smooths gain-30 frame noise without storing a fitted rate. +Central-90% averaging (not a whole-code median) preserves sub-ADU resolution, +which matters because the exposure-dependent dark signal is small. + +## Consequences + +- IMX462 SQM is zero-touch: no lens cap, dark frames, or calibration wizard. +- Manual calibration remains the fallback for sensors without usable OB and for + frames where OB is missing or unmarked; it never overrides valid OB. +- The patched kernel is built via `nixos-hardware`; `pifinder-fast` / + `pifinder-kernel-cross` provide a fast x86_64 cross build to seed the binary + cache so CI substitutes the kernel rather than compiling it. +- Added camera-process cost is about +0.23 percentage points of one core. +- Open: IMX296 OB uses a different CSI line-type path; IMX477 OB is not yet + proved accessible in this stack. Independent-unit and temperature-range + validation of IMX462 remains useful but is not required for users. diff --git a/docs/adr/0029-nearby-ranking-correctness-and-cost.md b/docs/adr/0029-nearby-ranking-correctness-and-cost.md new file mode 100644 index 000000000..d9dedb15a --- /dev/null +++ b/docs/adr/0029-nearby-ranking-correctness-and-cost.md @@ -0,0 +1,177 @@ +# Nearby objects: a (Dec, RA) haversine index, queried as a bounded window, with the cursor bound to the pointing + +Two features ask the same question — *which catalog objects are near where the +scope points?* The object list's **Nearby** sort wants them ranked, nearest +first; the chart's **nearby-DSO marker layer** wants everything inside the +current field. Both are served by `ClosestObjectsFinder` in +`PiFinder/nearby.py`, and both run against the live pointing while the user +slews. + +That last part sets the constraints. The answer must be correct at *any* +declination, not just near the equator where most testing happens. It must be +cheap enough to compute inside the 30 Hz draw loop on a Pi. And it must decide +what the list does when the answer changes under the user's hands. + +## Index the sky as `[dec, ra]`, in radians, haversine + +The spatial index is a scikit-learn `BallTree` with `metric="haversine"`. That +metric is documented for geographic coordinates: **dimension 0 is latitude, +dimension 1 is longitude**. Declination is the latitude; right ascension is the +longitude. So rows are `[dec_rad, ra_rad]`, and every query point must be built +the same way. + +This is written down because the argument order is invisible at the call site — +it is two floats in a list, and the wrong order raises nothing. Worse, the +mistake hides: when two objects share a meridian the metric degenerates to +`|dec1 - dec2|`, which is right whichever way round the axes go. Any test or +spot-check that keeps RA fixed will pass on a swapped index. **Treat a +same-meridian check as no check at all** — exercise this code with objects at +different RAs and at high declination, where a swapped axis order is loudest. + +The alternative was to drop the index: compute a vectorised haversine over all +objects and `argsort`. Measured at 0.9 ms (14 000 objects) against the tree's +1.5 ms — no meaningful gain, and it gives up `query_radius`, which is what the +chart layer actually needs (bounded by angular distance, not by count). Keep +the BallTree. + +## The Nearby list is a bounded window, not a total ordering + +`get_closest_objects` accepts `n=0` meaning "rank everything", and the object +list used to take it. Ranking the whole catalog to draw about nine rows costs +O(N) twice over: once in the k-NN query, and again in the cursor-tracking +helper that rebuilds a `(catalog_code, sequence)` dict over the new ordering in +pure Python. + +| N | k = N query | k = 200 query | cursor helper | +|---|-------------|---------------|---------------| +| 14 000 | 1.5 ms | 0.09 ms | 7.6 ms | +| 40 000 | 4.4 ms | 0.13 ms | 22 ms | + +(Measured on a fast dev machine; a Pi is roughly an order of magnitude slower, +against a 33 ms frame budget.) + +The cost is inherent to producing a *total* ordering — the vectorised +alternative above measured no better. It can only be avoided, so the decision +is to avoid it: `NEAREST_LIST_CAP = 200`, and the object list queries +`k = min(cap, N)`. Everything downstream is then bounded by the cap rather than +by catalog size, which is what keeps the re-rank off the frame budget. + +**The trade-off:** a Nearby-sorted list no longer scrolls down to the object on +the far side of the sky. An object 140° away is not "nearby" under any reading, +and paying O(N) on every degree of slew to keep that tail reachable is the +wrong bargain. Catalog and RA sort still expose the full set. + +Lazily extending the window as the user scrolls past 200 was considered and +rejected: it buys back an ordering with no observing use, at the cost of +carrying a paging state machine through the re-rank path. + +`n=0` stays supported for callers that genuinely want everything, but the cap +is the default posture for anything drawing a list. + +### Two lengths, and which one each caller means + +Capping makes explicit something the screen already half-had: the list the user +navigates is not the same length as the catalog behind it. (Even before the cap, +`deduplicate_objects` merged listings sharing an `object_id`, so the ranked list +was already the shorter of the two.) + +`UITextMenu.get_nr_of_menu_items` counts `_menu_items`, the source list. +`UIObjectList` overrides it to count `_menu_items_sorted`, because **that** is +what the screen draws, scrolls, opens and serialises. Anything addressing a row +— cursor clamping, the scrollbar, opening the focused object, the serialised +selection — must use the length of the list it is indexing, or the cursor can +address rows that do not exist. Long-DOWN to "the end" is the case that finds +this immediately. + +The catalog's own object count is a *different* quantity, and it stays the +source length: it is reported in `catalog_info_1` for the header, where the user +is being told how big the catalog is, not how far the carousel scrolls. + +Two consequences worth stating, because both were latent and are now reachable: +a sort may legitimately produce an empty list while the source is non-empty, so +the scrollbar must tolerate a zero count; and the in-frame re-rank must not run +before the spatial index has been built for the current list, or it replaces a +populated list with an empty one mid-draw. `Nearby.should_refresh` answers False +until the index is ready, which keeps the explicit `sort()` path the only thing +that can empty the list. + +## Staleness is measured on the sky, not per axis + +The ranking is recomputed when the pointing has moved far enough to change it. +"Far enough" is a **great-circle separation** from the pointing the current +ranking was built at (`great_circle_degrees`), compared against +`MAX_DEVIATION = 1.0` true degrees. + +Per-axis RA/Dec degrees are not a usable proxy for this, and the reason is +worth recording because the cheaper test looks reasonable: + +* One degree of RA spans `cos(dec)` degrees on the sky — 0.17° at Dec 80°, + 0.017° at Dec 89°. A per-axis threshold therefore re-ranks for movement the + user cannot see, and does so exactly where slewing is slowest and a stall is + most visible. +* RA wraps. A test on raw difference reads the step from 359.5° to 0.5° as 359, + so it fires continuously in a band around the meridian. + +Neither failure loses a refresh, so neither is visible as wrong output — they +are pure cost, which is why a per-axis test can sit unnoticed. The great-circle +form has no such blind spot and costs one `arccos`. + +A second trigger re-ranks after `MAX_TIME = 10` s regardless of pointing. Its +job is *not* pointing changes — the deviation test owns those. It exists to pick +up catalog and filter changes and altitude drift. Sized from the sky's 15°/hour: +10 s bounds the drift error to ~0.04°, far inside anything the list expresses. +A shorter cadence buys nothing and re-ranks a stationary scope for no reason. + +## The top row follows the pointing; a scrolled cursor follows the object + +A Nearby list is rebuilt for two different reasons, and the user's intent +differs between them. + +A **filter-driven** rebuild — the user logged an object, or tightened the +magnitude or altitude filter — should hold the cursor on the selected object, or +on the first of its old successors that survived. That is the natural next +target, and it is what `_next_target_index` exists for (see the filter-freshness +ADR). + +A **pointing-driven** re-rank is the opposite case. The user slewed the scope +*in order to change what is nearest*. Carrying the cursor along with the +previously selected object works directly against that: the focused row drifts +down the ranking, away from what they just pointed at. + +The decision splits the two: + +* While the cursor sits on the top row, a pointing-driven re-rank keeps it + there. The focused object is the nearest object, which is what the mode is + for. +* Once the user scrolls off the top they are browsing, and the cursor pins to + their selected object as it migrates through the ranking. Scrolling back to + the top re-arms the tracking. +* Filter-driven rebuilds keep the pinning behaviour unconditionally. + +This needs no new state and no user-facing setting: "the user has not scrolled" +is exactly `_current_item_index == 0`, read before the list is replaced. +Rejected alternatives: always reset to the top (destroys browsing — the list +becomes unusable as anything but a readout), and always pin to the object +(the behaviour this replaces). + +## One rebuild policy for the spatial index + +The tree is rebuilt only when the object set behind it changes, guarded on the +catalog filter's `dirty_time` — the same key `UIChart` already uses for its own +nearby-marker index. `dirty_time` does not cover a list rebuilt from source, so +`refresh_object_list` invalidates the index explicitly. Two consumers, one +policy, so a filter edit cannot leave the list and the chart disagreeing about +what is on the sky. + +## Consequences + +* Correctness no longer depends on where the scope is pointed. The chart layer + gains more from this than the list does: a marker that is silently absent from + the field is not something the user can notice. +* Per-refresh work is bounded by `NEAREST_LIST_CAP` rather than catalog size, + which is what allows the re-rank to stay on the draw path at all. +* Nearby lists are truncated to 200 objects — the one behaviour a user may + experience as a loss. +* Sort orders must each be given a branch in `sort()` *and* an entry in + `_sort_order_label`. Both labels route through that one helper so a mode + cannot be displayed under another mode's name. diff --git a/docs/ax/catalog.md b/docs/ax/catalog.md index 3090cf71d..b42c21b7a 100644 --- a/docs/ax/catalog.md +++ b/docs/ax/catalog.md @@ -31,7 +31,8 @@ At a high level: │ └─► re-filter │ ├── dynamic catalogs: PlanetCatalog (TimerMixin, every ~5 min) - │ CometCatalog (similar) + │ CometCatalog (downloaded MPC elements) + │ AsteroidCatalog (annual MPC bright subset) │ └─► Catalogs object (single instance shared across the app) │ @@ -64,7 +65,9 @@ displays. It's a dataclass that merges three things: the object's listings counts, keyed by `object_id`; virtual objects key on their own listing — see ADR 0025), `last_filtered_time`/`last_filtered_result` (used by the filter - cache). + cache), and optional structured solar-system fields + (`earth_distance_au`, `sun_distance_au`, `opposition_date`, + `peak_magnitude`, `peak_date`). Two `CompositeObject`s are equal iff their `object_id`s match. That means the same underlying object referenced by multiple catalogs (e.g. @@ -164,8 +167,8 @@ menu). It: one `Catalog` per entry in `catalogs_info`, ending up with a `Catalogs` instance even for catalogs that are currently empty (the background loader will populate them later). -5. Appends two dynamic catalogs: `PlanetCatalog` (`PL`) and, via local - import, `CometCatalog`. +5. Appends three dynamic catalogs: `PlanetCatalog` (`PL`), `CometCatalog` + (`CM`), and `AsteroidCatalog` (`MP`). 6. Asserts `check_catalogs_sequences(...)`. The reference to the background loader is also stashed on the @@ -251,6 +254,12 @@ Two freshness triggers advance `dirty_time` besides the setters and `UIObjectList.update()` polls it so an open list refreshes in place. +Runtime position/catalog replacements call `invalidate_filter_cache()` for +the changed catalog and set a catalog-content-dirty flag. This wakes an open +list without advancing `dirty_time`, so unchanged catalogs retain their cached +results. Automatic list rebuilds reapply the current sort silently, reserving +the “Sorting by…” toast for a user-selected sort. + ### 4.2 Altitude requires GPS `calc_fast_aa(shared_state)` builds a `FastAltAz` from the current @@ -334,10 +343,28 @@ A `Catalog` subclass that: ### 6.2 `CometCatalog` Imported locally in `CatalogBuilder.build()` to avoid a circular -import. Same general pattern: dynamic, status-aware, registered as a -regular `Catalog` in the `Catalogs` collection. +import. It downloads MPC comet elements transactionally and keeps the active +objects visible while a replacement downloads. `CatalogStatus` carries known +percentage progress or `None` for an indeterminate progress bar. Successful +downloads trigger a full recalculation; failed downloads leave both the old +objects and their displayed source age intact. + +### 6.3 `AsteroidCatalog` + +The `MP` catalog reads MPC's annual `Soft00Bright.txt` observing subset (ADR +0021). Its catalog listing sequence is the stable numbered minor planet, while +its negative object ID remains session-minted like every virtual object. +Vectorized propagation supplies J2000 RA/Dec, Earth/Sun distance, and an IAU +H-G apparent magnitude. Objects above the magnitude-15 safety limit are omitted; +ordinary catalog filters can impose a brighter user limit. + +Visible objects are enriched across the next 550 days with the first upcoming +ecliptic-longitude opposition (or greatest elongation) plus the peak magnitude +and date for that apparition. These structured values drive the Asteroids list's +Brightest, Distance, and Opposition sorts; description text is presentation +only. -### 6.3 `TimerMixin` +### 6.4 `TimerMixin` Provides `start_timer()` / `stop()` plus a `time_delay_seconds` that can be either an int or a callable. Each fire schedules itself again via @@ -346,7 +373,7 @@ catalog method does not run on the timer thread directly — useful because `do_timed_task` can take a noticeable amount of time (`sf_utils.calc_planets` is not cheap). -### 6.4 `VirtualIDManager` +### 6.5 `VirtualIDManager` Static helper that hands out monotonically decreasing `object_id` values for non-DB objects. Held under `virtual_id_lock` and persists diff --git a/docs/ax/catalog/CONTEXT.md b/docs/ax/catalog/CONTEXT.md index 4c4ae6be9..a661f140a 100644 --- a/docs/ax/catalog/CONTEXT.md +++ b/docs/ax/catalog/CONTEXT.md @@ -9,7 +9,7 @@ The Catalog context owns runtime loading, filtering, searching and display of as ### Identity **Catalog code**: -Short string identifier for a catalog as a whole — `"M"`, `"NGC"`, `"IC"`, `"WDS"`, `"PL"` (planets). Drives DB queries and the UI designator. Its readable sibling is the **catalog display name** ("Collinder" for code `"Cr"`). +Short string identifier for a catalog as a whole — `"M"`, `"NGC"`, `"IC"`, `"WDS"`, `"PL"` (planets), `"MP"` (asteroids). Drives DB queries and the UI designator. Its readable sibling is the **catalog display name** ("Collinder" for code `"Cr"`). _Avoid_: catalog id, prefix; for the readable form say **catalog display name**, not "catalog name". **Catalog display name**: @@ -101,6 +101,10 @@ _Avoid_: logged (when speaking to users or in UI copy), seen. Epoch timestamps driving the two filter cache layers. `mark_dirty()` advances `filter.dirty_time`; an object's verdict is re-evaluated only when `obj.last_filtered_time < filter.dirty_time` (per-object layer), and a whole catalog skips its scan while `catalog.last_filtered > filter.dirty_time` (per-catalog layer). Beyond the parameter setters, dirty time also advances when an object is logged with an observed criterion active (`Catalogs.mark_logged`) and when staleness is promoted (see **Stale**). _Avoid_: invalidation, cache key. +**Catalog content dirty**: +A wake-up flag for runtime object changes under unchanged filter criteria. The changed catalog resets its own cached verdicts, then `mark_catalog_content_dirty()` makes open lists rebuild without advancing **Dirty time**. +_Avoid_: filter dirty (the criteria did not change), stale (time did not expire the verdict). + **Stale** (filter staleness): Verdicts outdated by time passing rather than by a parameter change — only possible for time-sensitive criteria, today just altitude (the sky rotates ≤ 15°/hour). `CatalogFilter.is_stale()` reports it: altitude criterion active, alt/az available, and either verdicts older than `ALTITUDE_STALE_SECONDS` (600 s ≈ 2.5° of drift) or an alt/az fix arrived after verdicts were computed without one. Staleness never invalidates by itself; `Catalogs.filter_catalogs()` promotes it to a dirty bump. See [ADR 0025](../../adr/0025-filter-freshness-staleness-promotion.md). _Avoid_: dirty (that's a parameter change), expired. @@ -138,7 +142,7 @@ _Avoid_: load-done event, ready signal. ### Dynamic catalogs **Dynamic catalog**: -A `Catalog` whose objects are computed at runtime rather than loaded from `objects.db`. Currently `PlanetCatalog` and `CometCatalog`. +A `Catalog` whose objects are computed at runtime rather than loaded from `objects.db`. Currently `PlanetCatalog`, `CometCatalog`, and `AsteroidCatalog`. _Avoid_: live catalog, computed catalog. **PlanetCatalog**: @@ -149,6 +153,18 @@ _Avoid_: planets, ephemeris (those are more general). Comet equivalent of `PlanetCatalog`. Imported locally inside `CatalogBuilder.build()` to break a circular import. _Avoid_: comets. +**AsteroidCatalog**: +The `"MP"` dynamic catalog. Loads the MPC annual bright-minor-planet subset, uses the numbered minor planet as its stable **Sequence**, computes current position and H-G apparent magnitude, and enriches visible objects with distance, opposition/greatest-elongation, and apparition-peak data. Source replacement is atomic; populated objects remain available during download. +_Avoid_: minor planets (when the concrete catalog class is meant), `Ast` (that code means asterism). + +**Catalog data age**: +Whole days since the active downloaded elements file's server timestamp. Used for frequently refreshed sources such as comets. During refresh this continues to describe the objects actually on screen; it changes only after a validated file replaces the active source. Annual asteroid data instead shows its compact MPC edition label, because a large day count is normal rather than a stale-data warning. +_Avoid_: catalog age (ambiguous with the runtime `Catalog` object). + +**Download progress**: +The `CatalogStatus.data["progress"]` percentage for a downloaded dynamic catalog. `None` means the response has no known Content-Length and the UI draws an indeterminate bar. Progress does not imply the old objects have been removed; they stay usable until recalculation succeeds. +_Avoid_: loading progress (deferred database catalog loading is a different lifecycle). + **TimerMixin**: Composition helper providing self-rescheduling periodic timers. `time_delay_seconds` may be a callable for adaptive delays. Updates run in their own thread, not on the timer thread. _Avoid_: scheduler, ticker. diff --git a/docs/ax/catalog/obslist-formats/README.md b/docs/ax/catalog/obslist-formats/README.md index 499340858..f4470ec88 100644 --- a/docs/ax/catalog/obslist-formats/README.md +++ b/docs/ax/catalog/obslist-formats/README.md @@ -133,6 +133,7 @@ like `"Galaxy"`. The canonical set is defined by `OBJ_TYPES` in | `PN` | Planetary nebula | | `Kt` | Knot | | `DN` | Dark nebula | | `Pla` | Planet | | `C+N` | Cluster + nebula | | `CM` | Comet | +| `AS` | Asteroid | | | | | | | | `?` | Unknown | A code drives two things: the symbol drawn next to the object, and the **Type** 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..c836f4ca0 --- /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), but pushed to the short-retention `pifinder` cache rather than the retained one, so a beta reinstalls only while its closure survives GC. 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 prominence delta is done — the trunk row renders bold and set apart from the PR rows. One transitional delta remains: until upstreaming, the unstable trunk is read from the Fork's `nixos` branch (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**, whose closures live in the retained `pifinder-release` cache; **beta** and **unstable** (`main` head / PR) builds share the short-retention dev cache and may be GC'd. + +**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** that is guaranteed (its closures live in the never-GC'd `pifinder-release` cache), so any past stable release reinstalls forever; **beta** and **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..e725eb9ed --- /dev/null +++ b/docs/ax/nixos/adr/0001-attic-binary-cache.md @@ -0,0 +1,96 @@ +# 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`, plus `release.yml`'s beta + prereleases — short retention, these churn on every push) and + `pifinder-release` (stable 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..13556a4c0 --- /dev/null +++ b/docs/ax/nixos/adr/0002-update-channels-and-rollback.md @@ -0,0 +1,18 @@ +# Update channels stay Release-based (stable/beta) over a live main+PR unstable; rollback via reinstall + passive yank + +> **Corrected 2026-08:** only **stable** is pushed to the retained `pifinder-release` cache. `release.yml` sends **beta** to the finite-retention `pifinder` cache "so prereleases stay transient", so beta's rollback is durable only while its closure survives GC. The cache argument in the rejected `main`-head option below therefore no longer distinguishes beta from a continuous channel; what still does is the curation symmetry with stable (notes, explicit semver, the version gate). + +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); stable is pushed to the *retained* cache and beta to the short-retention one. 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** — its closures live in the never-GC'd `pifinder-release` cache ([0001](./0001-attic-binary-cache.md)), and the device never builds. **beta**, **unstable** (`main`-head / PR) closures share the short-retention cache: they may be GC'd and become un-installable until CI rebuilds and re-pushes them. Beta keeps the rest of a Release's ceremony (notes, semver, gate, a durable GitHub Release record with its migration asset), just not the durable closure. +- Channel *sourcing* matches the current code. The cosmetic delta is done — `software.py` renders the trunk row bold and distinct from the PR rows. Still transitional: the unstable trunk is read from the `nixos` branch (`source_ref == "nixos"`) rather than `main`, until the NixOS line becomes mainline. 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..c103879a0 --- /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:** `stable` is still empty, but `beta` now holds the `v3.0.0-beta` prerelease, so migration resolves **beta**, not the unstable trunk. Note beta's closure lives in the short-retention cache (see the correction in [0002](./0002-update-channels-and-rollback.md)), so a beta-resolved migration is only guaranteed while that closure survives GC — the never-GC'd guarantee above returns once a `stable` Release exists. The unstable trunk remains pinned to the **`nixos` branch** (`source_ref == "nixos"`), not `main`, because the NixOS line isn't mainline yet; drop that guard once it is. +- The tarball is built **once** and only rebuilt when the minimal migration system itself changes — never per Release. A "cut stable release" flow (build full system → push closure to `pifinder-release` → stamp a `stable` entry) is what makes new closures reachable; the tarball just rides whatever that produces. diff --git a/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md b/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md new file mode 100644 index 000000000..d11706ff1 --- /dev/null +++ b/docs/ax/nixos/adr/0004-migration-tarball-published-per-release.md @@ -0,0 +1,14 @@ +# Migration tarball is published as a (pre)release asset, not a built-once file (amends 0003) + +The tarball keeps [0003](./0003-migration-tarball-rides-latest-stable.md)'s *mechanism* — minimal system (`images.pifinder-migration`), full system resolved from the update manifest at first boot, so the tarball's content never goes stale — but its *publication* moves from "built once at a fixed URL" to an asset (with `.sha256` sidecar) on every stable/beta Release, referenced per-entry as `migration_url` in the manifest. Releases are visible, versioned, documented, and in the canonical place; a replace-in-place file on a private file server is none of those, and un-auditable after an incident. + +## Considered options + +- **Built-once evergreen tarball at a fixed URL (0003's original), rejected.** Minimal asset churn, but the artifact lives outside the release record: no version, no notes, no checksum ceremony, silent replace-in-place. The Raspbian-side updater would also need a hardcoded URL instead of reading the manifest like everything else. +- **Per-release *full-system* tarball (shipped briefly in the first release.yml), rejected.** ~1.4 GB per release forever, and it freezes the migrated system to the tarball's build date — the exact staleness trap 0003 exists to avoid. + +## Consequences + +- `release.yml` must tar `images.pifinder-migration`, **not** `images.pifinder` (the full SD image remains a separate asset for fresh flashes). +- Two-stage resolution: the Raspbian device picks a *tarball* (first manifest entry with a `migration_url`, stable → beta → unstable); first boot then picks the *closure* from the manifest — the authoritative version decision. Any reasonably recent tarball yields the same end state. +- Asset cost per release drops to the minimal system's size; old tarballs remain downloadable alongside their releases. diff --git a/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md b/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md new file mode 100644 index 000000000..486c4f1b8 --- /dev/null +++ b/docs/ax/nixos/adr/0005-self-arming-watchdog-confirmed-generations.md @@ -0,0 +1,17 @@ +# Boot watchdog is self-arming via a confirmed-generations ledger; confirmed generations are never auto-rolled-back + +A generation is a **trial** until it passes one boot health check, which **confirms** it permanently on that device (recorded in a device-local ledger). Any boot of an *unconfirmed* generation is a trial — regardless of which build installed it — so recovery never depends on the previous system's code: the version-skew hole where an older build (unaware of trial markers) installs a broken new one and leaves it unprotected, which is exactly how the first v3.0.0-beta crash-looped with no rollback on 2026-07-03. Confirmed generations are never auto-rolled-back: a transient failure in the field must not cause a surprise downgrade. + +## Considered options + +- **Marker-armed only (upgrade writes a trial marker; no marker → nothing to watch), rejected.** The original design. Protection depends on the *installing* build knowing to arm the marker — any device upgrading from an older build gets one unprotected hop, proven in the field. +- **Auto-rollback on any repeated boot failure (no confirmation concept), rejected.** Self-heals late-life breakage, but a confirmed build failing transiently (cold night, flaky SD read) would be silently downgraded — the failure mode the trial/confirm split exists to prevent. + +## Consequences + +- Persistent breakage of an already-confirmed generation does **not** self-heal — deliberate. It is covered by the explicit recovery ladder instead: the Software screen's Rollback channel, the power-on **recovery hold** into recovery mode (rollback without any working UI, see [0006](./0006-recovery-mode-reuses-update-screen.md)), and SSH. +- The upgrade-written trial marker survives as a *hint* (it names the exact pre-upgrade system, camera specialisation included); the ledger is what's load-bearing. Rollback targets are chosen newest-first from the profile, preferring confirmed generations. +- Ledger loss is benign: a healthy generation simply re-confirms on its next boot; an unhealthy one gets a rollback it genuinely needs. +- A failing trial with no rollback target at all (first-ever install) shows an on-screen failure message and stays up for rescue instead of boot-looping. +- "Never touches a confirmed generation" means never *acts on* — the watchdog still *reports*: a confirmed generation whose app fails gets an on-screen advisory naming the recovery hold, so the escape hatch reveals itself exactly when needed and never clutters a healthy boot. +- Known floor: a generation that dies before userspace (kernel/initrd) boot-loops — the watchdog never runs, and neither NixOS nor extlinux offers boot counting. Chosen future fix (backlogged): **U-Boot `bootcount`/`altbootcmd`** — the trial's confirm step resets the counter; exceeding the limit boots the previous generation's entry. Preferred over Raspberry Pi firmware `tryboot`, which works at the config.txt/partition level and would force an A/B boot-chain redesign; bootcount preserves the single-extlinux generation model. diff --git a/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md b/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md new file mode 100644 index 000000000..1bab15257 --- /dev/null +++ b/docs/ax/nixos/adr/0006-recovery-mode-reuses-update-screen.md @@ -0,0 +1,14 @@ +# Recovery mode is the update screen in isolation, with a blind-rollback fallback — not a separate minimal tool + +The recovery hold boots a stripped app entry running only the update screen (display + keypad; no camera/solver/positioning), so rescue gets the generation overview and the internet channels from the same code, screen, and install path users already know — one machinery, one set of bugs. Because that screen runs inside the possibly-broken generation, it is a *rung*, not the ladder: if recovery mode fails its own health check, the device falls back to a blind generation rollback to the newest confirmed generation with an on-screen message ([0005](./0005-self-arming-watchdog-confirmed-generations.md)). + +## Considered options + +- **Standalone minimal recovery tool (own renderer, own generation lister), rejected.** Smaller failure domain, but duplicates the generation list, manifest fetch, install trigger, and keypad/display handling — a second UI that rots separately from the real one, for a rung the blind fallback already backstops. +- **Blind rollback only (no interactive mode), rejected.** Works when everything is broken, but forces the newest-confirmed choice on the user; the "it's broken" scenario often wants *a specific* known-good version or a fresh install from a channel. + +## Consequences + +- Selection semantics are **sticky**: choosing a generation sets the boot default (a telescope user in recovery means "go back until I say otherwise"), never a one-shot boot. Internet picks go through the ordinary upgrade flow and face a normal trial. +- Recovery mode inherits the update screen's offline behavior: no network degrades to "rollback only". +- The app's readiness signal (health check) pulls double duty: it is also what decides whether recovery mode itself is alive or the blind fallback fires. diff --git a/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md b/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md new file mode 100644 index 000000000..e509faf4c --- /dev/null +++ b/docs/ax/nixos/adr/0007-boot-on-ext4-fat-firmware-only.md @@ -0,0 +1,13 @@ +# extlinux and kernels live on the ext4 root; the FAT partition is firmware-only + +A custom U-Boot (`CONFIG_CMD_SYSBOOT`) reads `extlinux.conf` and the per-generation kernels from `/boot` **on the ext4 root** (`mmc 0:2`); the FAT partition carries only what the GPU firmware itself must parse — `config.txt`, `start*.elf`, U-Boot, DTBs — written once at install and never touched at runtime. NixOS mutates `/boot` on every generation switch (upgrade, rollback, camera specialisation, watchdog auto-rollback, `set-extlinux-default`), and those rewrites must be crash-safe: ext4 renames are atomic, FAT's are not — a power cut mid-rewrite on FAT can corrupt the one file the boot chain needs, which is unacceptable for a bootloader the watchdog rewrites unattended. + +## Considered options + +- **Everything on FAT (Raspbian convention: firmware loads the kernel directly), rejected.** No atomic rename (crash-unsafe generation switches), no symlinks, and per-generation kernels+initrds (~50MB each, `configurationLimit` of them plus specialisation entries) outgrow a 256MB firmware partition. +- **extlinux on FAT, kernels on ext4, rejected.** Splits one logical unit (the boot menu and what it points at) across filesystems and still leaves the menu rewrite non-atomic. + +## Consequences + +- The partition most fragile to corruption (FAT, which the GPU bootloader must parse) is also the one never written after install. +- The tarball layout follows: its top-level `boot/` is the firmware payload, while `rootfs/boot` is a **populated** directory — anything consuming the tarball must not assume Raspbian's everything-in-FAT layout. The migration initramfs did exactly that (empty-`/boot` assumption + extlinux-on-FAT verifications) and failed the first real migration against this layout (2026-07-05); it now stages the firmware payload aside and verifies each partition for what its boot-chain stage actually needs. diff --git a/docs/ax/nixos/adr/0008-imx462-xclk-override.md b/docs/ax/nixos/adr/0008-imx462-xclk-override.md new file mode 100644 index 000000000..74f229346 --- /dev/null +++ b/docs/ax/nixos/adr/0008-imx462-xclk-override.md @@ -0,0 +1,23 @@ +# The imx462 camera gets an explicit 74.25 MHz xclk overlay because fdtoverlay drops overlay parameters + +**Status: proposed — pending a build on pi5 and a camera test on a device.** + +PiFinder's imx462 camera module has a 74.25 MHz oscillator. The kernel's `imx290`/`imx462` DT overlays default the sensor xclk to 37.125 MHz; on Raspbian, PiFinder corrects this with an overlay *parameter* (`switch_camera.py` writes `dtoverlay=imx290,clock-frequency=74250000` to `config.txt`, applied by the RPi firmware loader). The NixOS image applies overlays with `fdtoverlay`, which **cannot apply overlay parameters** (`__overrides__`) — so the 37.125 MHz default silently survived. The driver (`imx290.c`) reads the sensor node's `clock-frequency` property and programs a per-frequency INCKSEL/PLL register set; with the wrong xclk the sensor enumerates on I2C but never delivers frames, and libcamera reports `Camera frontend has timed out` with an empty kernel log. + +The image therefore compiles a small additional overlay, applied after the camera overlay, that sets `clock-frequency = 74250000` in both places the Raspbian parameter writes: the `cam1_clk` fixed-clock node (the driver `clk_set_rate`s it and errors on mismatch) and the sensor node property (selects the INCKSEL register set). + +This supersedes an earlier draft of this ADR that blamed the kernel: the hypothesis was that the mainline `imx290` driver's lack of an imx462 model caused the failure, to be fixed by switching to `pkgs.linuxPackages_rpi4`. That was wrong on both ends — the image already runs the Raspberry Pi vendor kernel (the `nixos-hardware` `raspberry-pi-4` module pins the downstream tree, `stable_20250916` / 6.12.47, whose `imx290` driver does carry imx462 support), and the mr2 diagnosis that "verified" the DT clock at 37.125 MHz was in fact confirming the bug: 37.125 MHz matches the overlay default, not the hardware. + +## Considered options + +- **Compiled xclk-override overlay applied after the camera overlay (chosen).** Reproduces Raspbian's field-proven configuration (`sony,imx290lqr` compatible + 74.25 MHz xclk) exactly, using the overlay machinery `hardware.nix` already has. Relies on `fdtoverlay` merging the camera overlay's `__symbols__` so `&cam_node` resolves — same mechanism the existing custom overlays use against base-DT labels. +- **Switch to the vendor kernel via `boot.kernelPackages = pkgs.linuxPackages_rpi4`, rejected.** The earlier draft's fix. Redundant — nixos-hardware already pins the same vendor tree at the same tag — and actively harmful: it swaps in an equivalent-but-different kernel derivation, forcing a full kernel rebuild on pi5 and re-opening the u-boot/extlinux boot-chain question for zero functional change. +- **Use the kernel's `imx462.dtbo` (compatible `sony,imx462lqr`, dedicated init registers), deferred.** Arguably the "more correct" driver model, but it is not what PiFinder runs on Raspbian, it still needs the same xclk override, and it changes the libcamera tuning file (sensor name `imx462` vs `imx290`). Not worth the untested variables while getting the camera working; worth revisiting once streaming is confirmed. +- **Patch the camera overlay source and compile it ourselves, rejected.** Duplicates kernel dtsi includes into the flake for something a two-property override achieves. + +## Consequences + +- imx296 and imx477 are unaffected: on Raspbian PiFinder configures them without a clock parameter, so the overlay defaults are already correct and the override is gated on `cameraType == "imx462"`. +- No kernel change, so no new boot-chain risk and no kernel rebuild on pi5; the DTB derivation is the only thing that changes. +- The override must stay ordered after the camera overlay in the `fdtoverlay` invocation — `&cam_node` only exists in the merged symbol table at that point. A future refactor that reorders the overlay list will break it at DTB build time (fdtoverlay fails to resolve the symbol), not silently. +- If the sensor still does not stream with the correct xclk, the next suspects are the `imx462.dtbo` model path above and the libcamera/IPA tuning — not the kernel. diff --git a/docs/ax/nixos/adr/README.md b/docs/ax/nixos/adr/README.md new file mode 100644 index 000000000..1df84a403 --- /dev/null +++ b/docs/ax/nixos/adr/README.md @@ -0,0 +1,14 @@ +# 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) +- [0004 — Migration tarball is published as a (pre)release asset, not a built-once file (amends 0003)](./0004-migration-tarball-published-per-release.md) +- [0005 — Boot watchdog is self-arming via a confirmed-generations ledger; confirmed generations are never auto-rolled-back](./0005-self-arming-watchdog-confirmed-generations.md) +- [0006 — Recovery mode is the update screen in isolation, with a blind-rollback fallback — not a separate minimal tool](./0006-recovery-mode-reuses-update-screen.md) +- [0007 — extlinux and kernels live on the ext4 root; the FAT partition is firmware-only](./0007-boot-on-ext4-fat-firmware-only.md) +- [0008 — The imx462 camera gets an explicit 74.25 MHz xclk overlay because fdtoverlay drops overlay parameters (proposed)](./0008-imx462-xclk-override.md) diff --git a/docs/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 7212d4e81..bc8eaffb3 100644 --- a/docs/source/dev_guide.rst +++ b/docs/source/dev_guide.rst @@ -57,61 +57,36 @@ to discuss the issue on the to sort things out and prioritize. Beta Testing --------------- - -When you look at the `PiFinder GitHub repository `_ you will see, that there are different branches. -That is the way, how we develop the PiFinder. The main branch is the one, on which development is happening. If you want to test the latest changes, you can -check out the main branch and run its code. For this your PiFinder needs to be connected to the internet, i.e. your WiFi. -Once you have connected, log into your PiFinder via ssh and run the following commands in the terminal: - -.. code-block:: bash - - cd ~/PiFinder - git fetch --all - sudo systemctl stop pifinder - git checkout main - git pull - ./pifinder_post_update.sh - sudo systemctl start pifinder - -This will stop the PiFinder, update the code and dependencies to the latest development version and start it again. - -If you want to return to the stable version, you can run the following command: - -.. code-block:: bash - - ./pifinder_update.sh - -If you really, really would like to use bleeding edge code, you can check out a different branch, or checkout one of the forks of the repository. - -To list all branches, run the following command: - -.. code-block:: bash - - cd ~/PiFinder - git branch -a - -To checkout one of the forks of the repository, run the following commands: - -.. code-block:: bash - - cd ~/PiFinder - git remote add - git fetch --all - git checkout -b / - -You have to replace with the name of the remote you added, with the URL of the fork you want to check out (you can copy this from github, by pressing on the "code" button), and with the name of the branch you want to check out. This will create a new branch in your local repository, which follows the branch of the fork you checked out. - -To keep up to date with the latest changes in the fork, you can run the following commands: - -.. code-block:: bash - - cd ~/PiFinder - git pull - cd python - sudo pip install -r requirements.txt - -The last command will install the requirements and only needs to be run occasionally, depending on the changes in the branch. You need to restart the pifinder service to see the changes. +------------ + +PiFinder updates over the air, right from the device. Open the +:ref:`user_guide:tools` menu and choose Software Upd; the PiFinder downloads a +prebuilt image and switches to it. The update screen is arranged as three +**channels** that you move between on the device: + +- **stable** — where Software Upd opens. The production channel of official + releases, listing the versions you can switch to. The safe choice for ordinary + observing. +- **beta** — press **RIGHT** from the stable channel to reach it. Pre-release + builds cut from the development branch, curated with release notes before they + go stable. This is the channel for most beta testers. +- **unstable** — the bleeding edge: the live tip of development plus individual + open pull requests, each installable before it's merged. It stays hidden until + you unlock it by pressing **SQUARE** seven times on the update screen. + +Each version you pick resolves to a build the project's binary cache has already +compiled, so the device only downloads and activates it — it never compiles +anything itself, and the switch takes a couple of minutes. If a build misbehaves +you can switch back to an earlier stable or beta version the same way, since +those are kept in the cache. + +The PiFinder needs internet access to reach the cache, so put it in Client Mode +on a WiFi network with a connection. See :ref:`user_guide:update software` for a +full walkthrough of the update screen. + +When you hit a problem on a beta or unstable build, report it as described in +`Submitting issues, bugs and ideas`_ above, and say which channel and version +you were running. Fork me - getting or contributing to the sources with pull request ------------------------------------------------------------------ @@ -139,15 +114,19 @@ The files are located in PiFinders GitHub repository under ``docs/source`` and h the ending ``.rst``. The documentation is then published to `readthedocs.io `_, when the change is committed to the official GitHub repository (using readthedocs's infrastructure). -You can link your fork also to your account on readthedocs.io, but it is easier to build the documentation locally. -For this, install Sphinx and the Read the Docs theme from the pinned -requirements file (run this from the ``docs`` directory): +Read the Docs rebuilds and publishes the site automatically whenever a change +lands on the official GitHub repository, so you don't have to do anything to +publish. To preview your changes first, build the site locally with the pinned +requirements. The dev shell provides ``uv``, which can run Sphinx in a throwaway +environment without installing anything globally — run this from the ``docs`` +directory: .. code-block:: - pip install -r source/requirements.txt + uv run --no-project --with-requirements source/requirements.txt --python 3.11 \ + sphinx-build -b html source build/html -You can then use the supplied ``Makefile`` to build a html tree using ``make html`` and running a http server in the directory with the files: +Then serve the result and open it in your browser: .. code-block:: @@ -248,22 +227,22 @@ also contain the compiled ``.mo`` files, which are binary representations of the When you edit the files, check for each entry that has a ``msgstr ""`` line, which means the string is not translated yet. You also need to check the translations of strings marked as "fuzzy". You need to remove the "fuzzy" line, once you have checked the translation. -In order to run the PiFinder software with the latest translation, you need to run the following commands: +The Babel toolchain extracts the strings, updates the ``.po`` files, and compiles +them into the ``.mo`` files the PiFinder reads. Run it from ``python/`` inside the +dev shell (see `Install dependencies with Nix`_): .. code-block:: - cd ~/PiFinder/python - sudo pip install -r requirements_dev.txt - nox -s babel - -The ``pip`` command installs the dependencies for the translation, the second command runs the babel toolchain to extract the strings -to translate and update the .po files. This also compiles the .po files into .mo files, which are then used by the PiFinder software. + cd python + pybabel extract -F babel.cfg -c TRANSLATORS -o locale/messages.pot ./PiFinder ./views + pybabel update -i locale/messages.pot -d locale + pybabel compile -d locale -So if you want to test your translations, you need to run the ``nox`` command every time you change the .po files, then restart the PiFinder software: +Run these again every time you change a ``.po`` file, then restart the PiFinder +to pick up the new ``.mo`` files. On a running device that is: .. code-block:: - nox -s babel sudo systemctl restart pifinder Please post the changed po files in the Discord channel "translation" and we will include it in the next release. @@ -271,46 +250,56 @@ Please post the changed po files in the Discord channel "translation" and we wil Setup the development environment --------------------------------- -On the PiFinder -.................. +PiFinder is developed on a Linux machine with the `Nix package manager +`_, which provides the exact toolchain the project +builds and tests with. An x86_64 machine running Linux — including WSL2 on +Windows — is the primary platform, and the rest of this guide assumes it. -The best development platform for the PiFinder is the PiFinder itself via SSH or with a -monitor keyboard attached. This will let you develop and test any part of the code. +Most UI and catalog work can be done on that machine alone: the display is +emulated and the camera, IMU and GPS are faked with the flags described under +`Running/Debugging from the command line`_. Those physical features can only be +exercised on a real PiFinder. -See the :ref:`software:build from scratch` section of the Software Setup guide for -information on creating a base SD card and getting the base software running. +The device itself runs an immutable NixOS image, so its software sits read-only in +the Nix store. For a finished change you build an image and install it over the +air through the update channels (see `Beta Testing`_), or cut a release. For quick +iteration against the real camera, IMU and GPS, though, you can point the device +at an editable copy of your code and skip the image build entirely — see +`Developing on the PiFinder itself`_. -Other Options -................ - -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. +To get started, fork the repo and clone your fork, then set up the environment as +described next. -You can also develop on any Posix compatible system (Linux / MacOS) in roughly the -same way you can on a Raspberry Pi. The emulated hardware and networking features -will work differently so this is mostly useful for UI/Catalog feature development. +Install dependencies with Nix +............................. -Note that you can develop on Windows by activating Windows Subsystem for Linux (WSL2) -and installing Ubuntu from the Microsoft Store. The window launched by PiFinder will -be fully integrated into your windows desktop. +PiFinder's development environment is described by the ``flake.nix`` at the +repository root, so you don't install Python or its libraries by hand. On NixOS +the Nix package manager is built in; on another Linux machine, install it and +enable flakes. If you use `direnv `_, let it manage the +shell automatically from the repository root: -To get started, fork the repo and set up your virtual environment system of choice -using Python 3.9. Then follow some of the steps below! +.. code-block:: -Install python dependencies -........................... + direnv allow -For running PiFinder, you need to install some python libraries in certain -versions. These lists can be installed via -`pip tool chain `_ and are separated in two -files: one for getting PiFinder to run, one for development purposes: +The shell then loads and unloads as you enter and leave the checkout. If you do +not use direnv, enter the identical shell explicitly instead: .. code-block:: - pip install -r requirements.txt - pip install -r requirements_dev.txt + nix develop + +Both routes give you everything PiFinder needs on your ``PATH``: a Python +interpreter with the project's dependencies, the ``ruff`` linter, the ``uv`` +package manager, and the ``cedar-detect-server`` plate-solving helper. The +repo's ``.envrc`` uses the classic ``shell.nix`` entry point, which selects the +same flake dev shell but filters large runtime data out of the source copied to +the Nix store. This keeps direnv reloads quick; manual ``nix develop`` and CI +still evaluate the flake directly. + +You still need to fetch the Tetra3 submodule once; see +`Install the Tetra3/Cedar solver`_ below. Hipparcos catalog @@ -341,76 +330,58 @@ command from with your checked out repo Code Quality Automation ----------------------- -The PiFinder codebase includes features for maintaining code quality, -adherence to style guide and for evaluation and testing. These will -be installed along with the dev dependencies and should be available -to run immediately. - -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 +426,18 @@ Running against a locally running instance at localhost:8080: .. code-block:: bash - cd ~/PiFinder/python - . .venv/bin/activate # Optionally active your virtual environment + cd python export SELENIUM_GRID_URL= # Optional, default is http://localhost:4444/wd/hub - nox -s web_tests + pytest -m web --local If you want to test against a real PiFinder, set the ``PIFINDER_HOMEPAGE`` environment variable to the URL of your PiFinder instance: .. code-block:: bash - cd ~/PiFinder/python - . .venv/bin/activate # Optionally active your virtual environment + cd python export SELENIUM_GRID_URL= # Optional, default is http://localhost:4444/wd/hub export PIFINDER_HOMEPAGE=http://pifinder.local # Change to the URL of your PiFinder, which needs to be in the same WiFi - nox -s web_tests + pytest -m web If you run the tests with-out a working Selenium Grid instance, the tests will all be skipped. You can also run individual tests with PyTest directly, use ``SELENIUM_GRID_URL=... PIFINDER_HOMEPAGE=... pytest tests/website/test_file.py``. @@ -507,6 +476,13 @@ python program with the command line parameters you need for the certain use cas You simply stop the program with "Ctrl + C". +.. note:: + + On a Nix development machine, enter the dev shell first (``nix develop``, or + let direnv load it) and run these commands from the ``python`` folder of your + own checkout rather than ``/home/pifinder/PiFinder``. Everything you need, + including ``cedar-detect-server``, is already on your ``PATH``. + **Remember**: PiFinder is designed to automatically start after boot. So a PiFinder process is likely running. Before you can start a PiFinder process for testing purposes from the command line, you have to stop all currently running @@ -522,14 +498,16 @@ PiFinder: Running cedar-detect-server ............................. -You will need to start the ``cedar-detect`` process manually, if your development machine is not a PiFinder, -as it is started as a separate process on the PiFinder starting with v2.4.0. -You can do this by running the following command in another terminal window: +If your development machine isn't a PiFinder, you need to start the +``cedar-detect`` star-detection process yourself — since v2.4.0 it runs as a +separate process. The Nix dev shell puts ``cedar-detect-server`` on your +``PATH``, so in another terminal window run: .. code-block:: - cd /home/pifinder/PiFinder/bin - ./cedar-detect-server- -p 50551 + cedar-detect-server -p 50551 + +The ``-p 50551`` port is required — PiFinder looks for the server there. -h, --help | available command line arguments ............................................. @@ -621,6 +599,117 @@ be retired because the remote server is always started. python3 -m PiFinder.main -fh -k server --camera debug -x +Developing on the PiFinder itself +--------------------------------- + +Most development happens on your desktop, but the camera, IMU, GPS and the +physical keypad and screen only exist on the device. When a change needs testing +against that real hardware, you can run your own code on the PiFinder directly, +without building and flashing an image for every edit. + +The shipped software sits read-only in the Nix store, and +``/home/pifinder/PiFinder`` is a symlink pointing at it. Repoint that symlink at a +writable copy of your code and the app runs your files instead, using the Python +interpreter and libraries already installed on the device. The service follows +the symlink into your checkout, so the loop is just edit, restart, look — no +rebuild. + +Connect to the PiFinder over SSH, then: + +1. Stop the running app. It starts automatically at boot, and only one instance + can use the hardware at a time: + + .. code-block:: bash + + sudo systemctl stop pifinder + +2. Get a copy of your fork into the ``pifinder`` home directory, under any name + except ``PiFinder`` itself — that's the symlink you're about to move. The + device carries ``git`` and ``rsync``, so clone your fork directly: + + .. code-block:: bash + + git clone --depth 1 https://github.com//PiFinder.git PiFinder-dev + + The checkout includes the bundled catalog data, so it's a few hundred + megabytes; ``--depth 1`` keeps the Git history lean. If you'd rather edit on + your desktop, ``rsync`` the changed files over between runs: + + .. code-block:: bash + + rsync -a --exclude .git ./ pifinder@pifinder.local:PiFinder-dev/ + +3. Note where the symlink currently points, so you can get back to the shipped + code later, then aim it at your copy: + + .. code-block:: bash + + readlink /home/pifinder/PiFinder # save this store path + ln -sfT /home/pifinder/PiFinder-dev /home/pifinder/PiFinder + +4. Start the app again. It now runs your code: + + .. code-block:: bash + + sudo systemctl start pifinder + +From here the cycle is quick. Edit a file — ``vim`` is on the device, or re-copy +it from your desktop — then restart the app and follow its log: + +.. code-block:: bash + + sudo systemctl restart pifinder + journalctl -u pifinder -f + +For verbose, interactive output (the ``-x`` flag and the other switches above), +stop the service instead and run the app in the foreground from your copy's +``python`` folder, exactly as in `Running/Debugging from the command line`_. + +For work that changes Python dependencies or needs development tools, enter the +repository's complete Nix development environment first: + +.. code-block:: bash + + cd /home/pifinder/PiFinder-dev + nix develop + +The flake provides this shell for both desktop Linux and the PiFinder's +``aarch64-linux`` system. It uses the checked-in ``flake.lock``, +``python/pyproject.toml`` and ``python/uv.lock`` and supplies the same native +``libcamera`` and GObject bindings as the service. CI publishes the aarch64 +shell dependency closure for testable PRs and releases to the PiFinder binary +caches. + +Why ``nix develop`` here instead of the desktop's preferred ``direnv allow``? +The released device does not currently ship direnv. More importantly, the +current dev-shell output still includes the PiFinder project source: editing a +file changes that output's store hash even when none of its dependencies +changed. ``--option max-jobs 0`` would therefore reject an ordinary edited +checkout whose exact source-dependent output cannot already exist in a cache. +The intended end state is to make the shell dependency-only and ship direnv on +the device; at that point ``direnv allow`` can be the identical entry point on +desktop and PiFinder while the editable source remains outside the cached +environment. + +.. note:: + + This override is deliberately temporary. A reboot or an over-the-air software + update re-runs the device's activation step, which restores + ``/home/pifinder/PiFinder`` to the shipped store path. To return to the + released software at any time, reboot — or repoint the symlink at the path you + saved with ``readlink``. + +.. note:: + + Repointing the symlink runs your code against the image's existing Python + environment, which is the fastest path for pure-Python edits. Use ``nix + develop`` when changing the environment itself; do not create a separate + ``uv venv`` on the device, because that omits native bindings supplied by + Nix. A dependency you intend to keep belongs in ``python/pyproject.toml`` + with an updated ``uv.lock`` and ultimately in a new image (see `Beta + Testing`_) so every device runs the same tested environment. + + Troubleshooting --------------- @@ -694,6 +783,3 @@ Finally, you can start straight into this mode from the command line — see the .. image:: images/user_guide/DEMO_MODE_001_docs.png .. image:: images/user_guide/DEMO_MODE_002_docs.png - - - diff --git a/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..a1c1cf7c4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,465 @@ +{ + 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 nixos-hardware pyproject-nix uv2nix pyproject-build-systems; }; + crossPkgsAarch64 = import nixpkgs { + localSystem = "x86_64-linux"; + crossSystem = "aarch64-linux"; + }; + pifinderCrossKernel = import ./nixos/pkgs/pifinder-kernel.nix { + pkgs = crossPkgsAarch64; + inherit nixos-hardware; + }; + # 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, kernel ? null }: + nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + # pifinderKernel must always be present in specialArgs: a NixOS module's + # `arg ? default` formal is not honoured by the module system, so an + # absent arg fails evaluation. null selects the natively-built patched + # kernel; a non-null value injects a prebuilt (e.g. cross-built) one. + specialArgs = pythonInputs // { pifinderKernel = kernel; }; + 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"; + specialArgs = pythonInputs // { pifinderKernel = null; }; + 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 // { pifinderKernel = null; }; + modules = commonModules ++ [ + { pifinder.devMode = true; } + { pifinder.cameraType = nixpkgs.lib.mkDefault "imx477"; } # HQ camera for netboot dev + # Camera specialisations for netboot (base is imx477) + ({ ... }: { + specialisation = { + imx296.configuration = { pifinder.cameraType = "imx296"; }; + imx462.configuration = { pifinder.cameraType = "imx462"; }; + }; + }) + ({ lib, pkgs, ... }: + let + boot-splash = import ./nixos/pkgs/boot-splash.nix { inherit pkgs; }; + in { + # Static passwd/group — NFS can't run activation scripts + users.mutableUsers = false; + # DNS for netboot (udhcpc doesn't configure resolvconf properly) + networking.nameservers = [ "192.168.5.1" "8.8.8.8" ]; + boot.supportedFilesystems = lib.mkForce [ "vfat" "ext4" "nfs" ]; + boot.initrd.supportedFilesystems = [ "nfs" ]; + # Add SPI kernel module for early OLED splash + boot.initrd.kernelModules = [ "spi_bcm2835" ]; + # Override the minimal module list from commonModules — add network drivers + # Note: genet (RPi4 ethernet) is built into the kernel, not a module + boot.initrd.availableKernelModules = lib.mkForce [ + "mmc_block" "usbhid" "usb_storage" "vc4" + ]; + # Add boot-splash to initrd + boot.initrd.extraUtilsCommands = '' + copy_bin_and_libs ${boot-splash}/bin/boot-splash + ''; + # Disable predictable interface names so eth0 works + boot.kernelParams = [ "net.ifnames=0" "biosdevname=0" ]; + boot.initrd.network = { + enable = true; + }; + # Show static splash, then configure network + boot.initrd.postDeviceCommands = '' + # Create device nodes for SPI OLED + mkdir -p /dev + mknod -m 666 /dev/spidev0.0 c 153 0 2>/dev/null || true + mknod -m 666 /dev/gpiochip0 c 254 0 2>/dev/null || true + + # Show static splash image (--static flag = display once and exit) + boot-splash --static || true + # Wait for interface to appear (up to 30 seconds) + echo "Waiting for eth0..." + for i in $(seq 1 60); do + if ip link show eth0 >/dev/null 2>&1; then + echo "eth0 found after $i attempts" + break + fi + sleep 0.5 + done + + ip link set eth0 up + + # Wait for link carrier (cable connected) + echo "Waiting for link carrier..." + for i in $(seq 1 20); do + if [ "$(cat /sys/class/net/eth0/carrier 2>/dev/null)" = "1" ]; then + echo "Link up after $i attempts" + break + fi + sleep 0.5 + done + + # DHCP with retries + echo "Starting DHCP..." + for attempt in 1 2 3; do + if udhcpc -i eth0 -t 5 -T 3 -n -q -s /etc/udhcpc.script; then + echo "DHCP succeeded on attempt $attempt" + break + fi + echo "DHCP attempt $attempt failed, retrying..." + sleep 2 + done + + # Verify we got an IP + if ip addr show eth0 | grep -q "inet "; then + echo "Network configured:" + ip addr show eth0 + else + echo "WARNING: No IP address on eth0!" + ip addr show eth0 + fi + ''; + # NFS root filesystem - NFSv4 with disabled caching for Nix compatibility + fileSystems."/" = { + device = "192.168.5.12:/srv/nfs/pifinder"; + fsType = "nfs"; + options = [ "vers=4" "noac" "actimeo=0" ]; + }; + # Dummy /boot — not used for netboot but NixOS requires it + fileSystems."/boot" = { + device = "none"; + fsType = "tmpfs"; + neededForBoot = false; + }; + }) + ]; + }; + # Custom u-boot variants + pkgsAarch64 = import nixpkgs { system = "aarch64-linux"; }; + # SD boot: skip PCI/USB/net probe, go straight to mmc extlinux + ubootSD = pkgsAarch64.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_CMD_PXE=y + CONFIG_CMD_SYSBOOT=y + CONFIG_BOOTDELAY=0 + CONFIG_PREBOOT="" + CONFIG_BOOTCOMMAND="sysboot mmc 0:2 any 0x02400000 /boot/extlinux/extlinux.conf" + CONFIG_PCI=n + CONFIG_USB=n + CONFIG_CMD_USB=n + CONFIG_CMD_PCI=n + CONFIG_USB_KEYBOARD=n + CONFIG_BCMGENET=n + ''; + }; + # Netboot: PCI + DHCP + PXE + ubootNetboot = pkgsAarch64.ubootRaspberryPi4_64bit.override { + extraConfig = '' + CONFIG_BOOTCOMMAND="pci enum; dhcp; pxe get; pxe boot" + ''; + }; + + configTxt = pkgsAarch64.writeText "config.txt" '' + [pi3] + kernel=u-boot-rpi3.bin + + [pi02] + kernel=u-boot-rpi3.bin + + [pi4] + kernel=u-boot-rpi4.bin + enable_gic=1 + armstub=armstub8-gic.bin + + disable_overscan=1 + arm_boost=1 + + [cm4] + otg_mode=1 + + [all] + arm_64bit=1 + enable_uart=1 + avoid_warnings=1 + ''; + + # Reproducible development environment for both desktop Linux and the + # aarch64 PiFinder itself. Runtime-native bindings come from Nix, just as + # they do in the systemd service; uv supplies the locked Python workspace. + mkDevShell = system: let + pkgs = import nixpkgs { + inherit system; + overlays = [(final: prev: { + libcamera = prev.libcamera.overrideAttrs (old: { + mesonFlags = (old.mesonFlags or []) ++ [ "-Dpycamera=enabled" ]; + buildInputs = (old.buildInputs or []) ++ [ + final.python313 + final.python313.pkgs.pybind11 + ]; + }); + })]; + }; + pyPkgs = import ./nixos/pkgs/uv-python.nix { + inherit pkgs pyproject-nix uv2nix pyproject-build-systems; + }; + cedar-detect = import ./nixos/pkgs/cedar-detect.nix { inherit pkgs; }; + in pkgs.mkShell { + packages = [ + pyPkgs.devEnv + pkgs.bashInteractive + pkgs.ruff + pkgs.uv + pkgs.git + pkgs.rsync + pkgs.gobject-introspection + pkgs.networkmanager + pkgs.libcamera + pkgs.gpsd + cedar-detect + ]; + shellHook = '' + export PYTHONPATH="${pkgs.libcamera}/lib/python3.13/site-packages:$PYTHONPATH" + export GI_TYPELIB_PATH="${pkgs.lib.makeSearchPath "lib/girepository-1.0" [ + pkgs.networkmanager + pkgs.glib.out + pkgs.gobject-introspection + ]}:$GI_TYPELIB_PATH" + export LIBCAMERA_IPA_MODULE_PATH="${pkgs.libcamera}/lib/libcamera" + ''; + }; + + in { + nixosConfigurations = { + # SD card boot — camera baked into DT, switched via specialisations + pifinder = mkPifinderSystem {}; + # Cache-compatible aarch64 userspace with a kernel cross-built on x86_64. + pifinder-fast = mkPifinderSystem { kernel = pifinderCrossKernel; }; + # 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 + ''; + }; + + packages.x86_64-linux.pifinder-kernel-cross = pifinderCrossKernel; + + devShells = { + x86_64-linux.default = mkDevShell "x86_64-linux"; + aarch64-linux.default = mkDevShell "aarch64-linux"; + }; + + devShells.aarch64-darwin.default = let + pkgs = import nixpkgs { system = "aarch64-darwin"; }; + pyPkgs = import ./nixos/pkgs/uv-python-darwin.nix { + inherit pkgs pyproject-nix uv2nix pyproject-build-systems; + }; + cedar-detect = import ./nixos/pkgs/cedar-detect.nix { inherit pkgs; }; + in pkgs.mkShell { + packages = [ pyPkgs.devEnv pkgs.ruff pkgs.uv cedar-detect ]; + }; + }; +} diff --git a/markers/mrk_asteroid.png b/markers/mrk_asteroid.png new file mode 100644 index 000000000..37ccc4805 Binary files /dev/null and b/markers/mrk_asteroid.png differ 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..09d2ea8b0 --- /dev/null +++ b/nixos/hardware.nix @@ -0,0 +1,226 @@ +{ config, lib, pkgs, nixos-hardware, pifinderKernel ? null, ... }: +let + cfg = config.pifinder; + + # Camera overlay name mapping. imx462 deliberately uses the imx290 overlay: + # that is the exact configuration PiFinder ships on Raspbian + # (switch_camera.py writes "dtoverlay=imx290,clock-frequency=74250000"), so + # the sony,imx290lqr driver path is field-proven on this sensor. The kernel + # also ships imx462.dtbo (sony,imx462lqr, dedicated init registers) — an + # untested-here alternative. The clock-frequency parameter is the part that + # actually matters; see cameraClockDtbo below. + cameraDriver = { + imx296 = "imx296"; + imx462 = "imx290"; + imx477 = "imx477"; + }.${cfg.cameraType}; + + # Compile DTS text to DTBO + compileOverlay = name: dtsText: pkgs.deviceTree.compileDTS { + name = "${name}-dtbo"; + dtsFile = pkgs.writeText "${name}.dts" dtsText; + }; + + # SPI0 — no nixos-hardware option, use custom overlay + spi0Dtbo = compileOverlay "spi0" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &spi0 { status = "okay"; }; + ''; + + # UART3 for the on-board GPS (published as /dev/gpsuart by udev) + uart3Dtbo = compileOverlay "uart3" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &uart3 { status = "okay"; }; + ''; + + # Peripheral I2C (BNO055 IMU, rev-4 BQ25895 charger) as a bit-banged + # i2c-gpio bus on the standard SDA/SCL pins (GPIO2/GPIO3). The BCM2711 + # hardware I2C block corrupts transfers when a slave stretches the clock + # (a silicon bug; the BNO055 stretches routinely) — the previous + # workaround, running &i2c1 at 10 kHz, only lowered the corruption odds + # while making every IMU transaction ~10x slower. i2c-gpio implements + # clock stretching per spec at ~60-100 kHz effective. &i2c1 is disabled + # explicitly so the hardware block never claims the pins; the Python + # side (PiFinder.i2c_bus.get_i2c) discovers this adapter through sysfs. + i2cGpioDtbo = compileOverlay "i2c-gpio" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &i2c1 { status = "disabled"; }; + &{/} { + i2c_gpio: i2c-gpio { + compatible = "i2c-gpio"; + sda-gpios = <&gpio 2 6>; /* GPIO_OPEN_DRAIN */ + scl-gpios = <&gpio 3 6>; /* GPIO_OPEN_DRAIN */ + i2c-gpio,delay-us = <2>; + #address-cells = <1>; + #size-cells = <0>; + }; + }; + ''; + + # PWM: GPIO 13 (channel 1) keypad backlight + GPIO 12 (channel 0) rev-4 + # buzzer earcons. Both ALT0 (function 4): GPIO12 = PWM0_0, GPIO13 = PWM0_1. + # Muxing GPIO12 unconditionally is safe — rev-3 boards leave it unconnected + # and the sound process only spawns when the rev-4 charger is detected. + pwmDtbo = compileOverlay "pwm" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &gpio { + pwm_pins: pwm_pins { + brcm,pins = <12 13>; + brcm,function = <4 4>; /* ALT0 = PWM0_0, PWM0_1 */ + }; + }; + &pwm { + status = "okay"; + pinctrl-names = "default"; + pinctrl-0 = <&pwm_pins>; + }; + ''; + + # Rev-4 power-off latch (ADR 0007 on main): driving GPIO14 low trips the + # LTC2954 and cuts power. The kernel's gpio-poweroff handler runs strictly + # after filesystems are down, and only on a real power-off (reboot takes a + # different path). Active-low; the board's hardware pull-up on GPIO14 holds + # power on until the handler fires. Deliberately unconditional across + # revisions: on rev-3 GPIO14-low does nothing electrically — the only effect + # is a cosmetic kernel WARN + ~3s wait at halt. + gpioPoweroffDtbo = compileOverlay "gpio-poweroff" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &{/} { + power_ctrl: power_ctrl { + compatible = "gpio-poweroff"; + gpios = <&gpio 14 1>; /* GPIO_ACTIVE_LOW */ + timeout-ms = <3000>; + }; + }; + ''; + + # Camera overlay from kernel's DTB overlays directory + cameraDtbo = "${config.boot.kernelPackages.kernel}/dtbs/overlays/${cameraDriver}.dtbo"; + + # PiFinder's imx462 module has a 74.25 MHz oscillator, but the kernel's + # imx290/imx462 overlays default the xclk to 37.125 MHz. Raspbian fixes this + # with the overlay parameter "clock-frequency=74250000"; fdtoverlay cannot + # apply overlay parameters (__overrides__), so without this the default + # sneaks through and the driver programs INCKSEL/PLL for the wrong xclk — + # the sensor enumerates on I2C but never delivers frames and libcamera + # reports "Camera frontend has timed out" with an empty kernel log. + # Override both places the Raspbian parameter writes: the fixed-clock node + # (the driver's clk_set_rate must match its rate) and the sensor node + # property (the driver selects the INCKSEL register set from it). Must be + # applied after cameraDtbo: &cam_node is defined by the camera overlay and + # resolves from its merged symbols. + cameraClockDtbo = compileOverlay "imx462-xclk" '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &cam1_clk { clock-frequency = <74250000>; }; + &cam_node { clock-frequency = <74250000>; }; + ''; +in { + options.pifinder = { + cameraType = lib.mkOption { + type = lib.types.enum [ "imx296" "imx462" "imx477" ]; + default = "imx462"; + description = "Camera sensor type for PiFinder"; + }; + }; + + config = { + # The nixos-hardware Raspberry Pi kernel expression fixes its patch list + # after normal package overrides, so boot.kernelPatches cannot extend it. + boot.kernelPackages = lib.mkForce (pkgs.linuxPackagesFor ( + if pifinderKernel != null then pifinderKernel else + import ./pkgs/pifinder-kernel.nix { inherit pkgs nixos-hardware; } + )); + + # 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; + # The bit-banged bus driver (DT modalias autoload also works when built + # as a module; listing it here makes the dependency explicit) + boot.kernelModules = [ "i2c-gpio" ]; + + # GPIO14 is the rev-4 power-off kill line (gpio-poweroff overlay above) — + # its default function is UART0 TXD, so nothing may drive serial console + # bytes onto it (ADR 0007). No console= kernel param points there, and this + # keeps a getty from ever claiming the port. + systemd.services."serial-getty@ttyAMA0".enable = false; + + # Apply all DT overlays via fdtoverlay, bypassing NixOS apply_overlays.py + # which rejects RPi camera overlays due to compatible string mismatch + # (overlays declare "brcm,bcm2835" but kernel DTBs use "brcm,bcm2711") + hardware.deviceTree.package = let + kernelDtbs = config.hardware.deviceTree.dtbSource; + in lib.mkForce (pkgs.runCommand "device-tree-with-overlays" { + nativeBuildInputs = [ pkgs.dtc ]; + } '' + mkdir -p $out/broadcom + for dtb in ${kernelDtbs}/broadcom/*rpi-4-b.dtb; do + fdtoverlay -i "$dtb" \ + -o "$out/broadcom/$(basename $dtb)" \ + ${i2cGpioDtbo} ${spi0Dtbo} ${uart3Dtbo} ${pwmDtbo} ${gpioPoweroffDtbo} ${cameraDtbo} \ + ${lib.optionalString (cfg.cameraType == "imx462") cameraClockDtbo} + done + ''); + + # udev rules for hardware access without root + services.udev.extraRules = '' + SUBSYSTEM=="spidev", GROUP="spi", MODE="0660" + SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660" + SUBSYSTEM=="pwm", GROUP="gpio", MODE="0660" + SUBSYSTEM=="gpio", GROUP="gpio", MODE="0660" + KERNEL=="gpiomem", GROUP="gpio", MODE="0660" + # On-board GPS UART (uart3, fe201600 on BCM2711): the kernel's ttyAMA + # numbering is not stable across versions, so match the DT node and + # publish a fixed name for gpsd to open. + SUBSYSTEM=="tty", KERNELS=="fe201600.serial", SYMLINK+="gpsuart", GROUP="dialout", MODE="0660", TAG+="systemd", ENV{SYSTEMD_WANTS}+="gpsd-add-uart.service" + # DMA heap for libcamera/picamera2 (CMA memory allocation) + SUBSYSTEM=="dma_heap", GROUP="video", MODE="0660" + ''; + + # Deterministic root password (sha-512 crypt of "solveit"), enforced on + # every activation — unlike initialPassword, which only applies at account + # creation and drifts if changed at runtime. Test-device convenience; the + # hash lives in the world-readable store, which is fine for a known cred. + users.users.root.hashedPassword = + "$6$caME5a7TbhnPfrV2$sXHx/OuQCaRkjCG/Lba8vxL5R8.SgD72YHKWzHwDVj9CfDgz1xJ766ht0VCB18Q/igzceaoQM8fwgYNj2ygap/"; + users.users.pifinder = { + isNormalUser = true; + # MUST stay initialPassword (not hashedPassword): the web UI changes this + # password at runtime via `sudo chpasswd` (sys_utils.change_password). + # initialPassword applies only at account creation, so that change + # persists; hashedPassword would re-enforce "solveit" on every activation + # and silently revert the user's password on the next upgrade. + initialPassword = "solveit"; + extraGroups = [ "spi" "i2c" "gpio" "dialout" "video" "networkmanager" "systemd-journal" "input" "kmem" ]; + }; + users.groups = { + spi = {}; + i2c = {}; + gpio = {}; + }; + }; +} diff --git a/nixos/networking.nix b/nixos/networking.nix new file mode 100644 index 000000000..8115945bb --- /dev/null +++ b/nixos/networking.nix @@ -0,0 +1,136 @@ +{ config, lib, pkgs, ... }: +{ + networking = { + hostName = "pifinder"; + networkmanager.enable = true; + wireless.enable = false; # NetworkManager handles WiFi + firewall = { + checkReversePath = "loose"; # Allow multi-interface (WiFi + ethernet) on same subnet + allowedUDPPorts = [ 53 67 ]; # DNS + DHCP for AP mode + # Web UI and the LX200 server used by SkySafari/planetarium clients. + allowedTCPPorts = [ 80 4030 ]; + }; + }; + + # Robust time sync for the RTC-less Pi: NTP= servers are always tried (and + # combined with any per-interface/DHCP servers), so a dead DHCP-advertised + # NTP server can't block the clock. FallbackNTP alone is skipped whenever a + # per-interface server is known — too fragile to rely on for first-boot + # migration, which gates the binary-cache fetch on a synchronized clock. + services.timesyncd.servers = [ + "0.pool.ntp.org" + "1.pool.ntp.org" + "2.pool.ntp.org" + "3.pool.ntp.org" + ]; + + # dnsmasq for NetworkManager AP shared mode (DHCP for AP clients) + services.dnsmasq.enable = false; # NM manages its own dnsmasq instance + environment.systemPackages = [ pkgs.dnsmasq ]; + + # Wired ethernet with DHCP (autoconnect) + environment.etc."NetworkManager/system-connections/Wired.nmconnection" = { + text = '' + [connection] + id=Wired + type=ethernet + autoconnect=true + + [ipv4] + method=auto + + [ipv6] + method=auto + ''; + mode = "0600"; + }; + + # Pre-configured AP profile (activated on demand via nmcli) + environment.etc."NetworkManager/system-connections/PiFinder-AP.nmconnection" = { + text = '' + [connection] + id=PiFinder-AP + type=wifi + # Never self-start: NetworkManager would activate the AP instantly at + # boot (own radio, no scan needed) and win the race against a client + # network that still has to scan + associate, then stay on it. The AP is + # brought up only on demand by pifinder-wifi-fallback below. + autoconnect=false + + [wifi] + mode=ap + ssid=PiFinderAP + band=bg + channel=7 + + [ipv4] + method=shared + address1=10.10.10.1/24 + + [ipv6] + method=disabled + ''; + mode = "0600"; + }; + + # The PiFinder-AP profile has autoconnect disabled, so NetworkManager joins + # a known client network when one is reachable and never self-starts the AP. + # AP-as-fallback policy (wired > wifi client > AP) is enforced by + # pifinder-net-policy in services.nix (full system) or by + # wifi-fallback-minimal.nix (migration image, no Python env) — this shared + # module deliberately carries no fallback service so the two can differ. + + # --------------------------------------------------------------------------- + # Avahi/mDNS for hostname discovery (.local). It lives in this shared + # networking module on purpose: BOTH the running system (commonModules) and + # the migration build (migrationModules) import networking.nix, whereas + # services.nix and device.nix are each only in one of those — so avahi must + # not live in either alone or one system ends up with no mDNS at all. + # --------------------------------------------------------------------------- + services.avahi = { + enable = true; + # PiFinder's application listeners (including LX200) are IPv4-only and AP + # mode disables IPv6. Do not advertise an unusable link-local AAAA address + # ahead of the working IPv4 address to mobile clients. + ipv6 = false; + nssmdns4 = true; + publish = { + enable = true; + addresses = true; + domain = true; + workstation = true; + }; + }; + + systemd.services.avahi-daemon.serviceConfig.ExecStartPre = + "${pkgs.coreutils}/bin/rm -f /run/avahi-daemon/pid"; + + # Apply user-chosen hostname from PiFinder_data (survives NixOS rebuilds), + # overriding networking.hostName above. + systemd.services.pifinder-hostname = { + description = "Apply PiFinder custom hostname"; + after = [ "avahi-daemon.service" ]; + wants = [ "avahi-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "apply-hostname" '' + f=/home/pifinder/PiFinder_data/hostname + [ -f "$f" ] || exit 0 + name=$(cat "$f") + [ -n "$name" ] || exit 0 + /run/current-system/sw/bin/hostname "$name" + /run/current-system/sw/bin/avahi-set-host-name "$name" || \ + /run/current-system/sw/bin/systemctl restart avahi-daemon.service + ''; + }; + }; + + # Avahi watches interface/address changes itself, so it must remain running + # while NetworkManager brings links up and down. Restarting it from a + # dispatcher briefly withdraws the .local record and also resets a custom + # runtime hostname to the static value above. NetworkManager must not manage + # the hostname either, or it undoes pifinder-hostname's persisted value. + networking.networkmanager.settings.main."hostname-mode" = "none"; +} diff --git a/nixos/patches/imx290-optical-black-stream.patch b/nixos/patches/imx290-optical-black-stream.patch new file mode 100644 index 000000000..10f3e815a --- /dev/null +++ b/nixos/patches/imx290-optical-black-stream.patch @@ -0,0 +1,136 @@ +--- a/drivers/media/i2c/imx290.c ++++ b/drivers/media/i2c/imx290.c +@@ -163,6 +163,13 @@ + + /* Equivalent value for 16bpp */ + #define IMX290_BLACK_LEVEL_DEFAULT 3840 ++#define IMX290_NUM_OPB_LINES 10 ++ ++enum imx290_pad { ++ IMX290_IMAGE_PAD, ++ IMX290_METADATA_PAD, ++ IMX290_NUM_PADS, ++}; + + #define IMX290_NUM_SUPPLIES 3 + +@@ -245,7 +252,7 @@ + const struct imx290_model_info *model; + + struct v4l2_subdev sd; +- struct media_pad pad; ++ struct media_pad pads[IMX290_NUM_PADS]; + + const struct imx290_mode *current_mode; + +@@ -1132,6 +1139,16 @@ + { + const struct imx290 *imx290 = to_imx290(sd); + ++ if (code->pad >= IMX290_NUM_PADS) ++ return -EINVAL; ++ ++ if (code->pad == IMX290_METADATA_PAD) { ++ if (code->index) ++ return -EINVAL; ++ code->code = MEDIA_BUS_FMT_SENSOR_DATA; ++ return 0; ++ } ++ + if (code->index >= ARRAY_SIZE(imx290_formats)) + return -EINVAL; + +@@ -1147,6 +1164,25 @@ + const struct imx290 *imx290 = to_imx290(sd); + const struct imx290_mode *imx290_modes = imx290_modes_ptr(imx290); + ++ if (fse->pad >= IMX290_NUM_PADS) ++ return -EINVAL; ++ ++ if (fse->pad == IMX290_METADATA_PAD) { ++ const struct v4l2_mbus_framefmt *format; ++ const struct imx290_format_info *info; ++ ++ if (fse->code != MEDIA_BUS_FMT_SENSOR_DATA || fse->index) ++ return -EINVAL; ++ ++ format = v4l2_subdev_state_get_format(sd_state, IMX290_IMAGE_PAD); ++ info = imx290_format_info(imx290, format->code); ++ fse->min_width = format->width * (info ? info->bpp : 12) / 8; ++ fse->max_width = fse->min_width; ++ fse->min_height = IMX290_NUM_OPB_LINES; ++ fse->max_height = fse->min_height; ++ return 0; ++ } ++ + if (!imx290_format_info(imx290, fse->code)) + return -EINVAL; + +@@ -1168,6 +1198,22 @@ + struct imx290 *imx290 = to_imx290(sd); + const struct imx290_mode *mode; + struct v4l2_mbus_framefmt *format; ++ const struct imx290_format_info *info; ++ ++ if (fmt->pad >= IMX290_NUM_PADS) ++ return -EINVAL; ++ ++ if (fmt->pad == IMX290_METADATA_PAD) { ++ format = v4l2_subdev_state_get_format(sd_state, IMX290_IMAGE_PAD); ++ info = imx290_format_info(imx290, format->code); ++ fmt->format.width = format->width * (info ? info->bpp : 12) / 8; ++ fmt->format.height = IMX290_NUM_OPB_LINES; ++ fmt->format.code = MEDIA_BUS_FMT_SENSOR_DATA; ++ fmt->format.field = V4L2_FIELD_NONE; ++ *v4l2_subdev_state_get_format(sd_state, IMX290_METADATA_PAD) = ++ fmt->format; ++ return 0; ++ } + + mode = v4l2_find_nearest_size(imx290_modes_ptr(imx290), + imx290_modes_num(imx290), width, height, +@@ -1185,7 +1231,7 @@ + fmt->format.quantization = V4L2_QUANTIZATION_FULL_RANGE; + fmt->format.xfer_func = V4L2_XFER_FUNC_NONE; + +- format = v4l2_subdev_state_get_format(sd_state, 0); ++ format = v4l2_subdev_state_get_format(sd_state, IMX290_IMAGE_PAD); + + if (fmt->which == V4L2_SUBDEV_FORMAT_ACTIVE) { + imx290->current_mode = mode; +@@ -1196,6 +1242,13 @@ + + *format = fmt->format; + ++ format = v4l2_subdev_state_get_format(sd_state, IMX290_METADATA_PAD); ++ info = imx290_format_info(imx290, fmt->format.code); ++ format->width = fmt->format.width * info->bpp / 8; ++ format->height = IMX290_NUM_OPB_LINES; ++ format->code = MEDIA_BUS_FMT_SENSOR_DATA; ++ format->field = V4L2_FIELD_NONE; ++ + return 0; + } + +@@ -1260,6 +1313,8 @@ + }; + + imx290_set_fmt(subdev, sd_state, &fmt); ++ fmt.pad = IMX290_METADATA_PAD; ++ imx290_set_fmt(subdev, sd_state, &fmt); + + return 0; + } +@@ -1311,8 +1366,10 @@ + imx290->sd.entity.ops = &imx290_subdev_entity_ops; + imx290->sd.entity.function = MEDIA_ENT_F_CAM_SENSOR; + +- imx290->pad.flags = MEDIA_PAD_FL_SOURCE; +- ret = media_entity_pads_init(&imx290->sd.entity, 1, &imx290->pad); ++ imx290->pads[IMX290_IMAGE_PAD].flags = MEDIA_PAD_FL_SOURCE; ++ imx290->pads[IMX290_METADATA_PAD].flags = MEDIA_PAD_FL_SOURCE; ++ ret = media_entity_pads_init(&imx290->sd.entity, IMX290_NUM_PADS, ++ imx290->pads); + if (ret < 0) { + dev_err(imx290->dev, "Could not register media entity\n"); + return ret; diff --git a/nixos/patches/libcamera-imx290-optical-black.patch b/nixos/patches/libcamera-imx290-optical-black.patch new file mode 100644 index 000000000..140ee9d3f --- /dev/null +++ b/nixos/patches/libcamera-imx290-optical-black.patch @@ -0,0 +1,136 @@ +--- a/src/ipa/rpi/cam_helper/cam_helper_imx290.cpp ++++ b/src/ipa/rpi/cam_helper/cam_helper_imx290.cpp +@@ -5,9 +5,12 @@ + * camera helper for imx290 sensor + */ + ++#include ++#include + #include + + #include "cam_helper.h" ++#include "controller/optical_black_status.h" + + using namespace RPiController; + +@@ -17,6 +20,9 @@ + CamHelperImx290(); + uint32_t gainCode(double gain) const override; + double gain(uint32_t gainCode) const override; ++ bool sensorEmbeddedDataPresent() const override; ++ void prepare(libcamera::Span buffer, ++ Metadata &metadata) override; + unsigned int hideFramesStartup() const override; + unsigned int hideFramesModeSwitch() const override; + +@@ -44,6 +50,72 @@ + return std::pow(10, 0.015 * gainCode); + } + ++bool CamHelperImx290::sensorEmbeddedDataPresent() const ++{ ++ return true; ++} ++ ++void CamHelperImx290::prepare(libcamera::Span buffer, ++ Metadata &metadata) ++{ ++ std::array histogram{}; ++ size_t count = 0; ++ const unsigned int bits = mode_.bitdepth; ++ const size_t expected = mode_.width * bits / 8 * 10; ++ const size_t size = buffer.size() < expected ? buffer.size() : expected; ++ ++ if (bits == 12) { ++ for (size_t i = 0; i + 2 < size; i += 3) { ++ histogram[(buffer[i] << 4) | (buffer[i + 2] & 0x0f)]++; ++ histogram[(buffer[i + 1] << 4) | (buffer[i + 2] >> 4)]++; ++ count += 2; ++ } ++ } else if (bits == 10) { ++ for (size_t i = 0; i + 4 < size; i += 5) { ++ histogram[(buffer[i] << 2) | (buffer[i + 4] & 0x03)]++; ++ histogram[(buffer[i + 1] << 2) | ((buffer[i + 4] >> 2) & 0x03)]++; ++ histogram[(buffer[i + 2] << 2) | ((buffer[i + 4] >> 4) & 0x03)]++; ++ histogram[(buffer[i + 3] << 2) | (buffer[i + 4] >> 6)]++; ++ count += 4; ++ } ++ } ++ ++ if (!count) ++ return; ++ ++ /* ++ * A raw-code median is quantised to a whole ADU, hiding the sub-ADU ++ * exposure and temperature signal available from thousands of shielded ++ * pixels. Average the central 90% of the OB distribution. Percentile ++ * trimming adapts to gain-dependent read-noise width while rejecting hot ++ * and cold tails. SensorBlackLevels' 16-bit scale preserves 1/16 ADU for ++ * RAW12. ++ */ ++ const size_t trim = count / 20; ++ const size_t target = count - 2 * trim; ++ size_t skipped = 0; ++ size_t kept = 0; ++ uint64_t sum = 0; ++ for (unsigned int value = 0; value < histogram.size() && kept < target; ++ value++) { ++ size_t bin = histogram[value]; ++ const size_t drop = std::min(bin, trim - skipped); ++ bin -= drop; ++ skipped += drop; ++ const size_t take = std::min(bin, target - kept); ++ sum += static_cast(value) * take; ++ kept += take; ++ } ++ if (!kept) ++ return; ++ const double mean = static_cast(sum) / kept; ++ const uint16_t level = std::lround(mean * (1 << (16 - bits))); ++ if (level < 1024 || level > 16384) ++ return; ++ ++ metadata.set("optical_black.status", OpticalBlackStatus{ level }); ++} ++ + unsigned int CamHelperImx290::hideFramesStartup() const + { + /* On startup, we seem to get 1 bad frame. */ +--- a/src/ipa/rpi/common/ipa_base.cpp ++++ b/src/ipa/rpi/common/ipa_base.cpp +@@ -27,6 +27,7 @@ + #include "controller/denoise_algorithm.h" + #include "controller/hdr_algorithm.h" + #include "controller/lux_status.h" ++#include "controller/optical_black_status.h" + #include "controller/sharpen_algorithm.h" + #include "controller/statistics.h" + +@@ -1547,7 +1548,15 @@ + } + + BlackLevelStatus *blackLevelStatus = rpiMetadata.getLocked("black_level.status"); +- if (blackLevelStatus) ++ OpticalBlackStatus *opticalBlackStatus = ++ rpiMetadata.getLocked("optical_black.status"); ++ if (opticalBlackStatus) ++ libcameraMetadata_.set(controls::SensorBlackLevels, ++ { static_cast(opticalBlackStatus->level), ++ static_cast(opticalBlackStatus->level), ++ static_cast(opticalBlackStatus->level), ++ static_cast(opticalBlackStatus->level + 1) }); ++ else if (blackLevelStatus) + libcameraMetadata_.set(controls::SensorBlackLevels, + { static_cast(blackLevelStatus->blackLevelR), + static_cast(blackLevelStatus->blackLevelG), +--- /dev/null ++++ b/src/ipa/rpi/controller/optical_black_status.h +@@ -0,0 +1,8 @@ ++/* SPDX-License-Identifier: BSD-2-Clause */ ++#pragma once ++ ++#include ++ ++struct OpticalBlackStatus { ++ uint16_t level; ++}; 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/gaia-stars.nix b/nixos/pkgs/gaia-stars.nix new file mode 100644 index 000000000..ddd197acd --- /dev/null +++ b/nixos/pkgs/gaia-stars.nix @@ -0,0 +1,20 @@ +{ pkgs }: + +# Gaia deep-chart star catalog (~454 MB compressed): metadata.json plus +# per-magnitude-band tiles (mag_XX_YY/{index,tiles}.bin), read-only at runtime. +# Fixed-output derivation so an unchanged catalog is never re-downloaded; +# referenced by the system closure (symlinked into PiFinder_data by services.nix) +# so fresh flashes and in-place upgrades both deliver it, like pifinder-src and +# astro_data. A changed catalog is a new store path the device fetches whole — +# Attic's chunk dedup saves server storage on re-upload, not device bandwidth. +pkgs.stdenv.mkDerivation { + pname = "pifinder-gaia-stars"; + version = "1.0"; + src = pkgs.fetchurl { + url = "https://files.miker.be/public/pifinder/gaia_stars.tar.zst"; + hash = "sha256-vmsOz7U0X4bnMZrcKjiwIk0YYy/AqRV2+fzaH7qO8wo="; + }; + nativeBuildInputs = [ pkgs.zstd ]; + unpackPhase = "tar xf $src"; + installPhase = "mv gaia_stars $out"; +} 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-kernel.nix b/nixos/pkgs/pifinder-kernel.nix new file mode 100644 index 000000000..05d4adf4f --- /dev/null +++ b/nixos/pkgs/pifinder-kernel.nix @@ -0,0 +1,21 @@ +{ pkgs, nixos-hardware }: + +pkgs.callPackage "${nixos-hardware}/raspberry-pi/common/kernel.nix" { + rpiVersion = 4; + argsOverride.kernelPatches = (with pkgs.kernelPatches; [ + bridge_stp_helper + request_key_helper + ]) ++ [ + { + name = "imx290-optical-black-stream"; + # builtins.path gives the patch its own content-addressed store path. + # A bare ../patches/… reference resolves inside the flake source tree, + # making the kernel derivation depend on the whole repo hash — every + # commit (even Python-only) would rebuild the kernel. + patch = builtins.path { + path = ../patches/imx290-optical-black-stream.patch; + name = "imx290-optical-black-stream.patch"; + }; + } + ]; +} diff --git a/nixos/pkgs/pifinder-src.nix b/nixos/pkgs/pifinder-src.nix new file mode 100644 index 000000000..6142d2f87 --- /dev/null +++ b/nixos/pkgs/pifinder-src.nix @@ -0,0 +1,71 @@ +{ 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 + # Development environments and caches can also live below python/. In + # particular, python/.venv is hundreds of MiB and must never become part + # of the runtime source closure. + rm -rf $out/python/.venv $out/python/.mypy_cache + rm -rf $out/python/.pytest_cache $out/python/.ruff_cache + find $out/python -type d -name __pycache__ -prune -exec rm -rf {} + + rm -rf $out/case $out/docs $out/gerbers $out/kicad + rm -rf $out/pi_config_files $out/scripts + rm -rf $out/bin + + # Strip doc photos from images/ but keep welcome.png (used at runtime) + find $out/images -type f ! -name 'welcome.png' -delete + + # Bulky, stable inputs live in their own derivations and are symlinked in, + # so a code change rewrites only the (small) code path — not astro-data + # (~193MB) or fonts (~31MB). See ADR 0001. + rm -rf $out/astro_data + ln -s ${astro-data} $out/astro_data + rm -rf $out/fonts + ln -s ${fonts} $out/fonts + + # Pre-compile .pyc bytecode so Python skips compilation at runtime + chmod -R u+w $out/python + python3 -m compileall -q $out/python + ''; +} diff --git a/nixos/pkgs/rpi-gpio-pi-detect.patch b/nixos/pkgs/rpi-gpio-pi-detect.patch new file mode 100644 index 000000000..eebe60114 --- /dev/null +++ b/nixos/pkgs/rpi-gpio-pi-detect.patch @@ -0,0 +1,29 @@ +Make RPi.GPIO board detection fall back to /proc/device-tree/model. + +get_rpi_info() reads the board revision from +/proc/device-tree/system/linux,revision or the /proc/cpuinfo "Revision" +line. On a NixOS Pi 4 (mainline device tree, arm64) neither is present, so +the C module init aborts with "This module can only be run on a Raspberry +Pi!" and any importer (adafruit-blinka -> board -> RPi.GPIO) crashes. Fall +back to the model string, which is always present, and synthesise a Pi 4 +Model B revision code so detection succeeds. + +--- a/source/cpuinfo.c ++++ b/source/cpuinfo.c +@@ -66,6 +66,16 @@ + else + return -1; + fclose(fp); ++ if (!found) { ++ FILE *mp; ++ if ((mp = fopen("/proc/device-tree/model", "r"))) { ++ if (fgets(buffer, sizeof(buffer), mp) && strstr(buffer, "Raspberry Pi")) { ++ found = 1; ++ strcpy(revision, "c03111"); ++ } ++ fclose(mp); ++ } ++ } + + if (!found) + return -1; diff --git a/nixos/pkgs/uv-python-darwin.nix b/nixos/pkgs/uv-python-darwin.nix new file mode 100644 index 000000000..3bea6fadb --- /dev/null +++ b/nixos/pkgs/uv-python-darwin.nix @@ -0,0 +1,47 @@ +{ pkgs, lib ? pkgs.lib, pyproject-nix, uv2nix, pyproject-build-systems }: +let + python = pkgs.python313; + + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ../../python; }; + + overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; + + # Only the overrides that are needed on macOS — Linux-only packages + # (spidev, rpi-gpio, picamera2, dbus-python, pygobject, python-libinput, + # python-prctl, python-pam, evdev, adafruit-blinka, pidng, videodev2) are + # gated behind sys_platform == 'linux' in pyproject.toml so they are never + # resolved for this platform and need no override here. + pyprojectOverrides = final: prev: { + # cedar-solve (the tetra3 plate-solver) is installed from git source and + # uses setup.py but doesn't declare setuptools as a build dependency. + cedar-solve = prev.cedar-solve.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + timezonefinder = prev.timezonefinder.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + sh = prev.sh.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + }; + + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope + (lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + pyprojectOverrides + ]); +in { + inherit pythonSet; + pifinderEnv = pythonSet.mkVirtualEnv "pifinder-env" workspace.deps.default; + devEnv = pythonSet.mkVirtualEnv "pifinder-dev-env" workspace.deps.all; +} diff --git a/nixos/pkgs/uv-python.nix b/nixos/pkgs/uv-python.nix new file mode 100644 index 000000000..86f54c944 --- /dev/null +++ b/nixos/pkgs/uv-python.nix @@ -0,0 +1,223 @@ +{ pkgs, lib ? pkgs.lib, pyproject-nix, uv2nix, pyproject-build-systems }: +let + python = pkgs.python313; + + # The uv workspace lives at the repo root (python/pyproject.toml + uv.lock). + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ../../python; }; + + # Prefer prebuilt wheels; fall back to sdist where no wheel exists. + overlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; + + # Native/C-extension packages that can't build from PyPI metadata alone. + # These mirror the patches the old hand-written python-packages.nix carried. + pyprojectOverrides = final: prev: { + python-libinput = prev.python-libinput.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config ] + ++ final.resolveBuildSystem { setuptools = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.libinput pkgs.systemd ]; + postPatch = (old.postPatch or "") + '' + substituteInPlace setup.py \ + --replace-fail 'from imp import load_source' 'import importlib.util, types +def load_source(name, path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod' + substituteInPlace libinput/__init__.py \ + --replace-fail "CDLL('libudev.so.1')" "CDLL('${lib.getLib pkgs.systemd}/lib/libudev.so.1')" \ + --replace-fail "CDLL('libinput.so.10')" "CDLL('${lib.getLib pkgs.libinput}/lib/libinput.so.10')" + ''; + }); + + python-prctl = prev.python-prctl.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.libcap ]; + }); + + # Installed from a prebuilt wheel (no source at patchPhase), so patch the + # installed module in $out: ctypes find_library("pam") can't locate libpam on + # NixOS, so pin it to the store path. + python-pam = prev.python-pam.overrideAttrs (old: { + postInstall = (old.postInstall or "") + '' + substituteInPlace "$out/${python.sitePackages}/pam/__internals.py" \ + --replace-fail 'find_library("pam")' '"${pkgs.pam}/lib/libpam.so"' \ + --replace-fail 'find_library("pam_misc")' '"${pkgs.pam}/lib/libpam_misc.so"' + ''; + }); + + # dbus-python and PyGObject build from sdist with meson-python; that build + # backend (resolveBuildSystem) plus pkg-config and the C libraries must be on + # the build inputs, otherwise the sdist build fails with "No module named + # 'mesonpy'". + dbus-python = prev.dbus-python.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config pkgs.ninja ] + ++ final.resolveBuildSystem { meson-python = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.dbus pkgs.glib ]; + }); + + pygobject = prev.pygobject.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config pkgs.ninja ] + ++ final.resolveBuildSystem { meson-python = []; }; + buildInputs = + (old.buildInputs or []) + ++ [ pkgs.glib pkgs.gobject-introspection pkgs.cairo pkgs.python313Packages.pycairo ]; + }); + + # evdev builds a C extension from sdist: it needs the setuptools backend, the + # kernel input headers on the compiler path (for build_ext), and its setup.py + # only searches /usr/include for linux/input.h — repoint that at linuxHeaders. + evdev = prev.evdev.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.linuxHeaders ]; + postPatch = (old.postPatch or "") + '' + substituteInPlace setup.py \ + --replace-fail 'include_paths.add("/usr/include")' 'include_paths.add("${pkgs.linuxHeaders}/include")' + ''; + }); + + # pycairo builds from sdist with meson-python (pulled in by pygobject's + # cairo support); same meson stack as dbus-python/pygobject. + pycairo = prev.pycairo.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ [ pkgs.pkg-config pkgs.ninja ] + ++ final.resolveBuildSystem { meson-python = []; }; + buildInputs = (old.buildInputs or []) ++ [ pkgs.cairo ]; + }); + + # Legacy setup.py packages (no [build-system]) need the setuptools backend + # provided explicitly, else the sdist build fails with "No module named + # 'setuptools'". + pidng = prev.pidng.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + adafruit-extended-bus = prev.adafruit-extended-bus.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + # cedar-solve declares the setuptools.build_meta backend but ships no + # setuptools in its build env, so the sdist build fails with "No module + # named 'setuptools'" — provide the backend like the other legacy packages. + cedar-solve = prev.cedar-solve.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + rpi-gpio = prev.rpi-gpio.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + # RPi.GPIO's C module init aborts with "This module can only be run on a + # Raspberry Pi!" when the board revision is in neither the device tree + # nor /proc/cpuinfo — the case on a mainline-DT arm64 NixOS Pi 4. Without + # this every importer (adafruit-blinka -> board -> RPi.GPIO) crashes and + # the whole app crash-loops. Patch in a /proc/device-tree/model fallback. + postPatch = + (old.postPatch or "") + + '' + patch -p1 < ${./rpi-gpio-pi-detect.patch} + ''; + }); + + # picamera2 installs from a py3-none-any wheel (no source patchPhase to + # hook), so patch the installed module in $out. It imports its DRM (pykms) + # and Qt preview backends unconditionally; the headless device has neither, + # so `import picamera2` dies on a missing 'pykms' and the camera process + # crash-loops. PiFinder only uses NullPreview, so make those optional. + picamera2 = prev.picamera2.overrideAttrs (old: { + postInstall = + (old.postInstall or "") + + '' + f=$(find "$out" -path '*/picamera2/previews/__init__.py' | head -1) + if [ -z "$f" ]; then + echo "picamera2: previews/__init__.py not found under $out" >&2 + exit 1 + fi + echo "picamera2: patching $f" + patch "$f" < ${./picamera2-optional-previews.patch} + ''; + }); + + # No aarch64 wheel, so it builds from sdist on the Pi (fine on x86 via wheel). + timezonefinder = prev.timezonefinder.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + sh = prev.sh.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + spidev = prev.spidev.overrideAttrs (old: { + nativeBuildInputs = + (old.nativeBuildInputs or []) + ++ final.resolveBuildSystem { setuptools = []; }; + }); + + # The manylinux pygame wheel bundles libSDL2, which dlopen()s libX11 / + # libwayland / libxkbcommon / libGL / libdecor at runtime instead of listing + # them as NEEDED, so autoPatchelf never discovers them. On a Nix host those + # libraries aren't on the loader path, so SDL falls back to the "offscreen" + # video driver and the emulator window never opens. Append them to the + # runpath so SDL can load its x11/wayland backends. Dev-only (pulled in via + # luma-emulator); pygame is not in the Pi runtime env. + pygame = prev.pygame.overrideAttrs (old: { + appendRunpaths = map (p: "${lib.getLib p}/lib") [ + pkgs.xorg.libX11 + pkgs.xorg.libXext + pkgs.xorg.libXcursor + pkgs.xorg.libXrandr + pkgs.xorg.libXi + pkgs.xorg.libXfixes + pkgs.libxrender + pkgs.libxscrnsaver + pkgs.libxinerama + pkgs.libxkbcommon + pkgs.wayland + pkgs.libGL + pkgs.libdecor + ]; + }); + + # adafruit-blinka's wheel vendors prebuilt libgpiod_pulsein helpers for + # non-Pi SoCs (amlogic, etc.) that link libgpiod.so.2. PiFinder never uses + # them (BNO055 is I2C), so don't fail auto-patchelf on that missing lib. + adafruit-blinka = prev.adafruit-blinka.overrideAttrs (old: { + autoPatchelfIgnoreMissingDeps = + (old.autoPatchelfIgnoreMissingDeps or []) ++ [ "libgpiod.so.2" ]; + }); + }; + + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope + (lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + pyprojectOverrides + ]); +in { + inherit pythonSet; + # Runtime env: [project.dependencies] only. + pifinderEnv = pythonSet.mkVirtualEnv "pifinder-env" workspace.deps.default; + # Dev env: adds the [dependency-groups].dev set (pytest, mypy, selenium…). + devEnv = pythonSet.mkVirtualEnv "pifinder-dev-env" workspace.deps.all; +} diff --git a/nixos/pkgs/welcome_image.h b/nixos/pkgs/welcome_image.h new file mode 100644 index 000000000..ef8cfc2ff --- /dev/null +++ b/nixos/pkgs/welcome_image.h @@ -0,0 +1,1027 @@ +// Auto-generated from welcome.png - 128x128 BGR565 +static const uint16_t welcome_image[16384] = { + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, + 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, + 0x6800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, + 0x6800, 0x6800, 0x7000, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6800, 0x6800, 0x7000, + 0x7000, 0x5800, 0x5800, 0x6000, 0x6800, 0x7800, 0x6000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6800, + 0x6000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6800, 0x6800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x7800, 0x8000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, + 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x8000, 0x9000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, + 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x5000, 0x5800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6800, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x6800, 0x6800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, 0x5000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x6800, 0x6000, 0x5000, 0x5000, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, 0x5800, 0x5800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x5000, 0x6000, 0x6800, 0x7800, 0x8000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x6000, 0x8000, 0x7800, 0x7000, 0x6800, 0x5800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3000, 0x4800, 0x6000, 0x7800, 0x8800, 0x9000, 0x8000, 0x7000, 0x6800, 0x5800, 0x5000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x5800, 0x6000, 0x7000, 0x7800, 0x8800, 0xA000, 0xA000, 0x9000, 0x8000, 0x6800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x6800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x5000, 0x7000, + 0x8800, 0x8000, 0x6800, 0x5800, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5800, 0x7000, 0x9000, 0xA800, 0xB000, 0x9000, 0x7000, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x7000, 0x6800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x6800, 0x8800, 0x7800, 0x6000, + 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x5000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x6000, 0x6800, 0x8000, 0x9800, + 0xA800, 0x9000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, 0x6800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x7800, 0x8000, 0x6000, 0x3800, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5800, 0x6000, 0x6000, 0x5800, 0x5000, + 0x6000, 0x8000, 0xA000, 0x9800, 0x7000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5800, 0x8000, 0x7000, 0x5000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5800, 0x7000, 0x9800, 0xA000, 0x7800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x8000, 0x7000, 0x4000, 0x3000, 0x3000, 0x4800, 0x3800, 0x3800, 0x3800, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x6000, 0x9000, 0xA000, 0x7000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, + 0x3000, 0x3000, 0x3000, 0x4800, 0x7800, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x6800, 0x9800, 0x9800, 0x8000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x7000, 0xA800, 0x8000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0x7800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x3000, 0x3000, 0x6800, 0x8000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x4800, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0xA800, 0x8800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x6800, 0x9800, 0x8000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x4800, 0x8000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x8800, 0xA000, 0x6800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x8000, 0x4800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA000, 0x8000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, + 0x8800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3800, 0x4000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x9000, 0x9800, + 0x6000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x6800, + 0x7800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x7800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, + 0xA000, 0x6800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x7000, 0x5000, 0x5800, 0x6000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0x8800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x7000, 0xA000, 0x7000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5800, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, + 0x5000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x5000, 0x6000, + 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x8800, + 0xA800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, + 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x4800, + 0x5800, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x7800, 0x5800, 0x5800, 0x6000, 0x6000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x7000, 0x5800, 0x7000, 0x6800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4800, 0x5000, 0x4800, 0x4800, 0x6000, 0x9800, 0x5000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xA800, 0x6800, 0x6000, 0x6000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x6000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x7000, 0xA800, 0x6800, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x8000, 0xA000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0xA000, 0x9000, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6800, 0xB000, 0x7800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5800, + 0x7000, 0x5800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x4000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x7800, 0xB000, 0x6000, 0x5800, 0x5800, 0x5000, 0x6800, + 0x9800, 0x6800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3800, 0x6000, 0x3800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4800, 0x4000, 0x5000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x9000, 0x8800, 0x5800, 0x5800, 0x5000, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5800, 0x6800, 0x5800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5000, 0x6800, 0x6000, 0x4800, 0x3800, 0x4000, 0x3800, 0x5800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, 0x4000, 0x4000, + 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0xA000, 0x6800, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x7000, 0x8000, 0x8000, 0x6800, 0x5800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x5800, 0x6800, 0x8000, 0x8800, 0x7000, 0x5800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4800, 0x6000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x6800, 0x9000, 0x5000, 0x5000, 0x5000, + 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x5800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x5800, 0x8000, 0x7000, 0x5800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x4000, 0x5800, 0x7800, 0x8800, 0x6000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x9000, 0x7000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x6800, 0x6800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x5800, 0x4800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x7800, 0x6000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x5800, 0x8000, 0x6800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3800, 0x4000, 0x5000, 0x7000, 0x8800, 0x6800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x5000, 0x8000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x9800, 0x4800, 0x4800, + 0x4000, 0x4000, 0x8000, 0x7800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5000, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x7000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4800, + 0x7800, 0x6800, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x3800, 0x3000, 0x4000, 0x7000, 0x8800, 0x5000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x8800, 0x7000, 0x4800, + 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x6000, 0x5800, 0x5000, + 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x9000, 0x8800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6000, 0x7800, + 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x5000, 0x8000, 0x6800, + 0x3800, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x9800, 0x4800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x6800, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x7000, + 0x7800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x8800, 0x6800, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x6000, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7800, 0x5800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x6000, 0x8000, 0x4000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x6000, 0x9000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x7000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7800, 0x5000, 0x2800, 0x2800, 0x2800, + 0x3000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x5800, 0x8800, 0x4000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x9000, + 0x5000, 0x3800, 0x3800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7800, 0x5000, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5800, 0x8000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x6800, + 0x7000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0x6000, 0x5800, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x5800, 0x2800, 0x2800, 0x3800, 0x4000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x6000, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x4800, + 0x8800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x6800, 0x6000, 0x5000, 0x5000, + 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x6800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x8000, 0x5000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x5000, 0x5800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x8000, 0x5000, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x6000, 0x7000, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x4800, 0x5000, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x8800, 0x7000, 0x5000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4800, 0x8800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x6000, 0xA000, 0x7800, 0x5000, 0x4800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x6800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x8000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x8800, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0xD000, 0x9000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x7000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, + 0x7000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x7000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x5800, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x8000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, + 0x4000, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x8800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, + 0x5000, 0x5000, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x4000, 0xA000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x5800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3800, 0x6000, + 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x7800, 0x5000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x9000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x7000, 0x6000, 0x5000, 0x5000, 0x5000, 0x5000, 0x4800, + 0x2800, 0x2800, 0x2000, 0x2800, 0x6800, 0x5800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x4000, 0x2800, 0x3000, 0x6000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x5000, 0x6800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x5800, 0x7000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x7800, 0x5000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x5000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x7000, 0x6800, 0x5800, 0x5800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2000, 0x8000, 0x2800, 0x2000, 0x4000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x5800, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x5800, 0x6000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x8800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x7000, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, + 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x5800, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x3800, 0x7000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x8000, 0x4800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x6000, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x4800, 0x5800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7800, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x4000, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x6800, 0x5800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5800, 0x7000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, 0x5000, + 0x2800, 0x2800, 0x2800, 0x5800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x5800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, + 0x9000, 0x6000, 0x2000, 0x2800, 0x2800, 0x2800, 0x5800, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x5800, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x5000, 0x7800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, 0x5800, 0x5800, + 0x2800, 0x2800, 0x2800, 0x4800, 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, + 0x7000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x4000, 0x5800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x4000, 0x6000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, 0x5800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x4800, + 0x5800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x6000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x6000, 0x6000, 0x6000, + 0x5000, 0x6000, 0x4000, 0x5800, 0x6000, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x8800, 0x9000, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA000, 0xA800, 0xA800, 0xA800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x5800, 0x6000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, 0x8000, 0xA000, 0x8000, + 0x6000, 0xB000, 0x6800, 0x5800, 0x9800, 0x8000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x6000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xE000, 0xD000, + 0xC000, 0xB800, 0xB800, 0xC000, 0xC000, 0xB800, 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7800, 0x9800, 0x9800, 0xA000, 0xA000, 0x9800, 0x9800, 0x9800, 0x9000, 0x9800, 0x9800, + 0x9800, 0x9000, 0x7800, 0x2800, 0x2800, 0x8800, 0x9000, 0x9000, 0x9000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0xA000, 0xB000, 0xA800, 0xA800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x9800, 0x4000, + 0x4000, 0xA000, 0x9000, 0x5000, 0xB000, 0x8000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5800, 0x6000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xF000, 0xE000, + 0xC800, 0xC000, 0xC000, 0xC000, 0xC800, 0xC800, 0xC000, 0xB800, 0x7800, 0x2800, 0x2800, 0x3800, 0x4800, 0xF800, 0xF800, 0xF800, + 0xF800, 0xF800, 0x2800, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x9800, 0xA000, 0x9800, 0x9800, 0x9800, 0x9800, 0x9800, + 0x9800, 0x9800, 0x7800, 0x2800, 0x2800, 0x9000, 0x9000, 0x9000, 0x9000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0xA800, 0xB000, 0xB000, 0xB800, 0x6000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4000, 0x4800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4000, 0x4800, 0x9800, 0x4000, + 0x4000, 0xA800, 0x9800, 0x8800, 0x8800, 0x8000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0xB800, 0xF800, 0xF800, 0xF800, 0xF000, + 0xE000, 0xD000, 0xC800, 0xD000, 0xC800, 0xC800, 0xC800, 0xC000, 0xB000, 0x8800, 0x2000, 0x8000, 0xA800, 0x4800, 0x2000, 0xF800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x9800, 0xA000, 0xA000, 0xA000, 0xA000, 0xA000, 0xA000, + 0xA000, 0xA000, 0x8800, 0x2800, 0x2800, 0x5000, 0x9000, 0x9000, 0x7800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA800, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x9800, 0x4000, + 0x4000, 0xB000, 0x7000, 0xA800, 0x6800, 0x8000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xB000, 0xF800, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0xA000, 0xC800, 0xC000, 0xB800, 0xB000, 0x6800, 0x4000, 0x5000, 0x3000, 0x2000, 0xF800, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x7000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x5000, 0x6800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0xA800, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x5800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x6000, 0x4000, + 0x4000, 0x6000, 0x4000, 0x5800, 0x4800, 0x5000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x4800, 0x3000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0xA800, 0xF000, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0xA800, 0xC000, 0xB800, 0xB000, 0xA000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x9800, 0x9800, 0x9800, 0x7800, 0x2800, 0x2000, 0x3000, 0x4000, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4800, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x5800, 0x3800, 0x3800, 0xB000, 0xB800, 0xC000, 0xC000, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x5800, 0x4800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4000, + 0x4800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x5800, 0x4800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0xA000, 0xE800, 0xF800, 0xF800, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x8800, 0xC000, 0xB800, 0xB800, 0xB800, 0x3000, 0x2000, 0x5800, 0xB000, 0xA000, + 0x9800, 0x8800, 0x2800, 0x2800, 0x2800, 0x7000, 0x9000, 0x9800, 0x9800, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x8800, 0xA000, 0xA000, 0xA000, 0x7000, 0x6000, 0x2800, 0x6000, 0x8800, 0x8800, 0x9000, + 0x5800, 0x4000, 0x6000, 0x9000, 0x9000, 0x9800, 0x8800, 0x6000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x6000, + 0xA000, 0xC800, 0xC800, 0xD000, 0x8800, 0x3800, 0xB000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x7000, 0x9000, 0xB800, 0xB800, 0xB800, 0xA000, 0x8000, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0xB000, 0xB800, 0xA800, + 0xA000, 0x4800, 0x6800, 0x9000, 0x9800, 0x8000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, + 0x2000, 0x2000, 0x2800, 0x4800, 0x5800, 0x2000, 0x4000, 0x3800, 0x2000, 0x2800, 0x2800, 0x9800, 0xE000, 0xE800, 0xF000, 0xB800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0xB800, 0xB800, 0xB000, 0xB000, 0x4000, 0x2000, 0x6000, 0xB800, 0xB000, + 0xA000, 0x8800, 0x2000, 0x2800, 0x2800, 0x7800, 0x9800, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA000, 0x7800, 0x5000, 0x2800, 0x6000, 0x9000, 0x9000, 0x9000, + 0x7800, 0x9000, 0x9000, 0x9000, 0x9000, 0x9000, 0x9800, 0x9800, 0x6800, 0x3000, 0x3000, 0x4000, 0x3000, 0x3000, 0x7000, 0xC000, + 0xC000, 0xC800, 0xC800, 0xC800, 0xD000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0x7000, 0x4000, 0x4000, 0x4000, 0x4800, 0xA800, + 0xC000, 0xC000, 0xC000, 0xC000, 0xB800, 0xB800, 0xB800, 0xA800, 0x5000, 0x4000, 0x4000, 0x4800, 0x4800, 0xB000, 0xB800, 0xA800, + 0xA000, 0x7000, 0xA000, 0xA000, 0xA000, 0x8800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, + 0x2000, 0x2800, 0x2000, 0x3800, 0x7000, 0x2000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x9800, 0xD000, 0xD800, 0xE000, 0xB000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x5000, 0xB800, 0xB800, 0xB000, 0xB000, 0x3000, 0x2000, 0x6000, 0xC800, 0xC000, + 0xB000, 0x9000, 0x2800, 0x2800, 0x4000, 0x8800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA800, 0x8800, 0x4000, 0x2800, 0x6800, 0x9000, 0x9800, 0x9800, + 0xA000, 0xA000, 0x9800, 0x9000, 0x9000, 0x9000, 0x9800, 0xA000, 0xA000, 0x4000, 0x3000, 0x4000, 0x4000, 0x5800, 0xB800, 0xB800, + 0xC000, 0xC000, 0xC800, 0xC800, 0xC800, 0xD000, 0xC800, 0xC000, 0xC000, 0xC000, 0x6800, 0x4000, 0x4000, 0x4000, 0xC800, 0xD000, + 0xC800, 0xC800, 0xC000, 0xC000, 0xB800, 0xB800, 0xB800, 0xB800, 0xA800, 0x4800, 0x4000, 0x4000, 0x4800, 0xA800, 0xB800, 0xA800, + 0xA800, 0xA000, 0xA000, 0xA000, 0xA800, 0x9800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x7800, 0x3000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x9800, 0xD000, 0xD000, 0xD800, 0xA800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x8800, 0xA800, 0xB000, 0xB800, 0xA800, 0x2800, 0x2000, 0x6000, 0xD000, 0xC800, + 0xB800, 0xA000, 0x2800, 0x5800, 0x7800, 0x8000, 0xA000, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x9000, 0xA800, 0xA800, 0xA800, 0x9000, 0x3000, 0x2800, 0x6800, 0xA000, 0xA000, 0xA000, + 0xA000, 0x7800, 0x2800, 0x2800, 0x5000, 0x9000, 0x9800, 0x9800, 0xA000, 0x7000, 0x4000, 0x6800, 0x6000, 0x9800, 0xB000, 0xB800, + 0xB800, 0xA800, 0x8000, 0x5800, 0x5000, 0xA800, 0xC800, 0xC000, 0xC000, 0xC000, 0x6800, 0x4000, 0x4000, 0xA000, 0xE800, 0xD800, + 0xD000, 0xC000, 0x7800, 0x3800, 0x4800, 0xA000, 0xC000, 0xC000, 0xC000, 0x8000, 0x4000, 0x4000, 0x4000, 0xA800, 0xB800, 0xA800, + 0xA800, 0xA000, 0x7000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x4800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x9000, 0xC000, 0xC800, 0xD000, 0xA800, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x8000, 0xA800, 0xA000, 0xA800, 0xB000, 0x8800, 0x5000, 0x2800, 0x6000, 0xC800, 0xC800, + 0xC000, 0xA800, 0x4800, 0x6000, 0x3000, 0x7800, 0xA800, 0xB000, 0xB800, 0xB800, 0xB800, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, + 0xC000, 0x9800, 0x2800, 0x2800, 0x2800, 0x9800, 0xB000, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x7000, 0xA800, 0xA800, 0xA800, + 0x8800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0xA000, 0xA000, 0xA000, 0x8000, 0x5000, 0x9000, 0x5800, 0xA800, 0xA800, 0xB000, + 0xB800, 0x4800, 0x7800, 0x4800, 0x3800, 0x4000, 0xB800, 0xC000, 0xC000, 0xC800, 0x6800, 0x4000, 0x4000, 0xE800, 0xF000, 0xE000, + 0xD000, 0x8000, 0x3800, 0x3800, 0x3800, 0x4800, 0xB800, 0xC000, 0xC000, 0xB800, 0x4000, 0x4000, 0x4000, 0xA800, 0xB000, 0xA800, + 0xA800, 0x6800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x6800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xD000, + 0xF000, 0xF800, 0xF800, 0xE800, 0xD000, 0xB800, 0xA800, 0xA000, 0xA000, 0x9000, 0x2000, 0x2800, 0x2800, 0x6000, 0xC000, 0xC000, + 0xC000, 0xB000, 0x2800, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, 0xC800, + 0xC800, 0xA000, 0x2800, 0x2800, 0x2800, 0x9800, 0xB800, 0xB000, 0xB000, 0x7800, 0x2800, 0x2800, 0x7000, 0xA800, 0xB000, 0xB000, + 0x8800, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0xA800, 0xA800, 0xA800, 0x9000, 0x3000, 0x2800, 0x6000, 0xA800, 0xA800, 0xA800, + 0x9800, 0x3000, 0x8800, 0x3800, 0x3000, 0x3800, 0xB000, 0xC000, 0xC000, 0xC000, 0x7000, 0x4000, 0x7000, 0xF000, 0xF000, 0xE000, + 0xD000, 0x4800, 0x3800, 0x3800, 0x3800, 0x4000, 0xA000, 0xC000, 0xC000, 0xC000, 0x6000, 0x4000, 0x4000, 0xA800, 0xB000, 0xB000, + 0xB000, 0x5800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x4800, 0x4800, 0x4800, 0x4800, 0x5800, 0x5800, 0x4800, 0x5000, 0x5800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x7000, 0x9000, 0xA000, 0xB000, 0xC800, + 0xE000, 0xF000, 0xF000, 0xE000, 0xD000, 0xC000, 0xB000, 0xA000, 0x7800, 0x3000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB800, 0xA800, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xB800, 0xC000, 0xC800, 0xD000, 0xD000, 0xC800, 0xD000, 0xD800, 0xE000, + 0xD800, 0xA800, 0x2800, 0x2800, 0x2800, 0xA000, 0xB800, 0xB800, 0xB800, 0x6800, 0x2000, 0x2000, 0x7000, 0xB000, 0xB800, 0xB800, + 0x8800, 0x2800, 0x2800, 0x3000, 0x3800, 0x6800, 0xA800, 0xB000, 0xB000, 0x9000, 0x3000, 0x2800, 0x7000, 0xA800, 0xA800, 0xA800, + 0x8000, 0x3800, 0x8000, 0x3000, 0x3000, 0x3800, 0xB000, 0xB800, 0xB800, 0xB800, 0x6800, 0x3800, 0x8800, 0xE800, 0xE800, 0xE000, + 0xC000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x9800, 0xC800, 0xC000, 0xC000, 0x7000, 0x4000, 0x4000, 0xA800, 0xB800, 0xB800, + 0xB800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x6000, 0x5800, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x5000, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x6800, 0x9000, 0x9800, 0xA800, 0xC000, + 0xD000, 0xD800, 0xE000, 0xD800, 0xD000, 0xC000, 0x7800, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB800, 0xA800, 0x2000, 0x2000, 0x2000, 0x7800, 0xA800, 0xB800, 0xC000, 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0xA800, 0xC000, 0xC000, 0xC000, 0x7000, 0x2000, 0x2000, 0x7800, 0xB800, 0xB800, 0xB800, + 0x8800, 0x2800, 0x2800, 0x3000, 0x3800, 0x6800, 0xA000, 0xA800, 0xB800, 0x9000, 0x3000, 0x3000, 0x7000, 0xA800, 0xA800, 0xA800, + 0x7000, 0x5000, 0x6800, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xB800, 0xC000, 0x6800, 0x3800, 0x9000, 0xE000, 0xE000, 0xE000, + 0xD800, 0xD000, 0xD000, 0xD800, 0xD800, 0xD800, 0xD800, 0xD000, 0xC800, 0xC000, 0x8000, 0x4000, 0x4000, 0xA800, 0xC000, 0xC000, + 0xC000, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x4800, 0x4800, 0x6800, 0x6800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x7800, 0x3000, 0x2800, 0x2000, 0x2000, 0x7000, 0x9800, 0x9800, 0xA000, 0x8800, + 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xC000, 0xB800, + 0xB000, 0xA000, 0x2000, 0x2000, 0x2000, 0x8000, 0xB000, 0xB800, 0xC000, 0x9000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0xB000, 0xD000, 0xD000, 0xC800, 0x7000, 0x1800, 0x2000, 0x7800, 0xC000, 0xC000, 0xC000, + 0x9000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0xA000, 0xA000, 0xA800, 0x8800, 0x3000, 0x3000, 0x7000, 0xA000, 0xA000, 0xA000, + 0x7000, 0x6000, 0x5800, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3800, 0x8800, 0xD800, 0xD800, 0xD800, + 0xD000, 0xD000, 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xD800, 0xD000, 0x8000, 0x4000, 0x4000, 0xB000, 0xC000, 0xC000, + 0xC800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0x6000, 0x2000, 0x2000, 0x2000, 0x7800, 0xA000, 0xA000, 0xA000, 0x8000, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB800, 0xB000, + 0xA800, 0xA000, 0x2800, 0x2800, 0x2800, 0x8000, 0xB000, 0xB000, 0xB800, 0x9000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xB000, 0xD800, 0xE000, 0xE800, 0x7800, 0x2800, 0x2000, 0x8000, 0xC000, 0xC000, 0xC000, + 0x9000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x7000, 0xA000, 0xA000, 0xA800, + 0x8000, 0x7800, 0x4000, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC800, 0x6800, 0x3800, 0x8800, 0xD000, 0xD000, 0xD000, + 0xD000, 0xD000, 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xE000, 0xD800, 0x8800, 0x3800, 0x4000, 0xB800, 0xC000, 0xC800, + 0xC800, 0x5800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x7000, 0x3800, 0x2000, 0x2000, 0x7800, 0x9800, 0x9800, 0xA000, 0x8000, + 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5000, 0xB000, 0xB000, + 0xA800, 0x9800, 0x2000, 0x2000, 0x2800, 0x8000, 0xA800, 0xA800, 0xB000, 0x8800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xB800, 0xE000, 0xF000, 0xF000, 0x9800, 0x3800, 0x2000, 0x8000, 0xC800, 0xC800, 0xC800, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x6000, 0xA800, 0xA800, 0xA800, + 0x9800, 0x8000, 0x3000, 0x3000, 0x3000, 0x3000, 0xB000, 0xC000, 0xC000, 0xC800, 0x6800, 0x3800, 0x6800, 0xC800, 0xC800, 0xC800, + 0xB800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC800, + 0xC000, 0x5800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x7000, 0x2800, 0x2800, 0x7000, 0x9000, 0x9000, 0x9000, 0x7800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB000, 0xB800, + 0xB000, 0x9800, 0x2000, 0x2000, 0x2800, 0x8000, 0xA800, 0xA800, 0xA800, 0x8000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0xC000, 0xE000, 0xF000, 0xF000, 0x9000, 0x2000, 0x2000, 0x8800, 0xD000, 0xD000, 0xC800, + 0x9800, 0x3000, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x3800, 0xB000, 0xB000, 0xB000, + 0xB000, 0x7000, 0x3000, 0x3000, 0x3000, 0x4000, 0xB800, 0xC000, 0xC000, 0xC800, 0x6800, 0x3000, 0x3800, 0xC000, 0xC800, 0xC800, + 0xC800, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x5800, 0x6800, 0x3800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5800, 0x4000, 0x4800, 0x4800, 0x4800, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0x6000, 0x2800, 0x6800, 0x9000, 0x9000, 0x9000, 0x7000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x5800, 0xB000, 0xB000, + 0xB800, 0xA000, 0x2000, 0x2000, 0x2800, 0x7800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2800, 0x6000, 0xB800, 0xE000, 0xF000, 0xF800, 0x8800, 0x2000, 0x2000, 0x8800, 0xD000, 0xD000, 0xC800, + 0xA000, 0x3800, 0x2800, 0x2800, 0x2800, 0x7000, 0xA800, 0xA000, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x9800, 0xB800, 0xC000, + 0xB800, 0xA800, 0x5000, 0x3000, 0x4800, 0x9800, 0xC000, 0xC000, 0xC000, 0xC000, 0x6800, 0x3000, 0x3000, 0x8800, 0xC800, 0xD000, + 0xD000, 0xD000, 0x9000, 0x3800, 0x3800, 0x3800, 0x9000, 0xD000, 0xD000, 0x6800, 0x3800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, 0x5800, 0x5800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6800, 0x5000, 0x6000, 0x8800, 0x9000, 0x9000, 0x7000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x5000, 0xA800, 0xB000, + 0xB800, 0xA000, 0x2000, 0x2000, 0x2000, 0x7800, 0xA000, 0xA000, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x5000, 0x7000, 0xB800, 0xE800, 0xF800, 0xF800, 0x8800, 0x2000, 0x2800, 0x8800, 0xD000, 0xD000, 0xC800, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB000, 0xA800, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x5000, 0xC000, 0xC000, + 0xC000, 0xB800, 0xB800, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0xA800, 0xD000, + 0xD000, 0xD800, 0xD800, 0xE000, 0xE000, 0xE000, 0xE000, 0xD800, 0xD800, 0xC800, 0x4800, 0x3800, 0x3800, 0xC000, 0xC800, 0xC000, + 0xC000, 0x5800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x7000, 0x8800, 0x8800, 0x8800, 0x6800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0xA000, 0xB000, + 0xB800, 0xA800, 0x2000, 0x2000, 0x2000, 0x8000, 0xA000, 0xA800, 0xA000, 0x7800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2000, 0x4800, 0x7000, 0x3000, 0xB800, 0xF000, 0xF800, 0xF800, 0x9000, 0x2800, 0x2800, 0x9000, 0xD800, 0xD000, 0xD000, + 0x9800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB000, 0xA800, 0xA000, 0x8800, 0x3000, 0x3000, 0x3000, 0x3000, 0x7800, 0xC800, + 0xC000, 0xC000, 0xC000, 0xC000, 0xC000, 0xB800, 0xB000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0xA800, + 0xD000, 0xD800, 0xE000, 0xE000, 0xE800, 0xE800, 0xE000, 0xE000, 0xC800, 0x6800, 0x3800, 0x3800, 0x3800, 0xB800, 0xC800, 0xC000, + 0xC000, 0x6000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x3000, 0x8000, 0x8800, 0x8800, 0x8800, 0x6800, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0xA000, 0xB000, + 0xB800, 0xA800, 0x2000, 0x2800, 0x2800, 0x8000, 0xA800, 0xA800, 0xA800, 0x8000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, + 0x2800, 0x4800, 0x7000, 0x3000, 0x2000, 0xB800, 0xF000, 0xF800, 0xF800, 0x9000, 0x2800, 0x2800, 0x9800, 0xE000, 0xE000, 0xD800, + 0xA000, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0xB800, 0xB000, 0xA800, 0x9000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x9000, + 0x9800, 0xC000, 0xC000, 0xC000, 0x8800, 0x5000, 0x8000, 0xC000, 0xC000, 0xC000, 0x6000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x6800, 0x9800, 0xE000, 0xE000, 0xE800, 0xE800, 0xB800, 0x8800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0xB800, 0xC000, 0xC000, + 0xC000, 0x5000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, 0x5000, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x7000, 0x5000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x5000, 0x7000, 0x6000, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x3000, 0x3000, 0x2800, 0x4800, 0x7800, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3000, 0x3800, 0x5800, 0x6000, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4800, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x6800, 0x6000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x5000, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, + 0x6800, 0x2800, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x7000, 0x5000, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x4000, 0x4000, 0x5800, 0x5000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x5800, 0x7000, + 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x4800, 0x4000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x7000, 0x5800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x4000, 0x8000, 0x3000, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, + 0x7000, 0x6000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x3800, 0x2800, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6000, 0x7000, 0x4000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x7000, 0x5800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2800, 0x5000, 0x7800, 0x5800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x5800, 0x7800, 0x5000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x8000, 0x3000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x6800, 0x6800, 0x7000, 0x6800, 0x4800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4800, 0x6800, 0x7800, 0x5000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x3000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x5000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x5000, 0x5000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x4000, 0x2800, 0x2000, 0x4000, 0x6000, 0x7800, 0x7000, 0x5800, 0x4800, 0x3000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x4800, 0x5800, 0x7000, 0x7800, 0x6000, 0x4000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5000, 0x7800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4800, 0x4800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x4800, 0x5800, 0x4800, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x4800, 0x5800, 0x4800, 0x3800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x4000, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4800, 0x5800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x4000, 0x4000, 0x4800, 0x4800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2800, 0x4000, + 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x5000, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x6800, 0x3000, 0x3000, 0x2800, 0x2800, + 0x4000, 0x3800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x5800, 0x7800, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4800, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, + 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x4800, 0x8000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x6000, 0x5000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x8000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x5800, 0x3800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x7000, 0x6000, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3000, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x4000, + 0x4000, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x3800, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x3800, 0xA000, 0x5800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, 0x7000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x3800, 0x3800, 0x4800, 0x4000, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x8800, + 0x8000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x5800, 0x4800, 0x3800, 0x4000, 0x3800, 0x3800, 0x3800, 0x5800, + 0x5000, 0x4000, 0x4800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x4000, 0x8000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x3000, 0x4000, 0x5800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x2800, 0x4000, 0x8000, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3800, 0x3000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x6800, 0x5800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x3000, 0x2800, 0x4000, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2800, 0x3000, 0x3000, 0x3000, 0x2000, 0x2000, 0x2800, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x4000, 0x8000, 0x5000, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4000, 0x5000, 0x3800, 0x3000, 0x3800, 0x3800, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x3800, 0x3800, 0x3800, 0x2800, 0x1800, 0x2000, + 0x3000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x3000, 0x4000, 0x4000, 0x4000, 0x3800, 0x2000, 0x2000, 0x3800, 0x4800, 0x5000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x4000, 0x8000, 0x4800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, 0x3000, 0x2800, 0x3000, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x3800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, + 0x3800, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x3800, 0x5000, 0x4800, 0x4800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, + 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x4800, 0x8000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, + 0x3800, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x3800, 0x4800, 0x4800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x5800, + 0x7800, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x3000, 0x3800, 0x3800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2800, 0x2800, 0x3800, 0x4800, 0x4800, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3800, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x6800, 0x7000, + 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2800, 0x3800, 0x3800, 0x2800, 0x2000, 0x2000, 0x2000, 0x4800, 0x4800, + 0x7000, 0x3800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x4000, 0x7800, 0x5800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x2800, 0x3800, 0x3800, 0x3800, 0x3800, 0x1800, 0x2000, 0x2000, 0x4000, 0x4000, + 0x3800, 0x7000, 0x5800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x6000, 0x7800, 0x4000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x4000, 0x4000, + 0x2000, 0x2800, 0x5800, 0x7000, 0x4000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x7800, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x3000, 0x3000, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x3800, 0x6800, 0x6800, 0x4000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x3000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, + 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x7000, 0x7000, 0x3800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, + 0x2800, 0x3000, 0x3000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x7800, 0x5800, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2800, 0x6000, 0x3800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x6800, 0x7800, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4000, 0x4000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x4000, 0x6800, 0x5800, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x5800, 0xA000, 0x3800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x4000, 0x6800, 0x7800, 0x5000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x3800, 0x6000, 0x6800, 0x4800, 0x2800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3800, 0x2800, 0x1800, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x3000, 0x5000, 0x7800, 0x7000, 0x4800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3800, 0x3800, 0x2000, 0x2800, 0x2800, 0x3800, 0x3000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x4800, 0x4000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x4000, 0x4000, 0x4000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x5000, 0x7000, 0x6000, 0x4000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x2800, 0x3000, + 0x3000, 0x2000, 0x1800, 0x1800, 0x2000, 0x1800, 0x2800, 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x5000, 0x7000, + 0x7800, 0x6000, 0x4000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, 0x2000, 0x2000, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3000, 0x3000, 0x3000, 0x3800, 0x4000, 0x4000, 0x3800, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x3800, 0x5800, + 0x7000, 0x6800, 0x5800, 0x4000, 0x2800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x3000, 0x4000, + 0x4800, 0x2000, 0x1800, 0x1800, 0x2800, 0x4800, 0x2800, 0x2000, 0x2800, 0x3000, 0x4800, 0x6000, 0x7800, 0x7800, 0x6000, 0x4000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x3800, 0x3000, 0x1800, 0x1800, 0x1800, 0x3000, 0x4000, 0x4000, 0x4000, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, + 0x2000, 0x3000, 0x4000, 0x5800, 0x7000, 0x7000, 0x6000, 0x5000, 0x4800, 0x4000, 0x3800, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2800, 0x3000, 0x4800, 0x6000, 0x5800, 0x6800, 0x7800, 0x7800, 0x6800, 0x5000, 0x3800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x3000, 0x3000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4000, + 0x3000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3800, 0x3800, 0x3000, 0x3000, 0x2800, 0x1800, 0x2000, 0x2000, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2800, 0x3000, 0x1800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3800, 0x4000, 0x4000, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x3000, 0x4000, 0x4800, 0x6000, 0x6800, 0x4000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x4000, 0x6000, 0x5800, 0x4800, 0x4800, 0x3800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x3000, 0x3800, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x2000, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x3000, 0x2800, 0x1800, 0x2800, 0x2800, 0x2800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x2800, 0x2800, 0x3000, 0x3000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x1800, 0x1800, 0x1800, 0x3000, 0x2800, 0x2000, 0x1800, 0x1800, 0x1800, 0x3000, 0x3800, 0x3800, 0x4000, + 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x5000, 0x6000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x1800, 0x2000, 0x2800, 0x1800, + 0x2000, 0x2800, 0x1800, 0x2800, 0x2800, 0x1800, 0x2800, 0x3000, 0x3000, 0x3000, 0x2800, 0x2000, 0x3000, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, + 0x1800, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x6000, 0x7000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x4800, 0x4800, 0x2800, 0x3000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2000, 0x1000, 0x2800, 0x3000, 0x1800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x2000, 0x1800, 0x1800, 0x2000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4000, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x2000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x2000, 0x2800, 0x3000, 0x2000, + 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x2800, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x1800, 0x2000, 0x2000, 0x3000, 0x2800, 0x1800, 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2800, 0x3800, 0x1800, 0x1800, 0x1800, 0x1800, + 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x2000, 0x3000, 0x2000, 0x1800, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x1800, 0x1800, 0x2000, 0x2800, 0x2800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x3000, 0x3000, 0x2000, 0x2000, + 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2800, 0x3800, 0x3800, + 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x1800, + 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x1800, 0x2000, + 0x2000, 0x1800, 0x1800, 0x2000, 0x2000, 0x1800, 0x1800, 0x1800, 0x2000, 0x2000, 0x2000, 0x2000, 0x4800, 0x4000, 0x2000, 0x2000, + 0x2800, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, 0x2000, + 0x2000, 0x2000, 0x2000, 0x2800, 0x2800, 0x2800, 0x2800, 0x2800, 0x3800, 0x4800, 0x2800, 0x2800, 0x3000, 0x2800, 0x2800, 0x3000, + 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x2800, 0x2800, 0x3000, 0x3000, 0x3000, + 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3000, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, 0x3800, +}; diff --git a/nixos/python-env.nix b/nixos/python-env.nix new file mode 100644 index 000000000..ed0f1be4a --- /dev/null +++ b/nixos/python-env.nix @@ -0,0 +1,42 @@ +{ 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: { + patches = (old.patches or []) ++ [ + ./patches/libcamera-imx290-optical-black.patch + ]; + 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..0cc46a8db --- /dev/null +++ b/nixos/services.nix @@ -0,0 +1,779 @@ +{ 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; }; + gaia-stars = import ./pkgs/gaia-stars.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 + ] ++ lib.optionals cfg.devMode [ + # On-device development only (excluded from the production image). Not used + # by the NixOS image updater, which is manifest/store-path based (ADR 0003). + git # clone/pull a checkout to run live + rsync # sync a checkout from a desktop without re-copying everything + ]; + + + + # --------------------------------------------------------------------------- + # Binary substituters — Pi downloads pre-built paths, never compiles. + # Two Attic caches on cache.pifinder.eu (NixOS ADR 0001): + # pifinder-release — tagged release closures, never garbage-collected, so a + # device upgrading long after a release still resolves it. + # pifinder — dev/nightly builds, short retention. + # cache.nixos.org serves everything not built locally. + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.pifinder.eu/pifinder-release" + "https://cache.pifinder.eu/pifinder" + "https://cache.nixos.org" + ]; + trusted-public-keys = [ + # Attic cache signing keys. pifinder is the original 8UU key: the S3 + # cutover briefly rotated it (Vkem), but nothing deployed trusted the new + # key so the whole fleet was stranded — the cache and this config were + # restored to 8UU. pifinder-release was minted fresh with the cutover (no + # device trusted a release key before). Real keys — never swap one for a + # placeholder; invalid base64 aborts every nix op and bricks upgrades. + "pifinder:8UU/O3oLkaJHHUyqEcPGl+9F1m4MqDca39Ewl49jBmE=" + "pifinder-release:WG/Fw1cIX7YpwfWrbWTP5eCzn3bz6AaicW5qKxLKpoM=" + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + ]; + }; + + # --------------------------------------------------------------------------- + # SD card optimizations + # --------------------------------------------------------------------------- + + # Keep 2 generations max in bootloader + boot.loader.generic-extlinux-compatible.configurationLimit = 2; + + nix.gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 3d"; + }; + # Disable store optimization on NFS (hard links cause issues) + nix.settings.auto-optimise-store = !cfg.devMode; + + boot.tmp.useTmpfs = true; + boot.tmp.tmpfsSize = "200M"; + + services.journald.extraConfig = '' + Storage=volatile + RuntimeMaxUse=50M + ''; + + zramSwap = { + enable = true; + memoryPercent = 50; + }; + + fileSystems."/" = lib.mkDefault { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + options = [ "noatime" "nodiratime" ]; + }; + + # --------------------------------------------------------------------------- + # Tmpfiles — runtime directory for upgrade ref file + # --------------------------------------------------------------------------- + systemd.tmpfiles.rules = [ + "d /run/pifinder 0755 pifinder users -" + ]; + + # --------------------------------------------------------------------------- + # PWM permissions setup for keypad backlight + # --------------------------------------------------------------------------- + systemd.services.pwm-permissions = { + description = "Set PWM sysfs permissions for pifinder"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + # Export PWM channels: 1 (GPIO 13, keypad backlight) and 0 (GPIO 12, + # rev-4 buzzer — harmless no-op wiring on rev-3). + for ch in 0 1; do + if [ ! -d /sys/class/pwm/pwmchip0/pwm$ch ]; then + echo $ch > /sys/class/pwm/pwmchip0/export || true + sleep 0.5 + fi + done + # sysfs doesn't support chgrp, so make files world-writable + chmod 0666 /sys/class/pwm/pwmchip0/export /sys/class/pwm/pwmchip0/unexport + for ch in 0 1; do + if [ -d /sys/class/pwm/pwmchip0/pwm$ch ]; then + chmod 0666 /sys/class/pwm/pwmchip0/pwm$ch/{enable,period,duty_cycle,polarity} + fi + done + # Red PWR LED — the app turns it off for night vision (sys_utils + # set_power_led writes these directly, no sudo). + if [ -d /sys/class/leds/PWR ]; then + chmod 0666 /sys/class/leds/PWR/trigger /sys/class/leds/PWR/brightness + fi + ''; + }; + + # --------------------------------------------------------------------------- + # Nix DB registration (first boot after migration) + # --------------------------------------------------------------------------- + # The migration tarball includes /nix-path-registration with store path data. + # Load it into the Nix DB so nix-store and nixos-rebuild work correctly. + systemd.services.nix-path-registration = { + description = "Load Nix store path registration from migration"; + after = [ "local-fs.target" ]; + before = [ "nix-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "/nix-path-registration"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ nix coreutils ]; + script = '' + nix-store --load-db < /nix-path-registration + rm /nix-path-registration + ''; + }; + + # --------------------------------------------------------------------------- + # Repair /nix/store ownership before NetworkManager starts + # --------------------------------------------------------------------------- + # NetworkManager (like other security-sensitive plugin loaders) silently + # refuses to load any plugin file not owned by root. Tarball-based migration + # and single-user nix imports can leave /nix/store paths owned by a non-root + # uid; NM then drops its wifi device plugin entirely — wlan0 shows as + # "unmanaged", WIFI-HW as "missing", and no wifi client connection ever comes + # up. Normalise ownership back to root before NM reads its plugins. Idempotent + # and cheap on a clean store (early-exits without touching the ro mount). + systemd.services.fix-nix-store-ownership = { + description = "Normalise /nix/store ownership to root (NM rejects non-root plugins)"; + after = [ "local-fs.target" ]; + before = [ "NetworkManager.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ util-linux findutils coreutils ]; + script = '' + set -u + if [ -z "$(find /nix/store -mindepth 1 -maxdepth 1 ! -uid 0 -print -quit)" ] \ + && [ "$(stat -c %u /nix/var/nix/db)" = 0 ]; then + exit 0 + fi + echo "normalising non-root /nix/store ownership" + # /nix/store is a read-only bind mount of the same device as /. The + # remount MUST carry "bind" so it flips only this mount's per-mount + # ro flag; a plain "remount,ro" would flip the shared superblock and + # take / (and /nix/var) read-only with it. + remounted=0 + if findmnt -no OPTIONS /nix/store | grep -qw ro; then + if mount -o remount,bind,rw /nix/store; then + remounted=1 + else + echo "WARNING: could not remount /nix/store rw; skipping repair" + exit 0 + fi + fi + find /nix/store -mindepth 1 -maxdepth 1 ! -uid 0 -exec chown -R 0:0 {} + || true + chown 0:0 /nix/var/nix/db || true + if [ "$remounted" = 1 ]; then + mount -o remount,bind,ro /nix/store || true + fi + echo "store ownership normalised" + ''; + }; + + # --------------------------------------------------------------------------- + # PiFinder source + data directory setup + # --------------------------------------------------------------------------- + system.activationScripts.pifinder-home = lib.stringAfter [ "users" ] '' + # Create writable data directory + mkdir -p /home/pifinder/PiFinder_data + chown pifinder:users /home/pifinder/PiFinder_data + + # Symlink immutable source tree from Nix store + # Database is opened read-only, so no need for writable copy + PFHOME=/home/pifinder/PiFinder + + # Remove existing directory (not symlink) to allow symlink creation + if [ -e "$PFHOME" ] && [ ! -L "$PFHOME" ]; then + rm -rf "$PFHOME" + fi + + # Create symlink to immutable Nix store path + ln -sfT ${pifinder-src} "$PFHOME" + + # Gaia deep-chart catalog — immutable, read-only; symlink from the closure + # into PiFinder_data where chart_provider expects it (utils.data_dir/gaia_stars) + GAIA=/home/pifinder/PiFinder_data/gaia_stars + if [ -e "$GAIA" ] && [ ! -L "$GAIA" ]; then + rm -rf "$GAIA" + fi + ln -sfT ${gaia-stars} "$GAIA" + ''; + + # --------------------------------------------------------------------------- + # 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"; + # Anonymous access, as on the original Raspbian PiFinder: unauthenticated + # clients are mapped to the pifinder user, which owns the share, so no SMB + # password is ever needed. (Samba's passdb is separate from the Unix login, + # so "solveit" never authenticated SMB anyway.) + "map to guest" = "bad user"; + "guest account" = "pifinder"; + }; + PiFinder_data = { + path = "/home/pifinder/PiFinder_data"; + browseable = "yes"; + "read only" = "no"; + "guest ok" = "yes"; + }; + }; + }; + + # Advertise the Samba share over mDNS so it appears in file-manager "Network" + # browse views (Finder, Nautilus). Samba itself never publishes an + # _smb._tcp record; Avahi (configured in networking.nix) does the DNS-SD. + # Lives here, tied to the samba block, so only the device build advertises it. + services.avahi.extraServiceFiles.smb = '' + + + + %h + + _smb._tcp + 445 + + + ''; + }; # config +} diff --git a/nixos/wifi-fallback-minimal.nix b/nixos/wifi-fallback-minimal.nix new file mode 100644 index 000000000..5982feae2 --- /dev/null +++ b/nixos/wifi-fallback-minimal.nix @@ -0,0 +1,55 @@ +# Minimal AP fallback for the migration image, which has no Python +# environment. The full system runs pifinder-net-policy (libnm daemon, +# services.nix) instead; this is a stripped-down shell version of the same +# priority — wired > wifi client > AP — good enough for the short-lived +# bootstrap system whose only job is staying reachable until first boot. +{ pkgs, ... }: +{ + systemd.services.pifinder-wifi-fallback = { + description = "Bring up PiFinder AP when offline (migration image)"; + after = [ "NetworkManager.service" ]; + wants = [ "NetworkManager.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ pkgs.networkmanager pkgs.coreutils pkgs.gnugrep ]; + serviceConfig.Type = "oneshot"; + script = '' + modefile=/home/pifinder/PiFinder_data/wifi_mode + + if [ -r "$modefile" ] && [ "$(cat "$modefile")" = "AP" ]; then + nmcli connection up PiFinder-AP || true + exit 0 + fi + + # Wired connectivity is sufficient — never start the AP over it. + eth_up() { + nmcli -t -f TYPE,STATE device | grep -q '^ethernet:connected' + } + # A wifi CLIENT connection. Matching the device state alone would + # count the AP itself as "connected" and make the AP sticky. + wifi_client_up() { + nmcli -t -f TYPE,NAME connection show --active \ + | grep '^802-11-wireless:' \ + | grep -qvx '802-11-wireless:PiFinder-AP' + } + + # Give NetworkManager a grace period to land on something better. + for _ in $(seq 1 45); do + if eth_up || wifi_client_up; then + exit 0 + fi + sleep 1 + done + + nmcli connection up PiFinder-AP || true + ''; + }; + + systemd.timers.pifinder-wifi-fallback = { + description = "Periodically ensure WiFi falls back to AP when offline"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "30s"; + OnUnitActiveSec = "120s"; + }; + }; +} diff --git a/python/DEPENDENCIES.md b/python/DEPENDENCIES.md new file mode 100644 index 000000000..4cd998f32 --- /dev/null +++ b/python/DEPENDENCIES.md @@ -0,0 +1,105 @@ +> **Auto-generated** from the Nix development shell on 2026-02-13. +> Do not edit manually — regenerate with: +> ``` +> nix develop --command ./scripts/generate-dependencies-md.sh +> ``` + +> **Note:** These dependencies are managed by Nix (`nixos/pkgs/python-packages.nix`). +> The versions listed here reflect the nixpkgs pin used by the flake and are +> **not necessarily installable via pip**. Some packages require system libraries +> or hardware (SPI, I2C, GPIO) only available on the Raspberry Pi. + +# Python Dependencies + +Python 3.13.11 + +## Runtime + +| Package | Version | +|---------|---------| +| aiofiles | 24.1.0 | +| attrs | 25.3.0 | +| av | 16.0.1 | +| bottle | 0.13.4 | +| cbor2 | 5.7.0 | +| certifi | 2025.7.14 | +| cffi | 2.0.0 | +| charset-normalizer | 3.4.3 | +| cheroot | 10.0.1 | +| dataclasses-json | 0.6.7 | +| dbus-python | 1.4.0 | +| Deprecated | 1.2.18 | +| evdev | 1.9.2 | +| flatbuffers | 25.9.23 | +| gpsdclient | 1.3.2 | +| grpcio | 1.76.0 | +| h3 | 4.3.1 | +| idna | 3.11 | +| jaraco.functools | 4.2.1 | +| joblib | 1.5.1 | +| jplephem | 2.23 | +| json5 | 0.12.0 | +| jsonpath-ng | 1.7.0 | +| jsonschema | 4.25.0 | +| jsonschema-specifications | 2025.4.1 | +| libarchive-c | 5.3 | +| luma.core | 2.4.2 | +| luma.lcd | 2.11.0 | +| luma.oled | 3.13.0 | +| lz4 | 4.4.4 | +| marshmallow | 3.26.2 | +| more-itertools | 10.7.0 | +| numpy | 2.3.4 | +| pandas | 2.3.1 | +| pillow | 12.1.0 | +| ply | 3.11 | +| protobuf | 6.33.1 | +| psutil | 7.1.2 | +| pycairo | 1.28.0 | +| pycparser | 2.23 | +| pydeepskylog | 1.6 | +| pyftdi | 0.57.1 | +| Pygments | 2.19.2 | +| PyGObject | 3.54.5 | +| PyJWT | 2.10.1 | +| pyserial | 3.5 | +| python-dateutil | 2.9.0.post0 | +| python-libinput | 0.3.0a0 | +| python-pam | 2.0.2 | +| pytz | 2025.2 | +| pyusb | 1.3.1 | +| referencing | 0.36.2 | +| requests | 2.32.5 | +| rpds-py | 0.25.0 | +| scikit-learn | 1.7.1 | +| scipy | 1.16.3 | +| sgp4 | 2.25 | +| sh | 1.14.3 | +| six | 1.17.0 | +| skyfield | 1.53 | +| smbus2 | 0.5.0 | +| spidev | 3.8 | +| threadpoolctl | 3.6.0 | +| timezonefinder | 8.1.0 | +| tqdm | 4.67.1 | +| typing_extensions | 4.15.0 | +| typing_inspect | 0.9.0 | +| tzdata | 2025.2 | +| urllib3 | 2.5.0 | +| wrapt | 1.17.2 | + +## Development only + +| Package | Version | +|---------|---------| +| iniconfig | 2.1.0 | +| luma.emulator | 1.5.0 | +| mypy | 1.17.1 | +| mypy_extensions | 1.1.0 | +| pathspec | 0.12.1 | +| pluggy | 1.6.0 | +| pygame | 2.6.1 | +| PyHotKey | 1.5.2 | +| pynput | 1.8.1 | +| pytest | 8.4.2 | +| python-xlib | 0.33 | diff --git a/python/PiFinder/asteroid_catalog.py b/python/PiFinder/asteroid_catalog.py new file mode 100644 index 000000000..11f613074 --- /dev/null +++ b/python/PiFinder/asteroid_catalog.py @@ -0,0 +1,291 @@ +"""Dynamic catalog of MPC's bright asteroids for the observing year.""" + +from __future__ import annotations + +import datetime +import logging +import threading +from pathlib import Path +from typing import Optional + +import pytz + +import PiFinder.asteroids as asteroids +from PiFinder import timez +from PiFinder.calc_utils import sf_utils +from PiFinder.catalog_base import ( + CatalogState, + CatalogStatus, + TimerMixin, + VirtualIDManager, +) +from PiFinder.catalogs import Catalog +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject +from PiFinder.state import SharedStateObj +from PiFinder.utils import Timer, asteroid_data_dir + + +logger = logging.getLogger("AsteroidCatalog") + + +class AsteroidCatalog(Catalog): + POSITION_UPDATE_SECONDS = 601 + WAITING_FOR_DATA_SECONDS = 10 + + def __init__( + self, + dt: datetime.datetime, + shared_state: SharedStateObj, + data_directory: Path = asteroid_data_dir, + ): + self._timer = TimerMixin() + self._virtual_id_manager = VirtualIDManager() + super().__init__("MP", "Asteroids") + self.shared_state = shared_state + self.data_directory = data_directory + self._task_lock = threading.Lock() + self._download_lock = threading.Lock() + self.download_progress: Optional[int] = None + self.calculation_progress: Optional[int] = None + self._is_downloading = False + self._cached_file_mtime: Optional[float] = None + self._last_state = CatalogState.READY + self.initialized = False + + self._timer.do_timed_task = self.do_timed_task + self._timer.time_delay_seconds = lambda: self.time_delay_seconds + + if self.shared_state.altaz_ready() and self._element_files(dt): + self.do_timed_task() + threading.Thread(target=self._refresh_sources, daemon=True).start() + self._timer.start_timer() + self._start_background_retry() + + def _element_files(self, dt: datetime.datetime) -> list[Path]: + return asteroids.available_element_files(dt, self.data_directory) + + @property + def time_delay_seconds(self) -> int: + return ( + self.POSITION_UPDATE_SECONDS + if self.initialized + else self.WAITING_FOR_DATA_SECONDS + ) + + def get_age(self) -> Optional[int]: + if not self.shared_state.altaz_ready(): + return None + files = self._element_files(self.shared_state.datetime()) + if not files: + return None + newest_mtime = max(path.stat().st_mtime for path in files) + self._cached_file_mtime = newest_mtime + local_date = timez.utc_from_timestamp(newest_mtime) + now = self.shared_state.datetime() + if now.tzinfo is None: + now = pytz.UTC.localize(now) + return round((now - local_date).total_seconds() / 86400.0) + + def get_data_label(self) -> Optional[str]: + """Annual MPC sets are editions, not feeds that become stale daily.""" + dt = self.shared_state.datetime() + if dt is not None: + current = asteroids.asteroid_file_for_year(dt.year, self.data_directory) + edition_year = dt.year if current.exists() else dt.year - 1 + return f"MPC {edition_year}" + + # A Pi 4 has no RTC, so its wall clock is not trustworthy before GPS. + # Report only an edition that is proven by an on-disk filename. + years = [] + for path in self.data_directory.glob("Soft00Bright-*.txt"): + try: + years.append(int(path.stem.rsplit("-", 1)[1])) + except (IndexError, ValueError): + continue + return f"MPC {max(years)}" if years else None + + def get_status(self) -> CatalogStatus: + if self._is_downloading: + current = CatalogState.DOWNLOADING + elif not self.shared_state.altaz_ready(): + current = CatalogState.NO_GPS + elif self.calculation_progress is not None or not self.initialized: + current = CatalogState.CALCULATING + else: + current = CatalogState.READY + data = None + if current == CatalogState.DOWNLOADING: + data = {"progress": self.download_progress} + elif ( + current == CatalogState.CALCULATING + and self.calculation_progress is not None + ): + data = {"progress": self.calculation_progress} + status = CatalogStatus(current, self._last_state, data) + self._last_state = current + return status + + def _download_year(self, year: int) -> bool: + if not self._download_lock.acquire(blocking=False): + return False + try: + self._is_downloading = True + self.download_progress = 0 + + def progress(value: Optional[int]) -> None: + self.download_progress = value + + result = asteroids.download_asteroid_year( + year, self.data_directory, progress_callback=progress + ) + if result.success: + self._cached_file_mtime = result.file_mtime + return result.success + finally: + self._is_downloading = False + self.download_progress = None + self._download_lock.release() + + def _refresh_sources(self, force_recalculate: bool = False) -> None: + if not self.shared_state.altaz_ready(): + logger.info("Deferring asteroid source selection until GPS time is ready") + return + dt = self.shared_state.datetime() + if dt is None: + return + changed = False + # Current year is required. If MPC has not published it yet, fetch the + # previous year as an explicitly stale New-Year fallback. Next year is + # opportunistic; a 404 leaves all active data untouched. + for year in (dt.year,): + needed, reason = asteroids.check_asteroid_download_needed( + year, self.data_directory + ) + if needed: + logger.info("Asteroid data %s: %s", year, reason) + changed = self._download_year(year) or changed + if not asteroids.asteroid_file_for_year(dt.year, self.data_directory).exists(): + previous_year = dt.year - 1 + needed, reason = asteroids.check_asteroid_download_needed( + previous_year, self.data_directory + ) + if needed: + logger.info("Asteroid fallback data %s: %s", previous_year, reason) + changed = self._download_year(previous_year) or changed + + next_year = dt.year + 1 + needed, reason = asteroids.check_asteroid_download_needed( + next_year, self.data_directory + ) + if needed: + logger.info("Asteroid next-year data %s: %s", next_year, reason) + changed = self._download_year(next_year) or changed + if (changed or force_recalculate) and self.shared_state.altaz_ready(): + self.do_timed_task() + + def refresh(self) -> None: + threading.Thread( + target=self._refresh_sources, + kwargs={"force_recalculate": True}, + daemon=True, + ).start() + + def _start_background_retry(self) -> None: + def retry() -> None: + retry_wait = threading.Event() + while True: + if not self.shared_state.altaz_ready(): + retry_wait.wait(self.WAITING_FOR_DATA_SECONDS) + continue + dt = self.shared_state.datetime() + if dt is None: + retry_wait.wait(self.WAITING_FOR_DATA_SECONDS) + continue + if self._element_files(dt): + break + self._refresh_sources() + if self._element_files(dt): + break + retry_wait.wait(60) + + threading.Thread(target=retry, daemon=True).start() + + def _make_object(self, asteroid: dict) -> CompositeObject: + ra, dec = asteroid["radec"] + mag = MagnitudeObject([asteroid["mag"]]) + opposition_kind = asteroid.get("opposition_kind", "Opposition") + opposition_date = asteroid.get("opposition_date") + peak_date = asteroid.get("peak_date") + description_lines = [] + if opposition_date: + event_label = "Opp" if opposition_kind == "Opposition" else "Elong" + description_lines.append(f"{event_label}: {opposition_date.isoformat()}") + else: + description_lines.append("Opp: unavailable") + if peak_date: + description_lines.append( + f"Peak {asteroid['peak_magnitude']:.1f}: {peak_date.isoformat()}" + ) + description_lines.extend( + ( + f"Earth: {asteroid['earth_distance']:.2f} AU", + f"Sun: {asteroid['sun_distance']:.2f} AU", + f"Motion: {asteroid['angular_motion_arcsec_per_hour']:.1f}\"/h", + ) + ) + description = "\n".join(description_lines) + return CompositeObject.from_dict( + { + "id": -1, + "obj_type": "AS", + "ra": ra, + "dec": dec, + "const": sf_utils.radec_to_constellation(ra, dec), + "size": SizeObject([]), + "mag": mag, + "mag_str": mag.calc_two_mag_representation(), + "names": [asteroid["name"]], + "catalog_code": "MP", + "sequence": asteroid["number"], + "description": description, + "earth_distance_au": asteroid["earth_distance"], + "sun_distance_au": asteroid["sun_distance"], + "angular_motion_arcsec_per_hour": asteroid[ + "angular_motion_arcsec_per_hour" + ], + "opposition_date": opposition_date, + "opposition_kind": opposition_kind, + "peak_magnitude": asteroid.get("peak_magnitude"), + "peak_date": peak_date, + } + ) + + def init_asteroids(self, dt: datetime.datetime) -> None: + self.calculation_progress = 0 + + def progress(value: int) -> None: + self.calculation_progress = value + + calculated = asteroids.calc_asteroids( + dt, self._element_files(dt), progress_callback=progress + ) + if not calculated: + self.initialized = bool(self.get_objects()) + self.calculation_progress = None + return + objects = [self._make_object(item) for item in calculated.values()] + self.replace_objects(objects) + self._virtual_id_manager.mint_ids(self) + if self.catalog_filter is not None: + self.catalog_filter.mark_catalog_content_dirty() + self.initialized = True + self.calculation_progress = None + + def do_timed_task(self) -> None: + with self._task_lock: + with Timer("Asteroid Catalog periodic update"): + if not self.shared_state.altaz_ready(): + return + dt = self.shared_state.datetime() + if self._element_files(dt): + self.init_asteroids(dt) diff --git a/python/PiFinder/asteroids.py b/python/PiFinder/asteroids.py new file mode 100644 index 000000000..826dfc4ed --- /dev/null +++ b/python/PiFinder/asteroids.py @@ -0,0 +1,414 @@ +"""MPC bright-asteroid elements, propagation, photometry, and apparitions.""" + +from __future__ import annotations + +import logging +import math +import re +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Optional + +import numpy as np +import pandas as pd +from skyfield.data import mpc +from skyfield.data.spice import inertial_frames +from skyfield.timelib import julian_day + +from PiFinder.calc_utils import sf_utils +from PiFinder.download_utils import ( + DownloadResult, + check_download_needed, + download_atomic, +) +from PiFinder.utils import Timer, asteroid_data_dir + + +logger = logging.getLogger("Asteroids") + +MPC_BRIGHT_URL = ( + "https://minorplanetcenter.net/iau/Ephemerides/Bright/{year}/" "Soft00Bright.txt" +) +ASTEROID_VISIBLE_MAG_LIMIT = 15.0 +APPARITION_SEARCH_DAYS = 550 +OPPOSITION_MIN_ELONGATION_DEG = 170.0 +_NUMBER_RE = re.compile(r"^\((\d+)\)(?:\s+(.*))?$") +_ECLIPTIC_TO_ICRF = inertial_frames["ECLIPJ2000"].T + + +def asteroid_file_for_year(year: int, directory: Path = asteroid_data_dir) -> Path: + return directory / f"Soft00Bright-{year}.txt" + + +def asteroid_url_for_year(year: int) -> str: + return MPC_BRIGHT_URL.format(year=year) + + +def _validate_asteroid_file(path: Path) -> None: + with path.open("rb") as source: + dataframe = mpc.load_mpcorb_dataframe(source) + if dataframe.empty: + raise ValueError("MPC bright-asteroid file contains no objects") + if ( + dataframe["designation"] + .map(lambda value: bool(_NUMBER_RE.match(str(value)))) + .sum() + == 0 + ): + raise ValueError("MPC bright-asteroid file has no numbered asteroids") + + +def download_asteroid_year( + year: int, + directory: Path = asteroid_data_dir, + progress_callback: Optional[Callable[[Optional[int]], None]] = None, +) -> DownloadResult: + return download_atomic( + asteroid_url_for_year(year), + asteroid_file_for_year(year, directory), + progress_callback=progress_callback, + validator=_validate_asteroid_file, + ) + + +def check_asteroid_download_needed( + year: int, directory: Path = asteroid_data_dir +) -> tuple[bool, str]: + return check_download_needed( + asteroid_file_for_year(year, directory), asteroid_url_for_year(year) + ) + + +def available_element_files( + dt: datetime, directory: Path = asteroid_data_dir +) -> list[Path]: + """Newest useful annual files, including next year when MPC has published it.""" + years = (dt.year - 1, dt.year, dt.year + 1) + return [ + asteroid_file_for_year(year, directory) + for year in years + if asteroid_file_for_year(year, directory).exists() + ] + + +def load_asteroids_dataframe(paths: list[Path]) -> pd.DataFrame: + frames = [] + for path in paths: + with path.open("rb") as source: + frames.append(mpc.load_mpcorb_dataframe(source)) + if not frames: + return pd.DataFrame() + dataframe = pd.concat(frames, ignore_index=True) + numeric = ( + "magnitude_H", + "magnitude_G", + "mean_anomaly_degrees", + "argument_of_perihelion_degrees", + "longitude_of_ascending_node_degrees", + "inclination_degrees", + "eccentricity", + "mean_daily_motion_degrees", + "semimajor_axis_au", + ) + for column in numeric: + dataframe[column] = pd.to_numeric(dataframe[column], errors="coerce") + dataframe["magnitude_G"] = dataframe["magnitude_G"].fillna(0.15) + required = [column for column in numeric if column != "magnitude_G"] + [ + "epoch_packed", + "designation", + ] + dataframe = dataframe.dropna(subset=required) + dataframe = dataframe[ + (dataframe.eccentricity >= 0.0) & (dataframe.eccentricity < 1.0) + ] + dataframe["number"] = dataframe.designation.map(_minor_planet_number) + dataframe = dataframe.dropna(subset=["number"]) + dataframe["number"] = dataframe.number.astype(int) + # Lexical order of the MPC packed epoch is chronological within these + # modern annual files. Prefer the freshest duplicate across year files. + return ( + dataframe.sort_values("epoch_packed") + .drop_duplicates(subset=["number"], keep="last") + .sort_values("number") + .reset_index(drop=True) + ) + + +def _minor_planet_number(designation: str) -> Optional[int]: + match = _NUMBER_RE.match(str(designation).strip()) + return int(match.group(1)) if match else None + + +def minor_planet_name(designation: str) -> str: + match = _NUMBER_RE.match(str(designation).strip()) + if not match: + return str(designation).strip() + return (match.group(2) or match.group(1)).strip() + + +def _packed_epoch_jd(value: str) -> float: + def unpack(char: str) -> int: + return ord(char) - (48 if char.isdigit() else 55) + + value = str(value) + year = 100 * unpack(value[0]) + int(value[1:3]) + return julian_day(year, unpack(value[3]), unpack(value[4])) - 0.5 + + +def _heliocentric_positions(dataframe: pd.DataFrame, tt_jd) -> np.ndarray: + """Return heliocentric ICRF positions shaped ``(3, objects, times)``.""" + target_jd = np.atleast_1d(np.asarray(tt_jd, dtype=float)) + epoch_jd = np.asarray([_packed_epoch_jd(v) for v in dataframe.epoch_packed]) + mean_anomaly = np.radians(dataframe.mean_anomaly_degrees.to_numpy(float))[:, None] + mean_motion = np.radians(dataframe.mean_daily_motion_degrees.to_numpy(float))[ + :, None + ] + anomaly = ( + mean_anomaly + mean_motion * (target_jd[None, :] - epoch_jd[:, None]) + ) % (2.0 * math.pi) + eccentricity = dataframe.eccentricity.to_numpy(float)[:, None] + + eccentric_anomaly = anomaly.copy() + for _ in range(12): + correction = ( + eccentric_anomaly - eccentricity * np.sin(eccentric_anomaly) - anomaly + ) / (1.0 - eccentricity * np.cos(eccentric_anomaly)) + eccentric_anomaly -= correction + if np.max(np.abs(correction)) < 1e-13: + break + + semimajor = dataframe.semimajor_axis_au.to_numpy(float)[:, None] + x_orbit = semimajor * (np.cos(eccentric_anomaly) - eccentricity) + y_orbit = semimajor * np.sqrt(1.0 - eccentricity**2) * np.sin(eccentric_anomaly) + + node = np.radians(dataframe.longitude_of_ascending_node_degrees.to_numpy(float))[ + :, None + ] + peri = np.radians(dataframe.argument_of_perihelion_degrees.to_numpy(float))[:, None] + inc = np.radians(dataframe.inclination_degrees.to_numpy(float))[:, None] + cos_node, sin_node = np.cos(node), np.sin(node) + cos_peri, sin_peri = np.cos(peri), np.sin(peri) + cos_inc, sin_inc = np.cos(inc), np.sin(inc) + + x = (cos_node * cos_peri - sin_node * sin_peri * cos_inc) * x_orbit + ( + -cos_node * sin_peri - sin_node * cos_peri * cos_inc + ) * y_orbit + y = (sin_node * cos_peri + cos_node * sin_peri * cos_inc) * x_orbit + ( + -sin_node * sin_peri + cos_node * cos_peri * cos_inc + ) * y_orbit + z = sin_peri * sin_inc * x_orbit + cos_peri * sin_inc * y_orbit + return np.einsum("ij,jnt->int", _ECLIPTIC_TO_ICRF, np.array([x, y, z])) + + +def hg_magnitude( + magnitude_h, + magnitude_g, + sun_distance, + observer_distance, + phase_angle_radians, +): + """IAU H-G apparent visual magnitude model.""" + tan_half = np.tan(np.clip(phase_angle_radians, 0.0, math.pi - 1e-9) / 2.0) + phi1 = np.exp(-3.33 * np.power(tan_half, 0.63)) + phi2 = np.exp(-1.87 * np.power(tan_half, 1.22)) + phase = (1.0 - magnitude_g) * phi1 + magnitude_g * phi2 + return ( + magnitude_h + + 5.0 * np.log10(sun_distance * observer_distance) + - 2.5 * np.log10(phase) + ) + + +def _geometry(dataframe: pd.DataFrame, times, observer_positions: np.ndarray): + helio = _heliocentric_positions(dataframe, times.tt) + sun = sf_utils.eph["sun"].at(times).position.au + if sun.ndim == 1: + sun = sun[:, None] + observer = observer_positions + if observer.ndim == 1: + observer = observer[:, None] + topocentric = sun[:, None, :] + helio - observer[:, None, :] + earth_distance = np.linalg.norm(topocentric, axis=0) + sun_distance = np.linalg.norm(helio, axis=0) + asteroid_to_sun = -helio + asteroid_to_observer = -topocentric + cos_phase = np.sum(asteroid_to_sun * asteroid_to_observer, axis=0) / ( + sun_distance * earth_distance + ) + phase_angle = np.arccos(np.clip(cos_phase, -1.0, 1.0)) + h = dataframe.magnitude_H.to_numpy(float)[:, None] + g = dataframe.magnitude_G.to_numpy(float)[:, None] + magnitude = hg_magnitude(h, g, sun_distance, earth_distance, phase_angle) + return helio, topocentric, earth_distance, sun_distance, magnitude + + +def _radec(topocentric: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + eq_pos = np.einsum("ij,jnt->int", sf_utils.ts.J2000.M, topocentric) + distance = np.linalg.norm(eq_pos, axis=0) + ra = np.degrees(np.arctan2(eq_pos[1], eq_pos[0])) % 360.0 + dec = np.degrees(np.arcsin(np.clip(eq_pos[2] / distance, -1.0, 1.0))) + return ra, dec + + +def _angular_motion_arcsec_per_hour(topocentric: np.ndarray) -> np.ndarray: + """Return apparent one-hour sky motion for each object.""" + unit = topocentric / np.linalg.norm(topocentric, axis=0)[None, :, :] + cos_separation = np.sum(unit[:, :, 0] * unit[:, :, 1], axis=0) + return np.degrees(np.arccos(np.clip(cos_separation, -1.0, 1.0))) * 3600.0 + + +def _next_apparition_index(separation: np.ndarray) -> tuple[int, bool]: + """Return the first future local maximum, never the day-0 endpoint.""" + local_maxima = ( + np.nonzero( + (separation[1:-1] >= separation[:-2]) & (separation[1:-1] >= separation[2:]) + )[0] + + 1 + ) + opposition_candidates = local_maxima[ + separation[local_maxima] >= OPPOSITION_MIN_ELONGATION_DEG + ] + if len(opposition_candidates): + return int(opposition_candidates[0]), True + if len(local_maxima): + return int(local_maxima[0]), False + + # A maximum can fall on the far scan boundary. Day 0 remains excluded: it + # might be an event that passed minutes ago and is therefore not "next". + future_index = int(np.nanargmax(separation[1:])) + 1 + return ( + future_index, + bool(separation[future_index] >= OPPOSITION_MIN_ELONGATION_DEG), + ) + + +def _apparitions(dataframe: pd.DataFrame, dt: datetime) -> dict[int, dict[str, Any]]: + start = sf_utils.ts.from_datetime(dt) + times = sf_utils.ts.tt_jd(start.tt + np.arange(APPARITION_SEARCH_DAYS + 1)) + earth = sf_utils.earth.at(times).position.au + _helio, geocentric, earth_distance, _sun_distance, magnitude = _geometry( + dataframe, times, earth + ) + sun = sf_utils.eph["sun"].at(times).position.au + sun_from_earth = sun - earth + cos_elongation = np.sum(geocentric * sun_from_earth[:, None, :], axis=0) / ( + earth_distance * np.linalg.norm(sun_from_earth, axis=0)[None, :] + ) + elongation = np.degrees(np.arccos(np.clip(cos_elongation, -1.0, 1.0))) + # Opposition is a 180-degree *ecliptic-longitude* separation. Its true + # angular elongation can be noticeably smaller for high-latitude objects. + to_ecliptic = _ECLIPTIC_TO_ICRF.T + asteroid_ecliptic = np.einsum("ij,jnt->int", to_ecliptic, geocentric) + sun_ecliptic = to_ecliptic @ sun_from_earth + asteroid_lon = np.degrees(np.arctan2(asteroid_ecliptic[1], asteroid_ecliptic[0])) + sun_lon = np.degrees(np.arctan2(sun_ecliptic[1], sun_ecliptic[0]))[None, :] + longitude_separation = np.abs((asteroid_lon - sun_lon + 180.0) % 360.0 - 180.0) + + result: dict[int, dict[str, Any]] = {} + datetimes = times.utc_datetime() + for row_index, number in enumerate(dataframe.number.to_numpy(int)): + separation = longitude_separation[row_index] + opposition_index, is_opposition = _next_apparition_index(separation) + # Keep peak brightness tied to this apparition instead of selecting a + # second, brighter opposition near the far edge of the 18-month scan. + peak_start = max(0, opposition_index - 90) + peak_stop = min(len(times), opposition_index + 91) + peak_index = peak_start + int( + np.nanargmin(magnitude[row_index, peak_start:peak_stop]) + ) + maximum_elongation = float(elongation[row_index, opposition_index]) + result[number] = { + "opposition_date": datetimes[opposition_index].date(), + "opposition_kind": "Opposition" if is_opposition else "Greatest elongation", + "maximum_elongation_deg": maximum_elongation, + "peak_date": datetimes[peak_index].date(), + "peak_magnitude": float(magnitude[row_index, peak_index]), + } + return result + + +def process_asteroid(row, dt: datetime) -> dict[str, Any]: + dataframe = pd.DataFrame([row]) + result = _calculate_dataframe(dataframe, dt, include_apparitions=False) + return next(iter(result.values()), {}) + + +def _calculate_dataframe( + dataframe: pd.DataFrame, + dt: datetime, + include_apparitions: bool = True, +) -> dict[int, dict[str, Any]]: + if dataframe.empty: + return {} + time = sf_utils.ts.from_datetime(dt) + motion_times = sf_utils.ts.tt_jd(np.array([time.tt, time.tt + 1.0 / 24.0])) + observer = sf_utils.observer_loc.at(motion_times).position.au + _, topocentric, earth_distance, sun_distance, magnitude = _geometry( + dataframe, motion_times, observer + ) + angular_motion = _angular_motion_arcsec_per_hour(topocentric) + ra, dec = _radec(topocentric) + visible = np.isfinite(magnitude[:, 0]) & ( + magnitude[:, 0] <= ASTEROID_VISIBLE_MAG_LIMIT + ) + visible_df = dataframe.loc[visible].reset_index(drop=True) + apparitions = ( + _apparitions(visible_df, dt) if include_apparitions and len(visible_df) else {} + ) + + result: dict[int, dict[str, Any]] = {} + for source_index in np.nonzero(visible)[0]: + row = dataframe.iloc[source_index] + number = int(row.number) + item = { + "number": number, + "name": minor_planet_name(row.designation), + "full_name": str(row.designation).strip(), + "radec": (float(ra[source_index, 0]), float(dec[source_index, 0])), + "mag": float(magnitude[source_index, 0]), + "earth_distance": float(earth_distance[source_index, 0]), + "sun_distance": float(sun_distance[source_index, 0]), + "angular_motion_arcsec_per_hour": float(angular_motion[source_index]), + } + item.update(apparitions.get(number, {})) + result[number] = item + return result + + +def calc_asteroids( + dt: datetime, + paths: Optional[list[Path]] = None, + progress_callback: Optional[Callable[[int], None]] = None, +) -> dict[int, dict[str, Any]]: + with Timer("calc_asteroids()"): + if sf_utils.observer_loc is None or dt is None: + return {} + if progress_callback: + progress_callback(0) + dataframe = load_asteroids_dataframe(paths or available_element_files(dt)) + if progress_callback: + progress_callback(10) + if dataframe.empty: + return {} + try: + result = _calculate_dataframe(dataframe, dt) + except Exception: + logger.error( + "VECTORIZED ASTEROID PROPAGATION FAILED — using per-object fallback", + exc_info=True, + ) + result = {} + total = len(dataframe) + for index, (_, row) in enumerate(dataframe.iterrows(), 1): + try: + item = process_asteroid(row, dt) + except Exception as exc: + logger.warning("Skipping asteroid %s: %s", row.designation, exc) + continue + if item: + result[int(item["number"])] = item + if progress_callback: + progress_callback(10 + int(90 * index / total)) + if progress_callback: + progress_callback(100) + return result diff --git a/python/PiFinder/audit_images.py b/python/PiFinder/audit_images.py index ef37fdb70..1e67742ea 100644 --- a/python/PiFinder/audit_images.py +++ b/python/PiFinder/audit_images.py @@ -6,11 +6,12 @@ images from AWS """ -import requests import sqlite3 + +import requests from tqdm import tqdm -from PiFinder import cat_images +from PiFinder.object_images.poss_provider import POSSImageProvider def get_catalog_objects(): @@ -44,8 +45,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() @@ -59,7 +60,7 @@ def check_object_image(catalog_object): if aka_sequence: catalog_object = {"catalog": "NGC", "sequence": aka_sequence} - object_image_path = cat_images.resolve_image_name(catalog_object, "POSS") + object_image_path = POSSImageProvider()._resolve_image_name(catalog_object, "POSS") # POSS image_name = object_image_path.split("/")[-1] seq_ones = image_name.split("_")[0][-1] diff --git a/python/PiFinder/auto_exposure.py b/python/PiFinder/auto_exposure.py index 29616dfbf..5f1c8b693 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" ) @@ -251,7 +251,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 @@ -262,14 +262,14 @@ def update( new_exposure = int(current_exposure * self.adjustment_factor) logger.info( f"SNR AE: Background too low ({background:.1f} < {min_bg:.1f}), " - f"increasing exposure {current_exposure / 1000:.0f}ms → {new_exposure / 1000:.0f}ms" + f"increasing exposure {current_exposure/1000:.0f}ms → {new_exposure/1000:.0f}ms" ) elif background > self.max_background: # Too bright - decrease exposure new_exposure = int(current_exposure / self.adjustment_factor) logger.info( f"SNR AE: Background too high ({background:.1f} > {self.max_background}), " - f"decreasing exposure {current_exposure / 1000:.0f}ms → {new_exposure / 1000:.0f}ms" + f"decreasing exposure {current_exposure/1000:.0f}ms → {new_exposure/1000:.0f}ms" ) else: # Background is in acceptable range diff --git a/python/PiFinder/battery_bq25895.py b/python/PiFinder/battery_bq25895.py index 8586b04b9..7f7b6b8cd 100644 --- a/python/PiFinder/battery_bq25895.py +++ b/python/PiFinder/battery_bq25895.py @@ -46,8 +46,8 @@ Structure note: ``decode_registers`` and ``estimate_soc`` are PURE (no hardware) so the bulk of the logic is unit-testable without a board. -``board`` is imported lazily-guarded so this module — and the pure -pieces ``battery_fake`` reuses — imports cleanly on dev machines. +The I2C bus factory is imported lazily-guarded so this module — and the +pure pieces ``battery_fake`` reuses — imports cleanly on dev machines. """ import logging @@ -57,12 +57,13 @@ from PiFinder.types.hardware import BatteryState, ChargeStatus try: - import board from adafruit_bus_device.i2c_device import I2CDevice + + from PiFinder.i2c_bus import get_i2c except (ImportError, NotImplementedError): # No blinka / not on real hardware: the pure decode helpers and module # constants still import. The BQ25895 class raises on construction. - board = None + get_i2c = None # type: ignore[assignment] I2CDevice = None logger = logging.getLogger("Battery.bq25895") @@ -384,10 +385,10 @@ class BQ25895: """ def __init__(self, address: int = BQ25895_ADDRESS, i2c=None): - if board is None or I2CDevice is None: + if get_i2c is None or I2CDevice is None: raise RuntimeError("blinka / board unavailable — no I2C bus") if i2c is None: - i2c = board.I2C() + i2c = get_i2c() self._device = I2CDevice(i2c, address) def read_reg(self, reg: int) -> int: diff --git a/python/PiFinder/camera_debug.py b/python/PiFinder/camera_debug.py index a4aa8faf1..7957a7c42 100644 --- a/python/PiFinder/camera_debug.py +++ b/python/PiFinder/camera_debug.py @@ -55,7 +55,7 @@ def setup_debug_images(self) -> None: self.images = list(zip(range(1, len(images) + 1), images)) self.image_cycle = cycle(self.images) self.last_image_time: float = time.time() - self.current_image_num, self.last_image = self.images[0] + self.current_image_num, self.last_image = self.images[1] # Use darker sky image def initialize(self) -> None: self._camera_started = True @@ -70,7 +70,6 @@ def capture(self) -> Image.Image: # Sleep for exposure time sleep_time = self.exposure_time / 1000000 time.sleep(sleep_time) - elapsed = time.time() - self.last_image_time # Swap every x seconds if elapsed > 10: diff --git a/python/PiFinder/camera_interface.py b/python/PiFinder/camera_interface.py index a63d74183..070b6a76e 100644 --- a/python/PiFinder/camera_interface.py +++ b/python/PiFinder/camera_interface.py @@ -308,6 +308,8 @@ def get_image_loop( shared_state.set_camera_type(camera_type) logger.info(f"Camera type set to: {camera_type}") + debug = False + # Check if auto-exposure was previously enabled in config config_exp = cfg.get_option("camera_exp") if config_exp == "auto": @@ -342,7 +344,6 @@ def get_image_loop( # 60 half-second cycles (30 seconds between captures in sleep mode) sleep_delay = 60 was_sleeping = False - test_mode_on = False while True: sleeping = state_utils.sleep_for_framerate( shared_state, limit_framerate=False @@ -367,6 +368,10 @@ def get_image_loop( imu_start = shared_state.imu() image_start_time = time.time() if self._camera_started: + # Test mode is owned by shared state (persisted in config + # and toggled from the menu), so it stays in sync with the + # UI and survives restarts. + test_mode_on = shared_state.test_mode() if not test_mode_on: base_image = self._capture_with_timeout() if base_image is None: @@ -405,12 +410,12 @@ def get_image_loop( pointing_diff = 0.0 # Make image available - if test_mode_on and abs(pointing_diff) > 0.01: - # Scope moved during the fake exposure: return a blank - # image so the solver doesn't report a stale solve + if debug and abs(pointing_diff) > 0.01: + # Check if we moved and return a blank image camera_image.paste(self._blank_capture()) else: camera_image.paste(base_image) + image_metadata = { "exposure_start": image_start_time, "exposure_end": image_end_time, @@ -513,7 +518,10 @@ def get_image_loop( try: if command == "debug": - test_mode_on = not test_mode_on + if debug: + debug = False + else: + debug = True if command.startswith("set_exp"): transient_exposure = command.startswith( diff --git a/python/PiFinder/camera_pi.py b/python/PiFinder/camera_pi.py index 4364bcd2f..e9d125955 100644 --- a/python/PiFinder/camera_pi.py +++ b/python/PiFinder/camera_pi.py @@ -23,6 +23,29 @@ logger = logging.getLogger("Camera.Pi") +def optical_black_pedestal(metadata, bit_depth): + """Return the per-frame optical-black level in native raw ADU. + + libcamera reports SensorBlackLevels on a 16-bit scale. The patched + IMX290/462 helper marks a measured value with a one-count sentinel in the + fourth channel; an unpatched stack's static tuning value is therefore not + mistaken for a measurement. + """ + levels = metadata.get("SensorBlackLevels") + if not isinstance(levels, (tuple, list)) or len(levels) != 4: + return None + values = np.asarray(levels, dtype=np.float64) + if not np.all(np.isfinite(values)) or np.any(values <= 0): + return None + if not (values[0] == values[1] == values[2] and values[3] == values[0] + 1): + return None + scale = float(1 << (16 - int(bit_depth))) + pedestal = float(values[0] / scale) + if pedestal <= 0 or pedestal >= 2 ** int(bit_depth): + return None + return pedestal + + class CameraPI(CameraInterface): """The camera class for PI cameras. Implements the CameraInterface interface.""" @@ -107,6 +130,12 @@ def capture(self) -> Image.Image: # driver chooses to report. self.last_frame_metadata = metadata + frame_optical_black = None + if self.camera_type in ("imx290", "imx462"): + frame_optical_black = optical_black_pedestal( + metadata, self.profile.bit_depth + ) + _request.release() # Apply camera-specific crop and rotation @@ -127,6 +156,7 @@ def capture(self) -> Image.Image: radiometer_exposure, sequence=self._radiometer_sequence, captured_at=time.time(), + optical_black_pedestal=frame_optical_black, ) if sample is not None: self.shared_state.set_sqm_radiometer_sample(sample) diff --git a/python/PiFinder/cat_images.py b/python/PiFinder/cat_images.py deleted file mode 100644 index 1886c57e8..000000000 --- a/python/PiFinder/cat_images.py +++ /dev/null @@ -1,447 +0,0 @@ -#!/usr/bin/python -# -*- coding:utf-8 -*- -""" -This module is used at runtime -to handle catalog image loading -""" - -import math -import os -from typing import List, Optional, Tuple -from PIL import Image, ImageChops, ImageDraw -from PiFinder import image_util -from PiFinder import utils -import PiFinder.ui.ui_utils as ui_utils -import logging - -BASE_IMAGE_PATH = f"{utils.data_dir}/catalog_images" -CATALOG_PATH = f"{utils.astro_data_dir}/pifinder_objects.db" - - -logger = logging.getLogger("Catalog.Images") - - -def rotation_radians(image_rotate: float) -> float: - """Image rotation as a y-down pixel-space angle, in radians. - - PIL's Image.rotate() turns the image counterclockwise, which in - y-down pixel coordinates is a rotation by the negated angle. - """ - return math.radians(-image_rotate) - - -def cardinal_vectors( - image_rotate: float, fx: int = 1, fy: int = 1 -) -> Tuple[Tuple[float, float], Tuple[float, float]]: - """Return (nx, ny), (ex, ey) unit vectors for North and East. - - image_rotate: degrees the POSS image was rotated (180 + roll). - fx, fy: -1 to mirror that axis (flip/flop), +1 otherwise. - """ - theta = rotation_radians(image_rotate) - n = (fx * math.sin(theta), fy * -math.cos(theta)) - e = (-fx * math.cos(theta), -fy * math.sin(theta)) - return n, e - - -def size_overlay_points( - extents: List[float], - pa: float, - image_rotate: float, - px_per_arcsec: float, - cx: float, - cy: float, - fx: int = 1, - fy: int = 1, -) -> Optional[List[Tuple[float, float]]]: - """Compute outline points for the size overlay. - - Returns a list of (x, y) tuples. - For 1 extent returns None (caller should use native ellipse). - """ - if not extents or len(extents) == 1: - return None - - theta = rotation_radians(image_rotate) - math.radians(pa + 90) - cos_t = math.cos(theta) - sin_t = math.sin(theta) - - points = [] - if len(extents) == 2: - rx = extents[0] * px_per_arcsec / 2 - ry = extents[1] * px_per_arcsec / 2 - for i in range(36): - t = 2 * math.pi * i / 36 - x = rx * math.cos(t) - y = ry * math.sin(t) - points.append( - (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) - ) - else: - step = 2 * math.pi / len(extents) - for i, ext in enumerate(extents): - angle = i * step - math.pi / 2 - r = ext * px_per_arcsec / 2 - x = r * math.cos(angle) - y = r * math.sin(angle) - points.append( - (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) - ) - return points - - -def vertex_overlay_points( - vertices: List[List[float]], - obj_ra: float, - obj_dec: float, - image_rotate: float, - px_per_arcsec: float, - cx: float, - cy: float, - fx: int = 1, - fy: int = 1, -) -> List[Tuple[float, float]]: - """Project RA/Dec vertex pairs to pixel coords via gnomonic projection. - - vertices: list of [ra, dec] pairs in degrees. - obj_ra, obj_dec: object center in degrees. - Returns list of (x, y) pixel tuples. - """ - theta = rotation_radians(image_rotate) - cos_t = math.cos(theta) - sin_t = math.sin(theta) - - ra0 = math.radians(obj_ra) - dec0 = math.radians(obj_dec) - cos_dec0 = math.cos(dec0) - sin_dec0 = math.sin(dec0) - - points = [] - for ra_deg, dec_deg in vertices: - ra = math.radians(ra_deg) - dec = math.radians(dec_deg) - cos_dec = math.cos(dec) - sin_dec = math.sin(dec) - dra = ra - ra0 - - cos_c = sin_dec0 * sin_dec + cos_dec0 * cos_dec * math.cos(dra) - if cos_c <= 0: - continue - # gnomonic: xi points East, eta points North (radians) - xi = (cos_dec * math.sin(dra)) / cos_c - eta = (cos_dec0 * sin_dec - sin_dec0 * cos_dec * math.cos(dra)) / cos_c - - # convert to arcsec offsets then pixels - dx_arcsec = -xi * 206264.806 # negate: East is left on POSS - dy_arcsec = -eta * 206264.806 # negate: North is up, pixel y is down - - dx_px = dx_arcsec * px_per_arcsec - dy_px = dy_arcsec * px_per_arcsec - - # apply image rotation - rx = dx_px * cos_t - dy_px * sin_t - ry = dx_px * sin_t + dy_px * cos_t - - points.append((cx + fx * rx, cy + fy * ry)) - return points - - -def _orient_image(return_image, roll, flip_image, flop_image): - """ - Orient a source survey image to match the eyepiece view. - - Applies the fixed 180° baseline rotation (plus the live solve roll), - then the active telescope's flip/flop mirrors: - flip_image -> top-to-bottom (vertical) mirror - flop_image -> left-to-right (horizontal) mirror - - Mirrors are applied AFTER the rotation so a mirrored optical train - (e.g. a refractor/SCT with a star diagonal) correctly reverses the - apparent sense of roll. See ADR 0003. - """ - # rotate for roll / newtonian orientation - image_rotate = 180 - if roll is not None: - image_rotate += roll - return_image = return_image.rotate(image_rotate) - - if flip_image: - return_image = return_image.transpose(Image.FLIP_TOP_BOTTOM) - if flop_image: - return_image = return_image.transpose(Image.FLIP_LEFT_RIGHT) - - return return_image - - -def get_display_image( - catalog_object, - eyepiece_text, - fov, - roll, - display_class, - burn_in=True, - magnification=None, - show_nsew=True, - show_bbox=True, - flip_image=False, - flop_image=False, -): - """ - Returns a 128x128 image buffer for - the catalog object/source - Resizing/cropping as needed to achieve FOV - in degrees - fov: 1-.125 - roll: - degrees - """ - object_image_path = resolve_image_name(catalog_object, source="POSS") - logger.debug("object_image_path = %s", object_image_path) - if not os.path.exists(object_image_path): - return_image = Image.new("RGB", display_class.resolution) - ri_draw = ImageDraw.Draw(return_image) - if burn_in: - ri_draw.text( - (30, 50), - _("No Image"), - font=display_class.fonts.large.font, - fill=display_class.colors.get(128), - ) - else: - return_image = Image.open(object_image_path) - - image_rotate = 180 - if roll is not None: - image_rotate += roll - - # Orient to match the eyepiece view (see ADR 0003) - return_image = _orient_image(return_image, roll, flip_image, flop_image) - - # FOV - fov_size = int(1024 * fov / 2) - return_image = return_image.crop( - ( - 512 - fov_size, - 512 - fov_size, - 512 + fov_size, - 512 + fov_size, - ) - ) - return_image = return_image.resize( - (display_class.fov_res, display_class.fov_res), Image.LANCZOS - ) - - # RED - return_image = image_util.make_red(return_image, display_class.colors) - - if burn_in: - # circle - _circle_dim = Image.new( - "RGB", - (display_class.fov_res, display_class.fov_res), - display_class.colors.get(127), - ) - _circle_draw = ImageDraw.Draw(_circle_dim) - _circle_draw.ellipse( - [2, 2, display_class.fov_res - 2, display_class.fov_res - 2], - fill=display_class.colors.get(255), - ) - return_image = ImageChops.multiply(return_image, _circle_dim) - - ri_draw = ImageDraw.Draw(return_image) - ri_draw.ellipse( - [2, 2, display_class.fov_res - 2, display_class.fov_res - 2], - outline=display_class.colors.get(64), - width=1, - ) - - cx = display_class.fov_res / 2 - cy = display_class.fov_res / 2 - fx = -1 if flop_image else 1 - fy = -1 if flip_image else 1 - - # NSEW cardinal labels — show the leftmost and rightmost of the - # four cardinals, out at the FOV ring. Clamped clear of the - # titlebar and footer text (drawn later, full brightness) so - # both letters always stay visible. - if show_nsew: - (nx, ny), (ex, ey) = cardinal_vectors(image_rotate, fx, fy) - label_font = display_class.fonts.base - label_color = display_class.colors.get(128) - r_label = display_class.fov_res / 2 - 2 - top_limit = display_class.titlebar_height + label_font.height - bottom_limit = display_class.fov_res - label_font.height * 2 - candidates = [ - ("N", nx, ny), - ("S", -nx, -ny), - ("E", ex, ey), - ("W", -ex, -ey), - ] - by_x = sorted(candidates, key=lambda c: c[1]) - for label, dx, dy in (by_x[0], by_x[-1]): - lx = cx + dx * r_label - label_font.width / 2 - ly = cy + dy * r_label - label_font.height / 2 - lx = max(0, min(lx, display_class.fov_res - label_font.width)) - ly = max(top_limit, min(ly, bottom_limit)) - ui_utils.shadow_outline_text( - ri_draw, - (lx, ly), - label, - font=label_font, - align="left", - fill=label_color, - shadow_color=display_class.colors.get(0), - outline=1, - ) - - # Size overlay - extents = catalog_object.size.extents - if show_bbox and extents and fov > 0: - px_per_arcsec = display_class.fov_res / (fov * 3600) - overlay_color = display_class.colors.get(100) - - if catalog_object.size.is_vertices: - points = vertex_overlay_points( - extents, - catalog_object.ra, - catalog_object.dec, - image_rotate, - px_per_arcsec, - cx, - cy, - fx, - fy, - ) - if len(points) >= 2: - ri_draw.line(points, fill=overlay_color, width=1) - elif len(extents) == 1: - r = extents[0] * px_per_arcsec / 2 - ri_draw.ellipse( - [cx - r, cy - r, cx + r, cy + r], - outline=overlay_color, - width=1, - ) - else: - points = size_overlay_points( - extents, - catalog_object.size.position_angle, - image_rotate, - px_per_arcsec, - cx, - cy, - fx, - fy, - ) - if points: - ri_draw.polygon(points, outline=overlay_color) - - # Pad out image if needed - if display_class.fov_res != display_class.resX: - pad_image = Image.new("RGB", display_class.resolution) - pad_image.paste( - return_image, - ( - int((display_class.resX - display_class.fov_res) / 2), - 0, - ), - ) - return_image = pad_image - ri_draw = ImageDraw.Draw(return_image) - if display_class.fov_res != display_class.resY: - pad_image = Image.new("RGB", display_class.resolution) - pad_image.paste( - return_image, - ( - 0, - int((display_class.resY - display_class.fov_res) / 2), - ), - ) - return_image = pad_image - ri_draw = ImageDraw.Draw(return_image) - - if burn_in: - # Top text - FOV on left, magnification on right - ui_utils.shadow_outline_text( - ri_draw, - (1, display_class.titlebar_height - 1), - f"{fov:0.2f}°", - font=display_class.fonts.base, - align="left", - fill=display_class.colors.get(254), - shadow_color=display_class.colors.get(0), - outline=2, - ) - - magnification_text = ( - f"{magnification:.0f}x" if magnification and magnification > 0 else "?x" - ) - ui_utils.shadow_outline_text( - ri_draw, - ( - display_class.resX - (display_class.fonts.base.width * 4), - display_class.titlebar_height - 1, - ), - magnification_text, - font=display_class.fonts.base, - align="right", - fill=display_class.colors.get(254), - shadow_color=display_class.colors.get(0), - outline=2, - ) - - # Bottom text - only eyepiece information - ui_utils.shadow_outline_text( - ri_draw, - (1, display_class.resY - (display_class.fonts.base.height * 1.1)), - eyepiece_text, - font=display_class.fonts.base, - align="left", - fill=display_class.colors.get(128), - shadow_color=display_class.colors.get(0), - outline=2, - ) - - return return_image - - -def resolve_image_name(catalog_object, source): - """ - returns the image path for this object - """ - - def create_image_path(image_name): - last_char = str(image_name)[-1] - image = f"{BASE_IMAGE_PATH}/{last_char}/{image_name}_{source}.jpg" - exists = os.path.exists(image) - return exists, image - - # Try primary name - image_name = f"{catalog_object.catalog_code}{catalog_object.sequence}" - ok, image = create_image_path(image_name) - - if ok: - catalog_object.image_name = image - return image - - # Try alternatives - for name in catalog_object.names: - alt_image_name = f"{''.join(name.split())}" - ok, image = create_image_path(alt_image_name) - if ok: - catalog_object.image_name = image - return image - - return "" - - -def create_catalog_image_dirs(): - """ - Checks for and creates catalog_image dirs - """ - if not os.path.exists(BASE_IMAGE_PATH): - os.makedirs(BASE_IMAGE_PATH) - - for i in range(0, 10): - _image_dir = f"{BASE_IMAGE_PATH}/{i}" - if not os.path.exists(_image_dir): - os.makedirs(_image_dir) diff --git a/python/PiFinder/catalog_base.py b/python/PiFinder/catalog_base.py index ac103cedb..f2bd1771a 100644 --- a/python/PiFinder/catalog_base.py +++ b/python/PiFinder/catalog_base.py @@ -120,6 +120,17 @@ def add_objects(self, objects: List): assert self.check_sequences() self.last_filtered = 0 # objects changed -> invalidate filter cache + def replace_objects(self, objects: List) -> None: + """Replace a dynamic catalog in one assignment, then rebuild indices.""" + replacement = list(objects) + replacement.sort(key=self.sort) + assert len({obj.sequence for obj in replacement}) == len(replacement) + self.__objects = replacement + self.max_sequence = max((obj.sequence for obj in replacement), default=0) + self._update_id_to_pos() + self._update_sequence_to_pos() + self.last_filtered = 0 + def clear_objects(self): """ Remove all objects and reset the sequence/id indexes. 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/catalogs.py b/python/PiFinder/catalogs.py index bae42010b..e23803262 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -121,6 +121,10 @@ def __init__( self._constellations = constellations self._selected_catalogs = set(selected_catalogs) self.last_filtered_time = 0 + # Dynamic catalogs can replace their objects without changing the + # active filter criteria. Wake open lists without invalidating every + # unchanged catalog's cached result. + self._catalog_content_dirty = False # Whether alt/az was available when verdicts were last computed. # Verdicts computed without it skip the altitude test entirely, so # they go stale the moment a fix arrives (see is_stale). @@ -137,11 +141,18 @@ def load_from_config(self, config_object: Config): self._constellations = config_object.get_option("filter.constellations", []) self._selected_catalogs = config_object.get_option("filter.selected_catalogs") self.last_filtered_time = 0 + self._catalog_content_dirty = False def mark_dirty(self): """Mark the filter as dirty, triggering a re-filter on next check""" self.dirty_time = time.time() + def mark_catalog_content_dirty(self) -> None: + self._catalog_content_dirty = True + + def clear_catalog_content_dirty(self) -> None: + self._catalog_content_dirty = False + @property def magnitude(self): return self._magnitude @@ -217,6 +228,8 @@ def is_dirty(self) -> bool: parameter changed since the last filter (dirty), or time-sensitive criteria have aged out (stale — see is_stale). False if not """ + if self._catalog_content_dirty: + return True if self.last_filtered_time > self.dirty_time: return self.is_stale() else: @@ -373,6 +386,12 @@ def filter_objects(self) -> List[CompositeObject]: self.last_filtered = time.time() return self.filtered_objects + def invalidate_filter_cache(self) -> None: + """Invalidate only this catalog after its runtime objects change.""" + self.last_filtered = 0 + for obj in self._get_objects(): + obj.last_filtered_time = 0 + def get_filtered_objects(self): return self.filtered_objects @@ -383,6 +402,10 @@ def get_age(self) -> Optional[int]: """If the catalog data is time-sensitive, return age in days.""" return None + def get_data_label(self) -> Optional[str]: + """Optional compact source-edition label for object-list headers.""" + return None + def get_status(self) -> CatalogStatus: """ Return the current status of the catalog with transition tracking. @@ -425,6 +448,8 @@ def filter_catalogs(self): self.catalog_filter.mark_dirty() for catalog in self.__catalogs: catalog.filter_objects() + if self.catalog_filter is not None: + self.catalog_filter.clear_catalog_content_dirty() def mark_logged(self, obj: CompositeObject) -> None: """ @@ -697,7 +722,6 @@ def init_planets(self, dt): if not planet_dict: logger.debug("No GPS lock during initialization - will retry soon") - self.initialised = True # Still mark as initialized so timer starts return sequence = 0 @@ -743,6 +767,7 @@ def do_timed_task(self): dt = self.shared_state.datetime() if not self.initialized: self.init_planets(dt) + return planet_dict = sf_utils.calc_planets(dt) @@ -764,6 +789,9 @@ def do_timed_task(self): obj.mag_str = obj.mag.calc_two_mag_representation() except (KeyError, ValueError) as e: logger.error(f"Error updating planet {name}: {e}") + self.invalidate_filter_cache() + if self.catalog_filter is not None: + self.catalog_filter.mark_catalog_content_dirty() class CatalogBackgroundLoader: @@ -1006,6 +1034,14 @@ def build(self, shared_state, ui_queue=None) -> Catalogs: ) all_catalogs.add(comet_catalog) + from PiFinder.asteroid_catalog import AsteroidCatalog + + asteroid_catalog: Catalog = AsteroidCatalog( + timez.utc_now(), + shared_state=shared_state, + ) + all_catalogs.add(asteroid_catalog) + assert self.check_catalogs_sequences(all_catalogs) is True return all_catalogs @@ -1015,7 +1051,7 @@ def check_catalogs_sequences(self, catalogs: Catalogs): if not result: logger.error("Duplicate sequence catalog %s!", catalog.catalog_code) return False - return True + return True def _create_full_composite_object( self, @@ -1115,8 +1151,7 @@ def _build_composite( def _on_loader_progress(self, loaded: int, total: int, catalog: str) -> None: """Progress callback - log every 10K objects""" - if loaded % 10000 == 0 or loaded == total: - logger.info(f"Background loading: {loaded}/{total} ({catalog})") + pass # Muted to reduce log noise def _on_loader_complete( self, loaded_objects: List[CompositeObject], ui_queue diff --git a/python/PiFinder/comet_catalog.py b/python/PiFinder/comet_catalog.py index f6f8c3b50..70af3b9fa 100644 --- a/python/PiFinder/comet_catalog.py +++ b/python/PiFinder/comet_catalog.py @@ -16,6 +16,7 @@ from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject import PiFinder.comets as comets from PiFinder.utils import Timer, comet_file +from PiFinder import timez from PiFinder.calc_utils import sf_utils logger = logging.getLogger("CometCatalog") @@ -55,19 +56,12 @@ def __init__(self, dt: datetime.datetime, shared_state: SharedStateObj): self._timer.do_timed_task = self.do_timed_task self._timer.time_delay_seconds = lambda: self.time_delay_seconds - # Check if we need to download - want_download, reason = comets.check_if_comet_download_needed(comet_file) - - if want_download: - logger.info(f"Download needed: {reason}") - # Start download in background and wait for completion - download_thread = threading.Thread(target=self._download_once, daemon=True) - download_thread.start() - download_thread.join() # Wait for download to complete - - # Now try to initialize comets immediately (if GPS available) + # Existing elements stay usable while freshness is checked and a new + # file downloads in the background. if self.shared_state.altaz_ready() and os.path.exists(comet_file): - self.do_timed_task() # Initialize immediately + self.do_timed_task() + + threading.Thread(target=self._refresh_if_needed, daemon=True).start() # Start timer after initialization self._timer.start_timer() @@ -90,14 +84,12 @@ def get_age(self) -> Optional[int]: self._cached_file_mtime = os.path.getmtime(comet_file) # Get file modification time from cache - local_date = datetime.datetime.fromtimestamp( - self._cached_file_mtime, tz=pytz.UTC - ) + local_date = timez.utc_from_timestamp(self._cached_file_mtime) # Calculate age using GPS time now = self.shared_state.datetime() if now.tzinfo is None: - now = now.replace(tzinfo=pytz.UTC) + now = pytz.UTC.localize(now) age_days = (now - local_date).total_seconds() / 86400 return round(age_days) @@ -108,7 +100,7 @@ def get_status(self) -> CatalogStatus: current_state = CatalogState.DOWNLOADING elif not self.shared_state.altaz_ready(): current_state = CatalogState.NO_GPS - elif not self.initialized: + elif self.calculation_progress is not None or not self.initialized: current_state = CatalogState.CALCULATING else: current_state = CatalogState.READY @@ -142,7 +134,7 @@ def _download_once(self): try: - def progress_callback(progress: int): + def progress_callback(progress: Optional[int]): self.download_progress = progress self._is_downloading = True @@ -151,20 +143,27 @@ def progress_callback(progress: int): success, _, file_mtime = comets.comet_data_download( comet_file, progress_callback=progress_callback ) - self._is_downloading = False - self.download_progress = None - # Update cached mtime after download - use the timestamp from download if success and file_mtime is not None: self._cached_file_mtime = file_mtime + if self.shared_state.altaz_ready(): + self.do_timed_task() age = self.get_age() age_str = f"{age} days" if age is not None else "? days" logger.info(f"Download completed: success={success}, age={age_str}") return success finally: + self._is_downloading = False + self.download_progress = None self._download_lock.release() + def _refresh_if_needed(self): + want_download, reason = comets.check_if_comet_download_needed(comet_file) + if want_download: + logger.info("Comet download needed: %s", reason) + self._download_once() + def refresh(self): """ Trigger a refresh by checking if download is needed. @@ -172,11 +171,6 @@ def refresh(self): """ logger.info("Refresh called - checking if download needed") - # Clear existing objects immediately - if self.get_objects(): - self.clear_objects() - self.initialized = False - # Do the check and download in background thread to return immediately def refresh_task(): # Check if we need to download @@ -184,16 +178,10 @@ def refresh_task(): if want_download: logger.info(f"Refresh will download: {reason}") - # Delete file to trigger download - if os.path.exists(comet_file): - os.remove(comet_file) - logger.info("Deleted comet file") - - # Download self._download_once() else: logger.info(f"Refresh using existing file: {reason}") - # File is fresh, just reinitialize from existing file + # File is fresh, recalculate from the existing elements. if self.shared_state.altaz_ready() and os.path.exists(comet_file): self.do_timed_task() @@ -231,9 +219,6 @@ def time_delay_seconds(self) -> int: def init_comets(self, dt): """Initialize comet catalog - called when GPS lock is available. Idempotent.""" logger.info("Starting comet calculation") - # Clear any existing objects to make this idempotent - if self.get_objects(): - self.clear_objects() def progress_callback(progress: int): self.calculation_progress = progress @@ -243,14 +228,20 @@ def progress_callback(progress: int): comet_dict = comets.calc_comets(dt, progress_callback=progress_callback) if not comet_dict: - self.initialized = False + # A failed refresh must not discard an already usable catalog. + self.initialized = bool(self.get_objects()) self.calculation_progress = None return - for sequence, (name, comet) in enumerate(comet_dict.items()): - self.add_comet(sequence, name, comet) + objects = [ + self._make_comet(sequence, name, comet) + for sequence, (name, comet) in enumerate(comet_dict.items()) + ] + self.replace_objects(objects) self._virtual_id_manager.mint_ids(self) + if self.catalog_filter is not None: + self.catalog_filter.mark_catalog_content_dirty() self.initialized = True self.calculation_progress = None # Clear progress after completion @@ -258,31 +249,36 @@ def progress_callback(progress: int): def add_comet(self, sequence: int, name: str, comet: Dict[str, Dict[str, float]]): """Add a single comet to the catalog""" try: - ra, dec = comet["radec"] - constellation = sf_utils.radec_to_constellation(ra, dec) - desc = f"Distance to\nEarth: {comet['earth_distance']:.2f} AU\nSun: {comet['sun_distance']:.2f} AU" - - mag = MagnitudeObject([comet.get("mag", [])]) - obj = CompositeObject.from_dict( - { - "id": -1, - "obj_type": "CM", - "ra": ra, - "dec": dec, - "const": constellation, - "size": SizeObject([]), - "mag": mag, - "mag_str": mag.calc_two_mag_representation(), - "names": [name], - "catalog_code": "CM", - "sequence": sequence + 1, - "description": desc, - } - ) - self.add_object(obj) + self.add_object(self._make_comet(sequence, name, comet)) except (KeyError, ValueError) as e: logger.error(f"Error adding comet {name}: {e}") + def _make_comet( + self, sequence: int, name: str, comet: Dict[str, Dict[str, float]] + ) -> CompositeObject: + ra, dec = comet["radec"] + constellation = sf_utils.radec_to_constellation(ra, dec) + desc = f"Distance to\nEarth: {comet['earth_distance']:.2f} AU\nSun: {comet['sun_distance']:.2f} AU" + mag = MagnitudeObject([comet.get("mag", [])]) + return CompositeObject.from_dict( + { + "id": -1, + "obj_type": "CM", + "ra": ra, + "dec": dec, + "const": constellation, + "size": SizeObject([]), + "mag": mag, + "mag_str": mag.calc_two_mag_representation(), + "names": [name], + "catalog_code": "CM", + "sequence": sequence + 1, + "description": desc, + "earth_distance_au": comet["earth_distance"], + "sun_distance_au": comet["sun_distance"], + } + ) + def do_timed_task(self): """Recalculate comet catalog periodically. diff --git a/python/PiFinder/comets.py b/python/PiFinder/comets.py index 4621f32a1..ea309ff85 100644 --- a/python/PiFinder/comets.py +++ b/python/PiFinder/comets.py @@ -1,18 +1,17 @@ from typing import Dict, Any, Tuple, Optional, Callable -from datetime import datetime, timezone +from pathlib import Path from skyfield.data import mpc from skyfield.constants import GM_SUN_Pitjeva_2005_km3_s2 as GM_SUN from PiFinder.utils import Timer, comet_file from PiFinder.calc_utils import sf_utils -from PiFinder import timez +from PiFinder.download_utils import check_download_needed, download_atomic import numpy as np import pandas as pd -import requests -import os import logging import math logger = logging.getLogger("Comets") +COMET_VISIBLE_MAG_LIMIT = 15.0 def process_comet(comet_data, dt) -> Dict[str, Any]: @@ -35,7 +34,7 @@ def process_comet(comet_data, dt) -> Dict[str, Any]: + 2.5 * mag_k * math.log10(sun_distance.au) + 5.0 * math.log10(earth_distance.au) ) - if mag > 15: + if mag > COMET_VISIBLE_MAG_LIMIT: logger.debug(f"Filtering out {name}: mag={mag:.1f} (too dim)") return {} @@ -58,50 +57,20 @@ def process_comet(comet_data, dt) -> Dict[str, Any]: def check_if_comet_download_needed( local_filename, url=mpc.COMET_URL, timeout=5 ) -> Tuple[bool, str]: - """ - Check if comet data download is needed by comparing local file with remote. - - Args: - local_filename: Path to local file - url: URL to check - timeout: Request timeout in seconds - - Returns: - Tuple of (need_download: bool, reason: str) - """ - if not os.path.exists(local_filename): - return (True, "no existing file") - - try: - # Send a HEAD request to get headers without downloading - response = requests.head(url, timeout=timeout) - response.raise_for_status() - - last_modified = response.headers.get("Last-Modified") - if not last_modified: - return (False, "cannot verify remote date") + return check_download_needed(local_filename, url, timeout) - remote_date = datetime.strptime( - last_modified, "%a, %d %b %Y %H:%M:%S GMT" - ).replace(tzinfo=timezone.utc) - local_date = timez.utc_from_timestamp(os.path.getmtime(local_filename)) - - if remote_date > local_date: - age_diff = (remote_date - local_date).total_seconds() / 86400 - return (True, f"file outdated by {age_diff:.1f} days") - else: - return (False, "file is up to date") - - except requests.RequestException as e: - logger.warning(f"Could not check remote file: {e}") - return (False, f"network error: {e}") +def _validate_comet_file(path: Path) -> None: + with path.open("rb") as comet_data: + dataframe = mpc.load_comets_dataframe(comet_data) + if dataframe.empty: + raise ValueError("MPC comet file contains no objects") def comet_data_download( local_filename, url=mpc.COMET_URL, - progress_callback: Optional[Callable[[int], None]] = None, + progress_callback: Optional[Callable[[Optional[int]], None]] = None, ) -> Tuple[bool, Optional[float], Optional[float]]: """ Download comet data from the Minor Planet Center. @@ -109,55 +78,20 @@ def comet_data_download( Args: local_filename: Path to save the downloaded file url: URL to download from - progress_callback: Optional callback function that receives progress percentage (0-100) + progress_callback: Optional callback receiving a percentage (0-100), + or ``None`` when the server does not report a total size. Returns: Tuple of (success: bool, age_in_days: Optional[float], file_mtime: Optional[float]) file_mtime is the file's modification time as a timestamp (for caching) """ - try: - now = datetime.now(timezone.utc) - - logger.debug("Downloading comet data...") - response = requests.get(url, stream=True) - response.raise_for_status() - - # Get file size for progress calculation - total_size = int(response.headers.get("content-length", 0)) - downloaded = 0 - - with open(local_filename, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - downloaded += len(chunk) - - # Report progress if callback provided and total size known - if progress_callback and total_size > 0: - progress = int((downloaded / total_size) * 100) - progress_callback(progress) - - # Try to get Last-Modified to set file mtime - last_modified = response.headers.get("Last-Modified") - if last_modified: - remote_date = datetime.strptime( - last_modified, "%a, %d %b %Y %H:%M:%S GMT" - ).replace(tzinfo=timezone.utc) - file_mtime = remote_date.timestamp() - os.utime(local_filename, (file_mtime, file_mtime)) - age_days = (now - remote_date).total_seconds() / 86400 - else: - file_mtime = os.path.getmtime(local_filename) - age_days = None - - logger.debug("File downloaded successfully.") - if progress_callback: - progress_callback(100) - return True, age_days, file_mtime - - except requests.RequestException as e: - logger.error(f"Error downloading comet data: {e}") - return False, None, None + result = download_atomic( + url, + local_filename, + progress_callback=progress_callback, + validator=_validate_comet_file, + ) + return result.success, result.age_days, result.file_mtime def _load_comets_dataframe() -> pd.DataFrame: @@ -251,8 +185,16 @@ def _calc_comets_vectorized(comets_df: pd.DataFrame, dt) -> Dict[str, Any]: # builder), propagated in a single call -> heliocentric state, AU, # equatorial ICRF, relative to the Sun. kepler = mpc._comet_orbits(comets_df, sf_utils.ts, GM_SUN) - helio_pos = kepler._at(t)[0] - if helio_pos.ndim == 1: # propagate() squeezes a single comet to (3,) + # Skyfield 1.51+ lays the result out as (3, #orbits, #times) but sets + # output_shape from only t1.shape, so a batched orbit only reshapes cleanly + # when the target time is itself shaped (#orbits, 1). Give every comet the + # same target time as an (N, 1) column. Versions through 1.50 squeeze the + # singleton time dimension back out, so accept both return shapes. + t_batched = sf_utils.ts.tt_jd(np.full((len(comets_df), 1), t.tt)) + helio_pos = kepler._at(t_batched)[0] + if helio_pos.ndim == 3: + helio_pos = helio_pos[:, :, 0] + elif helio_pos.ndim == 1: helio_pos = helio_pos[:, np.newaxis] # Sun and observer are single 3-vectors relative to the solar-system @@ -279,11 +221,11 @@ def _calc_comets_vectorized(comets_df: pd.DataFrame, dt) -> Dict[str, Any]: names = comets_df["designation"].to_numpy() - # Keep comets that are NOT dimmer than mag 15. Phrased as ~(mag > 15) + # Keep comets that are NOT dimmer than the catalog safety limit. Phrased # rather than (mag <= 15) so NaN magnitudes are kept, matching the old # per-comet filter. comet_dict: Dict[str, Any] = {} - for i in np.nonzero(~(mag > 15))[0]: + for i in np.nonzero(~(mag > COMET_VISIBLE_MAG_LIMIT))[0]: name = str(names[i]) comet_dict[name] = { "name": name, diff --git a/python/PiFinder/composite_object.py b/python/PiFinder/composite_object.py index 36fdc39f2..2e7fe69eb 100644 --- a/python/PiFinder/composite_object.py +++ b/python/PiFinder/composite_object.py @@ -3,7 +3,8 @@ import numpy as np import json import math -from typing import List, Union, cast +from datetime import date +from typing import List, Optional, Union, cast from PiFinder.utils import is_number @@ -262,6 +263,15 @@ class CompositeObject: _details_loaded: bool = field(default=False) image_name: str = field(default="") surface_brightness: float = field(default=0.0) + # Runtime solar-system metadata. Kept structured so lists can sort it; + # descriptions are presentation, never a data source. + earth_distance_au: Optional[float] = field(default=None) + sun_distance_au: Optional[float] = field(default=None) + angular_motion_arcsec_per_hour: Optional[float] = field(default=None) + opposition_date: Optional[date] = field(default=None) + opposition_kind: str = field(default="") + peak_magnitude: Optional[float] = field(default=None) + peak_date: Optional[date] = field(default=None) logged: bool = field(default=False) last_filtered_time: float = 0 last_filtered_result: bool = True @@ -298,7 +308,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/config.py b/python/PiFinder/config.py index c002fbad3..a09240b14 100644 --- a/python/PiFinder/config.py +++ b/python/PiFinder/config.py @@ -38,7 +38,8 @@ def load_config(self): self.config_file_path = Path(utils.data_dir, "config.json") self.default_file_path = Path(utils.pifinder_dir, "default_config.json") - if not os.path.exists(self.config_file_path): + had_saved_config = os.path.exists(self.config_file_path) + if not had_saved_config: self._config_dict = {} else: with open(self.config_file_path, "r") as config_file: @@ -58,6 +59,8 @@ def load_config(self): with open(self.default_file_path, "r") as config_file: self._default_config_dict = json.load(config_file) + self._migrate_asteroid_filters(had_saved_config) + # Load the equipment config eq_config = self.get_option("equipment") if eq_config is None: @@ -99,6 +102,27 @@ def load_config(self): else: self.locations = locations.Locations.from_dict(loc_config) + def _migrate_asteroid_filters(self, had_saved_config: bool) -> None: + """Enable the new asteroid catalog once in persisted filter lists. + + Defaults already cover fresh installs. The marker prevents a later + user choice to disable asteroids from being undone on every startup. + """ + marker = "migration.asteroid_filter_v1" + if not had_saved_config or self._config_dict.get(marker): + return + + additions = ( + ("filter.selected_catalogs", "MP"), + ("filter.object_types", "AS"), + ) + for option, value in additions: + saved_values = self._config_dict.get(option) + if isinstance(saved_values, list) and value not in saved_values: + saved_values.append(value) + self._config_dict[marker] = True + self.dump_config() + def save_equipment(self): """ Saves the equipment object state diff --git a/python/PiFinder/db/objects_db.py b/python/PiFinder/db/objects_db.py index 43f3b12ff..07a10fdef 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 self._ensure_catalog_object_indexes() @@ -392,6 +379,53 @@ def get_catalog_objects(self): ) return results + def get_priority_catalog_joined(self, priority_codes=("NGC", "IC", "M")): + """Combined JOIN query: catalog_objects + objects for priority catalogs only.""" + start_time = time.time() + placeholders = ",".join("?" * len(priority_codes)) + self.cursor.execute( + f""" + SELECT co.id, co.object_id, co.catalog_code, co.sequence, co.description, + o.ra, o.dec, o.obj_type, o.const, o.size, o.mag, o.surface_brightness + FROM catalog_objects co + JOIN objects o ON co.object_id = o.id + WHERE co.catalog_code IN ({placeholders}) + """, + priority_codes, + ) + rows = self.cursor.fetchall() + elapsed = time.time() - start_time + logging.info( + f"get_priority_catalog_joined took {elapsed:.2f}s, returned {len(rows)} rows" + ) + return rows + + def get_priority_names(self, priority_codes=("NGC", "IC", "M")): + """Get names only for objects in priority catalogs (much smaller than full names table).""" + start_time = time.time() + placeholders = ",".join("?" * len(priority_codes)) + self.cursor.execute( + f""" + SELECT n.object_id, n.common_name FROM names n + WHERE n.object_id IN ( + SELECT DISTINCT co.object_id FROM catalog_objects co + WHERE co.catalog_code IN ({placeholders}) + ) + """, + priority_codes, + ) + results = self.cursor.fetchall() + name_dict = defaultdict(list) + for object_id, common_name in results: + name_dict[object_id].append(common_name.strip()) + for object_id in name_dict: + name_dict[object_id] = list(set(name_dict[object_id])) + elapsed = time.time() - start_time + logging.info( + f"get_priority_names took {elapsed:.2f}s, {len(results)} rows for {len(name_dict)} objects" + ) + return name_dict + # ---- IMAGES_OBJECTS methods ---- def insert_image_object(self, object_id, image_name): self.cursor.execute( diff --git a/python/PiFinder/db/observations_db.py b/python/PiFinder/db/observations_db.py index 7409c6d8a..7734bfdd8 100644 --- a/python/PiFinder/db/observations_db.py +++ b/python/PiFinder/db/observations_db.py @@ -1,17 +1,45 @@ import json import logging +from dataclasses import dataclass from pathlib import Path -from typing import List, Optional, Tuple from sqlite3 import Connection, Cursor +from threading import RLock +from typing import List, Optional, Tuple + +from PiFinder.composite_object import CompositeObject from PiFinder.db.db import Database +from PiFinder.db.objects_db import ObjectsDatabase import PiFinder.utils as utils -from PiFinder.composite_object import CompositeObject logger = logging.getLogger("Observations_DB") +@dataclass +class _ObservedIdentityCache: + fingerprint: tuple[tuple[int, int], tuple[int, int]] + listings: set[tuple[str, int]] + object_ids: set[int] + + +_observed_identity_caches: dict[tuple[Path, Path], _ObservedIdentityCache] = {} +_observed_identity_cache_lock = RLock() + + +def _database_fingerprint(path: Path) -> tuple[int, int]: + try: + stat = path.stat() + except OSError: + return 0, 0 + return stat.st_mtime_ns, stat.st_size + + class ObservationsDatabase(Database): - def __init__(self, db_path: Path = utils.observations_db): + def __init__(self, db_path: Optional[Path] = None): + # Resolved at call time, not as a default argument: an import-time + # default captures utils.observations_db before the test sandbox + # patches it, sending writes to the real ~/PiFinder_data. + if db_path is None: + db_path = utils.observations_db self._objects_db = None new_db = False if not db_path.exists(): @@ -31,11 +59,49 @@ def _get_objects_db(self): it. Opened lazily and kept for the life of this instance. """ if self._objects_db is None: - from PiFinder.db.objects_db import ObjectsDatabase - self._objects_db = ObjectsDatabase() return self._objects_db + def _identity_cache_key(self) -> tuple[Path, Path]: + return self.db_path.resolve(), Path(utils.pifinder_db).resolve() + + def _identity_cache_fingerprint(self) -> tuple[tuple[int, int], tuple[int, int]]: + observations_path, objects_path = self._identity_cache_key() + return ( + _database_fingerprint(observations_path), + _database_fingerprint(objects_path), + ) + + def _query_observed_identities( + self, + ) -> tuple[set[tuple[str, int]], set[int]]: + """Load listing and sky-object identities with one indexed query.""" + alias = "catalog_identity" + self.cursor.execute( + f"ATTACH DATABASE ? AS {alias}", (str(Path(utils.pifinder_db)),) + ) + try: + rows = self.cursor.execute( + f""" + SELECT DISTINCT observed.catalog, observed.sequence, + catalog_object.object_id + FROM obs_objects AS observed + LEFT JOIN {alias}.catalog_objects AS catalog_object + ON catalog_object.catalog_code = observed.catalog + AND catalog_object.sequence = observed.sequence + """ + ).fetchall() + finally: + self.cursor.execute(f"DETACH DATABASE {alias}") + + listings = {(row["catalog"], row["sequence"]) for row in rows} + object_ids = { + row["object_id"] + for row in rows + if row["object_id"] is not None and row["object_id"] >= 0 + } + return listings, object_ids + def _resolve_object_id(self, catalog: str, sequence: int) -> Optional[int]: """ Maps a listing to its objects-table id; None when the listing @@ -171,11 +237,17 @@ def log_object(self, session_uuid, obs_time, catalog, sequence, solution, notes) ) self.conn.commit() - # Update caches so filters reflect the new observation immediately - self.observed_objects_cache.add((catalog, sequence)) - object_id = self._resolve_object_id(catalog, sequence) - if object_id is not None and object_id >= 0: - self.observed_object_ids.add(object_id) + # Update the process-wide cache so every existing view reflects the + # new observation immediately. + with _observed_identity_cache_lock: + self.observed_objects_cache.add((catalog, sequence)) + object_id = self._resolve_object_id(catalog, sequence) + if object_id is not None and object_id >= 0: + self.observed_object_ids.add(object_id) + + cache = _observed_identity_caches.get(self._identity_cache_key()) + if cache is not None: + cache.fingerprint = self._identity_cache_fingerprint() observation_id = self.cursor.execute( "select last_insert_rowid() as id" @@ -205,14 +277,39 @@ def load_observed_objects_cache(self) -> None: entries. Listings that don't resolve to an object id (virtual objects, removed catalogs) stay listing-keyed only. """ - self.observed_objects_cache: set[tuple[str, int]] = { - (x["catalog"], x["sequence"]) for x in self.get_observed_objects() - } - self.observed_object_ids: set[int] = set() - for catalog, sequence in self.observed_objects_cache: - object_id = self._resolve_object_id(catalog, sequence) - if object_id is not None and object_id >= 0: - self.observed_object_ids.add(object_id) + with _observed_identity_cache_lock: + key = self._identity_cache_key() + fingerprint = self._identity_cache_fingerprint() + cache = _observed_identity_caches.get(key) + if cache is None or cache.fingerprint != fingerprint: + try: + listings, object_ids = self._query_observed_identities() + except Exception: + logger.warning( + "Could not resolve observed object identities; " + "observed status stays per listing", + exc_info=True, + ) + listings = { + (row["catalog"], row["sequence"]) + for row in self.get_observed_objects() + } + object_ids = set() + + if cache is None: + cache = _ObservedIdentityCache(fingerprint, listings, object_ids) + _observed_identity_caches[key] = cache + else: + # Existing database instances retain these set objects, so + # refresh them in place rather than stranding stale readers. + cache.listings.clear() + cache.listings.update(listings) + cache.object_ids.clear() + cache.object_ids.update(object_ids) + cache.fingerprint = fingerprint + + self.observed_objects_cache = cache.listings + self.observed_object_ids = cache.object_ids def check_logged(self, obj_record: CompositeObject): """ diff --git a/python/PiFinder/displays.py b/python/PiFinder/displays.py index d9baff94d..3a3fa286d 100644 --- a/python/PiFinder/displays.py +++ b/python/PiFinder/displays.py @@ -1,4 +1,5 @@ import functools +import logging import math from collections import namedtuple @@ -15,6 +16,7 @@ from PiFinder.ui.fonts import Fonts +logger = logging.getLogger("Display") ColorMask = namedtuple("ColorMask", ["mask", "mode"]) RED_RGB: ColorMask = ColorMask(np.array([1, 0, 0]), "RGB") @@ -77,8 +79,15 @@ class DisplayPygame_128(DisplayBase): def __init__(self): from luma.emulator.device import pygame + import pygame as pg + from pathlib import Path + + # Set window icon to welcome splash screen before creating display + icon_path = Path(__file__).parent.parent.parent / "images" / "welcome.png" + if icon_path.exists(): + icon = pg.image.load(str(icon_path)) + pg.display.set_icon(icon) - # init display (SPI hardware) pygame = pygame( width=128, height=128, @@ -119,6 +128,14 @@ class DisplayPygame_320(Layout320, DisplayBase): def __init__(self): from luma.emulator.device import pygame + import pygame as pg + from pathlib import Path + + # Set window icon to welcome splash screen before creating display + icon_path = Path(__file__).parent.parent.parent / "images" / "welcome.png" + if icon_path.exists(): + icon = pg.image.load(str(icon_path)) + pg.display.set_icon(icon) pygame = pygame( width=self.resolution[0], diff --git a/python/PiFinder/download_utils.py b/python/PiFinder/download_utils.py new file mode 100644 index 000000000..6e7acbdd2 --- /dev/null +++ b/python/PiFinder/download_utils.py @@ -0,0 +1,141 @@ +"""Safe downloads for runtime catalog data files. + +Catalog updates are deliberately transactional: callers keep using the existing +file while bytes arrive in a sibling temporary file. Only a complete, +validated response replaces the active file. +""" + +from __future__ import annotations + +import logging +import os +import tempfile +import time +from datetime import timezone +from email.utils import parsedate_to_datetime +from pathlib import Path +from typing import Callable, NamedTuple, Optional + +import requests + + +logger = logging.getLogger("CatalogDownload") + +ProgressCallback = Callable[[Optional[int]], None] +Validator = Callable[[Path], None] +REQUEST_TIMEOUT = (5, 30) + + +class DownloadResult(NamedTuple): + success: bool + age_days: Optional[float] + file_mtime: Optional[float] + error: Optional[str] = None + + +def _remote_timestamp(headers) -> Optional[float]: + value = headers.get("Last-Modified") + if not value: + return None + try: + parsed = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.timestamp() + + +def check_download_needed( + local_filename: Path | str, + url: str, + timeout: float = 5, +) -> tuple[bool, str]: + """Compare a local catalog file with the server's Last-Modified value.""" + local_path = Path(local_filename) + if not local_path.exists(): + return True, "no existing file" + + try: + response = requests.head(url, timeout=timeout) + response.raise_for_status() + except requests.RequestException as exc: + logger.warning("Could not check %s: %s", url, exc) + return False, f"network error: {exc}" + + remote_mtime = _remote_timestamp(response.headers) + if remote_mtime is None: + return False, "cannot verify remote date" + local_mtime = local_path.stat().st_mtime + if remote_mtime > local_mtime: + age_diff = (remote_mtime - local_mtime) / 86400.0 + return True, f"file outdated by {age_diff:.1f} days" + return False, "file is up to date" + + +def download_atomic( + url: str, + local_filename: Path | str, + progress_callback: Optional[ProgressCallback] = None, + validator: Optional[Validator] = None, + timeout=REQUEST_TIMEOUT, +) -> DownloadResult: + """Download and validate ``url`` before atomically replacing the local file. + + ``progress_callback`` receives an integer percentage when Content-Length is + known, otherwise ``None`` to request an indeterminate progress indicator. + """ + local_path = Path(local_filename) + local_path.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Optional[Path] = None + try: + response = requests.get(url, stream=True, timeout=timeout) + response.raise_for_status() + total_size = int(response.headers.get("content-length", 0) or 0) + downloaded = 0 + if progress_callback: + progress_callback(0 if total_size else None) + + with tempfile.NamedTemporaryFile( + mode="wb", + prefix=f".{local_path.name}.", + suffix=".tmp", + dir=local_path.parent, + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + for chunk in response.iter_content(chunk_size=8192): + if not chunk: + continue + temporary.write(chunk) + downloaded += len(chunk) + if progress_callback and total_size: + progress_callback(min(99, int(downloaded * 100 / total_size))) + temporary.flush() + os.fsync(temporary.fileno()) + + if downloaded == 0: + raise ValueError("downloaded file is empty") + if validator: + validator(temporary_path) + + remote_mtime = _remote_timestamp(response.headers) + if remote_mtime is not None: + os.utime(temporary_path, (remote_mtime, remote_mtime)) + os.replace(temporary_path, local_path) + temporary_path = None + + file_mtime = local_path.stat().st_mtime + age_days = (time.time() - file_mtime) / 86400.0 + if progress_callback: + progress_callback(100) + return DownloadResult(True, age_days, file_mtime) + except (OSError, ValueError, requests.RequestException) as exc: + logger.error("Could not download %s: %s", url, exc) + return DownloadResult(False, None, None, str(exc)) + finally: + if temporary_path is not None: + try: + temporary_path.unlink() + except FileNotFoundError: + pass 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/get_images.py b/python/PiFinder/get_images.py index 8bedfec7b..f3012873e 100644 --- a/python/PiFinder/get_images.py +++ b/python/PiFinder/get_images.py @@ -5,14 +5,18 @@ images from AWS """ -import requests import os -from tqdm import tqdm from concurrent.futures import ThreadPoolExecutor, as_completed from typing import List, Tuple -from PiFinder import cat_images +import requests +from tqdm import tqdm + from PiFinder.db.objects_db import ObjectsDatabase +from PiFinder.object_images.poss_provider import ( + BASE_IMAGE_PATH, + create_catalog_image_dirs, +) def check_missing_images() -> List[str]: @@ -34,9 +38,7 @@ def check_missing_images() -> List[str]: missing_images = [] for image_name in tqdm(image_names, desc="Checking existing images"): # Check if POSS image exists (primary check) - poss_path = ( - f"{cat_images.BASE_IMAGE_PATH}/{image_name[-1]}/{image_name}_POSS.jpg" - ) + poss_path = f"{BASE_IMAGE_PATH}/{image_name[-1]}/{image_name}_POSS.jpg" if not os.path.exists(poss_path): missing_images.append(image_name) @@ -79,7 +81,7 @@ def fetch_images_for_object( # Download POSS image poss_filename = f"{image_name}_POSS.jpg" - poss_path = f"{cat_images.BASE_IMAGE_PATH}/{seq_ones}/{poss_filename}" + poss_path = f"{BASE_IMAGE_PATH}/{seq_ones}/{poss_filename}" poss_url = f"https://ddbeeedxfpnp0.cloudfront.net/catalog_images/{seq_ones}/{poss_filename}" poss_success, poss_error = download_image_from_url(session, poss_url, poss_path) @@ -88,7 +90,7 @@ def fetch_images_for_object( # Download SDSS image sdss_filename = f"{image_name}_SDSS.jpg" - sdss_path = f"{cat_images.BASE_IMAGE_PATH}/{seq_ones}/{sdss_filename}" + sdss_path = f"{BASE_IMAGE_PATH}/{seq_ones}/{sdss_filename}" sdss_url = f"https://ddbeeedxfpnp0.cloudfront.net/catalog_images/{seq_ones}/{sdss_filename}" sdss_success, sdss_error = download_image_from_url(session, sdss_url, sdss_path) @@ -154,7 +156,7 @@ def main(): """ Main function to check for and download missing catalog images. """ - cat_images.create_catalog_image_dirs() + create_catalog_image_dirs() print("Checking for missing images...") missing_images = check_missing_images() diff --git a/python/PiFinder/gps_ubx_parser.py b/python/PiFinder/gps_ubx_parser.py index e931432b2..232ba4dd0 100644 --- a/python/PiFinder/gps_ubx_parser.py +++ b/python/PiFinder/gps_ubx_parser.py @@ -14,9 +14,8 @@ logger = logging.getLogger("GPS.parser") -# u-blox quality indicator (qualityInd / flags bits 0-2) value at which the -# signal is code locked; below this the receiver is still searching and any -# reported C/N0 is an unconfirmed acquisition candidate. +# u-blox quality indicator (qualityInd / flags bits 0-2) thresholds. +QUALITY_SIGNAL_ACQUIRED = 2 QUALITY_CODE_LOCKED = 4 @@ -372,13 +371,13 @@ def _parse_nav_svinfo(self, data: bytes) -> dict: is_used = bool(flags & 0x01) - # During cold-start acquisition the receiver reports estimated - # C/N0 for candidates it is still searching for; counting those - # makes the seen count start high and sink as they fail to - # confirm. Only quality >= 4 (code locked) is really tracked. - # uSat is counted over this same set so it can never exceed nSat: - # svUsed implies code lock, so restricting it drops nothing real. - if quality >= QUALITY_CODE_LOCKED: + # Include signals the receiver has acquired, but not idle channels + # or candidates which are still only being searched. A used flag + # below code lock is internally inconsistent and is ignored. + if cno > 0 and ( + quality >= QUALITY_CODE_LOCKED + or (quality >= QUALITY_SIGNAL_ACQUIRED and not is_used) + ): if is_used: used_sats += 1 satellites.append( diff --git a/python/PiFinder/hardware_detect.py b/python/PiFinder/hardware_detect.py index 26956b863..5771982e0 100644 --- a/python/PiFinder/hardware_detect.py +++ b/python/PiFinder/hardware_detect.py @@ -9,8 +9,8 @@ BQ25895 charger?". The battery monitor only spawns when the charger is detected. -Import-safe on dev machines: ``board`` is imported under try/except so -this module loads even without blinka / an I2C bus. +Import-safe on dev machines: the I2C bus factory is imported under +try/except so this module loads even without blinka / an I2C bus. """ import logging @@ -18,17 +18,17 @@ from PiFinder.types.hardware import HardwareCapabilities try: - import board + from PiFinder.i2c_bus import get_i2c except (ImportError, NotImplementedError): - board = None + get_i2c = None # type: ignore[assignment] logger = logging.getLogger("HardwareDetect") -# BQ25895 single-cell Li-ion charger, I2C address 0x6A on bus 1. +# BQ25895 single-cell Li-ion charger, I2C address 0x6A. BQ25895_ADDRESS = 0x6A -def i2c_present(address: int, bus: int = 1) -> bool: +def i2c_present(address: int) -> bool: """Non-destructive I2C presence check: does ``address`` ACK on the bus? @@ -41,10 +41,10 @@ def i2c_present(address: int, bus: int = 1) -> bool: Raises if no I2C bus is available (no blinka); callers that want a soft answer should catch. """ - if board is None: + if get_i2c is None: raise RuntimeError("blinka / board unavailable — no I2C bus") - i2c = board.I2C() + i2c = get_i2c() locked = False try: while not i2c.try_lock(): diff --git a/python/PiFinder/i2c_bus.py b/python/PiFinder/i2c_bus.py new file mode 100644 index 000000000..4232bc1a3 --- /dev/null +++ b/python/PiFinder/i2c_bus.py @@ -0,0 +1,62 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +I2C bus selection for PiFinder peripherals (BNO055 IMU, BQ25895 charger). + +The BCM2835/BCM2711 hardware I2C block (Pi 4 and earlier) has a +well-documented silicon bug: when a slave stretches the clock — which the +BNO055 does on almost every transaction — the controller can emit a +too-short SCL pulse and corrupt the transfer. Boards affected by that bug +provision a software (bit-banged) i2c-gpio bus on the same SDA/SCL pins +via a device-tree overlay; i2c-gpio implements clock stretching per spec. + +Which bus exists is a boot-configuration decision (the per-board NixOS +hardware profile). This module hands out whatever the device tree +provides: the i2c-gpio adapter when one is present, the default hardware +bus otherwise. Boards with a correct I2C controller (Pi 5 / CM5 with RP1) +simply don't provision the overlay and get the hardware bus. +""" + +import glob +import logging + +import board +from adafruit_extended_bus import ExtendedI2C + +logger = logging.getLogger("I2C") + + +def _is_gpio_adapter(adapter_dir: str) -> bool: + """Return True when the sysfs adapter dir belongs to an i2c-gpio bus. + + Checks the adapter name first, then the platform device's device-tree + compatible string (the adapter dir's parent) — the name format has + varied across kernel versions, the compatible string has not. + """ + try: + with open(adapter_dir + "/name") as handle: + if handle.read().strip().startswith("i2c-gpio"): + return True + except OSError: + pass + try: + with open(adapter_dir + "/../of_node/compatible", "rb") as handle: + return b"i2c-gpio" in handle.read() + except OSError: + return False + + +def get_i2c(): + """Return the I2C bus object for PiFinder peripherals. + + Prefers a bit-banged i2c-gpio adapter when the device tree provides + one; falls back to the default hardware bus (``board.I2C()``). + """ + for name_path in sorted(glob.glob("/sys/bus/i2c/devices/i2c-*/name")): + adapter_dir = name_path.rsplit("/", 1)[0] + if _is_gpio_adapter(adapter_dir): + bus_number = int(adapter_dir.rsplit("/i2c-", 1)[1]) + logger.info("Using i2c-gpio bus /dev/i2c-%d", bus_number) + return ExtendedI2C(bus_number) + logger.info("Using default hardware I2C bus") + return board.I2C() diff --git a/python/PiFinder/image_util.py b/python/PiFinder/image_util.py index cf1d1b4fe..6768e82bd 100644 --- a/python/PiFinder/image_util.py +++ b/python/PiFinder/image_util.py @@ -10,7 +10,6 @@ from PIL import Image, ImageChops import numpy as np -import scipy.ndimage def make_red(in_image, colors): @@ -37,6 +36,8 @@ def gamma_correct(in_value, gamma): def subtract_background(image, percent=1): + import scipy.ndimage + image = np.asarray(image, dtype=np.float32) if image.ndim == 3: assert image.shape[2] in (1, 3), "Colour image must have 1 or 3 colour channels" diff --git a/python/PiFinder/imu_pi.py b/python/PiFinder/imu_pi.py index adc2a85f3..348635ebb 100644 --- a/python/PiFinder/imu_pi.py +++ b/python/PiFinder/imu_pi.py @@ -9,7 +9,7 @@ from PiFinder import config from PiFinder.multiproclogging import MultiprocLogging from PiFinder.types.positioning import ImuSample -import board +from PiFinder.i2c_bus import get_i2c import adafruit_bno055 import logging import quaternion # Numpy quaternion @@ -27,7 +27,7 @@ class Imu: """ def __init__(self): - i2c = board.I2C() + i2c = get_i2c() self.sensor = adafruit_bno055.BNO055_I2C(i2c) # IMPLUS mode: Accelerometer + Gyro + Fusion data self.sensor.mode = adafruit_bno055.IMUPLUS_MODE @@ -197,9 +197,33 @@ def imu_monitor(shared_state, console_queue, log_queue): # fake-IMU fallback has no such attr (and self-throttles), hence the default. sample_period = getattr(imu, "imu_sample_frequency", 1 / 30) + # A transient bus error (e.g. the BCM2711 clock-stretching bug NACKing + # a transfer) must not kill the IMU process: skip the sample and retry. + # Only a persistent failure warrants re-creating Imu() — construction + # re-opens the bus and restores IMUPLUS mode, which matters because a + # power-glitched BNO055 wakes up in CONFIG mode. + reinit_after_errors = 60 # consecutive errors (~2 s at 30 Hz) + consecutive_errors = 0 + while True: loop_start = time.monotonic() - imu.update() + try: + imu.update() + consecutive_errors = 0 + except (OSError, RuntimeError) as e: + consecutive_errors += 1 + if consecutive_errors == 1: + logger.warning("IMU: bus error, retrying: %s", e) + if consecutive_errors >= reinit_after_errors: + logger.error("IMU: persistent bus errors, re-initialising sensor") + console_queue.put("IMU: bus errors, re-initialising") + try: + imu = Imu() + except (OSError, RuntimeError) as reinit_error: + logger.error("IMU: re-init failed: %s", reinit_error) + consecutive_errors = 0 + time.sleep(sample_period) + continue imu_sample.status = imu.calibration # Raw data + read epoch are captured by imu.update() in the same diff --git a/python/PiFinder/keyboard_local.py b/python/PiFinder/keyboard_local.py index 8899259c0..efe0f6cf6 100644 --- a/python/PiFinder/keyboard_local.py +++ b/python/PiFinder/keyboard_local.py @@ -31,9 +31,14 @@ class KeyboardLocal(KeyboardInterface): def __init__(self, q): try: from PyHotKey import Key, keyboard + + logger.info("PyHotKey imported successfully") except ModuleNotFoundError: logger.error("pyhotkey not supported on pi hardware") return + except Exception as e: + logger.error(f"Failed to import PyHotKey: {e}", exc_info=True) + return # pynput bug on macOS: KeyCode.__repr__ crashes with TypeError when vk is None. # PyHotKey calls repr(key) to look up hotkeys, so patch it to be safe. try: @@ -51,6 +56,7 @@ def _safe_repr(self): except Exception: pass try: + logger.info("Setting up keyboard bindings...") self.q = q # Configure unmodified keys keyboard.set_magickey_on_release(Key.left, self.callback, self.LEFT) @@ -95,10 +101,11 @@ def _safe_repr(self): keyboard.set_magickey_on_release("i", self.callback, self.LNG_UP) keyboard.set_magickey_on_release("k", self.callback, self.LNG_DOWN) keyboard.set_magickey_on_release("l", self.callback, self.LNG_RIGHT) + logger.info("Keyboard bindings set up successfully") except Exception as e: - logger.error("KeyboardLocal.__init__: {}".format(e)) + logger.error("KeyboardLocal.__init__ failed: {}".format(e), exc_info=True) # keyboard.logger = True - logger.debug("KeyboardLocal.__init__") + logger.info("KeyboardLocal.__init__ complete") def callback(self, key): self.q.put(key) @@ -106,9 +113,79 @@ def callback(self, key): def run_keyboard(q, shared_state, log_queue): MultiprocLogging.configurer(log_queue) - KeyboardLocal(q) - while True: - # the KeyboardLocal class has callbacks to handle - # keypresses. We just need to not terminate here - time.sleep(1) + logger.info("Keyboard process starting...") + + # Try pynput directly first (more reliable on macOS) + try: + from pynput import keyboard as pynput_keyboard + + logger.info("Using pynput for keyboard handling") + + # Key mapping + key_map = { + pynput_keyboard.Key.left: KeyboardInterface.LEFT, + pynput_keyboard.Key.up: KeyboardInterface.UP, + pynput_keyboard.Key.down: KeyboardInterface.DOWN, + pynput_keyboard.Key.right: KeyboardInterface.RIGHT, + "q": KeyboardInterface.PLUS, + "a": KeyboardInterface.MINUS, + "z": KeyboardInterface.SQUARE, + "m": KeyboardInterface.LNG_SQUARE, + "0": 0, + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6": 6, + "7": 7, + "8": 8, + "9": 9, + "w": KeyboardInterface.ALT_PLUS, + "s": KeyboardInterface.ALT_MINUS, + "d": KeyboardInterface.ALT_LEFT, + "r": KeyboardInterface.ALT_UP, + "f": KeyboardInterface.ALT_DOWN, + "g": KeyboardInterface.ALT_RIGHT, + "e": KeyboardInterface.ALT_0, + "j": KeyboardInterface.LNG_LEFT, + "i": KeyboardInterface.LNG_UP, + "k": KeyboardInterface.LNG_DOWN, + "l": KeyboardInterface.LNG_RIGHT, + } + + def on_release(key): + try: + # Handle special keys + if key in key_map: + q.put(key_map[key]) + logger.debug(f"Key released: {key} -> {key_map[key]}") + # Handle character keys + elif hasattr(key, "char") and key.char in key_map: + q.put(key_map[key.char]) + logger.debug(f"Key released: {key.char} -> {key_map[key.char]}") + except Exception as e: + logger.error(f"Error handling key: {e}") + + # Start listener + listener = pynput_keyboard.Listener(on_release=on_release) + listener.start() + logger.info("pynput keyboard listener started") + + while True: + time.sleep(1) + + except Exception as e: + logger.error(f"pynput failed, falling back to PyHotKey: {e}", exc_info=True) + + # Fallback to PyHotKey + try: + KeyboardLocal(q) + logger.info("KeyboardLocal initialized successfully") + except Exception as e2: + logger.error(f"Failed to initialize KeyboardLocal: {e2}", exc_info=True) + return + + while True: + time.sleep(1) diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index 6f6e2eb68..629c37794 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 @@ -124,6 +126,31 @@ def set_brightness(level, cfg): set_keypad_brightness(level * 0.05 * keypad_offsets[keypad_brightness]) +def apply_test_mode_gps(shared_state, location, console): + """ + Fake GPS fix + datetime used while test mode is active. + Called on toggle-ON and at startup when test_mode was persisted, + so a reboot in test mode comes back fully in test mode. + """ + dt = timez.utc(2025, 6, 28, 11, 0, 0) + shared_state.set_datetime(dt) + location.lat = 41.13 + location.lon = -120.97 + location.altitude = 1315 + location.source = "test" + location.error_in_m = 5 + location.lock = True + location.lock_type = 3 + location.last_gps_lock = timez.local_now().time().isoformat()[:8] + console.write(f"GPS: Location {location.lat} {location.lon} {location.altitude}") + shared_state.set_location(location) + sf_utils.set_location( + location.lat, + location.lon, + location.altitude, + ) + + def setup_dirs(): utils.create_path(Path(utils.data_dir)) utils.create_path(Path(utils.data_dir, "captures")) @@ -153,6 +180,8 @@ def __init__(self, cfg, shared_state, display_device): self.shared_state = shared_state self.display_device = display_device self.last_activity = time.time() + self.sleep_start_time = None + self.screen_off_start_time = None def register_activity(self): """ @@ -162,8 +191,9 @@ def register_activity(self): self.last_activity = time.time() # power states - # 0 = Sleep - # 1 = Wake + # -1 = Screen off + # 0 = Sleep + # 1 = Wake if self.shared_state.power_state() < 1: # wake up self.wake_up() @@ -176,6 +206,8 @@ def wake_up(self): Do all the wakeup things """ self.last_activity = time.time() + self.sleep_start_time = None + self.screen_off_start_time = None self.shared_state.set_power_state(1) self.wake_screen() @@ -184,6 +216,7 @@ def go_to_sleep(self): Do all the sleep things """ self.shared_state.set_power_state(0) + self.sleep_start_time = time.time() self.sleep_screen() def update(self): @@ -202,11 +235,36 @@ def update(self): if time.time() - self.last_activity > self.get_sleep_timeout(): self.go_to_sleep() - else: # We are asleepd, should we wake up? + elif self.shared_state.power_state() == 0: + # We are asleep, should we wake up or go to screen off? _imu = self.shared_state.imu() if _imu: if _imu.moving: self.wake_up() + return + + # Check if we should turn screen off + screen_off_timeout = self.get_screen_off_timeout() + if ( + screen_off_timeout > 0 + and self.sleep_start_time is not None + and time.time() - self.sleep_start_time > screen_off_timeout + ): + self.screen_off() + + # Screen off mode: LED heartbeat, longer sleep + if self.shared_state.power_state() == -1: + _imu = self.shared_state.imu() + if _imu and _imu.moving: + self.wake_up() + return + self.update_heartbeat() + time.sleep(1.0) + return + + # should we pause execution for a bit? + if self.shared_state.power_state() < 1: + time.sleep(0.2) def get_sleep_timeout(self): """ @@ -247,6 +305,23 @@ def sleep_screen(self): set_brightness(int(screen_brightness / 4), self.cfg) self.display_device.device.show() + def screen_off(self): + """Completely blank screen and turn off LEDs""" + self.shared_state.set_power_state(-1) + self.screen_off_start_time = time.time() + self.display_device.device.hide() + set_keypad_brightness(0) + + def update_heartbeat(self): + """Pulse all LEDs briefly every hour""" + if self.screen_off_start_time is None: + return + seconds_into_hour = (time.time() - self.screen_off_start_time) % 3600 + if seconds_into_hour < 0.5: + set_keypad_brightness(2) + else: + set_keypad_brightness(0) + def start_profiling(): """Start profiling for performance analysis""" @@ -444,14 +519,31 @@ def main( shared_state.set_ui_state(ui_state) shared_state.set_arch(arch) # Normal shared_state.set_hardware(capabilities) + # Initialize test_mode from config so camera process can read it at startup + shared_state.set_test_mode(cfg.get_option("test_mode", False)) logger.debug("Ui state in main is" + str(shared_state.ui_state())) console = UIConsole( display_device, None, shared_state, command_queues, cfg, Catalogs([]) ) + if shared_state.test_mode(): + apply_test_mode_gps(shared_state, location, console) console.write("Starting....") console.update() logger.info("Starting ....") + # One-shot notice from the boot watchdog: a failed upgrade was + # auto-rolled-back to this (previous) generation. + upgrade_failed_notice = utils.data_dir / "upgrade_failed.json" + if upgrade_failed_notice.exists(): + console.write("!! Update failed") + console.write("!! Rolled back") + console.update() + logger.warning("Previous upgrade failed; watchdog rolled back") + try: + upgrade_failed_notice.unlink() + except OSError: + pass + # spawn gps service.... console.write(" GPS") console.update() @@ -637,6 +729,18 @@ def main( _new_filter = CatalogFilter(shared_state=shared_state) _new_filter.load_from_config(cfg) catalogs.set_catalog_filter(_new_filter) + + # Initialize Gaia chart generator in background to avoid first-use delay + console.write(" Gaia Charts") + console.update() + logger.info(" Initializing Gaia chart generator...") + from PiFinder.object_images.gaia_chart import get_gaia_chart_generator + + chart_gen = get_gaia_chart_generator(cfg, shared_state) + # Trigger background loading so catalog is ready when needed + chart_gen.ensure_catalog_loading() + logger.info(" Gaia chart background loading started") + console.write(" Menus") console.update() @@ -658,6 +762,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) @@ -787,6 +897,9 @@ def main( except queue.Empty: pass + # Gaia catalog loading removed - now lazy-loads on first chart view + # (object_images triggers loading when needed) + # ui queue try: ui_command = ui_queue.get(block=False) @@ -809,25 +922,17 @@ def main( catalogs.catalog_filter.mark_dirty() menu_manager.message(_("Catalogs\nFully Loaded"), 2) elif ui_command == "test_mode": - dt = timez.utc(2025, 6, 28, 11, 0, 0) - shared_state.set_datetime(dt) - location.lat = 41.13 - location.lon = -120.97 - location.altitude = 1315 - location.source = "test" - location.error_in_m = 5 - location.lock = True - location.lock_type = 3 - location.last_gps_lock = timez.local_now().time().isoformat()[:8] - console.write( - f"GPS: Location {location.lat} {location.lon} {location.altitude}" - ) - shared_state.set_location(location) - sf_utils.set_location( - location.lat, - location.lon, - location.altitude, - ) + # Toggle test mode (store in both shared_state and config). + # The camera process follows shared_state.test_mode() + # directly, so this is the single point of control. + new_test_mode = not cfg.get_option("test_mode", False) + shared_state.set_test_mode(new_test_mode) + cfg.set_option("test_mode", new_test_mode) + if new_test_mode: + apply_test_mode_gps(shared_state, location, console) + menu_manager.message(_("Test Mode ON\nfake cam+GPS"), 2) + else: + menu_manager.message(_("Test Mode\nOFF"), 2) elif ui_command == "set_volume": # Master volume changed in the menu: re-push the level # (main owns both cfg and sound_queue). The player plays @@ -1115,11 +1220,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" @@ -1130,13 +1230,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()) @@ -1326,13 +1426,18 @@ def main( rlogger.warn("not using camera") from PiFinder import camera_none as camera # type: ignore[no-redef] - if args.keyboard.lower() == "pi": - from PiFinder import keyboard_pi as keyboard + # When using Pygame display, use built-in event polling (no keyboard subprocess needed) + if display_hardware in ["pg_128", "pg_320"]: + from PiFinder import keyboard_none as keyboard + + rlogger.info("using pygame built-in keyboard (no subprocess)") + elif args.keyboard.lower() == "pi": + from PiFinder import keyboard_pi as keyboard # type: ignore[no-redef] rlogger.info("using pi keyboard hat") elif args.keyboard.lower() == "local": if display_hardware.startswith("pg_"): - from PiFinder import keyboard_none as keyboard # type: ignore[no-redef] + from PiFinder import keyboard_none as keyboard rlogger.info("using pygame keyboard (main loop captures keys)") else: @@ -1340,7 +1445,7 @@ def main( rlogger.info("using local keyboard") elif args.keyboard.lower() == "none": - from PiFinder import keyboard_none as keyboard # type: ignore[no-redef] + from PiFinder import keyboard_none as keyboard rlogger.warning("using no keyboard") @@ -1354,4 +1459,11 @@ def main( main(log_helper, args.script, args.fps, args.verbose, args.profile_startup) except Exception: rlogger.exception("Exception in main(). Aborting program.") + # Logging is multiprocess (QueueHandler -> listener); os._exit() below + # can kill this process before the queued traceback is ever written to + # the log file. Write it straight to stderr (captured by the journal) + # and flush every handler so the cause is never lost on a hard abort. + traceback.print_exc() + sys.stderr.flush() + logging.shutdown() os._exit(1) diff --git a/python/PiFinder/multiproclogging.py b/python/PiFinder/multiproclogging.py index 46c780a12..9e150af51 100644 --- a/python/PiFinder/multiproclogging.py +++ b/python/PiFinder/multiproclogging.py @@ -10,7 +10,6 @@ import multiprocessing.queues from pathlib import Path from multiprocessing import Queue, Process -import multiprocessing from queue import Empty from time import sleep from typing import TextIO, List, Optional @@ -19,6 +18,8 @@ import logging.config import logging.handlers +from PiFinder import utils + class MultiprocLogging: """ @@ -190,7 +191,7 @@ def configurer(queue: Queue): queue, multiprocessing.queues.Queue ), "That's not a Queue! You have to pass a queue" - log_conf_file = Path("pifinder_logconf.json") + log_conf_file = utils.active_logconf_path() with open(log_conf_file, "r") as logconf: config = json5.load(logconf) logging.config.dictConfig(config) diff --git a/python/PiFinder/nearby.py b/python/PiFinder/nearby.py index ea7c36a9a..9e16ac157 100644 --- a/python/PiFinder/nearby.py +++ b/python/PiFinder/nearby.py @@ -1,48 +1,79 @@ from PiFinder.catalogs import CompositeObject -from typing import List +from typing import List, Optional, Sequence import time import numpy as np -from sklearn.neighbors import BallTree import logging logger = logging.getLogger("Catalog.Nearby") + +# Great-circle degrees the pointing may drift before the ranking is stale. MAX_DEVIATION = 1.0 -MAX_TIME = 2 +# Seconds before the ranking is re-run regardless of pointing. This exists to +# pick up catalog/filter changes and altitude drift, not pointing changes -- +# the sky turns 15 deg/hour, so a short cadence buys nothing. +MAX_TIME = 10 +# The Nearby list is a window onto the closest objects, not a total ordering of +# the catalog. See ADR 0029. +NEAREST_LIST_CAP = 200 + + +def great_circle_degrees(ra_a, dec_a, ra_b, dec_b) -> float: + """ + Angular separation between two RA/Dec pairs, in degrees. Scalar helper for + the refresh trigger; the ranking itself uses the BallTree. + """ + ra_a, dec_a, ra_b, dec_b = np.deg2rad([ra_a, dec_a, ra_b, dec_b]) + cos_sep = np.sin(dec_a) * np.sin(dec_b) + np.cos(dec_a) * np.cos(dec_b) * np.cos( + ra_a - ra_b + ) + return float(np.rad2deg(np.arccos(np.clip(cos_sep, -1.0, 1.0)))) class Nearby: - """Nearby class to calcluate and display the closest objects""" + """Nearby class to calculate and display the closest objects""" def __init__(self, shared_state) -> None: self.shared_state = shared_state self.closest_objects_finder = ClosestObjectsFinder() - self.last_ra = 0 - self.last_dec = 0 - self.last_refresh = 0 + self.last_ra: Optional[float] = None + self.last_dec: Optional[float] = None + self.last_refresh = 0.0 + self.result: Sequence[CompositeObject] = [] def set_items(self, items: list[CompositeObject]): self.closest_objects_finder.calculate_objects_balltree( objects=items, ) + def has_pointing(self) -> bool: + solution = self.shared_state.solution() + return bool(solution and solution.has_pointing()) + def should_refresh(self): + if not self.closest_objects_finder.is_ready(): + # No index yet -- set_items() has not run for the current list. + # Ranking now would replace a populated list with an empty one. + return False solution = self.shared_state.solution() if not solution or not solution.has_pointing(): # No solution yet (initial state before first successful solve) return False + if self.last_ra is None or self.last_dec is None: + return True aligned = solution.pointing.aligned.estimate ra, dec = aligned.RA, aligned.Dec - # After first successful solve, RA/Dec are guaranteed to be valid + # After first successful solve, RA/Dec are guaranteed to be valid. + # Compare on the sky: one degree of RA spans cos(dec) degrees, so a + # per-axis test re-ranks for invisible movement near the poles and + # fires permanently across the RA 0 wrap. + deviation = great_circle_degrees(ra, dec, self.last_ra, self.last_dec) should = ( - abs(ra - self.last_ra) > MAX_DEVIATION - or abs(dec - self.last_dec) > MAX_DEVIATION - or (time.time() - self.last_refresh) > MAX_TIME + deviation > MAX_DEVIATION or (time.time() - self.last_refresh) > MAX_TIME ) logger.debug( - "Should refresh? %s, %s, %s, %s", + "Should refresh? %s, %s deg, %s s", should, - ra - self.last_ra, - dec - self.last_dec, + deviation, time.time() - self.last_refresh, ) return should @@ -59,7 +90,9 @@ def refresh(self): self.last_dec = dec self.last_refresh = time.time() - self.result = self.closest_objects_finder.get_closest_objects(ra, dec) + self.result = self.closest_objects_finder.get_closest_objects( + ra, dec, n=NEAREST_LIST_CAP + ) return self.result @@ -68,27 +101,41 @@ def __init__(self): self._objects_balltree = None self._objects = None + def is_ready(self) -> bool: + """True once an index has been built over a non-empty object set.""" + return self._objects_balltree is not None + def calculate_objects_balltree(self, objects: list[CompositeObject]) -> None: """ - Calculates a flat list of objects and the balltree for those objects + Calculates a flat list of objects and the balltree for those objects. + + Rows are ``[dec_rad, ra_rad]``: sklearn's haversine metric reads + dimension 0 as latitude and dimension 1 as longitude. Feeding it + ``[ra, dec]`` computes separations on a swapped sphere -- correct only + between objects sharing a meridian, and increasingly wrong towards the + poles. See ADR 0029. """ deduplicated_objects = deduplicate_objects(objects) if not deduplicated_objects: self._objects = np.array([]) self._objects_balltree = None return - object_radecs = np.array( - [[np.deg2rad(x.ra), np.deg2rad(x.dec)] for x in deduplicated_objects] + object_decras = np.array( + [[np.deg2rad(x.dec), np.deg2rad(x.ra)] 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" + object_decras, leaf_size=20, metric="haversine" ) - def get_closest_objects(self, ra, dec, n: int = 0) -> List[CompositeObject]: + def get_closest_objects(self, ra, dec, n: int = 0) -> Sequence[CompositeObject]: """ - Takes the current catalog or a list of catalogs, gets the filtered - objects and returns the n closest objects to ra/dec + Returns the n closest objects to ra/dec, nearest first. n=0 ranks the + whole set -- callers drawing a list should pass a cap instead, since + the query and everything downstream of it is then O(n) rather than + O(catalog). """ if self._objects_balltree is None or self._objects is None: @@ -100,13 +147,9 @@ def get_closest_objects(self, ra, dec, n: int = 0) -> List[CompositeObject]: if n == 0: n = nr_objects - query = [[np.deg2rad(ra), np.deg2rad(dec)]] - # logger.debug("Query: %s, objects: %s", query, self._objects) + query = [[np.deg2rad(dec), np.deg2rad(ra)]] _, obj_ind = self._objects_balltree.query(query, k=min(n, nr_objects)) - # logger.debug("Found %i objects, from %i objects, k=%i", len(obj_ind), nr_objects, min(n, nr_objects)) - results = self._objects[obj_ind[0]] - # logger.debug("Found %i objects, from %i objects, n=%i", len(results), nr_objects, n) - return results + return self._objects[obj_ind[0]] def get_objects_within_radius( self, ra, dec, radius_deg: float @@ -118,13 +161,15 @@ def get_objects_within_radius( is empty. Unlike ``get_closest_objects`` (k-NN), this bounds the result by angular distance rather than count -- what the chart needs to plot the objects that actually fall inside the current field. + + The query row is ``[dec_rad, ra_rad]`` to match the tree's layout. """ if self._objects_balltree is None or self._objects is None: return [] if len(self._objects) == 0: return [] - query = [[np.deg2rad(ra), np.deg2rad(dec)]] + query = [[np.deg2rad(dec), np.deg2rad(ra)]] obj_ind = self._objects_balltree.query_radius(query, r=np.deg2rad(radius_deg)) return list(self._objects[obj_ind[0]]) diff --git a/python/PiFinder/net_policy.py b/python/PiFinder/net_policy.py new file mode 100644 index 000000000..10b9284f7 --- /dev/null +++ b/python/PiFinder/net_policy.py @@ -0,0 +1,206 @@ +"""Network policy daemon: wired -> wifi client -> access point. + +Event-driven via NetworkManager's GObject API (libnm) — the same API +sys_utils uses — plus a slow tick for the time-based rules. Replaces the +nmcli-parsing shell fallback, whose "wifi connected" check matched the AP +itself, so once the AP was up nothing ever retried the client network. + +The decision logic lives in net_policy_core (pure, unit-tested); this module +only observes NetworkManager and executes the returned actions. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import time +from pathlib import Path +from typing import Optional + +import gi + +gi.require_version("NM", "1.0") +from gi.repository import GLib, NM # noqa: E402 + +from PiFinder.net_policy_core import ( # noqa: E402 + AP_DOWN, + AP_UP, + PolicyState, + Snapshot, + decide, +) + +logger = logging.getLogger("NetPolicy") + +AP_CONNECTION_NAME = "PiFinder-AP" +WIFI_MODE_FILE = ( + Path(os.environ.get("PIFINDER_DATA", "/home/pifinder/PiFinder_data")) / "wifi_mode" +) + +# Safety-net re-evaluation cadence; NM signals are the primary trigger. +TICK_SECONDS = 10 +# Collapse bursts of NM signals into one evaluation. +DEBOUNCE_SECONDS = 2 + + +def _read_forced_ap() -> bool: + try: + return WIFI_MODE_FILE.read_text().strip() == "AP" + except OSError: + return False + + +def _count_ap_stations(iface: str) -> int: + """Associated stations on the AP interface, via `iw` (libnm has no API + for AP client lists). Errs toward "occupied" so a counting failure never + causes the retry logic to yank a possibly-used AP.""" + try: + out = subprocess.run( + ["iw", "dev", iface, "station", "dump"], + capture_output=True, + text=True, + timeout=10, + ).stdout + except (OSError, subprocess.SubprocessError): + return 1 + return sum(1 for line in out.splitlines() if line.startswith("Station")) + + +class NetPolicyDaemon: + def __init__(self) -> None: + self._client = NM.Client.new(None) + self._state = PolicyState() + self._debounce_id: Optional[int] = None + + self._client.connect("notify::active-connections", self._on_change) + self._client.connect("device-added", self._on_device_added) + self._client.connect("device-removed", self._on_change) + for dev in self._client.get_devices(): + self._hook_device(dev) + + GLib.timeout_add_seconds(TICK_SECONDS, self._on_tick) + self._evaluate() + + # -- NM signal plumbing -------------------------------------------------- + + def _hook_device(self, dev: NM.Device) -> None: + dev.connect("state-changed", self._on_change) + + def _on_device_added(self, _client, dev) -> None: + self._hook_device(dev) + self._schedule_evaluate() + + def _on_change(self, *_args) -> None: + self._schedule_evaluate() + + def _on_tick(self) -> bool: + self._evaluate() + return True # keep the tick alive + + def _schedule_evaluate(self) -> None: + if self._debounce_id is not None: + GLib.source_remove(self._debounce_id) + self._debounce_id = GLib.timeout_add_seconds( + DEBOUNCE_SECONDS, self._debounced_evaluate + ) + + def _debounced_evaluate(self) -> bool: + self._debounce_id = None + self._evaluate() + return False # one-shot + + # -- state observation --------------------------------------------------- + + def _wifi_iface(self) -> Optional[str]: + for dev in self._client.get_devices(): + if dev.get_device_type() == NM.DeviceType.WIFI: + return dev.get_iface() + return None + + def _snapshot(self) -> Snapshot: + eth_connected = False + wifi_client_active = False + ap_active = False + + for ac in self._client.get_active_connections(): + if ac.get_state() != NM.ActiveConnectionState.ACTIVATED: + continue + conn_type = ac.get_connection_type() + if conn_type == "802-3-ethernet": + eth_connected = True + elif conn_type == "802-11-wireless": + if ac.get_id() == AP_CONNECTION_NAME: + ap_active = True + else: + wifi_client_active = True + + ap_stations = 0 + if ap_active: + iface = self._wifi_iface() + ap_stations = _count_ap_stations(iface) if iface else 1 + + return Snapshot( + forced_ap=_read_forced_ap(), + eth_connected=eth_connected, + wifi_client_active=wifi_client_active, + ap_active=ap_active, + ap_stations=ap_stations, + ) + + # -- actions --------------------------------------------------------------- + + def _evaluate(self) -> None: + snap = self._snapshot() + action = decide(snap, self._state, time.monotonic()) + if action == AP_UP: + logger.info("no connectivity after grace period — bringing AP up") + self._ap_up() + elif action == AP_DOWN: + reason = ( + "wired connectivity present" + if snap.eth_connected + else ("idle AP — retrying client network") + ) + logger.info("%s — bringing AP down", reason) + self._ap_down() + + def _ap_up(self) -> None: + conn = None + for c in self._client.get_connections(): + if c.get_id() == AP_CONNECTION_NAME: + conn = c + break + if conn is None: + logger.error("AP connection %r not found", AP_CONNECTION_NAME) + return + self._client.activate_connection_async( + conn, None, None, None, self._on_action_done, "activate" + ) + + def _ap_down(self) -> None: + for ac in self._client.get_active_connections(): + if ac.get_id() == AP_CONNECTION_NAME: + self._client.deactivate_connection_async( + ac, None, self._on_action_done, "deactivate" + ) + return + + def _on_action_done(self, client, result, verb) -> None: + try: + if verb == "activate": + client.activate_connection_finish(result) + else: + client.deactivate_connection_finish(result) + except GLib.Error as e: + logger.warning("AP %s failed: %s", verb, e.message) + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") + NetPolicyDaemon() + GLib.MainLoop().run() + + +if __name__ == "__main__": + main() diff --git a/python/PiFinder/net_policy_core.py b/python/PiFinder/net_policy_core.py new file mode 100644 index 000000000..5b59547bd --- /dev/null +++ b/python/PiFinder/net_policy_core.py @@ -0,0 +1,95 @@ +"""Decision core for the PiFinder network policy daemon. + +Pure logic with no NetworkManager dependency, so it is unit-testable on any +machine. The daemon (net_policy.py) feeds it snapshots of the current network +state and executes the actions it returns. + +Connectivity priority: wired -> wifi client -> access point. The AP is a +fallback for reaching an otherwise-offline device, never a preference: with a +cable plugged in or a client network joined, the AP stays down. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +# How long NetworkManager gets to join a known client network before the AP +# comes up. Also applies after an idle-AP retry drops the AP. +GRACE_SECONDS = 45 + +# While the AP is up with nobody connected to it, drop it this often so NM can +# rescan and rejoin a client network that has come into range. Without this +# the AP is sticky: an active AP counts as "wifi connected", so nothing ever +# retries the client network. +CLIENT_RETRY_SECONDS = 300 + +AP_UP = "ap_up" +AP_DOWN = "ap_down" + + +@dataclass +class Snapshot: + """Point-in-time network state, as observed by the daemon.""" + + forced_ap: bool # operator forced AP mode via PiFinder_data/wifi_mode + eth_connected: bool # an ethernet connection is activated + wifi_client_active: bool # a non-AP wifi connection is activated + ap_active: bool # the PiFinder-AP connection is activated + ap_stations: int # clients associated to the AP (0 when ap inactive) + + +@dataclass +class PolicyState: + """Timing state carried between decisions.""" + + disconnected_since: Optional[float] = None + last_client_retry: Optional[float] = None + + +def decide(snap: Snapshot, state: PolicyState, now: float) -> Optional[str]: + """Return the action to take (AP_UP / AP_DOWN / None), updating `state`. + + `now` is a monotonic timestamp supplied by the caller. + """ + if snap.forced_ap: + state.disconnected_since = None + return None if snap.ap_active else AP_UP + + if snap.eth_connected: + # Wired connectivity is sufficient: the device is reachable and + # online. Dropping the AP frees the radio so NM autoconnects to a + # client network whenever one appears. + state.disconnected_since = None + return AP_DOWN if snap.ap_active else None + + if snap.wifi_client_active: + state.disconnected_since = None + return None + + if snap.ap_active: + # Offline fallback is serving. Periodically drop an *idle* AP so NM + # can retry the client network; the grace path below restores the AP + # if nothing joins. Never yanks the AP away from a connected user. + if state.last_client_retry is None: + state.last_client_retry = now + return None + if ( + snap.ap_stations == 0 + and now - state.last_client_retry >= CLIENT_RETRY_SECONDS + ): + state.last_client_retry = now + state.disconnected_since = now + return AP_DOWN + return None + + # Fully disconnected: give NM a grace period to join a known client + # network, then bring up the AP. + if state.disconnected_since is None: + state.disconnected_since = now + return None + if now - state.disconnected_since >= GRACE_SECONDS: + state.disconnected_since = None + state.last_client_retry = now + return AP_UP + return None diff --git a/python/PiFinder/nixos_migration_wifi.py b/python/PiFinder/nixos_migration_wifi.py deleted file mode 100644 index 03b0e688e..000000000 --- a/python/PiFinder/nixos_migration_wifi.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Convert wpa_supplicant.conf to NetworkManager keyfiles. - -Runs during the pre-migration phase on Debian, before reboot into the -initramfs. Keyfiles get staged into the initramfs build dir and the -init script just copies them into the new rootfs — much safer than -generating them in busybox shell after the rootfs has been formatted. -""" - -from __future__ import annotations - -import argparse -import os -import re -import sys -import uuid -from pathlib import Path -from typing import Callable, Iterable, List, Optional - - -WPA_NETWORK_OPEN = re.compile(r"^\s*network\s*=\s*\{") -WPA_NETWORK_CLOSE = re.compile(r"^\s*\}") -WPA_KEY_VALUE = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*$") - -HEX_PSK_RE = re.compile(r"^[0-9a-fA-F]{64}$") -HEX_STRING_RE = re.compile(r"^(?:[0-9a-fA-F]{2})+$") - - -class Network: - __slots__ = ("ssid", "psk") - - def __init__(self, ssid: str, psk: Optional[str]) -> None: - self.ssid = ssid - self.psk = psk - - def __eq__(self, other: object) -> bool: - return ( - isinstance(other, Network) - and self.ssid == other.ssid - and self.psk == other.psk - ) - - def __repr__(self) -> str: - psk_repr = "None" if self.psk is None else f"<{len(self.psk)}c>" - return f"Network(ssid={self.ssid!r}, psk={psk_repr})" - - -def _unquote(value: str) -> str: - """Strip a single surrounding pair of double quotes if present.""" - if len(value) >= 2 and value.startswith('"') and value.endswith('"'): - return value[1:-1] - return value - - -def _parse_ssid(value: str) -> str: - """Decode a wpa_supplicant ssid value. - - Quoted values are plain strings. Unquoted values are hex-encoded byte - strings per wpa_supplicant syntax — decode them here, or the network - name ends up mangled; surrogateescape keeps non-UTF-8 SSID bytes - round-trippable into the keyfile byte list. - """ - if len(value) >= 2 and value.startswith('"') and value.endswith('"'): - return value[1:-1] - if HEX_STRING_RE.fullmatch(value): - return bytes.fromhex(value).decode("utf-8", "surrogateescape") - return value - - -def parse_wpa_supplicant_conf(text: str) -> List[Network]: - """Parse the subset of wpa_supplicant.conf we care about. - - Recognises `network={ ... }` blocks containing `ssid=` and `psk=`. - Quoted values get their outer quotes stripped. Unquoted PSKs (the - 64-hex-char pre-shared-key form) are kept verbatim — NetworkManager - accepts both. Networks without an SSID are skipped. - - Returns the networks in declaration order. - """ - networks: List[Network] = [] - in_net = False - ssid: Optional[str] = None - psk: Optional[str] = None - - for raw_line in text.splitlines(): - line = raw_line.split("#", 1)[0].strip() - if not line: - continue - - if WPA_NETWORK_OPEN.match(line): - in_net = True - ssid = None - psk = None - continue - - if WPA_NETWORK_CLOSE.match(line): - if in_net and ssid is not None: - networks.append(Network(ssid=ssid, psk=psk)) - in_net = False - ssid = None - psk = None - continue - - if not in_net: - continue - - match = WPA_KEY_VALUE.match(line) - if not match: - continue - key, value = match.group(1), match.group(2) - if key == "ssid": - ssid = _parse_ssid(value) - elif key == "psk": - psk = _unquote(value) - - return networks - - -def ssid_to_bytelist(ssid: str) -> str: - """Encode an SSID as a NetworkManager keyfile byte list (`97;112;...`). - - NM's keyfile format documents exactly two ssid forms: a plain string and - a semicolon-separated list of DECIMAL byte values. Anything else (hex, - 0x-prefixed or not) is silently kept as a literal-string SSID, mangling - the network name so the device can never join it. surrogateescape - restores non-UTF-8 bytes captured from wpa_supplicant's hex ssid form. - """ - return "".join(f"{b};" for b in ssid.encode("utf-8", "surrogateescape")) - - -def escape_keyfile_value(value: str) -> str: - """Escape characters that have meaning in NM keyfile values. - - Backslash and semicolon are the only special characters in a plain - string value. (The byte-list format uses the semicolon as separator, - but we don't use that for the PSK.) - """ - return value.replace("\\", "\\\\").replace(";", "\\;") - - -_SAFE_FN_CHARS = re.compile(r"[^A-Za-z0-9._-]") - - -def sanitize_filename(ssid: str) -> str: - """Build a safe filename for a connection keyfile from an SSID. - - Non-alphanumeric characters (except `.`, `_`, `-`) are replaced with - `_`. The result is also guarded against the empty string and `.`/`..` - so a hostile or odd SSID can't escape the connections directory. - """ - cleaned = _SAFE_FN_CHARS.sub("_", ssid) - if cleaned in ("", ".", ".."): - return "wifi" - return cleaned - - -def build_keyfile( - ssid: str, - psk: Optional[str], - connection_uuid: Optional[str] = None, -) -> str: - """Build a NetworkManager keyfile body for a single WiFi connection. - - `psk=None` produces an open-network keyfile (no [wifi-security]). - `connection_uuid=None` generates a fresh v4 UUID. - """ - if connection_uuid is None: - connection_uuid = str(uuid.uuid4()) - - id_escaped = escape_keyfile_value(ssid) - ssid_encoded = ssid_to_bytelist(ssid) - - lines = [ - "[connection]", - f"id={id_escaped}", - f"uuid={connection_uuid}", - "type=wifi", - "autoconnect=true", - "", - "[wifi]", - "mode=infrastructure", - f"ssid={ssid_encoded}", - "", - ] - - if psk is not None and psk != "": - psk_escaped = escape_keyfile_value(psk) - lines.extend( - [ - "[wifi-security]", - "key-mgmt=wpa-psk", - f"psk={psk_escaped}", - "", - ] - ) - - lines.extend( - [ - "[ipv4]", - "method=auto", - "", - "[ipv6]", - "method=auto", - "", - ] - ) - return "\n".join(lines) - - -def emit_keyfiles( - networks: Iterable[Network], - output_dir: Path, - uuid_factory: Callable[[], str] = lambda: str(uuid.uuid4()), -) -> List[Path]: - """Write a keyfile per network into `output_dir`. - - Filenames are based on a sanitised SSID; collisions get a numeric - suffix. Files are mode 0600. Returns the list of paths written. - """ - output_dir.mkdir(parents=True, exist_ok=True) - written: List[Path] = [] - used: set = set() - - for net in networks: - base = sanitize_filename(net.ssid) - name = base - n = 1 - while name in used: - n += 1 - name = f"{base}_{n}" - used.add(name) - - path = output_dir / f"{name}.nmconnection" - body = build_keyfile(net.ssid, net.psk, connection_uuid=uuid_factory()) - path.write_text(body) - os.chmod(path, 0o600) - written.append(path) - - return written - - -def _main(argv: Optional[List[str]] = None) -> int: - parser = argparse.ArgumentParser( - description=( - "Convert wpa_supplicant.conf to NetworkManager keyfiles for " - "the PiFinder NixOS migration." - ) - ) - parser.add_argument( - "--wpa-conf", - required=True, - help="Path to the source wpa_supplicant.conf file.", - ) - parser.add_argument( - "--out", - required=True, - help="Directory to write the .nmconnection files into (created if absent).", - ) - args = parser.parse_args(argv) - - src = Path(args.wpa_conf) - if not src.exists(): - print(f"wpa_supplicant.conf not found at {src}", file=sys.stderr) - return 0 - - networks = parse_wpa_supplicant_conf(src.read_text()) - if not networks: - print(f"No networks parsed from {src}", file=sys.stderr) - return 0 - - written = emit_keyfiles(networks, Path(args.out)) - print(f"Wrote {len(written)} keyfile(s) to {args.out}", file=sys.stderr) - return 0 - - -if __name__ == "__main__": - sys.exit(_main()) diff --git a/python/PiFinder/nixos_upgrade.py b/python/PiFinder/nixos_upgrade.py new file mode 100644 index 000000000..1b37d5322 --- /dev/null +++ b/python/PiFinder/nixos_upgrade.py @@ -0,0 +1,582 @@ +"""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 + + # Whether the chosen camera boots via a specialisation entry is a property + # of the NEW build, so ask the new store path — never compare against + # --default-camera, which is the RUNNING generation's base camera. When the + # two builds disagree about the base (e.g. an imx477-base device upgrading + # onto an imx462-base build), that comparison concludes "camera is the + # base, nothing to do" and reboots into the wrong DTB, killing the camera. + specialisation = Path(store_path) / "specialisation" / camera if camera else None + if specialisation is not None and 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, store_path) + return + + arm_trial_marker(Path(store_path)) + command([str(Path(store_path) / "bin/switch-to-configuration"), "boot"]) + set_extlinux_default(camera or default_camera, store_path) + + +def set_extlinux_default(camera: str, store_path: str | None = None) -> 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. + + The helper maps camera name -> boot entry using its build's own base-camera + constant, so it must come from the NEW store path when available: the + running generation's copy applies the OLD base mapping to the NEW entries + and picks the wrong one whenever the two builds' base cameras differ. + """ + helper = "set-extlinux-default" + if store_path: + candidate = Path(store_path) / "sw/bin/set-extlinux-default" + if candidate.exists(): + helper = str(candidate) + command([helper, 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/obj_types.py b/python/PiFinder/obj_types.py index a2a7cfa13..16efb3a17 100644 --- a/python/PiFinder/obj_types.py +++ b/python/PiFinder/obj_types.py @@ -21,6 +21,7 @@ def _(key: str) -> str: "Ast": _("Asterism"), # TRANSLATORS: Object type "Pla": _("Planet"), # TRANSLATORS: Object type "CM": _("Comet"), # TRANSLATORS: Object type + "AS": _("Asteroid"), # TRANSLATORS: Object type "?": _("Unkn"), # TRANSLATORS: Object type } @@ -34,6 +35,7 @@ def _(key: str) -> str: "***": "dstar", "Ast": "ast", "Pla": "planet", + "AS": "asteroid", } # abbreviations and symbols as used in the NGC/IC catalogues diff --git a/python/PiFinder/object_images/__init__.py b/python/PiFinder/object_images/__init__.py new file mode 100644 index 000000000..690593c5f --- /dev/null +++ b/python/PiFinder/object_images/__init__.py @@ -0,0 +1,71 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Object image providers for catalog objects + +Provides POSS survey images and generated Gaia star charts +""" + +from typing import Union, Generator +from PIL import Image +from .poss_provider import POSSImageProvider +from .chart_provider import ChartImageProvider +from .image_base import ImageProvider + + +def get_display_image( + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=True, + force_chart=False, + **kwargs, +) -> Union[Image.Image, Generator]: + """ + Get display image for catalog object + + Returns POSS image if available, otherwise generated Gaia chart. + Use force_chart=True to prefer chart even if POSS exists. + + Args: + catalog_object: The astronomical object to image + eyepiece_text: Eyepiece description for overlay + fov: Field of view in degrees + roll: Rotation angle in degrees + display_class: Display configuration object + burn_in: Whether to add overlays (FOV, mag, etc.) + force_chart: Force Gaia chart even if POSS exists + **kwargs: Additional provider-specific parameters + + Returns: + PIL.Image for POSS images + Generator yielding progressive images for Gaia charts + """ + provider: ImageProvider + if force_chart: + provider = ChartImageProvider( + kwargs.get("config_object"), kwargs.get("shared_state") + ) + else: + poss = POSSImageProvider() + if poss.can_provide(catalog_object): + provider = poss + else: + provider = ChartImageProvider( + kwargs.get("config_object"), kwargs.get("shared_state") + ) + + return provider.get_image( + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=burn_in, + **kwargs, + ) + + +__all__ = ["get_display_image", "POSSImageProvider", "ChartImageProvider"] diff --git a/python/PiFinder/object_images/chart_provider.py b/python/PiFinder/object_images/chart_provider.py new file mode 100644 index 000000000..624d90181 --- /dev/null +++ b/python/PiFinder/object_images/chart_provider.py @@ -0,0 +1,132 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Gaia chart provider - generates star charts from Gaia catalog +""" + +from pathlib import Path +from typing import Generator +from PIL import ImageChops +from PiFinder import utils +from .image_base import ImageProvider, ImageType +import logging + +logger = logging.getLogger("PiFinder.ChartProvider") + + +class ChartImageProvider(ImageProvider): + """ + Provides dynamically generated Gaia star charts + + Uses the GaiaChartGenerator to create on-demand star charts + from the HEALPix-indexed Gaia star catalog. Returns a generator + that yields progressive updates as magnitude bands load. + """ + + def __init__(self, config_object, shared_state): + """ + Initialize chart provider + + Args: + config_object: PiFinder config object + shared_state: Shared state object + """ + self.config_object = config_object + self.shared_state = shared_state + self._chart_generator = None + + def can_provide(self, catalog_object, **kwargs) -> bool: + """ + Check if Gaia chart can be generated + + Returns True if Gaia star catalog exists + """ + gaia_catalog_path = Path(utils.data_dir, "gaia_stars", "metadata.json") + return gaia_catalog_path.exists() + + def get_image( + self, + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=True, + magnification=None, + config_object=None, + shared_state=None, + **kwargs, + ) -> Generator: + """ + Generate Gaia star chart + + Yields progressive chart updates as magnitude bands load. + Each yielded image has an `is_loading_placeholder` attribute + indicating whether it's a loading screen or actual chart. + + Returns: + Generator yielding PIL.Image objects + """ + from .image_utils import create_loading_image, create_no_image_placeholder + + # Get chart generator (singleton) + if self._chart_generator is None: + from .gaia_chart import get_gaia_chart_generator + + self._chart_generator = get_gaia_chart_generator( + self.config_object, self.shared_state + ) + + gaia_catalog_path = Path(utils.data_dir, "gaia_stars", "metadata.json") + + if not gaia_catalog_path.exists(): + logger.warning(f"Gaia star catalog not found at {gaia_catalog_path}") + placeholder = create_no_image_placeholder(display_class, burn_in=burn_in) + yield placeholder + return + + try: + # Ensure catalog loading started + logger.debug("Calling chart_generator.ensure_catalog_loading()...") + self._chart_generator.ensure_catalog_loading() + logger.debug(f"Catalog state: {self._chart_generator.get_catalog_state()}") + + # Create generator that yields converted images + for image in self._chart_generator.generate_chart( + catalog_object, + (display_class.fov_res, display_class.fov_res), + burn_in=burn_in, + display_class=display_class, + roll=roll, + ): + if image is None: + # Catalog not ready yet, show "Loading..." with progress + if self._chart_generator.catalog: + progress_text = self._chart_generator.catalog.load_progress + progress_percent = self._chart_generator.catalog.load_percent + else: + progress_text = "Initializing..." + progress_percent = 0 + + loading_image = create_loading_image( + display_class, + message="Loading...", + progress_text=progress_text, + progress_percent=progress_percent, + ) + loading_image.image_type = ImageType.LOADING + yield loading_image + else: + # Convert chart to red and yield it + red_image = ImageChops.multiply( + image.convert("RGB"), display_class.colors.red_image + ) + # Mark as Gaia chart image + red_image.image_type = ImageType.GAIA_CHART # type: ignore[attr-defined] + yield red_image + + except Exception as e: + logger.error(f"Gaia chart generation failed: {e}", exc_info=True) + placeholder = create_no_image_placeholder(display_class, burn_in=burn_in) + placeholder.image_type = ImageType.ERROR + yield placeholder diff --git a/python/PiFinder/object_images/gaia_chart.py b/python/PiFinder/object_images/gaia_chart.py new file mode 100644 index 000000000..545000417 --- /dev/null +++ b/python/PiFinder/object_images/gaia_chart.py @@ -0,0 +1,1080 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Gaia star chart generator for objects without DSS/POSS images + +Generates on-demand star charts using HEALPix-indexed Gaia star catalog. +Features: +- Equipment-aware FOV and magnitude limits +- Stereographic projection (matching chart.py) +- Center marker for target object +- Info overlays (FOV, magnification, eyepiece) +- Caching for performance +""" + +import logging +from pathlib import Path +from typing import Generator, Optional, Tuple + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from PiFinder import utils +from PiFinder.object_images.star_catalog import CatalogState, GaiaStarCatalog +from PiFinder.object_images.image_utils import ( + pad_to_display_resolution, + add_image_overlays, + eyepiece_image_rotation, +) + +logger = logging.getLogger("PiFinder.GaiaChart") + +# Global singleton instance to ensure same catalog across all uses +_gaia_chart_generator_instance = None + + +def get_gaia_chart_generator(config, shared_state): + """Get or create the global chart generator singleton""" + global _gaia_chart_generator_instance + logger.debug( + f">>> get_gaia_chart_generator() called, instance exists: {_gaia_chart_generator_instance is not None}" + ) + if _gaia_chart_generator_instance is None: + logger.info(">>> Creating new GaiaChartGenerator instance...") + _gaia_chart_generator_instance = GaiaChartGenerator(config, shared_state) + logger.info( + f">>> GaiaChartGenerator created, state: {_gaia_chart_generator_instance.get_catalog_state()}" + ) + else: + logger.debug( + f">>> Returning existing instance, state: {_gaia_chart_generator_instance.get_catalog_state()}" + ) + return _gaia_chart_generator_instance + + +class GaiaChartGenerator: + """ + Generate on-demand star charts with equipment-aware settings + + Usage: + gen = GaiaChartGenerator(config, shared_state) + image = gen.generate_chart(catalog_object, (128, 128), burn_in=True) + """ + + def __init__(self, config, shared_state): + """ + Initialize chart generator + + Args: + config: PiFinder config object + shared_state: Shared state object + """ + logger.info(">>> GaiaChartGenerator.__init__() called") + self.config = config + self.shared_state = shared_state + self.catalog = None + self.chart_cache = {} + self._lm_cache = None # Cache (sqm, eyepiece_id, lm) to avoid recalculation + + # Initialize font for text overlays + font_path = Path(Path.cwd(), "../fonts/RobotoMonoNerdFontMono-Bold.ttf") + try: + self.small_font = ImageFont.truetype(str(font_path), 8) + except Exception as e: + logger.warning(f"Failed to load font {font_path}: {e}, using default") + self.small_font = ImageFont.load_default() + + def get_catalog_state(self) -> CatalogState: + """Get current catalog loading state""" + if self.catalog is None: + return CatalogState.NOT_LOADED + return self.catalog.state + + def ensure_catalog_loading(self): + """ + Ensure catalog is loading or loaded + Triggers background load if needed + """ + logger.debug( + f">>> ensure_catalog_loading() called, catalog is None: {self.catalog is None}" + ) + + if self.catalog is None: + logger.info(">>> Calling initialize_catalog()...") + self.initialize_catalog() + logger.info(f">>> initialize_catalog() done, state: {self.catalog.state}") + + if self.catalog.state == CatalogState.NOT_LOADED: + # Trigger background load + location = self.shared_state.location() + sqm = self.shared_state.sqm() + + observer_lat = location.lat if location and location.lock else None + limiting_mag = self.get_limiting_magnitude(sqm) + + logger.info( + f">>> Starting background catalog load: lat={observer_lat}, mag_limit={limiting_mag:.1f}" + ) + self.catalog.start_background_load(observer_lat, limiting_mag) + logger.info( + f">>> start_background_load() called, new state: {self.catalog.state}" + ) + + def initialize_catalog(self): + """Create catalog instance (doesn't load data yet)""" + catalog_path = Path(utils.data_dir, "gaia_stars") + logger.info(f">>> initialize_catalog() - catalog_path: {catalog_path}") + + # Check if catalog exists before initializing + metadata_file = catalog_path / "metadata.json" + if not metadata_file.exists(): + logger.warning(f"Gaia star catalog not found at {catalog_path}") + logger.warning( + "To build catalog, run: python -m PiFinder.catalog_tools.gaia_downloader --mag-limit 12 --output /tmp/gaia.csv" + ) + logger.warning( + "Then: python -m PiFinder.catalog_tools.healpix_builder --input /tmp/gaia.csv --output {}/astro_data/gaia_stars".format( + Path.home() / "PiFinder" + ) + ) + + logger.info(">>> Creating GaiaStarCatalog instance...") + import time + + t0 = time.time() + self.catalog = GaiaStarCatalog(str(catalog_path)) + t_init = (time.time() - t0) * 1000 + logger.info(f">>> GaiaStarCatalog.__init__() took {t_init:.1f}ms") + logger.info( + f">>> Catalog initialized: {catalog_path}, state: {self.catalog.state}" + ) + + def generate_chart( + self, + catalog_object, + resolution: Tuple[int, int], + burn_in: bool = True, + display_class=None, + roll=None, + ) -> Generator[Optional[Image.Image], None, None]: + """ + Generate chart for object at current equipment settings + + Args: + catalog_object: CompositeObject with RA/Dec + resolution: (width, height) tuple + burn_in: Add FOV/mag/eyepiece overlays + + Returns: + PIL Image in RGB (red colorspace), or None if catalog not ready + """ + logger.info(f">>> generate_chart() ENTRY: object={catalog_object.display_name}") + + # Ensure catalog is loading + self.ensure_catalog_loading() + + # Check state + if self.catalog.state != CatalogState.READY: + logger.info( + f">>> Chart generation skipped: catalog state = {self.catalog.state}" + ) + yield None + return + + logger.info(">>> Catalog state is READY, proceeding...") + + # Check cache + cache_key = self.get_cache_key(catalog_object) + if cache_key in self.chart_cache: + # Return cached base image, adding overlays if needed + # Crosshair will be added by add_pulsating_crosshair() each frame + logger.debug(f"Chart cache HIT for {cache_key}") + cached_image = self.chart_cache[cache_key] + + # Make a copy to avoid modifying cached image + image = cached_image.copy() + + # ALWAYS pad to display resolution when display_class is provided + if display_class is not None: + image = pad_to_display_resolution(image, display_class) + + # Add overlays if burn_in requested + if burn_in and display_class is not None: + # Add FOV circle + draw = ImageDraw.Draw(image) + width, height = display_class.resolution + cx, cy = width / 2.0, height / 2.0 + radius = min(width, height) / 2.0 - 2 + marker_color = display_class.colors.get(64) + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + draw.ellipse(bbox, outline=marker_color, width=1) + + # Add text overlays + sqm = self.shared_state.sqm() + mag_limit_calculated = self.get_limiting_magnitude(sqm) + equipment = self.config.equipment + fov = equipment.calc_tfov() + mag = equipment.calc_magnification() + + image = add_image_overlays( + image, + display_class, + fov, + mag, + equipment.active_eyepiece, + burn_in=True, + limiting_magnitude=mag_limit_calculated, + ) + + yield image + return + + # Get equipment settings + equipment = self.config.equipment + fov = equipment.calc_tfov() + if fov <= 0: + fov = 10.0 # Default fallback + + mag = equipment.calc_magnification() + if mag <= 0: + mag = 50.0 # Default fallback + + logger.info( + f">>> Chart Generation: object={catalog_object.display_name}, center=({catalog_object.ra:.4f}, {catalog_object.dec:.4f}), fov={fov:.4f}°, mag={mag:.1f}x, eyepiece={equipment.active_eyepiece}" + ) + + sqm = self.shared_state.sqm() + mag_limit_calculated = self.get_limiting_magnitude(sqm) + # For query, cap at catalog max + mag_limit_query = min(mag_limit_calculated, 17.0) + + logger.info( + f">>> Mag Limit: calculated={mag_limit_calculated:.2f}, query={mag_limit_query:.2f}, sqm={sqm.value if sqm else 'None'}" + ) + + # Query stars PROGRESSIVELY (bright to faint) + # This is a generator that yields partial results as each magnitude band loads + import time + + t0 = time.time() + + logger.info( + f"Chart for {catalog_object.catalog_code}{catalog_object.sequence}: " + f"Center RA={catalog_object.ra:.4f}° Dec={catalog_object.dec:.4f}°, " + f"FOV={fov:.4f}°, Roll={roll if roll is not None else 0:.1f}°, " + f"Starting PROGRESSIVE loading (mag_limit={mag_limit_query:.1f})" + ) + + # Use progressive loading to show bright stars first + stars_generator = self.catalog.get_stars_for_fov_progressive( + ra_deg=catalog_object.ra, + dec_deg=catalog_object.dec, + fov_deg=fov, + mag_limit=mag_limit_query, + ) + + # Rotate the North-up/East-left chart into the parity-preserving + # eyepiece baseline, then apply any mirrors during rendering. + telescope = equipment.active_telescope + image_rotate = eyepiece_image_rotation(roll) + + # Get flip/flop settings from telescope config + flip_image = telescope.flip_image if telescope else False + flop_image = telescope.flop_image if telescope else False + + # Progressive rendering: Yield image after each magnitude band loads + # Re-render all stars each time (simple, correct, fast enough) + final_image = None + iteration_count = 0 + + logger.info(">>> Starting star generator loop...") + for stars, is_complete in stars_generator: + iteration_count += 1 + logger.info( + f">>> Star generator iteration {iteration_count}: got {len(stars)} stars, complete={is_complete}" + ) + t_render_start = time.time() + + # Render ALL stars from scratch (base image without overlays) + base_image = self.render_chart( + stars, + catalog_object.ra, + catalog_object.dec, + fov, + resolution, + mag, + image_rotate, + mag_limit_query, + flip_image=flip_image, + flop_image=flop_image, + ) + + # Store base image for caching (without overlays) + final_base_image = base_image + + # Make a copy for display (don't modify the base image) + display_image = base_image.copy() + + # ALWAYS pad to display resolution when display_class is provided + if display_class is not None: + display_image = pad_to_display_resolution(display_image, display_class) + + # Add overlays if burn_in requested + if burn_in and display_class is not None: + # Add FOV circle BEFORE text overlays so it appears behind them + draw = ImageDraw.Draw(display_image) + width, height = display_class.resolution + cx, cy = width / 2.0, height / 2.0 + radius = min(width, height) / 2.0 - 2 # Leave 2 pixel margin + marker_color = display_class.colors.get(64) # Subtle but visible + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + draw.ellipse(bbox, outline=marker_color, width=1) + + # Add text overlays (using shared utility) + display_image = add_image_overlays( + display_image, + display_class, + fov, + mag, + equipment.active_eyepiece, + burn_in=True, + limiting_magnitude=mag_limit_calculated, # Pass uncapped value for display + ) + + t_render_end = time.time() + logger.info( + f"PROGRESSIVE: Total render time {(t_render_end - t_render_start) * 1000:.1f}ms " + f"(complete={is_complete}, total_stars={len(stars)})" + ) + + # Yield display image (with or without overlays) + if not is_complete: + yield display_image + # If complete, will yield final image after loop + + # Final yield with complete image + t1 = time.time() + logger.info( + f">>> Star generator loop complete: {iteration_count} iterations, {(t1 - t0) * 1000:.1f}ms total" + ) + + if iteration_count == 0: + logger.warning( + f">>> WARNING: Star generator yielded NO results! FOV={fov:.4f}°, center=({catalog_object.ra:.4f}, {catalog_object.dec:.4f})" + ) + # Generate blank chart (no stars) - this is the base image + final_base_image = self.render_chart( + np.array([]).reshape(0, 3), # Empty star array + catalog_object.ra, + catalog_object.dec, + fov, + resolution, + mag, + image_rotate, + mag_limit_query, + flip_image=flip_image, + flop_image=flop_image, + ) + + # Cache base image (without overlays) so it can be reused + if "final_base_image" in locals() and final_base_image is not None: + self.chart_cache[cache_key] = final_base_image + if len(self.chart_cache) > 10: + # Remove oldest + oldest = next(iter(self.chart_cache)) + del self.chart_cache[oldest] + + # Create final display image + final_display_image = final_base_image.copy() + + # ALWAYS pad to display resolution when display_class is provided + if display_class is not None: + final_display_image = pad_to_display_resolution( + final_display_image, display_class + ) + + # Add overlays if burn_in requested + if burn_in and display_class is not None: + # Add FOV circle + draw = ImageDraw.Draw(final_display_image) + width, height = display_class.resolution + cx, cy = width / 2.0, height / 2.0 + radius = min(width, height) / 2.0 - 2 + marker_color = display_class.colors.get(64) + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + draw.ellipse(bbox, outline=marker_color, width=1) + + # Add overlays + final_display_image = add_image_overlays( + final_display_image, + display_class, + fov, + mag, + equipment.active_eyepiece, + burn_in=True, + limiting_magnitude=mag_limit_calculated, + ) + + yield final_display_image + else: + yield None + + def render_chart( + self, + stars: np.ndarray, + center_ra: float, + center_dec: float, + fov: float, + resolution: Tuple[int, int], + magnification: float = 50.0, + rotation: float = 0.0, + mag_limit: float = 17.0, + flip_image: bool = False, + flop_image: bool = False, + ) -> Image.Image: + """ + Render stars to PIL Image with center crosshair + Uses fast vectorized stereographic projection + + Args: + stars: Numpy array (N, 3) of (ra, dec, mag) + center_ra: Center RA in degrees + center_dec: Center Dec in degrees + fov: Field of view in degrees + resolution: (width, height) tuple + magnification: Magnification factor + rotation: Rotation angle in degrees (applied to coordinates) + + Returns: + PIL Image in RGB (black background, red stars) + """ + import time + + t_start = time.time() + + width, height = resolution + # Use NumPy array for fast pixel operations + image_array = np.zeros((height, width, 3), dtype=np.uint8) + image = Image.new("RGB", (width, height), (0, 0, 0)) + ImageDraw.Draw(image) + + logger.info( + f"Render Chart: {len(stars)} stars input, center=({center_ra:.4f}, {center_dec:.4f}), fov={fov:.4f}, res={resolution}" + ) + + # stars is already a numpy array (N, 3) + stars_array = stars + ra_arr = stars_array[:, 0] + dec_arr = stars_array[:, 1] + mag_arr = stars_array[:, 2] + t2 = time.time() + # logger.debug(f" Array conversion: {(t2-t1)*1000:.1f}ms") + + # Fast stereographic projection (vectorized) + # Convert degrees to radians + center_ra_rad = np.radians(center_ra) + center_dec_rad = np.radians(center_dec) + ra_rad = np.radians(ra_arr) + dec_rad = np.radians(dec_arr) + + # Use simple tangent plane projection (like POSS images) + # This gives linear scaling: pixels_per_degree is constant + # x = tan(ra - ra0) * cos(dec0) + # y = (tan(dec) - tan(dec0)) / cos(ra - ra0) + # Simplified for small angles: x ≈ (ra - ra0), y ≈ (dec - dec0) + + # Tangent plane projection (matches POSS images) + # For small FOV (< 10°), linear approximation works well + # IMPORTANT: Scale RA by CENTER declination, not individual star declinations + cos_center_dec = np.cos(center_dec_rad) + + dra = ra_rad - center_ra_rad + # Handle RA wrapping at 0°/360° + dra = np.where(dra > np.pi, dra - 2 * np.pi, dra) + dra = np.where(dra < -np.pi, dra + 2 * np.pi, dra) + ddec = dec_rad - center_dec_rad + + # Project onto tangent plane + # X: RA offset scaled by CENTER declination (matches POSS projection) + # Y: Dec offset (linear) + x_proj = dra * cos_center_dec + y_proj = ddec + + # Simple linear pixel scale (matches POSS behavior) + # fov degrees should map to width pixels + pixel_scale = width / np.radians(fov) + + if fov < 0.2: # Debug small FOVs + logger.info( + f">>> SMALL FOV DEBUG: fov={fov:.4f}°, pixel_scale={pixel_scale:.1f} px/rad" + ) + if len(stars) > 0: + logger.info( + f">>> Star RA range: [{np.min(ra_arr):.4f}, {np.max(ra_arr):.4f}]" + ) + logger.info( + f">>> Star Dec range: [{np.min(dec_arr):.4f}, {np.max(dec_arr):.4f}]" + ) + logger.info(f">>> Center: RA={center_ra:.4f}, Dec={center_dec:.4f}") + + # Convert to screen coordinates FIRST + # Center of field should always be at width/2, height/2 + # IMPORTANT: Flip X-axis to match POSS image orientation + # RA increases EASTWARD, which is to the LEFT when facing south + # So positive RA offset should go to the LEFT (subtract from center) + x_screen = width / 2.0 - x_proj * pixel_scale # FLIPPED: RA increases to LEFT + y_screen = height / 2.0 - y_proj * pixel_scale + + # Apply rotation to SCREEN coordinates (after scaling) + # This avoids magnifying small numerical errors. + # Negate: screen coords are y-down, so a positive image_rotate must + # turn the field the same visual way PIL.rotate() turns the POSS image + # (counter-clockwise). Without the negation the chart rotates opposite + # to POSS/reality for any nonzero roll. + if rotation != 0: + rot_rad = np.radians(-rotation) + cos_rot = np.cos(rot_rad) + sin_rot = np.sin(rot_rad) + + # Rotate around center + center_x = width / 2.0 + center_y = height / 2.0 + x_rel = x_screen - center_x + y_rel = y_screen - center_y + + x_rotated = x_rel * cos_rot - y_rel * sin_rot + y_rotated = x_rel * sin_rot + y_rel * cos_rot + + x_screen = x_rotated + center_x + y_screen = y_rotated + center_y + + # Filter stars within screen bounds only (no circular mask) + mask = ( + (x_screen >= 0) & (x_screen < width) & (y_screen >= 0) & (y_screen < height) + ) + + x_visible = x_screen[mask] + y_visible = y_screen[mask] + mag_visible = mag_arr[mask] + ra_arr[mask] + dec_arr[mask] + + logger.info( + f"Render Chart: {len(x_visible)} stars visible on screen (of {len(stars)} total)" + ) + + # Scale brightness based on FIXED magnitude range + # Use brightest visible star and LIMITING MAGNITUDE (not faintest loaded star) + # This ensures consistent intensity scaling across progressive renders + + if len(mag_visible) == 0: + intensities = np.array([]) + else: + brightest_mag = np.min(mag_visible) + faintest_mag = mag_limit # Use limiting magnitude, not max(mag_visible) + + # Always use proper magnitude scaling + # Linear scaling from brightest (255) to limiting magnitude (50) + # Note: Lower magnitude = brighter star + mag_range = faintest_mag - brightest_mag + if mag_range < 0.01: + mag_range = 0.01 # Avoid division by zero + + intensities = 255 - ((mag_visible - brightest_mag) / mag_range * 205) + intensities = np.clip(intensities, 50, 255).astype(int) + + # Render stars: crosses for bright ones, single pixels for faint + t3 = time.time() + ix = np.round(x_visible).astype(int) + iy = np.round(y_visible).astype(int) + t4 = time.time() + logger.debug(f" Star projection: {(t3 - t2) * 1000:.1f}ms") + + for i in range(len(ix)): + px = ix[i] + py = iy[i] + intensity = intensities[i] + + # Draw all stars as single pixels (no crosses) + if 0 <= px < width and 0 <= py < height: + # Use max to avoid bright blobs from overlapping stars + image_array[py, px, 0] = max(image_array[py, px, 0], intensity) + + np.clip(image_array[:, :, 0], 0, 255, out=image_array[:, :, 0]) + t5 = time.time() + logger.debug(f" Star drawing loop: {(t5 - t4) * 1000:.1f}ms ({len(ix)} stars)") + + # Convert NumPy array back to PIL Image + image = Image.fromarray(image_array, mode="RGB") + t6 = time.time() + logger.debug(f" Image conversion: {(t6 - t5) * 1000:.1f}ms") + + # Apply telescope flip/flop transformations + # flip_image = vertical flip (mirror top to bottom) + # flop_image = horizontal flip (mirror left to right) + if flip_image: + image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + if flop_image: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + + # Note: Limiting magnitude display added by add_image_overlays() in generate_chart() + # Note: Pulsating crosshair added separately via add_pulsating_crosshair() + # so base chart can be cached + + t_end = time.time() + logger.debug(f" Total render time: {(t_end - t_start) * 1000:.1f}ms") + + # Tag image as a Gaia chart (not a loading placeholder) + # This enables the correct marking menu in UIObjectDetails + image.is_loading_placeholder = False # type: ignore[attr-defined] + + return image + + def render_chart_incremental( + self, + new_stars: np.ndarray, + base_image: Optional[Image.Image], + center_ra: float, + center_dec: float, + fov: float, + resolution: Tuple[int, int], + magnification: float = 50.0, + rotation: float = 0.0, + mag_limit: float = 17.0, + fixed_brightest_mag: Optional[float] = None, + fixed_faintest_mag: Optional[float] = None, + ) -> Image.Image: + """ + Incrementally render new stars onto existing base image. + Uses FIXED intensity scaling to maintain consistent brightness across bands. + + Args: + new_stars: Only the new stars to render + base_image: Existing image to draw onto (None for first render) + center_ra: Center RA in degrees + center_dec: Center Dec in degrees + fov: Field of view in degrees + resolution: (width, height) tuple + magnification: Magnification factor + rotation: Rotation angle in degrees + mag_limit: Limiting magnitude + fixed_brightest_mag: Brightest magnitude for intensity scaling (from first band) + fixed_faintest_mag: Faintest magnitude for intensity scaling (limiting mag) + + Returns: + PIL Image with new stars added + """ + import time + + t_start = time.time() + + width, height = resolution + + # Start with base image or create new blank one + if base_image is None: + image_array = np.zeros((height, width, 3), dtype=np.uint8) + else: + image_array = np.array(base_image) + + logger.info(f"Render Chart INCREMENTAL: {len(new_stars)} new stars") + + if len(new_stars) == 0: + return Image.fromarray(image_array, mode="RGB") + + # Use FIXED intensity scaling (established from first band + limiting mag) + if fixed_brightest_mag is None or fixed_faintest_mag is None: + # Fallback: calculate from new stars only + new_mags = new_stars[:, 2] + brightest_mag = np.min(new_mags) + faintest_mag = np.max(new_mags) + logger.warning( + f"INCREMENTAL: No fixed scale provided, using fallback: {brightest_mag:.2f} to {faintest_mag:.2f}" + ) + else: + brightest_mag = fixed_brightest_mag + faintest_mag = fixed_faintest_mag + + # Convert new stars to numpy arrays + ra_arr = new_stars[:, 0] + dec_arr = new_stars[:, 1] + mag_arr = new_stars[:, 2] + + # Projection (same as render_chart) + center_ra_rad = np.radians(center_ra) + center_dec_rad = np.radians(center_dec) + ra_rad = np.radians(ra_arr) + dec_rad = np.radians(dec_arr) + + cos_center_dec = np.cos(center_dec_rad) + + dra = ra_rad - center_ra_rad + dra = np.where(dra > np.pi, dra - 2 * np.pi, dra) + dra = np.where(dra < -np.pi, dra + 2 * np.pi, dra) + ddec = dec_rad - center_dec_rad + + x_proj = dra * cos_center_dec + y_proj = ddec + + pixel_scale = width / np.radians(fov) + + x_screen = width / 2.0 - x_proj * pixel_scale + y_screen = height / 2.0 - y_proj * pixel_scale + + # Apply rotation + if rotation != 0: + rot_rad = np.radians(-rotation) # y-down: match POSS/PIL rotation direction + cos_rot = np.cos(rot_rad) + sin_rot = np.sin(rot_rad) + + center_x = width / 2.0 + center_y = height / 2.0 + x_rel = x_screen - center_x + y_rel = y_screen - center_y + + x_rotated = x_rel * cos_rot - y_rel * sin_rot + y_rotated = x_rel * sin_rot + y_rel * cos_rot + + x_screen = x_rotated + center_x + y_screen = y_rotated + center_y + + # Filter visible stars + mask = ( + (x_screen >= 0) & (x_screen < width) & (y_screen >= 0) & (y_screen < height) + ) + + x_visible = x_screen[mask] + y_visible = y_screen[mask] + mag_visible = mag_arr[mask] + + logger.info( + f"Render Chart INCREMENTAL: {len(x_visible)} of {len(new_stars)} new stars visible" + ) + + # Calculate intensities using GLOBAL magnitude range (from all_stars) + if len(mag_visible) == 0: + intensities = np.array([]) + elif faintest_mag - brightest_mag < 0.1: + intensities = np.full_like(mag_visible, 255, dtype=int) + else: + # Use global magnitude range for consistent scaling + intensities = 255 - ( + (mag_visible - brightest_mag) / (faintest_mag - brightest_mag) * 205 + ) + intensities = intensities.astype(int) + + # Draw new stars + ix = np.round(x_visible).astype(int) + iy = np.round(y_visible).astype(int) + + for i in range(len(ix)): + px = ix[i] + py = iy[i] + intensity = intensities[i] + + if 0 <= px < width and 0 <= py < height: + # Use max instead of add to avoid bright blobs from overlapping stars + image_array[py, px, 0] = max(image_array[py, px, 0], intensity) + + np.clip(image_array[:, :, 0], 0, 255, out=image_array[:, :, 0]) + + image = Image.fromarray(image_array, mode="RGB") + + # Tag as Gaia chart + image.is_loading_placeholder = False # type: ignore[attr-defined] + + t_end = time.time() + logger.debug(f" Incremental render time: {(t_end - t_start) * 1000:.1f}ms") + + return image + + def _draw_star_antialiased_fast(self, image_array, ix, iy, fx, fy, intensity): + """ + Draw star with bilinear anti-aliasing using fast NumPy operations + + Args: + image_array: NumPy array (height, width, 3) + ix, iy: Integer pixel coordinates (top-left) + fx, fy: Fractional offsets (0-1) + intensity: Peak intensity (0-255) + """ + # Bilinear interpolation weights + w00 = (1 - fx) * (1 - fy) # Top-left + w10 = fx * (1 - fy) # Top-right + w01 = (1 - fx) * fy # Bottom-left + w11 = fx * fy # Bottom-right + + # Apply to 2x2 region using NumPy (much faster than getpixel/putpixel) + # Red channel only (index 0) + if w00 > 0.01: + image_array[iy, ix, 0] = min( + 255, image_array[iy, ix, 0] + int(intensity * w00) + ) + if w10 > 0.01: + image_array[iy, ix + 1, 0] = min( + 255, image_array[iy, ix + 1, 0] + int(intensity * w10) + ) + if w01 > 0.01: + image_array[iy + 1, ix, 0] = min( + 255, image_array[iy + 1, ix, 0] + int(intensity * w01) + ) + if w11 > 0.01: + image_array[iy + 1, ix + 1, 0] = min( + 255, image_array[iy + 1, ix + 1, 0] + int(intensity * w11) + ) + + def mag_to_intensity(self, mag: float) -> int: + """ + Convert magnitude to red pixel intensity (0-255) + + Args: + mag: Stellar magnitude + + Returns: + Red pixel value (0-255) + """ + if mag < 3: + return 255 + elif mag < 6: + return 200 + elif mag < 9: + return 150 + elif mag < 12: + return 100 + elif mag < 14: + return 75 + else: + return 50 + + @staticmethod + def sqm_to_nelm(sqm: float) -> float: + """ + Convert SQM reading (sky brightness) to NELM (naked eye limiting magnitude) + + Formula: NELM ≈ (SQM - 8.89) / 2 + 0.5 + + Reference: https://www.unihedron.com/projects/darksky/faq.php + Unihedron manufacturer formula for SQM-L devices + + Args: + sqm: Sky Quality Meter reading in mag/arcsec² + + Returns: + Naked Eye Limiting Magnitude + + Examples: + SQM 22.0 (pristine dark sky) → NELM 7.1 + SQM 21.0 (good dark sky) → NELM 6.6 + SQM 20.0 (rural sky) → NELM 6.1 + SQM 19.0 (suburban) → NELM 5.6 + SQM 18.0 (suburban/urban) → NELM 5.1 + SQM 17.0 (urban) → NELM 4.6 + """ + nelm = (sqm - 8.89) / 2.0 + 0.5 + return nelm + + @staticmethod + def feijth_comello_limiting_magnitude( + mv: float, D: float, d: float, M: float, t: float + ) -> float: + """ + Calculate limiting magnitude using Feijth & Comello formula + + Formula: mg = mv - 2 + 2.5 × log₁₀(√(D² - d²) × M × t) + + Where: + - mv = naked eye limiting magnitude + - D = telescope aperture [cm] + - d = central obstruction diameter [cm] (0 for unobstructed) + - M = magnification + - t = transmission (100% = 1.0, typically 0.5-0.9) + + This practical formula is based on over 100,000 observations by Henk Feijth + and Georg Comello (mid-1990s). Unlike simple aperture formulas, it accounts + for obstruction, magnification, and transmission. + + References: + - https://astrobasics.de/en/basics/physical-quantities/limiting-magnitude/ + - https://www.y-auriga.de/astro/formeln.html (section 14) + - https://fr.wikipedia.org/wiki/Magnitude_limite_visuelle + + Args: + mv: Naked eye limiting magnitude + D: Aperture in cm + d: Central obstruction diameter in cm + M: Magnification + t: Transmission (0-1) + + Returns: + Telescopic limiting magnitude + + Example: + With mv=6.04, D=25cm, d=4cm, M=400, t=0.54 → mg=13.36 + """ + from math import log10, sqrt + + # Effective aperture accounting for central obstruction + # Only the (D² - d²) term is under the square root + effective_aperture = sqrt(D**2 - d**2) + + # Complete formula: mg = mv - 2 + 2.5 × log₁₀(√(D² - d²) × M × t) + mg = mv - 2.0 + 2.5 * log10(effective_aperture * M * t) + return mg + + def get_limiting_magnitude(self, sqm) -> float: + """ + Get limiting magnitude based on config mode (auto or fixed) + + Args: + sqm: SQM state object for sky brightness + + Returns: + Limiting magnitude value + """ + # Build cache key from sqm, telescope, and eyepiece focal lengths + # Round SQM to 1 decimal to avoid floating point comparison issues + equipment = self.config.equipment + telescope = equipment.active_telescope + eyepiece = equipment.active_eyepiece + + # Cache key includes all factors that affect LM calculation + telescope_fl = telescope.focal_length_mm if telescope else None + telescope_aperture = telescope.aperture_mm if telescope else None + eyepiece_fl = eyepiece.focal_length_mm if eyepiece else None + sqm_value = ( + round(sqm.value, 1) if sqm and hasattr(sqm, "value") and sqm.value else None + ) + + # Include config mode and fixed value in cache key to handle mode switching + lm_mode = self.config.get_option("obj_chart_lm_mode") + lm_fixed = self.config.get_option("obj_chart_lm_fixed") + + cache_key = ( + sqm_value, + telescope_aperture, + telescope_fl, + eyepiece_fl, + lm_mode, + lm_fixed, + ) + + # Check cache - return cached value without logging + if self._lm_cache is not None and self._lm_cache[0] == cache_key: + return self._lm_cache[1] + + if lm_mode == "fixed": + # Use fixed limiting magnitude from config + lm = self.config.get_option("obj_chart_lm_fixed") + try: + lm = float(lm) + logger.info(f"Using fixed LM from config: {lm:.1f}") + self._lm_cache = (cache_key, lm) + return lm + except (ValueError, TypeError): + # Invalid fixed value, fall back to auto + logger.warning(f"Invalid fixed LM value: {lm}, falling back to auto") + lm = self.calculate_limiting_magnitude(sqm) + self._lm_cache = (cache_key, lm) + return lm + else: + # Auto mode: calculate based on equipment and sky brightness + lm = self.calculate_limiting_magnitude(sqm) + self._lm_cache = (cache_key, lm) + return lm + + def calculate_limiting_magnitude(self, sqm) -> float: + """ + Calculate limiting magnitude using Feijth & Comello formula + + Converts SQM to NELM, then applies Feijth & Comello formula accounting + for telescope aperture, obstruction, magnification, and transmission. + + Args: + sqm: SQM state object for sky brightness + + Returns: + Limiting magnitude (uncapped - caller caps for catalog queries) + """ + + equipment = self.config.equipment + telescope = equipment.active_telescope + eyepiece = equipment.active_eyepiece + + # Get naked eye limiting magnitude from SQM + if sqm and hasattr(sqm, "value") and sqm.value: + sqm_value = sqm.value + mv = self.sqm_to_nelm(sqm_value) + else: + sqm_value = 19.5 # Default suburban sky + mv = self.sqm_to_nelm(sqm_value) # ≈ 5.8 + + # Calculate telescopic limiting magnitude + if telescope and telescope.aperture_mm > 0 and eyepiece: + # Convert aperture from mm to cm for formula + D_cm = telescope.aperture_mm / 10.0 + + # Calculate magnification + magnification = telescope.focal_length_mm / eyepiece.focal_length_mm + exit_pupil_mm = telescope.aperture_mm / magnification + + # No obstruction assumed (we don't know the secondary mirror size) + d_cm = 0.0 + + # Transmission (typical value for good optics) + transmission = 0.85 + + # Apply Feijth & Comello formula directly + # The formula already accounts for magnification effects + lm = self.feijth_comello_limiting_magnitude( + mv, D_cm, d_cm, magnification, transmission + ) + + logger.info( + f"LM calculation: mv={mv:.1f} (SQM={sqm_value:.1f}), " + f"aperture={telescope.aperture_mm:.0f}mm, mag={magnification:.1f}x, " + f"exit_pupil={exit_pupil_mm:.1f}mm → LM={lm:.1f}" + ) + elif telescope and telescope.aperture_mm > 0: + # No eyepiece: assume minimum useful magnification (exit pupil = 7mm) + D_cm = telescope.aperture_mm / 10.0 + min_magnification = telescope.aperture_mm / 7.0 + transmission = 0.85 + + lm = self.feijth_comello_limiting_magnitude( + mv, D_cm, 0.0, min_magnification, transmission + ) + logger.info( + f"LM calculation: aperture={telescope.aperture_mm}mm (no eyepiece, min mag={min_magnification:.1f}x) → LM={lm:.1f}" + ) + else: + # No telescope: use naked eye + lm = mv + logger.info(f"LM calculation: no telescope, NELM={lm:.1f}") + + # Return uncapped value (caller will cap for queries if needed) + return lm + + def get_cache_key(self, catalog_object) -> str: + """ + Generate cache key for object + eyepiece + limiting magnitude combination + + Args: + catalog_object: CompositeObject + + Returns: + Cache key string + """ + obj_key = f"{catalog_object.catalog_code}{catalog_object.sequence}" + eyepiece = self.config.equipment.active_eyepiece + eyepiece_key = str(eyepiece) if eyepiece else "none" + + # Include limiting magnitude in cache key + sqm = self.shared_state.sqm() + lm = self.get_limiting_magnitude(sqm) + lm_key = f"{lm:.1f}" + + return f"{obj_key}_{eyepiece_key}_lm{lm_key}" + + def invalidate_cache(self): + """Clear chart cache (call when equipment changes)""" + self.chart_cache.clear() + logger.debug("Chart cache invalidated") diff --git a/python/PiFinder/object_images/image_base.py b/python/PiFinder/object_images/image_base.py new file mode 100644 index 000000000..94b715a72 --- /dev/null +++ b/python/PiFinder/object_images/image_base.py @@ -0,0 +1,73 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Abstract base class for object image providers +""" + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Union, Generator +from PIL import Image + + +class ImageType(Enum): + """Image type enumeration for object images""" + + POSS = "poss" # Survey image from disk + GAIA_CHART = "gaia_chart" # Generated star chart + LOADING = "loading" # Loading placeholder + ERROR = "error" # Error placeholder + + +class ImageProvider(ABC): + """ + Base class for object image providers + + Provides a common interface for different image sources: + - POSS/survey images from disk + - Generated Gaia star charts + - Future: SDSS, online images, etc. + """ + + @abstractmethod + def can_provide(self, catalog_object, **kwargs) -> bool: + """ + Check if this provider can supply an image for the given object + + Args: + catalog_object: The astronomical object to image + **kwargs: Additional parameters (config, paths, etc.) + + Returns: + True if this provider can supply an image + """ + pass + + @abstractmethod + def get_image( + self, + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=True, + **kwargs, + ) -> Union[Image.Image, Generator]: + """ + Get image for catalog object + + Args: + catalog_object: The astronomical object to image + eyepiece_text: Eyepiece description for overlay + fov: Field of view in degrees + roll: Rotation angle in degrees + display_class: Display configuration object + burn_in: Whether to add overlays (FOV, mag, etc.) + **kwargs: Provider-specific parameters + + Returns: + PIL.Image for static images (POSS) + Generator yielding progressive images (Gaia charts) + """ + pass diff --git a/python/PiFinder/object_images/image_utils.py b/python/PiFinder/object_images/image_utils.py new file mode 100644 index 000000000..22686d646 --- /dev/null +++ b/python/PiFinder/object_images/image_utils.py @@ -0,0 +1,666 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Shared image utility functions for object images + +Provides common operations for: +- POSS survey images +- Generated Gaia star charts +""" + +import math +from typing import List, Optional, Tuple + +from PIL import Image, ImageDraw, ImageChops + +from PiFinder.ui import ui_utils + + +def eyepiece_image_rotation(roll: Optional[float]) -> float: + """Return the parity-preserving eyepiece rotation for a solved roll. + + Survey plates and Gaia charts start North-up/East-left. Newtonians and + straight-through refractors both invert that field by 180 degrees; an odd + reflection is represented separately by flip/flop after this rotation. + See ADR 0003. + """ + return 180.0 + (roll if roll is not None else 0.0) + + +def rotation_radians(image_rotate: float) -> float: + """Image rotation as a y-down pixel-space angle, in radians. + + PIL's Image.rotate() turns the image counterclockwise, which in + y-down pixel coordinates is a rotation by the negated angle. + """ + return math.radians(-image_rotate) + + +def cardinal_vectors( + image_rotate: float, fx: int = 1, fy: int = 1 +) -> Tuple[Tuple[float, float], Tuple[float, float]]: + """Return (nx, ny), (ex, ey) unit vectors for North and East. + + image_rotate: degrees the field image was rotated. + fx, fy: -1 to mirror that axis (flip/flop), +1 otherwise. + """ + theta = rotation_radians(image_rotate) + n = (fx * math.sin(theta), fy * -math.cos(theta)) + e = (-fx * math.cos(theta), -fy * math.sin(theta)) + return n, e + + +def size_overlay_points( + extents: List[float], + pa: float, + image_rotate: float, + px_per_arcsec: float, + cx: float, + cy: float, + fx: int = 1, + fy: int = 1, +) -> Optional[List[Tuple[float, float]]]: + """Compute outline points for the size overlay. + + Returns a list of (x, y) tuples. + For 1 extent returns None (caller should use native ellipse). + """ + if not extents or len(extents) == 1: + return None + + theta = rotation_radians(image_rotate) - math.radians(pa + 90) + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + points = [] + if len(extents) == 2: + rx = extents[0] * px_per_arcsec / 2 + ry = extents[1] * px_per_arcsec / 2 + for i in range(36): + t = 2 * math.pi * i / 36 + x = rx * math.cos(t) + y = ry * math.sin(t) + points.append( + (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) + ) + else: + step = 2 * math.pi / len(extents) + for i, ext in enumerate(extents): + angle = i * step - math.pi / 2 + r = ext * px_per_arcsec / 2 + x = r * math.cos(angle) + y = r * math.sin(angle) + points.append( + (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) + ) + return points + + +def vertex_overlay_points( + vertices: List[List[float]], + obj_ra: float, + obj_dec: float, + image_rotate: float, + px_per_arcsec: float, + cx: float, + cy: float, + fx: int = 1, + fy: int = 1, +) -> List[Tuple[float, float]]: + """Project RA/Dec vertex pairs to pixel coords via gnomonic projection. + + vertices: list of [ra, dec] pairs in degrees. + obj_ra, obj_dec: object center in degrees. + Returns list of (x, y) pixel tuples. + """ + theta = rotation_radians(image_rotate) + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + ra0 = math.radians(obj_ra) + dec0 = math.radians(obj_dec) + cos_dec0 = math.cos(dec0) + sin_dec0 = math.sin(dec0) + + points = [] + for ra_deg, dec_deg in vertices: + ra = math.radians(ra_deg) + dec = math.radians(dec_deg) + cos_dec = math.cos(dec) + sin_dec = math.sin(dec) + dra = ra - ra0 + + cos_c = sin_dec0 * sin_dec + cos_dec0 * cos_dec * math.cos(dra) + if cos_c <= 0: + continue + # gnomonic: xi points East, eta points North (radians) + xi = (cos_dec * math.sin(dra)) / cos_c + eta = (cos_dec0 * sin_dec - sin_dec0 * cos_dec * math.cos(dra)) / cos_c + + # convert to arcsec offsets then pixels + dx_arcsec = -xi * 206264.806 # negate: East is left on the survey image + dy_arcsec = -eta * 206264.806 # negate: North is up, pixel y is down + + dx_px = dx_arcsec * px_per_arcsec + dy_px = dy_arcsec * px_per_arcsec + + # apply image rotation + rx = dx_px * cos_t - dy_px * sin_t + ry = dx_px * sin_t + dy_px * cos_t + + points.append((cx + fx * rx, cy + fy * ry)) + return points + + +def project_radec_to_chart( + ra_deg: float, + dec_deg: float, + center_ra: float, + center_dec: float, + fov: float, + width: int, + height: int, + rotation: float, +) -> Tuple[float, float]: + """Project one RA/Dec point to Gaia-chart pixel coordinates. + + Mirrors the tangent-plane projection in ``GaiaChartGenerator.render_chart`` + exactly (RA scaled by centre declination, East to the left, screen rotation + by ``rotation`` degrees) so overlays land on the same pixels as the stars. + Flip/flop are applied by the caller via ``Image.transpose`` after drawing. + """ + ra = math.radians(ra_deg) + dec = math.radians(dec_deg) + cra = math.radians(center_ra) + cdec = math.radians(center_dec) + + dra = ra - cra + if dra > math.pi: + dra -= 2 * math.pi + elif dra < -math.pi: + dra += 2 * math.pi + ddec = dec - cdec + + x_proj = dra * math.cos(cdec) + y_proj = ddec + pixel_scale = width / math.radians(fov) + + x = width / 2.0 - x_proj * pixel_scale + y = height / 2.0 - y_proj * pixel_scale + + if rotation: + # Negated to match render_chart's screen-space rotation (y-down, same + # visual direction as PIL.rotate / the POSS image). + rot = math.radians(-rotation) + cos_r = math.cos(rot) + sin_r = math.sin(rot) + x_rel = x - width / 2.0 + y_rel = y - height / 2.0 + x = (x_rel * cos_r - y_rel * sin_r) + width / 2.0 + y = (x_rel * sin_r + y_rel * cos_r) + height / 2.0 + + return (x, y) + + +def extent_perimeter_polylines( + center_ra: float, + center_dec: float, + size, + steps: int = 48, +) -> List[List[List[float]]]: + """Build RA/Dec polylines outlining a ``SizeObject``'s angular extent. + + Returns a list of polylines, each a list of ``[ra, dec]`` points in degrees: + + * stored vertices -> the polyline as-is + * stored segments -> one 2-point polyline per segment + * ``[d]`` -> a closed circle of diameter ``d`` + * ``[major, minor]`` -> a closed ellipse, position angle N through E + * ``[r1, r2, ...]`` -> a closed polygon of radial spokes + + Numeric extents are stored in arcseconds. Empty near the poles where the RA + scaling blows up, or when no usable extent is present. + """ + if not size or not size.extents: + return [] + if size.is_segments: + return [list(seg) for seg in size.extents] + if size.is_vertices: + return [list(size.extents)] + + cos_dec0 = math.cos(math.radians(center_dec)) + if abs(cos_dec0) < 1e-6: + return [] + + pa = math.radians(size.position_angle) + sin_pa = math.sin(pa) + cos_pa = math.cos(pa) + extents = size.extents + + # Local tangent-plane offsets in arcsec: East, North. + offsets: List[Tuple[float, float]] = [] + if len(extents) == 1: + r = extents[0] / 2.0 + for i in range(steps): + t = 2.0 * math.pi * i / steps + offsets.append((r * math.cos(t), r * math.sin(t))) + elif len(extents) == 2: + a = extents[0] / 2.0 + b = extents[1] / 2.0 + for i in range(steps): + t = 2.0 * math.pi * i / steps + u = a * math.cos(t) # along major axis + v = b * math.sin(t) # along minor axis + offsets.append((u * sin_pa + v * cos_pa, u * cos_pa - v * sin_pa)) + else: + step = 2.0 * math.pi / len(extents) + for i, ext in enumerate(extents): + phi = pa + i * step # position angle of this spoke, N through E + r = ext / 2.0 + offsets.append((r * math.sin(phi), r * math.cos(phi))) + + radec = [ + [center_ra + (e / 3600.0) / cos_dec0, center_dec + n / 3600.0] + for e, n in offsets + ] + radec.append(radec[0]) # close the outline + return [radec] + + +def add_orientation_overlays( + image, + display_class, + catalog_object, + fov, + image_rotate, + fx=1, + fy=1, + show_nsew=True, + show_bbox=True, +): + """Draw NSEW cardinal labels and the object size box on a field image. + + Restores the image_nsew / image_bbox behaviour for the object_images + backend. Operates on the square field image (display_class.fov_res), + before any padding, with the centre at fov_res / 2. + """ + if not (show_nsew or show_bbox): + return image + + draw = ImageDraw.Draw(image) + cx = display_class.fov_res / 2 + cy = display_class.fov_res / 2 + + # NSEW cardinal labels — show the leftmost and rightmost of the four + # cardinals out at the FOV ring, clamped clear of the titlebar/footer. + if show_nsew: + (nx, ny), (ex, ey) = cardinal_vectors(image_rotate, fx, fy) + label_font = display_class.fonts.base + label_color = display_class.colors.get(128) + r_label = display_class.fov_res / 2 - 2 + top_limit = display_class.titlebar_height + label_font.height + bottom_limit = display_class.fov_res - label_font.height * 2 + candidates = [ + ("N", nx, ny), + ("S", -nx, -ny), + ("E", ex, ey), + ("W", -ex, -ey), + ] + by_x = sorted(candidates, key=lambda c: c[1]) + for label, dx, dy in (by_x[0], by_x[-1]): + lx = cx + dx * r_label - label_font.width / 2 + ly = cy + dy * r_label - label_font.height / 2 + lx = max(0, min(lx, display_class.fov_res - label_font.width)) + ly = max(top_limit, min(ly, bottom_limit)) + ui_utils.shadow_outline_text( + draw, + (lx, ly), + label, + font=label_font, + align="left", + fill=label_color, + shadow_color=display_class.colors.get(0), + outline=1, + ) + + # Size overlay + size = getattr(catalog_object, "size", None) + extents = size.extents if size else None + if show_bbox and extents and fov > 0: + px_per_arcsec = display_class.fov_res / (fov * 3600) + overlay_color = display_class.colors.get(100) + + if size.is_vertices: + points = vertex_overlay_points( + extents, + catalog_object.ra, + catalog_object.dec, + image_rotate, + px_per_arcsec, + cx, + cy, + fx, + fy, + ) + if len(points) >= 2: + draw.line(points, fill=overlay_color, width=1) + elif len(extents) == 1: + r = extents[0] * px_per_arcsec / 2 + draw.ellipse( + [cx - r, cy - r, cx + r, cy + r], + outline=overlay_color, + width=1, + ) + else: + points = size_overlay_points( + extents, + size.position_angle, + image_rotate, + px_per_arcsec, + cx, + cy, + fx, + fy, + ) + if points: + draw.polygon(points, outline=overlay_color) + + return image + + +def add_image_overlays( + image, + display_class, + fov, + magnification, + eyepiece, + burn_in=True, + limiting_magnitude=None, +): + """ + Add FOV/magnification/eyepiece overlays to image + + This function is shared by: + - POSS image display (poss_provider.py) + - Generated Gaia star charts (chart_provider.py) + + Args: + image: PIL Image to modify + display_class: Display configuration object + fov: Field of view in degrees + magnification: Telescope magnification + eyepiece: Active eyepiece object + burn_in: Whether to add overlays (default True) + limiting_magnitude: Optional limiting magnitude to display (for generated charts) + + Returns: + Modified PIL Image with overlays added + """ + if not burn_in: + return image + + draw = ImageDraw.Draw(image) + + # Top-left: FOV in degrees + ui_utils.shadow_outline_text( + draw, + (1, display_class.titlebar_height - 1), + f"{fov:0.2f}°", + font=display_class.fonts.base, + align="left", + fill=display_class.colors.get(254), + shadow_color=display_class.colors.get(0), + outline=2, + ) + + # Top-right: Magnification + mag_text = f"{magnification:.0f}x" if magnification and magnification > 0 else "?x" + ui_utils.shadow_outline_text( + draw, + ( + display_class.resX - (display_class.fonts.base.width * 4), + display_class.titlebar_height - 1, + ), + mag_text, + font=display_class.fonts.base, + align="right", + fill=display_class.colors.get(254), + shadow_color=display_class.colors.get(0), + outline=2, + ) + + # Top-center: Limiting magnitude (for generated charts) + if limiting_magnitude is not None: + # Show ">17" if exceeds catalog limit, otherwise show actual value + if limiting_magnitude > 17.0: + lm_text = "LM:>17" + else: + lm_text = f"LM:{limiting_magnitude:.1f}" + lm_bbox = draw.textbbox((0, 0), lm_text, font=display_class.fonts.base.font) + lm_width = lm_bbox[2] - lm_bbox[0] + lm_x = (display_class.resX - lm_width) // 2 + + ui_utils.shadow_outline_text( + draw, + (lm_x, display_class.titlebar_height - 1), + lm_text, + font=display_class.fonts.base, + align="left", + fill=display_class.colors.get(254), + shadow_color=display_class.colors.get(0), + outline=2, + ) + + # Bottom-left: Eyepiece name + if eyepiece: + eyepiece_text = f"{eyepiece.focal_length_mm:.0f}mm {eyepiece.name}" + ui_utils.shadow_outline_text( + draw, + (1, display_class.resY - (display_class.fonts.base.height * 1.1)), + eyepiece_text, + font=display_class.fonts.base, + align="left", + fill=display_class.colors.get(128), # Dimmer than FOV/mag + shadow_color=display_class.colors.get(0), + outline=2, + ) + + return image + + +def create_loading_image( + display_class, message="Loading...", progress_text=None, progress_percent=0 +): + """ + Create a placeholder image with loading message and optional progress + + Args: + display_class: Display configuration object + message: Main text to display (default "Loading...") + progress_text: Optional progress status text + progress_percent: Progress percentage (0-100) + + Returns: + PIL Image with centered message and progress + """ + image = Image.new("RGB", display_class.resolution, (0, 0, 0)) + draw = ImageDraw.Draw(image) + + # Use center of display for positioning + center_x = display_class.resolution[0] // 2 + center_y = display_class.resolution[1] // 2 + + # Draw main message + text_bbox = draw.textbbox((0, 0), message, font=display_class.fonts.large.font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + x = center_x - (text_width // 2) + y = center_y - (text_height // 2) - 20 + + draw.text( + (x, y), + message, + font=display_class.fonts.large.font, + fill=(128, 0, 0), # Medium red for night vision + ) + + # Draw progress text if provided + if progress_text: + progress_bbox = draw.textbbox( + (0, 0), progress_text, font=display_class.fonts.base.font + ) + progress_width = progress_bbox[2] - progress_bbox[0] + + px = center_x - (progress_width // 2) + py = y + text_height + 8 + + draw.text( + (px, py), + progress_text, + font=display_class.fonts.base.font, + fill=(100, 0, 0), # Dimmer red + ) + + # Draw progress bar if percentage > 0 + if progress_percent > 0: + bar_width = int(display_class.resolution[0] * 0.8) + bar_height = 4 + bar_x = center_x - (bar_width // 2) + bar_y = display_class.resolution[1] - 25 + + # Background bar + draw.rectangle( + [bar_x, bar_y, bar_x + bar_width, bar_y + bar_height], + outline=(64, 0, 0), + fill=(32, 0, 0), + ) + + # Progress fill + fill_width = int(bar_width * (progress_percent / 100)) + if fill_width > 0: + draw.rectangle( + [bar_x, bar_y, bar_x + fill_width, bar_y + bar_height], fill=(128, 0, 0) + ) + + # Percentage text + percent_text = f"{progress_percent}%" + percent_bbox = draw.textbbox( + (0, 0), percent_text, font=display_class.fonts.base.font + ) + percent_width = percent_bbox[2] - percent_bbox[0] + + draw.text( + (center_x - (percent_width // 2), bar_y + bar_height + 4), + percent_text, + font=display_class.fonts.base.font, + fill=(100, 0, 0), + ) + + return image + + +def create_no_image_placeholder(display_class, burn_in=True): + """ + Create a "No Image" placeholder + + Used when neither POSS nor Gaia chart is available + + Args: + display_class: Display configuration object + burn_in: Whether to add text (default True) + + Returns: + PIL Image with "No Image" message + """ + image = Image.new("RGB", display_class.resolution) + if burn_in: + draw = ImageDraw.Draw(image) + draw.text( + (30, 50), + "No Image", + font=display_class.fonts.large.font, + fill=display_class.colors.get(128), + ) + return image + + +def apply_circular_vignette(image, display_class): + """ + Apply circular vignette to show eyepiece FOV boundary + + Creates a circular mask that dims everything outside + the eyepiece field of view, then adds a subtle outline. + + Args: + image: PIL Image to modify + display_class: Display configuration object + + Returns: + Modified PIL Image with circular vignette + """ + # Create dimming mask (circle is full brightness, outside is dimmed) + _circle_dim = Image.new( + "RGB", + (display_class.fov_res, display_class.fov_res), + display_class.colors.get(127), # Dim the outside + ) + _circle_draw = ImageDraw.Draw(_circle_dim) + _circle_draw.ellipse( + [2, 2, display_class.fov_res - 2, display_class.fov_res - 2], + fill=display_class.colors.get(255), # Full brightness inside + ) + + # Apply dimming by multiplying + image = ImageChops.multiply(image, _circle_dim) + + # Add subtle outline + draw = ImageDraw.Draw(image) + draw.ellipse( + [2, 2, display_class.fov_res - 2, display_class.fov_res - 2], + outline=display_class.colors.get(64), + width=1, + ) + + return image + + +def pad_to_display_resolution(image, display_class): + """ + Pad image to match display resolution + + If FOV resolution differs from display resolution, + centers the image and pads with black. + + Args: + image: PIL Image to pad + display_class: Display configuration object + + Returns: + Padded PIL Image at display resolution + """ + # Pad horizontally if needed + if display_class.fov_res != display_class.resX: + pad_image = Image.new("RGB", display_class.resolution) + pad_image.paste( + image, + ( + int((display_class.resX - display_class.fov_res) / 2), + 0, + ), + ) + image = pad_image + + # Pad vertically if needed + if display_class.fov_res != display_class.resY: + pad_image = Image.new("RGB", display_class.resolution) + pad_image.paste( + image, + ( + 0, + int((display_class.resY - display_class.fov_res) / 2), + ), + ) + image = pad_image + + return image diff --git a/python/PiFinder/object_images/poss_provider.py b/python/PiFinder/object_images/poss_provider.py new file mode 100644 index 000000000..4d69ec9c0 --- /dev/null +++ b/python/PiFinder/object_images/poss_provider.py @@ -0,0 +1,205 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +POSS image provider - loads pre-downloaded survey images from disk +""" + +import os +from PIL import Image +from PiFinder import utils +from PiFinder import image_util +from .image_base import ImageProvider, ImageType +from .image_utils import ( + apply_circular_vignette, + pad_to_display_resolution, + add_image_overlays, + add_orientation_overlays, + eyepiece_image_rotation, +) +import logging + +logger = logging.getLogger("PiFinder.POSSProvider") + +BASE_IMAGE_PATH = f"{utils.data_dir}/catalog_images" + + +class POSSImageProvider(ImageProvider): + """ + Provides POSS (Palomar Observatory Sky Survey) images from disk + + POSS images are pre-downloaded 1024x1024 JPG files stored in + subdirectories by object ID. This provider: + - Loads image from disk + - Rotates for telescope orientation + - Crops to field of view + - Resizes to display resolution + - Converts to red + - Adds circular vignette (optional) + - Adds text overlays (optional) + """ + + def can_provide(self, catalog_object, **kwargs) -> bool: + """Check if POSS image exists on disk""" + image_path = self._resolve_image_name(catalog_object, source="POSS") + return os.path.exists(image_path) + + def get_image( + self, + catalog_object, + eyepiece_text, + fov, + roll, + display_class, + burn_in=True, + magnification=None, + config_object=None, + **kwargs, + ) -> Image.Image: + """ + Load and process POSS image + + Returns: + PIL.Image with POSS image processed and overlayed + """ + # Load image from disk + image_path = self._resolve_image_name(catalog_object, source="POSS") + return_image = Image.open(image_path) + + # Rotate the North-up/East-left survey plate into the + # parity-preserving eyepiece baseline, then apply any mirrors below. + telescope = None + if config_object and hasattr(config_object, "equipment"): + telescope = config_object.equipment.active_telescope + + image_rotate = eyepiece_image_rotation(roll) + return_image = return_image.rotate(image_rotate) # type: ignore[assignment] + + # Apply telescope flip/flop transformations + # flip_image = vertical flip (mirror top to bottom) + # flop_image = horizontal flip (mirror left to right) + if telescope: + if telescope.flip_image: + return_image = return_image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) # type: ignore[assignment] + if telescope.flop_image: + return_image = return_image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) # type: ignore[assignment] + + # Crop to FOV + fov_size = int(1024 * fov / 2) + return_image = return_image.crop( # type: ignore[assignment] + ( + 512 - fov_size, + 512 - fov_size, + 512 + fov_size, + 512 + fov_size, + ) + ) + + # Resize to display resolution + return_image = return_image.resize( # type: ignore[assignment] + (display_class.fov_res, display_class.fov_res), Image.Resampling.LANCZOS + ) + + # Convert to red + return_image = image_util.make_red(return_image, display_class.colors) + + # Add circular vignette if burn_in + if burn_in: + return_image = apply_circular_vignette(return_image, display_class) + + # NSEW labels + object size box (image_nsew / image_bbox settings). + # Drawn on the field image before padding, matching the orientation + # (image_rotate + flip/flop) the POSS image was rendered with. + fx = -1 if (telescope and telescope.flop_image) else 1 + fy = -1 if (telescope and telescope.flip_image) else 1 + return_image = add_orientation_overlays( + return_image, + display_class, + catalog_object, + fov, + image_rotate, + fx, + fy, + show_nsew=kwargs.get("show_nsew", True), + show_bbox=kwargs.get("show_bbox", True), + ) + + # Pad to display resolution if needed + return_image = pad_to_display_resolution(return_image, display_class) + + # Add text overlays if burn_in + if burn_in: + # Get eyepiece object for overlay + if config_object and hasattr(config_object, "equipment"): + eyepiece_obj = config_object.equipment.active_eyepiece + else: + # Create minimal eyepiece object from text + class FakeEyepiece: + def __init__(self, text): + self.focal_length_mm = 0 + self.name = text + + eyepiece_obj = FakeEyepiece(eyepiece_text) + + return_image = add_image_overlays( + return_image, + display_class, + fov, + magnification, + eyepiece_obj, + burn_in=True, + ) + + # Mark as POSS image + return_image.image_type = ImageType.POSS + return return_image + + def _resolve_image_name(self, catalog_object, source): + """ + Resolve image path for this object + + Checks primary name and alternatives + + Args: + catalog_object: Object to find image for + source: Image source ("POSS", "SDSS", etc.) + + Returns: + Path to image file, or empty string if not found + """ + + def create_image_path(image_name): + last_char = str(image_name)[-1] + image = f"{BASE_IMAGE_PATH}/{last_char}/{image_name}_{source}.jpg" + exists = os.path.exists(image) + return exists, image + + # Try primary name + image_name = f"{catalog_object.catalog_code}{catalog_object.sequence}" + ok, image = create_image_path(image_name) + + if ok: + catalog_object.image_name = image + return image + + # Try alternatives + for name in catalog_object.names: + alt_image_name = f"{''.join(name.split())}" + ok, image = create_image_path(alt_image_name) + if ok: + catalog_object.image_name = image + return image + + return "" + + +def create_catalog_image_dirs(): + """ + Checks for and creates catalog_image dirs + """ + if not os.path.exists(BASE_IMAGE_PATH): + os.makedirs(BASE_IMAGE_PATH) + + for i in range(0, 10): + _image_dir = f"{BASE_IMAGE_PATH}/{i}" + if not os.path.exists(_image_dir): + os.makedirs(_image_dir) diff --git a/python/PiFinder/object_images/star_catalog.py b/python/PiFinder/object_images/star_catalog.py new file mode 100644 index 000000000..2cd30dcd2 --- /dev/null +++ b/python/PiFinder/object_images/star_catalog.py @@ -0,0 +1,1530 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +HEALPix-indexed star catalog loader with background loading and CPU throttling + +This module provides efficient loading of Gaia star catalogs for chart generation. +Features: +- Background loading with thread safety +- CPU throttling to avoid blocking other processes +- LRU tile caching +- Hemisphere filtering for memory efficiency +- Proper motion corrections +""" + +import json +import logging +import mmap +import struct +import threading +import time +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +import numpy as np + +from PiFinder import timez + +# Import healpy at module level to avoid first-use delay +# This ensures the slow import happens during initialization, not during first chart render +import healpy as hp # type: ignore[import-untyped] + +logger = logging.getLogger("PiFinder.StarCatalog") + +# Optimized tile format: header + star records (no redundant HEALPix per star) +TILE_HEADER_FORMAT = " Optional[Tuple[int, int]]: + """ + Get (offset, size) for a tile ID. + + Returns None if tile doesn't exist. + """ + # Binary search in run directory + left, right = 0, len(self.run_directory) - 1 + run_idx = -1 + + while left <= right: + mid = (left + right) // 2 + start_tile = self.run_directory[mid][0] + + # Check if tile is in this run + if mid < len(self.run_directory) - 1: + next_start = self.run_directory[mid + 1][0] + if start_tile <= tile_id < next_start: + run_idx = mid + break + else: + # Last run + if start_tile <= tile_id: + run_idx = mid + break + + if tile_id < start_tile: + right = mid - 1 + else: + left = mid + 1 + + if run_idx == -1: + return None + + # Read run data from mmap + start_tile, data_offset = self.run_directory[run_idx] + offset_in_run = tile_id - start_tile + + # Read run header + run_length, offset_base = struct.unpack_from("= run_length: + return None + + # Read sizes up to and including our tile + sizes_offset = data_offset + 10 # After length(2) + offset_base(8) + sizes_data = self._mm[sizes_offset : sizes_offset + (offset_in_run + 1) * 2] + sizes = struct.unpack(f"<{offset_in_run + 1}H", sizes_data) + + # Calculate tile offset and size + tile_offset = offset_base + sum(sizes[:-1]) + tile_size = sizes[-1] + + return (tile_offset, tile_size) + + def close(self): + """Close mmap and file (idempotent)""" + if self._mm is not None: + self._mm.close() + self._mm = None + if self._file is not None: + self._file.close() + self._file = None + + def __del__(self): + """Cleanup on deletion""" + self.close() + + +class GaiaStarCatalog: + """ + HEALPix-indexed star catalog with background loading + + Usage: + catalog = GaiaStarCatalog("/path/to/gaia_stars") + catalog.start_background_load(observer_lat=40.0, limiting_mag=14.0) + # ... wait for catalog.state == CatalogState.READY ... + stars = catalog.get_stars_for_fov(ra=180.0, dec=45.0, fov=10.0, mag_limit=12.0) + """ + + def __init__(self, catalog_path: str): + """ + Initialize catalog (doesn't load data yet) + + Args: + catalog_path: Path to gaia_stars directory containing metadata.json + """ + logger.info(f">>> GaiaStarCatalog.__init__() called with path: {catalog_path}") + self.catalog_path = Path(catalog_path) + self.state = CatalogState.NOT_LOADED + self.metadata: Optional[Dict[str, Any]] = None + self.nside: Optional[int] = None + self.observer_lat: Optional[float] = None + self.limiting_magnitude: float = 12.0 + self.visible_tiles: Optional[Set[int]] = None + self.tile_cache: Dict[Tuple[int, float], np.ndarray] = {} + self.cache_lock = threading.Lock() + self.load_thread: Optional[threading.Thread] = None + self.load_progress: str = "" # Status message for UI + self.load_percent: int = 0 # Progress percentage (0-100) + self._index_cache: Dict[str, Any] = {} + # Cache of existing tile IDs per magnitude band to avoid scanning for non-existent tiles + self._existing_tiles_cache: Dict[str, Set[int]] = {} + logger.info(">>> GaiaStarCatalog.__init__() completed") + + def start_background_load( + self, observer_lat: Optional[float] = None, limiting_mag: float = 12.0 + ): + """ + Start loading catalog in background thread + + Args: + observer_lat: Observer latitude for hemisphere filtering (None = full sky) + limiting_mag: Magnitude limit for preloading bright stars + """ + logger.info(f">>> start_background_load() called, current state: {self.state}") + if self.state != CatalogState.NOT_LOADED: + logger.warning( + f">>> Catalog already loading or loaded (state={self.state}), skipping" + ) + return + + logger.info( + f">>> Starting background load: lat={observer_lat}, mag={limiting_mag}, path={self.catalog_path}" + ) + + self.state = CatalogState.LOADING + self.observer_lat = observer_lat + self.limiting_magnitude = limiting_mag + + # Start background thread + logger.info(">>> Creating background thread...") + self.load_thread = threading.Thread( + target=self._background_load_worker, daemon=True, name="CatalogLoader" + ) + self.load_thread.start() + logger.info( + f">>> Background thread started, thread alive: {self.load_thread.is_alive()}" + ) + + def _background_load_worker(self): + """Background worker - just loads metadata""" + logger.info(">>> _background_load_worker() started") + try: + # Load metadata + self.load_progress = "Loading..." + self.load_percent = 50 + logger.info(f">>> Loading catalog metadata from {self.catalog_path}") + + metadata_file = self.catalog_path / "metadata.json" + + if not metadata_file.exists(): + logger.error(f">>> Catalog metadata not found: {metadata_file}") + logger.error( + ">>> Please build catalog using: python -m PiFinder.catalog_tools.gaia_downloader" + ) + self.load_progress = "Error: catalog not built" + self.state = CatalogState.NOT_LOADED + return + + with open(metadata_file, "r") as f: + self.metadata = json.load(f) + logger.info(">>> metadata.json loaded") + + self.nside = self.metadata.get("nside", 512) + star_count = self.metadata.get("star_count", 0) + logger.info( + f">>> Catalog metadata ready: {star_count:,} stars, " + f"mag limit {self.metadata.get('mag_limit', 0):.1f}, nside={self.nside}" + ) + + # Log available bands + bands = self.metadata.get("mag_bands", []) + logger.info(f">>> Catalog mag bands: {json.dumps(bands)}") + + # Preload all compressed indices (run directories) into memory (~2-12 MB total) + # This eliminates first-query delays (70ms per band → 420ms total stuttering) + self._preload_compressed_indices() + + # Initialize empty structures (no preloading) + self.visible_tiles = None # Load full sky on-demand + + # Mark ready + self.load_progress = "Ready" + self.load_percent = 100 + self.state = CatalogState.READY + logger.info(f">>> _background_load_worker() completed, state: {self.state}") + + except Exception as e: + logger.error(f">>> Catalog loading failed: {e}", exc_info=True) + self.load_progress = f"Error: {str(e)}" + self.state = CatalogState.NOT_LOADED + + def _calc_visible_tiles(self, observer_lat: float) -> Optional[Set[int]]: + """ + Calculate HEALPix tiles visible from observer latitude + + DISABLED: Too slow (iterates 3M+ pixels) + TODO: Pre-compute hemisphere mask during catalog build + + Args: + observer_lat: Observer latitude in degrees + + Returns: + None (full sky always loaded for now) + """ + return None + + def _preload_mag_band(self, mag_min: float, mag_max: float): + """ + Preload all tiles for a magnitude band + + Args: + mag_min: Minimum magnitude + mag_max: Maximum magnitude + """ + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + if not band_dir.exists(): + return + + # Get all tile files in this band + tile_files = sorted(band_dir.glob("tile_*.bin")) + + for tile_file in tile_files: + # Extract tile ID from filename + tile_id = int(tile_file.stem.split("_")[1]) + + # Filter by hemisphere if applicable + if self.visible_tiles and tile_id not in self.visible_tiles: + continue + + # Load tile + self._load_tile_from_file(tile_file, mag_min, mag_max) + + # CPU throttle: 10ms pause between tiles + # (50ms was too conservative, slowing down loading significantly) + time.sleep(0.01) + + def get_stars_for_fov_progressive( + self, + ra_deg: float, + dec_deg: float, + fov_deg: float, + mag_limit: Optional[float] = None, + ): + """ + Query stars in field of view progressively (bright to faint) + + This is a generator that yields (stars, is_complete) tuples as each + magnitude band is loaded. This allows the UI to display bright stars + immediately while continuing to load fainter stars in the background. + + Uses background thread to load magnitude bands asynchronously, eliminating + UI event loop blocking. The UI consumes results at its own pace (~10 FPS) + while catalog loading continues uninterrupted. + + Blocks if state == LOADING (waits for load to complete) + Returns empty array if state == NOT_LOADED + + Args: + ra_deg: Center RA in degrees + dec_deg: Center Dec in degrees + fov_deg: Field of view in degrees + mag_limit: Limiting magnitude (uses catalog default if None) + + Yields: + (stars, is_complete) tuples where: + - stars: Numpy array (N, 3) of (ra, dec, mag) with proper motion corrected + - is_complete: True if this is the final yield with all stars + """ + if self.state == CatalogState.NOT_LOADED: + logger.warning("Catalog not loaded") + yield (np.empty((0, 3)), True) + return + + # Wait for catalog to be loaded + while self.state == CatalogState.LOADING: + import time + + time.sleep(0.1) + + if mag_limit is None: + mag_limit = self.metadata.get("mag_limit", 17.0) if self.metadata else 17.0 + + # Calculate HEALPix tiles covering FOV + # fov_deg is the diagonal field width, query_disc expects radius + # For square FOV rotated arbitrarily, need circumscribed circle radius = diagonal/2 + # Add 10% margin to ensure edge tiles are fully covered + # Use inclusive=True to ensure boundary tiles are included (critical for small FOVs) + vec = hp.ang2vec(ra_deg, dec_deg, lonlat=True) + radius_rad = np.radians(fov_deg / 2 * 1.1) + tiles = hp.query_disc(self.nside, vec, radius_rad, inclusive=True) + logger.debug( + f"HEALPix query_disc: FOV={fov_deg:.4f}°, radius={np.degrees(radius_rad):.4f}°, nside={self.nside}, returned {len(tiles)} tiles" + ) + + # Filter by visible hemisphere + if self.visible_tiles: + tiles = [t for t in tiles if t in self.visible_tiles] + + if not self.metadata: + yield (np.empty((0, 3)), True) + return + + # Background loading using producer-consumer pattern + import queue + import threading + import time + + # Queue to pass star arrays from background thread to generator + result_queue: queue.Queue = queue.Queue( + maxsize=6 + ) # Buffer up to 6 magnitude bands + + def load_bands_background(): + """Background thread that loads magnitude bands continuously""" + try: + all_stars_list = [] + mag_bands = self.metadata.get("mag_bands", []) + + for i, mag_band_info in enumerate(mag_bands): + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + + # Skip bands fainter than limit + if mag_min >= mag_limit: + break + + logger.debug( + f">>> BACKGROUND: Loading mag band {mag_min}-{mag_max}, tiles={len(tiles)}" + ) + + # Load stars from this magnitude band only + band_stars = self._load_tiles_for_mag_band( + tiles, mag_band_info, mag_limit, ra_deg, dec_deg, fov_deg + ) + + # Add to cumulative list + if len(band_stars) > 0: + all_stars_list.append(band_stars) + + # Concatenate for this yield + if all_stars_list: + current_total = np.concatenate(all_stars_list) + else: + current_total = np.empty((0, 3)) + + is_last_band = mag_max >= mag_limit + + # Push to queue (blocks if queue is full - back-pressure) + result_queue.put((current_total, is_last_band, len(band_stars))) + + logger.info( + f">>> BACKGROUND: mag {mag_min}-{mag_max}: " + f"stars={len(band_stars)}, cumulative={len(current_total)}" + ) + + if is_last_band: + break + + except Exception as e: + logger.error(f">>> BACKGROUND: Error loading bands: {e}", exc_info=True) + # Push error marker + result_queue.put((None, True, 0)) + + # Start background loading thread + loader_thread = threading.Thread( + target=load_bands_background, daemon=True, name="StarCatalogLoader" + ) + loader_thread.start() + logger.info(">>> PROGRESSIVE: Background loading thread started") + + # Yield results as they become available + while True: + try: + # Get next result from queue + # Use timeout to avoid blocking forever if thread crashes + current_total, is_last_band, band_star_count = result_queue.get( + timeout=10.0 + ) + + if current_total is None: + # Error in background thread + logger.error(">>> PROGRESSIVE: Background thread encountered error") + yield (np.empty((0, 3)), True) + break + + # Yield to consumer (UI) + yield (current_total, is_last_band) + + logger.info( + f">>> PROGRESSIVE: stars_in_band={band_star_count}, cumulative={len(current_total)}" + ) + + if is_last_band: + logger.info( + f"PROGRESSIVE: Complete! Total {len(current_total)} stars loaded" + ) + break + + except queue.Empty: + logger.error(">>> PROGRESSIVE: Timeout waiting for background thread") + yield (np.empty((0, 3)), True) + break + + def get_stars_for_fov( + self, + ra_deg: float, + dec_deg: float, + fov_deg: float, + mag_limit: Optional[float] = None, + ) -> np.ndarray: + """ + Query stars in field of view + + Blocks if state == LOADING (waits for load to complete) + Returns empty array if state == NOT_LOADED + + Args: + ra_deg: Center RA in degrees + dec_deg: Center Dec in degrees + fov_deg: Field of view in degrees + mag_limit: Limiting magnitude (uses catalog default if None) + + Returns: + Numpy array (N, 3) of (ra, dec, mag) with proper motion corrected + """ + if self.state == CatalogState.NOT_LOADED: + logger.warning("Catalog not loaded") + return np.empty((0, 3)) + + if self.state == CatalogState.LOADING: + # Wait for loading to complete (with timeout) + logger.info("Waiting for catalog to finish loading...") + timeout = 30 # seconds + start = time.time() + while self.state == CatalogState.LOADING: + time.sleep(0.1) + if time.time() - start > timeout: + logger.error("Catalog loading timeout") + return np.empty((0, 3)) + + # State is READY - metadata must be loaded by now + assert ( + self.metadata is not None + ), "metadata should be loaded when state is READY" + assert self.nside is not None, "nside should be set when state is READY" + + mag_limit = mag_limit or self.limiting_magnitude + + # Calculate HEALPix tiles covering FOV + # fov_deg is the diagonal field width, query_disc expects radius + # For square FOV rotated arbitrarily, need circumscribed circle radius = diagonal/2 + # Add 10% margin to ensure edge tiles are fully covered + vec = hp.ang2vec(ra_deg, dec_deg, lonlat=True) + radius_rad = np.radians(fov_deg / 2 * 1.1) + tiles = hp.query_disc(self.nside, vec, radius_rad) + logger.debug( + f"HEALPix: Querying {len(tiles)} tiles for FOV={fov_deg:.2f}° (radius={np.degrees(radius_rad):.3f}°) at nside={self.nside}" + ) + + # Filter by visible hemisphere + if self.visible_tiles: + tiles = [t for t in tiles if t in self.visible_tiles] + + # Load stars from tiles (batch load for better performance) + stars: np.ndarray = np.empty((0, 3)) + tile_star_counts = {} + + # Try batch loading if catalog is compact format + # Only batch for moderate tile counts (10-50) to avoid UI blocking + is_compact = self.metadata.get("format") == "compact" + if is_compact and 10 < len(tiles) <= 50: + # Batch load is much faster for many tiles + # Note: batch loading returns PM-corrected (ra, dec, mag) tuples + logger.info(f"Using BATCH loading for {len(tiles)} tiles") + stars = self._load_tiles_batch(tiles, mag_limit) + logger.info(f"Batch load complete: {len(stars)} stars") + tile_star_counts = { + t: 0 for t in tiles + } # Don't track individual counts for batch + else: + # Load one by one (better for small queries or legacy format) + logger.info( + f"Using SINGLE-TILE loading for {len(tiles)} tiles (compact={is_compact})" + ) + stars_raw_list = [] + + # To prevent UI blocking, limit the number of tiles loaded at once + # For small FOVs (<1°), 20-30 tiles is more than enough + MAX_TILES = 25 + if len(tiles) > MAX_TILES: + logger.warning( + f"Large tile count ({len(tiles)}) detected! Limiting to {MAX_TILES} tiles to prevent UI freeze" + ) + # Tiles from query_disc are roughly ordered by distance from center + # Keep the first MAX_TILES which are closest to FOV center + tiles = tiles[:MAX_TILES] + + cache_hits = 0 + cache_misses = 0 + + for i, tile_id in enumerate(tiles): + # Check if this tile is cached (for performance tracking) + cache_key = (tile_id, mag_limit) + was_cached = cache_key in self.tile_cache + + # Returns (N, 5) array + tile_stars = self._load_tile_data(tile_id, mag_limit) + tile_star_counts[tile_id] = len(tile_stars) + + if len(tile_stars) > 0: + stars_raw_list.append(tile_stars) + + if was_cached: + cache_hits += 1 + else: + cache_misses += 1 + + # Log cache performance + logger.debug( + f"Tile cache: {cache_hits} hits, {cache_misses} misses ({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)" + ) + + total_raw = sum(len(x) for x in stars_raw_list) + logger.debug(f"Single-tile loading complete: {total_raw} stars") + + # Log tile loading stats + if tile_star_counts: + logger.debug( + f"Loaded from {len(tile_star_counts)} tiles: " + + f"min={min(tile_star_counts.values())} max={max(tile_star_counts.values())} " + + f"total={sum(tile_star_counts.values())}" + ) + + # Apply proper motion correction (for non-batch path only) + t_pm_start = time.time() + + if stars_raw_list: + stars_raw_combined = np.concatenate(stars_raw_list) + ras = stars_raw_combined[:, 0] + decs = stars_raw_combined[:, 1] + mags = stars_raw_combined[:, 2] + pmras = stars_raw_combined[:, 3] + pmdecs = stars_raw_combined[:, 4] + stars = self._apply_proper_motion((ras, decs, mags, pmras, pmdecs)) + else: + stars = np.empty((0, 3)) + + t_pm_end = time.time() + logger.debug( + f"Proper motion correction: {len(stars)} stars in {(t_pm_end - t_pm_start) * 1000:.1f}ms" + ) + + return stars + + def _load_tiles_for_mag_band( + self, + tile_ids: List[int], + mag_band_info: dict, + mag_limit: float, + ra_deg: float, + dec_deg: float, + fov_deg: float, + ) -> np.ndarray: + """ + Load tiles for a specific magnitude band (used by progressive loading) + + Args: + tile_ids: List of HEALPix tile IDs to load + mag_band_info: Magnitude band metadata dict with 'min', 'max' keys + mag_limit: Maximum magnitude to include + ra_deg: Center RA (for logging) + dec_deg: Center Dec (for logging) + fov_deg: Field of view (for logging) + + Returns: + Numpy array (N, 3) of (ra, dec, mag) with proper motion corrected + """ + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + + # logger.info(f">>> _load_tiles_for_mag_band: mag {mag_min}-{mag_max}, band_dir={band_dir}, tiles={len(tile_ids)}") + + # Check if this band directory exists + if not band_dir.exists(): + logger.warning(f">>> Magnitude band directory not found: {band_dir}") + return np.empty((0, 3)) + + # For compact format, use vectorized batch loading per band + assert self.metadata is not None, "metadata must be loaded" + is_compact = self.metadata.get("format") == "compact" + # logger.info(f">>> Format is_compact={is_compact}, calling _load_tiles_batch_single_band...") + if is_compact: + result = self._load_tiles_batch_single_band( + tile_ids, mag_band_info, mag_limit + ) + # logger.info(f">>> _load_tiles_batch_single_band returned {len(result)} stars") + return result + else: + # Legacy format - load tiles one by one (will load all bands for each tile) + # This is less efficient but legacy format doesn't support per-band loading + stars_raw_list = [] + for tile_id in tile_ids: + tile_stars = self._load_tile_data(tile_id, mag_limit) + # Filter to just this magnitude band + # tile_stars is (N, 5) + if len(tile_stars) > 0: + mags = tile_stars[:, 2] + mask = (mags >= mag_min) & (mags < mag_max) + if np.any(mask): + stars_raw_list.append(tile_stars[mask]) + + if stars_raw_list: + stars_raw_combined = np.concatenate(stars_raw_list) + ras = stars_raw_combined[:, 0] + decs = stars_raw_combined[:, 1] + mags = stars_raw_combined[:, 2] + pmras = stars_raw_combined[:, 3] + pmdecs = stars_raw_combined[:, 4] + return self._apply_proper_motion((ras, decs, mags, pmras, pmdecs)) + else: + return np.empty((0, 3)) + + def _load_tile_data(self, tile_id: int, mag_limit: float) -> np.ndarray: + """ + Load star data for a HEALPix tile + + Args: + tile_id: HEALPix tile ID + mag_limit: Maximum magnitude to load + + Returns: + Numpy array of shape (N, 5) containing (ra, dec, mag, pmra, pmdec) + """ + assert ( + self.metadata is not None + ), "metadata must be loaded before calling _load_tile_data" + + cache_key = (tile_id, mag_limit) + + # Check cache + with self.cache_lock: + if cache_key in self.tile_cache: + return self.tile_cache[cache_key] + + # Load from disk + stars_list = [] + + # Check catalog format + is_compact = self.metadata.get("format") == "compact" + + # Determine which magnitude bands to load + for mag_band_info in self.metadata.get("mag_bands", []): + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + + if mag_min >= mag_limit: + continue # Band too faint + + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + + if is_compact: + # Compact format: read from consolidated file using index + ras, decs, mags, pmras, pmdecs = self._load_tile_compact( + band_dir, tile_id, mag_min, mag_max + ) + else: + # Legacy format: one file per tile + tile_file = band_dir / f"tile_{tile_id:06d}.bin" + if tile_file.exists(): + ras, decs, mags, pmras, pmdecs = self._load_tile_from_file( + tile_file, mag_min, mag_max + ) + else: + ras, decs, mags, pmras, pmdecs = ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + if len(ras) > 0: + # Filter by magnitude + mask = mags <= mag_limit + if np.any(mask): + # Stack into (N, 5) array for this band + band_stars = np.column_stack( + (ras[mask], decs[mask], mags[mask], pmras[mask], pmdecs[mask]) + ) + stars_list.append(band_stars) + logger.debug( + f" Tile {tile_id} Band {mag_min}-{mag_max}: {len(band_stars)} stars (file: {tile_file if not is_compact else 'compact'})" + ) + else: + logger.debug( + f" Tile {tile_id} Band {mag_min}-{mag_max}: 0 stars (mask empty)" + ) + + if not stars_list: + stars = np.empty((0, 5)) + else: + stars = np.concatenate(stars_list) + + # Cache result + with self.cache_lock: + self.tile_cache[cache_key] = stars + # Simple cache size management (keep last 100 tiles) + if len(self.tile_cache) > 100: + # Remove oldest (first) entry + oldest_key = next(iter(self.tile_cache)) + del self.tile_cache[oldest_key] + + return stars + + def _load_tile_from_file( + self, tile_file: Path, mag_min: float, mag_max: float + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Load stars from a tile file + + Args: + tile_file: Path to tile binary file + mag_min: Minimum magnitude in this band + mag_max: Maximum magnitude in this band + + Returns: + Tuple of (ras, decs, mags, pmras, pmdecs) arrays + """ + + # Read entire file at once + with open(tile_file, "rb") as f: + data = f.read() + + return self._parse_records(data) + + def _load_tile_compact( + self, band_dir: Path, tile_id: int, mag_min: float, mag_max: float + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Load stars from compact format (consolidated tiles.bin + v3 compressed index) + + Args: + band_dir: Magnitude band directory + tile_id: HEALPix tile ID + mag_min: Minimum magnitude + mag_max: Maximum magnitude + + Returns: + Tuple of (ras, decs, mags, pmras, pmdecs) arrays + """ + + index_file = band_dir / "index.bin" + tiles_file = band_dir / "tiles.bin" + + if not tiles_file.exists(): + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + if not index_file.exists(): + raise FileNotFoundError( + f"Compressed index not found: {index_file}\n" + f"This catalog requires v3 format. Please rebuild using healpix_builder_compact.py" + ) + + # Load index (cached per band) + cache_key = f"index_{mag_min}_{mag_max}" + if cache_key not in self._index_cache: + self._index_cache[cache_key] = CompressedIndex(index_file) + + index = self._index_cache[cache_key] + + # Get tile offset and size from compressed index + result = index.get(tile_id) + if result is None: + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + offset, size = result + + # Read tile data + with open(tiles_file, "rb") as f: + f.seek(offset) + data = f.read(size) + return self._parse_records(data) + + def _parse_records( + self, data: bytes + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Parse binary tile data into numpy arrays (VECTORIZED) + + New format: [Tile Header: 6 bytes][Star Records: 5 bytes each] + + Args: + data: Binary tile data (header + star records) + + Returns: + Tuple of (ras, decs, mags, pmras, pmdecs) as numpy arrays + """ + if len(data) < TILE_HEADER_SIZE: + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + # Parse tile header + healpix_pixel, num_stars = struct.unpack( + TILE_HEADER_FORMAT, data[:TILE_HEADER_SIZE] + ) + + # Extract star records + star_data = data[TILE_HEADER_SIZE:] + + if len(star_data) == 0: + return ( + np.array([]), + np.array([]), + np.array([]), + np.array([]), + np.array([]), + ) + + # Verify data size matches expected + expected_size = num_stars * STAR_RECORD_SIZE + if len(star_data) != expected_size: + logger.warning( + f"Tile {healpix_pixel}: size mismatch. Expected {expected_size} bytes " + f"for {num_stars} stars, got {len(star_data)} bytes" + ) + # Truncate to valid records + num_stars = len(star_data) // STAR_RECORD_SIZE + + # Parse all star records using numpy + records = np.frombuffer(star_data, dtype=STAR_RECORD_DTYPE, count=num_stars) + + # Get pixel center (same for all stars in this tile) + pixel_ra, pixel_dec = hp.pix2ang(self.nside, healpix_pixel, lonlat=True) + + # Calculate pixel size once + pixel_size_deg = np.sqrt(hp.nside2pixarea(self.nside, degrees=True)) + max_offset_arcsec = pixel_size_deg * 3600.0 * 0.75 + + # Decode all offsets + ra_offset_arcsec = (records["ra_offset"] / 127.5 - 1.0) * max_offset_arcsec + dec_offset_arcsec = (records["dec_offset"] / 127.5 - 1.0) * max_offset_arcsec + + # Calculate final positions (broadcast pixel center to all stars) + decs = pixel_dec + dec_offset_arcsec / 3600.0 + ras = pixel_ra + ra_offset_arcsec / 3600.0 / np.cos(np.radians(decs)) + + # Decode magnitudes + mags = records["mag"] / 10.0 + + # v2.1: Proper motion has been pre-applied at build time + # Return empty arrays for backward compatibility + pmras = np.zeros(len(records)) + pmdecs = np.zeros(len(records)) + + return ras, decs, mags, pmras, pmdecs + + def _preload_compressed_indices(self) -> None: + """ + Preload all v3 compressed indices (run directories) into memory during startup. + + Loads compressed index run directories (~2-12 MB total) to eliminate first-query + delays during chart generation. Each compressed index loads its run directory + into RAM for fast binary search, while keeping run data in mmap. + + This runs in background thread during catalog startup and trades a one-time + ~200ms startup cost for eliminating 6 × 70ms = 420ms of stuttering during + first chart generation. + """ + if not self.metadata or "mag_bands" not in self.metadata: + logger.warning( + ">>> No metadata available, skipping compressed index preload" + ) + return + + t0_total = time.time() + bands_loaded = 0 + + logger.info(">>> Preloading v3 compressed indices for all magnitude bands...") + + for band_info in self.metadata["mag_bands"]: + mag_min = int(band_info["min"]) + mag_max = int(band_info["max"]) + cache_key = f"index_{mag_min}_{mag_max}" + + # Load compressed index (v3 format stored as index.bin) + index_file = ( + self.catalog_path / f"mag_{mag_min:02d}_{mag_max:02d}" / "index.bin" + ) + + if not index_file.exists(): + raise FileNotFoundError( + f"Compressed index not found: {index_file}\n" + f"This catalog requires v3 format. Please rebuild using healpix_builder_compact.py" + ) + + t0 = time.time() + + # Load compressed index (v3 only) + self._index_cache[cache_key] = CompressedIndex(index_file) + t_load = (time.time() - t0) * 1000 + + compressed_idx = self._index_cache[cache_key] + bands_loaded += 1 + + logger.info( + f">>> Loaded compressed index {cache_key}: " + f"{compressed_idx.num_tiles:,} tiles, {len(compressed_idx.run_directory):,} runs " + f"in {t_load:.1f}ms" + ) + + t_total = (time.time() - t0_total) * 1000 + logger.info( + f">>> Compressed index preload complete: {bands_loaded} indices " + f"in {t_total:.1f}ms" + ) + + def _load_existing_tiles_set(self, index_file: Path) -> Set[int]: + """ + Quickly load the set of all existing tile IDs from an index file. + This is much faster than scanning for specific tiles when we just need + to know "does this tile exist?" to avoid wasteful searches. + + Args: + index_file: Path to binary index file + + Returns: + Set of existing tile IDs (as integers) + """ + existing_tiles: set[int] = set() + + if not index_file.exists(): + return existing_tiles + + with open(index_file, "rb") as f: + # Read header + header = f.read(8) + if len(header) < 8: + return existing_tiles + + version, _num_tiles = struct.unpack(" np.ndarray: + """ + Apply proper motion corrections from J2016.0 to current epoch (VECTORIZED) + + Args: + stars: Tuple of (ras, decs, mags, pmras, pmdecs) arrays + + Returns: + Numpy array of shape (N, 3) containing (ra, dec, mag) + """ + ras, decs, mags, pmras, pmdecs = stars + + if len(ras) == 0: + return np.empty((0, 3)) + + # Calculate years from J2016.0 to current date + now = timez.utc_now() + current_year = now.year + (now.timetuple().tm_yday / 365.25) + years_elapsed = current_year - 2016.0 + + # Apply proper motion forward to current epoch + # pmra is in mas/year and needs cos(dec) correction for RA + # Vectorized calculation + ra_corrections = ( + (pmras / 1000 / 3600) / np.cos(np.radians(decs)) * years_elapsed + ) + dec_corrections = (pmdecs / 1000 / 3600) * years_elapsed + + ra_corrected = ras + ra_corrections + dec_corrected = decs + dec_corrections + + # Keep dec in valid range + dec_corrected = np.clip(dec_corrected, -90, 90) + + # Stack into (N, 3) array + return np.column_stack((ra_corrected, dec_corrected, mags)) + + def _trim_index_cache(self, cache_key: str, protected_tile_ids: List[int]) -> None: + """ + Trim index cache to stay within MAX_INDEX_CACHE_SIZE limit. + + Strategy: Remove oldest tiles not in the current request (protected_tile_ids). + This ensures we keep tiles needed for the current chart while evicting others. + + Args: + cache_key: Cache key (e.g., "index_12_14") + protected_tile_ids: Tile IDs that must NOT be evicted (current FOV) + """ + index = self._index_cache.get(cache_key) + if not index: + return + + cache_size = len(index) + if cache_size <= MAX_INDEX_CACHE_SIZE: + return # Within limit, nothing to do + + # Calculate how many to remove + tiles_to_remove = cache_size - MAX_INDEX_CACHE_SIZE + logger.info( + f">>> Cache {cache_key} exceeds limit ({cache_size} > {MAX_INDEX_CACHE_SIZE}), removing {tiles_to_remove} tiles" + ) + + # Build set of protected tiles + protected_set = {str(tid) for tid in protected_tile_ids} + + # Find eviction candidates (tiles not in current request) + candidates = [ + tile_key for tile_key in index.keys() if tile_key not in protected_set + ] + + if len(candidates) < tiles_to_remove: + # Not enough non-protected tiles, just remove what we can + logger.warning( + f">>> Only {len(candidates)} evictable tiles, removing all of them" + ) + tiles_to_remove = len(candidates) + + # Remove the first N candidates (simple FIFO-ish eviction) + # Could enhance this with LRU tracking later + for i in range(tiles_to_remove): + tile_key = candidates[i] + del index[tile_key] + + logger.info(f">>> Cache trimmed: {cache_size} → {len(index)} tiles") + + def _load_tiles_batch_single_band( + self, + tile_ids: List[int], + mag_band_info: dict, + mag_limit: float, + ) -> np.ndarray: + """ + Batch load multiple tiles for a SINGLE magnitude band (compact format only) + Used by progressive loading to load one mag band at a time + + Args: + tile_ids: List of HEALPix tile IDs + mag_band_info: Magnitude band metadata dict + mag_limit: Maximum magnitude + + Returns: + Numpy array of shape (N, 3) containing (ra, dec, mag) + """ + + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + index_file = band_dir / "index.bin" + tiles_file = band_dir / "tiles.bin" + + if not tiles_file.exists(): + return np.empty((0, 3)) + + if not index_file.exists(): + raise FileNotFoundError( + f"Compressed index not found: {index_file}\n" + f"This catalog requires v3 format. Please rebuild using healpix_builder_compact.py" + ) + + cache_key = f"index_{mag_min}_{mag_max}" + + # Load v3 compressed index (cached) + if not hasattr(self, "_index_cache"): + self._index_cache = {} + + t_index_start = time.time() + logger.debug(f"Checking index cache for {cache_key}") + if cache_key not in self._index_cache: + logger.info(f">>> Loading v3 compressed index from {index_file}") + t0 = time.time() + self._index_cache[cache_key] = CompressedIndex(index_file) + t_read_index = (time.time() - t0) * 1000 + logger.info(f">>> Compressed index loaded in {t_read_index:.1f}ms") + else: + logger.debug(f">>> Using cached index for {cache_key}") + + index = self._index_cache[cache_key] + t_index_total = (time.time() - t_index_start) * 1000 + logger.debug(f">>> Index cache operations took {t_index_total:.1f}ms") + + t_readops_start = time.time() + logger.debug(f"Building read_ops for {len(tile_ids)} tiles...") + + # Collect all tile read operations from v3 compressed index + read_ops: List[Tuple[int, Dict[str, int]]] = [] + missing_tiles = 0 + for tile_id in tile_ids: + # Ensure tile_id is a Python int (not numpy.int64) + tile_id_int = int(tile_id) + tile_tuple = index.get(tile_id_int) + if tile_tuple: + offset, size = tile_tuple + read_ops.append((tile_id_int, {"offset": offset, "size": size})) + else: + missing_tiles += 1 + + if missing_tiles > 0: + logger.debug( + f"{missing_tiles} of {len(tile_ids)} tiles missing from index for mag {mag_min}-{mag_max}" + ) + + if not read_ops: + logger.debug( + f"No tiles to load (all {len(tile_ids)} requested tiles are empty)" + ) + return np.empty((0, 3)) + + # Sort by offset to minimize seeks + read_ops.sort(key=lambda x: x[1]["offset"]) + t_readops = (time.time() - t_readops_start) * 1000 + logger.debug(f"Built {len(read_ops)} read_ops in {t_readops:.1f}ms") + + # Read data in larger sequential chunks when possible + MAX_GAP = 100 * 1024 # 100KB gap tolerance + + # Accumulate arrays + all_ras = [] + all_decs = [] + all_mags = [] + all_pmras = [] + all_pmdecs = [] + + t_io_start = time.time() + t_decode_total = 0.0 + bytes_read = 0 + logger.debug(f"Batch loading {len(read_ops)} tiles for mag {mag_min}-{mag_max}") + with open(tiles_file, "rb") as f: + i = 0 + chunk_num = 0 + while i < len(read_ops): + chunk_num += 1 + # logger.debug(f">>> Processing chunk {chunk_num}, tile {i+1}/{len(read_ops)}") + + tile_id, tile_info = read_ops[i] + offset = tile_info["offset"] + chunk_end = offset + tile_info["size"] + + # Find consecutive tiles for chunk reading + tiles_in_chunk: List[Tuple[int, Dict[str, int]]] = [ + (tile_id, tile_info) + ] + j = i + 1 + inner_iterations = 0 + while j < len(read_ops): + inner_iterations += 1 + if inner_iterations > 1000: + logger.error( + f">>> INFINITE LOOP DETECTED in chunk consolidation! j={j}, len={len(read_ops)}, i={i}" + ) + break # Safety break + + next_tile_id, next_tile_info = read_ops[j] + next_offset = next_tile_info["offset"] + if next_offset - chunk_end <= MAX_GAP: + chunk_end = next_offset + next_tile_info["size"] + tiles_in_chunk.append((next_tile_id, next_tile_info)) + j += 1 + else: + break + + # Read entire chunk + chunk_size = chunk_end - offset + # logger.debug(f">>> Reading chunk: {len(tiles_in_chunk)} tiles, size={chunk_size} bytes") + f.seek(offset) + chunk_data = f.read(chunk_size) + bytes_read += chunk_size + # logger.debug(f">>> Chunk read complete, processing tiles...") + + # Process each tile in chunk + for tile_idx, (tile_id, tile_info) in enumerate(tiles_in_chunk): + # logger.debug(f">>> Processing tile {tile_idx+1}/{len(tiles_in_chunk)} (id={tile_id})") + tile_offset = tile_info["offset"] - offset + size = tile_info["size"] + data = chunk_data[tile_offset : tile_offset + size] + + # Parse records using shared helper + t_decode_start = time.time() + ras, decs, mags, pmras, pmdecs = self._parse_records(data) + t_decode_total += time.time() - t_decode_start + + # Filter by magnitude + mask = mags <= mag_limit + + if np.any(mask): + all_ras.append(ras[mask]) + all_decs.append(decs[mask]) + all_mags.append(mags[mask]) + all_pmras.append(pmras[mask]) + all_pmdecs.append(pmdecs[mask]) + + i = j + + if not all_ras: + return np.empty((0, 3)) + + # Concatenate all arrays + t_concat_start = time.time() + ras_final = np.concatenate(all_ras) + decs_final = np.concatenate(all_decs) + mags_final = np.concatenate(all_mags) + pmras_final = np.concatenate(all_pmras) + pmdecs_final = np.concatenate(all_pmdecs) + (time.time() - t_concat_start) * 1000 + + # Apply proper motion + t_pm_start = time.time() + result = self._apply_proper_motion( + (ras_final, decs_final, mags_final, pmras_final, pmdecs_final) + ) + (time.time() - t_pm_start) * 1000 + + # Log performance breakdown + t_io_total = (time.time() - t_io_start) * 1000 + logger.debug( + f"Tile I/O for mag {mag_min}-{mag_max}: " + f"{t_io_total:.1f}ms, {len(result)} stars, {bytes_read / 1024:.1f}KB" + ) + + return result + + def _load_tiles_batch(self, tile_ids: List[int], mag_limit: float) -> np.ndarray: + """ + Batch load multiple tiles efficiently (compact format only) + Much faster than loading tiles one-by-one due to reduced I/O overhead + + Args: + tile_ids: List of HEALPix tile IDs + mag_limit: Maximum magnitude + + Returns: + Numpy array of shape (N, 3) containing (ra, dec, mag) + """ + assert ( + self.metadata is not None + ), "metadata must be loaded before calling _load_tiles_batch" + + all_ras = [] + all_decs = [] + all_mags = [] + all_pmras = [] + all_pmdecs = [] + + logger.info(f"_load_tiles_batch: Starting batch load of {len(tile_ids)} tiles") + + # Process each magnitude band + for mag_band_info in self.metadata.get("mag_bands", []): + mag_min = mag_band_info["min"] + mag_max = mag_band_info["max"] + + if mag_min >= mag_limit: + continue # Skip faint bands + + logger.info(f"_load_tiles_batch: Processing mag band {mag_min}-{mag_max}") + band_dir = self.catalog_path / f"mag_{mag_min:02.0f}_{mag_max:02.0f}" + index_file = band_dir / "index.bin" + tiles_file = band_dir / "tiles.bin" + + if not tiles_file.exists(): + continue + + if not index_file.exists(): + raise FileNotFoundError( + f"Compressed index not found: {index_file}\n" + f"This catalog requires v3 format. Please rebuild using healpix_builder_compact.py" + ) + + # Load v3 compressed index + cache_key = f"index_{mag_min}_{mag_max}" + if not hasattr(self, "_index_cache"): + self._index_cache = {} + + if cache_key not in self._index_cache: + self._index_cache[cache_key] = CompressedIndex(index_file) + + index = self._index_cache[cache_key] + + # Collect all tile read operations from v3 compressed index + read_ops = [] + for tile_id in tile_ids: + tile_tuple = index.get(tile_id) + if tile_tuple: + offset, size = tile_tuple + read_ops.append((tile_id, {"offset": offset, "size": size})) + + if not read_ops: + continue + + logger.info( + f"_load_tiles_batch: Found {len(read_ops)} tiles in mag band {mag_min}-{mag_max}" + ) + + # Sort by offset to minimize seeks + read_ops.sort(key=lambda x: x[1]["offset"]) + + # Optimize: Read data in larger sequential chunks when possible + # Group tiles that are close together (within 100KB) + MAX_GAP = 100 * 1024 # 100KB gap tolerance + + logger.info(f"_load_tiles_batch: Opening {tiles_file}") + # Open file once and read all tiles + with open(tiles_file, "rb") as f: + i = 0 + while i < len(read_ops): + tile_id, tile_info = read_ops[i] + offset = tile_info["offset"] + size = tile_info["size"] + + # Check if next tiles are sequential (within gap tolerance) + chunk_end = offset + size + tiles_in_chunk = [(tile_id, tile_info)] + + j = i + 1 + while j < len(read_ops): + next_tile_id, next_tile_info = read_ops[j] + next_offset = next_tile_info["offset"] + + # If next tile is within gap tolerance, include in chunk + if next_offset - chunk_end <= MAX_GAP: + tiles_in_chunk.append((next_tile_id, next_tile_info)) + next_size = next_tile_info["size"] + chunk_end = next_offset + next_size + j += 1 + else: + break + + # Read entire chunk at once + chunk_size = chunk_end - offset + logger.info( + f"_load_tiles_batch: Reading chunk at offset {offset}, size {chunk_size / 1024:.1f}KB with {len(tiles_in_chunk)} tiles" + ) + f.seek(offset) + chunk_data = f.read(chunk_size) + logger.info( + f"_load_tiles_batch: Read complete, processing {len(tiles_in_chunk)} tiles" + ) + + # Process each tile in the chunk using vectorized operations + for tile_id, tile_info in tiles_in_chunk: + tile_offset = ( + tile_info["offset"] - offset + ) # Relative offset in chunk + size = tile_info["size"] + data = chunk_data[tile_offset : tile_offset + size] + + # Parse records using shared helper + ras, decs, mags, pmras, pmdecs = self._parse_records(data) + + # Filter by magnitude + mask = mags <= mag_limit + + if np.any(mask): + all_ras.append(ras[mask]) + all_decs.append(decs[mask]) + all_mags.append(mags[mask]) + all_pmras.append(pmras[mask]) + all_pmdecs.append(pmdecs[mask]) + + # Move to next chunk + i = j + + logger.info( + f"_load_tiles_batch: Loaded {len(all_ras)} batches of stars, applying proper motion" + ) + + if not all_ras: + return np.empty((0, 3)) + + # Concatenate all arrays + ras_final = np.concatenate(all_ras) + decs_final = np.concatenate(all_decs) + mags_final = np.concatenate(all_mags) + pmras_final = np.concatenate(all_pmras) + pmdecs_final = np.concatenate(all_pmdecs) + + # Apply proper motion + result = self._apply_proper_motion( + (ras_final, decs_final, mags_final, pmras_final, pmdecs_final) + ) + logger.info(f"_load_tiles_batch: Complete, returning {len(result)} stars") + return result diff --git a/python/PiFinder/obslist_formats.py b/python/PiFinder/obslist_formats.py index 1ac8f1175..cf0522f31 100644 --- a/python/PiFinder/obslist_formats.py +++ b/python/PiFinder/obslist_formats.py @@ -211,6 +211,7 @@ def _parse_catalog_name(name: str) -> tuple[str, int]: "Ast": "ASTERISM", "Pla": "STAR", "CM": "COMET", + "AS": "ASTEROID", "?": "USER", } ARGO_TYPE_MAP_INV: dict[str, str] = {} @@ -233,6 +234,7 @@ def _parse_catalog_name(name: str) -> tuple[str, int]: "Ast": "Asterism", "Pla": "Star", "CM": "Star", + "AS": "Star", "?": "Star", } CELESTRON_TYPE_MAP_INV: dict[str, str] = {} @@ -252,7 +254,7 @@ def _parse_catalog_name(name: str) -> tuple[str, int]: def _skylist_object_id(obj_type: str) -> str: if obj_type in ("*", "D*", "***"): return "2,-1,-1" - if obj_type == "Pla": + if obj_type in ("Pla", "AS"): return "1,-1,-1" return "4,-1,-1" @@ -534,7 +536,8 @@ def read_text(text: str) -> ObsList: "asterism": "Ast", "planet": "Pla", "moon": "Pla", - "minor planet": "Pla", + "minor planet": "AS", + "asteroid": "AS", "dwarf planet": "Pla", "comet": "CM", "region of the sky": "?", diff --git a/python/PiFinder/plot.py b/python/PiFinder/plot.py index bd66b5a31..83b25c5e8 100644 --- a/python/PiFinder/plot.py +++ b/python/PiFinder/plot.py @@ -472,9 +472,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 431af27f1..50f29b83c 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -110,7 +110,7 @@ def __init__( shared_state=None, is_debug=False, ): - self.version_txt = f"{utils.pifinder_dir}/version.txt" + self._software_version = utils.get_version() self.keyboard_queue = keyboard_queue or multiprocessing.Queue() self.ui_queue = ui_queue or multiprocessing.Queue() self.gps_queue = gps_queue or multiprocessing.Queue() @@ -211,12 +211,8 @@ def send_css(filename): def home(): # logger.debug("/ called") # Get version info - software_version = "Unknown" - try: - with open(self.version_txt, "r") as ver_f: - software_version = ver_f.read() - except (FileNotFoundError, IOError) as e: - logger.warning(f"Could not read version file: {str(e)}") + + software_version = self._software_version # Try to update GPS state try: @@ -254,7 +250,7 @@ def home(): software_version=software_version, wifi_mode=self.network.wifi_mode(), ip=self.network.local_ip(), - network_name=self.network.get_connected_ssid(), + network_name=self.network.get_active_label(), gps_icon=gps_icon, gps_text=gps_text, lat_text=lat_text, @@ -536,7 +532,18 @@ def network_update(): self.network.set_wifi_mode(wifi_mode) self.network.set_ap_name(ap_name) self.network.set_host_name(host_name) - return app.jinja_env.get_template("restart.html").render(title=_("Restart")) + + applied_host = self.network.get_host_name() + return app.jinja_env.get_template("network.html").render( + title=_("Network"), + net=self.network, + show_new_form=0, + status_message=_( + "Network settings updated — no restart needed. This device is " + "now reachable at http://{host}.local. If you changed the host " + "name, the previous address stops working, so reconnect there." + ).format(host=applied_host), + ) @app.route("/tools/pwchange", methods=["POST"]) @auth_required @@ -741,13 +748,13 @@ def equipment_add_eyepiece(eyepiece_id: int): ) if eyepiece_id >= 0: - cfg.equipment.update_eyepiece(eyepiece_id, eyepiece) + cfg.equipment.eyepieces[eyepiece_id] = eyepiece else: try: index = cfg.equipment.telescopes.index(eyepiece) - cfg.equipment.update_eyepiece(index, eyepiece) + cfg.equipment.eyepieces[index] = eyepiece except ValueError: - cfg.equipment.add_eyepiece(eyepiece) + cfg.equipment.eyepieces.append(eyepiece) cfg.save_equipment() self.ui_queue.put("reload_config") @@ -1028,23 +1035,16 @@ def remove_file(response): @app.route("/logs/configs") @auth_required def list_log_configs(): - """Return all available logconf_*.json files with display names.""" - import glob - + """Return all available logconf_*.json presets with display names.""" + active = utils.active_logconf_name() configs = [] - active = ( - os.path.realpath("pifinder_logconf.json") - if os.path.exists("pifinder_logconf.json") - else None - ) - for path in sorted(glob.glob("logconf_*.json")): - stem = path[len("logconf_") : -len(".json")] - display = stem.replace("_", " ").title() + for name in utils.available_logconfs(): + stem = name[len("logconf_") : -len(".json")] configs.append( { - "file": path, - "name": display, - "active": os.path.realpath(path) == active, + "file": name, + "name": stem.replace("_", " ").title(), + "active": name == active, } ) return jsonify({"configs": configs}) @@ -1052,29 +1052,15 @@ def list_log_configs(): @app.route("/logs/switch_config", methods=["POST"]) @auth_required def switch_log_config(): - """Atomically repoint pifinder_logconf.json to the chosen config, then restart.""" + """Persist the chosen log config to the data dir, then restart.""" logconf_file = request.form.get("logconf_file", "").strip() - if ( - not logconf_file - or not logconf_file.startswith("logconf_") - or not logconf_file.endswith(".json") - ): + try: + utils.set_active_logconf(logconf_file) + logger.info("Switched log config to %s", logconf_file) + except (ValueError, FileNotFoundError): return jsonify( {"status": "error", "message": "Invalid log config file name"} ) - if not os.path.exists(logconf_file): - return jsonify( - { - "status": "error", - "message": f"Log config file not found: {logconf_file}", - } - ) - try: - link = "pifinder_logconf.json" - tmp = link + ".tmp" - os.symlink(logconf_file, tmp) - os.replace(tmp, link) - logger.info("Switched log config to %s", logconf_file) except Exception as e: logger.error("Failed to switch log config: %s", e) return jsonify({"status": "error", "message": str(e)}) diff --git a/python/PiFinder/solver.py b/python/PiFinder/solver.py index 1b980be81..63a13bab4 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 @@ -33,6 +32,7 @@ from PiFinder.sqm.wings import WingEstimator from PiFinder.sqm.clouds import CloudEstimator from PiFinder.sqm.black_level import BlackLevelTracker +from PiFinder.sqm.airglow import AirglowTracker, sample_diagnostics from PiFinder.sqm.radiometer import ( RadiometerAccumulator, extract_photometry_image, @@ -50,7 +50,6 @@ SuccessfulSolve, ) -sys.path.append(str(utils.tetra3_dir)) import tetra3 from tetra3 import cedar_detect_client @@ -193,37 +192,14 @@ def update_radiometric_sqm( calculation_interval_seconds=1.0, now=None, black_level_tracker=None, + airglow_tracker=None, field_width_degrees=None, ): """Collect every frame and publish a solve-independent value at cadence.""" from datetime import datetime - fresh_sample = accumulator.add(sample) current_time = time.time() if now is None else float(now) - # Every fresh radiometer sample carries (exposure, background) — feed the - # black-level tracker here rather than only from the 10-second stellar - # diagnostics: this cadence conditions its fit in minutes and keeps working - # through failed solves. Withheld while the last transmission diagnostic - # said cloud (a moving sky breaks the intercept's single-line model; the - # tracker's own stderr gate catches drift the flag misses). - if black_level_tracker is not None and fresh_sample: - cloudy_now = shared_state.sqm_details().get("cloud_flag") is True - black_level_tracker.add_sample( - float(sample["exposure_sec"]), - float(sample["background_per_pixel"]), - stable=not cloudy_now, - ) - - current_sqm = shared_state.sqm() - if current_sqm.last_update is not None: - try: - last_update = datetime.fromisoformat(current_sqm.last_update).timestamp() - if current_time - last_update < calculation_interval_seconds: - return False - except (ValueError, AttributeError): - logger.warning("Failed to parse SQM timestamp, recalculating") - noise = sqm_calculator.noise_floor_estimator def tracked_or_static_bias(): @@ -247,6 +223,48 @@ def pedestal_for_exposure(exposure_sec): # dark from sky (both are linear in exposure). return bias + sqm_calculator.profile.dark_current_rate * exposure_sec + if sample is not None: + sample = dict(sample) + if airglow_tracker is not None and sample is not None: + optical_black = sample.get("optical_black_pedestal") + if optical_black is not None and np.isfinite(optical_black): + colour_pedestal = float(optical_black) + colour_pedestal_source = "optical_black" + else: + colour_pedestal = pedestal_for_exposure(float(sample["exposure_sec"])) + colour_pedestal_source = "tracked_or_calibrated" + diagnostic = sample_diagnostics( + sample, airglow_tracker.camera_type, colour_pedestal + ) + diagnostic["pedestal_source"] = colour_pedestal_source + sample["airglow_diagnostic"] = diagnostic + if diagnostic["valid"]: + sample["paired_pedestal"] = colour_pedestal + sample["spectral_floor"] = diagnostic["correction_adu_per_sec"] + sample["paired_radiometric_zero_point"] = diagnostic["paired_zero_point"] + + fresh_sample = accumulator.add(sample) + if black_level_tracker is not None and fresh_sample: + cloudy_now = shared_state.sqm_details().get("cloud_flag") is True + black_level_tracker.add_sample( + float(sample["exposure_sec"]), + float(sample["background_per_pixel"]), + stable=not cloudy_now, + ) + if airglow_tracker is not None and fresh_sample: + airglow_tracker.add_sample( + sample, float(sample["airglow_diagnostic"]["pedestal"]) + ) + + current_sqm = shared_state.sqm() + if current_sqm.last_update is not None: + try: + last_update = datetime.fromisoformat(current_sqm.last_update).timestamp() + if current_time - last_update < calculation_interval_seconds: + return False + except (ValueError, AttributeError): + logger.warning("Failed to parse SQM timestamp, recalculating") + sqm_value, details = accumulator.estimate( sqm_calculator.profile, current_time, @@ -255,6 +273,11 @@ def pedestal_for_exposure(exposure_sec): ) if sqm_value is None: previous = shared_state.sqm_details() + details["window_radiometer"] = accumulator.dump() + if black_level_tracker is not None: + details["window_black_level"] = black_level_tracker.dump() + if airglow_tracker is not None: + details["window_airglow"] = airglow_tracker.dump() shared_state.set_sqm_details({**previous, **details}) return False @@ -280,6 +303,8 @@ def pedestal_for_exposure(exposure_sec): details["black_level_pedestal"] = tracked details["black_level_stderr"] = tracked_stderr details["window_black_level"] = black_level_tracker.dump() + if airglow_tracker is not None: + details["window_airglow"] = airglow_tracker.dump() details["window_radiometer"] = accumulator.dump() details["measurement_role"] = "primary_radiometer" shared_state.set_sqm_details({**previous, **details}) @@ -694,6 +719,12 @@ def _get_stub(self): ) return self._stub + def _alloc_shmem(self, size): + # A fresh segment also requires the server to reopen its cached fd. + fresh = self._shmem is None + resized = super()._alloc_shmem(size) + return resized or fresh + def extract_centroids( self, image, sigma, max_size, use_binned, detect_hot_pixels=True ): @@ -707,14 +738,17 @@ def extract_centroids( # Use shared memory path (same machine) if self._use_shmem: - self._alloc_shmem(size=width * height) + reopen = self._alloc_shmem(size=width * height) shimg = np.ndarray( np_image.shape, dtype=np_image.dtype, buffer=self._shmem.buf ) shimg[:] = np_image[:] im = cedar_detect_pb2.Image( - width=width, height=height, shmem_name=self._shmem.name + width=width, + height=height, + shmem_name=self._shmem.name, + reopen_shmem=reopen, ) req = cedar_detect_pb2.CentroidsRequest( input_image=im, @@ -870,7 +904,7 @@ def solver( ): MultiprocLogging.configurer(log_queue) logger.debug("Starting Solver") - t3 = tetra3.Tetra3(str(utils.tetra3_dir / "data" / "default_database.npz")) + t3 = tetra3.Tetra3("default_database") align_ra = 0 align_dec = 0 last_solve_attempt: float = 0.0 @@ -894,6 +928,7 @@ def solver( # camera type is not yet known here. sqm_cloud_estimator = None sqm_black_level = None + sqm_airglow = None sqm_radiometer = RadiometerAccumulator() last_stellar_diagnostic = 0.0 @@ -946,6 +981,7 @@ def solver( # here so stale seeds/history cannot carry over. sqm_cloud_estimator = None sqm_black_level = None + sqm_airglow = None sqm_radiometer.reset() last_stellar_diagnostic = 0.0 else: @@ -1010,6 +1046,10 @@ def solver( clear_sky_brightness=profile.clear_sky_brightness, ) sqm_black_level = BlackLevelTracker(profile.bias_offset) + camera_type = shared_state.camera_type() + sqm_airglow = ( + AirglowTracker(camera_type) if camera_type == "imx462" else None + ) if sqm_calculator is not None: update_radiometric_sqm( shared_state, @@ -1018,6 +1058,7 @@ def solver( radiometer_sample, calculation_interval_seconds=SQM_CALCULATION_INTERVAL_SECONDS, black_level_tracker=sqm_black_level, + airglow_tracker=sqm_airglow, field_width_degrees=train.fov_degrees, ) diff --git a/python/PiFinder/splash.py b/python/PiFinder/splash.py index 3fdb31b7d..ff20fe71f 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/airglow.py b/python/PiFinder/sqm/airglow.py new file mode 100644 index 000000000..3caecd2e3 --- /dev/null +++ b/python/PiFinder/sqm/airglow.py @@ -0,0 +1,198 @@ +"""Per-frame airglow floor from the colour of the sky background. + +At a dark site the radiometer's background carries a diffuse floor a hand-held +SQM-L does not report: measured on the 2026-07 imx462/HQ reference sweeps it is +~45-70 ADU/s while catalog-calibrated unresolved starlight accounts for only +~3-4 ADU/s of it — the rest fits a single zenith rate through the van Rhijn +law, i.e. it is atmospheric airglow. Airglow varies across the sky and night +to night (the OH bands swing 2-3x with season and solar activity), so no +stored constant or all-sky map can carry it; it must be measured in-session. + +The Bayer mosaic supplies that measurement for free. OH airglow lives at +700-1000 nm where every Bayer filter leaks about equally, so an +airglow-dominated background is grey (R = G = B), while visible sky light is +green-peaked: bright LP skies measure R/G = 0.83-0.87, dark airglow-dominated +skies R/G = 1.0. The red excess of the background above the visible-sky colour +line is therefore a direct airglow gauge: + + floor = red_response * max(R_rate - visible_r_over_g * G_rate, 0) + +One measured constant (``visible_r_over_g``, the bright-sky background colour) +and one fitted constant (``red_response``) for the test imx462. A deliberately minimal +form: a knob-count ablation under leave-one-night-out cross-validation showed +richer models (fitted colour, two-endmember unmixing with a blue channel) +generalize WORSE — dark-night RMS 0.21-0.23 mag against 0.14 for this form, +with bright skies at 0.08. The fitted ``red_response`` also matches its +physical prior: grey light adds only (NIR_r_over_g - visible_r_over_g) = 0.2 +of itself to the red excess, and the SQM-L already sees ~15% of airglow, so +1/0.2 * 0.85 = 4.3 vs 4.07 fitted. + +``paired_zero_point`` was calibrated together with ``red_response`` and +replaces the profile ``radiometric_zero_point`` whenever the floor is applied +— using either half of the pairing alone reintroduces a constant offset. + +Other cameras are deliberately absent: ``floor_from_sample`` returns None and +the live process does not create a tracker for them. +""" + +from __future__ import annotations + +from collections import deque +from typing import Optional + +import numpy as np + +# Calibrated on the 2026-07 imx462 reference archive (22 sweeps over 5 nights): +# leave-one-sweep-out bright/dark RMS 0.08/0.14 mag. +_CAMERA = { + "imx462": { + "visible_r_over_g": 0.85, + "red_response": 4.07, + "paired_zero_point": 15.19, + }, +} + + +def paired_zero_point(camera_type: str) -> Optional[float]: + """Radiometric zero point calibrated together with ``red_response``.""" + cam = _CAMERA.get(camera_type) + return cam["paired_zero_point"] if cam else None + + +def calibration(camera_type: str) -> Optional[dict]: + """Stored airglow calibration constants for a camera (a copy), or None. + + Snapshotting these into a telemetry header keeps a session recomputable if + the constants change in code later. + """ + cam = _CAMERA.get(camera_type) + return dict(cam) if cam is not None else None + + +def floor_from_sample( + sample: dict, + camera_type: str, + pedestal: float, +) -> Optional[float]: + """NIR-excess airglow floor (ADU/s) for one radiometer sample. + + Needs the per-channel red background ``collect_radiometer_sample`` records + on Bayer sensors (``background_red``); returns None on mono sensors or + incomplete samples, so callers fall back to their static floor. + """ + cam = _CAMERA.get(camera_type) + if cam is None: + return None + red = sample.get("background_red") + exposure = sample.get("exposure_sec") + if red is None or not exposure or exposure <= 0: + return None + green_rate = (float(sample["background_per_pixel"]) - pedestal) / exposure + red_rate = (float(red) - pedestal) / exposure + excess = red_rate - cam["visible_r_over_g"] * green_rate + return cam["red_response"] * max(excess, 0.0) + + +def sample_diagnostics(sample: dict, camera_type: str, pedestal: float) -> dict: + """Return every intermediate used by the experimental colour correction.""" + result = { + "camera_type": camera_type, + "sequence": sample.get("sequence"), + "captured_at": sample.get("captured_at"), + "exposure_sec": sample.get("exposure_sec"), + "pedestal": float(pedestal), + "valid": False, + } + cam = _CAMERA.get(camera_type) + if cam is None: + result["failure_reason"] = "camera_not_calibrated" + return result + exposure = sample.get("exposure_sec") + red = sample.get("background_red") + green = sample.get("background_per_pixel") + blue = sample.get("background_blue") + if red is None or green is None or not exposure or exposure <= 0: + result["failure_reason"] = "colour_sample_incomplete" + return result + + exposure = float(exposure) + red_rate = (float(red) - pedestal) / exposure + green_rate = (float(green) - pedestal) / exposure + blue_rate = (float(blue) - pedestal) / exposure if blue is not None else None + excess = red_rate - cam["visible_r_over_g"] * green_rate + clipped = max(excess, 0.0) + correction = cam["red_response"] * clipped + result.update( + { + "valid": True, + "background_red": float(red), + "background_green": float(green), + "background_blue": float(blue) if blue is not None else None, + "red_rate": red_rate, + "green_rate": green_rate, + "blue_rate": blue_rate, + "red_over_green": red_rate / green_rate if green_rate > 0 else None, + "blue_over_green": ( + blue_rate / green_rate + if blue_rate is not None and green_rate > 0 + else None + ), + "visible_r_over_g": cam["visible_r_over_g"], + "red_excess_unclipped": excess, + "red_excess_clipped": clipped, + "red_response": cam["red_response"], + "correction_adu_per_sec": correction, + "paired_zero_point": cam["paired_zero_point"], + } + ) + return result + + +class AirglowTracker: + """Rolling median of per-frame airglow floors for a stable estimate. + + Single frames are shot-noise limited at short exposures; the tracker + medians the recent per-frame floors the same way the radiometer + accumulator medians its SQM samples. + """ + + def __init__(self, camera_type: str, max_samples: int = 12): + self.camera_type = camera_type + self.max_samples = max_samples + self._samples: deque[dict] = deque(maxlen=max_samples) + + def add_sample(self, sample: dict, pedestal: float) -> Optional[float]: + stored = sample.get("airglow_diagnostic") + diagnostic = ( + dict(stored) + if stored is not None + else sample_diagnostics(sample, self.camera_type, pedestal) + ) + if not diagnostic["valid"]: + return None + self._samples.append(diagnostic) + return diagnostic["correction_adu_per_sec"] + + def floor(self) -> Optional[float]: + if not self._samples: + return None + return float( + np.median([sample["correction_adu_per_sec"] for sample in self._samples]) + ) + + def dump(self) -> dict: + """Full JSON-serializable state for post-observation replay.""" + floors = [sample["correction_adu_per_sec"] for sample in self._samples] + return { + "camera_type": self.camera_type, + "n_samples": len(self._samples), + "max_samples": self.max_samples, + "floor": self.floor(), + "floor_stddev": float(np.std(floors)) if floors else None, + "floor_min": min(floors) if floors else None, + "floor_max": max(floors) if floors else None, + "samples": list(self._samples), + } + + def reset(self) -> None: + self._samples.clear() diff --git a/python/PiFinder/sqm/radiometer.py b/python/PiFinder/sqm/radiometer.py index 2153665d6..c35a9de85 100644 --- a/python/PiFinder/sqm/radiometer.py +++ b/python/PiFinder/sqm/radiometer.py @@ -109,6 +109,7 @@ def collect_radiometer_sample( captured_at: float, border_fraction: float = 0.10, stride: int = 4, + optical_black_pedestal: Optional[float] = None, ) -> Optional[dict]: """Reduce a raw frame to a robust sky-background sample. @@ -158,6 +159,8 @@ def collect_radiometer_sample( if red is not None: sample["background_red"] = red sample["background_green"] = green + if optical_black_pedestal is not None and np.isfinite(optical_black_pedestal): + sample["optical_black_pedestal"] = float(optical_black_pedestal) return sample @@ -167,6 +170,8 @@ def radiometric_sqm( *, pedestal: Optional[float] = None, field_width_degrees: Optional[float] = None, + floor: float = 0.0, + zero_point: Optional[float] = None, ) -> tuple[Optional[float], dict]: """Convert one camera sample directly to SQM-L-equivalent brightness. @@ -183,18 +188,24 @@ def radiometric_sqm( pedestal = float(profile.bias_offset) if field_width_degrees is None: field_width_degrees = optical_train_for_profile(profile).fov_degrees - signal = background - pedestal + signal = background - pedestal - floor * exposure_sec + effective_zero_point = ( + float(zero_point) + if zero_point is not None + else float(profile.radiometric_zero_point) + ) details = { **sample, "pedestal": pedestal, + "skyglow_floor": floor, "background_corrected": signal, - "radiometric_zero_point": profile.radiometric_zero_point, + "radiometric_zero_point": effective_zero_point, "radiometric_fov_degrees": field_width_degrees, } if signal <= 1.0: details["failure_reason"] = "background_not_resolved_above_pedestal" return None, details - if not profile.radiometric_zero_point or not field_width_degrees: + if not effective_zero_point or not field_width_degrees: details["failure_reason"] = "radiometric_factory_calibration_unavailable" return None, details @@ -202,26 +213,36 @@ def radiometric_sqm( # zero point moves with it. Slope 0 (mono, or an IR-cut sensor with no NIR # leak to correct) leaves this a plain constant. R/G is clamped to the # calibrated range rather than extrapolated off the end of the fit. - zero_point = float(profile.radiometric_zero_point) + applied_zero_point = effective_zero_point slope = float(getattr(profile, "radiometric_colour_slope", 0.0) or 0.0) red = sample.get("background_red") green = sample.get("background_green") - if slope and red is not None and green is not None and (green - pedestal) > 1.0: + if ( + zero_point is None + and slope + and red is not None + and green is not None + and (green - pedestal) > 1.0 + ): ratio = (red - pedestal) / (green - pedestal) lo, hi = profile.radiometric_colour_range clamped = min(max(ratio, lo), hi) - zero_point += slope * (clamped - profile.radiometric_colour_pivot) + applied_zero_point += slope * (clamped - profile.radiometric_colour_pivot) details["sky_red_over_green"] = ratio details["sky_red_over_green_clamped"] = clamped # radiometric_zero_point keeps meaning the profile constant, so archives # stay comparable across this change; the applied value is reported # alongside it and is always present, corrected or not. - details["radiometric_zero_point_effective"] = zero_point + details["radiometric_zero_point_effective"] = applied_zero_point pixels_per_side = int(sample["pixels_per_side"]) arcsec_squared_per_pixel = (field_width_degrees * 3600.0) ** 2 / pixels_per_side**2 flux_density = signal / arcsec_squared_per_pixel - value = zero_point + 2.5 * math.log10(exposure_sec) - 2.5 * math.log10(flux_density) + value = ( + applied_zero_point + + 2.5 * math.log10(exposure_sec) + - 2.5 * math.log10(flux_density) + ) details.update( { "background_flux_density": flux_density, @@ -266,16 +287,18 @@ def estimate( age = now - float(sample["captured_at"]) if age < 0 or age > self.max_age_seconds: continue - pedestal = ( - pedestal_for_exposure(float(sample["exposure_sec"])) - if pedestal_for_exposure is not None - else None - ) + pedestal = sample.get("paired_pedestal") + if pedestal is None and pedestal_for_exposure is not None: + pedestal = pedestal_for_exposure(float(sample["exposure_sec"])) + sample_floor = float(sample.get("spectral_floor", 0.0)) + sample_zero_point = sample.get("paired_radiometric_zero_point") value, details = radiometric_sqm( sample, profile, pedestal=pedestal, field_width_degrees=field_width_degrees, + floor=sample_floor, + zero_point=sample_zero_point, ) if value is not None: values.append(value) diff --git a/python/PiFinder/sqm/skyglow_map.py b/python/PiFinder/sqm/skyglow_map.py new file mode 100644 index 000000000..bec07a150 --- /dev/null +++ b/python/PiFinder/sqm/skyglow_map.py @@ -0,0 +1,190 @@ +"""Expected natural-sky diffuse background ("floor") for the radiometer. + +At a dark site the median sky background the radiometer converts to SQM sits +above the true patch the reference meter reports, by a diffuse floor that a +zenith-pointed hand-held meter does not integrate the same way. Measured on +the imx462/HQ cross-calibration sweeps, that floor is real light through the +optics — not pedestal, dark current, amp glow, or self-light — and it varies +with where the telescope points and how low in the sky it looks. + +This module predicts that floor from three natural components, each of which is +a genuine sky-brightness term (the pieces GAMBONS / Leinert 1998 combine into a +full natural-sky-brightness map): + + integrated starlight depends on galactic latitude b (and longitude l): a + field into the Milky Way plane carries far more + unresolved starlight than one toward the pole. The + b-profile here is fitted to Gaia DR3 faint-star + (13 < G < 19) surface density sampled along l ~ 70: + an exponential in |b| with a ~19.5 deg scale height, + dropping ~20x from plane to pole. + airglow brightens toward the horizon as the van Rhijn + function of airmass (needs the pointing altitude, + hence an observer location + time). + base the isotropic remainder (instrumental diffuse light, + zodiacal residual, extragalactic background). + +The absolute scale is per camera: the sensitive, NIR-enhanced imx462 collects +~11x more of this red-rich diffuse light than the IR-cut HQ, which is why the +HQ reads much closer to the reference without any correction. + +The correction is OPTIONAL and solve-gated: ``expected_floor`` returns ``None`` +when no RA/Dec is available (unsolved frame), and the caller falls back to the +per-camera base floor. When an observer location/time is not supplied the +airglow term is dropped (zenith assumption) rather than guessed. + +CALIBRATION STATUS: the per-camera scale constants are fitted from only three +dark sweeps (imx462) that confound galactic latitude with altitude, plus the +Gaia vertical profile. The *structure* is physical and the b-profile is +data-grounded; the absolute constants are provisional and want a calibration +campaign spanning |b| and altitude independently. Upgrade path: replace +``_integrated_starlight`` with a bundled all-sky GAMBONS/Gaia radiance map +looked up by (l, b). +""" + +from __future__ import annotations + +import math +from datetime import datetime, timezone +from typing import Optional + +# Equatorial (ICRS) -> galactic rotation matrix, J2000. +_EQ2GAL = ( + (-0.0548755604, -0.8734370902, -0.4838350155), + (0.4941094279, -0.4448296300, 0.7469822445), + (-0.8676661490, -0.1980763734, 0.4559837762), +) + +# Integrated-starlight vertical profile, fitted to Gaia DR3 faint-star flux +# (13 < G < 19, 0.3 deg cones) along l ~ 70: isl(|b|) = C + A*exp(-|b|/H). +_ISL_C = 0.000100 +_ISL_A = 0.002662 +_ISL_H_DEG = 19.5 +_ISL_PLANE = _ISL_C + _ISL_A # value at b = 0, used to normalise to 1.0 + +# Per-camera floor model (ADU/s): +# floor = base + k_isl * isl_norm(l, b) + k_air * (van_rhijn(airmass) - 1) +# imx462 fitted to the three 2026-07-20 dark sweeps + the Gaia profile. +# HQ is provisional: its dark sweeps sit near the plane and cannot constrain +# the slope, so it carries a flat base only until a |b|-spread calibration. +_CAMERA_FLOOR = { + "imx462": {"base": 27.2, "k_isl": 33.4, "k_air": 29.4}, + "imx290": {"base": 27.2, "k_isl": 33.4, "k_air": 29.4}, # shares imx462 optics + "hq": {"base": 4.5, "k_isl": 0.0, "k_air": 0.0}, + "imx477": {"base": 4.5, "k_isl": 0.0, "k_air": 0.0}, +} + + +def equatorial_to_galactic(ra_deg: float, dec_deg: float) -> tuple[float, float]: + """(l, b) in degrees for ICRS (ra, dec) in degrees; l in [0, 360).""" + ra, dec = math.radians(ra_deg), math.radians(dec_deg) + v = ( + math.cos(dec) * math.cos(ra), + math.cos(dec) * math.sin(ra), + math.sin(dec), + ) + g = tuple(sum(_EQ2GAL[i][j] * v[j] for j in range(3)) for i in range(3)) + b = math.degrees(math.asin(max(-1.0, min(1.0, g[2])))) + gl = math.degrees(math.atan2(g[1], g[0])) % 360.0 + return gl, b + + +def _julian_date(when: datetime) -> float: + when = when.astimezone(timezone.utc) + y, m = when.year, when.month + d = when.day + (when.hour + (when.minute + when.second / 60.0) / 60.0) / 24.0 + if m <= 2: + y -= 1 + m += 12 + a = y // 100 + b = 2 - a + a // 4 + return ( + math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + d + b - 1524.5 + ) + + +def altitude_deg( + ra_deg: float, dec_deg: float, lat_deg: float, lon_deg: float, when: datetime +) -> float: + """Apparent altitude (deg) of (ra, dec) from (lat, lon) at UTC ``when``. + + lon_deg is positive east. Refraction is not applied (irrelevant to the + airmass weighting at the altitudes SQM sweeps use). + """ + jd = _julian_date(when) + t = jd - 2451545.0 + gmst = (280.46061837 + 360.98564736629 * t) % 360.0 + lst = (gmst + lon_deg) % 360.0 + ha = math.radians((lst - ra_deg) % 360.0) + dec, lat = math.radians(dec_deg), math.radians(lat_deg) + sin_alt = math.sin(dec) * math.sin(lat) + math.cos(dec) * math.cos(lat) * math.cos( + ha + ) + return math.degrees(math.asin(max(-1.0, min(1.0, sin_alt)))) + + +def _airmass(alt_deg: float) -> float: + """Plane-parallel airmass, floored at ~5 deg altitude.""" + return 1.0 / math.sin(math.radians(max(alt_deg, 5.0))) + + +def _van_rhijn(airmass: float) -> float: + """Airglow brightness relative to zenith for a ~90 km emitting layer.""" + sin_z = math.sqrt(max(0.0, 1.0 - 1.0 / airmass**2)) + return 1.0 / math.sqrt(max(1e-6, 1.0 - 0.96 * sin_z**2)) + + +def _integrated_starlight(l_deg: float, b_deg: float) -> float: + """Integrated-starlight surface brightness, normalised to 1.0 at b = 0. + + Longitude dependence is not yet calibrated (all sampled fields sit near + l ~ 70), so only the well-constrained |b| profile is applied. + """ + isl = _ISL_C + _ISL_A * math.exp(-abs(b_deg) / _ISL_H_DEG) + return isl / _ISL_PLANE + + +def expected_floor( + ra_deg: Optional[float], + dec_deg: Optional[float], + camera_type: str, + *, + alt_deg: Optional[float] = None, + when: Optional[datetime] = None, + lat_deg: Optional[float] = None, + lon_deg: Optional[float] = None, +) -> Optional[float]: + """Predicted diffuse-background floor (ADU/s) for a solved pointing. + + Returns ``None`` when no RA/Dec is available (unsolved frame) so the caller + falls back to the per-camera base floor. + + The airglow term needs the pointing altitude. Pass ``alt_deg`` directly + (PiFinder knows it from the solve + IMU) or supply observer ``lat_deg`` / + ``lon_deg`` / ``when`` to derive it. With neither, airglow is dropped + (zenith assumption) rather than guessed. + """ + cam = _CAMERA_FLOOR.get(camera_type) + if cam is None: + return None + if ra_deg is None or dec_deg is None: + return None + gl, b = equatorial_to_galactic(ra_deg, dec_deg) + floor = cam["base"] + cam["k_isl"] * _integrated_starlight(gl, b) + if cam["k_air"]: + if ( + alt_deg is None + and lat_deg is not None + and lon_deg is not None + and when is not None + ): + alt_deg = altitude_deg(ra_deg, dec_deg, lat_deg, lon_deg, when) + if alt_deg is not None: + floor += cam["k_air"] * (_van_rhijn(_airmass(alt_deg)) - 1.0) + return floor + + +def base_floor(camera_type: str) -> float: + """Solve-independent fallback floor (ADU/s) when RA/Dec is unavailable.""" + cam = _CAMERA_FLOOR.get(camera_type) + return cam["base"] if cam else 0.0 diff --git a/python/PiFinder/sqm/sqm.ipynb b/python/PiFinder/sqm/sqm.ipynb index 8d37e393d..490d27c57 100644 --- a/python/PiFinder/sqm/sqm.ipynb +++ b/python/PiFinder/sqm/sqm.ipynb @@ -32,11 +32,9 @@ "import logging as logger\n", "from pathlib import Path\n", "import matplotlib.pyplot as plt\n", - "\n", "%matplotlib inline\n", "import pprint\n", - "\n", - "pp = pprint.PrettyPrinter(depth=5)" + "pp = pprint.PrettyPrinter(depth=5)\n" ] }, { @@ -72,28 +70,33 @@ } ], "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", - "root_path = 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", "\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 tetra3\n", - "from tetra3 import cedar_detect_client\n", + "import PiFinder.tetra3.tetra3 as tetra3\n", + "from PiFinder.tetra3.tetra3 import cedar_detect_client\n", "from PiFinder import utils\n", - "\n", "os_detail, platform, arch = utils.get_os_info()\n", "\n", - "t3 = tetra3.Tetra3(\"default_database\")\n", + "t3 = tetra3.Tetra3(\n", + " str(tetra3_path / \"data/default_database.npz\")\n", + ")\n", "\n", "logger.info(\"Starting Solver Loop\")\n", "# Start cedar detect server\n", @@ -157,26 +160,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']}" ] }, { @@ -194,7 +197,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", @@ -202,18 +205,16 @@ " # 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", - "\n", + " plt.show() \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()" @@ -535,7 +536,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)" ] @@ -591,10 +592,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", @@ -606,16 +607,13 @@ " del solution[\"cache_hit_fraction\"]\n", " return centroids, solution\n", "\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(\n", - " 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(f\"For {key}, there are {len(value['matched_stars'])} matched_stars and {len(centroids)} total centroids\")" ] }, { @@ -634,11 +632,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", @@ -647,16 +645,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)" + "#pp.pprint(images)\n", + "\n" ] }, { @@ -687,16 +685,15 @@ "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(\n", - " str(i), (x, y), color=\"yellow\", fontsize=8, ha=\"right\", va=\"top\"\n", - " ) # ha/va center the text on the point\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", "plt.show()" ] }, @@ -732,38 +729,31 @@ "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(\n", - " mean_val,\n", - " color=\"r\",\n", - " linestyle=\"dashed\",\n", - " alpha=0.5,\n", - " label=f\"Mean: {mean_val:.1f}\",\n", - " )\n", + " plt.axvline(mean_val, color='r', linestyle='dashed', alpha=0.5, \n", + " label=f'Mean: {mean_val:.1f}')\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)" ] }, @@ -805,21 +795,19 @@ "\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 = (\n", - " (stretched - stretched.min()) * (255.0 / (stretched.max() - stretched.min()))\n", - ").astype(np.uint8)\n", + "stretched = ((stretched - stretched.min()) * (255.0/(stretched.max() - stretched.min()))).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", @@ -922,52 +910,46 @@ "\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(\n", - " f\"{'Image':<25} {'Expected':<12} {'Calculated':<12} {'Error':<12} {'Error %':<12}\"\n", - ")\n", + "print(f\"{'Image':<25} {'Expected':<12} {'Calculated':<12} {'Error':<12} {'Error %':<12}\")\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(\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", + " \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", " else:\n", " print(f\"{key:<25} FAILED\")\n", "\n", @@ -1071,17 +1053,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", @@ -1099,27 +1081,25 @@ "\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", - " {\n", - " \"filename\": filename,\n", - " \"expected\": info[\"realsqm\"],\n", - " \"calculated\": None,\n", - " \"error\": None,\n", - " \"status\": \"SOLVE_FAILED\",\n", - " }\n", - " )\n", + " results_summary.append({\n", + " 'filename': filename,\n", + " 'expected': info['realsqm'],\n", + " 'calculated': None,\n", + " 'error': None,\n", + " 'status': 'SOLVE_FAILED'\n", + " })\n", " continue\n", - "\n", + " \n", " # Calculate SQM\n", " sqm_val, details = sqm.calculate(\n", " centroids=centroids,\n", @@ -1129,41 +1109,33 @@ " 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(\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", + " 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", " else:\n", " print(\" ❌ Failed to calculate SQM\")\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", + " results_summary.append({\n", + " 'filename': filename,\n", + " 'expected': info['realsqm'],\n", + " 'calculated': None,\n", + " 'error': None,\n", + " 'status': 'CALC_FAILED'\n", + " })\n", "\n", "print(\"\\n\" + \"=\" * 100)\n", "print(\"SUMMARY\")\n", @@ -1172,28 +1144,22 @@ "print(\"-\" * 100)\n", "\n", "for result in results_summary:\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", + " 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", " else:\n", - " print(\n", - " f\"{result['filename']:<30} {result['expected']:>10.2f} {'---':>10} {'---':>10} {result['status']:<15}\"\n", - " )\n", + " print(f\"{result['filename']:<30} {result['expected']:>10.2f} {'---':>10} {'---':>10} {result['status']:<15}\")\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(\n", - " f\"RMS error: {np.sqrt(np.mean(np.array(errors) ** 2)):.2f} mag/arcsec²\"\n", - " )\n", + " print(f\"RMS error: {np.sqrt(np.mean(np.array(errors)**2)):.2f} mag/arcsec²\")\n", " print(f\"Max error: {np.max(np.abs(errors)):.2f} mag/arcsec²\")" ] }, @@ -1363,40 +1329,36 @@ "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", - "\n", - "def detect_aperture_overlaps(\n", - " star_centroids, aperture_radius, annulus_inner, annulus_outer\n", - "):\n", + "def detect_aperture_overlaps(star_centroids, aperture_radius, annulus_inner, annulus_outer):\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", @@ -1408,68 +1370,61 @@ " \"\"\"\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", - " {\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", + " 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", " elif distance < aperture_radius + annulus_outer:\n", " # HIGH: Aperture inside another star's annulus (background contamination)\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", + " 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", " elif distance < 2 * annulus_outer:\n", " # MEDIUM: Annulus-annulus overlap (less critical)\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", + " 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", " 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", @@ -1480,9 +1435,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", @@ -1493,102 +1448,90 @@ " 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(\n", - " star_centroids, APERTURE_RADIUS, ANNULUS_INNER, ANNULUS_OUTER\n", - " )\n", - "\n", + " overlaps = detect_aperture_overlaps(star_centroids, APERTURE_RADIUS, ANNULUS_INNER, ANNULUS_OUTER)\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(\n", - " f\" Stars affected: {len(overlapping_stars)}/{len(star_centroids)} ({100 * len(overlapping_stars) / len(star_centroids):.0f}%)\"\n", - " )\n", + " print(f\" Stars affected: {len(overlapping_stars)}/{len(star_centroids)} ({100*len(overlapping_stars)/len(star_centroids):.0f}%)\")\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 = [\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_indices = [i for i in range(len(star_fluxes)) \n", + " if star_fluxes[i] > 0 and star_mzeros[i] is not None]\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(\n", - " valid_mzeros, sigma=2.0\n", - " )\n", + " mzero_sigclip, mzero_sigclip_std, sigclip_mask = sigma_clip_mean(valid_mzeros, sigma=2.0)\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(\n", - " valid_mags, valid_mzeros\n", - " )\n", + " slope_all, intercept_all, r_value_all, _, _ = stats.linregress(valid_mags, valid_mzeros)\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(\n", - " clipped_mags, clipped_mzeros\n", - " )\n", + " slope_clip, intercept_clip, r_value_clip, _, _ = stats.linregress(clipped_mags, clipped_mzeros)\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", @@ -1609,632 +1552,398 @@ " 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 = (\n", - " mzero_trend_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", - " )\n", + " sqm_trend_sigclip = mzero_trend_sigclip - 2.5 * np.log10(bg_flux_density) + extinction\n", " else:\n", - " mzero_mean = mzero_median = mzero_sigclip = mzero_trend = (\n", - " mzero_trend_sigclip\n", - " ) = None\n", + " mzero_mean = mzero_median = mzero_sigclip = mzero_trend = mzero_trend_sigclip = 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(\n", - " [x1, x2], [y1, y2], color=line_color, linestyle=\":\", linewidth=2, alpha=0.7\n", - " )\n", - "\n", + " line_color = 'yellow'\n", + " \n", + " ax1.plot([x1, x2], [y1, y2], color=line_color, linestyle=':', linewidth=2, alpha=0.7)\n", + " \n", " # Draw apertures on matched stars\n", - " for i, (centroid, flux, mag, local_bg) in enumerate(\n", - " zip(star_centroids, star_fluxes, star_mags, star_local_bgs)\n", - " ):\n", + " for i, (centroid, flux, mag, local_bg) in enumerate(zip(star_centroids, star_fluxes, star_mags, star_local_bgs)):\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(\n", - " (x, y),\n", - " APERTURE_RADIUS,\n", - " fill=False,\n", - " edgecolor=color,\n", - " linewidth=2,\n", - " alpha=alpha,\n", - " )\n", + " circle = mpatches.Circle((x, y), APERTURE_RADIUS, \n", + " fill=False, edgecolor=color, linewidth=2, alpha=alpha)\n", " ax1.add_patch(circle)\n", - "\n", + " \n", " # Draw annulus inner (dashed)\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", + " annulus_inner = mpatches.Circle((x, y), ANNULUS_INNER,\n", + " fill=False, edgecolor=color, linewidth=1, \n", + " linestyle='--', alpha=0.4)\n", " ax1.add_patch(annulus_inner)\n", - "\n", + " \n", " # Draw annulus outer (dashed)\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", + " annulus_outer_circle = mpatches.Circle((x, y), ANNULUS_OUTER,\n", + " fill=False, edgecolor=color, linewidth=1, \n", + " linestyle='--', alpha=0.4)\n", " ax1.add_patch(annulus_outer_circle)\n", - "\n", + " \n", " # Label star\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", + " 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", " # 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(\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", + " 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", " ]\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(\n", - " zip(star_mags, star_fluxes, star_local_bgs, star_mzeros)\n", - " ):\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(zip(star_mags, star_fluxes, star_local_bgs, star_mzeros)):\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", - " [\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", + " \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", " # Create table\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 = 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.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(\n", - " \"Per-Star Breakdown\\n(Δmz = deviation from mean)\",\n", - " fontsize=11,\n", - " weight=\"bold\",\n", - " pad=20,\n", - " )\n", - "\n", + " \n", + " ax2.set_title('Per-Star Breakdown\\n(Δmz = deviation from mean)', fontsize=11, weight='bold', pad=20)\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 = [\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", + " 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", " # Horizontal lines for different methods\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", + " 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", " # 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(\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", + " 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", " # Sigma-clipped trend\n", " if n_clipped > 0:\n", " trend_line_clip = slope_clip * mag_range + intercept_clip\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", + " 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", " # Mark median magnitude\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", + " ax3.axvline(np.median(valid_mags), color='gray', linestyle='--', linewidth=1, alpha=0.5, zorder=1)\n", + " \n", " # Std deviation bands\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.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.grid(True, alpha=0.3)\n", " ax3.invert_xaxis() # Brighter stars on right\n", " else:\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", + " ax3.text(0.5, 0.5, 'No valid stars', transform=ax3.transAxes, ha='center', va='center')\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 = [\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", + " 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", " # Horizontal lines\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", + " 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", " # Check for trend with flux\n", " if len(valid_mzeros) >= 3:\n", - " slope_flux, intercept_flux, r_value_flux, _, _ = stats.linregress(\n", - " log_fluxes, valid_mzeros\n", - " )\n", + " slope_flux, intercept_flux, r_value_flux, _, _ = stats.linregress(log_fluxes, valid_mzeros)\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(\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.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.grid(True, alpha=0.3)\n", " else:\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", + " ax4.text(0.5, 0.5, 'No valid stars', transform=ax4.transAxes, ha='center', va='center')\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(\n", - " valid_mzeros,\n", - " bins=min(15, len(valid_mzeros)),\n", - " color=\"steelblue\",\n", - " alpha=0.7,\n", - " edgecolor=\"black\",\n", - " )\n", - "\n", + " ax5.hist(valid_mzeros, bins=min(15, len(valid_mzeros)), \n", + " color='steelblue', alpha=0.7, edgecolor='black')\n", + " \n", " # Mark different estimators\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", + " ax5.axvline(mzero_mean, color='blue', linestyle='-', linewidth=2, label='Mean')\n", + " ax5.axvline(mzero_median, color='green', linestyle='--', linewidth=2, label='Median')\n", " if n_clipped > 0:\n", - " ax5.axvline(\n", - " mzero_sigclip, color=\"red\", linestyle=\"-.\", linewidth=2, label=\"σ-clip\"\n", - " )\n", + " ax5.axvline(mzero_sigclip, color='red', linestyle='-.', linewidth=2, label='σ-clip')\n", " if len(valid_mzeros) >= 3:\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.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.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(\n", - " 0.5,\n", - " 0.5,\n", - " \"No valid stars\",\n", - " transform=ax5.transAxes,\n", - " ha=\"center\",\n", - " va=\"center\",\n", - " )\n", - "\n", + " ax5.text(0.5, 0.5, 'No valid stars', transform=ax5.transAxes, ha='center', va='center')\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", - " [\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", + " 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", " if n_clipped > 0:\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", + " 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", " if len(valid_mzeros) >= 3:\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", + " 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", " if n_clipped > 0:\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", + " 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", " # Add overlap-corrected result\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", + " 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", " 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(\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", + " 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", " # ========== 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 = [\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", + " 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", " 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 = (\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", + " 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", "\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", @@ -2245,7 +1954,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", @@ -2258,86 +1967,60 @@ "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(\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", + " \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", " 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(\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(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 summary\n", " print(f\"\\n✓ Processed {filename}\")\n", " print(\"\\n WITHOUT overlap correction:\")\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", + " 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", " if sqm_val_corrected is not None:\n", " print(\"\\n WITH overlap correction:\")\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\" 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(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", @@ -2347,23 +2030,19 @@ " 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(\n", - " f\"⚠️ Large error (no corr): {sqm_val - info['realsqm']:+.2f} mag/arcsec²\"\n", - " )\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 n_clipped > 0:\n", " issues.append(f\"ℹ️ {n_clipped} outliers removed by σ-clipping\")\n", " if overlaps:\n", - " issues.append(\n", - " f\"⚠️ {len(overlaps)} aperture overlaps detected ({len(overlapping_stars)} stars affected)\"\n", - " )\n", - "\n", + " issues.append(f\"⚠️ {len(overlaps)} aperture overlaps detected ({len(overlapping_stars)} stars affected)\")\n", + " \n", " if issues:\n", " print(\"\\n Notes:\")\n", " for issue in issues:\n", " print(f\" {issue}\")\n", - "\n", - " print(\"\")" + " \n", + " print(\"\")\n" ] }, { diff --git a/python/PiFinder/sqm/sqm.py b/python/PiFinder/sqm/sqm.py index 5511c7c0a..6c2871f8e 100644 --- a/python/PiFinder/sqm/sqm.py +++ b/python/PiFinder/sqm/sqm.py @@ -356,7 +356,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: @@ -560,7 +560,7 @@ def calculate( logger.info( f"Overlap correction: excluded {n_stars_excluded}/{n_stars_original} stars " - f"({n_stars_excluded * 100 // n_stars_original}%), using {len(valid_indices)} stars" + f"({n_stars_excluded*100//n_stars_original}%), using {len(valid_indices)} stars" ) if len(valid_indices) < 3: diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index 555b6dd04..7ae9a47a4 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -318,6 +318,7 @@ def __init__(self) -> None: # We need gps lock and datetime self.__tz_finder = TimezoneFinder() self.__current_ui_state = None + self.__test_mode = False def serialize(self, output_file): with open(output_file, "wb") as f: @@ -344,10 +345,11 @@ def power_state(self): def set_power_state(self, v): """ - Sets the power_state. Allowed states are 0 (sleep) or 1 (awake). If - the input v is any other value, power_state will be unchanged. + Sets the power_state. Allowed states are -1 (screen off), 0 (sleep) + or 1 (awake). If the input v is any other value, power_state will be + unchanged. """ - if v in (0, 1): + if v in (-1, 0, 1): self.__power_state = v else: logger.error( @@ -635,3 +637,9 @@ def __str__(self): f"Screen: {self.__screen}\n" f"Target Pixel: {self.__target_pixel}" ) + + def test_mode(self): + return self.__test_mode + + def set_test_mode(self, v: bool): + self.__test_mode = v diff --git a/python/PiFinder/sys_utils.py b/python/PiFinder/sys_utils.py index a3094dc08..9ed1d9d7b 100644 --- a/python/PiFinder/sys_utils.py +++ b/python/PiFinder/sys_utils.py @@ -1,571 +1,809 @@ -import glob -import json +""" +NixOS system utilities for PiFinder. + +Uses: +- NetworkManager GLib bindings (gi.repository.NM) for WiFi management +- python-pam for password verification +- D-Bus for hostname/reboot/shutdown +- stdlib zipfile for backup/restore +- NixOS specialisations for camera switching +- systemd service for software updates +""" + +import os import re -from typing import Dict, Any +import json +import subprocess +import logging + +from PiFinder import timez +from pathlib import Path +from typing import Optional +import dbus import pam -import requests -import sh -from sh import wpa_cli, unzip, passwd +import gi -import socket -from PiFinder import utils -import logging +gi.require_version("NM", "1.0") +from gi.repository import GLib, NM # noqa: E402 + +from PiFinder.sys_utils_base import ( # noqa: E402 + NetworkBase, + BACKUP_PATH, # noqa: F401 + remove_backup, # noqa: F401 + backup_userdata, # noqa: F401 + restore_userdata, # noqa: F401 + restart_pifinder, # noqa: F401 +) + +AP_CONNECTION_NAME = "PiFinder-AP" -BACKUP_PATH = "/home/pifinder/PiFinder_data/PiFinder_backup.zip" +logger = logging.getLogger("SysUtils.NixOS") -logger = logging.getLogger("SysUtils") + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run a command, logging failures.""" + result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + if result.returncode != 0: + logger.error( + "Command %s failed (rc=%d): %s", + cmd, + result.returncode, + result.stderr.strip(), + ) + return result -class Network: +def _nm_client() -> NM.Client: + """Create a NetworkManager client (synchronous).""" + return NM.Client.new(None) + + +def _nm_run_async(async_fn, *args): """ - Provides wifi network info + Run an async NM operation synchronously by spinning a local GLib MainLoop. """ + loop = GLib.MainLoop.new(None, False) + state = {"result": None, "error": None} + + def callback(source, async_result, _user_data): + try: + method_name = async_fn.__name__.replace("_async", "_finish") + finish_fn = getattr(source, method_name) + state["result"] = finish_fn(async_result) + except Exception as e: + state["error"] = e + finally: + loop.quit() + + async_fn(*args, callback, None) + loop.run() + + if state["error"]: + raise state["error"] + return state["result"] + + +def _get_system_bus() -> dbus.SystemBus: + return dbus.SystemBus() + + +# --------------------------------------------------------------------------- +# Network class — WiFi management via NM GLib bindings +# --------------------------------------------------------------------------- - def __init__(self): - self.wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" - with open(self.wifi_txt, "r") as wifi_f: - self._wifi_mode = wifi_f.read() +class Network(NetworkBase): + """ + Provides wifi network info via NetworkManager GLib bindings (libnm). + """ + + def __init__(self): + self._client = _nm_client() + self._wifi_networks: list[dict] = [] + self._wifi_mode = self._detect_wifi_mode() self.populate_wifi_networks() + def _detect_wifi_mode(self) -> str: + """Detect whether we're in AP or Client mode.""" + for ac in self._client.get_active_connections(): + if ac.get_id() == AP_CONNECTION_NAME: + return "AP" + return "Client" + def populate_wifi_networks(self) -> None: - wpa_supplicant_path = "/etc/wpa_supplicant/wpa_supplicant.conf" + """Get saved WiFi connections from NetworkManager.""" self._wifi_networks = [] - try: - with open(wpa_supplicant_path, "r") as wpa_conf: - contents = wpa_conf.readlines() - except IOError as e: - logger.error(f"Error reading wpa_supplicant.conf: {e}") - return - - self._wifi_networks = Network._parse_wpa_supplicant(contents) - - @staticmethod - def _parse_wpa_supplicant(contents: list[str]) -> list: - """ - Parses wpa_supplicant.conf to get current config - """ - wifi_networks = [] - network_dict: Dict[str, Any] = {} network_id = 0 - in_network_block = False - for line in contents: - line = line.strip() - if line.startswith("network={"): - in_network_block = True - network_dict = { + for conn in self._client.get_connections(): + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + continue + if conn.get_id() == AP_CONNECTION_NAME: + continue + ssid_bytes = s_wifi.get_ssid() + ssid = ( + ssid_bytes.get_data().decode("utf-8", "replace") if ssid_bytes else "" + ) + self._wifi_networks.append( + { "id": network_id, - "ssid": None, + "uuid": conn.get_uuid(), + "ssid": ssid, "psk": None, - "key_mgmt": None, + "key_mgmt": "WPA-PSK", } - - elif line == "}" and in_network_block: - in_network_block = False - wifi_networks.append(network_dict) - network_id += 1 - - elif in_network_block: - match = re.match(r"(\w+)=(.+)", line) - if match: - key, value = match.groups() - if key in network_dict: - network_dict[key] = value.strip('"') - - return wifi_networks + ) + network_id += 1 def get_wifi_networks(self): - return self._wifi_networks + """Return the saved networks, re-queried live from NetworkManager. - def delete_wifi_network(self, network_id): + The list is not cached: changes made outside this process (the AP/CLI + switch, another tool, a repair) are reflected on the next read. """ - Immediately deletes a wifi network - """ - self._wifi_networks.pop(network_id) - - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "r") as wpa_conf: - wpa_contents = list(wpa_conf) + self.populate_wifi_networks() + return self._wifi_networks - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "w") as wpa_conf: - in_networks = False - for line in wpa_contents: - if not in_networks: - if line.startswith("network={"): - in_networks = True - else: - wpa_conf.write(line) + def wifi_mode(self) -> str: + """Report the actual current mode from NetworkManager. - for network in self._wifi_networks: - ssid = network["ssid"] - key_mgmt = network["key_mgmt"] - psk = network["psk"] + AP fallback (or any out-of-band change) can flip the radio after init, + so detect live rather than trusting the value cached at construction — + otherwise the UI shows "Client" while the device is really broadcasting + the AP. Refreshing the cached field keeps local_ip()/set_wifi_mode() + consistent too. + """ + self._wifi_mode = self._detect_wifi_mode() + return self._wifi_mode - wpa_conf.write("\nnetwork={\n") - wpa_conf.write(f'\tssid="{ssid}"\n') - if key_mgmt == "WPA-PSK": - wpa_conf.write(f'\tpsk="{psk}"\n') - wpa_conf.write(f"\tkey_mgmt={key_mgmt}\n") + def is_wired_connected(self) -> bool: + """True if an ethernet device has an active connection.""" + try: + for dev in self._client.get_devices(): + if ( + dev.get_device_type() == NM.DeviceType.ETHERNET + and dev.get_active_connection() is not None + ): + return True + except Exception: + return False + return False - wpa_conf.write("}\n") + def delete_wifi_network(self, network_id): + """Delete a saved WiFi connection by its NetworkManager UUID. + Matching on the UUID (not the connection id or SSID) is what makes this + robust: a connection's id need not equal its SSID, and corrupt entries + store unrelated text in the SSID field, so an id/SSID match silently + fails to delete them. + """ + if network_id < 0 or network_id >= len(self._wifi_networks): + logger.error("Invalid network_id: %d", network_id) + return + entry = self._wifi_networks[network_id] + conn = self._client.get_connection_by_uuid(entry["uuid"]) + if conn is None: + logger.error("Connection uuid %s not found", entry["uuid"]) + else: + try: + _nm_run_async(conn.delete_async, None) + except Exception as e: + logger.error("Failed to delete connection '%s': %s", entry["ssid"], e) self.populate_wifi_networks() def add_wifi_network(self, ssid, key_mgmt, psk=None): - """ - Add a wifi network - """ - with open("/etc/wpa_supplicant/wpa_supplicant.conf", "a") as wpa_conf: - wpa_conf.write("\nnetwork={\n") - wpa_conf.write(f'\tssid="{ssid}"\n') - if key_mgmt == "WPA-PSK": - wpa_conf.write(f'\tpsk="{psk}"\n') - wpa_conf.write(f"\tkey_mgmt={key_mgmt}\n") + """Add and connect to a WiFi network.""" + profile = NM.SimpleConnection.new() + + s_con = NM.SettingConnection.new() + s_con.set_property(NM.SETTING_CONNECTION_ID, ssid) + s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") + s_con.set_property(NM.SETTING_CONNECTION_AUTOCONNECT, True) + profile.add_setting(s_con) + + s_wifi = NM.SettingWireless.new() + s_wifi.set_property( + NM.SETTING_WIRELESS_SSID, + GLib.Bytes.new(ssid.encode("utf-8")), + ) + s_wifi.set_property(NM.SETTING_WIRELESS_MODE, "infrastructure") + profile.add_setting(s_wifi) + + if key_mgmt == "WPA-PSK" and psk: + s_wsec = NM.SettingWirelessSecurity.new() + s_wsec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk") + s_wsec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, psk) + profile.add_setting(s_wsec) + + s_ip4 = NM.SettingIP4Config.new() + s_ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto") + profile.add_setting(s_ip4) + + # Persist the connection first. Saving must not depend on being able to + # activate it right now: wlan0 is often unavailable at add time (in AP + # mode, or out of range of the new network), and add_and_activate would + # then fail and save nothing. + try: + conn = _nm_run_async(self._client.add_connection_async, profile, True, None) + except Exception as e: + logger.error("Failed to add WiFi network '%s': %s", ssid, e) + self.populate_wifi_networks() + return - wpa_conf.write("}\n") + # Best effort: bring it up now if the radio is available. + device = self._client.get_device_by_iface("wlan0") + if device is not None: + try: + _nm_run_async( + self._client.activate_connection_async, conn, device, None, None + ) + except Exception as e: + logger.warning("Saved '%s' but could not activate it now: %s", ssid, e) self.populate_wifi_networks() - if self._wifi_mode == "Client": - # Restart the supplicant - wpa_cli("reconfigure") - - def get_ap_name(self): - with open("/etc/hostapd/hostapd.conf", "r") as conf: - for line in conf: - if line.startswith("ssid="): - return line[5:-1] - return "UNKN" - - def set_ap_name(self, ap_name): + + def get_ap_name(self) -> str: + """Get the current AP SSID from the PiFinder-AP profile.""" + for conn in self._client.get_connections(): + if conn.get_id() == AP_CONNECTION_NAME: + s_wifi = conn.get_setting_wireless() + if s_wifi: + ssid_bytes = s_wifi.get_ssid() + if ssid_bytes: + return ssid_bytes.get_data().decode("utf-8") + return "PiFinderAP" + + def set_ap_name(self, ap_name: str) -> None: + """Change the AP SSID. + + Commit the new SSID to the PiFinder-AP profile and, when AP is the live + WiFi mode, re-activate the connection so the running access point + rebroadcasts under the new name without a reboot. (Clients must rejoin + anyway once the SSID changes.) + """ if ap_name == self.get_ap_name(): return - with open("/tmp/hostapd.conf", "w") as new_conf: - with open("/etc/hostapd/hostapd.conf", "r") as conf: - for line in conf: - if line.startswith("ssid="): - line = f"ssid={ap_name}\n" - new_conf.write(line) - sh.sudo("cp", "/tmp/hostapd.conf", "/etc/hostapd/hostapd.conf") - - def get_host_name(self): - return socket.gethostname() + conn = None + for c in self._client.get_connections(): + if c.get_id() == AP_CONNECTION_NAME: + conn = c + break + if conn is None: + logger.error("Connection '%s' not found", AP_CONNECTION_NAME) + return + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + return + s_wifi.set_property( + NM.SETTING_WIRELESS_SSID, + GLib.Bytes.new(ap_name.encode("utf-8")), + ) + try: + _nm_run_async(conn.commit_changes_async, True, None) + except Exception as e: + logger.error("Failed to update AP SSID: %s", e) + return + if self.wifi_mode() == "AP": + self._activate_connection(AP_CONNECTION_NAME) def get_connected_ssid(self) -> str: - """ - Returns the SSID of the connected wifi network or - None if not connected or in AP mode - """ + """Returns the SSID of the connected wifi network.""" if self.wifi_mode() == "AP": return "" - # get output from iwgetid - try: - iwgetid = sh.Command("iwgetid") - _t = iwgetid(_ok_code=(0, 255)).strip() - return _t.split(":")[-1].strip('"') - except sh.CommandNotFound: - return "ssid_not_found" + device = self._client.get_device_by_iface("wlan0") + if device is None: + return "" + ac = device.get_active_connection() + if ac is None: + return "" + conn = ac.get_connection() + if conn is None: + return "" + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + return "" + ssid_bytes = s_wifi.get_ssid() + if ssid_bytes is None: + return "" + return ssid_bytes.get_data().decode("utf-8") + + _HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$") - def set_host_name(self, hostname) -> None: + def set_host_name(self, hostname: str) -> None: + """Set kernel hostname and update avahi mDNS announcement. + + NixOS makes /etc/hostname read-only (nix store symlink), so we set + the kernel hostname directly and persist to a file that a boot + service reads on startup. + """ + hostname = hostname.strip() + if not self._HOSTNAME_RE.match(hostname): + logger.warning("Invalid hostname rejected: %r", hostname) + return if hostname == self.get_host_name(): return - _result = sh.sudo("hostnamectl", "set-hostname", hostname) - self._update_etc_hosts(hostname) + subprocess.run(["sudo", "hostname", hostname], check=False) + result = subprocess.run(["sudo", "avahi-set-host-name", hostname], check=False) + if result.returncode != 0: + logger.warning( + "avahi-set-host-name failed (rc=%d), restarting avahi-daemon", + result.returncode, + ) + subprocess.run( + ["sudo", "systemctl", "restart", "avahi-daemon.service"], + check=False, + ) + data_dir = Path(os.environ.get("PIFINDER_DATA", "/home/pifinder/PiFinder_data")) + (data_dir / "hostname").write_text(hostname) + + def _go_ap(self) -> None: + """Activate the AP connection and remember the choice across reboots.""" + self._persist_wifi_mode("AP") + self._activate_connection(AP_CONNECTION_NAME) + + def _go_client(self) -> None: + """Deactivate the AP connection (fall back to client).""" + self._persist_wifi_mode("Client") + self._deactivate_connection(AP_CONNECTION_NAME) @staticmethod - def _rewrite_hosts(contents: str, new_hostname: str) -> str: - """ - Rewrite the Debian-convention ``127.0.1.1`` line in /etc/hosts to point - at ``new_hostname``. Preserves indentation, the IP, and any trailing - aliases/comments. If no ``127.0.1.1`` line exists, appends one so that - ``sudo`` can still resolve the host. - """ - lines = contents.splitlines(keepends=True) - pattern = re.compile(r"^(\s*127\.0\.1\.1\s+)\S+(.*)$") - replaced = False - for i, line in enumerate(lines): - match = pattern.match(line) - if match: - eol = "\n" if line.endswith("\n") else "" - lines[i] = f"{match.group(1)}{new_hostname}{match.group(2)}{eol}" - replaced = True - break - if not replaced: - if lines and not lines[-1].endswith("\n"): - lines[-1] += "\n" - lines.append(f"127.0.1.1\t{new_hostname}\n") - return "".join(lines) + def _persist_wifi_mode(mode: str) -> None: + """Persist the desired WiFi mode for the boot-time fallback service. - def _update_etc_hosts(self, new_hostname: str) -> None: + The PiFinder-AP NetworkManager profile has a low autoconnect priority, + so a forced AP would otherwise be lost on reboot; the fallback service + reads this file to restore it. + """ + data_dir = Path(os.environ.get("PIFINDER_DATA", "/home/pifinder/PiFinder_data")) try: - with open("/etc/hosts", "r") as hosts_f: - contents = hosts_f.read() - except IOError as e: - logger.error(f"Error reading /etc/hosts: {e}") + (data_dir / "wifi_mode").write_text(mode) + except OSError as e: + logger.warning("Could not persist WiFi mode %r: %s", mode, e) + + def _activate_connection(self, name: str) -> None: + """Activate a saved connection by name.""" + conn = None + for c in self._client.get_connections(): + if c.get_id() == name: + conn = c + break + if conn is None: + logger.error("Connection '%s' not found", name) return - new_contents = Network._rewrite_hosts(contents, new_hostname) - with open("/tmp/hosts", "w") as new_hosts: - new_hosts.write(new_contents) - sh.sudo("cp", "/tmp/hosts", "/etc/hosts") + device = self._client.get_device_by_iface("wlan0") + try: + _nm_run_async( + self._client.activate_connection_async, + conn, + device, + None, + None, + ) + except Exception as e: + logger.error("Failed to activate '%s': %s", name, e) + + def _deactivate_connection(self, name: str) -> None: + """Deactivate an active connection by name.""" + for ac in self._client.get_active_connections(): + if ac.get_id() == name: + try: + _nm_run_async( + self._client.deactivate_connection_async, + ac, + None, + ) + except Exception as e: + logger.error("Failed to deactivate '%s': %s", name, e) + return + logger.warning("No active connection named '%s' to deactivate", name) - def wifi_mode(self): - return self._wifi_mode - def set_wifi_mode(self, mode): - if mode == self._wifi_mode: - return - if mode == "AP": - go_wifi_ap() +# --------------------------------------------------------------------------- +# Module-level WiFi switching (called by callbacks.py and status.py) +# --------------------------------------------------------------------------- - if mode == "Client": - go_wifi_cli() +_network_instance: Optional[Network] = None - def local_ip(self): - if self._wifi_mode == "AP": - return "10.10.10.1" - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - s.connect(("192.255.255.255", 1)) - ip = s.getsockname()[0] - except Exception: - ip = "NONE" - finally: - s.close() - return ip +def _get_network() -> Network: + global _network_instance + if _network_instance is None: + _network_instance = Network() + return _network_instance def go_wifi_ap(): logger.info("SYS: Switching to AP") - sh.sudo("/home/pifinder/PiFinder/switch-ap.sh") + net = _get_network() + net.set_wifi_mode("AP") return True def go_wifi_cli(): logger.info("SYS: Switching to Client") - sh.sudo("/home/pifinder/PiFinder/switch-cli.sh") + net = _get_network() + net.set_wifi_mode("Client") return True -def remove_backup(): - """ - Removes backup file - """ - sh.sudo("rm", BACKUP_PATH, _ok_code=(0, 1)) +def get_wifi_mode() -> str: + """The live WiFi mode ("AP" or "Client") from NetworkManager.""" + return _get_network().wifi_mode() -def backup_userdata(): - """ - Back up userdata to a single zip file for later - restore. Returns the path to the zip file. +# --------------------------------------------------------------------------- +# System control (systemctl subprocess + D-Bus for reboot/shutdown) +# --------------------------------------------------------------------------- - Backs up: - config.json - observations.db - obslist/* - """ - remove_backup() +def restart_system() -> None: + """Restart the system via D-Bus to login1.""" + logger.info("SYS: Initiating System Restart") + try: + bus = _get_system_bus() + login1 = bus.get_object( + "org.freedesktop.login1", + "/org/freedesktop/login1", + ) + manager = dbus.Interface(login1, "org.freedesktop.login1.Manager") + manager.Reboot(False) + except dbus.DBusException as e: + logger.error("D-Bus reboot failed, falling back to subprocess: %s", e) + _run(["sudo", "shutdown", "-r", "now"]) - _zip = sh.Command("zip") - _zip( - BACKUP_PATH, - "/home/pifinder/PiFinder_data/config.json", - "/home/pifinder/PiFinder_data/observations.db", - glob.glob("/home/pifinder/PiFinder_data/obslists/*"), - ) - return BACKUP_PATH +def shutdown() -> None: + """Shut down the system via D-Bus to login1.""" + logger.info("SYS: Initiating Shutdown") + try: + bus = _get_system_bus() + login1 = bus.get_object( + "org.freedesktop.login1", + "/org/freedesktop/login1", + ) + manager = dbus.Interface(login1, "org.freedesktop.login1.Manager") + manager.PowerOff(False) + except dbus.DBusException as e: + logger.error("D-Bus shutdown failed, falling back to subprocess: %s", e) + _run(["sudo", "shutdown", "now"]) -def restore_userdata(zip_path): - """ - Compliment to backup_userdata - restores userdata - OVERWRITES existing data! - """ - unzip("-d", "/", "-o", zip_path) +# --------------------------------------------------------------------------- +# Software updates — async upgrade via systemd service +# --------------------------------------------------------------------------- +UPGRADE_STATE_IDLE = "idle" +UPGRADE_STATE_RUNNING = "running" +UPGRADE_STATE_SUCCESS = "success" +UPGRADE_STATE_FAILED = "failed" -def restart_pifinder() -> None: - """ - Uses systemctl to restart the PiFinder - service - """ - logger.info("SYS: Restarting PiFinder") - sh.sudo("systemctl", "restart", "pifinder") +UPGRADE_REF_FILE = Path("/run/pifinder/upgrade-ref") +UPGRADE_SELECTION_FILE = Path("/run/pifinder/upgrade-selection.json") +UPGRADE_STATUS_FILE = Path("/run/pifinder/upgrade-status") -def restart_system() -> None: - """ - Restarts the system - """ - logger.info("SYS: Initiating System Restart") - sh.sudo("shutdown", "-r", "now") +def _upgrade_service_state() -> str: + result = subprocess.run( + ["systemctl", "is-active", "pifinder-upgrade.service"], + capture_output=True, + text=True, + ) + return result.stdout.strip() -def shutdown() -> None: - """ - shuts down the system - """ - logger.info("SYS: Initiating Shutdown") - sh.sudo("shutdown", "now") - +def start_upgrade(ref: str = "release", selection: Optional[dict] = None) -> bool: + """Start pifinder-upgrade.service with a specific git ref.""" + try: + UPGRADE_REF_FILE.write_text(ref) + if selection: + UPGRADE_SELECTION_FILE.write_text(json.dumps(selection, sort_keys=True)) + else: + UPGRADE_SELECTION_FILE.unlink(missing_ok=True) + except OSError as e: + logger.error("Failed to write upgrade ref file: %s", e) + return False -def update_software(): - """ - Uses systemctl to git pull and then restart - service - """ - logger.info("SYS: Running update") - sh.bash("/home/pifinder/PiFinder/pifinder_update.sh") + # Clean stale status from previous run + UPGRADE_STATUS_FILE.unlink(missing_ok=True) + + # reset-failed errors on a unit that isn't loaded, so only clear an + # actual failed state + if _upgrade_service_state() == "failed": + _run(["sudo", "systemctl", "reset-failed", "pifinder-upgrade.service"]) + result = _run( + [ + "sudo", + "systemctl", + "start", + "--no-block", + "pifinder-upgrade.service", + ] + ) + if result.returncode != 0: + UPGRADE_STATUS_FILE.write_text("failed") + return False return True -def verify_password(username, password): - """ - Checks the provided password against the provided user - password +def list_rollback_targets(profile_dir: Path = Path("/nix/var/nix/profiles")) -> list: + """On-disk system generations available for rollback (all but the current). + + Reads only immutable generation data — the profile symlinks and the + store-path names — so there is NO sidecar state file to evolve or corrupt, + and it works even when the updater is offline. Each entry mirrors a + Software-screen version entry so the same list UI can render it. """ - p = pam.pam() + try: + current = (profile_dir / "system").resolve() + except OSError: + return [] - return p.authenticate(username, password) + targets = [] + for link in profile_dir.glob("system-*-link"): + try: + generation = int(link.name.split("-")[1]) + store_path = link.resolve() + mtime = link.lstat().st_mtime + except (OSError, ValueError, IndexError): + continue + if store_path == current: + continue + marker = "nixos-system-pifinder-" + name = store_path.name + label = name.split(marker, 1)[-1] if marker in name else name + # Local time for display, via the tz-aware timez helper (DTZ) + date = timez.utc_from_timestamp(mtime).astimezone().strftime("%d %b %H:%M") + targets.append( + ( + generation, + { + "ref": str(store_path), + "label": label, + "version": label, + "notes": None, + "subtitle": f"gen {generation} · {date}", + "channel": "rollback", + }, + ) + ) + targets.sort(key=lambda t: t[0], reverse=True) + return [entry for _generation, entry in targets] -def change_password(username, current_password, new_password): - """ - Changes the PiFinder User password +def get_upgrade_state() -> str: + """Poll upgrade status file written by the upgrade service.""" + try: + status = UPGRADE_STATUS_FILE.read_text().strip() + except FileNotFoundError: + # Service hasn't written status yet — check if it's still starting + svc = _upgrade_service_state() + if svc in ("activating", "active"): + return UPGRADE_STATE_RUNNING + if svc == "failed": + return UPGRADE_STATE_FAILED + return UPGRADE_STATE_IDLE + + if status == "success": + return UPGRADE_STATE_SUCCESS + elif status in ("failed", "unavailable", "connfail"): + return UPGRADE_STATE_FAILED + elif status.startswith("downloading") or status in ( + "starting", + "activating", + "rebooting", + ): + return UPGRADE_STATE_RUNNING + return UPGRADE_STATE_IDLE + + +def get_upgrade_progress() -> dict: + """Return structured upgrade progress for UI display. + + Returns dict with keys: + phase: "starting" | "downloading" | "activating" | "rebooting" + | "success" | "failed" | "unavailable" | "connfail" | "" + done: int (downloaded so far, in `unit`) + total: int (total to download, in `unit`) + unit: "bytes" | "paths" + percent: int (0-100) + + The download status line is "downloading /" in bytes; + a trailing " paths" marks the fallback where byte sizes were not + available and the figures are path counts instead. """ - result = passwd( - username, - _in=f"{current_password}\n{new_password}\n{new_password}\n", - _ok_code=(0, 10), + empty = { + "phase": "", + "done": 0, + "total": 0, + "unit": "bytes", + "percent": 0, + "item": "", + } + try: + raw = UPGRADE_STATUS_FILE.read_text().strip() + except FileNotFoundError: + svc = _upgrade_service_state() + if svc in ("activating", "active"): + return {**empty, "phase": "starting"} + if svc == "failed": + return {**empty, "phase": "failed"} + return empty + + svc = _upgrade_service_state() + if raw in ("starting", "activating") or raw.startswith("downloading "): + if svc in ("failed", "inactive"): + return {**empty, "phase": "failed"} + + if raw.startswith("downloading "): + body = raw[len("downloading ") :].strip() + unit = "bytes" + if body.endswith(" paths"): + unit = "paths" + body = body[: -len(" paths")].strip() + # body is "/" optionally followed by " " + nums, _sep, item = body.partition(" ") + parts = nums.split("/") + try: + done, total = int(parts[0]), int(parts[1]) + pct = int(done * 100 / total) if total > 0 else 0 + pct = max(0, min(100, pct)) + return { + "phase": "downloading", + "done": done, + "total": total, + "unit": unit, + "percent": pct, + "item": item.strip(), + } + except (ValueError, IndexError): + return {**empty, "phase": "downloading"} + if raw == "starting": + return {**empty, "phase": "starting"} + if raw == "activating": + return {**empty, "phase": "activating", "percent": 100} + if raw == "rebooting": + return {**empty, "phase": "rebooting", "percent": 100} + if raw == "success": + return {**empty, "phase": "success", "percent": 100} + if raw == "unavailable": + return {**empty, "phase": "unavailable"} + if raw == "connfail": + return {**empty, "phase": "connfail"} + if raw == "failed": + return {**empty, "phase": "failed"} + return empty + + +def get_upgrade_log_tail(lines: int = 3) -> str: + """Last N lines from upgrade journal for UI display.""" + result = _run( + [ + "journalctl", + "-u", + "pifinder-upgrade.service", + "-n", + str(lines), + "--no-pager", + "-o", + "cat", + ] ) + return result.stdout.strip() if result.returncode == 0 else "" - if result.exit_code == 0: - return True - else: - return False +def update_software(ref: str = "release", selection: Optional[dict] = None) -> bool: + """Start the upgrade service (non-blocking). -def switch_cam_imx477() -> None: - logger.info("SYS: Switching cam to imx477") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx477") + The service downloads, sets the boot profile, and reboots. + UI should poll get_upgrade_progress() for status. + """ + return start_upgrade(ref=ref, selection=selection) -def switch_cam_imx296() -> None: - logger.info("SYS: Switching cam to imx296") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx296") +# --------------------------------------------------------------------------- +# Password management (python-pam + chpasswd) +# --------------------------------------------------------------------------- -def switch_cam_imx462() -> None: - logger.info("SYS: Switching cam to imx462") - sh.sudo("python", "-m", "PiFinder.switch_camera", "imx462") +def verify_password(username: str, password: str) -> bool: + """Verify a password against PAM.""" + p = pam.pam() + return p.authenticate(username, password, service="pifinder") -def check_and_sync_gpsd_config(baud_rate: int) -> bool: - """ - Checks if GPSD configuration matches the desired baud rate, - and updates it only if necessary. +def change_password(username: str, current_password: str, new_password: str) -> bool: + """Change the user password via chpasswd.""" + if not verify_password(username, current_password): + return False + result = subprocess.run( + ["sudo", "chpasswd"], + input=f"{username}:{new_password}\n", + capture_output=True, + text=True, + ) + return result.returncode == 0 - Args: - baud_rate: The desired baud rate (9600 or 115200) - Returns: - True if configuration was updated, False if already correct - """ - logger.info(f"SYS: Checking GPSD config for baud rate {baud_rate}") +# --------------------------------------------------------------------------- +# Camera switching (specialisations + reboot) +# --------------------------------------------------------------------------- - try: - # Read current config - with open("/etc/default/gpsd", "r") as f: - content = f.read() - - # Determine expected GPSD_OPTIONS - if baud_rate == 115200: - # NOTE: the space before -s in the next line is really needed - expected_options = 'GPSD_OPTIONS=" -s 115200"' - else: - expected_options = 'GPSD_OPTIONS=""' - - # Check if update is needed - current_match = re.search(r"^GPSD_OPTIONS=.*$", content, re.MULTILINE) - if current_match: - current_options = current_match.group(0) - if current_options == expected_options: - logger.info("SYS: GPSD config already correct, no update needed") - return False - - # Update is needed - logger.info(f"SYS: GPSD config mismatch, updating to {expected_options}") - update_gpsd_config(baud_rate) - return True - - except Exception as e: - logger.error(f"SYS: Error checking/syncing GPSD config: {e}") - return False +CAMERA_TYPE_FILE = "/var/lib/pifinder/camera-type" -def update_gpsd_config(baud_rate: int) -> None: +def switch_camera(cam_type: str) -> None: """ - Updates the GPSD configuration file with the specified baud rate - and restarts the GPSD service. - - Args: - baud_rate: The baud rate to configure (9600 or 115200) + Switch camera via NixOS specialisation. + Requires reboot (dtoverlay change). """ - logger.info(f"SYS: Updating GPSD config with baud rate {baud_rate}") - - try: - # Read the current config - with open("/etc/default/gpsd", "r") as f: - lines = f.readlines() - - # Update GPSD_OPTIONS line - updated_lines = [] - for line in lines: - if line.startswith("GPSD_OPTIONS="): - if baud_rate == 115200: - # NOTE: the space before -s in the next line is really needed - updated_lines.append('GPSD_OPTIONS=" -s 115200"\n') - else: - updated_lines.append('GPSD_OPTIONS=""\n') - else: - updated_lines.append(line) - - # Write the updated config to a temporary file - with open("/tmp/gpsd.conf", "w") as f: - f.writelines(updated_lines) - - # Copy the temp file to the actual location with sudo - sh.sudo("cp", "/tmp/gpsd.conf", "/etc/default/gpsd") + logger.info("SYS: Switching camera to %s via specialisation", cam_type) + result = _run(["sudo", "pifinder-switch-camera", cam_type]) + if result.returncode != 0: + logger.error("SYS: Camera switch failed: %s", result.stderr) - # Restart GPSD service - sh.sudo("systemctl", "restart", "gpsd") - logger.info("SYS: GPSD configuration updated and service restarted") +def get_camera_type() -> list[str]: + try: + with open(CAMERA_TYPE_FILE) as f: + return [f.read().strip()] + except FileNotFoundError: + return ["imx462"] - except Exception as e: - logger.error(f"SYS: Error updating GPSD config: {e}") - raise +def switch_cam_imx477() -> None: + logger.info("SYS: Switching cam to imx477") + switch_camera("imx477") -# Raspberry Pi red power LED. It is a plain gpio-led (on/off only, not -# dimmable), so the brightness file is effectively a boolean. -PWR_LED_PATH = "/sys/class/leds/PWR" +def switch_cam_imx296() -> None: + logger.info("SYS: Switching cam to imx296") + switch_camera("imx296") -def set_power_led(on: bool) -> None: - """ - Turn the Raspberry Pi's red PWR LED on or off. - The LED is not dimmable, so this is strictly on/off. We set the kernel - trigger to "none" first, otherwise the firmware's "default-on" trigger - keeps re-asserting the LED, then write the brightness directly. Uses - passwordless sudo, like the other privileged helpers in this module. - """ - value = "1" if on else "0" - sh.sudo( - "sh", - "-c", - f"echo none > {PWR_LED_PATH}/trigger; " - f"echo {value} > {PWR_LED_PATH}/brightness", - ) - logger.info("SYS: Power LED %s", "on" if on else "off") +def switch_cam_imx462() -> None: + logger.info("SYS: Switching cam to imx462") + switch_camera("imx462") # --------------------------------------------------------------------------- -# NixOS migration +# GPSD config (declarative on NixOS — no-ops) # --------------------------------------------------------------------------- -MIGRATION_PROGRESS_FILE = "/tmp/nixos_migration_progress" -MIGRATION_SCRIPT = "/home/pifinder/PiFinder/python/scripts/nixos_migration.sh" - -def _fetch_migration_sha256(version_info: dict) -> str: - """Fetch SHA256 from sidecar URL, falling back to hardcoded value.""" - sha256_url = version_info.get("migration_sha256_url", "") - if sha256_url: - try: - resp = requests.get(sha256_url, timeout=15) - if resp.status_code == 200: - sha256 = resp.text.strip().split()[0] - logger.info(f"SYS: Fetched migration SHA256: {sha256[:16]}...") - return sha256 - logger.warning(f"SYS: SHA256 fetch returned {resp.status_code}") - except requests.exceptions.RequestException as e: - logger.warning(f"SYS: Failed to fetch SHA256: {e}") - - sha256 = version_info.get("migration_sha256", "") - if sha256: - logger.info("SYS: Using hardcoded migration SHA256") - return sha256 - - -def start_nixos_migration(version_info: dict) -> None: +def check_and_sync_gpsd_config(baud_rate: int) -> bool: """ - Start the NixOS migration process in the background. - - Raises ValueError if migration_url or a migration SHA256 cannot be - obtained — an in-place OS replacement must not run without checksum - verification. + On NixOS, GPSD config is managed declaratively via services.nix. + This is a no-op. """ - url = version_info.get("migration_url", "") - if not url: - raise ValueError("Missing migration_url") - sha256 = _fetch_migration_sha256(version_info) - if not sha256: - raise ValueError( - "No migration SHA256 available (neither migration_sha256_url nor " - "migration_sha256 produced a value); refusing to migrate without " - "checksum verification" - ) - display_class = str(version_info.get("display_class", "")) - display_resolution_value = version_info.get("display_resolution", "") - if isinstance(display_resolution_value, (list, tuple)): - display_resolution = "x".join(str(part) for part in display_resolution_value) - else: - display_resolution = str(display_resolution_value) - - logger.info(f"SYS: Starting NixOS migration to {version_info.get('version', '?')}") + logger.info("SYS: GPSD baud rate %d — managed by NixOS configuration", baud_rate) + return False - with open(MIGRATION_PROGRESS_FILE, "w") as f: - json.dump({"percent": 0, "status": "Starting..."}, f) - def _log_output(line): - logger.info(f"SYS: migration: {line.strip()}") +def update_gpsd_config(baud_rate: int) -> None: + """On NixOS, GPSD configuration is declarative. This is a no-op.""" + logger.info( + "SYS: GPSD config is managed declaratively on NixOS (baud=%d)", baud_rate + ) - def _log_error(line): - logger.error(f"SYS: migration: {line.strip()}") - def _on_done(cmd, success, exit_code): - if not success: - logger.error(f"SYS: Migration script failed with exit code {exit_code}") +# Raspberry Pi red power LED — a plain gpio-led (on/off only, not dimmable). +PWR_LED_PATH = Path("/sys/class/leds/PWR") - try: - sh.bash( - MIGRATION_SCRIPT, - url, - sha256, - MIGRATION_PROGRESS_FILE, - display_class, - display_resolution, - _bg=True, - _bg_exc=False, - _out=_log_output, - _err=_log_error, - _done=_on_done, - ) - except Exception as e: - logger.error(f"SYS: Migration failed to start: {e}") - raise +def set_power_led(on: bool) -> None: + """Turn the Raspberry Pi's red PWR LED on or off. -def get_migration_progress() -> Dict[str, Any]: - """ - Read current migration progress from the progress file. + The kernel trigger is set to "none" first, otherwise the firmware's + "default-on" trigger keeps re-asserting the LED. Direct sysfs writes — + pwm-permissions (services.nix) makes these files user-writable at boot, + so no sudo is needed. A missing LED (dev box, other SBC) raises OSError, + which the caller treats as non-fatal. """ - try: - with open(MIGRATION_PROGRESS_FILE, "r") as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return {} + (PWR_LED_PATH / "trigger").write_text("none") + (PWR_LED_PATH / "brightness").write_text("1" if on else "0") diff --git a/python/PiFinder/sys_utils_base.py b/python/PiFinder/sys_utils_base.py new file mode 100644 index 000000000..8dcb5ccfb --- /dev/null +++ b/python/PiFinder/sys_utils_base.py @@ -0,0 +1,181 @@ +""" +Abstract base for PiFinder system utilities. + +Defines the public API contract and shared implementations used by all +platform backends (Debian, NixOS, fake/testing). +""" + +import logging +import socket +import zipfile +from abc import ABC, abstractmethod +from pathlib import Path + +from PiFinder import utils + +BACKUP_PATH = str(utils.data_dir / "PiFinder_backup.zip") + +logger = logging.getLogger("SysUtils") + + +# --------------------------------------------------------------------------- +# Network ABC — shared + abstract methods +# --------------------------------------------------------------------------- + + +class NetworkBase(ABC): + """Base class for platform-specific Network implementations.""" + + _wifi_mode: str = "Client" + _wifi_networks: list = [] + + def get_host_name(self) -> str: + return socket.gethostname() + + def is_wired_connected(self) -> bool: + """True when a wired (ethernet) link is the active uplink. Overridden + on hardware; the base default assumes no wired link.""" + return False + + def local_ip(self) -> str: + # In AP mode the only address is the AP's own — unless an ethernet + # cable is plugged in, in which case the device is really reachable on + # the wired IP, so fall through to it. + if self._wifi_mode == "AP" and not self.is_wired_connected(): + return "10.10.10.1" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("192.255.255.255", 1)) + ip = s.getsockname()[0] + except Exception: + ip = "NONE" + finally: + s.close() + return ip + + def get_active_label(self) -> str: + """Label for the active uplink, for the status display: a wired link + wins (shown as 'Ethernet'), then the connected client SSID, then the + AP name; empty if nothing is up.""" + if self.is_wired_connected(): + return "Ethernet" + ssid = self.get_connected_ssid() + if ssid: + return ssid + if self.wifi_mode() == "AP": + return self.get_ap_name() + return "" + + def wifi_mode(self) -> str: + return self._wifi_mode + + def get_wifi_networks(self): + return self._wifi_networks + + def set_wifi_mode(self, mode: str) -> None: + if mode == self._wifi_mode: + return + if mode == "AP": + self._go_ap() + elif mode == "Client": + self._go_client() + self._wifi_mode = mode + + @abstractmethod + def _go_ap(self) -> None: ... + + @abstractmethod + def _go_client(self) -> None: ... + + @abstractmethod + def populate_wifi_networks(self) -> None: ... + + @abstractmethod + def delete_wifi_network(self, network_id) -> None: ... + + @abstractmethod + def add_wifi_network(self, ssid, key_mgmt, psk=None) -> None: ... + + @abstractmethod + def get_ap_name(self) -> str: ... + + @abstractmethod + def set_ap_name(self, ap_name: str) -> None: ... + + @abstractmethod + def get_connected_ssid(self) -> str: ... + + @abstractmethod + def set_host_name(self, hostname: str) -> None: ... + + +# --------------------------------------------------------------------------- +# Backup / restore (stdlib zipfile — portable across all platforms) +# --------------------------------------------------------------------------- + + +def remove_backup() -> None: + """Removes backup file.""" + path = Path(BACKUP_PATH) + if path.exists(): + path.unlink() + + +def backup_userdata() -> str: + """ + Back up userdata to a single zip file. + + Backs up: + config.json + observations.db + obslists/* + """ + remove_backup() + + files = [ + utils.data_dir / "config.json", + utils.data_dir / "observations.db", + ] + for p in utils.data_dir.glob("obslists/*"): + files.append(p) + + with zipfile.ZipFile(BACKUP_PATH, "w", zipfile.ZIP_DEFLATED) as zf: + for filepath in files: + filepath = Path(filepath) + if filepath.exists(): + zf.write(filepath, filepath.relative_to("/")) + + return BACKUP_PATH + + +def restore_userdata(zip_path: str) -> None: + """ + Restore userdata from a zip backup. + OVERWRITES existing data! + """ + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall("/") + + +# --------------------------------------------------------------------------- +# Service control (shared across Debian + NixOS) +# --------------------------------------------------------------------------- + + +def restart_pifinder() -> None: + """Restart the PiFinder service via systemctl.""" + import subprocess + + logger.info("SYS: Restarting PiFinder") + # Must be the full unit name: the NixOS sudoers rule allows exactly + # "systemctl restart pifinder.service", and sudo matches arguments + # verbatim — "restart pifinder" is refused and the restart silently + # never happens (the UI shows "Restarting..." but the stale process + # keeps running, e.g. with the old screen_direction IMU geometry). + result = subprocess.run( + ["sudo", "-n", "systemctl", "restart", "pifinder.service"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + logger.error("SYS: PiFinder restart failed: %s", result.stderr.strip()) diff --git a/python/PiFinder/sys_utils_fake.py b/python/PiFinder/sys_utils_fake.py index 77ca71504..9f6952e58 100644 --- a/python/PiFinder/sys_utils_fake.py +++ b/python/PiFinder/sys_utils_fake.py @@ -1,244 +1,91 @@ -import socket import logging -import os -import zipfile -import tempfile - -# For testing, use a directory structure that mimics the production setup -# but in a writable location. The server serves from /home/pifinder/PiFinder_data -# so we need to create a backup file that can be served from there. -# Since we can't write to /home/pifinder as a regular user, we'll use the current -# user's directory structure that mirrors the production layout. -_pifinder_data_dir = os.path.expanduser("~/PiFinder_data") -os.makedirs(_pifinder_data_dir, exist_ok=True) -BACKUP_PATH = os.path.join(_pifinder_data_dir, "PiFinder_backup.zip") + +from PiFinder.sys_utils_base import ( + NetworkBase, + BACKUP_PATH, +) logger = logging.getLogger("SysUtils.Fake") -class Network: +class Network(NetworkBase): """ - Provides wifi network info + Fake network for testing/development. """ def __init__(self): - pass + self._wifi_mode = "Client" + self._wifi_networks: list = [] - def populate_wifi_networks(self): - """ - Parses wpa_supplicant.conf to get current config - """ + def populate_wifi_networks(self) -> None: pass - def get_wifi_networks(self): - return "" - - def delete_wifi_network(self, network_id): - """ - Immediately deletes a wifi network - """ + def delete_wifi_network(self, network_id) -> None: pass - def add_wifi_network(self, ssid, key_mgmt, psk=None): - """ - Add a wifi network - """ + def add_wifi_network(self, ssid, key_mgmt, psk=None) -> None: pass - def get_ap_name(self): + def get_ap_name(self) -> str: return "UNKN" - def set_ap_name(self, ap_name): + def set_ap_name(self, ap_name: str) -> None: pass - def get_host_name(self): - return socket.gethostname() - - def get_connected_ssid(self): - """ - Returns the SSID of the connected wifi network or - None if not connected or in AP mode - """ - return "UNKN" - - def set_host_name(self, hostname): - if hostname == self.get_host_name(): - return - - def wifi_mode(self): + def get_connected_ssid(self) -> str: return "UNKN" - def set_wifi_mode(self, mode): + def set_host_name(self, hostname: str) -> None: pass - def local_ip(self): - return "NONE" + def _go_ap(self) -> None: + logger.info("SYS: Fake switching to AP") + def _go_client(self) -> None: + logger.info("SYS: Fake switching to Client") -def remove_backup(): - """ - Removes backup file - """ - try: - if os.path.exists(BACKUP_PATH): - os.remove(BACKUP_PATH) - except OSError: - pass +def remove_backup() -> None: + pass -def backup_userdata(): - """ - Back up userdata to a single zip file for later - restore. Returns the path to the zip file. - - Backs up: - config.json - observations.db - obslist/* - """ - remove_backup() - - # Use actual files from ~/PiFinder_data directory - source_dir = _pifinder_data_dir - - # Create zip file with actual user data - with zipfile.ZipFile(BACKUP_PATH, "w", zipfile.ZIP_DEFLATED) as zipf: - # Add config.json if it exists - config_path = os.path.join(source_dir, "config.json") - if os.path.exists(config_path): - zipf.write(config_path, "home/pifinder/PiFinder_data/config.json") - - # Add observations.db if it exists - db_path = os.path.join(source_dir, "observations.db") - if os.path.exists(db_path): - zipf.write(db_path, "home/pifinder/PiFinder_data/observations.db") - - # Add all files from obslists directory if it exists - obslists_dir = os.path.join(source_dir, "obslists") - if os.path.exists(obslists_dir): - for filename in os.listdir(obslists_dir): - file_path = os.path.join(obslists_dir, filename) - if os.path.isfile(file_path): - zipf.write( - file_path, f"home/pifinder/PiFinder_data/obslists/{filename}" - ) +def backup_userdata() -> str: return BACKUP_PATH -def restore_userdata(zip_path): - """ - Compliment to backup_userdata - "restores" userdata +def restore_userdata(zip_path) -> None: + pass - For the fake version, this compares the zip contents - with the current ~/PiFinder_data contents and throws - an exception if they don't match. - """ - import zipfile - import filecmp - - if not os.path.exists(zip_path): - raise FileNotFoundError(f"Backup file not found: {zip_path}") - - # Extract zip to temporary directory for comparison - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(zip_path, "r") as zipf: - # Extract all files - zipf.extractall(temp_dir) - - # Compare extracted files with actual files in ~/PiFinder_data - extracted_base = os.path.join(temp_dir, "home", "pifinder", "PiFinder_data") - actual_base = _pifinder_data_dir - - if not os.path.exists(extracted_base): - raise ValueError( - "Invalid backup file: missing expected directory structure" - ) - - # Check each file that should exist - files_to_check = ["config.json", "observations.db"] - - for filename in files_to_check: - extracted_file = os.path.join(extracted_base, filename) - actual_file = os.path.join(actual_base, filename) - - # If file exists in backup but not in actual directory - if os.path.exists(extracted_file) and not os.path.exists(actual_file): - raise ValueError( - f"Backup contains {filename} but it doesn't exist in {actual_base}" - ) - - # If file exists in both, compare contents - if os.path.exists(extracted_file) and os.path.exists(actual_file): - if not filecmp.cmp(extracted_file, actual_file, shallow=False): - raise ValueError( - f"Backup file {filename} differs from current version in {actual_base}" - ) - - # Check obslists directory - extracted_obslists = os.path.join(extracted_base, "obslists") - actual_obslists = os.path.join(actual_base, "obslists") - - if os.path.exists(extracted_obslists): - if not os.path.exists(actual_obslists): - raise ValueError( - "Backup contains obslists directory but it doesn't exist in current data" - ) - - # Compare each file in obslists - for filename in os.listdir(extracted_obslists): - extracted_obslist = os.path.join(extracted_obslists, filename) - actual_obslist = os.path.join(actual_obslists, filename) - - if os.path.isfile(extracted_obslist): - if not os.path.exists(actual_obslist): - raise ValueError( - f"Backup contains obslist {filename} but it doesn't exist in current obslists" - ) - - if not filecmp.cmp( - extracted_obslist, actual_obslist, shallow=False - ): - raise ValueError( - f"Backup obslist {filename} differs from current version" - ) - - # If we get here, all files match - logger.info("Restore validation successful: backup contents match current data") - return True - - -def shutdown(): - """ - shuts down the Pi - """ + +def shutdown() -> None: logger.info("SYS: Initiating Shutdown") - return True -def update_software(): - """ - Uses systemctl to git pull and then restart - service - """ - logger.info("SYS: Running update") +def update_software(ref: str = "release", selection=None): + logger.info("SYS: Running update (ref=%s)", ref) return True -def restart_pifinder(): - """ - Uses systemctl to restart the PiFinder - service - """ +def list_rollback_targets() -> list: + return [] + + +def get_upgrade_progress() -> dict: + return { + "phase": "", + "done": 0, + "total": 0, + "unit": "bytes", + "percent": 0, + "item": "", + } + + +def restart_pifinder() -> None: logger.info("SYS: Restarting PiFinder") - return True -def restart_system(): - """ - Restarts the system - """ +def restart_system() -> None: logger.info("SYS: Initiating System Restart") @@ -252,29 +99,41 @@ def go_wifi_cli(): return True +def get_wifi_mode() -> str: + return "Client" + + def verify_password(username, password): - """ - Checks the provided password against the provided user - password - """ return True def change_password(username, current_password, new_password): - """ - Changes the PiFinder User password - """ return False +def get_camera_type() -> list[str]: + return ["imx462"] + + def switch_cam_imx477() -> None: logger.info("SYS: Switching cam to imx477") - logger.info('sh.sudo("python", "-m", "PiFinder.switch_camera", "imx477")') def switch_cam_imx296() -> None: logger.info("SYS: Switching cam to imx296") - logger.info('sh.sudo("python", "-m", "PiFinder.switch_camera", "imx296")') + + +def switch_cam_imx462() -> None: + logger.info("SYS: Switching cam to imx462") + + +def check_and_sync_gpsd_config(baud_rate: int) -> bool: + logger.info("SYS: Checking GPSD config for baud rate %d (fake)", baud_rate) + return False + + +def update_gpsd_config(baud_rate: int) -> None: + logger.info("SYS: Updating GPSD config with baud rate %d (fake)", baud_rate) def set_power_led(on: bool) -> None: diff --git a/python/PiFinder/telemetry.py b/python/PiFinder/telemetry.py index f4625c5db..90970869e 100644 --- a/python/PiFinder/telemetry.py +++ b/python/PiFinder/telemetry.py @@ -11,10 +11,12 @@ import copy import json import logging +import os import queue import threading import time from collections import deque +from dataclasses import asdict from datetime import datetime, timedelta from pathlib import Path @@ -23,6 +25,14 @@ from PiFinder import calc_utils from PiFinder import utils from PiFinder import timez +from PiFinder.sqm.camera_profiles import get_camera_profile + +try: + from PiFinder.sqm import airglow +except ImportError: + # The airglow model is an optional part of the SQM stack; on a branch + # without it there are simply no airglow constants to snapshot. + airglow = None # type: ignore[assignment] from PiFinder.types.positioning import ( FailedSolve, ImuSample, @@ -41,12 +51,58 @@ # Stationary IMU downsampling: record every Nth sample when not moving _STATIONARY_DECIMATION = 10 +# Independently toggleable recording sections. "imu"/"sqm"/"solve"/"target" +# each gate one event class in the session file; "images" gates saving the +# solve-frame PNGs alongside solve events. Stored as a list of enabled names +# under the single ``telemetry_sections`` config option (a multi-select +# checklist in the menu); an absent/None value means all enabled. +# +# "images" ships OFF by default: one 512x512 PNG is written per solve, so at a +# typical ~1 solve/s it costs ~300 MB/hour against a few MB/hour for every +# other section combined. Turn it on deliberately, for short diagnostic runs. +SECTION_NAMES = ("imu", "sqm", "solve", "target", "images") +SECTION_CONFIG_OPTION = "telemetry_sections" + +# Session size cap (MB, 0 = unlimited). Frames are written by the camera +# process, so the recorder measures the session directory rather than counting +# its own writes. On reaching the cap the Images section is suspended — that is +# the only unbounded consumer; the event log keeps running at a few MB/hour so +# a capped session stays useful instead of going dark. +MAX_SESSION_MB_OPTION = "telemetry_max_session_mb" + def _rf(v): """Round a float for compact serialization.""" return round(v, _R) +def _rfn(v): + """Round a float, or pass through None (for optional fields).""" + return None if v is None else round(v, _R) + + +def _sqm_calibration_snapshot(camera_type): + """Every SQM/airglow constant for a camera, for the session header. + + Snapshots the full camera profile (zero points, FOV, pedestal, band + offsets, …) plus the airglow calibration so a recorded session stays + recomputable even if these numbers change in code later — the radio-event + ingredients are only reproducible against the constants that produced them. + Returns None for an unknown or unset camera. + """ + if not camera_type: + return None + try: + profile = get_camera_profile(camera_type) + except Exception: + return None + snapshot = {"profile": asdict(profile)} + cal = airglow.calibration(camera_type) if airglow is not None else None + if cal is not None: + snapshot["airglow"] = cal + return snapshot + + def _serialize_quat(q): """Serialize a quaternion to a list [w, x, y, z].""" if q is None: @@ -77,7 +133,15 @@ class TelemetryRecorder: def __init__(self): self.enabled = False - self.images_enabled = False + # Per-section record gates (including "images"); refreshed from config + # on start() and by apply_sections() so menu toggles reach an + # in-progress recording. + self.sections = {name: True for name in SECTION_NAMES} + # Session size cap; 0 disables it. _session_bytes is refreshed by the + # flush loop, so the cap is checked at most every 5 s. + self.max_session_bytes = 0 + self._session_bytes = 0 + self._cap_logged = False self._buffer = deque(maxlen=300) self._file = None self._flush_thread = None @@ -115,6 +179,7 @@ def start(self, cfg, shared_state): self._file = open(session_file, "a") self.enabled = True + self.apply_sections(cfg) self._last_flush = time.time() # Reset per-session state @@ -124,12 +189,31 @@ def start(self, cfg, shared_state): self._last_radio_time = 0.0 self._last_target_id = None self._dropped_events = 0 - - # Write header (no location — written to separate .location file) + try: + cap_mb = float(cfg.get_option(MAX_SESSION_MB_OPTION) or 0) + except (TypeError, ValueError): + cap_mb = 0 + self.max_session_bytes = int(max(cap_mb, 0) * 1024 * 1024) + self._session_bytes = 0 + self._cap_logged = False + + # Write header (no location — written to separate .location file). + # camera_type + sqm_calibration snapshot the SQM/airglow constants + # (zero point, FOV, pedestal, red response, …) so the logged radio + # ingredients can be recomputed under a future calculation. dt = shared_state.datetime() + camera_type = None + try: + candidate = shared_state.camera_type() + if isinstance(candidate, str): + camera_type = candidate + except Exception: + pass self._header_cfg = { "screen_direction": cfg.get_option("screen_direction"), "mount_type": cfg.get_option("mount_type"), + "camera_type": camera_type, + "sqm_calibration": _sqm_calibration_snapshot(camera_type), } header = { "t": time.time(), @@ -152,6 +236,19 @@ def start(self, cfg, shared_state): self._flush_thread.start() logger.info("Telemetry recording started: %s", session_file) + def apply_sections(self, cfg): + """Refresh the per-section record gates from config. + + Reads the ``telemetry_sections`` checklist (a list of enabled names); + an absent value means all enabled. Safe to call on a live recording: a + menu toggle takes effect on the next event of that class without + restarting the session. + """ + enabled = cfg.get_option(SECTION_CONFIG_OPTION) + if enabled is None: + enabled = SECTION_NAMES + self.sections = {name: name in enabled for name in SECTION_NAMES} + def _write_location_sidecar(self, location): """Write the .location sidecar. Returns True if written.""" if not location or self._session_dir is None: @@ -214,7 +311,7 @@ def record_imu(self, imu): When stationary, only records every _STATIONARY_DECIMATION-th sample to reduce file size during long sessions. """ - if not self.enabled or imu is None: + if not self.enabled or not self.sections["imu"] or imu is None: return if imu.timestamp == self._last_imu_timestamp: return # same sample re-polled by a faster loop @@ -238,17 +335,26 @@ def record_imu(self, imu): } self._append(record) - def record_radio(self, sample): + def record_radio(self, sample, sqm=None, floor=None): """Record one radiometer sample event (camera-side sky background). - Fields: t = capture epoch [s], exp = driver-reported exposure [s], - bg = background median [ADU], mad = median absolute deviation [ADU], - grad = quadrant gradient [ADU], seq = camera frame sequence. - Dedupes on sequence and rate-limits to ~1 Hz: the integrator loop - polls far faster than frames arrive at night, and daytime short - exposures produce many frames per second. + Ingredients (raw inputs — a future SQM/airglow algorithm can recompute + the sky brightness from these alone): exp = exposure [s], bg / red / + blue = green/red/blue Bayer background medians [ADU] (red/blue None on + mono sensors), ped = per-frame optical-black pedestal [ADU] (None if + the sensor exposes no shielded pixels), px = photometry image side + [px], mad = background MAD [ADU], grad = quadrant gradient [ADU]. + + Derived (audit only — what the device published at capture time, so the + floor and its warm-up are visible without a rerun; do NOT treat as + ground truth if the calculation later changes): sqm = sky brightness + [mag/arcsec²], floor = applied airglow floor [ADU/s]. + + seq = camera frame sequence. Dedupes on sequence and rate-limits to + ~1 Hz: the integrator loop polls far faster than frames arrive at + night, and daytime short exposures produce many frames per second. """ - if not self.enabled or not sample: + if not self.enabled or not self.sections["sqm"] or not sample: return seq = sample.get("sequence") t = sample.get("captured_at") @@ -267,6 +373,12 @@ def record_radio(self, sample): "bg": _rf(sample.get("background_per_pixel")), "mad": _rf(sample.get("background_mad")), "grad": _rf(sample.get("background_gradient")), + "red": _rfn(sample.get("background_red")), + "blue": _rfn(sample.get("background_blue")), + "ped": _rfn(sample.get("optical_black_pedestal")), + "px": sample.get("pixels_per_side"), + "sqm": _rfn(sqm), + "floor": _rfn(floor), } ) @@ -279,7 +391,7 @@ def record_solve(self, solve_result, predicted=None): Returns the timestamp used for the record, or None if not recorded. """ - if not self.enabled or solve_result is None: + if not self.enabled or not self.sections["solve"] or solve_result is None: return None t = time.time() success = isinstance(solve_result, SuccessfulSolve) @@ -308,7 +420,7 @@ def record_solve(self, solve_result, predicted=None): def record_target(self, target, alt=None, az=None): """Record a target change event. Pass None when target is cleared.""" - if not self.enabled: + if not self.enabled or not self.sections["target"]: return if target is None: target_id = None @@ -345,6 +457,49 @@ def get_session_dir(self): """Return current session directory path, or None.""" return self._session_dir + @property + def session_bytes(self) -> int: + """Bytes on disk for this session as of the last flush.""" + return self._session_bytes + + @property + def images_capped(self) -> bool: + """True once the session has grown past its size cap. + + Frame saving is suspended while this holds; the (small) event log + continues, so the cap bounds growth without ending the session. + """ + return bool(self.max_session_bytes) and ( + self._session_bytes >= self.max_session_bytes + ) + + def _refresh_session_bytes(self): + """Measure the session directory (event log plus any saved frames). + + Frames are written by the camera process, so the recorder cannot count + them as it writes; measuring the directory captures both. Called from + the flush path, i.e. at most every 5 seconds. + """ + if self._session_dir is None or not self.max_session_bytes: + return + total = 0 + try: + with os.scandir(self._session_dir) as entries: + for entry in entries: + if entry.is_file(): + total += entry.stat().st_size + except OSError: + return # a transient stat failure must not break recording + self._session_bytes = total + if self.images_capped and not self._cap_logged: + self._cap_logged = True + logger.warning( + "Telemetry session reached its %d MB cap (%d MB on disk); " + "suspending frame capture, event log continues", + self.max_session_bytes // (1024 * 1024), + total // (1024 * 1024), + ) + def flush(self): """Time-gated flush - only actually flushes every 5 seconds.""" if not self.enabled: @@ -385,6 +540,9 @@ def _flush_loop(self): while not self._stop_event.is_set(): self._stop_event.wait(5.0) self._do_flush() + # Runs unconditionally: frames can grow the session even in a + # stretch where no events were buffered to flush. + self._refresh_session_bytes() class TelemetryPlayer: @@ -566,8 +724,9 @@ def __init__(self, cfg, shared_state, console_queue, camera_command_queue=None): self._console_queue = console_queue self._camera_command_queue = camera_command_queue self._recorder = TelemetryRecorder() - self._recorder.images_enabled = bool(cfg.get_option("telemetry_images")) self._player = None + # One console notice per session when the size cap suspends frames. + self._cap_announced = False # Pre-replay state to restore when replay ends. self._saved_location = None self._datetime_overridden = False @@ -592,9 +751,7 @@ def poll_commands(self, command_queue): def _handle_command(self, cmd_name, cmd_arg): """Dispatch a telemetry command.""" if cmd_name == "telemetry_record_on": - self._recorder.images_enabled = bool( - self._cfg.get_option("telemetry_images") - ) + self._cap_announced = False self._recorder.start(self._cfg, self._shared_state) self._console_queue.put("Telemetry: Recording") @@ -602,6 +759,11 @@ def _handle_command(self, cmd_name, cmd_arg): self._recorder.stop() self._console_queue.put("Telemetry: Stopped") + elif cmd_name == "telemetry_update_sections": + # A section toggle in the menu; apply it to a live recording so the + # change takes effect without stopping and restarting the session. + self._recorder.apply_sections(self._cfg) + elif cmd_name == "replay": logger.info("Entering replay mode: %s", cmd_arg) try: @@ -652,19 +814,46 @@ def next_replay_message(self): return None def record_radio(self, sample): - """Record a radiometer sample event (no-op while replaying).""" + """Record a radiometer sample event (no-op while replaying). + + Pulls the last published SQM value and applied airglow floor from + shared state so each camera-side background frame is stamped with the + sky brightness the device was reporting at the time. + """ if self.replaying: return - self._recorder.record_radio(sample) + sqm_value = None + floor = None + try: + sqm_state = self._shared_state.sqm() + if sqm_state is not None: + sqm_value = getattr(sqm_state, "value", None) + details = self._shared_state.sqm_details() + if details: + floor = details.get("skyglow_floor") + except Exception: + # Shared-state access must never break recording. + pass + self._recorder.record_radio(sample, sqm=sqm_value, floor=floor) def record_solve(self, solve_result, predicted=None): - """Record a solve event and send save_image command if enabled.""" + """Record a solve event and save the frame if both sections are on. + + ``record_solve`` returns a timestamp only when the Solves section is + enabled, so image saving is naturally gated on solves as well as the + Images section. + """ if self.replaying: return t = self._recorder.record_solve(solve_result, predicted) + if t is not None and self._recorder.images_capped: + if not self._cap_announced: + self._cap_announced = True + self._console_queue.put("Telemetry: Size cap, frames off") + return if ( t is not None - and self._recorder.images_enabled + and self._recorder.sections.get("images") and self._camera_command_queue is not None ): session_dir = self._recorder.get_session_dir() 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 436ada913..800b30d59 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 @@ -104,6 +104,7 @@ class UIModule: __uuid__ = str(uuid.uuid1()).split("-")[0] _config_options: dict _CAM_ICON = "" + _CAM_ICON_HOLLOW = "" _IMU_ICON = "" _GPS_ICON = "󰤉" _LEFT_ARROW = "" @@ -133,7 +134,7 @@ class UIModule: def __init__( self, - display_class: Type[DisplayBase], + display_class: DisplayBase, camera_image, shared_state, command_queues, @@ -490,6 +491,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 @@ -501,9 +504,14 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: # self.draw.rectangle([115, 2, 125, 14], fill=bg) if self._unmoved: + is_test = self.config_object.get_option("test_mode", False) + icon_x = self.display_class.resX * 0.91 + # In test mode the camera feed is faked, so show the + # hollow (outline) camera icon as a subtle indicator + # rather than a bright inverted box. self.draw.text( - (self.display_class.resX * 0.91, icon_y), - self._CAM_ICON, + (icon_x, icon_y), + self._CAM_ICON_HOLLOW if is_test else self._CAM_ICON, font=self.fonts.icon_bold_large.font, fill=var_fg, ) diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index e34e6ec4d..57d908a6d 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -54,6 +54,16 @@ def show_advanced_message(ui_module: UIModule) -> None: return +def set_obj_chart_mark_fallback(ui_module: UIModule) -> None: + """Select the 'fallback' obj-chart mark source (entering its shape picker).""" + ui_module.config_object.set_option("obj_chart_mark_source", "fallback") + + +def set_obj_chart_mark_custom(ui_module: UIModule) -> None: + """Select the 'custom' obj-chart mark source (entering its shape picker).""" + ui_module.config_object.set_option("obj_chart_mark_source", "custom") + + def reset_filters(ui_module: UIModule) -> None: """ Reset all filters to default @@ -72,13 +82,18 @@ def reset_filters(ui_module: UIModule) -> None: def activate_debug(ui_module: UIModule) -> None: """ - Sets camera into debug - add fake gps info + Toggles test mode (fake camera image + fake GPS). + Main flips shared_state/config; the camera process follows + shared_state.test_mode() on its own. """ - ui_module.command_queues["camera"].put("debug") - ui_module.command_queues["console"].put("Test Mode Activated") ui_module.command_queues["ui_queue"].put("test_mode") - ui_module.message(_("Test Mode")) + + +def test_mode_suffix(ui_module: UIModule) -> str: + """Returns ON/OFF suffix for Test Mode menu entry.""" + if ui_module.config_object.get_option("test_mode", False): + return " ON" + return " OFF" def set_exposure(ui_module: UIModule) -> None: @@ -241,21 +256,7 @@ def set_camera_lens(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: @@ -267,9 +268,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: @@ -285,9 +283,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: @@ -525,8 +529,9 @@ def generate_custom_object_name(ui_module: UIModule) -> str: def telemetry_record_toggle(ui_module: UIModule) -> None: - """Toggle telemetry recording on/off via integrator command queue.""" - enabled = ui_module.config_object.get_option("telemetry_record") + """Flip telemetry recording on/off in place (inline menu toggle).""" + enabled = not ui_module.config_object.get_option("telemetry_record") + ui_module.config_object.set_option("telemetry_record", enabled) if "integrator" in ui_module.command_queues: if enabled: ui_module.command_queues["integrator"].put(("telemetry_record_on", None)) @@ -538,6 +543,21 @@ def telemetry_record_toggle(ui_module: UIModule) -> None: ui_module.message("No integrator\nqueue", 2) +def telemetry_record_suffix(ui_module: UIModule) -> str: + """Return ' On'/' Off' for the inline Record toggle's current state.""" + return " On" if ui_module.config_object.get_option("telemetry_record") else " Off" + + +def telemetry_section_toggle(ui_module: UIModule) -> None: + """Apply a telemetry section on/off change to any live recording. + + The section flag is already persisted to config by the menu; nudge the + integrator's recorder to re-read it so the change takes effect mid-session. + """ + if "integrator" in ui_module.command_queues: + ui_module.command_queues["integrator"].put(("telemetry_update_sections", None)) + + def update_gpsd_baud_rate(ui_module: UIModule) -> None: """ Updates the GPSD configuration with the current baud rate setting. diff --git a/python/PiFinder/ui/chart.py b/python/PiFinder/ui/chart.py index 1372fa8bb..5a420c3bc 100644 --- a/python/PiFinder/ui/chart.py +++ b/python/PiFinder/ui/chart.py @@ -12,6 +12,7 @@ import datetime import logging +import math import time from dataclasses import dataclass from PIL import ImageChops, Image @@ -27,6 +28,76 @@ logger = logging.getLogger("Chart") +# Smallest on-screen span (px) worth outlining. Below this the object's marker +# glyph carries the position and an outline would just be a blob. +_MIN_OUTLINE_PX = 4 + + +def _angular_sep_deg(ra1: float, dec1: float, ra2: float, dec2: float) -> float: + """Great-circle separation between two RA/Dec points, all in degrees.""" + d1 = math.radians(dec1) + d2 = math.radians(dec2) + dra = math.radians(ra2 - ra1) + cos_sep = math.sin(d1) * math.sin(d2) + math.cos(d1) * math.cos(d2) * math.cos(dra) + return math.degrees(math.acos(max(-1.0, min(1.0, cos_sep)))) + + +def size_perimeter_radec( + ra0: float, + dec0: float, + extents: list, + position_angle: float, + steps: int = 48, +) -> list: + """Build a closed RA/Dec perimeter for a numeric-extent size. + + ``extents`` are angular sizes in arcseconds (as stored by ``SizeObject``): + + * ``[d]`` -> circle of diameter ``d`` + * ``[major, minor]`` -> ellipse, ``position_angle`` measured N through E + * ``[r1, r2, ...]`` -> polygon of radial distances at equal angular steps + + Returns ``[[ra, dec], ...]`` in degrees, first point repeated at the end so + the caller can draw a closed outline. Empty near the poles where the RA + scaling blows up. + """ + if not extents: + return [] + cos_dec0 = math.cos(math.radians(dec0)) + if abs(cos_dec0) < 1e-6: + return [] + + pa = math.radians(position_angle) + sin_pa = math.sin(pa) + cos_pa = math.cos(pa) + + # Local tangent-plane offsets in arcsec: E(ast), N(orth). + offsets = [] + if len(extents) == 1: + r = extents[0] / 2.0 + for i in range(steps): + t = 2.0 * math.pi * i / steps + offsets.append((r * math.cos(t), r * math.sin(t))) + elif len(extents) == 2: + a = extents[0] / 2.0 + b = extents[1] / 2.0 + for i in range(steps): + t = 2.0 * math.pi * i / steps + u = a * math.cos(t) # along major axis + v = b * math.sin(t) # along minor axis + offsets.append((u * sin_pa + v * cos_pa, u * cos_pa - v * sin_pa)) + else: + step = 2.0 * math.pi / len(extents) + for i, ext in enumerate(extents): + phi = pa + i * step # position angle of this radial spoke, N through E + r = ext / 2.0 + offsets.append((r * math.sin(phi), r * math.cos(phi))) + + radec = [[ra0 + (e / 3600.0) / cos_dec0, dec0 + n / 3600.0] for e, n in offsets] + radec.append(radec[0]) + return radec + + # --- Nearby-DSO marker tuning ------------------------------------------------ # Starting values; tune on-device (see the chart-markers handoff). The radius # query fetches catalog objects within ``fov * NEARBY_RADIUS_FACTOR`` degrees of @@ -41,15 +112,7 @@ def dso_mag_limit(fov: float) -> float: - """ - Magnitude limit for nearby DSO markers as a function of chart FOV. - - Linear between the two hard-coded endpoints and clamped outside the - chart's zoom range: 5deg -> mag 11 (zoomed in, show dimmer objects), - 60deg -> mag 7 (zoomed out, only the brightest). Kept deliberately - separate from ``plot.Starfield.set_fov``'s *star* mag limit -- different - curve, different purpose (DSO markers vs Hipparcos stars). - """ + """Magnitude limit for nearby DSO markers as a function of chart FOV.""" fov_lo, mag_lo = _MAG_LIMIT_LO fov_hi, mag_hi = _MAG_LIMIT_HI if fov <= fov_lo: @@ -115,6 +178,7 @@ def plot_markers(self): return W, H = self.display_class.resolution + center = self._chart_center() # --- Target cross: always drawn, full brightness, chart_dso-independent target = self.ui_state.target() @@ -122,13 +186,14 @@ def plot_markers(self): if target is not None and target.ra is not None and target.dec is not None: exclude_ids.add(target.object_id) self._draw_target(target, W, H) + self._draw_object_outline(target, self.colors.get(255), center) marker_brightness = self.config_object.get_option("chart_dso", 128) if marker_brightness == 0: return # --- DSO layers (observing list + nearby), deduped against the target - marker_list, vertex_objects = self._collect_dso_markers(exclude_ids) + marker_list, outline_objects = self._collect_dso_markers(exclude_ids) if marker_list: marker_image = self.starfield.plot_markers( @@ -145,12 +210,61 @@ def plot_markers(self): ) self.screen.paste(ImageChops.add(self.screen, marker_image)) - if vertex_objects: - line_color = self.colors.get(marker_brightness) - for obj in vertex_objects: - screen_pts = self.starfield.project_vertices(obj.size.extents) - if len(screen_pts) >= 2: - self.draw.line(screen_pts, fill=line_color, width=1) + line_color = self.colors.get(marker_brightness) + for obj in outline_objects: + self._draw_object_outline(obj, line_color, center) + + def _chart_center(self): + """(RA, Dec) the chart is currently centred on, or ``None``.""" + if self.solution and self.solution.has_pointing(): + est = self.solution.pointing.aligned.estimate + return est.RA, est.Dec + return None + + def _draw_object_outline(self, obj, line_color, center): + """Outline an object's true angular extent on the chart. + + Draws polyline/segment shapes as stored, and renders numeric + circle/ellipse/polygon sizes from their major/minor axes and position + angle. Objects well outside the field, or too small to resolve into + more than a glyph, are skipped so the outline never degrades to a blob. + """ + size = getattr(obj, "size", None) + if not size or not size.extents: + return + + # Cheap RA/Dec cull before any projection: skip anything comfortably + # off the current field of view. + if center is not None: + if _angular_sep_deg(obj.ra, obj.dec, center[0], center[1]) > self.fov: + return + + if size.is_segments: + for seg in size.extents: + pts = self.starfield.project_vertices(seg) + if len(pts) >= 2: + self.draw.line(pts, fill=line_color, width=1) + return + + if size.is_vertices: + pts = self.starfield.project_vertices(size.extents) + if len(pts) >= 2: + self.draw.line(pts, fill=line_color, width=1) + return + + radec = size_perimeter_radec(obj.ra, obj.dec, size.extents, size.position_angle) + if not radec: + return + pts = self.starfield.project_vertices(radec) + if len(pts) < 2: + return + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + if (max(xs) - min(xs)) < _MIN_OUTLINE_PX and ( + max(ys) - min(ys) + ) < _MIN_OUTLINE_PX: + return + self.draw.line(pts, fill=line_color, width=1) def _draw_target(self, target, W, H): """ @@ -184,13 +298,13 @@ def _collect_dso_markers(self, exclude_ids): Build the marker list for the observing-list and nearby-catalog layers, deduped by ``object_id`` with precedence target -> observing-list -> nearby (``exclude_ids`` seeds the target). Returns - ``(marker_list, vertex_objects)`` where marker_list holds + ``(marker_list, outline_objects)`` where marker_list holds ``(ra_hours, dec_deg, symbol)`` tuples for ``Starfield.plot_markers`` - and vertex_objects holds asterism-polyline objects (observing list - only; nearby markers are symbols only). + and outline_objects holds sized observing-list objects (nearby markers + are symbols only). """ marker_list = [] - vertex_objects = [] + outline_objects = [] seen = set(exclude_ids) # Observing list: always on, uncapped, no mag limit. @@ -198,8 +312,8 @@ def _collect_dso_markers(self, exclude_ids): if obj.object_id in seen: continue seen.add(obj.object_id) - if obj.size.is_vertices: - vertex_objects.append(obj) + if obj.size and obj.size.extents: + outline_objects.append(obj) symbol = OBJ_TYPE_MARKERS.get(obj.obj_type) if symbol: marker_list.append((plot.Angle(degrees=obj.ra)._hours, obj.dec, symbol)) @@ -213,7 +327,7 @@ def _collect_dso_markers(self, exclude_ids): if symbol: marker_list.append((plot.Angle(degrees=obj.ra)._hours, obj.dec, symbol)) - return marker_list, vertex_objects + return marker_list, outline_objects def _get_nearby_markers(self): """ diff --git a/python/PiFinder/ui/console.py b/python/PiFinder/ui/console.py index db20086a4..3af561e5b 100644 --- a/python/PiFinder/ui/console.py +++ b/python/PiFinder/ui/console.py @@ -11,7 +11,6 @@ from PIL import Image from PiFinder.ui.base import GPS_ANIM_RATE, UIModule -from PiFinder import timez from PiFinder.ui.layout import rows_below_titlebar from PiFinder.image_util import convert_image_to_mode @@ -36,7 +35,7 @@ def __init__(self, *args, **kwargs): self.dirty = True self.welcome = True - # load welcome image to screen + # Load welcome image as startup backdrop root_dir = os.path.realpath( os.path.join(os.path.dirname(__file__), "..", "..", "..") ) @@ -53,21 +52,13 @@ def __init__(self, *args, **kwargs): self.lines = ["---- TOP ---", "Sess UUID:" + self.__uuid__] self.scroll_offset = 0 - self.debug_mode = False def set_shared_state(self, shared_state): self.shared_state = shared_state def key_number(self, number): if number == 0: - self.command_queues["camera"].put("debug") - if self.debug_mode: - self.debug_mode = False - else: - self.debug_mode = True - self.command_queues["console"].put("Debug: " + str(self.debug_mode)) - dt = timez.utc(2022, 11, 15, 2, 0, 0) - self.shared_state.set_datetime(dt) + self.command_queues["ui_queue"].put("test_mode") def key_enter(self): # reset scroll offset @@ -94,6 +85,13 @@ def write(self, line): self.scroll_offset = 0 self.dirty = True + def finish_startup(self): + """End the startup splash phase and clear the welcome backdrop.""" + self.welcome = False + self.clear_screen() + self.dirty = True + self.update() + def active(self): self.welcome = False self.dirty = True @@ -188,12 +186,28 @@ def screen_update(self, title_bar=True, button_hints=True): # self.draw.rectangle([115, 2, 125, 14], fill=bg) if self._unmoved: - self.draw.text( - (self.display_class.resX * 0.91, -2), - self._CAM_ICON, - font=self.fonts.icon_bold_large.font, - fill=var_fg, - ) + is_test = self.config_object.get_option("test_mode", False) + icon_x = self.display_class.resX * 0.91 + icon_y = -2 + if is_test: + # Invert camera icon: white bg, dark icon + self.draw.rectangle( + [icon_x - 1, 0, icon_x + 13, 13], + fill=self.colors.get(128), + ) + self.draw.text( + (icon_x, icon_y), + self._CAM_ICON, + font=self.fonts.icon_bold_large.font, + fill=self.colors.get(0), + ) + else: + self.draw.text( + (icon_x, icon_y), + self._CAM_ICON, + font=self.fonts.icon_bold_large.font, + fill=var_fg, + ) if len(self.title) < 9: # draw the constellation diff --git a/python/PiFinder/ui/lm_entry.py b/python/PiFinder/ui/lm_entry.py new file mode 100644 index 000000000..71dbb5344 --- /dev/null +++ b/python/PiFinder/ui/lm_entry.py @@ -0,0 +1,208 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +""" +Limiting Magnitude Entry UI + +Allows user to enter a fixed limiting magnitude value (e.g., 14.5) +with one decimal place precision. +""" + +from PIL import Image, ImageDraw +from PiFinder.ui.base import UIModule + + +class UILMEntry(UIModule): + """ + UI for entering limiting magnitude value + + Controls: + - 0-9: Enter digits + - Up/Down: Move cursor left/right between digits + - -: Delete digit (backspace) + - Right: Accept (save and return) + - Left: Cancel (discard and return) + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.config_option = self.item_definition.get( + "config_option", "obj_chart_lm_fixed" + ) + + # Start with placeholder/blank value for user to fill in + # Store as string for editing: format is " . " (spaces for digits) + self.digits = [" ", " ", ".", " "] # Two digits, decimal, one digit + + # Cursor position (0-3 for "XX.X" format) + # Position 2 is the decimal point (not editable) + self.cursor_pos = 0 + + self.width = 128 + self.height = 128 + self.screen = Image.new("RGB", (self.width, self.height), "black") + + def update(self, force=False): + """Render the LM entry screen""" + self.screen = Image.new("RGB", (self.width, self.height), "black") + draw = ImageDraw.Draw(self.screen) + + # Title + title = "Set Limiting Mag" + title_bbox = draw.textbbox((0, 0), title, font=self.fonts.base.font) + title_width = title_bbox[2] - title_bbox[0] + title_x = (self.width - title_width) // 2 + draw.text( + (title_x, 5), title, font=self.fonts.base.font, fill=self.colors.get(128) + ) + + # Display current value with cursor + value_y = (self.height - self.fonts.large.height) // 2 - 10 + + # Use fixed-width spacing for consistent alignment + char_width = self.fonts.large.width # Fixed character width + total_width = char_width * len(self.digits) + + # Center the entire value + start_x = (self.width - total_width) // 2 + + # Draw each character + for i, char in enumerate(self.digits): + x_pos = start_x + (i * char_width) + + # Display character or underscore for empty + display_char = char if char != " " else "_" + + # Highlight cursor position (but not the decimal point) + if i == self.cursor_pos and char != ".": + # Draw filled rectangle background + draw.rectangle( + [ + x_pos - 2, + value_y - 2, + x_pos + char_width + 2, + value_y + self.fonts.large.height + 2, + ], + fill=self.colors.get(255), + outline=self.colors.get(255), + width=2, + ) + # Draw text in inverse color + text_color = self.colors.get(0) + else: + text_color = self.colors.get(255) + + draw.text( + (x_pos, value_y), + display_char, + font=self.fonts.large.font, + fill=text_color, + ) + + # Icons (matching radec_entry style) + arrow_icons = "󰹺" + left_icon = "" + right_icon = "" + + # Legends at bottom (two lines) + bar_y = self.height - (self.fonts.base.height * 2) - 4 + + # Draw separator line + draw.line( + [(2, bar_y), (self.width - 2, bar_y)], fill=self.colors.get(128), width=1 + ) + + # Line 1: Navigation + line1 = f"{arrow_icons}Nav" + draw.text( + (2, bar_y + 2), line1, font=self.fonts.base.font, fill=self.colors.get(128) + ) + + # Line 2: Actions + line2 = f"{left_icon}Cancel {right_icon}Save -Del" + draw.text( + (2, bar_y + 12), line2, font=self.fonts.base.font, fill=self.colors.get(128) + ) + + return self.screen, None + + def key_up(self): + """Move cursor left""" + if self.cursor_pos > 0: + self.cursor_pos -= 1 + # Skip over decimal point + if self.cursor_pos == 2: + self.cursor_pos = 1 + return True + + def key_down(self): + """Move cursor right""" + if self.cursor_pos < 3: + self.cursor_pos += 1 + # Skip over decimal point + if self.cursor_pos == 2: + self.cursor_pos = 3 + return True + + def key_number(self, number): + """Enter digit 0-9 at cursor position""" + if 0 <= number <= 9: + # Don't allow editing the decimal point + if self.cursor_pos == 2: + return False + + # Replace digit at cursor position + self.digits[self.cursor_pos] = str(number) + + # Move cursor to next position after entering digit + if self.cursor_pos < 3: + self.cursor_pos += 1 + # Skip over decimal point + if self.cursor_pos == 2: + self.cursor_pos = 3 + + return True + return False + + def key_minus(self): + """Delete digit at cursor position (replace with space)""" + if self.cursor_pos == 2: + # Can't delete decimal point + return False + + # Replace with space (blank) + self.digits[self.cursor_pos] = " " + return True + + def key_left(self): + """Cancel - return without saving""" + return True + + def key_right(self): + """Accept - save value and exit""" + try: + value_str = "".join(self.digits).strip() + + if value_str.replace(".", "").replace(" ", "") == "": + return False + + value_str = value_str.replace(" ", "0") + final_value = float(value_str) + + if final_value < 5.0 or final_value > 20.0: + return False + + self.config_object.set_option(self.config_option, final_value) + self.config_object.set_option("obj_chart_lm_mode", "fixed") + + # Exit: LM entry -> LM menu -> back to chart + if self.remove_from_stack: + self.remove_from_stack() + self.remove_from_stack() + return True + except ValueError: + return False + + def active(self): + """Called when screen becomes active""" + return False diff --git a/python/PiFinder/ui/marking_menus.py b/python/PiFinder/ui/marking_menus.py index 19391cffc..93d9ad542 100644 --- a/python/PiFinder/ui/marking_menus.py +++ b/python/PiFinder/ui/marking_menus.py @@ -22,6 +22,7 @@ class MarkingMenuOption: selected: bool = False # shade bg? callback: Any = None menu_jump: Union[None, str] = None + value: Any = None def __str__(self): return self.label diff --git a/python/PiFinder/ui/menu_manager.py b/python/PiFinder/ui/menu_manager.py index f6b6c993d..9ecd79aee 100644 --- a/python/PiFinder/ui/menu_manager.py +++ b/python/PiFinder/ui/menu_manager.py @@ -126,7 +126,7 @@ def __init__( self._stack_anim_counter: float = 0 self._stack_anim_direction: int = 0 - self.stack: list[type[UIModule]] = [] + self.stack: list[UIModule] = [] self.add_to_stack(menu_structure.pifinder_menu) self.marking_menu_stack: list[MarkingMenu] = [] @@ -150,7 +150,7 @@ def __init__( def screengrab(self): self.ss_count += 1 - filename = f"{self.stack[-1].__uuid__}_{self.ss_count :0>3}_{self.stack[-1].title.replace('/','-')}" + filename = f"{self.stack[-1].__uuid__}_{self.ss_count:0>3}_{self.stack[-1].title.replace('/', '-')}" ss_imagepath = self.ss_path + f"/{filename}.png" ss = self.shared_state.screen().copy() ss.save(ss_imagepath) @@ -159,9 +159,9 @@ def screengrab(self): def remove_from_stack(self) -> None: if len(self.stack) > 1: self._stack_top_image = self.stack[-1].screen.copy() - self.stack[-1].inactive() # type: ignore[call-arg] + self.stack[-1].inactive() self.stack.pop() - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() self._stack_anim_counter = time.time() + self.config_object.get_option( "menu_anim_speed", 0 ) @@ -195,7 +195,7 @@ def add_to_stack(self, item: dict) -> None: item dict """ if item.get("state") is not None: - self.stack[-1].inactive() # type: ignore[call-arg] + self.stack[-1].inactive() self.stack.append(item["state"]) else: self.stack.append( @@ -215,7 +215,7 @@ def add_to_stack(self, item: dict) -> None: if item.get("stateful", False): item["state"] = self.stack[-1] - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() if len(self.stack) > 1: self._stack_anim_counter = time.time() + self.config_object.get_option( "menu_anim_speed", 0 @@ -223,7 +223,7 @@ def add_to_stack(self, item: dict) -> None: self._stack_anim_direction = -1 def message(self, message: str, timeout: float) -> None: - self.stack[-1].message(message, timeout) # type: ignore[arg-type] + self.stack[-1].message(message, timeout) def jump_to_label(self, label: str) -> None: # to prevent many recent/object UI modules @@ -235,7 +235,7 @@ def jump_to_label(self, label: str) -> None: for stack_index, ui_module in enumerate(self.stack): if ui_module.item_definition.get("label", "") == label: self.stack = self.stack[: stack_index + 1] - self.stack[-1].active() # type: ignore[call-arg] + self.stack[-1].active() return # either this is not a special case, or we didn't find # the label already in the stack @@ -290,7 +290,7 @@ def update(self) -> None: return # Business as usual, update the module at the top of the stack - self.stack[-1].update() # type: ignore[call-arg] + self.stack[-1].update() # are we animating? if self._stack_anim_counter > time.time(): diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index 8b35d7b47..e1aeabc63 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -20,6 +20,7 @@ from PiFinder.ui.locationentry import UILocationEntry from PiFinder.ui.radec_entry import UIRADecEntry from PiFinder.ui.telemetry_list import UITelemetryList +from PiFinder.ui.lm_entry import UILMEntry import PiFinder.ui.callbacks as callbacks @@ -36,6 +37,18 @@ def _(key: str) -> Any: s = s del s + +# Glyph-shape options for the obj-chart mark (sets obj_chart_crosshair_style). +# Copy per use site (dict(d)) so each submenu gets its own item objects. +_OBJ_CHART_SHAPE_ITEMS = [ + {"name": _("Simple"), "value": "simple"}, + {"name": _("Circle"), "value": "circle"}, + {"name": _("Bullseye"), "value": "bullseye"}, + {"name": _("Brackets"), "value": "brackets"}, + {"name": _("Dots"), "value": "dots"}, + {"name": _("Cross"), "value": "cross"}, +] + pifinder_menu = { "name": "PiFinder", "class": UITextMenu, @@ -102,6 +115,12 @@ def _(key: str) -> Any: "objects": "catalog", "value": "CM", }, + { + "name": _("Asteroids"), + "class": UIObjectList, + "objects": "catalog", + "value": "MP", + }, { "name": _("NGC"), "class": UIObjectList, @@ -307,6 +326,10 @@ def _(key: str) -> Any: "name": _("Comets"), "value": "CM", }, + { + "name": _("Asteroids"), + "value": "MP", + }, { "name": _("NGC"), "value": "NGC", @@ -913,6 +936,150 @@ def _(key: str) -> Any: }, ], }, + { + "name": _("Obj Chart..."), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_settings", + "items": [ + { + "name": _("Mark"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_mark", + "config_option": "obj_chart_mark_source", + "items": [ + { + "name": _("Standard"), + "value": "standard", + }, + { + "name": _("Fallback"), + "value": "fallback", + "class": UITextMenu, + "select": "single", + "label": "obj_chart_mark_fallback", + "config_option": "obj_chart_crosshair_style", + "pre_callback": callbacks.set_obj_chart_mark_fallback, + "items": [dict(d) for d in _OBJ_CHART_SHAPE_ITEMS], + }, + { + "name": _("Custom"), + "value": "custom", + "class": UITextMenu, + "select": "single", + "label": "obj_chart_mark_custom", + "config_option": "obj_chart_crosshair_style", + "pre_callback": callbacks.set_obj_chart_mark_custom, + "items": [dict(d) for d in _OBJ_CHART_SHAPE_ITEMS], + }, + ], + }, + { + "name": _("Anim"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_crosshair", + "config_option": "obj_chart_crosshair", + "items": [ + { + "name": _("Off"), + "value": "off", + }, + { + "name": _("On"), + "value": "on", + }, + { + "name": _("Pulse"), + "value": "pulse", + }, + { + "name": _("Fade"), + "value": "fade", + }, + ], + }, + { + "name": _("Style"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_style", + "config_option": "obj_chart_crosshair_style", + "items": [ + { + "name": _("Simple"), + "value": "simple", + }, + { + "name": _("Circle"), + "value": "circle", + }, + { + "name": _("Bullseye"), + "value": "bullseye", + }, + { + "name": _("Brackets"), + "value": "brackets", + }, + { + "name": _("Dots"), + "value": "dots", + }, + { + "name": _("Cross"), + "value": "cross", + }, + ], + }, + { + "name": _("Speed"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_speed", + "config_option": "obj_chart_crosshair_speed", + "items": [ + { + "name": _("Fast (1s)"), + "value": "1.0", + }, + { + "name": _("Medium (2s)"), + "value": "2.0", + }, + { + "name": _("Slow (3s)"), + "value": "3.0", + }, + { + "name": _("Very Slow (4s)"), + "value": "4.0", + }, + ], + }, + { + "name": _("Set LM"), + "class": UITextMenu, + "select": "single", + "label": "obj_chart_lm", + "config_option": "obj_chart_lm_mode", + "items": [ + { + "name": _("Auto"), + "value": "auto", + }, + { + "name": _("Fixed"), + "value": "fixed", + "class": UILMEntry, + "mode": "lm_entry", + "config_option": "obj_chart_lm_fixed", + }, + ], + }, + ], + }, { "name": _("Camera Exp"), "class": UITextMenu, @@ -1226,7 +1393,11 @@ def _(key: str) -> Any: }, {"name": _("Console"), "class": UIConsole}, {"name": _("Software Upd"), "class": UISoftware}, - {"name": _("Test Mode"), "callback": callbacks.activate_debug}, + { + "name": _("Test Mode"), + "callback": callbacks.activate_debug, + "name_suffix_callback": callbacks.test_mode_suffix, + }, { "name": _("Experimental"), "class": UITextMenu, @@ -1242,6 +1413,29 @@ def _(key: str) -> Any: "class": UITextMenu, "select": "single", "items": [ + { + "name": _("Dev Mode"), + "class": UITextMenu, + "select": "single", + "config_option": "dev_mode", + "items": [ + {"name": _("Off"), "value": False}, + {"name": _("On"), "value": True}, + ], + }, + { + "name": _("Screen Off"), + "class": UITextMenu, + "select": "single", + "config_option": "screen_off_timeout", + "items": [ + {"name": _("Off"), "value": "Off"}, + {"name": "30s", "value": "30s"}, + {"name": "1m", "value": "1m"}, + {"name": "10m", "value": "10m"}, + {"name": "30m", "value": "30m"}, + ], + }, { "name": _("Telemetry"), "class": UITextMenu, @@ -1249,35 +1443,40 @@ def _(key: str) -> Any: "items": [ { "name": _("Record"), + "callback": callbacks.telemetry_record_toggle, + "name_suffix_callback": callbacks.telemetry_record_suffix, + }, + { + "name": _("Sections"), "class": UITextMenu, - "select": "single", - "config_option": "telemetry_record", - "post_callback": callbacks.telemetry_record_toggle, + "select": "multi", + "config_option": "telemetry_sections", + "post_callback": callbacks.telemetry_section_toggle, "items": [ + {"name": _("IMU"), "value": "imu"}, + {"name": _("SQM"), "value": "sqm"}, + {"name": _("Solves"), "value": "solve"}, { - "name": _("Off"), - "value": False, + "name": _("Targets"), + "value": "target", }, { - "name": _("On"), - "value": True, + "name": _("Images"), + "value": "images", }, ], }, { - "name": _("Images"), + "name": _("Max Size"), "class": UITextMenu, "select": "single", - "config_option": "telemetry_images", + "config_option": "telemetry_max_session_mb", "items": [ - { - "name": _("Off"), - "value": False, - }, - { - "name": _("On"), - "value": True, - }, + {"name": _("250 MB"), "value": 250}, + {"name": _("500 MB"), "value": 500}, + {"name": _("1 GB"), "value": 1024}, + {"name": _("2 GB"), "value": 2048}, + {"name": _("Unlimited"), "value": 0}, ], }, { diff --git a/python/PiFinder/ui/object_details.py b/python/PiFinder/ui/object_details.py index e81a71b35..5b3768948 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -8,7 +8,14 @@ from pydeepskylog.exceptions import InvalidParameterError -from PiFinder import cat_images +from PiFinder.object_images import get_display_image +from PiFinder.object_images.image_base import ImageType +from PiFinder.object_images.image_utils import ( + extent_perimeter_polylines, + eyepiece_image_rotation, + project_radec_to_chart, +) +from PiFinder.object_images.star_catalog import CatalogState from PiFinder.composite_object import MagnitudeObject from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder.obj_types import OBJ_TYPES @@ -29,10 +36,13 @@ from PiFinder.db.observations_db import ObservationsDatabase from PiFinder.db.objects_db import ObjectsDatabase +from PIL import Image +import logging import numpy as np import time import pydeepskylog as pds +logger = logging.getLogger("PiFinder.UIObjectDetails") # Read-only handle to the catalog DB, opened once and shared across detail # views. Used by _other_catalog_descriptions() to pull an object's listings in @@ -52,11 +62,71 @@ def _catalog_db() -> ObjectsDatabase: # Constants for display modes DM_DESC = 0 # Display mode for description DM_LOCATE = 1 # Display mode for LOCATE -DM_POSS = 2 # Display mode for POSS +DM_IMAGE = 2 # Display mode for images (POSS or Gaia chart) +DM_POSS = 2 # Display mode for POSS (alias of DM_IMAGE) DM_SDSS = 3 # Display mode for SDSS DM_CONTRAST = 4 # Display mode for Contrast Reserve explanation +class EyepieceInput: + """ + Handles custom eyepiece focal length input (1-99mm) + """ + + def __init__(self): + self.focal_length_mm = 0 + self.digits = [] + self.last_input_time = 0 + + def append_digit(self, digit: int) -> bool: + """ + Append a digit to the input. + Returns True if input is complete (2 digits or auto-timeout) + """ + import time + + self.digits.append(digit) + self.last_input_time = time.time() + + # Update focal length + if len(self.digits) == 1: + self.focal_length_mm = digit + else: + self.focal_length_mm = self.digits[0] * 10 + self.digits[1] + + # Auto-complete after 2 digits + return len(self.digits) >= 2 + + def is_complete(self) -> bool: + """Check if input has timed out (1.5 seconds)""" + import time + + if len(self.digits) == 0: + return False + if len(self.digits) >= 2: + return True + return time.time() - self.last_input_time > 1.5 + + def reset(self): + """Clear the input""" + self.digits = [] + self.focal_length_mm = 0 + self.last_input_time = 0 + + def has_input(self) -> bool: + """Check if any digits have been entered""" + return len(self.digits) > 0 + + def __str__(self): + """Return display string for popup""" + if len(self.digits) == 0: + return "__" + elif len(self.digits) == 1: + return f"{self.digits[0]}_" + else: + return f"{self.digits[0]}{self.digits[1]}" + + class UIObjectDetails(UIModule): """ Shows details about an object @@ -72,13 +142,27 @@ def __init__(self, *args, **kwargs): self.contrast = None self.screen_direction = self.config_object.get_option("screen_direction") self.mount_type = self.config_object.get_option("mount_type") + self._chart_gen = None # Cached chart generator instance self.object = self.item_definition["object"] self.object_list = self.item_definition["object_list"] self.object_display_mode = DM_LOCATE self.object_image = None - - # Marking Menu - Just default help for now - self.marking_menu = MarkingMenu( + self._chart_generator = None # Active generator for progressive chart updates + self._is_showing_loading_chart = ( + False # Track if showing "Loading..." for Gaia chart + ) + self._force_gaia_chart = ( + False # Toggle: force Gaia chart even if POSS image exists + ) + # Geometry (center, fov, rotation, flip/flop) the current Gaia chart was + # rendered with; used to align the per-frame extent-mark overlay. + self._chart_geom = None + self.eyepiece_input = EyepieceInput() # Custom eyepiece input handler + self.eyepiece_input_display = False # Show eyepiece input popup + self._custom_eyepiece = None # Reference to custom eyepiece object in equipment list (None = not active) + + # Default Marking Menu + self._default_marking_menu = MarkingMenu( left=MarkingMenuOption(), right=MarkingMenuOption(), down=MarkingMenuOption( @@ -92,6 +176,14 @@ def __init__(self, *args, **kwargs): ), ) + # Gaia Chart Marking Menu - Settings access + self._gaia_chart_marking_menu = MarkingMenu( + up=MarkingMenuOption(label=_("SETTINGS"), menu_jump="obj_chart_settings"), + right=MarkingMenuOption(label=_("ANIM"), menu_jump="obj_chart_crosshair"), + down=MarkingMenuOption(label=_("MARK"), menu_jump="obj_chart_mark"), + left=MarkingMenuOption(label=_("LM"), menu_jump="obj_chart_lm"), + ) + # Used for displaying observation counts self.observations_db = ObservationsDatabase() @@ -134,6 +226,15 @@ def __init__(self, *args, **kwargs): self.active() # fill in activation time self.update_object_info() + @property + def marking_menu(self): + """ + Return appropriate marking menu based on current view mode + """ + if self._is_gaia_chart: + return self._gaia_chart_marking_menu + return self._default_marking_menu + def _layout_designator(self): """ Generates designator layout object @@ -200,13 +301,16 @@ def update_object_info(self): """ Generates object text and loads object images """ + # Clear this before generator consumption to prevent recursive update() + # calls while a chart image is being loaded. + self._is_showing_loading_chart = False + # Mirror the just-selected object into UIState as the chart/telemetry # "target" (see docs/ax/ui/CONTEXT.md). This is the single chokepoint # where the displayed object is (re)set -- open, scroll, eyepiece cycle # and display-mode cycle all route through here -- so the chart's target # cross and the telemetry poller track the last-viewed object. self.ui_state.set_target(self.object) - # Title... self.title = self.object.display_name @@ -376,26 +480,238 @@ def update_object_info(self): if solution and solution.has_pointing(): roll = solution.pointing.aligned.estimate.Roll + # Calculate magnification and TFOV using current active eyepiece (custom or configured) magnification = self.config_object.equipment.calc_magnification() + tfov = self.config_object.equipment.calc_tfov() + eyepiece_text = str(self.config_object.equipment.active_eyepiece) flip_image, flop_image = ( self.config_object.equipment.active_telescope_image_orientation() ) - self.object_image = cat_images.get_display_image( - self.object, - str(self.config_object.equipment.active_eyepiece), - self.config_object.equipment.calc_tfov(), - roll, - self.display_class, - burn_in=self.object_display_mode in [DM_POSS, DM_SDSS], - magnification=magnification, - show_nsew=self.config_object.get_option("image_nsew", True), - show_bbox=self.config_object.get_option("image_bbox", True), - flip_image=flip_image, - flop_image=flop_image, + + # Capture the exact geometry the Gaia chart is rendered with, so the + # per-frame extent overlay projects onto the same pixels as the baked + # stars (which don't re-rotate as the live solution drifts). Mirrors + # GaiaChartGenerator uses the fixed parity-preserving 180° baseline. + image_rotate = eyepiece_image_rotation(roll) + self._chart_geom = { + "center_ra": self.object.ra, + "center_dec": self.object.dec, + "fov": tfov, + "image_rotate": image_rotate, + "flip": flip_image, + "flop": flop_image, + } + + if self._custom_eyepiece is not None: + logger.info( + f">>> Using custom eyepiece: {eyepiece_text}, tfov={tfov}, mag={magnification}" + ) + else: + logger.info( + f">>> Using configured eyepiece: {eyepiece_text}, tfov={tfov}, mag={magnification}" + ) + + # Only regenerate the display image when in image mode. + # DM_DESC/DM_LOCATE only need text info, not the image. + # Regenerating in non-image modes creates a generator that sets + # object_image=None, causing a black screen until consumed. + if self.object_display_mode == DM_IMAGE: + # Get or create chart generator (owned by UI layer) + logger.info(">>> Getting chart generator...") + chart_gen = self._get_gaia_chart_generator() + logger.info( + f">>> Chart generator obtained, state: {chart_gen.get_catalog_state() if chart_gen else 'None'}" + ) + + logger.info( + f">>> Calling get_display_image with force_gaia_chart={self._force_gaia_chart}" + ) + + # get_display_image returns either an image directly (POSS) or a generator (Gaia chart) + result = get_display_image( + self.object, + eyepiece_text, + tfov, + roll, + self.display_class, + burn_in=True, + magnification=magnification, + show_nsew=self.config_object.get_option("image_nsew", True), + show_bbox=self.config_object.get_option("image_bbox", True), + flip_image=flip_image, + flop_image=flop_image, + config_object=self.config_object, + shared_state=self.shared_state, + chart_generator=chart_gen, # Pass our chart generator to object_images + force_chart=self._force_gaia_chart, # Toggle state + ) + + # Check if it's a generator (progressive Gaia chart) or direct image (POSS) + if hasattr(result, "__iter__") and hasattr(result, "__next__"): + # It's a generator - store it for progressive consumption by update() + logger.info( + ">>> get_display_image returned GENERATOR, storing for progressive updates..." + ) + self._chart_generator = result + self.object_image = None # Will be set by first yield + else: + # Direct image (POSS) + logger.info( + f">>> get_display_image returned direct image: {type(result)}" + ) + self._chart_generator = None + self.object_image = result + + logger.info( + f">>> update_object_info() complete, self.object_image is now: {type(self.object_image)}" + ) + + # Track if we're showing a "Loading..." placeholder for chart + self._is_showing_loading_chart = ( + self.object_image is not None + and hasattr(self.object_image, "image_type") + and self.object_image.image_type == ImageType.LOADING + ) + + @property + def _is_gaia_chart(self): + """Check if currently displaying a Gaia chart""" + return ( + self.object_image is not None + and hasattr(self.object_image, "image_type") + and self.object_image.image_type == ImageType.GAIA_CHART + ) + + # Below this on-screen span (px) an extent is just a dot; the marker glyph + # carries the position instead of an unreadable blob. + _MIN_EXTENT_PX = 4 + + def _object_has_visible_extent(self) -> bool: + """True when this object has a known extent big enough to outline. + + An extent is worth drawing only if it spans more than a few pixels at + the current FOV; otherwise the marker glyph is used instead. + """ + size = getattr(self.object, "size", None) + if not size or not size.extents: + return False + fov = self.config_object.equipment.calc_tfov() + if fov <= 0: + return False + px_per_arcsec = self.display_class.fov_res / (fov * 3600.0) + return size.max_extent_arcsec * px_per_arcsec >= self._MIN_EXTENT_PX + + def _draw_object_mark(self): + """Draw the object mark on the Gaia chart per the MARK / ANIM settings. + + ANIM (``obj_chart_crosshair``): off / on / pulse / fade -- how the mark + animates. ``off`` draws nothing. + + MARK (``obj_chart_mark_source``): what to draw -- + * ``standard`` -- the object's extent outline; the ``simple`` glyph + when the object has no resolvable extent. + * ``fallback`` -- the extent outline; the configured glyph + (``obj_chart_crosshair_style``) when there is no extent. + * ``custom`` -- always the configured glyph, never the extent. + """ + mode = self.config_object.get_option("obj_chart_crosshair") + if mode == "off": + return + + source = self.config_object.get_option("obj_chart_mark_source", "standard") + use_extent = source in ("standard", "fallback") and ( + self._object_has_visible_extent() + ) + + if use_extent: + if self._draw_extent_mark(mode): + return + # Extent couldn't be projected (e.g. no chart geometry yet); fall + # through to a glyph so the object is still marked. + + if source == "standard": + style = "simple" + else: + style = self.config_object.get_option("obj_chart_crosshair_style") + + style_methods = { + "simple": self._draw_crosshair_simple, + "circle": self._draw_crosshair_circle, + "bullseye": self._draw_crosshair_bullseye, + "brackets": self._draw_crosshair_brackets, + "dots": self._draw_crosshair_dots, + "cross": self._draw_crosshair_cross, + } + style_methods.get(style, self._draw_crosshair_simple)(mode=mode) + + def _extent_mark_intensity(self, mode: str) -> int: + """Red intensity for the extent outline under the given ANIM mode. + + The extent keeps its true angular size, so pulse/fade throb the + *brightness* rather than the size (shrinking it would misstate the + object's real extent). + """ + if mode == "pulse": + _, _, color_intensity = self._get_pulse_factor() + return color_intensity + if mode == "fade": + return self._get_fade_factor() + return 160 # solid ("on") + + def _draw_extent_mark(self, mode: str) -> bool: + """Outline the object's extent on the chart, aligned to the baked stars. + + Returns True if it drew (or intentionally drew nothing this frame, e.g. + fully faded), False if the chart geometry isn't available so the caller + can fall back to a glyph. + """ + geom = self._chart_geom + if not geom or geom.get("fov", 0) <= 0: + return False + + polylines = extent_perimeter_polylines( + geom["center_ra"], geom["center_dec"], self.object.size ) + if not polylines: + return False + + fov_res = self.display_class.fov_res + pad_x = (self.display_class.resX - fov_res) / 2.0 + pad_y = (self.display_class.resY - fov_res) / 2.0 + flip = geom["flip"] + flop = geom["flop"] + + def to_screen(ra, dec): + x, y = project_radec_to_chart( + ra, + dec, + geom["center_ra"], + geom["center_dec"], + geom["fov"], + fov_res, + fov_res, + geom["image_rotate"], + ) + # Reproduce the render's flip/flop transpose (mirror about center). + if flop: + x = (fov_res - 1) - x + if flip: + y = (fov_res - 1) - y + return (x + pad_x, y + pad_y) + + intensity = self._extent_mark_intensity(mode) + color = (intensity, 0, 0) + for polyline in polylines: + pts = [to_screen(ra, dec) for ra, dec in polyline] + if len(pts) >= 2: + self.draw.line(pts, fill=color, width=1) + return True def active(self): self.activation_time = time.time() + # Regenerate object info when returning to this screen + # This ensures config changes (like LM) are applied + self.update_object_info() def _check_catalog_initialized(self): code = self.object.catalog_code @@ -407,6 +723,324 @@ def _check_catalog_initialized(self): catalog = self.catalogs.get_catalog_by_code(code) return catalog and catalog.initialized + def _get_pulse_factor(self): + """ + Calculate current pulse factor for animations + Returns tuple: (pulse_factor, size_multiplier, color_intensity) + - pulse_factor: 0.0 to 1.0 sine wave + - size_multiplier: factor to multiply sizes by (0.6 to 1.0 for smoother animation) + - color_intensity: brightness value (48 to 128 for more visible change) + """ + import time + import numpy as np + + # Get pulse period from config (default 2.0 seconds) + pulse_period = float( + self.config_object.get_option("obj_chart_crosshair_speed", "2.0") + ) + + t = time.time() % pulse_period + # Sine wave for smooth pulsation (0.0 to 1.0 range) + pulse_factor = 0.5 + 0.5 * np.sin(2 * np.pi * t / pulse_period) + + # Size multiplier: 0.6 to 1.0 (smaller range, smoother looking) + size_multiplier = 0.6 + 0.4 * pulse_factor + + # Color intensity: 48 to 128 (brighter and more visible) + color_intensity = int(48 + 80 * pulse_factor) + + return pulse_factor, size_multiplier, color_intensity + + def _get_fade_factor(self): + """ + Calculate current fade factor for animations + Returns color_intensity that fades from 0 to 128 + - Crosshair stays at minimum size + - Only brightness changes + """ + import time + import numpy as np + + # Get fade period from config (default 2.0 seconds) + fade_period = float( + self.config_object.get_option("obj_chart_crosshair_speed", "2.0") + ) + + t = time.time() % fade_period + # Sine wave for smooth fading (0.0 to 1.0 range) + fade_factor = 0.5 + 0.5 * np.sin(2 * np.pi * t / fade_period) + + # Color intensity: 0 to 128 (fade from invisible to half brightness) + # Use round instead of int for better distribution + color_intensity = round(128 * fade_factor) + + return color_intensity + + def _draw_crosshair_simple(self, mode="off"): + """ + Draw simple crosshair with 4 lines and center gap using inverted pixels + + Args: + mode: Animation mode - "off", "pulse", or "fade" (fade not supported for inverted pixels) + """ + import numpy as np + + width, height = self.display_class.resolution + cx, cy = int(width / 2.0), int(height / 2.0) + + if mode == "pulse": + pulse_factor, _, _ = self._get_pulse_factor() + # Size pulsates from 7 down to 4 pixels (inverted - more steps) + outer = int( + 7.0 - (3.0 * pulse_factor) + ) # 7.0 down to 4.0 (smooth animation) + else: + # Fixed size (fade mode not supported for inverted pixels) + outer = 5 + + inner = 3 # Fixed gap (slightly larger center hole) + + # Get screen buffer as numpy array for pixel manipulation + pixels = np.array(self.screen) + + # Invert crosshair pixels (red channel only) for visibility + # Horizontal lines (left and right of center) + for x in range(max(0, cx - outer), max(0, cx - inner)): + if 0 <= x < width and 0 <= cy < height: + pixels[cy, x, 0] = 255 - pixels[cy, x, 0] + for x in range(min(width, cx + inner), min(width, cx + outer)): + if 0 <= x < width and 0 <= cy < height: + pixels[cy, x, 0] = 255 - pixels[cy, x, 0] + + # Vertical lines (top and bottom of center) + for y in range(max(0, cy - outer), max(0, cy - inner)): + if 0 <= y < height and 0 <= cx < width: + pixels[y, cx, 0] = 255 - pixels[y, cx, 0] + for y in range(min(height, cy + inner), min(height, cy + outer)): + if 0 <= y < height and 0 <= cx < width: + pixels[y, cx, 0] = 255 - pixels[y, cx, 0] + + # Write the inverted pixels back into the *existing* screen buffer. + # We must not rebind self.screen/self.draw here: the text layouters + # (designator, type/const, mag/size, aka, description, contrast + # interpretation) captured this module's original ImageDraw in + # __init__. Replacing self.draw would orphan them onto a discarded + # image, so all layouter-drawn text would silently vanish on every + # screen that follows the chart view (e.g. the black DESC screen). + self.screen.paste(Image.fromarray(pixels, mode="RGB")) + + def _draw_crosshair_circle(self, mode="off"): + """ + Draw circle reticle + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + radius = 8.0 - (4.0 * pulse_factor) # 8.0 down to 4.0 (smooth animation) + elif mode == "fade": + color_intensity = self._get_fade_factor() + radius = 4 # Fixed minimum size + else: + color_intensity = 64 + radius = 4 # Smaller fixed size + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + self.draw.ellipse(bbox, outline=marker_color, width=1) + + def _draw_crosshair_bullseye(self, mode="off"): + """ + Draw concentric circles (bullseye) + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + # Pulsate from larger to smaller (smooth animation) + radii = [ + 4.0 - (2.0 * pulse_factor), + 8.0 - (4.0 * pulse_factor), + 12.0 - (6.0 * pulse_factor), + ] # 4→2, 8→4, 12→6 + elif mode == "fade": + color_intensity = self._get_fade_factor() + radii = [2, 4, 6] # Fixed minimum radii + else: + color_intensity = 64 + radii = [2, 4, 6] # Smaller fixed radii + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + for radius in radii: + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + self.draw.ellipse(bbox, outline=marker_color, width=1) + + def _draw_crosshair_brackets(self, mode="off"): + """ + Draw corner brackets (frame corners) + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = int(width / 2.0), int(height / 2.0) + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + size = int(8.0 - (4.0 * pulse_factor)) # 8.0 down to 4.0 (smooth animation) + length = int( + 5.0 - (2.0 * pulse_factor) + ) # 5.0 down to 3.0 (smooth animation) + elif mode == "fade": + color_intensity = self._get_fade_factor() + size = 4 # Fixed minimum size + length = 3 # Fixed minimum length + else: + color_intensity = 64 + size = 4 # Smaller distance from center to bracket corner + length = 3 # Shorter bracket arms + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + + # Top-left bracket + self.draw.line( + [cx - size, cy - size, cx - size + length, cy - size], + fill=marker_color, + width=1, + ) + self.draw.line( + [cx - size, cy - size, cx - size, cy - size + length], + fill=marker_color, + width=1, + ) + + # Top-right bracket + self.draw.line( + [cx + size - length, cy - size, cx + size, cy - size], + fill=marker_color, + width=1, + ) + self.draw.line( + [cx + size, cy - size, cx + size, cy - size + length], + fill=marker_color, + width=1, + ) + + # Bottom-left bracket + self.draw.line( + [cx - size, cy + size, cx - size + length, cy + size], + fill=marker_color, + width=1, + ) + self.draw.line( + [cx - size, cy + size - length, cx - size, cy + size], + fill=marker_color, + width=1, + ) + + # Bottom-right bracket + self.draw.line( + [cx + size - length, cy + size, cx + size, cy + size], + fill=marker_color, + width=1, + ) + self.draw.line( + [cx + size, cy + size - length, cx + size, cy + size], + fill=marker_color, + width=1, + ) + + def _draw_crosshair_dots(self, mode="off"): + """ + Draw four corner dots + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + if mode == "pulse": + pulse_factor, _, color_intensity = self._get_pulse_factor() + distance = 8.0 - (4.0 * pulse_factor) # 8 down to 4 (smooth animation) + dot_size = 3.0 - (1.5 * pulse_factor) # 3 down to 1 (smooth animation) + elif mode == "fade": + color_intensity = self._get_fade_factor() + distance = 4 # Fixed minimum distance + dot_size = 1 # Fixed minimum size + else: + color_intensity = 64 + distance = 4 # Smaller distance from center to dots + dot_size = 1 # Smaller dot radius + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + + # Four corner dots + positions = [ + (cx - distance, cy - distance), # Top-left + (cx + distance, cy - distance), # Top-right + (cx - distance, cy + distance), # Bottom-left + (cx + distance, cy + distance), # Bottom-right + ] + + for x, y in positions: + bbox = [x - dot_size, y - dot_size, x + dot_size, y + dot_size] + self.draw.ellipse(bbox, fill=marker_color) + + def _draw_crosshair_cross(self, mode="off"): + """ + Draw full cross (lines extend across entire screen) + + Args: + mode: Animation mode - "off", "pulse", or "fade" + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + if mode == "pulse": + _, _, color_intensity = self._get_pulse_factor() + elif mode == "fade": + color_intensity = self._get_fade_factor() + else: + color_intensity = 64 + + # Draw directly on screen + marker_color = (color_intensity, 0, 0) + + # Horizontal line + self.draw.line([0, cy, width, cy], fill=marker_color, width=1) + # Vertical line + self.draw.line([cx, 0, cx, height], fill=marker_color, width=1) + + def _draw_fov_circle(self): + """ + Draw FOV circle to show eyepiece field of view boundary + Matches the POSS view circular crop + """ + width, height = self.display_class.resolution + cx, cy = width / 2.0, height / 2.0 + + # Use slightly smaller than screen to show the boundary + # Screen is typically 128x128, so use radius that fits within screen + radius = min(width, height) / 2.0 - 2 # Leave 2 pixel margin + + # Draw subtle circle + marker_color = self.colors.get(32) # Very dim, just to show boundary + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + self.draw.ellipse(bbox, outline=marker_color, width=1) + def _render_pointing_instructions(self): # Pointing Instructions if not self.shared_state.solution().has_pointing(): @@ -499,14 +1133,126 @@ def _render_pointing_instructions(self): self, point_az, point_alt, indicator_color, self.mount_type ) + def _get_gaia_chart_generator(self): + """Get the global chart generator singleton""" + from PiFinder.object_images.gaia_chart import get_gaia_chart_generator + import logging + + logger = logging.getLogger("ObjectDetails") + + chart_gen = get_gaia_chart_generator(self.config_object, self.shared_state) + logger.info(f">>> _get_gaia_chart_generator returning: {chart_gen}") + return chart_gen + + def _apply_custom_eyepiece(self): + """Apply the custom eyepiece focal length and update display""" + from PiFinder.equipment import Eyepiece + + # Capture the focal length before resetting + focal_length = self.eyepiece_input.focal_length_mm + + # Reset input state FIRST to prevent recursion in update() + self.eyepiece_input.reset() + self.eyepiece_input_display = False + + # Apply the custom eyepiece + if focal_length > 0: + logger.info(f">>> Applying custom eyepiece: {focal_length}mm") + + # Remove old custom eyepiece if it exists + if ( + self._custom_eyepiece is not None + and self._custom_eyepiece in self.config_object.equipment.eyepieces + ): + logger.info( + f">>> Removing old custom eyepiece: {self._custom_eyepiece}" + ) + self.config_object.equipment.eyepieces.remove(self._custom_eyepiece) + + # Create and add new custom eyepiece + self._custom_eyepiece = Eyepiece( + make="Custom", + name=f"{focal_length}mm", + focal_length_mm=focal_length, + afov=50, # Default AFOV for custom eyepiece + field_stop=0, + ) + self.config_object.equipment.eyepieces.append(self._custom_eyepiece) + self.config_object.equipment.active_eyepiece_index = ( + len(self.config_object.equipment.eyepieces) - 1 + ) + logger.info( + f">>> Added custom eyepiece to equipment list: {self._custom_eyepiece}" + ) + + self.update_object_info() + self.update() + else: + logger.warning(f">>> Invalid focal length: {focal_length}mm, not applying") + def update(self, force=True): - # Clear Screen - self.clear_screen() + import logging - # paste image - if self.object_display_mode in [DM_POSS, DM_SDSS]: + logger = logging.getLogger("ObjectDetails") + + # Check for eyepiece input timeout + if self.eyepiece_input_display and self.eyepiece_input.is_complete(): + # Auto-complete the input + self._apply_custom_eyepiece() + + # If we have a chart generator, consume one yield to get the next progressive update + if hasattr(self, "_chart_generator") and self._chart_generator is not None: + try: + next_image = next(self._chart_generator) + # logger.debug(f">>> update(): Consumed next chart yield: {type(next_image)}") + self.object_image = next_image + + except StopIteration: + logger.info(">>> update(): Chart generator exhausted") + self._chart_generator = None # Generator exhausted + + # Update loading flag based on current image + if self.object_image is not None: + self._is_showing_loading_chart = ( + hasattr(self.object_image, "image_type") + and self.object_image.image_type == ImageType.LOADING + ) + + # Check if we're showing "Loading..." for a Gaia chart + # and if catalog is now ready, regenerate the image + if self._is_showing_loading_chart: + try: + # Use cached chart generator to preserve catalog state + chart_gen = self._get_gaia_chart_generator() + state = chart_gen.get_catalog_state() + # logger.debug(f">>> Update check: catalog state = {state}") + + if state == CatalogState.READY: + # Catalog ready! Regenerate display + # logger.info(">>> Catalog READY! Regenerating image...") + self._is_showing_loading_chart = False + self.update_object_info() + except Exception as e: + logger.error(f">>> Update check failed: {e}", exc_info=True) + pass + # Clear screen + self.draw.rectangle( + [0, 0, self.display_class.resX, self.display_class.resY], + fill=self.colors.get(0), + ) + + if self.object_display_mode == DM_IMAGE and self.object_image: self.screen.paste(self.object_image) + # If showing Gaia chart, draw crosshair based on config + is_chart = ( + self.object_image is not None + and hasattr(self.object_image, "image_type") + and self.object_image.image_type == ImageType.GAIA_CHART + ) + if is_chart: + self._draw_object_mark() + if self.object_display_mode == DM_DESC or self.object_display_mode == DM_LOCATE: # catalog and entry field i.e. NGC-311 self.refresh_designator() @@ -515,8 +1261,9 @@ def update(self, force=True): # large-font line below the designator (derived so they track res). desig_y = self.display_class.titlebar_height + 3 typeconst_y = desig_y + self.fonts.large.height - desig = self.texts["designator"] - desig.draw((0, desig_y)) + desig = self.texts.get("designator") + if desig: + desig.draw((0, desig_y)) # Object TYPE and Constellation i.e. 'Galaxy PER' typeconst = self.texts.get("type-const") @@ -627,7 +1374,16 @@ def update(self, force=True): ) y_pos += 11 - return self.screen_update() + # Display eyepiece input popup if active + if self.eyepiece_input_display: + self.message( + f"{str(self.eyepiece_input)}mm", + 0.1, + [30, 10, 93, 40], + ) + + result = self.screen_update() + return result def cycle_display_mode(self): """ @@ -675,6 +1431,40 @@ def mm_cancel(self, _marking_menu, _menu_item) -> bool: """ return True + def mm_toggle_crosshair(self, _marking_menu, _menu_item) -> bool: + """ + Cycle through crosshair modes: off -> on -> pulse -> off + """ + current_mode = self.config_object.get_option("obj_chart_crosshair") + modes = ["off", "on", "pulse"] + current_index = modes.index(current_mode) if current_mode in modes else 0 + next_index = (current_index + 1) % len(modes) + self.config_object.set_option("obj_chart_crosshair", modes[next_index]) + return False # Don't exit, just update + + def mm_cycle_style(self, _marking_menu, _menu_item) -> bool: + """ + Cycle through crosshair styles + """ + current_style = self.config_object.get_option("obj_chart_crosshair_style") + styles = ["simple", "circle", "bullseye", "brackets", "dots", "cross"] + current_index = styles.index(current_style) if current_style in styles else 0 + next_index = (current_index + 1) % len(styles) + self.config_object.set_option("obj_chart_crosshair_style", styles[next_index]) + return False # Don't exit, just update + + def mm_toggle_lm_mode(self, _marking_menu, _menu_item) -> bool: + """ + Toggle between auto and fixed LM mode + """ + current_mode = self.config_object.get_option("obj_chart_lm_mode") + new_mode = "fixed" if current_mode == "auto" else "auto" + self.config_object.set_option("obj_chart_lm_mode", new_mode) + # If switching to auto, regenerate the chart with new calculation + if new_mode == "auto": + self.update_object_info() + return False # Don't exit, just update + def mm_align(self, _marking_menu, _menu_item) -> bool: """ Called from marking menu to align on curent object @@ -707,9 +1497,14 @@ def key_left(self): def key_right(self): """ - When right is pressed, move to - logging screen + When right is pressed, move to logging screen + Or, if eyepiece input is active, complete the input """ + # If eyepiece input is active, complete it + if self.eyepiece_input_display: + self._apply_custom_eyepiece() + return True + self.maybe_add_to_recents() if not self.shared_state.solution().has_pointing(): return @@ -721,7 +1516,66 @@ def key_right(self): self.add_to_stack(object_item_definition) def change_fov(self, direction): - self.config_object.equipment.cycle_eyepieces(direction) + """ + Change field of view by cycling eyepieces. + If a custom eyepiece is active, jump to the nearest configured eyepiece and remove custom. + """ + if self._custom_eyepiece is not None: + # Custom eyepiece is active - remove it and find nearest configured eyepiece + logger.info(">>> Custom eyepiece active, switching to configured eyepieces") + custom_focal_length = self._custom_eyepiece.focal_length_mm + + # Remove custom eyepiece from equipment list + if self._custom_eyepiece in self.config_object.equipment.eyepieces: + self.config_object.equipment.eyepieces.remove(self._custom_eyepiece) + self._custom_eyepiece = None + + # Get configured eyepieces (now that custom is removed) + eyepieces = self.config_object.equipment.eyepieces + if not eyepieces: + return + + # Sort eyepieces by focal length + sorted_eyepieces = sorted(eyepieces, key=lambda e: e.focal_length_mm) + + if direction > 0: + # Find next larger eyepiece (smaller magnification) + for ep in sorted_eyepieces: + if ep.focal_length_mm > custom_focal_length: + self.config_object.equipment.active_eyepiece_index = ( + eyepieces.index(ep) + ) + logger.info(f">>> Jumped to next larger: {ep}") + break + else: + # No larger eyepiece found, wrap to smallest + self.config_object.equipment.active_eyepiece_index = ( + eyepieces.index(sorted_eyepieces[0]) + ) + logger.info(f">>> Wrapped to smallest: {sorted_eyepieces[0]}") + else: + # Find next smaller eyepiece (larger magnification) + for i in range(len(sorted_eyepieces) - 1, -1, -1): + ep = sorted_eyepieces[i] + if ep.focal_length_mm < custom_focal_length: + self.config_object.equipment.active_eyepiece_index = ( + eyepieces.index(ep) + ) + logger.info(f">>> Jumped to next smaller: {ep}") + break + else: + # No smaller eyepiece found, wrap to largest + self.config_object.equipment.active_eyepiece_index = ( + eyepieces.index(sorted_eyepieces[-1]) + ) + logger.info(f">>> Wrapped to largest: {sorted_eyepieces[-1]}") + else: + # Normal eyepiece cycling + self.config_object.equipment.cycle_eyepieces(direction) + logger.info( + f">>> Normal cycle to: {self.config_object.equipment.active_eyepiece}" + ) + self.update_object_info() self.update() @@ -827,3 +1681,60 @@ def serialize_ui_state(self) -> dict: } except Exception as e: return {"error": f"Failed to serialize object details state: {str(e)}"} + + def key_number(self, number): + """ + Handle number key presses + When viewing image (DM_IMAGE): + - 0: Toggle between POSS image and Gaia chart (only if no input active) + - 1-9: Start custom eyepiece input + - After first digit, 0-9 adds second digit or completes input + """ + logger.info(f">>> key_number({number}) called") + + # Only handle custom eyepiece input in image display modes + if self.object_display_mode != DM_IMAGE: + return + + # Special case: 0 when no input is active toggles POSS/chart + if number == 0 and not self.eyepiece_input_display: + logger.info( + f">>> Toggling _force_gaia_chart (was: {self._force_gaia_chart})" + ) + # Toggle the flag + self._force_gaia_chart = not self._force_gaia_chart + logger.info(f">>> _force_gaia_chart now: {self._force_gaia_chart}") + + # Reload image with new setting + logger.info(">>> Calling update_object_info()...") + self.update_object_info() + logger.info( + f">>> After update_object_info(), self.object_image type: {type(self.object_image)}, size: {self.object_image.size if self.object_image else None}" + ) + logger.info(">>> Calling update()...") + update_result = self.update() + logger.info(f">>> update() returned: {type(update_result)}") + logger.info(">>> key_number(0) complete") + return True + + # Handle custom eyepiece input (1-9 to start, 0-9 for second digit) + if number >= 1 or (number == 0 and self.eyepiece_input_display): + logger.info(f">>> Adding digit {number} to eyepiece input") + is_complete = self.eyepiece_input.append_digit(number) + self.eyepiece_input_display = True + logger.info( + f">>> After adding digit: focal_length={self.eyepiece_input.focal_length_mm}mm, complete={is_complete}, display='{self.eyepiece_input}'" + ) + + if is_complete: + # Two digits entered, apply immediately + logger.info( + f">>> Input complete, applying {self.eyepiece_input.focal_length_mm}mm" + ) + self._apply_custom_eyepiece() + else: + # Show popup with current input + logger.info(">>> Input incomplete, showing popup") + self.update() + + return True diff --git a/python/PiFinder/ui/object_list.py b/python/PiFinder/ui/object_list.py index c3c052888..794cb0de4 100644 --- a/python/PiFinder/ui/object_list.py +++ b/python/PiFinder/ui/object_list.py @@ -13,6 +13,8 @@ import functools from functools import cache import math as math +import datetime +import time from PIL import Image, ImageChops from itertools import cycle @@ -58,7 +60,50 @@ class SortOrder(Enum): CATALOG_SEQUENCE = 0 # By catalog/sequence NEAREST = 1 # By Distance to target + BRIGHTEST = 2 # By apparent magnitude RA = 3 # By RA + EARTH_DISTANCE = 4 # By physical distance from Earth + OPPOSITION = 5 # By next opposition / greatest elongation + + +def _sort_objects( + objects: list[CompositeObject], order: SortOrder +) -> list[CompositeObject]: + if order == SortOrder.CATALOG_SEQUENCE: + return list(objects) + if order == SortOrder.RA: + return sorted(objects, key=lambda obj: obj.ra) + if order == SortOrder.BRIGHTEST: + return sorted(objects, key=lambda obj: obj.mag.filter_mag) + if order == SortOrder.EARTH_DISTANCE: + return sorted( + objects, + key=lambda obj: obj.earth_distance_au + if obj.earth_distance_au is not None + else math.inf, + ) + if order == SortOrder.OPPOSITION: + return sorted( + objects, + key=lambda obj: obj.opposition_date or datetime.date.max, + ) + return list(objects) + + +# Sentinel for "the Nearby spatial index has never been built". A plain None +# can't serve: it is a legitimate dirty_time when no filter is configured. +_NEARBY_INDEX_UNBUILT = object() + + +def _sort_order_label(sort_order: "SortOrder") -> str: + return { + SortOrder.CATALOG_SEQUENCE: _("Catalog"), + SortOrder.NEAREST: _("Nearby"), + SortOrder.BRIGHTEST: _("Brightest"), + SortOrder.RA: _("RA"), + SortOrder.EARTH_DISTANCE: _("Distance"), + SortOrder.OPPOSITION: _("Opposition"), + }[sort_order] def _next_target_index( @@ -120,7 +165,10 @@ def __init__(self, *args, **kwargs) -> None: self._menu_items_sorted: list[CompositeObject] = [] self.catalog_info_1: str = "" self.catalog_info_2: str = "" + self.catalog_data_label: str = "" self._was_loading: bool = False # Track loading state to detect completion + # Filter dirty_time the Nearby spatial index was last built against + self._nearby_index_key: Any = _NEARBY_INDEX_UNBUILT # Init display mode defaults self.mode_cycle = cycle(DisplayModes) @@ -156,27 +204,41 @@ def __init__(self, *args, **kwargs) -> None: # Base marking menu marking_menu_down = MarkingMenuOption() - # Add refresh option for comet catalog only - if ( - self.item_definition.get("objects") == "catalog" - and self.item_definition.get("value") == "CM" - ): + # Downloaded dynamic catalogs can refresh without discarding their + # currently displayed objects. + if self.item_definition.get( + "objects" + ) == "catalog" and self.item_definition.get("value") in ("CM", "MP"): marking_menu_down = MarkingMenuOption( label=_("Refresh"), - callback=self.mm_refresh_comets, # TRANSLATORS: Marking menu option to refresh comet catalog + callback=self.mm_refresh_dynamic_catalog, ) + asteroid_list = self.item_definition.get("value") == "MP" + self.marking_menu = MarkingMenu( left=MarkingMenuOption( label=_("Sort"), callback=MarkingMenu( - up=MarkingMenuOption(), + up=MarkingMenuOption( + label=_("MAG"), + callback=self.mm_change_sort, + value=SortOrder.BRIGHTEST, + ), left=MarkingMenuOption( - label=_("Nearest"), callback=self.mm_change_sort + label=_("NEAR"), + callback=self.mm_change_sort, + value=SortOrder.NEAREST, + ), + down=MarkingMenuOption( + label=_("OPP") if asteroid_list else _("RA"), + callback=self.mm_change_sort, + value=(SortOrder.OPPOSITION if asteroid_list else SortOrder.RA), ), - down=MarkingMenuOption(), right=MarkingMenuOption( - label=_("Standard"), callback=self.mm_change_sort + label=_("STD"), + callback=self.mm_change_sort, + value=SortOrder.CATALOG_SEQUENCE, ), ), ), @@ -227,8 +289,12 @@ def refresh_object_list(self, force_update=False): for catalog in self.catalogs.get_catalogs(only_selected=False): if catalog.catalog_code == self.item_definition["value"]: self._menu_items = catalog.get_filtered_objects() - age = catalog.get_age() - self.catalog_info_2 = "" if age is None else str(round(age, 0)) + self.catalog_data_label = catalog.get_data_label() or "" + if self.catalog_data_label: + self.catalog_info_2 = "" + else: + age = catalog.get_age() + self.catalog_info_2 = "" if age is None else str(round(age, 0)) if self.item_definition["objects"] == "recent": self._menu_items = self.ui_state.recent_list() @@ -243,14 +309,40 @@ def refresh_object_list(self, force_update=False): object_list = self.catalogs.catalog_filter.apply(object_list) self._menu_items = object_list - self.catalog_info_1 = str(self.get_nr_of_menu_items()) + # The header count describes the catalog behind the screen, so it is + # deliberately the whole filtered set -- not the possibly-shorter list + # the carousel is currently navigating (see get_nr_of_menu_items). + self.catalog_info_1 = str(len(self._menu_items)) self._menu_items_sorted = self._menu_items - self.sort() + # _menu_items was rebuilt from source, so the spatial index no longer + # describes it whatever the filter's dirty_time says. + self._nearby_index_key = _NEARBY_INDEX_UNBUILT + self.sort(show_message=False) self._current_item_index = _next_target_index( self._menu_items_sorted, old_order, old_index ) - def _get_catalog_status_message(self) -> Tuple[Optional[str], Optional[int]]: + def get_nr_of_menu_items(self): + """Count the sorted rows that this screen actually navigates.""" + return len(self._menu_items_sorted) + + def _get_catalog_status(self): + if self.item_definition.get("objects") != "catalog": + return None + catalog = self.catalogs.get_catalog_by_code(self.item_definition.get("value")) + if catalog is None: + return None + status = catalog.get_status() + if ( + status.previous != CatalogState.READY + and status.current == CatalogState.READY + ): + self.refresh_object_list(force_update=True) + return status + + def _get_catalog_status_message( + self, status=None + ) -> Tuple[Optional[str], Optional[int]]: """ Generate status message explaining why catalog might be empty. Returns tuple of (message, progress_percentage). @@ -258,72 +350,28 @@ def _get_catalog_status_message(self) -> Tuple[Optional[str], Optional[int]]: Also handles refreshing object list when catalog transitions to READY. """ - if self.item_definition.get("objects") != "catalog": + status = status or self._get_catalog_status() + if status is None: return (None, None) - - catalog_code = self.item_definition.get("value") - if not catalog_code: + progress = status.data.get("progress") if status.data else None + if status.current == CatalogState.READY: return (None, None) - - for catalog in self.catalogs.get_catalogs(only_selected=False): - if catalog.catalog_code == catalog_code: - status = catalog.get_status() - - # Handle state transitions - refresh immediately when transitioning to READY - if ( - status.previous != CatalogState.READY - and status.current == CatalogState.READY - ): - self.refresh_object_list(force_update=True) - - # Extract progress if available - progress = None - if status.data and "progress" in status.data: - progress = status.data["progress"] - - # Map state to user-facing messages - if status.current == CatalogState.READY: - return (None, None) - elif status.current == CatalogState.DOWNLOADING: - return ( - _( - "Downloading..." - ), # TRANSLATORS: Status when catalog data is downloading - progress, - ) - elif status.current == CatalogState.NO_GPS: - return ( - _( - "No GPS lock" - ), # TRANSLATORS: Status when waiting for GPS position - None, - ) - elif status.current == CatalogState.CALCULATING: - return ( - _( - "Calculating..." - ), # TRANSLATORS: Status when computing object positions - progress, - ) - elif status.current == CatalogState.ERROR: - return (_("Error"), None) # TRANSLATORS: Generic error status - else: - return ( - _("Loading..."), - None, - ) # TRANSLATORS: Generic loading status - - return (None, None) - - def sort(self) -> None: + if status.current == CatalogState.DOWNLOADING: + return (_("Downloading..."), progress) + if status.current == CatalogState.NO_GPS: + return (_("No GPS lock"), None) + if status.current == CatalogState.CALCULATING: + return (_("Calculating..."), progress) + if status.current == CatalogState.ERROR: + return (_("Error"), None) + return (_("Loading..."), None) + + def sort(self, show_message: bool = True) -> None: message = _("Sorting by\n{sort_order}").format( - sort_order=_("RA") - if self.current_sort == SortOrder.RA - else _("Catalog") - if self.current_sort == SortOrder.CATALOG_SEQUENCE - else _("Nearby") + sort_order=_sort_order_label(self.current_sort) ) - self.message(message, 0.1) + if show_message: + self.message(message, 0.1) self.update() if self.current_sort == SortOrder.NEAREST: @@ -335,20 +383,37 @@ def sort(self) -> None: self._menu_items = self.catalogs.catalog_filter.apply( self._menu_items ) - self.nearby.set_items(self._menu_items) + self._build_nearby_index() self.nearby_refresh() self._current_item_index = 0 - if self.current_sort == SortOrder.CATALOG_SEQUENCE: - self._menu_items_sorted = self._menu_items + if self.current_sort != SortOrder.NEAREST: + self._menu_items_sorted = _sort_objects(self._menu_items, self.current_sort) self._current_item_index = 0 self.update() + def _build_nearby_index(self) -> None: + """ + (Re)build the Nearby spatial index, skipping the rebuild while both the + item list and the filter are unchanged -- the same dirty_time guard + UIChart uses for its nearby-marker index. + """ + catalog_filter = getattr(self.catalogs, "catalog_filter", None) + dirty_time = getattr(catalog_filter, "dirty_time", None) + if ( + self._nearby_index_key is not _NEARBY_INDEX_UNBUILT + and self._nearby_index_key == dirty_time + ): + return + self.nearby.set_items(self._menu_items) + self._nearby_index_key = dirty_time + def nearby_refresh(self): - self._menu_items_sorted = self.nearby.refresh() - if self._menu_items_sorted is None: + if not self.nearby.has_pointing(): self._menu_items_sorted = self._menu_items self.message(_("No Solve Yet"), 1) + return + self._menu_items_sorted = self.nearby.refresh() def format_az_alt(self, point_az, point_alt): az_arrow_symbol, point_az, alt_arrow_symbol, point_alt = pointing_arrows( @@ -465,6 +530,9 @@ def _draw_scrollbar(self): sbr_y_start = self.display_class.titlebar_height + 1 sbr_y = self.display.height total = self.get_nr_of_menu_items() + if total <= 0: + # Nothing to scroll through; a bar would divide by zero anyway. + return one_item_height = max(1, int((sbr_y - sbr_y_start) / total)) box_pos = (sbr_y - sbr_y_start) * (self._current_item_index) / (total) # print(f"{sbr_x=} {sbr_y=} {total=} {box_pos=} {one_item_height=}, {sbr_y_start=}, {self._current_item_index=}, {self.get_nr_of_menu_items()=}") @@ -534,6 +602,29 @@ def active(self): else: self.refresh_object_list() + def _draw_download_progress(self, progress: Optional[int], intensity: int) -> None: + """Draw a compact determinate/indeterminate bar beside catalog age.""" + width = max(18, min(36, self.display.width // 4)) + height = 4 + x = self.display.width - width - 2 + y = self.line_position(0) + self.fonts.bold.height - height + color = self.colors.get(intensity) + self.draw.rectangle((x, y, x + width, y + height), outline=color) + inner_width = width - 2 + if progress is None: + segment = max(3, inner_width // 4) + offset = int(time.monotonic() * 8) % max(1, inner_width - segment + 1) + self.draw.rectangle( + (x + 1 + offset, y + 1, x + offset + segment, y + height - 1), + fill=color, + ) + else: + filled = round(inner_width * max(0, min(100, progress)) / 100) + if filled: + self.draw.rectangle( + (x + 1, y + 1, x + filled, y + height - 1), fill=color + ) + def update(self, force: bool = False) -> None: self.clear_screen() @@ -555,17 +646,23 @@ def update(self, force: bool = False) -> None: self._was_loading = is_loading # Altitude verdicts age out while the screen sits open (the sky - # rotates); refresh the list when the filter reports staleness. - # Before the no-objects check so an emptied-by-altitude list can - # repopulate as objects rise. + # rotates); refresh the list when the filter reports staleness or a + # dynamic catalog replaced its objects. Before the no-objects check so + # an emptied-by-altitude list can repopulate as objects rise. catalog_filter = self.catalogs.catalog_filter - if catalog_filter is not None and catalog_filter.is_stale(): + if catalog_filter is not None and ( + catalog_filter.is_dirty() or catalog_filter.is_stale() + ): self.refresh_object_list() + # Poll dynamic-catalog state even while objects remain populated: an + # update keeps serving the old catalog and reports download progress. + catalog_status = self._get_catalog_status() + # no objects to display if self.get_nr_of_menu_items() == 0: # Get catalog-specific status message if available - status_msg, progress = self._get_catalog_status_message() + status_msg, progress = self._get_catalog_status_message(catalog_status) # Re-check menu items in case refresh happened during status check if self.get_nr_of_menu_items() > 0: @@ -608,41 +705,55 @@ def update(self, force: bool = False) -> None: # should we refresh the nearby list? if self.current_sort == SortOrder.NEAREST and self.nearby.should_refresh(): - # keep the cursor on the selected object as it migrates - # through the distance ranking + # A pointing-driven re-rank, not a list rebuild: the user slewed in + # order to change what is nearest, so while the cursor sits on the + # top row it keeps following the pointing. Once they have scrolled + # off the top they are browsing, and the cursor pins to the + # selected object as it migrates through the ranking. + parked_at_top = self._current_item_index == 0 old_order = self._menu_items_sorted old_index = self._current_item_index self.nearby_refresh() - self._current_item_index = _next_target_index( - self._menu_items_sorted, old_order, old_index + self._current_item_index = ( + 0 + if parked_at_top + else _next_target_index(self._menu_items_sorted, old_order, old_index) ) # Draw sorting mode in the empty rows above the focus line if self._current_item_index < half: intensity: int = int(64 + (((half - 1) - self._current_item_index) * 32.0)) - self.draw.text( - (begin_x, self.line_position(0)), - _("{catalog_info_1} obj").format( - catalog_info_1=self.catalog_info_1 - ) # TRANSLATORS: number of objects in object list - + _(", {catalog_info_2}d old").format( + catalog_header = _("{catalog_info_1} obj").format( + catalog_info_1=self.catalog_info_1 + ) + if self.catalog_data_label: + catalog_header += f", {self.catalog_data_label}" + elif self.catalog_info_2: + catalog_header += _(", {catalog_info_2}d old").format( catalog_info_2=self.catalog_info_2 ) - if self.catalog_info_2 - else "", # TRANSLATORS: suffix to number of objects in object list (indicating age of catalog data) + self.draw.text( + (begin_x, self.line_position(0)), + catalog_header, font=self.fonts.bold.font, fill=self.colors.get(intensity), ) self.draw.text( (begin_x, self.line_position(1)), _("Sort: {sort_order}").format( - sort_order=_("Catalog") - if self.current_sort == SortOrder.CATALOG_SEQUENCE - else _("Nearby") + sort_order=_sort_order_label(self.current_sort) ), font=self.fonts.bold.font, fill=self.colors.get(intensity), ) + if ( + catalog_status is not None + and catalog_status.current == CatalogState.DOWNLOADING + ): + progress = ( + catalog_status.data.get("progress") if catalog_status.data else None + ) + self._draw_download_progress(progress, intensity) # Draw current selection hint self.draw.rectangle(layout.selection_box, outline=self.colors.get(128), width=1) line_number, line_pos = 0, 0 @@ -840,7 +951,7 @@ def key_right(self): object info screen """ nr_menu_items = self.get_nr_of_menu_items() - if nr_menu_items < self._current_item_index or nr_menu_items == 0: + if nr_menu_items == 0 or self._current_item_index >= nr_menu_items: return # turn off input box if it's there @@ -876,21 +987,13 @@ def mm_change_sort(self, marking_menu, menu_item): marking_menu.select_none() menu_item.selected = True - if menu_item.label == _("Nearest"): - self.current_sort = SortOrder.NEAREST - self.nearby_refresh() - self.sort() - return True + sort_order = getattr(menu_item, "value", None) + if not isinstance(sort_order, SortOrder): + return False - if menu_item.label == _("Standard"): - self.current_sort = SortOrder.CATALOG_SEQUENCE - self.sort() - return True - - if menu_item.label == _("RA"): - self.current_sort = SortOrder.RA - self.sort() - return True + self.current_sort = sort_order + self.sort() + return True def mm_jump_to_filter(self, marking_menu, menu_item): pass @@ -900,9 +1003,12 @@ def serialize_ui_state(self) -> dict: Serialize the current state of the object list for inter-process communication """ try: + # _current_item_index addresses the sorted list, which is what the + # screen draws -- indexing _menu_items reports a different object + # under any sort that reorders or shortens the source. current_item = None - if 0 <= self._current_item_index < len(self._menu_items): - obj = self._menu_items[self._current_item_index] + if 0 <= self._current_item_index < len(self._menu_items_sorted): + obj = self._menu_items_sorted[self._current_item_index] # For CompositeObject, use display_name which is JSON serializable current_item = ( obj.display_name if hasattr(obj, "display_name") else str(obj) @@ -911,7 +1017,7 @@ def serialize_ui_state(self) -> dict: return { "current_index": self._current_item_index, "current_item": current_item, - "total_items": len(self._menu_items), + "total_items": len(self._menu_items_sorted), "display_mode": self.current_mode.name if hasattr(self.current_mode, "name") else str(self.current_mode), @@ -920,19 +1026,20 @@ def serialize_ui_state(self) -> dict: else str(self.current_sort), "catalog_info_1": self.catalog_info_1, "catalog_info_2": self.catalog_info_2, + "catalog_data_label": self.catalog_data_label, } except Exception as e: return {"error": f"Failed to serialize object list state: {str(e)}"} - def mm_refresh_comets(self, marking_menu, menu_item): - """Force refresh of comet data from the internet""" - catalog = self.catalogs.get_catalog_by_code("CM") + def mm_refresh_dynamic_catalog(self, marking_menu, menu_item): + """Refresh downloaded elements while retaining the active objects.""" + catalog = self.catalogs.get_catalog_by_code(self.item_definition.get("value")) if catalog and hasattr(catalog, "refresh"): self.message( _("Refreshing..."), 1 ) # TRANSLATORS: Status message when refreshing comet catalog catalog.refresh() - # Clear the UI object list and refresh to show status + # Keep the current objects visible and refresh the status header. self.refresh_object_list(force_update=True) return True diff --git a/python/PiFinder/ui/preview.py b/python/PiFinder/ui/preview.py index a9b41b92c..14e24cf5a 100644 --- a/python/PiFinder/ui/preview.py +++ b/python/PiFinder/ui/preview.py @@ -3,7 +3,6 @@ """Raw, magnified multi-star Focus screen.""" import math -import sys import time from collections import deque from typing import Optional @@ -11,12 +10,10 @@ import numpy as np from PIL import Image, ImageChops, ImageDraw, ImageOps -from PiFinder import focus, utils +from PiFinder import focus from PiFinder.ui.base import UIModule from PiFinder.ui.marking_menus import MarkingMenu, MarkingMenuOption -sys.path.append(str(utils.tetra3_dir)) - # Ten times the apparent size of the old full-frame preview. On a square panel # this maps a 26x26 patch from the 512x512 camera frame into each half-screen # tile. The crop expands for a broad blob so a defocused star remains visible. @@ -114,8 +111,6 @@ def focus_crop_size( class UIPreview(UIModule): - from PiFinder import tetra3 - __title__ = "CAMERA" __help_name__ = "camera" _display_mode_list = [DISPLAY_STARS, DISPLAY_SINGLE, DISPLAY_IMAGE, DISPLAY_STATS] diff --git a/python/PiFinder/ui/software.py b/python/PiFinder/ui/software.py index 8e6ee031e..5489e3b3f 100644 --- a/python/PiFinder/ui/software.py +++ b/python/PiFinder/ui/software.py @@ -1,144 +1,305 @@ #!/usr/bin/python # -*- coding:utf-8 -*- """ -This module contains the UI Module classes for -software updates and NixOS migration. +UI modules for software updates, channel selection, and release notes. + +Channels: + - stable: release entries from update-manifest.json + - beta: prerelease entries from update-manifest.json + - unstable: trunk + testable PR entries from update-manifest.json """ +import json import logging -import time -from typing import Any, Optional, TYPE_CHECKING +import re +import threading +from datetime import datetime, timezone +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING import requests -from PiFinder import utils -from PiFinder.ui.base import UIModule -from PiFinder.ui.ui_utils import TextLayouter - if TYPE_CHECKING: + # At runtime PiFinder.i18n gettext-installs _ into builtins; mypy only + # sees it inside annotated functions, so give it a signature here. + def _(message: str) -> str: ... - def _(a) -> Any: - return a +from PiFinder import utils +from PiFinder.ui.base import UIModule +from PiFinder.ui.ui_utils import TextLayouter, TextLayouterScroll sys_utils = utils.get_sys_utils() logger = logging.getLogger("UISoftware") -REQUEST_TIMEOUT = 10 -MIGRATION_GATE_URL = ( - "https://raw.githubusercontent.com/brickbots/PiFinder/release/migration_gate.json" -) +# --- Update channel source ----------------------------------------------------- +# CI publishes generated update metadata to a metadata-only branch. Devices read +# one raw JSON file instead of calling the GitHub REST API, so they do not burn +# unauthenticated rate limits. +MANIFEST_REPO = "brickbots/PiFinder" +MANIFEST_BRANCH = "nixos-manifest" +# ------------------------------------------------------------------------------ UPDATE_MANIFEST_URL = ( - "https://raw.githubusercontent.com/brickbots/PiFinder/" - "nixos-manifest/update-manifest.json" + f"https://raw.githubusercontent.com/{MANIFEST_REPO}/" + f"{MANIFEST_BRANCH}/update-manifest.json" ) +REQUEST_TIMEOUT = 10 +_STORE_PATH_RE = re.compile(r"^/nix/store/[a-z0-9]+-[A-Za-z0-9._+=?,-]+$") -# Secret unlock: 7x square button -_UNLOCK_SEQUENCE = ["square"] * 7 +# Last successfully fetched manifest, kept on disk so the screen can render a +# version list immediately on entry while a fresh copy is fetched in the +# background. +MANIFEST_CACHE_PATH = utils.data_dir / "update_manifest.json" -# Migration targets are read from the update manifest, consulted in descending -# stability; the first available entry carrying a migration tarball wins. -_MIGRATION_CHANNELS = ("stable", "beta", "unstable") +# A trunk entry is only offered when the branch it tracks is actually a NixOS +# branch — detected by this file existing in the branch's tree. Keeps a +# non-NixOS upstream main from showing up as an installable build. +NIXOS_MARKER_FILE = "flake.nix" -def _fetch_migration_config() -> Optional[dict]: - """Fetch and parse the remote migration gate JSON. +def _entry_from_manifest(item: dict, channel: str) -> Optional[dict]: + label = item.get("label") + if not isinstance(label, str) or not label: + return None - Returns the parsed dict on success; None on network error, non-200 - response, or malformed JSON. Only the `nixos_for_everyone` flag is used by - the caller — the tarball itself comes from the update manifest. + title = item.get("title") or item.get("subtitle") or label + entry = { + "label": label, + "ref": item.get("store_path"), + "notes": item.get("notes") or None, + "version": item.get("version") or label, + "subtitle": title, + "title": title, + "channel": channel, + "kind": item.get("kind"), + "number": item.get("number"), + "built_at": item.get("built_at"), + "source_ref": item.get("source_ref"), + "source_sha": item.get("source_sha"), + } + if item.get("kind") == "trunk": + entry["is_trunk"] = True + + store_path = item.get("store_path") + available = item.get("available", bool(store_path)) + if not available or not isinstance(store_path, str): + entry["ref"] = None + entry["unavailable"] = True + reason = item.get("reason") + if reason: + entry["subtitle"] = f"{title} ({reason})" + elif not _STORE_PATH_RE.fullmatch(store_path): + entry["ref"] = None + entry["unavailable"] = True + entry["subtitle"] = f"{title} (invalid build)" + + return entry + + +def _fetch_raw_manifest() -> dict: """ - try: - res = requests.get(MIGRATION_GATE_URL, timeout=REQUEST_TIMEOUT) - except requests.exceptions.RequestException: - return None - if res.status_code != 200: - return None - try: - data = res.json() - except ValueError: - return None - if not isinstance(data, dict): - return None - return data + Fetch CI-generated update metadata as the raw manifest document. + Raises RequestException for network failures so the caller can show offline. + """ + res = requests.get(UPDATE_MANIFEST_URL, timeout=REQUEST_TIMEOUT) + res.raise_for_status() + manifest = res.json() + if manifest.get("schema") != 1: + raise ValueError("unsupported update manifest schema") + if not isinstance(manifest.get("channels", {}), dict): + raise ValueError("invalid update manifest channels") + return manifest + +def _parse_manifest(manifest: dict) -> dict[str, list[dict]]: + """Convert a raw manifest document into per-channel UI entries. -def _fetch_update_manifest() -> Optional[dict]: - """Fetch and parse the update manifest, or None on any failure.""" + Trunk entries whose branch is known to lack the NixOS marker file are + dropped (annotation left by _annotate_trunk_entries; unknown means keep). + """ + channels: dict[str, list[dict]] = {} + manifest_channels = manifest.get("channels", {}) + if not isinstance(manifest_channels, dict): + manifest_channels = {} + + for channel in ("stable", "beta", "unstable"): + entries: list[dict] = [] + raw_entries = manifest_channels.get(channel, []) + if not isinstance(raw_entries, list): + continue + for item in raw_entries: + if not isinstance(item, dict): + continue + if item.get("kind") == "trunk" and item.get("nixos_branch") is False: + continue + entry = _entry_from_manifest(item, channel) + if entry is not None: + entries.append(entry) + channels[channel] = entries + + return channels + + +def _fetch_update_manifest() -> dict[str, list[dict]]: + """Fetch and parse update metadata in one step (no cache involvement).""" + return _parse_manifest(_fetch_raw_manifest()) + + +def _load_cached_manifest() -> Optional[dict]: + """Return the last cached raw manifest, or None when absent/invalid.""" try: - res = requests.get(UPDATE_MANIFEST_URL, timeout=REQUEST_TIMEOUT) - except requests.exceptions.RequestException: + with open(MANIFEST_CACHE_PATH) as f: + manifest = json.load(f) + except (OSError, ValueError): return None - if res.status_code != 200: + if not isinstance(manifest, dict) or manifest.get("schema") != 1: return None + return manifest + + +def _save_cached_manifest(manifest: dict) -> None: + """Best-effort atomic write of the manifest cache.""" try: - data = res.json() - except ValueError: - return None - if not isinstance(data, dict): - return None - return data + MANIFEST_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp_path = MANIFEST_CACHE_PATH.with_suffix(".json.tmp") + with open(tmp_path, "w") as f: + json.dump(manifest, f) + tmp_path.replace(MANIFEST_CACHE_PATH) + except OSError as e: + logger.warning("Could not cache update manifest: %s", e) -def _fetch_download_size_mb(url: str) -> Optional[int]: - """Return the tarball download size in MB from a HEAD request, or None. +def _branch_has_nixos_marker(source_repo: str, source_ref: str) -> Optional[bool]: + """Whether the branch's tree contains the NixOS marker file. - GitHub release assets 302-redirect to a signed URL whose response carries - the real Content-Length, so redirects must be followed. Returns None on - network error, non-200 status, or a missing/unparseable Content-Length. + Returns None when the check is inconclusive (network trouble, unexpected + status) so callers can keep the entry rather than hide a real build. """ + url = ( + f"https://raw.githubusercontent.com/{source_repo}/" + f"{source_ref}/{NIXOS_MARKER_FILE}" + ) try: - res = requests.head(url, allow_redirects=True, timeout=REQUEST_TIMEOUT) + res = requests.head(url, timeout=REQUEST_TIMEOUT) except requests.exceptions.RequestException: return None - if res.status_code != 200: - return None - length = res.headers.get("Content-Length") - if length is None: + if res.status_code == 200: + return True + if res.status_code == 404: + return False + return None + + +def _annotate_trunk_entries(manifest: dict) -> None: + """Stamp nixos_branch on trunk entries in a raw manifest (in place). + + An inconclusive check leaves the entry unannotated, and unannotated trunk + entries stay visible — a flaky network must not hide a real build. The + annotation is persisted with the cache, so the cached list applies the + same verdict without re-checking. + """ + channels = manifest.get("channels", {}) + if not isinstance(channels, dict): + return + unstable = channels.get("unstable", []) + if not isinstance(unstable, list): + return + for item in unstable: + if not isinstance(item, dict) or item.get("kind") != "trunk": + continue + repo = item.get("source_repo") + ref = item.get("source_ref") + if not repo or not ref: + continue + marker = _branch_has_nixos_marker(repo, ref) + if marker is not None: + item["nixos_branch"] = marker + + +def _format_age(built_at: Optional[str]) -> Optional[str]: + """Human-readable age of a build timestamp ("5m ago", "3h ago", "2d ago").""" + if not built_at: return None try: - return round(int(length) / (1024 * 1024)) - except (TypeError, ValueError): + built = datetime.fromisoformat(built_at) + except ValueError: return None + if built.tzinfo is None: + built = built.replace(tzinfo=timezone.utc) + minutes = max(0, int((datetime.now(timezone.utc) - built).total_seconds() // 60)) + if minutes < 60: + return _("{minutes}m ago").format(minutes=minutes) + hours = minutes // 60 + if hours < 24: + return _("{hours}h ago").format(hours=hours) + return _("{days}d ago").format(days=hours // 24) + + +def _entry_row_parts(entry: dict) -> Tuple[str, str]: + """Split a version row into a fixed prefix and the scrollable text. + + PR rows lead with the bare PR number, trunk rows with a dot; the store + hash never appears in the list — titles/branch names are what a human + scans for. + """ + if entry.get("kind") == "pr" and entry.get("number"): + return f"{entry['number']} ", entry.get("title") or entry["label"] + if entry.get("is_trunk"): + return "• ", entry.get("source_ref") or entry["label"] + return "", entry["label"] -def _migration_version_info_from_manifest() -> Optional[dict]: - """Select a migration target from the update manifest. +def _entry_detail(entry: dict) -> str: + """Second line of a focused version row: unavailability beats build info. - Walks channels stable -> beta -> unstable and returns version_info for the - first available entry that carries a migration tarball, or None if the - manifest can't be fetched or has no such entry. + Build info is age plus the short commit hash ("built 5h ago · 2692406") — + the hash stays out of the list rows but remains one focus away. """ - manifest = _fetch_update_manifest() - if not manifest: - return None - channels = manifest.get("channels") - if not isinstance(channels, dict): - return None - for channel in _MIGRATION_CHANNELS: - entries = channels.get(channel) - if not isinstance(entries, list): - continue - for entry in entries: - if not isinstance(entry, dict) or not entry.get("available"): - continue - url = entry.get("migration_url") - sha_url = entry.get("migration_sha256_url") - if not url or not sha_url: - continue - version_info = { - "version": entry.get("version", "?"), - "type": "upgrade", - "migration_url": url, - "migration_sha256_url": sha_url, - } - # Size comes from a live HEAD on the tarball; omitted on failure - # (the confirm screen then shows "?"). - size_mb = _fetch_download_size_mb(url) - if size_mb is not None: - version_info["migration_size_mb"] = size_mb - return version_info - return None + if entry.get("unavailable"): + return entry.get("subtitle", "") + parts = [] + age = _format_age(entry.get("built_at")) + if age: + parts.append(_("built {age}").format(age=age)) + sha = entry.get("source_sha") + if isinstance(sha, str) and sha: + parts.append(sha[:7]) + if parts: + return " · ".join(parts) + return entry.get("subtitle", "") + + +def _current_store_path() -> Optional[str]: + """Store path of the running build, or None when unknown. + + The store path — not the version string — is a build's identity: a re-cut + release keeps its version/label but is a different system. Prefer + current-build.json (it names the base store path even on a camera-specialised + device), but only when it actually describes the running system: it is + written before the reboot, so a failed boot or rollback can leave it naming a + build that isn't running. When stale, fall back to the actually-running + system so the update list hides the real build, not a phantom one. + """ + try: + with open(utils.current_build_json) as f: + recorded = json.load(f).get("store_path") or None + except (OSError, ValueError): + recorded = None + if recorded and utils.build_is_running(recorded): + return recorded + return utils.running_system_store_path() + + +def _hide_current_build(entries: List[dict], current_ref: Optional[str]) -> List[dict]: + """Hide only the exact running build, matched by store path. + + A same-version entry with a different store path (e.g. a re-cut release) + is a real upgrade and must stay visible. When the current build is + unknown, nothing is hidden — offering the running build is harmless, + hiding a real upgrade is not. + """ + if not current_ref: + return list(entries) + return [e for e in entries if e.get("ref") != current_ref] def update_needed(current_version: str, repo_version: str) -> bool: @@ -173,463 +334,802 @@ def update_needed(current_version: str, repo_version: str) -> bool: class UISoftware(UIModule): """ - UI for updating software versions. - Includes secret 7x square unlock to trigger NixOS migration. + Software update UI. + + Phases: + loading - animated "Checking for updates..." + browse - header (version + channel selector) + scrollable version list + confirm - selected version details + Install / Notes / Cancel + upgrading - progress bar with download progress, then reboot + failed - update failed + Retry / Cancel """ __title__ = "SOFTWARE" + MAX_VISIBLE = 4 def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self.version_txt = f"{utils.pifinder_dir}/version.txt" self.wifi_txt = f"{utils.pifinder_dir}/wifi_status.txt" - with open(self.wifi_txt, "r") as wfs: - self._wifi_mode = wfs.read() - with open(self.version_txt, "r") as ver: - self._software_version = ver.read() - - self._release_version = "-.-.-" + with open(self.wifi_txt, "r") as f: + self._wifi_mode = f.read().strip() + self._software_version = utils.get_version() + self._software_subtitle: Optional[str] = None + + self._channels: Dict[str, List[dict]] = {} + self._manifest_channels: Dict[str, List[dict]] = {} + self._channel_names: List[str] = [] + self._channel_index = 0 + + self._version_list: List[dict] = [] + self._list_index = 0 + self._scroll_offset = 0 + + self._phase = "loading" + self._focus = "channel" # "channel" or "list" (browse phase) self._elipsis_count = 0 - self._go_for_update = False - self._option_select = "Update" - - # Unlock sequence tracking (7x square triggers migration) - self._key_buffer: list = [] - - def _record_key(self, key_name: str): - """Record a key press for unlock sequence detection.""" - self._key_buffer.append(key_name) - if len(self._key_buffer) > len(_UNLOCK_SEQUENCE): - self._key_buffer = self._key_buffer[-len(_UNLOCK_SEQUENCE) :] - if self._key_buffer == _UNLOCK_SEQUENCE: - self._key_buffer = [] - # Unlock: offer the first available migration target from the - # manifest, ignoring the nixos_for_everyone gate (that governs only - # the public path). - version_info = _migration_version_info_from_manifest() - if version_info: - self._trigger_migration(version_info) - else: - self.message(_("No release found"), 2) - - def _trigger_migration(self, version_info: dict): - """Push UIMigrationConfirm onto the UI stack with the supplied - version_info (must already contain migration_url and - migration_sha256_url).""" - self.message(_("System Upgrade"), 1) - self.add_to_stack( - { - "class": UIMigrationConfirm, - "version_info": version_info, - "current_version": self._software_version.strip(), - } + + self._selected_version: Optional[dict] = None + self._confirm_options: List[str] = [] + self._confirm_index = 0 + + self._fail_option = "Retry" + self._fail_reason = "" + self._unstable_entries: List[dict] = [] + + # Background manifest refresh: the worker thread writes a single + # ("ok", channels) / ("error", None) tuple; update() consumes it. + self._refresh_thread: Optional[threading.Thread] = None + self._refresh_result: Optional[Tuple[str, Optional[Dict[str, List[dict]]]]] = ( + None ) + self._checking = False + self._check_failed = False - def get_release_version(self): - """ - Fetches current release version from - github, sets class variable if found. - Also checks the remote migration config. - """ - config = _fetch_migration_config() - if config and config.get("nixos_for_everyone"): - # Gate is open for everyone; the tarball comes from the manifest - # (stable -> beta -> unstable). - version_info = _migration_version_info_from_manifest() - if version_info: - self._trigger_migration(version_info) - return + self._scrollers: Dict[str, TextLayouterScroll] = {} + self._scroller_phase: Optional[str] = None + self._scroller_index: Optional[int] = None + def active(self): + super().active() + self._elipsis_count = 0 + self._focus = "channel" + self._channel_index = 0 + self._list_index = 0 + self._scroll_offset = 0 + self._selected_version = None + self._scrollers = {} + self._scroller_phase = None + self._scroller_index = None + self._check_failed = False + + # Render the last cached manifest immediately; a fresh copy is fetched + # in the background and swapped in when it lands. + cached = _load_cached_manifest() + if cached is not None: + self._apply_manifest(_parse_manifest(cached)) + self._phase = "browse" + else: + self._phase = "loading" + self._start_refresh() + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + + def _list_rollback_targets(self) -> List[dict]: + # Rollback targets come from local, immutable generation data, so they + # are available even when the manifest can't be fetched — which is + # exactly when rollback matters most. Entries are validated like + # manifest entries: anything without a string label can't be rendered + # or installed, so it is dropped rather than crash the screen. try: - res = requests.get( - "https://raw.githubusercontent.com/brickbots/PiFinder/release/version.txt", - timeout=REQUEST_TIMEOUT, - ) - except requests.exceptions.RequestException: - logger.warning("Could not fetch release version from github") - self._release_version = "Unknown" + targets = sys_utils.list_rollback_targets() + return [ + t + for t in targets + if isinstance(t, dict) and isinstance(t.get("label"), str) + ] + except Exception as e: # never let rollback listing break the screen + logger.warning("Could not list rollback targets: %s", e) + return [] + + def _start_refresh(self): + if self._refresh_thread is not None and self._refresh_thread.is_alive(): return + self._checking = True + self._refresh_result = None + self._refresh_thread = threading.Thread( + target=self._refresh_worker, daemon=True + ) + self._refresh_thread.start() - if res.status_code == 200: - self._release_version = res.text[:-1] + def _refresh_worker(self): + """Background thread: fetch the manifest, annotate, cache, parse.""" + try: + manifest = _fetch_raw_manifest() + except (requests.exceptions.RequestException, ValueError) as e: + logger.warning("Software update check failed (offline/invalid?): %s", e) + self._refresh_result = ("error", None) + return + _annotate_trunk_entries(manifest) + _save_cached_manifest(manifest) + self._refresh_result = ("ok", _parse_manifest(manifest)) + + def _consume_refresh_result(self): + """Apply a finished background refresh, if any (main thread only).""" + result = self._refresh_result + if result is None: + return + self._refresh_result = None + self._checking = False + status, manifest_channels = result + + if status == "ok" and manifest_channels is not None: + self._check_failed = False + self._apply_manifest(manifest_channels, keep_position=True) + if self._phase in ("loading", "offline"): + self._phase = "browse" + return + + # Refresh failed. With a cached list on screen just flag it; without + # one fall back to rollback-only browse, or the offline notice. + if self._phase != "loading": + self._check_failed = True + return + rollback = self._list_rollback_targets() + if rollback: + self._check_failed = True + self._manifest_channels = {} + self._channels = {"rollback": rollback} + self._channel_names = list(self._channels.keys()) + self._channel_index = 0 + self._refresh_version_list() + self._phase = "browse" + else: + self._phase = "offline" + + def _apply_manifest( + self, manifest_channels: Dict[str, List[dict]], keep_position: bool = False + ): + self._manifest_channels = manifest_channels + self._channels = { + "stable": manifest_channels.get("stable", []), + "beta": manifest_channels.get("beta", []), + } + + if self.config_object.get_option("dev_mode", False): + self._unstable_entries = manifest_channels.get("unstable", []) + self._channels["unstable"] = self._unstable_entries + + rollback = self._list_rollback_targets() + if rollback: + self._channels["rollback"] = rollback + + # Try to find subtitle for current version from fetched entries + self._software_subtitle = self._find_current_subtitle() + + # A background refresh should not yank the user's channel selection. + prev_channel = ( + self._channel_names[self._channel_index] if self._channel_names else None + ) + self._channel_names = list(self._channels.keys()) + if keep_position and prev_channel in self._channel_names: + self._channel_index = self._channel_names.index(prev_channel) else: - self._release_version = "Unknown" + self._channel_index = 0 + self._refresh_version_list(keep_position=keep_position) - def update_software(self): - self.message(_("Updating..."), 10) - if sys_utils.update_software(): - self.message(_("Ok! Restarting"), 10) - sys_utils.restart_system() + def _find_current_subtitle(self) -> Optional[str]: + """Find a subtitle for the running build. + + Matches by store path (the build's identity) first, falling back to + the version string when the current store path is unknown. + """ + current_ref = _current_store_path() + for entries in self._channels.values(): + for entry in entries: + if current_ref and entry.get("ref") == current_ref: + return entry.get("subtitle") + if not current_ref and entry.get("version") == self._software_version: + return entry.get("subtitle") + + return None + + def _refresh_version_list(self, keep_position: bool = False): + if not self._channel_names: + self._version_list = [] + return + channel = self._channel_names[self._channel_index] + entries = self._channels.get(channel, []) + if channel == "rollback": + self._version_list = entries else: - self.message(_("Error on Upd"), 3) + self._version_list = _hide_current_build(entries, _current_store_path()) + if keep_position and self._version_list: + self._list_index = min(self._list_index, len(self._version_list) - 1) + self._scroll_offset = min(self._scroll_offset, self._list_index) + else: + self._list_index = 0 + self._scroll_offset = 0 + self._scrollers = {} + self._scroller_phase = None + self._scroller_index = None + + def _get_scrollspeed_config(self): + scroll_dict = { + "Off": 0, + "Fast": TextLayouterScroll.FAST, + "Med": TextLayouterScroll.MEDIUM, + "Slow": TextLayouterScroll.SLOW, + } + scrollspeed = self.config_object.get_option("text_scroll_speed", "Med") + return scroll_dict[scrollspeed] + + def _get_scroller(self, key: str, text: str, font, color, width: int): + """Get or create a cached scroller, reset cache on phase/index change.""" + phase_index = (self._phase, self._list_index) + if (self._scroller_phase, self._scroller_index) != phase_index: + self._scrollers = {} + self._scroller_phase = self._phase + self._scroller_index = self._list_index + + if key not in self._scrollers: + self._scrollers[key] = TextLayouterScroll( + text, + draw=self.draw, + color=color, + font=font, + width=width, + scrollspeed=self._get_scrollspeed_config(), + ) + return self._scrollers[key] - def update(self, force=False): - self.clear_screen() - draw_pos = self.display_class.titlebar_height + 2 + # ------------------------------------------------------------------ + # Drawing helpers + # ------------------------------------------------------------------ + + def _draw_separator(self, y): + self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) + + def _draw_loading(self): + y = self.display_class.titlebar_height + 2 + ver_scroller = self._get_scroller( + "loading_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) + dots = "." * (self._elipsis_count // 10) self.draw.text( - (0, draw_pos), - _("Wifi Mode: {mode}").format(mode=self._wifi_mode), - font=self.fonts.base.font, - fill=self.colors.get(128), + (10, 90), + _("Checking for"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - draw_pos += self.fonts.base.height + 4 - self.draw.text( - (0, draw_pos), - _("Current Version"), - font=self.fonts.bold.font, - fill=self.colors.get(128), + (10, 105), + _("updates{elipsis}").format(elipsis=dots), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - draw_pos += self.fonts.bold.height - 3 + self._elipsis_count += 1 + if self._elipsis_count > 39: + self._elipsis_count = 0 + def _draw_wifi_warning(self): + y = self.display_class.titlebar_height + 2 + ver_scroller = self._get_scroller( + "wifi_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) self.draw.text( - (10, draw_pos), - f"{self._software_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), + (10, 90), + _("WiFi must be"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - draw_pos += self.fonts.bold.height + 3 - self.draw.text( - (0, draw_pos), - _("Release Version"), - font=self.fonts.bold.font, - fill=self.colors.get(128), + (10, 105), + _("client mode"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - draw_pos += self.fonts.bold.height - 3 + def _draw_offline(self): + y = self.display_class.titlebar_height + 2 + ver_scroller = self._get_scroller( + "offline_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) self.draw.text( - (10, draw_pos), - f"{self._release_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), + (10, 90), + _("No internet -"), + font=self.fonts.large.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, 105), + _("check WiFi"), + font=self.fonts.large.font, + fill=self.colors.get(255), ) - # The two-line status / action message is anchored up from the bottom - # so it clears the (taller-font) info block on larger displays. - msg_pitch = self.fonts.large.height - msg_top = self.display_class.resY - 2 * msg_pitch - 6 - msg_bottom = msg_top + msg_pitch + def _draw_browse(self): + y = self.display_class.titlebar_height + 2 - if self._wifi_mode != "Client": - self.draw.text( - (10, msg_top), - _("WiFi must be"), - font=self.fonts.large.font, - fill=self.colors.get(255), + # Current version + ver_scroller = self._get_scroller( + "browse_cur_ver", + self._software_version, + self.fonts.bold, + self.colors.get(255), + self.fonts.bold.line_length, + ) + ver_scroller.draw((0, y)) + y += 12 + if self._software_subtitle: + sub_scroller = self._get_scroller( + "browse_cur_sub", + self._software_subtitle, + self.fonts.base, + self.colors.get(128), + self.fonts.base.line_length, ) + sub_scroller.draw((0, y)) + y += 12 + else: + y += 2 + + # Channel selector + channel_name = ( + self._channel_names[self._channel_index].capitalize() + if self._channel_names + else "---" + ) + if self._focus == "channel": self.draw.text( - (10, msg_bottom), - _("client mode"), - font=self.fonts.large.font, + (0, y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, fill=self.colors.get(255), ) - return self.screen_update() - - if self._release_version == "-.-.-": - # check elipsis count here... if we are at >30 check for - # release versions - if self._elipsis_count > 30: - self.get_release_version() self.draw.text( - (10, msg_top), - _("Checking for"), - font=self.fonts.large.font, + (10, y), + channel_name, + font=self.fonts.bold.font, fill=self.colors.get(255), ) + else: self.draw.text( - (10, msg_bottom), - _("updates{elipsis}").format( - elipsis="." * int(self._elipsis_count / 10) - ), - font=self.fonts.large.font, - fill=self.colors.get(255), + (10, y), + channel_name, + font=self.fonts.base.font, + fill=self.colors.get(128), ) - self._elipsis_count += 1 - if self._elipsis_count > 39: - self._elipsis_count = 0 - return self.screen_update() + y += 14 - if not update_needed( - self._software_version.strip(), self._release_version.strip() - ): + self._draw_separator(y) + y += 4 + + # Version list + if not self._version_list: self.draw.text( - (10, msg_top), - _("No Update"), - font=self.fonts.large.font, - fill=self.colors.get(255), + (10, y + 10), + _("No versions"), + font=self.fonts.base.font, + fill=self.colors.get(128), ) self.draw.text( - (10, msg_bottom), - _("needed"), - font=self.fonts.large.font, - fill=self.colors.get(255), + (10, y + 22), + _("available"), + font=self.fonts.base.font, + fill=self.colors.get(128), ) - return self.screen_update() + self._draw_refresh_status() + return - # If we are here, go for update! - self._go_for_update = True - self.draw.text( - (10, 90), - _("Update Now"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - self.draw.text( - (10, 105), - _("Cancel"), - font=self.fonts.large.font, - fill=self.colors.get(255), - ) - if self._option_select == "Update": - ind_pos = msg_top + label_width = self.fonts.base.line_length - 2 + list_bottom = 114 if (self._checking or self._check_failed) else 128 + current_y = y + for i in range(len(self._version_list)): + idx = self._scroll_offset + i + if idx >= len(self._version_list): + break + entry = self._version_list[idx] + prefix, text = _entry_row_parts(entry) + + if self._focus == "list" and idx == self._list_index: + if current_y + 24 > list_bottom: + break + self.draw.text( + (0, current_y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + # The prefix (PR number / trunk dot) stays fixed; only the + # title scrolls after it. + text_x = 10 + scroll_width = label_width + if prefix: + self.draw.text( + (text_x, current_y), + prefix, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + text_x += int(self.fonts.bold.width * len(prefix)) + scroll_width = max(1, label_width - len(prefix)) + scroller = self._get_scroller( + "browse_label", + text, + self.fonts.bold, + self.colors.get(255), + scroll_width, + ) + scroller.draw((text_x, current_y)) + current_y += 12 + detail = _entry_detail(entry) + if detail: + sub_scroller = self._get_scroller( + "browse_sub", + detail, + self.fonts.base, + self.colors.get(255), + label_width, + ) + sub_scroller.draw((10, current_y)) + current_y += 12 + else: + # Unfocused rows stay dim so the selected row and its detail + # line carry the visual weight. + if current_y + 12 > list_bottom: + break + # The trunk ("main") row stands out from the PR rows: bold and + # brighter, with a leading dot. + if entry.get("is_trunk"): + self.draw.text( + (10, current_y), + f"{prefix}{text}"[:label_width], + font=self.fonts.bold.font, + fill=self.colors.get(192), + ) + else: + self.draw.text( + (10, current_y), + f"{prefix}{text}"[:label_width], + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + current_y += 12 + + self._draw_refresh_status() + + def _draw_refresh_status(self): + """Bottom-line indicator for the background manifest refresh.""" + if self._checking: + dots = "." * ((self._elipsis_count // 10) % 4) + text = _("checking for updates") + dots + self._elipsis_count += 1 + if self._elipsis_count > 39: + self._elipsis_count = 0 + elif self._check_failed: + text = _("update check failed") else: - ind_pos = msg_bottom + return self.draw.text( - (0, ind_pos), - self._RIGHT_ARROW, - font=self.fonts.large.font, - fill=self.colors.get(255), + (4, 115), + text, + font=self.fonts.base.font, + fill=self.colors.get(96), ) - return self.screen_update() - - def toggle_option(self): - if not self._go_for_update: - return - if self._option_select == "Update": - self._option_select = "Cancel" - else: - self._option_select = "Update" - - def key_square(self): - self._record_key("square") - - def key_up(self): - self.toggle_option() - - def key_down(self): - self.toggle_option() - - def key_right(self): - if self._option_select == "Cancel": - self.remove_from_stack() - else: - self.update_software() - - -class UIMigrationConfirm(UIModule): - """ - Warning screen before initiating NixOS migration. - Shows version info, warns about irreversibility, requires confirmation. - """ - - __title__ = "UPGRADE" - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._version_info = self.item_definition.get("version_info", {}) - self._current_version = self.item_definition.get("current_version", "?") - self._target_version = self._version_info.get("version", "?") - self._option_index = 0 - self._options = [_("Confirm"), _("Cancel")] - - def update(self, force=False): - time.sleep(1 / 30) - self.clear_screen() + def _draw_confirm(self): y = self.display_class.titlebar_height + 2 self.draw.text( (0, y), - _("Major Upgrade"), - font=self.fonts.bold.font, - fill=self.colors.get(255), + _("Update to:"), + font=self.fonts.base.font, + fill=self.colors.get(128), ) y += 14 - self.draw.text( - (5, y), - f"{self._current_version} -> {self._target_version}", - font=self.fonts.bold.font, - fill=self.colors.get(192), + label_width = self.fonts.base.line_length + version_label = ( + self._selected_version.get("version") or self._selected_version["label"] ) - y += 16 - - # Separator - self.draw.line([(0, y), (127, y)], fill=self.colors.get(64)) - y += 4 - - self.draw.text( - (0, y), - _("IRREVERSIBLE"), - font=self.fonts.bold.font, - fill=self.colors.get(255), + scroller = self._get_scroller( + "confirm_label", + version_label, + self.fonts.bold, + self.colors.get(255), + label_width, ) + scroller.draw((0, y)) y += 12 - size_mb = self._version_info.get("migration_size_mb", "?") - self.draw.text( - (0, y), - _("Download: {size}MB").format(size=size_mb), - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - y += 11 - - self.draw.text( - (0, y), - _("Power + WiFi req"), - font=self.fonts.base.font, - fill=self.colors.get(128), - ) - y += 11 + subtitle = self._selected_version.get("subtitle", "") + if subtitle: + sub_scroller = self._get_scroller( + "confirm_sub", + subtitle, + self.fonts.base, + self.colors.get(128), + label_width, + ) + sub_scroller.draw((0, y)) + y += 14 - if not self._version_info.get( - "migration_sha256_url" - ) and not self._version_info.get("migration_sha256"): + age = _format_age(self._selected_version.get("built_at")) + if age: self.draw.text( (0, y), - _("No checksum avail."), + _("built {age}").format(age=age), font=self.fonts.base.font, fill=self.colors.get(128), ) y += 11 - y += 5 + self._draw_separator(y) + y += 4 - # Options - for i, label in enumerate(self._options): - oy = y + i * 12 + for i, opt in enumerate(self._confirm_options): + item_y = y + i * 12 + if i == self._confirm_index: + self.draw.text( + (0, item_y), + self._RIGHT_ARROW, + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (10, item_y), + _(opt), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + else: + self.draw.text( + (10, item_y), + _(opt), + font=self.fonts.base.font, + fill=self.colors.get(192), + ) + + def _draw_failed(self): + y = self.display_class.titlebar_height + 20 + reason = self._fail_reason or _("Update failed!") + for line in reason.split("\n"): self.draw.text( - (10, oy), - label, + (10, y), + line, font=self.fonts.bold.font, fill=self.colors.get(255), ) - if i == self._option_index: + y += 14 + y += 6 + for label in ("Retry", "Cancel"): + if self._fail_option == label: self.draw.text( - (0, oy), + (0, y), self._RIGHT_ARROW, font=self.fonts.bold.font, fill=self.colors.get(255), ) + self.draw.text( + (10, y), + _(label), + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + y += 12 - return self.screen_update() + # ------------------------------------------------------------------ + # Main update loop + # ------------------------------------------------------------------ - def key_up(self): - self._option_index = (self._option_index - 1) % len(self._options) + def update(self, force=False): + self.clear_screen() - def key_down(self): - self._option_index = (self._option_index + 1) % len(self._options) + if self._phase == "upgrading": + self._draw_upgrading() + return self.screen_update() - def key_left(self): - return True + if self._phase == "failed": + self._draw_failed() + return self.screen_update() - def key_right(self): - if self._options[self._option_index] == _("Cancel"): - self.remove_from_stack() - elif self._options[self._option_index] == _("Confirm"): - self.add_to_stack( - { - "class": UIMigrationProgress, - "version_info": self._version_info, - } - ) + if self._wifi_mode != "Client": + self._draw_wifi_warning() + return self.screen_update() + self._consume_refresh_result() -class UIMigrationProgress(UIModule): - """ - Migration download and preparation progress screen. - Triggers the actual migration via sys_utils. - """ + if self._phase == "loading": + self._draw_loading() + return self.screen_update() - __title__ = "UPGRADE" + if self._phase == "offline": + self._draw_offline() + return self.screen_update() - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._version_info = self.item_definition.get("version_info", {}) - self._started = False - self._status = _("Starting...") - self._progress = 0 - self._terminal_failure = False - self._status_layout = TextLayouter( - self._status, - draw=self.draw, - color=self.colors.get(255), - colors=self.colors, - font=self.fonts.base, - available_lines=4, - ) + if self._phase == "browse": + self._draw_browse() + elif self._phase == "confirm": + self._draw_confirm() - def active(self): - super().active() - if not self._started: - self._started = True - self._start_migration() + return self.screen_update() - def _start_migration(self): - """Kick off the migration process in the background.""" - self._status = _("Downloading...") - try: - version_info = dict(self._version_info) - version_info["display_class"] = self.display_class.__class__.__name__ - version_info["display_resolution"] = list(self.display_class.resolution) - supported_displays = { - "DisplaySSD1351": (128, 128), - "DisplaySSD1333": (176, 176), - } - display_class = version_info["display_class"] - display_resolution = tuple(version_info["display_resolution"]) - display_supported = ( - supported_displays.get(display_class) == display_resolution - ) - display_supported = display_supported or ( - "SSD1333" in display_class and display_resolution == (176, 176) - ) - if not display_supported: - logger.error( - "Unsupported migration progress renderer display: " - f"{display_class} {version_info['display_resolution']}" + # ------------------------------------------------------------------ + # Key handlers + # ------------------------------------------------------------------ + + def key_up(self): + if self._phase == "upgrading": + return + if self._phase == "failed": + self._fail_option = "Cancel" if self._fail_option == "Retry" else "Retry" + elif self._phase == "browse": + if self._focus == "list": + if self._list_index == 0: + self._focus = "channel" + else: + self._list_index -= 1 + if self._list_index < self._scroll_offset: + self._scroll_offset = self._list_index + elif self._phase == "confirm": + if self._confirm_index > 0: + self._confirm_index -= 1 + + def key_down(self): + if self._phase == "upgrading": + return + if self._phase == "failed": + self._fail_option = "Cancel" if self._fail_option == "Retry" else "Retry" + elif self._phase == "browse": + if self._focus == "channel": + if self._version_list: + self._focus = "list" + self._list_index = 0 + self._scroll_offset = 0 + elif self._focus == "list": + if self._list_index < len(self._version_list) - 1: + self._list_index += 1 + if self._list_index >= self._scroll_offset + self.MAX_VISIBLE: + self._scroll_offset = self._list_index - self.MAX_VISIBLE + 1 + elif self._phase == "confirm": + if self._confirm_index < len(self._confirm_options) - 1: + self._confirm_index += 1 + + def key_right(self): + if self._phase == "upgrading": + return + if self._phase == "failed": + if self._fail_option == "Retry": + # Re-enters update_software() → "upgrading" phase, so Retry + # reuses the same progress UI as the first attempt. + self.update_software() + else: + self.remove_from_stack() + elif self._phase == "browse": + if self._focus == "channel" and self._channel_names: + self._channel_index = (self._channel_index + 1) % len( + self._channel_names ) - self._status = _("Not supported") - return - sys_utils.start_nixos_migration(version_info) - except AttributeError: - logger.error("sys_utils.start_nixos_migration not available") - self._status = _("Not supported") - self._status_layout.set_text(self._status) - self._terminal_failure = True - except Exception as e: - logger.error(f"Migration failed to start: {e}") - self._status = _("Failed: ") + str(e) - self._status_layout.set_text(self._status) - self._terminal_failure = True + self._refresh_version_list() + elif self._focus == "list" and self._version_list: + self._selected_version = self._version_list[self._list_index] + self._confirm_options = [] + if not self._selected_version.get("unavailable"): + self._confirm_options.append("Install") + if self._selected_version.get("notes"): + self._confirm_options.append("Notes") + self._confirm_options.append("Cancel") + self._confirm_index = 0 + self._phase = "confirm" + elif self._phase == "confirm": + opt = self._confirm_options[self._confirm_index] + if opt == "Install": + self.update_software() + elif opt == "Notes": + notes = self._selected_version.get("notes") + if notes: + self.add_to_stack({"class": UIReleaseNotes, "notes_text": notes}) + elif opt == "Cancel": + self._phase = "browse" - def update(self, force=False): - time.sleep(1 / 30) + def key_left(self): + if self._phase == "upgrading": + return False + if self._phase == "confirm": + self._phase = "browse" + return False + return True + + def key_square(self): + # Manual refresh: re-fetch the manifest with the same feedback as the + # automatic check on entry — the bottom status line in browse, the + # full "Checking for updates" screen when currently offline. + if self._phase == "browse": + self._start_refresh() + elif self._phase == "offline": + self._phase = "loading" + self._elipsis_count = 0 + self._start_refresh() + + # ------------------------------------------------------------------ + # Update action + # ------------------------------------------------------------------ + + def update_software(self): + if not self._selected_version: + return + if self._selected_version.get("unavailable"): + self._phase = "failed" + self._fail_reason = _("Version no\nlonger available") + self._fail_option = "Cancel" + return + self._phase = "upgrading" self.clear_screen() + self._draw_upgrading() + self.screen_update() + + ref = self._selected_version.get("ref") or "release" + selection = { + "ref": ref, + "label": self._selected_version.get("label"), + "version": self._selected_version.get("version"), + "channel": self._selected_version.get("channel"), + } + if not sys_utils.update_software(ref=ref, selection=selection): + self._phase = "failed" + self._fail_option = "Retry" + + def _draw_upgrading(self): y = self.display_class.titlebar_height + 2 - # Try to read progress from sys_utils. AttributeError happens when - # running against sys_utils_fake (no migration support); the helper - # itself swallows OS/JSON errors and returns {}. - try: - progress = sys_utils.get_migration_progress() - except AttributeError: - progress = None - if progress: - try: - self._progress = int(progress.get("percent", self._progress)) - except (TypeError, ValueError): - pass # bad/missing percent — keep prior value - new_status = progress.get("status", self._status) - if isinstance(new_status, str) and new_status != self._status: - self._status = new_status - self._status_layout.set_text(self._status) + progress = sys_utils.get_upgrade_progress() + phase = progress["phase"] + pct = progress["percent"] + done = progress["done"] + total = progress["total"] + unit = progress.get("unit", "bytes") + + if phase in ("failed", "unavailable", "connfail"): + if phase == "unavailable": + self._fail_reason = _("Version no\nlonger available") + elif phase == "connfail": + self._fail_reason = _("Can't reach\nupdate server") + else: + self._fail_reason = _("Update failed!") + self._phase = "failed" + self._fail_option = "Retry" + return + + # Title + if phase == "rebooting": + label = _("Rebooting...") + elif phase == "activating": + label = _("Activating...") + elif phase == "starting": + label = _("Preparing...") + else: + label = _("Downloading...") self.draw.text( (0, y), - _("System Upgrade"), + label, font=self.fonts.bold.font, fill=self.colors.get(255), ) @@ -637,17 +1137,21 @@ def update(self, force=False): # Progress bar bar_x, bar_w, bar_h = 4, 120, 12 + # Background fill so bar is always visible self.draw.rectangle( [bar_x, y, bar_x + bar_w, y + bar_h], - outline=self.colors.get(64), + fill=self.colors.get(48), + outline=self.colors.get(128), ) - fill_w = int(bar_w * self._progress / 100) + fill_w = int(bar_w * pct / 100) if fill_w > 0: self.draw.rectangle( [bar_x + 1, y + 1, bar_x + fill_w, y + bar_h - 1], fill=self.colors.get(255), ) - pct_text = f"{self._progress}%" + + # Percentage centered on bar + pct_text = f"{pct}%" pct_bbox = self.fonts.base.font.getbbox(pct_text) pct_w = pct_bbox[2] - pct_bbox[0] pct_h = pct_bbox[3] - pct_bbox[1] @@ -657,44 +1161,46 @@ def update(self, force=False): (pct_x, pct_y), pct_text, font=self.fonts.base.font, - fill=self.colors.get(0) if self._progress > 45 else self.colors.get(192), + fill=self.colors.get(0) if pct > 45 else self.colors.get(192), ) - y += bar_h + 4 - - # Use TextLayouter for scrollable status text - self._status_layout.draw((0, y)) - - return self.screen_update() - - def key_up(self): - self._status_layout.previous() + y += bar_h + 6 - def key_down(self): - self._status_layout.next() - - def key_left(self): - # Allow exit only if the migration never actually started (e.g., - # pre-flight refused due to missing checksum or unsupported display). - # Once the bash script is running, going back is unsafe. - if self._terminal_failure: - self.remove_from_stack() - return True - return False + # Amount below the bar: megabytes downloaded out of the total, or a + # path count in the fallback case where byte sizes were unavailable. + if phase == "downloading" and total > 0: + if unit == "bytes": + amount_text = f"{done / 1048576:.0f}/{total / 1048576:.0f} MB" + else: + amount_text = f"{done}/{total} paths" + self.draw.text( + (4, y), + amount_text, + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + # Name the package currently being copied, if known. + item = progress.get("item", "") + if item: + self.draw.text( + (4, y + 12), + item[:22], + font=self.fonts.base.font, + fill=self.colors.get(96), + ) class UIReleaseNotes(UIModule): """ Scrollable release notes viewer. - Fetches markdown from a URL and displays as plain text. + Accepts markdown text directly via notes_text in item_definition. """ __title__ = "NOTES" def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self._notes_url = self.item_definition.get("notes_url", "") + self._notes_text = self.item_definition.get("notes_text", "") self._loaded = False - self._error = False self._text_layout = TextLayouter( "", draw=self.draw, @@ -707,38 +1213,31 @@ def __init__(self, *args, **kwargs) -> None: def active(self): super().active() if not self._loaded: - self._fetch_notes() - - def _fetch_notes(self): - """Fetch release notes from the configured URL.""" - try: - res = requests.get(self._notes_url, timeout=REQUEST_TIMEOUT) - if res.status_code == 200: - text = _strip_markdown(res.text) - self._text_layout.set_text(text) - self._loaded = True - else: - self._error = True - logger.warning(f"Failed to fetch release notes: HTTP {res.status_code}") - except requests.exceptions.RequestException as e: - self._error = True - logger.warning(f"Failed to fetch release notes: {e}") + self._load_notes() + + def _load_notes(self): + """Process notes text for display.""" + if self._notes_text: + text = _strip_markdown(self._notes_text) + self._text_layout.set_text(text) + self._loaded = True + else: + self._loaded = True def update(self, force=False): - time.sleep(1 / 30) self.clear_screen() draw_pos = self.display_class.titlebar_height + 2 - if self._error: + if not self._notes_text: self.draw.text( (10, draw_pos + 20), - _("Could not load"), + _("No release notes"), font=self.fonts.large.font, fill=self.colors.get(255), ) self.draw.text( (10, draw_pos + 35), - _("release notes"), + _("available"), font=self.fonts.large.font, fill=self.colors.get(255), ) diff --git a/python/PiFinder/ui/sqm.py b/python/PiFinder/ui/sqm.py index 8ccc9ab21..589bed219 100644 --- a/python/PiFinder/ui/sqm.py +++ b/python/PiFinder/ui/sqm.py @@ -192,7 +192,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/text_menu.py b/python/PiFinder/ui/text_menu.py index feba282ba..3fbbe1ce7 100644 --- a/python/PiFinder/ui/text_menu.py +++ b/python/PiFinder/ui/text_menu.py @@ -61,24 +61,31 @@ def __init__( _("Select None") ] + self._menu_items # TRANSLATORS: catalog filter deselect all else: - stored_value = self.config_object.get_option(config_option) - if stored_value is None and self.item_definition.get("value_callback"): - # The option is unset and this menu knows how to work out - # its own default (typically from detected hardware, where - # a single written-down default would be wrong on some - # devices). Keep what value_callback already resolved. - stored_value = ( - self._selected_values[0] if self._selected_values else None - ) - self._selected_values = [stored_value] - if self._selected_values == [None]: - # default to the first option... just in case - self._selected_values = [self.item_definition["items"][0]["value"]] - - # Set current item index based on selection - for i, _item in enumerate(self.item_definition["items"]): - if _item["value"] == self._selected_values[0]: - self._current_item_index = i + self._sync_single_selection(config_option) + + def _sync_single_selection(self, config_option): + """Match the highlight/checkmark to the stored config value.""" + stored_value = self.config_object.get_option(config_option) + if stored_value is None and self.item_definition.get("value_callback"): + # Hardware-dependent menus calculate their default through the + # callback; preserve that value until the option is explicitly set. + stored_value = self._selected_values[0] if self._selected_values else None + self._selected_values = [stored_value] + if self._selected_values == [None]: + # default to the first option... just in case + self._selected_values = [self.item_definition["items"][0]["value"]] + + # Set current item index based on selection + for i, _item in enumerate(self.item_definition["items"]): + if _item["value"] == self._selected_values[0]: + self._current_item_index = i + + def active(self): + # Re-read config so the highlight tracks values changed while a + # submenu was open (e.g. returning from Fallback/Custom shape pickers). + config_option = self.item_definition.get("config_option") + if config_option and self._menu_type != "multi": + self._sync_single_selection(config_option) def update(self, force=False): # clear screen 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..8ba6b5c51 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,163 @@ pifinder_dir = Path(__file__).resolve().parents[2] assert (pifinder_dir / "astro_data").is_dir(), f"repo root not at {pifinder_dir}" astro_data_dir = pifinder_dir / "astro_data" -tetra3_dir = pifinder_dir / "python/PiFinder/tetra3/tetra3" data_dir = Path(Path.home(), "PiFinder_data") pifinder_db = astro_data_dir / "pifinder_objects.db" observations_db = data_dir / "observations.db" +# The device's single identity file: seeded with the store path at image +# build, rewritten (with version/label/channel) by every upgrade. Human +# version labels come from the update manifest, which maps store paths to +# versions — the retired pifinder-build.json duplicated that mapping. +current_build_json = Path("/var/lib/pifinder/current-build.json") +# The booted system's own toplevel — ground truth for "what is actually +# running". current_build_json is written at upgrade time, before the reboot, +# so it can outlive a failed boot or a rollback; this symlink cannot. +running_system_link = Path("/run/current-system") + + +def sd_notify(state: str) -> None: + """Send a systemd service notification (e.g. "READY=1"). + + The app's readiness signal is what the boot watchdog's health check keys + off (pifinder.service is Type=notify). Outside systemd — development + runs, tests — NOTIFY_SOCKET is unset and this is a silent no-op; a + notification failure must never be able to break the app itself. + """ + addr = os.environ.get("NOTIFY_SOCKET") + if not addr: + return + if addr.startswith("@"): + # Abstract-namespace socket (leading @ in the env var) + addr = "\0" + addr[1:] + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock: + sock.connect(addr) + sock.sendall(state.encode()) + except OSError as e: + logger.warning("sd_notify failed (harmless outside systemd): %s", e) + + +def running_system_store_path() -> Optional[str]: + """Store path the system is actually running, or None when unavailable. + + Reflects the booted generation, so it stays correct across a failed reboot, + a manual rollback, or a watchdog revert — cases where current_build_json, + written before the reboot, still names the build that was merely selected. + None off-device (no /run/current-system symlink into the Nix store). + """ + try: + if not running_system_link.is_symlink(): + return None + resolved = os.path.realpath(running_system_link) + except OSError: + return None + return resolved if resolved.startswith("/nix/store/") else None + + +def build_is_running(store_path: Optional[str]) -> bool: + """Whether store_path is the running system, directly or as the base of the + running camera specialisation. + + A camera specialisation boots /specialisation/ — a different + store path from the recorded base — so a plain equality check would flag + every specialised device as stale. When the running path can't be read + (e.g. off-device), assume a match rather than cry stale. + """ + if not store_path: + return False + running = running_system_store_path() + if running is None: + return True + if os.path.realpath(store_path) == running: + return True + spec_dir = os.path.join(store_path, "specialisation") + try: + specs = os.listdir(spec_dir) + except OSError: + return False + return any( + os.path.realpath(os.path.join(spec_dir, name)) == running for name in specs + ) + + +def _store_path_hash(store_path: str) -> str: + """The 8-char store-hash prefix of a build, or "Unknown".""" + name = store_path.rsplit("/", 1)[-1] + return name.split("-", 1)[0][:8] if name else "Unknown" + + +def get_version() -> str: + """Best available version string for the running build. + + The upgrade service writes an explicit version; a freshly-flashed image + only knows its store path, so fall back to its hash prefix (the update + screen upgrades that to a proper label once the manifest is fetched). + + current_build_json is written before the reboot, so its label can be stale + (failed boot, rollback). When it doesn't describe the running system, ignore + the label and report the running build's hash rather than assert an identity + the device isn't running. + """ + try: + with open(current_build_json, "r") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return "Unknown" + store_path = data.get("store_path") or "" + if store_path and not build_is_running(store_path): + running = running_system_store_path() + return _store_path_hash(running) if running else "Unknown" + version = data.get("version") + if version: + return version + return _store_path_hash(store_path) if store_path else "Unknown" + + debug_dump_dir = data_dir / "solver_debug_dumps" -comet_file = astro_data_dir / Path("comets.txt") +comet_file = data_dir / "comets.txt" +asteroid_data_dir = data_dir / "asteroids" + +# Logging-config presets ship read-only in the source tree; the user's active +# selection is persisted in the writable data dir (like config.json), stored as +# a bare filename so it survives upgrades (no immutable store path is baked in). +logconf_dir = pifinder_dir / "python" +_active_logconf_file = data_dir / "log_config" +DEFAULT_LOGCONF = "logconf_default.json" + + +def _valid_logconf_name(name: str) -> bool: + return ( + name.startswith("logconf_") + and name.endswith(".json") + and (logconf_dir / name).is_file() + ) + + +def active_logconf_name() -> str: + """Name of the active logging-config preset (defaults to logconf_default.json).""" + try: + name = _active_logconf_file.read_text().strip() + except OSError: + return DEFAULT_LOGCONF + return name if _valid_logconf_name(name) else DEFAULT_LOGCONF + + +def active_logconf_path() -> Path: + """Absolute path to the active logging-config file in the source tree.""" + return logconf_dir / active_logconf_name() + + +def available_logconfs() -> list: + """Sorted bare filenames of the available logconf_*.json presets.""" + return sorted(p.name for p in logconf_dir.glob("logconf_*.json")) + + +def set_active_logconf(name: str) -> None: + """Persist the chosen logging-config preset name to the writable data dir.""" + if not _valid_logconf_name(name): + raise ValueError(f"Invalid log config: {name}") + _active_logconf_file.parent.mkdir(parents=True, exist_ok=True) + _active_logconf_file.write_text(name + "\n") def create_dir(adir: str): @@ -167,23 +321,28 @@ def serialize_solution(solution) -> str: return json.dumps(out_dict) +_sys_utils_module = None + + def get_sys_utils(): - # Check if we should use fake sys_utils for local development - use_fake = os.environ.get("PIFINDER_USE_FAKE_SYS_UTILS", "").lower() in ( - "1", - "true", - "yes", - ) + global _sys_utils_module + if _sys_utils_module is not None: + return _sys_utils_module - if use_fake: - sys_utils = importlib.import_module("PiFinder.sys_utils_fake") - else: - try: - # Attempt to import the real sys_utils - sys_utils = importlib.import_module("PiFinder.sys_utils") - except ImportError: - sys_utils = importlib.import_module("PiFinder.sys_utils_fake") - return sys_utils + try: + _sys_utils_module = importlib.import_module("PiFinder.sys_utils") + except Exception as exc: + logger.info( + "Running without on-device system controls (%s). This is normal " + "on a desktop/dev machine: WiFi/AP/hostname/reboot options are " + "stubbed out, everything else works as usual. On the PiFinder " + "hardware these controls are available automatically.", + exc, + ) + logger.debug("PiFinder.sys_utils import failed", exc_info=True) + _sys_utils_module = importlib.import_module("PiFinder.sys_utils_fake") + + return _sys_utils_module def get_os_info(): diff --git a/python/locale/de/LC_MESSAGES/messages.mo b/python/locale/de/LC_MESSAGES/messages.mo index 97d492617..2007e769c 100644 Binary files a/python/locale/de/LC_MESSAGES/messages.mo and b/python/locale/de/LC_MESSAGES/messages.mo differ diff --git a/python/locale/de/LC_MESSAGES/messages.po b/python/locale/de/LC_MESSAGES/messages.po index cfa56ccee..ab6751b22 100644 --- a/python/locale/de/LC_MESSAGES/messages.po +++ b/python/locale/de/LC_MESSAGES/messages.po @@ -3216,6 +3216,20 @@ msgstr "Hochladen und wiederherstellen" msgid "Restore User Data" msgstr "Benutzerdaten wiederherstellen" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:527 +msgid "" +"Network settings updated — no restart needed. This device is now " +"reachable at http://{host}.local. If you changed the host name, the " +"previous address stops working, so reconnect there." +msgstr "" +"Netzwerkeinstellungen aktualisiert – kein Neustart nötig. Dieses Gerät ist jetzt unter http://{host}.local erreichbar. Wenn Sie den Hostnamen geändert haben, funktioniert die vorherige Adresse nicht mehr – verbinden Sie sich dort neu." + +# AI-TRANSLATED (claude): needs human review +#: views/network.html:39 +msgid "Update" +msgstr "Aktualisieren" + # AI-TRANSLATED (claude): needs human review #: views/tools.html:93 msgid "" diff --git a/python/locale/es/LC_MESSAGES/messages.mo b/python/locale/es/LC_MESSAGES/messages.mo index 6bb39cd75..8c410a17b 100644 Binary files a/python/locale/es/LC_MESSAGES/messages.mo and b/python/locale/es/LC_MESSAGES/messages.mo differ diff --git a/python/locale/es/LC_MESSAGES/messages.po b/python/locale/es/LC_MESSAGES/messages.po index 60911295e..26465a8cd 100644 --- a/python/locale/es/LC_MESSAGES/messages.po +++ b/python/locale/es/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-07-30 18:03-0700\n" +"POT-Creation-Date: 2026-06-22 09:17-0700\n" "PO-Revision-Date: 2025-01-22 17:58+0100\n" "Last-Translator: Claude Code\n" "Language: es\n" @@ -23,7 +23,7 @@ msgid "No Image" msgstr "Sin Imagen" # AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:703 +#: PiFinder/main.py:648 msgid "" "Degraded\n" "Check Status" @@ -32,7 +32,7 @@ msgstr "" "Ver Estado" # AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:810 +#: PiFinder/main.py:750 msgid "" "Catalogs\n" "Fully Loaded" @@ -40,285 +40,235 @@ msgstr "" "Catálogos\n" "Cargados" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:860 -msgid "" -"Low battery\n" -"Shutting down" -msgstr "" -"Batería baja\n" -"Apagando" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:893 -msgid "" -"Low battery\n" -"at {pct}%" -msgstr "" -"Batería baja\n" -"al {pct}%" - -#: PiFinder/obj_types.py:10 +#: PiFinder/obj_types.py:7 PiFinder/ui/menu_structure.py:422 msgid "Galaxy" msgstr "Galaxia" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:11 +#: PiFinder/obj_types.py:8 PiFinder/ui/menu_structure.py:426 msgid "Open Cluster" msgstr "Cúmulo Abierto" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:12 -msgid "Cluster + Neb" -msgstr "Cúmulo + Neb" - -#. TRANSLATORS: Object type -#: PiFinder/obj_types.py:13 +#: PiFinder/obj_types.py:9 PiFinder/ui/menu_structure.py:434 msgid "Globular" msgstr "Globular" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:14 +#: PiFinder/obj_types.py:10 PiFinder/ui/menu_structure.py:438 msgid "Nebula" msgstr "Nebulosa" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:15 -msgid "Planetary" -msgstr "Planetaria" - -#. TRANSLATORS: Object type -#: PiFinder/obj_types.py:16 +#: PiFinder/obj_types.py:11 PiFinder/ui/menu_structure.py:446 msgid "Dark Nebula" msgstr "Nebulosa Oscura" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:17 -msgid "Star" -msgstr "Estrella" +#: PiFinder/obj_types.py:12 +msgid "Planetary" +msgstr "Planetaria" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:18 -msgid "Double star" -msgstr "Estrella Doble" +#: PiFinder/obj_types.py:13 +msgid "Cluster + Neb" +msgstr "Cúmulo + Neb" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:19 -msgid "Triple star" -msgstr "Estrella Triple" +#: PiFinder/obj_types.py:14 PiFinder/ui/menu_structure.py:466 +msgid "Asterism" +msgstr "Asterismo" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:20 +#: PiFinder/obj_types.py:15 PiFinder/ui/menu_structure.py:462 msgid "Knot" msgstr "Nudo" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:21 -msgid "Asterism" -msgstr "Asterismo" +#: PiFinder/obj_types.py:16 +msgid "Triple star" +msgstr "Estrella Triple" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:22 -msgid "Planet" -msgstr "Planeta" +#: PiFinder/obj_types.py:17 +msgid "Double star" +msgstr "Estrella Doble" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:23 -msgid "Comet" -msgstr "Cometa" +#: PiFinder/obj_types.py:18 PiFinder/ui/menu_structure.py:450 +msgid "Star" +msgstr "Estrella" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:24 +#: PiFinder/obj_types.py:19 msgid "Unkn" msgstr "Desc" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:54 -#, python-format -msgid "%s is required" -msgstr "%s es obligatorio" +#. TRANSLATORS: Object type +#: PiFinder/obj_types.py:20 PiFinder/ui/menu_structure.py:470 +msgid "Planet" +msgstr "Planeta" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:58 -#, python-format -msgid "%s must be a number" -msgstr "%s debe ser un número" +#. TRANSLATORS: Object type +#: PiFinder/obj_types.py:21 PiFinder/ui/menu_structure.py:474 +msgid "Comet" +msgstr "Cometa" -#: PiFinder/server.py:231 +#: PiFinder/server.py:219 msgid "Locked" msgstr "Bloqueado" -#: PiFinder/server.py:231 +#: PiFinder/server.py:219 msgid "Not Locked" msgstr "No Bloqueado" -#: PiFinder/server.py:253 views/base.html:17 views/base.html:28 +#: PiFinder/server.py:241 views/base.html:17 views/base.html:28 msgid "Home" msgstr "Inicio" -#: PiFinder/server.py:281 PiFinder/server.py:289 views/login.html:31 +#: PiFinder/server.py:269 PiFinder/server.py:277 views/login.html:31 msgid "Login" msgstr "Iniciar Sesión" -#: PiFinder/server.py:283 +#: PiFinder/server.py:271 msgid "Invalid Password" msgstr "Contraseña Inválida" -#: PiFinder/server.py:295 views/base.html:18 views/base.html:29 +#: PiFinder/server.py:283 views/base.html:18 views/base.html:29 msgid "Remote" msgstr "Remoto" -#: PiFinder/server.py:301 PiFinder/ui/menu_structure.py:995 +#: PiFinder/server.py:289 PiFinder/ui/menu_structure.py:1033 msgid "Advanced" msgstr "Avanzado" -#: PiFinder/server.py:310 +#: PiFinder/server.py:298 msgid "Network" msgstr "Red" -#: PiFinder/server.py:328 +#: PiFinder/server.py:316 msgid "GPS" msgstr "GPS" -#: PiFinder/server.py:362 PiFinder/server.py:415 PiFinder/server.py:468 +#: PiFinder/server.py:350 PiFinder/server.py:401 PiFinder/server.py:452 #: views/base.html:21 views/base.html:32 msgid "Locations" msgstr "Ubicaciones" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:372 PiFinder/server.py:432 views/locations.html:63 -msgid "Latitude" -msgstr "Latitud" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:373 PiFinder/server.py:433 views/locations.html:64 -msgid "Longitude" -msgstr "Longitud" - -#: PiFinder/server.py:374 PiFinder/server.py:434 -#: PiFinder/ui/menu_structure.py:431 views/locations.html:65 -msgid "Altitude" -msgstr "Altitud" - -#: PiFinder/server.py:376 PiFinder/server.py:436 PiFinder/ui/object_list.py:309 -#: views/locations.html:66 -msgid "Error" -msgstr "Error" - -#: PiFinder/server.py:382 PiFinder/server.py:442 +#: PiFinder/server.py:368 PiFinder/server.py:426 msgid "Location name is required" msgstr "El nombre de ubicación es requerido" -#: PiFinder/server.py:384 PiFinder/server.py:444 +#: PiFinder/server.py:370 PiFinder/server.py:428 msgid "Latitude must be between -90 and 90" msgstr "La latitud debe estar entre -90 y 90" -#: PiFinder/server.py:386 PiFinder/server.py:446 +#: PiFinder/server.py:372 PiFinder/server.py:430 msgid "Longitude must be between -180 and 180" msgstr "La longitud debe estar entre -180 y 180" -#: PiFinder/server.py:389 PiFinder/server.py:449 +#: PiFinder/server.py:375 PiFinder/server.py:433 msgid "Altitude must be between -1000 and 10000 meters" msgstr "La altitud debe estar entre -1000 y 10000 metros" -#: PiFinder/server.py:392 PiFinder/server.py:452 +#: PiFinder/server.py:378 PiFinder/server.py:436 msgid "Error must be between 0 and 10000 meters" msgstr "El error debe estar entre 0 y 10000 metros" -#: PiFinder/server.py:539 PiFinder/ui/menu_structure.py:1283 +#: PiFinder/server.py:523 PiFinder/ui/menu_structure.py:1302 msgid "Restart" msgstr "Reiniciar" -#: PiFinder/server.py:550 PiFinder/server.py:559 PiFinder/server.py:563 -#: PiFinder/server.py:567 PiFinder/server.py:922 -#: PiFinder/ui/menu_structure.py:1151 views/base.html:23 views/base.html:34 +#: PiFinder/server.py:534 PiFinder/server.py:543 PiFinder/server.py:547 +#: PiFinder/server.py:551 PiFinder/server.py:906 +#: PiFinder/ui/menu_structure.py:1173 views/base.html:23 views/base.html:34 #: views/tools.html:6 msgid "Tools" msgstr "Herramientas" -#: PiFinder/server.py:551 +#: PiFinder/server.py:535 msgid "You must fill in all password fields" msgstr "Debe rellenar todos los campos de contraseña" -#: PiFinder/server.py:559 +#: PiFinder/server.py:543 msgid "Password Changed" msgstr "Contraseña Cambiada" -#: PiFinder/server.py:563 +#: PiFinder/server.py:547 msgid "Incorrect current password" msgstr "Contraseña actual incorrecta" -#: PiFinder/server.py:567 +#: PiFinder/server.py:551 msgid "New passwords do not match" msgstr "Las nuevas contraseñas no coinciden" -#: PiFinder/server.py:592 PiFinder/server.py:603 PiFinder/server.py:620 -#: PiFinder/server.py:702 PiFinder/server.py:758 PiFinder/server.py:771 -#: PiFinder/server.py:844 PiFinder/server.py:857 -#: PiFinder/ui/menu_structure.py:1156 views/base.html:22 views/base.html:33 +#: PiFinder/server.py:576 PiFinder/server.py:587 PiFinder/server.py:604 +#: PiFinder/server.py:686 PiFinder/server.py:742 PiFinder/server.py:755 +#: PiFinder/server.py:828 PiFinder/server.py:841 +#: PiFinder/ui/menu_structure.py:1178 views/base.html:22 views/base.html:33 #: views/equipment.html:6 msgid "Equipment" msgstr "Equipo" -#: PiFinder/server.py:609 +#: PiFinder/server.py:593 msgid "set as active instrument." msgstr "establecido como instrumento activo." -#: PiFinder/server.py:626 +#: PiFinder/server.py:610 msgid "set as active eyepiece." msgstr "establecido como ocular activo." -#: PiFinder/server.py:704 +#: PiFinder/server.py:688 msgid "Equipment Imported, restart your PiFinder to use this new data" msgstr "Equipo Importado, reinicia tu PiFinder para usar estos nuevos datos" -#: PiFinder/server.py:720 +#: PiFinder/server.py:704 msgid "Edit Eyepiece" msgstr "Editar Ocular" -#: PiFinder/server.py:760 +#: PiFinder/server.py:744 msgid "Eyepiece added, restart your PiFinder to use" msgstr "Ocular añadido, reinicia tu PiFinder para usar" -#: PiFinder/server.py:773 +#: PiFinder/server.py:757 msgid "Eyepiece Deleted, restart your PiFinder to remove from menu" msgstr "Ocular Eliminado, reinicia tu PiFinder para quitar del menú" -#: PiFinder/server.py:798 +#: PiFinder/server.py:782 msgid "Edit Instrument" msgstr "Editar Instrumento" -#: PiFinder/server.py:846 +#: PiFinder/server.py:830 msgid "Instrument Added, restart your PiFinder to use" msgstr "Instrumento Añadido, reinicia tu PiFinder para usar" -#: PiFinder/server.py:859 +#: PiFinder/server.py:843 msgid "Instrument Deleted, restart your PiFinder to remove from menu" msgstr "Instrumento Eliminado, reinicia tu PiFinder para quitar del menú" -#: PiFinder/server.py:887 views/base.html:20 views/base.html:31 +#: PiFinder/server.py:871 views/base.html:20 views/base.html:31 msgid "Observations" msgstr "Observaciones" -#: PiFinder/server.py:916 +#: PiFinder/server.py:900 msgid "Session Log" msgstr "Registro de Sesión" -#: PiFinder/server.py:927 PiFinder/server.py:1025 views/base.html:24 +#: PiFinder/server.py:911 PiFinder/server.py:1010 views/base.html:24 #: views/base.html:35 msgid "Logs" msgstr "Registros" -#: PiFinder/server.py:1025 +#: PiFinder/server.py:1010 msgid "Error creating log archive" msgstr "Error creando archivo de registros" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:1082 views/restart_pifinder.html:6 +#: PiFinder/server.py:1067 views/restart_pifinder.html:6 msgid "Restarting PiFinder" msgstr "Reiniciando PiFinder" -#: PiFinder/server.py:1144 +#: PiFinder/server.py:1129 msgid "Restart PiFinder" msgstr "Reiniciar PiFinder" @@ -348,21 +298,21 @@ msgstr "{icon} ELEGIR ESTRELLA" msgid "{icon} SAVE / 0 CANCEL" msgstr "{icon} GUARDAR / 0 CANC." -#: PiFinder/ui/align.py:279 PiFinder/ui/chart.py:421 +#: PiFinder/ui/align.py:279 PiFinder/ui/chart.py:273 msgid "Can't plot" msgstr "No se puede trazar" -#: PiFinder/ui/align.py:288 PiFinder/ui/chart.py:430 PiFinder/ui/log.py:166 -#: PiFinder/ui/object_list.py:331 PiFinder/ui/object_list.py:351 +#: PiFinder/ui/align.py:288 PiFinder/ui/chart.py:282 PiFinder/ui/log.py:166 +#: PiFinder/ui/object_list.py:286 PiFinder/ui/object_list.py:306 msgid "No Solve Yet" msgstr "Aún Sin Resolver" -#: PiFinder/ui/align.py:399 PiFinder/ui/object_details.py:682 +#: PiFinder/ui/align.py:399 PiFinder/ui/object_details.py:675 msgid "Aligning..." msgstr "Alineando..." #: PiFinder/ui/align.py:407 PiFinder/ui/align_daytime.py:310 -#: PiFinder/ui/object_details.py:690 +#: PiFinder/ui/object_details.py:683 msgid "Aligned!" msgstr "¡Alineado!" @@ -392,13 +342,7 @@ msgid "AUTO" msgstr "AUTO" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/base.py:300 PiFinder/ui/dateentry.py:166 -#: PiFinder/ui/locationentry.py:222 PiFinder/ui/timeentry.py:171 -msgid " Cancel" -msgstr " Cancelar" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:52 +#: PiFinder/ui/callbacks.py:51 msgid "" "Options for\n" "DIY PiFinders" @@ -406,37 +350,37 @@ msgstr "" "Opciones para\n" "PiFinders DIY" -#: PiFinder/ui/callbacks.py:67 +#: PiFinder/ui/callbacks.py:66 msgid "Filters Reset" msgstr "Filtros Restablecidos" -#: PiFinder/ui/callbacks.py:80 PiFinder/ui/menu_structure.py:1202 +#: PiFinder/ui/callbacks.py:79 PiFinder/ui/menu_structure.py:1221 msgid "Test Mode" msgstr "Modo de Prueba" -#: PiFinder/ui/callbacks.py:174 +#: PiFinder/ui/callbacks.py:161 msgid "Shutting Down" msgstr "Apagando" -#: PiFinder/ui/callbacks.py:183 PiFinder/ui/callbacks.py:191 +#: PiFinder/ui/callbacks.py:170 PiFinder/ui/callbacks.py:178 msgid "Restarting..." msgstr "Reiniciando..." -#: PiFinder/ui/callbacks.py:196 PiFinder/ui/callbacks.py:202 -#: PiFinder/ui/callbacks.py:208 +#: PiFinder/ui/callbacks.py:183 PiFinder/ui/callbacks.py:189 +#: PiFinder/ui/callbacks.py:195 msgid "Switching cam" msgstr "Cambiando cámara" -#: PiFinder/ui/callbacks.py:246 +#: PiFinder/ui/callbacks.py:233 msgid "WiFi to AP" msgstr "WiFi a Punto de Acceso" -#: PiFinder/ui/callbacks.py:252 +#: PiFinder/ui/callbacks.py:239 msgid "WiFi to Client" msgstr "WiFi a Cliente" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:275 +#: PiFinder/ui/callbacks.py:262 msgid "" "{lat:.2f}, {lon:.2f}\n" "{alt}m alt" @@ -445,22 +389,22 @@ msgstr "" "{alt}m alt" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:282 +#: PiFinder/ui/callbacks.py:269 msgid "Location Reset" msgstr "Restablecer ubicación" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:287 +#: PiFinder/ui/callbacks.py:274 msgid "Time/Date Reset" msgstr "Restablecer hora/fecha" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:294 +#: PiFinder/ui/callbacks.py:281 msgid "No location lock" msgstr "Sin bloqueo de ubicación" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:308 +#: PiFinder/ui/callbacks.py:295 msgid "" "Saved\n" "{name}" @@ -468,22 +412,22 @@ msgstr "" "Guardado\n" "{name}" -#: PiFinder/ui/callbacks.py:312 PiFinder/ui/gpsstatus.py:79 -#: PiFinder/ui/location_list.py:135 views/location_form.html:6 +#: PiFinder/ui/callbacks.py:299 PiFinder/ui/gpsstatus.py:79 +#: PiFinder/ui/location_list.py:137 views/location_form.html:6 #: views/locations.html:117 msgid "Location Name" msgstr "Nombre de Ubicación" -#: PiFinder/ui/callbacks.py:315 PiFinder/ui/gpsstatus.py:82 +#: PiFinder/ui/callbacks.py:302 PiFinder/ui/gpsstatus.py:82 msgid "Loc {number}" msgstr "Ubi {number}" -#: PiFinder/ui/callbacks.py:346 +#: PiFinder/ui/callbacks.py:329 msgid "Time: {time}" msgstr "Hora: {time}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:364 +#: PiFinder/ui/callbacks.py:347 msgid "" "{date}\n" "{time}" @@ -492,7 +436,7 @@ msgstr "" "{time}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:384 +#: PiFinder/ui/callbacks.py:367 msgid "" "User object created\n" "{name}" @@ -501,7 +445,7 @@ msgstr "" "{name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:514 +#: PiFinder/ui/callbacks.py:497 msgid "" "Checking GPS\n" "config..." @@ -510,7 +454,7 @@ msgstr "" "config. GPS..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:519 +#: PiFinder/ui/callbacks.py:502 msgid "" "GPS config\n" "updated" @@ -519,7 +463,7 @@ msgstr "" "actualizada" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:521 +#: PiFinder/ui/callbacks.py:504 msgid "" "GPS config\n" "OK" @@ -528,7 +472,7 @@ msgstr "" "OK" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:524 +#: PiFinder/ui/callbacks.py:507 msgid "" "GPS config\n" "failed" @@ -536,13 +480,13 @@ msgstr "" "Config. GPS\n" "fallida" -#: PiFinder/ui/chart.py:92 PiFinder/ui/menu_structure.py:543 +#: PiFinder/ui/chart.py:50 PiFinder/ui/menu_structure.py:596 msgid "Settings" msgstr "Configuración" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: chart corner label, e.g. "Zenith up" — keep short -#: PiFinder/ui/chart.py:273 +#: PiFinder/ui/chart.py:125 msgid "{label} up" msgstr "{label} arriba" @@ -562,36 +506,33 @@ msgid "dd" msgstr "dd" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:141 +#: PiFinder/ui/dateentry.py:130 msgid "Enter Local Date" msgstr "Ingresar Fecha Local" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:159 PiFinder/ui/timeentry.py:164 +#: PiFinder/ui/dateentry.py:148 PiFinder/ui/timeentry.py:135 msgid " Done" msgstr " Listo" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:173 PiFinder/ui/locationentry.py:232 -#: PiFinder/ui/timeentry.py:178 +#: PiFinder/ui/dateentry.py:155 PiFinder/ui/locationentry.py:222 +#: PiFinder/ui/timeentry.py:142 +msgid " Cancel" +msgstr " Cancelar" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/dateentry.py:162 PiFinder/ui/locationentry.py:232 +#: PiFinder/ui/timeentry.py:149 msgid "󰍴 Delete/Previous" msgstr "󰍴 Eliminar/Anterior" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:236 PiFinder/ui/locationentry.py:346 -#: PiFinder/ui/polar_align.py:525 PiFinder/ui/timeentry.py:252 +#: PiFinder/ui/dateentry.py:219 PiFinder/ui/locationentry.py:346 +#: PiFinder/ui/polar_align.py:516 PiFinder/ui/timeentry.py:217 msgid "Cancelled" msgstr "Cancelado" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:250 PiFinder/ui/timeentry.py:270 -msgid "" -"Set location\n" -"first" -msgstr "" -"Fija primero\n" -"la ubicación" - #: PiFinder/ui/equipment.py:39 msgid "No telescope selected" msgstr "Ningún telescopio seleccionado" @@ -636,7 +577,8 @@ msgstr "Preciso" msgid "Precise" msgstr "Exacto" -#: PiFinder/ui/gpsstatus.py:45 views/gps.html:77 views/network.html:81 +#: PiFinder/ui/gpsstatus.py:45 PiFinder/ui/sqm_correction.py:71 +#: views/gps.html:77 views/network.html:81 msgid "Save" msgstr "Guardar" @@ -688,8 +630,8 @@ msgstr "para bloqueo más rápido" msgid "Lock Type:" msgstr "Tipo de Bloqueo:" -#: PiFinder/ui/gpsstatus.py:211 PiFinder/ui/menu_structure.py:437 -#: PiFinder/ui/menu_structure.py:469 views/network.html:74 +#: PiFinder/ui/gpsstatus.py:211 PiFinder/ui/menu_structure.py:490 +#: PiFinder/ui/menu_structure.py:522 views/network.html:74 msgid "None" msgstr "Ninguno" @@ -743,7 +685,7 @@ msgid "From: {location_source}" msgstr "De: {location_source}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:28 PiFinder/ui/menu_structure.py:1257 +#: PiFinder/ui/location_list.py:28 PiFinder/ui/menu_structure.py:1276 msgid "Load" msgstr "Cargar" @@ -765,12 +707,12 @@ msgid "Loaded: {name}" msgstr "Cargado: {name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:128 +#: PiFinder/ui/location_list.py:129 msgid "Deleted: {name}" msgstr "Eliminado: {name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:148 +#: PiFinder/ui/location_list.py:150 msgid "" "Renamed to:\n" "{name}" @@ -779,7 +721,7 @@ msgstr "" "{name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:187 +#: PiFinder/ui/location_list.py:189 msgid "No locations" msgstr "Sin ubicaciones" @@ -830,7 +772,7 @@ msgstr "󰍴 Borrar 󰐕 E/O" # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/locationentry.py:312 PiFinder/ui/locationentry.py:324 -#: PiFinder/ui/menu_structure.py:1172 +#: PiFinder/ui/menu_structure.py:1194 msgid "Enter Coords" msgstr "Ingresar Coords" @@ -901,697 +843,678 @@ msgstr "Ocular" msgid "Telescope" msgstr "Telescopio" -#: PiFinder/ui/menu_structure.py:31 +#: PiFinder/ui/menu_structure.py:30 msgid "Language: de" msgstr "Idioma: Alemán" -#: PiFinder/ui/menu_structure.py:32 +#: PiFinder/ui/menu_structure.py:31 msgid "Language: en" msgstr "Idioma: Inglés" -#: PiFinder/ui/menu_structure.py:33 +#: PiFinder/ui/menu_structure.py:32 msgid "Language: es" msgstr "Idioma: Español" -#: PiFinder/ui/menu_structure.py:34 +#: PiFinder/ui/menu_structure.py:33 msgid "Language: fr" msgstr "Idioma: Francés" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:35 +#: PiFinder/ui/menu_structure.py:34 msgid "Language: zh" msgstr "Idioma: Chino" -#: PiFinder/ui/menu_structure.py:46 +#: PiFinder/ui/menu_structure.py:45 msgid "Start" msgstr "Inicio" -#: PiFinder/ui/menu_structure.py:51 +#: PiFinder/ui/menu_structure.py:50 msgid "Focus" msgstr "Enfoque" -#: PiFinder/ui/menu_structure.py:55 +#: PiFinder/ui/menu_structure.py:54 msgid "Align" msgstr "Alinear" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:61 +#: PiFinder/ui/menu_structure.py:60 msgid "Align (Day)" msgstr "Alinear (Día)" -#: PiFinder/ui/menu_structure.py:67 PiFinder/ui/menu_structure.py:1163 +#: PiFinder/ui/menu_structure.py:66 PiFinder/ui/menu_structure.py:1185 msgid "GPS Status" msgstr "Estado GPS" -#: PiFinder/ui/menu_structure.py:73 +#: PiFinder/ui/menu_structure.py:72 msgid "Chart" msgstr "Carta" -#: PiFinder/ui/menu_structure.py:79 views/obs_session_log.html:7 +#: PiFinder/ui/menu_structure.py:78 views/obs_session_log.html:7 #: views/obs_sessions.html:11 views/obs_sessions.html:25 msgid "Objects" msgstr "Objetos" -#: PiFinder/ui/menu_structure.py:84 +#: PiFinder/ui/menu_structure.py:83 msgid "All Filtered" msgstr "Todos Filtrados" -#: PiFinder/ui/menu_structure.py:89 +#: PiFinder/ui/menu_structure.py:88 msgid "By Catalog" msgstr "Por Catálogo" -#: PiFinder/ui/menu_structure.py:94 PiFinder/ui/menu_structure.py:303 +#: PiFinder/ui/menu_structure.py:93 PiFinder/ui/menu_structure.py:302 msgid "Planets" msgstr "Planetas" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:100 PiFinder/ui/menu_structure.py:307 +#: PiFinder/ui/menu_structure.py:99 PiFinder/ui/menu_structure.py:306 msgid "Comets" msgstr "Cometas" -#: PiFinder/ui/menu_structure.py:106 PiFinder/ui/menu_structure.py:189 -#: PiFinder/ui/menu_structure.py:311 PiFinder/ui/menu_structure.py:369 +#: PiFinder/ui/menu_structure.py:105 PiFinder/ui/menu_structure.py:188 +#: PiFinder/ui/menu_structure.py:310 PiFinder/ui/menu_structure.py:368 msgid "NGC" msgstr "NGC" -#: PiFinder/ui/menu_structure.py:112 PiFinder/ui/menu_structure.py:183 -#: PiFinder/ui/menu_structure.py:315 PiFinder/ui/menu_structure.py:365 +#: PiFinder/ui/menu_structure.py:111 PiFinder/ui/menu_structure.py:182 +#: PiFinder/ui/menu_structure.py:314 PiFinder/ui/menu_structure.py:364 msgid "Messier" msgstr "Messier" -#: PiFinder/ui/menu_structure.py:118 PiFinder/ui/menu_structure.py:319 +#: PiFinder/ui/menu_structure.py:117 PiFinder/ui/menu_structure.py:318 msgid "DSO..." msgstr "OCP..." -#: PiFinder/ui/menu_structure.py:123 PiFinder/ui/menu_structure.py:325 +#: PiFinder/ui/menu_structure.py:122 PiFinder/ui/menu_structure.py:324 msgid "Abell Pn" msgstr "Abell Pn" -#: PiFinder/ui/menu_structure.py:129 PiFinder/ui/menu_structure.py:329 +#: PiFinder/ui/menu_structure.py:128 PiFinder/ui/menu_structure.py:328 msgid "Arp Galaxies" msgstr "Galaxias Arp" -#: PiFinder/ui/menu_structure.py:135 PiFinder/ui/menu_structure.py:333 +#: PiFinder/ui/menu_structure.py:134 PiFinder/ui/menu_structure.py:332 msgid "Barnard" msgstr "Barnard" -#: PiFinder/ui/menu_structure.py:141 PiFinder/ui/menu_structure.py:337 +#: PiFinder/ui/menu_structure.py:140 PiFinder/ui/menu_structure.py:336 msgid "Caldwell" msgstr "Caldwell" -#: PiFinder/ui/menu_structure.py:147 PiFinder/ui/menu_structure.py:341 +#: PiFinder/ui/menu_structure.py:146 PiFinder/ui/menu_structure.py:340 msgid "Collinder" msgstr "Collinder" -#: PiFinder/ui/menu_structure.py:153 PiFinder/ui/menu_structure.py:345 +#: PiFinder/ui/menu_structure.py:152 PiFinder/ui/menu_structure.py:344 msgid "E.G. Globs" msgstr "E.G. Globs" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:159 PiFinder/ui/menu_structure.py:349 +#: PiFinder/ui/menu_structure.py:158 PiFinder/ui/menu_structure.py:348 msgid "Harris Globs" msgstr "Harris Globs" -#: PiFinder/ui/menu_structure.py:165 PiFinder/ui/menu_structure.py:353 +#: PiFinder/ui/menu_structure.py:164 PiFinder/ui/menu_structure.py:352 msgid "Herschel 400" msgstr "Herschel 400" -#: PiFinder/ui/menu_structure.py:171 PiFinder/ui/menu_structure.py:357 +#: PiFinder/ui/menu_structure.py:170 PiFinder/ui/menu_structure.py:356 msgid "IC" msgstr "IC" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:177 PiFinder/ui/menu_structure.py:361 +#: PiFinder/ui/menu_structure.py:176 PiFinder/ui/menu_structure.py:360 msgid "Lynga Opn Cl" msgstr "Lynga Cl.Ab." -#: PiFinder/ui/menu_structure.py:195 PiFinder/ui/menu_structure.py:373 +#: PiFinder/ui/menu_structure.py:194 PiFinder/ui/menu_structure.py:372 msgid "Sharpless" msgstr "Sharpless" -#: PiFinder/ui/menu_structure.py:201 PiFinder/ui/menu_structure.py:377 +#: PiFinder/ui/menu_structure.py:200 PiFinder/ui/menu_structure.py:376 msgid "TAAS 200" msgstr "TAAS 200" -#: PiFinder/ui/menu_structure.py:209 PiFinder/ui/menu_structure.py:383 +#: PiFinder/ui/menu_structure.py:208 PiFinder/ui/menu_structure.py:382 msgid "Stars..." msgstr "Estrellas..." -#: PiFinder/ui/menu_structure.py:214 PiFinder/ui/menu_structure.py:389 +#: PiFinder/ui/menu_structure.py:213 PiFinder/ui/menu_structure.py:388 msgid "Bright Named" msgstr "Brillantes con Nombre" -#: PiFinder/ui/menu_structure.py:220 PiFinder/ui/menu_structure.py:393 +#: PiFinder/ui/menu_structure.py:219 PiFinder/ui/menu_structure.py:392 msgid "SAC Doubles" msgstr "SAC Dobles" -#: PiFinder/ui/menu_structure.py:226 PiFinder/ui/menu_structure.py:397 +#: PiFinder/ui/menu_structure.py:225 PiFinder/ui/menu_structure.py:396 msgid "SAC Asterisms" msgstr "SAC Asterismos" -#: PiFinder/ui/menu_structure.py:232 PiFinder/ui/menu_structure.py:401 +#: PiFinder/ui/menu_structure.py:231 PiFinder/ui/menu_structure.py:400 msgid "SAC Red Stars" msgstr "SAC Estrellas Rojas" -#: PiFinder/ui/menu_structure.py:238 PiFinder/ui/menu_structure.py:405 +#: PiFinder/ui/menu_structure.py:237 PiFinder/ui/menu_structure.py:404 msgid "RASC Doubles" msgstr "RASC Dobles" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:244 +#: PiFinder/ui/menu_structure.py:243 msgid "WDS Doubles" msgstr "WDS Dobles" -#: PiFinder/ui/menu_structure.py:250 PiFinder/ui/menu_structure.py:409 +#: PiFinder/ui/menu_structure.py:249 PiFinder/ui/menu_structure.py:408 msgid "TLK 90 Variables" msgstr "TLK 90 Variables" -#: PiFinder/ui/menu_structure.py:260 +#: PiFinder/ui/menu_structure.py:259 msgid "Recent" msgstr "Recientes" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:266 PiFinder/ui/obs_list.py:70 +#: PiFinder/ui/menu_structure.py:265 PiFinder/ui/obs_list.py:70 msgid "Obs Lists" msgstr "Listas obs." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:270 +#: PiFinder/ui/menu_structure.py:269 msgid "Custom" msgstr "Personal" -#: PiFinder/ui/menu_structure.py:275 +#: PiFinder/ui/menu_structure.py:274 msgid "Name Search" msgstr "Búsqueda por Nombre" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:279 +#: PiFinder/ui/menu_structure.py:278 msgid "Set Filters" msgstr "Establecer Filtros" -#: PiFinder/ui/menu_structure.py:285 +#: PiFinder/ui/menu_structure.py:284 msgid "Reset All" msgstr "Restablecer Todo" -#: PiFinder/ui/menu_structure.py:290 PiFinder/ui/menu_structure.py:1278 -#: PiFinder/ui/menu_structure.py:1289 PiFinder/ui/software.py:435 -#: PiFinder/ui/software.py:531 +#: PiFinder/ui/menu_structure.py:289 PiFinder/ui/menu_structure.py:1297 +#: PiFinder/ui/menu_structure.py:1308 msgid "Confirm" msgstr "Confirmar" -#: PiFinder/ui/menu_structure.py:293 PiFinder/ui/menu_structure.py:1279 -#: PiFinder/ui/menu_structure.py:1292 PiFinder/ui/software.py:380 -#: PiFinder/ui/software.py:435 PiFinder/ui/software.py:529 -#: views/edit_eyepiece.html:54 views/edit_instrument.html:108 -#: views/equipment.html:58 views/location_form.html:77 views/locations.html:187 +#: PiFinder/ui/menu_structure.py:292 PiFinder/ui/menu_structure.py:1298 +#: PiFinder/ui/menu_structure.py:1311 PiFinder/ui/software.py:208 +#: PiFinder/ui/sqm_correction.py:70 views/edit_eyepiece.html:54 +#: views/edit_instrument.html:108 views/equipment.html:58 +#: views/location_form.html:77 views/locations.html:187 #: views/locations.html:200 views/network.html:46 views/network.html:83 #: views/network_item.html:19 views/tools.html:97 msgid "Cancel" msgstr "Cancelar" -#: PiFinder/ui/menu_structure.py:297 +#: PiFinder/ui/menu_structure.py:296 msgid "Catalogs" msgstr "Catálogos" -#: PiFinder/ui/menu_structure.py:417 +#: PiFinder/ui/menu_structure.py:416 msgid "Type" msgstr "Tipo" -#: PiFinder/ui/menu_structure.py:463 +#: PiFinder/ui/menu_structure.py:430 +msgid "Cluster/Neb" +msgstr "Cúmulo/Neb" + +#: PiFinder/ui/menu_structure.py:442 +msgid "P. Nebula" +msgstr "N. Planetaria" + +#: PiFinder/ui/menu_structure.py:454 +msgid "Double Str" +msgstr "Str Doble" + +#: PiFinder/ui/menu_structure.py:458 +msgid "Triple Str" +msgstr "Str Triple" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/menu_structure.py:478 +msgid "Unknown" +msgstr "Desconocido" + +#: PiFinder/ui/menu_structure.py:484 views/locations.html:65 +msgid "Altitude" +msgstr "Altitud" + +#: PiFinder/ui/menu_structure.py:516 msgid "Magnitude" msgstr "Magnitud" -#: PiFinder/ui/menu_structure.py:515 PiFinder/ui/menu_structure.py:525 +#: PiFinder/ui/menu_structure.py:568 PiFinder/ui/menu_structure.py:578 msgid "Observed" msgstr "Observado" -#: PiFinder/ui/menu_structure.py:521 +#: PiFinder/ui/menu_structure.py:574 msgid "Any" msgstr "Cualquiera" -#: PiFinder/ui/menu_structure.py:529 +#: PiFinder/ui/menu_structure.py:582 msgid "Not Observed" msgstr "No Observado" -#: PiFinder/ui/menu_structure.py:548 +#: PiFinder/ui/menu_structure.py:601 msgid "User Pref..." msgstr "Pref. Usuario..." -#: PiFinder/ui/menu_structure.py:553 +#: PiFinder/ui/menu_structure.py:606 msgid "Key Bright" msgstr "Brillo Teclas" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:594 -msgid "Volume" -msgstr "Volumen" +#: PiFinder/ui/menu_structure.py:647 +msgid "Sleep Time" +msgstr "Tiempo de Suspensión" -#: PiFinder/ui/menu_structure.py:600 PiFinder/ui/menu_structure.py:615 -#: PiFinder/ui/menu_structure.py:647 PiFinder/ui/menu_structure.py:671 -#: PiFinder/ui/menu_structure.py:790 PiFinder/ui/menu_structure.py:814 -#: PiFinder/ui/menu_structure.py:838 PiFinder/ui/menu_structure.py:862 -#: PiFinder/ui/menu_structure.py:893 PiFinder/ui/menu_structure.py:909 -#: PiFinder/ui/menu_structure.py:1125 PiFinder/ui/menu_structure.py:1231 -#: PiFinder/ui/menu_structure.py:1247 +#: PiFinder/ui/menu_structure.py:653 PiFinder/ui/menu_structure.py:685 +#: PiFinder/ui/menu_structure.py:709 PiFinder/ui/menu_structure.py:828 +#: PiFinder/ui/menu_structure.py:852 PiFinder/ui/menu_structure.py:876 +#: PiFinder/ui/menu_structure.py:900 PiFinder/ui/menu_structure.py:931 +#: PiFinder/ui/menu_structure.py:947 PiFinder/ui/menu_structure.py:1147 +#: PiFinder/ui/menu_structure.py:1250 PiFinder/ui/menu_structure.py:1266 msgid "Off" msgstr "Apagado" -#: PiFinder/ui/menu_structure.py:609 -msgid "Sleep Time" -msgstr "Tiempo de Suspensión" - -#: PiFinder/ui/menu_structure.py:641 +#: PiFinder/ui/menu_structure.py:679 msgid "Menu Anim" msgstr "Animación Menú" -#: PiFinder/ui/menu_structure.py:651 PiFinder/ui/menu_structure.py:675 +#: PiFinder/ui/menu_structure.py:689 PiFinder/ui/menu_structure.py:713 msgid "Fast" msgstr "Rápido" -#: PiFinder/ui/menu_structure.py:655 PiFinder/ui/menu_structure.py:679 -#: PiFinder/ui/menu_structure.py:798 PiFinder/ui/menu_structure.py:822 -#: PiFinder/ui/menu_structure.py:846 PiFinder/ui/menu_structure.py:1139 +#: PiFinder/ui/menu_structure.py:693 PiFinder/ui/menu_structure.py:717 +#: PiFinder/ui/menu_structure.py:836 PiFinder/ui/menu_structure.py:860 +#: PiFinder/ui/menu_structure.py:884 PiFinder/ui/menu_structure.py:1161 msgid "Medium" msgstr "Medio" -#: PiFinder/ui/menu_structure.py:659 PiFinder/ui/menu_structure.py:683 +#: PiFinder/ui/menu_structure.py:697 PiFinder/ui/menu_structure.py:721 msgid "Slow" msgstr "Lento" -#: PiFinder/ui/menu_structure.py:665 +#: PiFinder/ui/menu_structure.py:703 msgid "Scroll Speed" msgstr "Velocidad de Desplazamiento" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:689 +#: PiFinder/ui/menu_structure.py:727 msgid "Search Input" msgstr "Entrada de búsqueda" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:696 +#: PiFinder/ui/menu_structure.py:734 msgid "Multi-Tap" msgstr "Multitoque" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:700 +#: PiFinder/ui/menu_structure.py:738 msgid "T9" msgstr "T9" -#: PiFinder/ui/menu_structure.py:706 +#: PiFinder/ui/menu_structure.py:744 msgid "Az Arrows" msgstr "Flechas Az" -#: PiFinder/ui/menu_structure.py:713 +#: PiFinder/ui/menu_structure.py:751 msgid "Default" msgstr "Predeterminado" -#: PiFinder/ui/menu_structure.py:717 +#: PiFinder/ui/menu_structure.py:755 msgid "Reverse" msgstr "Invertido" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:723 +#: PiFinder/ui/menu_structure.py:761 msgid "Language" msgstr "Idioma" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:730 +#: PiFinder/ui/menu_structure.py:768 msgid "English" msgstr "Inglés" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:734 +#: PiFinder/ui/menu_structure.py:772 msgid "German" msgstr "Alemán" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:738 +#: PiFinder/ui/menu_structure.py:776 msgid "French" msgstr "Francés" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:742 +#: PiFinder/ui/menu_structure.py:780 msgid "Spanish" msgstr "Español" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:746 +#: PiFinder/ui/menu_structure.py:784 msgid "Chinese" msgstr "Chino" -#: PiFinder/ui/menu_structure.py:754 +#: PiFinder/ui/menu_structure.py:792 msgid "Chart..." msgstr "Carta..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:760 +#: PiFinder/ui/menu_structure.py:798 msgid "Coordinate Sys." msgstr "Sist. Coord." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:766 +#: PiFinder/ui/menu_structure.py:804 msgid "Horizontal" msgstr "Horizontal" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:770 +#: PiFinder/ui/menu_structure.py:808 msgid "EQ (Auto)" msgstr "EQ (Auto)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:774 +#: PiFinder/ui/menu_structure.py:812 msgid "EQ (North-up)" msgstr "EQ (Norte)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:778 +#: PiFinder/ui/menu_structure.py:816 msgid "EQ (South-up)" msgstr "EQ (Sur)" -#: PiFinder/ui/menu_structure.py:784 +#: PiFinder/ui/menu_structure.py:822 msgid "Reticle" msgstr "Retícula" -#: PiFinder/ui/menu_structure.py:794 PiFinder/ui/menu_structure.py:818 -#: PiFinder/ui/menu_structure.py:842 PiFinder/ui/menu_structure.py:1135 +#: PiFinder/ui/menu_structure.py:832 PiFinder/ui/menu_structure.py:856 +#: PiFinder/ui/menu_structure.py:880 PiFinder/ui/menu_structure.py:1157 msgid "Low" msgstr "Bajo" -#: PiFinder/ui/menu_structure.py:802 PiFinder/ui/menu_structure.py:826 -#: PiFinder/ui/menu_structure.py:850 PiFinder/ui/menu_structure.py:1143 +#: PiFinder/ui/menu_structure.py:840 PiFinder/ui/menu_structure.py:864 +#: PiFinder/ui/menu_structure.py:888 PiFinder/ui/menu_structure.py:1165 msgid "High" msgstr "Alto" -#: PiFinder/ui/menu_structure.py:808 +#: PiFinder/ui/menu_structure.py:846 msgid "Constellation" msgstr "Constelación" -#: PiFinder/ui/menu_structure.py:832 +#: PiFinder/ui/menu_structure.py:870 msgid "DSO Display" msgstr "Visualización OCP" -#: PiFinder/ui/menu_structure.py:856 +#: PiFinder/ui/menu_structure.py:894 msgid "RA/DEC Disp." msgstr "Visual. RA/Dec" -#: PiFinder/ui/menu_structure.py:866 +#: PiFinder/ui/menu_structure.py:904 msgid "HH:MM" msgstr "HH:MM" -#: PiFinder/ui/menu_structure.py:870 +#: PiFinder/ui/menu_structure.py:908 msgid "Degrees" msgstr "Grados" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:878 +#: PiFinder/ui/menu_structure.py:916 msgid "Image..." msgstr "Imagen..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:883 +#: PiFinder/ui/menu_structure.py:921 msgid "NSEW Labels" msgstr "Etiquetas NSEO" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:889 PiFinder/ui/menu_structure.py:905 -#: PiFinder/ui/menu_structure.py:1235 PiFinder/ui/menu_structure.py:1251 +#: PiFinder/ui/menu_structure.py:927 PiFinder/ui/menu_structure.py:943 +#: PiFinder/ui/menu_structure.py:1254 PiFinder/ui/menu_structure.py:1270 msgid "On" msgstr "Activado" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:899 +#: PiFinder/ui/menu_structure.py:937 msgid "Object Size" msgstr "Tamaño de objeto" -#: PiFinder/ui/menu_structure.py:917 +#: PiFinder/ui/menu_structure.py:955 msgid "Camera Exp" msgstr "Exp. Cámara" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:925 +#: PiFinder/ui/menu_structure.py:963 msgid "Auto" msgstr "Auto" -#: PiFinder/ui/menu_structure.py:930 +#: PiFinder/ui/menu_structure.py:968 msgid "0.025s" msgstr "0,025s" -#: PiFinder/ui/menu_structure.py:934 +#: PiFinder/ui/menu_structure.py:972 msgid "0.05s" msgstr "0,05s" -#: PiFinder/ui/menu_structure.py:938 +#: PiFinder/ui/menu_structure.py:976 msgid "0.1s" msgstr "0,1s" -#: PiFinder/ui/menu_structure.py:942 +#: PiFinder/ui/menu_structure.py:980 msgid "0.2s" msgstr "0,2s" -#: PiFinder/ui/menu_structure.py:946 +#: PiFinder/ui/menu_structure.py:984 msgid "0.4s" msgstr "0,4s" -#: PiFinder/ui/menu_structure.py:950 +#: PiFinder/ui/menu_structure.py:988 msgid "0.8s" msgstr "0,8s" -#: PiFinder/ui/menu_structure.py:954 +#: PiFinder/ui/menu_structure.py:992 msgid "1s" msgstr "1s" -#: PiFinder/ui/menu_structure.py:960 +#: PiFinder/ui/menu_structure.py:998 msgid "WiFi Mode" msgstr "Modo WiFi" -#: PiFinder/ui/menu_structure.py:966 +#: PiFinder/ui/menu_structure.py:1004 msgid "Client Mode" msgstr "Modo Cliente" -#: PiFinder/ui/menu_structure.py:971 +#: PiFinder/ui/menu_structure.py:1009 msgid "AP Mode" msgstr "Modo PA" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:978 views/edit_instrument.html:61 +#: PiFinder/ui/menu_structure.py:1016 views/edit_instrument.html:61 #: views/equipment.html:73 msgid "Mount Type" msgstr "Tipo Montura" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:985 views/edit_instrument.html:57 +#: PiFinder/ui/menu_structure.py:1023 views/edit_instrument.html:57 msgid "Alt/Az" msgstr "Alt/Az" -#: PiFinder/ui/menu_structure.py:989 views/edit_instrument.html:58 +#: PiFinder/ui/menu_structure.py:1027 views/edit_instrument.html:58 msgid "Equatorial" msgstr "Ecuatorial" -#: PiFinder/ui/menu_structure.py:1001 +#: PiFinder/ui/menu_structure.py:1039 msgid "PiFinder Type" msgstr "Tipo PiFinder" -#: PiFinder/ui/menu_structure.py:1008 +#: PiFinder/ui/menu_structure.py:1046 msgid "Left" msgstr "Izquierda" -#: PiFinder/ui/menu_structure.py:1012 +#: PiFinder/ui/menu_structure.py:1050 msgid "Right" msgstr "Derecha" -#: PiFinder/ui/menu_structure.py:1016 +#: PiFinder/ui/menu_structure.py:1054 msgid "Straight" msgstr "Recto" -#: PiFinder/ui/menu_structure.py:1020 +#: PiFinder/ui/menu_structure.py:1058 msgid "Flat v3" msgstr "Plano v3" -#: PiFinder/ui/menu_structure.py:1024 +#: PiFinder/ui/menu_structure.py:1062 msgid "Flat v2" msgstr "Plano v2" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1028 +#: PiFinder/ui/menu_structure.py:1066 msgid "AS Bloom" msgstr "AS Bloom" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1032 -msgid "AS Heart" -msgstr "AS Heart" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1036 -msgid "Rev4 Left" -msgstr "Rev4 Izquierda" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1040 -msgid "Rev4 Right" -msgstr "Rev4 Derecha" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1044 -msgid "Rev4 Straight" -msgstr "Rev4 Recto" - -#: PiFinder/ui/menu_structure.py:1050 +#: PiFinder/ui/menu_structure.py:1072 msgid "Camera Type" msgstr "Tipo de Cámara" -#: PiFinder/ui/menu_structure.py:1056 +#: PiFinder/ui/menu_structure.py:1078 msgid "v2 - imx477" msgstr "v2 - imx477" -#: PiFinder/ui/menu_structure.py:1061 +#: PiFinder/ui/menu_structure.py:1083 msgid "v3 - imx296" msgstr "v3 - imx296" -#: PiFinder/ui/menu_structure.py:1066 +#: PiFinder/ui/menu_structure.py:1088 msgid "v3 - imx462" msgstr "v3 - imx462" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1077 -# AI-TRANSLATED (claude): needs human review -msgid "Lens" -msgstr "Objetivo" - -#: PiFinder/ui/menu_structure.py:1086 -msgid "12mm" -msgstr "12mm" - -#: PiFinder/ui/menu_structure.py:1090 -msgid "16mm" -msgstr "16mm" - -#: PiFinder/ui/menu_structure.py:1094 -msgid "25mm" -msgstr "25mm" - -#: PiFinder/ui/menu_structure.py:1100 views/gps.html:6 +#: PiFinder/ui/menu_structure.py:1095 views/gps.html:6 msgid "GPS Settings" msgstr "Configuración GPS" -#: PiFinder/ui/menu_structure.py:1078 +#: PiFinder/ui/menu_structure.py:1100 msgid "GPS Type" msgstr "Tipo de GPS" -#: PiFinder/ui/menu_structure.py:1086 +#: PiFinder/ui/menu_structure.py:1108 msgid "UBlox" msgstr "UBlox" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1090 +#: PiFinder/ui/menu_structure.py:1112 msgid "GPSD (generic)" msgstr "GPSD (genérico)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1096 +#: PiFinder/ui/menu_structure.py:1118 msgid "GPS Baud Rate" msgstr "Baudios GPS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1104 +#: PiFinder/ui/menu_structure.py:1126 msgid "9600 (standard)" msgstr "9600 (estándar)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1108 +#: PiFinder/ui/menu_structure.py:1130 msgid "115200 (UBlox-10)" msgstr "115200 (UBlox-10)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1118 +#: PiFinder/ui/menu_structure.py:1140 msgid "IMU Sensit." msgstr "Sensib. IMU" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1129 +#: PiFinder/ui/menu_structure.py:1151 msgid "Very Low" msgstr "Muy Bajo" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1155 +#: PiFinder/ui/menu_structure.py:1177 msgid "Status" msgstr "Estado" -#: PiFinder/ui/menu_structure.py:1158 +#: PiFinder/ui/menu_structure.py:1180 msgid "Place & Time" msgstr "Lugar y Hora" -#: PiFinder/ui/menu_structure.py:1167 +#: PiFinder/ui/menu_structure.py:1189 msgid "Set Location" msgstr "Establecer Ubicación" -#: PiFinder/ui/menu_structure.py:1176 views/locations.html:86 +#: PiFinder/ui/menu_structure.py:1198 views/locations.html:86 msgid "Load Location" msgstr "Cargar Ubicación" -#: PiFinder/ui/menu_structure.py:1180 views/location_form.html:76 +#: PiFinder/ui/menu_structure.py:1202 views/location_form.html:76 msgid "Save Location" msgstr "Guardar Ubicación" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1186 +#: PiFinder/ui/menu_structure.py:1208 msgid "Set Time/Date" msgstr "Establecer Hora/Fecha" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1193 +#: PiFinder/ui/menu_structure.py:1212 msgid "Reset Location" msgstr "Restablecer Ubicación" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1195 +#: PiFinder/ui/menu_structure.py:1214 msgid "Reset Time/Date" msgstr "Restablecer Hora/Fecha" -#: PiFinder/ui/menu_structure.py:1200 +#: PiFinder/ui/menu_structure.py:1219 msgid "Console" msgstr "Consola" -#: PiFinder/ui/menu_structure.py:1201 +#: PiFinder/ui/menu_structure.py:1220 msgid "Software Upd" msgstr "Act. Software" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1204 +#: PiFinder/ui/menu_structure.py:1223 msgid "Experimental" msgstr "Experimental" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1209 +#: PiFinder/ui/menu_structure.py:1228 msgid "Polar Align" msgstr "Alin. polar" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1214 +#: PiFinder/ui/menu_structure.py:1233 msgid "Dev Tools" msgstr "Herram. dev" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1219 +#: PiFinder/ui/menu_structure.py:1238 msgid "Telemetry" msgstr "Telemetría" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1224 +#: PiFinder/ui/menu_structure.py:1243 msgid "Record" msgstr "Grabar" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1241 +#: PiFinder/ui/menu_structure.py:1260 msgid "Images" msgstr "Imágenes" -#: PiFinder/ui/menu_structure.py:1267 +#: PiFinder/ui/menu_structure.py:1286 msgid "Power" msgstr "Energía" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1273 +#: PiFinder/ui/menu_structure.py:1292 msgid "Shutdown" msgstr "Apagar" @@ -1607,134 +1530,138 @@ msgstr "CANCELAR" msgid "No Object Found" msgstr "Ningún Objeto Encontrado" -#: PiFinder/ui/object_details.py:238 PiFinder/ui/object_details.py:245 +#: PiFinder/ui/object_details.py:231 PiFinder/ui/object_details.py:238 msgid "Mag:{obj_mag}" msgstr "Mag:{obj_mag}" #. TRANSLATORS: object info magnitude -#: PiFinder/ui/object_details.py:241 +#: PiFinder/ui/object_details.py:234 msgid "Sz:{size}" msgstr "Tam:{size}" -#: PiFinder/ui/object_details.py:367 +#: PiFinder/ui/object_details.py:360 msgid "  Not Logged" msgstr "  No Registrado" -#: PiFinder/ui/object_details.py:369 +#: PiFinder/ui/object_details.py:362 msgid "  {logs} Logs" msgstr "  {logs} Registros" -#: PiFinder/ui/object_details.py:415 PiFinder/ui/polar_align.py:444 +#: PiFinder/ui/object_details.py:408 PiFinder/ui/polar_align.py:444 msgid "No solve" msgstr "Sin resolver" -#: PiFinder/ui/object_details.py:421 +#: PiFinder/ui/object_details.py:414 msgid "yet{elipsis}" msgstr "aún{elipsis}" -#: PiFinder/ui/object_details.py:435 +#: PiFinder/ui/object_details.py:428 msgid "Searching" msgstr "Buscando" -#: PiFinder/ui/object_details.py:441 +#: PiFinder/ui/object_details.py:434 msgid "for GPS{elipsis}" msgstr "GPS{elipsis}" -#: PiFinder/ui/object_details.py:455 PiFinder/ui/object_details.py:483 +#: PiFinder/ui/object_details.py:448 PiFinder/ui/object_details.py:476 msgid "Calculating" msgstr "Calculando" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:461 +#: PiFinder/ui/object_details.py:454 msgid "positions" msgstr "posiciones" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:489 +#: PiFinder/ui/object_details.py:482 msgid "position" msgstr "posición" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:573 +#: PiFinder/ui/object_details.py:566 msgid "Contrast Reserve" msgstr "Reserva de Contraste" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:599 +#: PiFinder/ui/object_details.py:592 msgid "No contrast data" msgstr "Sin datos de contraste" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:607 +#: PiFinder/ui/object_details.py:600 msgid "CR measures object" msgstr "RC mide visibilidad" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 1 -#: PiFinder/ui/object_details.py:610 +#: PiFinder/ui/object_details.py:603 msgid "visibility based on" msgstr "del objeto según" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 2 -#: PiFinder/ui/object_details.py:613 +#: PiFinder/ui/object_details.py:606 msgid "sky brightness," msgstr "brillo del cielo," # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 3 -#: PiFinder/ui/object_details.py:616 +#: PiFinder/ui/object_details.py:609 msgid "telescope, and EP." msgstr "telescopio y ocular." -#: PiFinder/ui/object_details.py:692 +#: PiFinder/ui/object_details.py:685 msgid "Too Far" msgstr "Muy Lejos" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:717 +#: PiFinder/ui/object_details.py:710 msgid "LOG" msgstr "REG" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:165 +#: PiFinder/ui/object_list.py:134 msgid "Refresh" msgstr "Actualizar" -#: PiFinder/ui/object_list.py:171 +#: PiFinder/ui/object_list.py:140 msgid "Sort" msgstr "Ordenar" -#: PiFinder/ui/object_list.py:175 PiFinder/ui/object_list.py:879 +#: PiFinder/ui/object_list.py:144 PiFinder/ui/object_list.py:819 msgid "Nearest" msgstr "Más Cercano" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:179 PiFinder/ui/object_list.py:885 +#: PiFinder/ui/object_list.py:148 PiFinder/ui/object_list.py:825 msgid "Standard" msgstr "Estándar" -#: PiFinder/ui/object_list.py:184 +#: PiFinder/ui/object_list.py:153 msgid "Filter" msgstr "Filtro" -#: PiFinder/ui/object_list.py:289 PiFinder/ui/software.py:572 +#: PiFinder/ui/object_list.py:244 msgid "Downloading..." msgstr "Descargando..." -#: PiFinder/ui/object_list.py:296 +#: PiFinder/ui/object_list.py:251 msgid "No GPS lock" msgstr "Sin GPS" -#: PiFinder/ui/object_list.py:303 +#: PiFinder/ui/object_list.py:258 msgid "Calculating..." msgstr "Calculando..." -#: PiFinder/ui/object_list.py:312 PiFinder/ui/software.py:750 +#: PiFinder/ui/object_list.py:264 views/locations.html:66 +msgid "Error" +msgstr "Error" + +#: PiFinder/ui/object_list.py:267 msgid "Loading..." msgstr "Cargando..." -#: PiFinder/ui/object_list.py:319 +#: PiFinder/ui/object_list.py:274 msgid "" "Sorting by\n" "{sort_order}" @@ -1742,43 +1669,43 @@ msgstr "" "Ordenando por\n" "{sort_order}" -#: PiFinder/ui/object_list.py:320 PiFinder/ui/object_list.py:890 +#: PiFinder/ui/object_list.py:275 PiFinder/ui/object_list.py:830 msgid "RA" msgstr "AR" -#: PiFinder/ui/object_list.py:322 PiFinder/ui/object_list.py:639 +#: PiFinder/ui/object_list.py:277 PiFinder/ui/object_list.py:579 #: views/obs_session_log.html:21 msgid "Catalog" msgstr "Catálogo" -#: PiFinder/ui/object_list.py:324 PiFinder/ui/object_list.py:641 +#: PiFinder/ui/object_list.py:279 PiFinder/ui/object_list.py:581 msgid "Nearby" msgstr "Cercano" -#: PiFinder/ui/object_list.py:596 +#: PiFinder/ui/object_list.py:543 msgid "No objects" msgstr "Sin objetos" -#: PiFinder/ui/object_list.py:602 +#: PiFinder/ui/object_list.py:549 msgid "match filter" msgstr "coincidir con filtro" -#: PiFinder/ui/object_list.py:625 +#: PiFinder/ui/object_list.py:565 msgid "{catalog_info_1} obj" msgstr "{catalog_info_1} obj" #. TRANSLATORS: number of objects in object list -#: PiFinder/ui/object_list.py:628 +#: PiFinder/ui/object_list.py:568 msgid ", {catalog_info_2}d old" msgstr ", {catalog_info_2}d ant." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:638 +#: PiFinder/ui/object_list.py:578 msgid "Sort: {sort_order}" msgstr "Orden: {sort_order}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:932 +#: PiFinder/ui/object_list.py:872 msgid "Refreshing..." msgstr "Actualizando..." @@ -1811,12 +1738,12 @@ msgid "STATS" msgstr "DATOS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:228 PiFinder/ui/polar_align.py:549 +#: PiFinder/ui/polar_align.py:228 PiFinder/ui/polar_align.py:540 msgid "Need GPS lock" msgstr "Falta GPS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:234 PiFinder/ui/polar_align.py:551 +#: PiFinder/ui/polar_align.py:234 PiFinder/ui/polar_align.py:542 msgid "Rotate more" msgstr "Gira más" @@ -1946,8 +1873,8 @@ msgstr "resol. de cámara." # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: hint bar; {icon} is the MINUS button glyph #. TRANSLATORS: hint bar; {icon} is the SQUARE button glyph -#: PiFinder/ui/polar_align.py:398 PiFinder/ui/polar_align.py:470 -#: PiFinder/ui/polar_align.py:500 +#: PiFinder/ui/polar_align.py:398 PiFinder/ui/polar_align.py:461 +#: PiFinder/ui/polar_align.py:491 msgid "{icon} BACK" msgstr "{icon} ATRÁS" @@ -1967,7 +1894,7 @@ msgid "bad" msgstr "mal" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:414 PiFinder/ui/polar_align.py:481 +#: PiFinder/ui/polar_align.py:414 PiFinder/ui/polar_align.py:472 msgid "pt" msgstr "pt" @@ -1979,76 +1906,91 @@ msgid "{square} REDO {minus} CANCEL" msgstr "{square} REHACER {minus} CANCELA" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:468 +#: PiFinder/ui/polar_align.py:459 msgid "No result yet" msgstr "Sin result. aún" -#: PiFinder/ui/polar_align.py:477 +#: PiFinder/ui/polar_align.py:468 msgid "RA/Dec" msgstr "RA/Dec" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:477 +#: PiFinder/ui/polar_align.py:468 msgid "3-axis" msgstr "3 ejes" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:484 +#: PiFinder/ui/polar_align.py:475 msgid "Fit" msgstr "Ajuste" -#: PiFinder/ui/polar_align.py:485 +#: PiFinder/ui/polar_align.py:476 msgid "Alt" msgstr "Alt" -#: PiFinder/ui/polar_align.py:486 +#: PiFinder/ui/polar_align.py:477 msgid "Az" msgstr "Az" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:487 +#: PiFinder/ui/polar_align.py:478 msgid "Axis" msgstr "Eje" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:497 +#: PiFinder/ui/polar_align.py:488 msgid "time" msgstr "tiempo" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:497 +#: PiFinder/ui/polar_align.py:488 msgid "sec" msgstr "seg" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:547 +#: PiFinder/ui/polar_align.py:538 msgid "Need 2 points" msgstr "Faltan 2 puntos" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:561 PiFinder/ui/polar_align.py:568 +#: PiFinder/ui/polar_align.py:552 PiFinder/ui/polar_align.py:559 msgid "Roll Off" msgstr "Sin Roll" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:561 PiFinder/ui/polar_align.py:568 +#: PiFinder/ui/polar_align.py:552 PiFinder/ui/polar_align.py:559 msgid "Roll On" msgstr "Con Roll" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:576 +#: PiFinder/ui/polar_align.py:567 msgid "No points" msgstr "Sin puntos" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:582 +#: PiFinder/ui/polar_align.py:573 msgid "Dropped point" msgstr "Punto borrado" -#: PiFinder/ui/preview.py:93 +#: PiFinder/ui/preview.py:79 msgid "Exposure" msgstr "Exposición" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:304 +msgid "keep going" +msgstr "continúa" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:354 +msgid "det {n}" +msgstr "det {n}" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:438 +msgid "Zoom x{zoom_number}" +msgstr "Zoom x{zoom_number}" + #: PiFinder/ui/radec_entry.py:516 msgid "Full" msgstr "Completo" @@ -2088,185 +2030,125 @@ msgstr "ÉPOCA:" msgid "RA/DEC" msgstr "RA/Dec" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:213 -msgid "No release found" -msgstr "Sin versión disponible" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:219 PiFinder/ui/software.py:632 -msgid "System Upgrade" -msgstr "Actualizar sistema" - -#: PiFinder/ui/software.py:259 +#: PiFinder/ui/software.py:87 msgid "Updating..." msgstr "Actualizando..." -#: PiFinder/ui/software.py:261 +#: PiFinder/ui/software.py:89 msgid "Ok! Restarting" msgstr "¡Ok! Reiniciando" -#: PiFinder/ui/software.py:264 +#: PiFinder/ui/software.py:92 msgid "Error on Upd" msgstr "Error en Act." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:271 +#: PiFinder/ui/software.py:99 msgid "Wifi Mode: {mode}" msgstr "Modo WiFi: {mode}" -#: PiFinder/ui/software.py:279 +#: PiFinder/ui/software.py:107 msgid "Current Version" msgstr "Versión Actual" -#: PiFinder/ui/software.py:295 +#: PiFinder/ui/software.py:123 msgid "Release Version" msgstr "Versión de Lanzamiento" -#: PiFinder/ui/software.py:317 +#: PiFinder/ui/software.py:145 msgid "WiFi must be" msgstr "WiFi debe estar" -#: PiFinder/ui/software.py:323 +#: PiFinder/ui/software.py:151 msgid "client mode" msgstr "en modo cliente" -#: PiFinder/ui/software.py:336 +#: PiFinder/ui/software.py:164 msgid "Checking for" msgstr "Verificando" -#: PiFinder/ui/software.py:342 +#: PiFinder/ui/software.py:170 msgid "updates{elipsis}" msgstr "actualizaciones{elipsis}" -#: PiFinder/ui/software.py:358 +#: PiFinder/ui/software.py:186 msgid "No Update" msgstr "Sin Actualización" -#: PiFinder/ui/software.py:364 +#: PiFinder/ui/software.py:192 msgid "needed" msgstr "necesaria" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:374 +#: PiFinder/ui/software.py:202 msgid "Update Now" msgstr "Actualizar Ahora" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:444 -msgid "Major Upgrade" -msgstr "Actualiz. mayor" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:464 -msgid "IRREVERSIBLE" -msgstr "IRREVERSIBLE" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:473 -msgid "Download: {size}MB" -msgstr "Descarga: {size}MB" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:481 -msgid "Power + WiFi req" -msgstr "Req. corriente+WiFi" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:492 -msgid "No checksum avail." -msgstr "Sin suma de verif." - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:552 -msgid "Starting..." -msgstr "Iniciando..." - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:594 PiFinder/ui/software.py:599 -msgid "Not supported" -msgstr "No compatible" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:604 -msgid "Failed: " -msgstr "Error: " - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:735 -msgid "Could not load" -msgstr "No se cargan" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:741 -msgid "release notes" -msgstr "notas versión" - #: PiFinder/ui/sqm.py:25 msgid "SQM" msgstr "SQM" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:41 -msgid "CALIB" -msgstr "CALIB" +#: PiFinder/ui/sqm.py:42 +msgid "CAL" +msgstr "CAL" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:47 -msgid "SWEEP" -msgstr "BARRIDO" +#: PiFinder/ui/sqm.py:48 +msgid "CORRECT" +msgstr "CORREGIR" -#: PiFinder/ui/sqm.py:100 +#: PiFinder/ui/sqm.py:101 msgid "NO SQM DATA" msgstr "SIN DATOS SQM" -#: PiFinder/ui/sqm.py:128 PiFinder/ui/sqm.py:215 +#: PiFinder/ui/sqm.py:129 PiFinder/ui/sqm.py:216 msgid "mag/arcsec²" msgstr "mag/arcseg²" -#: PiFinder/ui/sqm.py:147 PiFinder/ui/sqm.py:250 +#: PiFinder/ui/sqm.py:148 PiFinder/ui/sqm.py:252 msgid "Bortle {bc}" msgstr "Bortle {bc}" -#: PiFinder/ui/sqm.py:156 +#: PiFinder/ui/sqm.py:157 msgid "BACK" msgstr "ATRÁS" -#: PiFinder/ui/sqm.py:157 +#: PiFinder/ui/sqm.py:158 msgid "SCROLL" msgstr "DESPLAZAR" -#: PiFinder/ui/sqm.py:170 +#: PiFinder/ui/sqm.py:171 msgid "{s}s ago" msgstr "hace {s}s" -#: PiFinder/ui/sqm.py:172 +#: PiFinder/ui/sqm.py:173 msgid "{m}m ago" msgstr "hace {m}m" -#: PiFinder/ui/sqm.py:256 +#: PiFinder/ui/sqm.py:258 msgid "DETAILS" msgstr "DETALLES" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:311 +#: PiFinder/ui/sqm.py:316 msgid "SQM Calibration" msgstr "Calibración SQM" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:323 +#: PiFinder/ui/sqm.py:328 msgid "SQM Sweep" msgstr "Barrido SQM" -#: PiFinder/ui/sqm.py:345 +#: PiFinder/ui/sqm.py:350 msgid "Excellent Dark-Sky Site" msgstr "Cielo Oscuro Excelente" -#: PiFinder/ui/sqm.py:349 +#: PiFinder/ui/sqm.py:354 msgid "The zodiacal light is visible and colorful. Gegenschein readily visible." msgstr "La luz zodiacal es visible y colorida. Gegenschein fácilmente visible." -#: PiFinder/ui/sqm.py:352 +#: PiFinder/ui/sqm.py:357 msgid "" "The Scorpius and Sagittarius regions of the Milky Way cast obvious " "shadows." @@ -2274,19 +2156,19 @@ msgstr "" "Las regiones de Escorpio y Sagitario de la Vía Láctea proyectan sombras " "obvias." -#: PiFinder/ui/sqm.py:355 +#: PiFinder/ui/sqm.py:360 msgid "M33 is a direct naked-eye object. Airglow readily visible." msgstr "M33 es un objeto visible a simple vista. Airglow fácilmente visible." -#: PiFinder/ui/sqm.py:356 +#: PiFinder/ui/sqm.py:361 msgid "Abundant stars make faint constellations hard to distinguish." msgstr "Estrellas abundantes dificultan distinguir constelaciones tenues." -#: PiFinder/ui/sqm.py:361 +#: PiFinder/ui/sqm.py:366 msgid "Typical Truly Dark Site" msgstr "Sitio Verdaderamente Oscuro Típico" -#: PiFinder/ui/sqm.py:365 +#: PiFinder/ui/sqm.py:370 msgid "" "The zodiacal light is distinctly yellowish and bright enough to cast " "shadows at dusk and dawn." @@ -2294,25 +2176,25 @@ msgstr "" "La luz zodiacal es distintamente amarillenta y lo suficientemente " "brillante para proyectar sombras al atardecer y amanecer." -#: PiFinder/ui/sqm.py:368 +#: PiFinder/ui/sqm.py:373 msgid "Clouds appear as dark silhouettes against the sky." msgstr "Las nubes aparecen como siluetas oscuras contra el cielo." -#: PiFinder/ui/sqm.py:369 +#: PiFinder/ui/sqm.py:374 msgid "The summer Milky Way is highly structured. M33 easily visible." msgstr "La Vía Láctea de verano está muy estructurada. M33 fácilmente visible." -#: PiFinder/ui/sqm.py:374 +#: PiFinder/ui/sqm.py:379 msgid "Rural Sky" msgstr "Cielo Rural" -#: PiFinder/ui/sqm.py:378 +#: PiFinder/ui/sqm.py:383 msgid "The zodiacal light is striking in spring and autumn, color still visible." msgstr "" "La luz zodiacal es llamativa en primavera y otoño, el color aún es " "visible." -#: PiFinder/ui/sqm.py:381 +#: PiFinder/ui/sqm.py:386 msgid "" "Some light pollution at horizon. Clouds illuminated near horizon, dark " "overhead." @@ -2320,27 +2202,27 @@ msgstr "" "Algo de contaminación lumínica en el horizonte. Nubes iluminadas cerca " "del horizonte, oscuras en el cenit." -#: PiFinder/ui/sqm.py:384 +#: PiFinder/ui/sqm.py:389 msgid "The summer Milky Way still appears complex." msgstr "La Vía Láctea de verano aún aparece compleja." -#: PiFinder/ui/sqm.py:385 +#: PiFinder/ui/sqm.py:390 msgid "Several Messier objects remain naked-eye visible." msgstr "Varios objetos Messier permanecen visibles a simple vista." -#: PiFinder/ui/sqm.py:390 +#: PiFinder/ui/sqm.py:395 msgid "Brighter Rural" msgstr "Rural Más Brillante" -#: PiFinder/ui/sqm.py:394 +#: PiFinder/ui/sqm.py:399 msgid "Zodiacal light still visible but doesn't extend halfway to zenith." msgstr "Luz zodiacal aún visible pero no se extiende hasta la mitad del cenit." -#: PiFinder/ui/sqm.py:397 +#: PiFinder/ui/sqm.py:402 msgid "Light pollution domes apparent in multiple directions." msgstr "Cúpulas de contaminación lumínica aparentes en múltiples direcciones." -#: PiFinder/ui/sqm.py:398 +#: PiFinder/ui/sqm.py:403 msgid "" "The Milky Way well above the horizon is still impressive, but lacks " "detail." @@ -2348,125 +2230,125 @@ msgstr "" "La Vía Láctea bien por encima del horizonte aún es impresionante, pero " "carece de detalle." -#: PiFinder/ui/sqm.py:401 +#: PiFinder/ui/sqm.py:406 msgid "M33 difficult to see." msgstr "M33 difícil de ver." -#: PiFinder/ui/sqm.py:406 +#: PiFinder/ui/sqm.py:411 msgid "Semi-Suburban/Transition Sky" msgstr "Cielo Semi-Suburbano/Transición" -#: PiFinder/ui/sqm.py:410 +#: PiFinder/ui/sqm.py:415 msgid "Clouds have a grayish glow at zenith and appear bright toward city domes." msgstr "" "Las nubes tienen un brillo grisáceo en el cenit y aparecen brillantes " "hacia las cúpulas de la ciudad." -#: PiFinder/ui/sqm.py:413 +#: PiFinder/ui/sqm.py:418 msgid "Milky Way only vaguely visible 10-15° above horizon." msgstr "Vía Láctea solo vagamente visible 10-15° sobre el horizonte." -#: PiFinder/ui/sqm.py:414 +#: PiFinder/ui/sqm.py:419 msgid "Great Rift observable overhead." msgstr "Gran Grieta observable en el cenit." -#: PiFinder/ui/sqm.py:419 +#: PiFinder/ui/sqm.py:424 msgid "Suburban Sky" msgstr "Cielo Suburbano" -#: PiFinder/ui/sqm.py:423 +#: PiFinder/ui/sqm.py:428 msgid "Only hints of zodiacal light seen on best nights in autumn and spring." msgstr "" "Solo indicios de luz zodiacal vistos en las mejores noches de otoño y " "primavera." -#: PiFinder/ui/sqm.py:426 +#: PiFinder/ui/sqm.py:431 msgid "Light pollution visible in most, if not all, directions." msgstr "" "Contaminación lumínica visible en la mayoría, si no en todas, las " "direcciones." -#: PiFinder/ui/sqm.py:427 +#: PiFinder/ui/sqm.py:432 msgid "Clouds noticeably brighter than the sky." msgstr "Nubes notablemente más brillantes que el cielo." -#: PiFinder/ui/sqm.py:428 +#: PiFinder/ui/sqm.py:433 msgid "Milky Way invisible near horizon, looks washed out overhead." msgstr "Vía Láctea invisible cerca del horizonte, parece deslavada en el cenit." -#: PiFinder/ui/sqm.py:433 +#: PiFinder/ui/sqm.py:438 msgid "Bright Suburban Sky" msgstr "Cielo Suburbano Brillante" -#: PiFinder/ui/sqm.py:437 +#: PiFinder/ui/sqm.py:442 msgid "The zodiacal light is invisible." msgstr "La luz zodiacal es invisible." -#: PiFinder/ui/sqm.py:438 +#: PiFinder/ui/sqm.py:443 msgid "Light pollution makes sky within 35° of horizon glow grayish white." msgstr "" "La contaminación lumínica hace que el cielo dentro de 35° del horizonte " "brille gris blanquecino." -#: PiFinder/ui/sqm.py:441 +#: PiFinder/ui/sqm.py:446 msgid "The Milky Way is only visible near the zenith. M33 undetectable." msgstr "La Vía Láctea solo es visible cerca del cenit. M33 indetectable." -#: PiFinder/ui/sqm.py:444 +#: PiFinder/ui/sqm.py:449 msgid "M31 modestly apparent. Surroundings easily visible." msgstr "M31 modestamente aparente. Alrededores fácilmente visibles." -#: PiFinder/ui/sqm.py:449 +#: PiFinder/ui/sqm.py:454 msgid "Suburban/Urban Transition" msgstr "Transición Suburbana/Urbana" -#: PiFinder/ui/sqm.py:453 +#: PiFinder/ui/sqm.py:458 msgid "Light pollution makes the entire sky light gray." msgstr "La contaminación lumínica hace que todo el cielo sea gris claro." -#: PiFinder/ui/sqm.py:454 +#: PiFinder/ui/sqm.py:459 msgid "Strong light sources evident in all directions." msgstr "Fuentes de luz fuertes evidentes en todas direcciones." -#: PiFinder/ui/sqm.py:455 +#: PiFinder/ui/sqm.py:460 msgid "The Milky Way is nearly or totally invisible." msgstr "La Vía Láctea es casi o totalmente invisible." -#: PiFinder/ui/sqm.py:456 +#: PiFinder/ui/sqm.py:461 msgid "M31 and M44 may be glimpsed, but with no detail." msgstr "M31 y M44 pueden vislumbrarse, pero sin detalle." -#: PiFinder/ui/sqm.py:461 +#: PiFinder/ui/sqm.py:466 msgid "City Sky" msgstr "Cielo de Ciudad" -#: PiFinder/ui/sqm.py:465 +#: PiFinder/ui/sqm.py:470 msgid "The sky is light gray or orange—one can easily read." msgstr "El cielo es gris claro o naranja—uno puede leer fácilmente." -#: PiFinder/ui/sqm.py:466 +#: PiFinder/ui/sqm.py:471 msgid "Stars forming recognizable patterns may vanish entirely." msgstr "" "Estrellas que forman patrones reconocibles pueden desaparecer por " "completo." -#: PiFinder/ui/sqm.py:467 +#: PiFinder/ui/sqm.py:472 msgid "Only bright Messier objects can be detected with telescopes." msgstr "Solo objetos Messier brillantes pueden detectarse con telescopios." -#: PiFinder/ui/sqm.py:472 +#: PiFinder/ui/sqm.py:477 msgid "Inner-City Sky" msgstr "Cielo Centro de Ciudad" -#: PiFinder/ui/sqm.py:476 +#: PiFinder/ui/sqm.py:481 msgid "The sky is brilliantly lit." msgstr "El cielo está brillantemente iluminado." -#: PiFinder/ui/sqm.py:477 +#: PiFinder/ui/sqm.py:482 msgid "Many stars forming constellations invisible." msgstr "Muchas estrellas que forman constelaciones son invisibles." -#: PiFinder/ui/sqm.py:478 +#: PiFinder/ui/sqm.py:483 msgid "" "Only the Moon, planets, bright satellites, and a few of the brightest " "star clusters observable." @@ -2475,36 +2357,59 @@ msgstr "" "estelares más brillantes son observables." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:69 -msgid "Stop replay" -msgstr "Detener repetición" +#: PiFinder/ui/sqm_correction.py:45 PiFinder/ui/sqm_correction.py:87 +msgid "SQM Correction" +msgstr "Corrección SQM" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:93 -msgid "" -"No integrator\n" -"queue" -msgstr "" -"Sin cola del\n" -"integrador" +#: PiFinder/ui/sqm_correction.py:72 +msgid "Del" +msgstr "Elim" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:99 -msgid "" -"Replay\n" -"stopped" -msgstr "" -"Repetición\n" -"detenida" +#: PiFinder/ui/sqm_correction.py:106 +msgid "Original: {sqm:.2f}" +msgstr "Original: {sqm:.2f}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:105 -msgid "" -"Replay\n" -"started" -msgstr "" -"Repetición\n" -"iniciada" +#: PiFinder/ui/sqm_correction.py:115 +msgid "Corrected:" +msgstr "Corregido:" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:211 +msgid "Enter a value" +msgstr "Ingresar un valor" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:213 +msgid "Range: 10-23" +msgstr "Rango: 10-23" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:218 +msgid "Saving..." +msgstr "Guardando..." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:225 +msgid "Saved: {filename}" +msgstr "Guardado: {filename}" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:232 +msgid "Save failed" +msgstr "Error al guardar" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:340 +msgid "Saving {label}..." +msgstr "Guardando {label}..." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/telemetry_list.py:69 +msgid "Stop replay" +msgstr "Detener repetición" #: PiFinder/ui/text_menu.py:61 msgid "Select None" @@ -2540,6 +2445,39 @@ msgstr "hh" msgid "ss" msgstr "ss" +#: PiFinder/ui/timeentry.py:116 +msgid "Enter Local Time" +msgstr "Ingresar Hora Local" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:527 +msgid "" +"Network settings updated — no restart needed. This device is now " +"reachable at http://{host}.local. If you changed the host name, the " +"previous address stops working, so reconnect there." +msgstr "" +"Configuración de red actualizada: no es necesario reiniciar. Este dispositivo ahora es accesible en http://{host}.local. Si cambió el nombre de host, la dirección anterior deja de funcionar; vuelva a conectarse allí." + +# AI-TRANSLATED (claude): needs human review +#: views/network.html:39 +msgid "Update" +msgstr "Actualizar" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/menu_structure.py:626 +msgid "Volume" +msgstr "Volumen" + +# AI-TRANSLATED (claude): needs human review +msgid "Low battery\nat {pct}%" +msgstr "Batería baja\nal {pct}%" + +# AI-TRANSLATED (claude): needs human review +msgid "Low battery\nShutting down" +msgstr "Batería baja\nApagando" + +#~ msgid "Integrator" +#~ msgstr "" # AI-TRANSLATED (claude): needs human review #: views/advanced.html:7 msgid "GPS location lock" @@ -2885,6 +2823,16 @@ msgstr "Gestión de ubicaciones" msgid "Add New Location" msgstr "Agregar nueva ubicación" +# AI-TRANSLATED (claude): needs human review +#: views/locations.html:63 +msgid "Latitude" +msgstr "Latitud" + +# AI-TRANSLATED (claude): needs human review +#: views/locations.html:64 +msgid "Longitude" +msgstr "Longitud" + # AI-TRANSLATED (claude): needs human review #: views/locations.html:89 msgid "Set as Default" @@ -2921,37 +2869,37 @@ msgid "This action cannot be undone." msgstr "Esta acción no se puede deshacer." # AI-TRANSLATED (claude): needs human review -#: views/locations.html:435 views/locations.html:523 views/locations.html:580 +#: views/locations.html:428 views/locations.html:516 views/locations.html:565 msgid "This field is required" msgstr "Este campo es obligatorio" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:437 +#: views/locations.html:430 msgid "Must be a valid number" msgstr "Debe ser un número válido" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:441 +#: views/locations.html:434 msgid "Must be between -90 and 90" msgstr "Debe estar entre -90 y 90" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:444 +#: views/locations.html:437 msgid "Must be between -180 and 180" msgstr "Debe estar entre -180 y 180" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:447 +#: views/locations.html:440 msgid "Must be between -1000 and 10000 meters" msgstr "Debe estar entre -1000 y 10000 metros" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:450 +#: views/locations.html:443 msgid "Must be between 0 and 10000 meters" msgstr "Debe estar entre 0 y 10000 metros" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:548 +#: views/locations.html:533 msgid "Please fix the validation errors before saving" msgstr "Corrige los errores de validación antes de guardar" @@ -3312,9 +3260,6 @@ msgstr "" "Sobrescribirá cualquier preferencia y observación existente. ¿Estás " "seguro?" -#~ msgid "Integrator" -#~ msgstr "" - # AI-TRANSLATED (claude): needs human review #~ msgid "AE Algo" #~ msgstr "Algo. AE" @@ -3358,81 +3303,3 @@ msgstr "" #~ msgid "T9 Search" #~ msgstr "Búsqueda T9" -#~ msgid "Cluster/Neb" -#~ msgstr "Cúmulo/Neb" - -#~ msgid "P. Nebula" -#~ msgstr "N. Planetaria" - -#~ msgid "Double Str" -#~ msgstr "Str Doble" - -#~ msgid "Triple Str" -#~ msgstr "Str Triple" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Unknown" -#~ msgstr "Desconocido" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "keep going" -#~ msgstr "continúa" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "det {n}" -#~ msgstr "det {n}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Zoom x{zoom_number}" -#~ msgstr "Zoom x{zoom_number}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "CORRECT" -#~ msgstr "CORREGIR" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "SQM Correction" -#~ msgstr "Corrección SQM" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Del" -#~ msgstr "Elim" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Original: {sqm:.2f}" -#~ msgstr "Original: {sqm:.2f}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Corrected:" -#~ msgstr "Corregido:" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Enter a value" -#~ msgstr "Ingresar un valor" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Range: 10-23" -#~ msgstr "Rango: 10-23" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saving..." -#~ msgstr "Guardando..." - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saved: {filename}" -#~ msgstr "Guardado: {filename}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Save failed" -#~ msgstr "Error al guardar" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saving {label}..." -#~ msgstr "Guardando {label}..." - -#~ msgid "Enter Local Time" -#~ msgstr "Ingresar Hora Local" - -#~ msgid "Download: {}MB" -#~ msgstr "" - diff --git a/python/locale/fr/LC_MESSAGES/messages.mo b/python/locale/fr/LC_MESSAGES/messages.mo index 0fef898a7..c8785aede 100644 Binary files a/python/locale/fr/LC_MESSAGES/messages.mo and b/python/locale/fr/LC_MESSAGES/messages.mo differ diff --git a/python/locale/fr/LC_MESSAGES/messages.po b/python/locale/fr/LC_MESSAGES/messages.po index ec308f218..3a8f0b3c0 100644 --- a/python/locale/fr/LC_MESSAGES/messages.po +++ b/python/locale/fr/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-07-30 18:03-0700\n" +"POT-Creation-Date: 2026-06-22 09:17-0700\n" "PO-Revision-Date: 2025-01-12 18:13+0100\n" "Last-Translator: xxxxxx \n" "Language: fr_FR\n" @@ -22,7 +22,7 @@ msgid "No Image" msgstr "Pas d'image" # AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:703 +#: PiFinder/main.py:648 msgid "" "Degraded\n" "Check Status" @@ -31,7 +31,7 @@ msgstr "" "Vérifier état" # AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:810 +#: PiFinder/main.py:750 msgid "" "Catalogs\n" "Fully Loaded" @@ -39,306 +39,256 @@ msgstr "" "Catalogues\n" "Chargés" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:860 -msgid "" -"Low battery\n" -"Shutting down" -msgstr "" -"Batterie faible\n" -"Arrêt" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:893 -msgid "" -"Low battery\n" -"at {pct}%" -msgstr "" -"Batterie faible\n" -"à {pct}%" - -#: PiFinder/obj_types.py:10 +#: PiFinder/obj_types.py:7 PiFinder/ui/menu_structure.py:422 msgid "Galaxy" msgstr "Galaxie" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:11 +#: PiFinder/obj_types.py:8 PiFinder/ui/menu_structure.py:426 msgid "Open Cluster" msgstr "Amas Ouvert" -# AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:12 -msgid "Cluster + Neb" -msgstr "Amas + nébuleuse" - -#. TRANSLATORS: Object type -#: PiFinder/obj_types.py:13 +#: PiFinder/obj_types.py:9 PiFinder/ui/menu_structure.py:434 msgid "Globular" msgstr "Amas Globulaire" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:14 +#: PiFinder/obj_types.py:10 PiFinder/ui/menu_structure.py:438 msgid "Nebula" msgstr "Nébuleuse" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:15 -msgid "Planetary" -msgstr "Planétaire" - -# AI-TRANSLATED (claude): needs human review -#. TRANSLATORS: Object type -#: PiFinder/obj_types.py:16 +#: PiFinder/obj_types.py:11 PiFinder/ui/menu_structure.py:446 msgid "Dark Nebula" msgstr "Nébuleuse obscure" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:17 -msgid "Star" -msgstr "Étoile" +#: PiFinder/obj_types.py:12 +msgid "Planetary" +msgstr "Planétaire" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:18 -msgid "Double star" -msgstr "Étoile Double" +#: PiFinder/obj_types.py:13 +msgid "Cluster + Neb" +msgstr "Amas + nébuleuse" -# AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:19 -msgid "Triple star" -msgstr "Étoile Triple" +#: PiFinder/obj_types.py:14 PiFinder/ui/menu_structure.py:466 +msgid "Asterism" +msgstr "Asterisme" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:20 +#: PiFinder/obj_types.py:15 PiFinder/ui/menu_structure.py:462 msgid "Knot" msgstr "Knot" +# AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:21 -msgid "Asterism" -msgstr "Asterisme" +#: PiFinder/obj_types.py:16 +msgid "Triple star" +msgstr "Étoile Triple" +# AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:22 -msgid "Planet" -msgstr "Planète" +#: PiFinder/obj_types.py:17 +msgid "Double star" +msgstr "Étoile Double" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:23 -msgid "Comet" -msgstr "Comète" +#: PiFinder/obj_types.py:18 PiFinder/ui/menu_structure.py:450 +msgid "Star" +msgstr "Étoile" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:24 +#: PiFinder/obj_types.py:19 msgid "Unkn" msgstr "Inconnu" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:54 -#, python-format -msgid "%s is required" -msgstr "%s est obligatoire" +#. TRANSLATORS: Object type +#: PiFinder/obj_types.py:20 PiFinder/ui/menu_structure.py:470 +msgid "Planet" +msgstr "Planète" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:58 -#, python-format -msgid "%s must be a number" -msgstr "%s doit être un nombre" +#. TRANSLATORS: Object type +#: PiFinder/obj_types.py:21 PiFinder/ui/menu_structure.py:474 +msgid "Comet" +msgstr "Comète" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:231 +#: PiFinder/server.py:219 msgid "Locked" msgstr "Verrouillé" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:231 +#: PiFinder/server.py:219 msgid "Not Locked" msgstr "Non verrouillé" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:253 views/base.html:17 views/base.html:28 +#: PiFinder/server.py:241 views/base.html:17 views/base.html:28 msgid "Home" msgstr "Accueil" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:281 PiFinder/server.py:289 views/login.html:31 +#: PiFinder/server.py:269 PiFinder/server.py:277 views/login.html:31 msgid "Login" msgstr "Connexion" -#: PiFinder/server.py:283 +#: PiFinder/server.py:271 msgid "Invalid Password" msgstr "Mot de passe invalide" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:295 views/base.html:18 views/base.html:29 +#: PiFinder/server.py:283 views/base.html:18 views/base.html:29 msgid "Remote" msgstr "Télécommande" -#: PiFinder/server.py:301 PiFinder/ui/menu_structure.py:995 +#: PiFinder/server.py:289 PiFinder/ui/menu_structure.py:1033 msgid "Advanced" msgstr "Avancé" -#: PiFinder/server.py:310 +#: PiFinder/server.py:298 msgid "Network" msgstr "Réseau" -#: PiFinder/server.py:328 +#: PiFinder/server.py:316 msgid "GPS" msgstr "GPS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:362 PiFinder/server.py:415 PiFinder/server.py:468 +#: PiFinder/server.py:350 PiFinder/server.py:401 PiFinder/server.py:452 #: views/base.html:21 views/base.html:32 msgid "Locations" msgstr "Localisations" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:372 PiFinder/server.py:432 views/locations.html:63 -msgid "Latitude" -msgstr "Latitude" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:373 PiFinder/server.py:433 views/locations.html:64 -msgid "Longitude" -msgstr "Longitude" - -#: PiFinder/server.py:374 PiFinder/server.py:434 -#: PiFinder/ui/menu_structure.py:431 views/locations.html:65 -msgid "Altitude" -msgstr "Altitude" - -#: PiFinder/server.py:376 PiFinder/server.py:436 PiFinder/ui/object_list.py:309 -#: views/locations.html:66 -msgid "Error" -msgstr "Erreur" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:382 PiFinder/server.py:442 +#: PiFinder/server.py:368 PiFinder/server.py:426 msgid "Location name is required" msgstr "Nom de localisation requis" -#: PiFinder/server.py:384 PiFinder/server.py:444 +#: PiFinder/server.py:370 PiFinder/server.py:428 msgid "Latitude must be between -90 and 90" msgstr "Latitude doit être entre -90 et 90" -#: PiFinder/server.py:386 PiFinder/server.py:446 +#: PiFinder/server.py:372 PiFinder/server.py:430 msgid "Longitude must be between -180 and 180" msgstr "Longitude doit être entre -180 et 180" -#: PiFinder/server.py:389 PiFinder/server.py:449 +#: PiFinder/server.py:375 PiFinder/server.py:433 msgid "Altitude must be between -1000 and 10000 meters" msgstr "Altitude doit être entre -1000 et 10000 metres" -#: PiFinder/server.py:392 PiFinder/server.py:452 +#: PiFinder/server.py:378 PiFinder/server.py:436 msgid "Error must be between 0 and 10000 meters" msgstr "Erreur, doit être entre 0 et 10000 metres" -#: PiFinder/server.py:539 PiFinder/ui/menu_structure.py:1283 +#: PiFinder/server.py:523 PiFinder/ui/menu_structure.py:1302 msgid "Restart" msgstr "Redémarrage" -#: PiFinder/server.py:550 PiFinder/server.py:559 PiFinder/server.py:563 -#: PiFinder/server.py:567 PiFinder/server.py:922 -#: PiFinder/ui/menu_structure.py:1151 views/base.html:23 views/base.html:34 +#: PiFinder/server.py:534 PiFinder/server.py:543 PiFinder/server.py:547 +#: PiFinder/server.py:551 PiFinder/server.py:906 +#: PiFinder/ui/menu_structure.py:1173 views/base.html:23 views/base.html:34 #: views/tools.html:6 msgid "Tools" msgstr "Outils" -#: PiFinder/server.py:551 +#: PiFinder/server.py:535 msgid "You must fill in all password fields" msgstr "Vous devez remplir tous les champs du mot de passe" -#: PiFinder/server.py:559 +#: PiFinder/server.py:543 msgid "Password Changed" msgstr "Mot de passe changé" -#: PiFinder/server.py:563 +#: PiFinder/server.py:547 msgid "Incorrect current password" msgstr "Mot de passe actuel incorrect" -#: PiFinder/server.py:567 +#: PiFinder/server.py:551 msgid "New passwords do not match" msgstr "les nouveaux mots de passe ne sont pas identiques" -#: PiFinder/server.py:592 PiFinder/server.py:603 PiFinder/server.py:620 -#: PiFinder/server.py:702 PiFinder/server.py:758 PiFinder/server.py:771 -#: PiFinder/server.py:844 PiFinder/server.py:857 -#: PiFinder/ui/menu_structure.py:1156 views/base.html:22 views/base.html:33 +#: PiFinder/server.py:576 PiFinder/server.py:587 PiFinder/server.py:604 +#: PiFinder/server.py:686 PiFinder/server.py:742 PiFinder/server.py:755 +#: PiFinder/server.py:828 PiFinder/server.py:841 +#: PiFinder/ui/menu_structure.py:1178 views/base.html:22 views/base.html:33 #: views/equipment.html:6 msgid "Equipment" msgstr "Equipment" -#: PiFinder/server.py:609 +#: PiFinder/server.py:593 msgid "set as active instrument." msgstr "choisi comme instrument actif" -#: PiFinder/server.py:626 +#: PiFinder/server.py:610 msgid "set as active eyepiece." msgstr "choisi comme oculaire actif" -#: PiFinder/server.py:704 +#: PiFinder/server.py:688 msgid "Equipment Imported, restart your PiFinder to use this new data" msgstr "" "Equipement importé, redemerage du PiFinder pour utilisation de ces " "nouvelles données" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:720 +#: PiFinder/server.py:704 msgid "Edit Eyepiece" msgstr "Modifier oculaire" -#: PiFinder/server.py:760 +#: PiFinder/server.py:744 msgid "Eyepiece added, restart your PiFinder to use" msgstr "Oculaire ajouté, redemarage du PiFinder pour utilisation" -#: PiFinder/server.py:773 +#: PiFinder/server.py:757 msgid "Eyepiece Deleted, restart your PiFinder to remove from menu" msgstr "Oculaire supprimé, redémarage du PiFinder pour le supprimer du menu" -#: PiFinder/server.py:798 +#: PiFinder/server.py:782 msgid "Edit Instrument" msgstr "Editer instrument" -#: PiFinder/server.py:846 +#: PiFinder/server.py:830 msgid "Instrument Added, restart your PiFinder to use" msgstr "instrument ajouté, redemarage du PiFinder pour utilisation" -#: PiFinder/server.py:859 +#: PiFinder/server.py:843 msgid "Instrument Deleted, restart your PiFinder to remove from menu" msgstr "instrument supprimé, redémarage du PiFinder pour le supprimer du menu" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:887 views/base.html:20 views/base.html:31 +#: PiFinder/server.py:871 views/base.html:20 views/base.html:31 msgid "Observations" msgstr "Observations" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:916 +#: PiFinder/server.py:900 msgid "Session Log" msgstr "Journal de session" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:927 PiFinder/server.py:1025 views/base.html:24 +#: PiFinder/server.py:911 PiFinder/server.py:1010 views/base.html:24 #: views/base.html:35 msgid "Logs" msgstr "Journaux" -#: PiFinder/server.py:1025 +#: PiFinder/server.py:1010 msgid "Error creating log archive" -msgstr "Erreur dans la création des logs" +msgstr "Erreur dans la création des logs " # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:1082 views/restart_pifinder.html:6 +#: PiFinder/server.py:1067 views/restart_pifinder.html:6 msgid "Restarting PiFinder" msgstr "Redémarrage PiFinder" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:1144 +#: PiFinder/server.py:1129 msgid "Restart PiFinder" msgstr "Redémarrer PiFinder" @@ -368,23 +318,23 @@ msgstr "{icon} CHOISIR ÉTOILE" msgid "{icon} SAVE / 0 CANCEL" msgstr "{icon} SAUVER / 0 ABANDON" -#: PiFinder/ui/align.py:279 PiFinder/ui/chart.py:421 +#: PiFinder/ui/align.py:279 PiFinder/ui/chart.py:273 msgid "Can't plot" msgstr "Pas d'astrométrie" -#: PiFinder/ui/align.py:288 PiFinder/ui/chart.py:430 PiFinder/ui/log.py:166 -#: PiFinder/ui/object_list.py:331 PiFinder/ui/object_list.py:351 +#: PiFinder/ui/align.py:288 PiFinder/ui/chart.py:282 PiFinder/ui/log.py:166 +#: PiFinder/ui/object_list.py:286 PiFinder/ui/object_list.py:306 msgid "No Solve Yet" msgstr "Pas d'astrometrie" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/align.py:399 PiFinder/ui/object_details.py:682 +#: PiFinder/ui/align.py:399 PiFinder/ui/object_details.py:675 msgid "Aligning..." msgstr "Alignement..." # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/align.py:407 PiFinder/ui/align_daytime.py:310 -#: PiFinder/ui/object_details.py:690 +#: PiFinder/ui/object_details.py:683 msgid "Aligned!" msgstr "Aligné!" @@ -413,13 +363,7 @@ msgid "AUTO" msgstr "AUTO" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/base.py:300 PiFinder/ui/dateentry.py:166 -#: PiFinder/ui/locationentry.py:222 PiFinder/ui/timeentry.py:171 -msgid " Cancel" -msgstr " Annuler" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:52 +#: PiFinder/ui/callbacks.py:51 msgid "" "Options for\n" "DIY PiFinders" @@ -427,38 +371,38 @@ msgstr "" "Options pour\n" "DIY PiFinders" -#: PiFinder/ui/callbacks.py:67 +#: PiFinder/ui/callbacks.py:66 msgid "Filters Reset" msgstr "RàZ filtres" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:80 PiFinder/ui/menu_structure.py:1202 +#: PiFinder/ui/callbacks.py:79 PiFinder/ui/menu_structure.py:1221 msgid "Test Mode" msgstr "Mode test" -#: PiFinder/ui/callbacks.py:174 +#: PiFinder/ui/callbacks.py:161 msgid "Shutting Down" msgstr "Arrêt" -#: PiFinder/ui/callbacks.py:183 PiFinder/ui/callbacks.py:191 +#: PiFinder/ui/callbacks.py:170 PiFinder/ui/callbacks.py:178 msgid "Restarting..." msgstr "Redémarrage..." -#: PiFinder/ui/callbacks.py:196 PiFinder/ui/callbacks.py:202 -#: PiFinder/ui/callbacks.py:208 +#: PiFinder/ui/callbacks.py:183 PiFinder/ui/callbacks.py:189 +#: PiFinder/ui/callbacks.py:195 msgid "Switching cam" msgstr "Bascule Caméra" -#: PiFinder/ui/callbacks.py:246 +#: PiFinder/ui/callbacks.py:233 msgid "WiFi to AP" msgstr "Wifi vers AP" -#: PiFinder/ui/callbacks.py:252 +#: PiFinder/ui/callbacks.py:239 msgid "WiFi to Client" msgstr "Wifi vers Client" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:275 +#: PiFinder/ui/callbacks.py:262 msgid "" "{lat:.2f}, {lon:.2f}\n" "{alt}m alt" @@ -467,22 +411,22 @@ msgstr "" "{alt}m alt" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:282 +#: PiFinder/ui/callbacks.py:269 msgid "Location Reset" msgstr "Réinit. position" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:287 +#: PiFinder/ui/callbacks.py:274 msgid "Time/Date Reset" msgstr "Réinit. heure/date" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:294 +#: PiFinder/ui/callbacks.py:281 msgid "No location lock" msgstr "Position non verrouillée" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:308 +#: PiFinder/ui/callbacks.py:295 msgid "" "Saved\n" "{name}" @@ -491,22 +435,22 @@ msgstr "" "{name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:312 PiFinder/ui/gpsstatus.py:79 -#: PiFinder/ui/location_list.py:135 views/location_form.html:6 +#: PiFinder/ui/callbacks.py:299 PiFinder/ui/gpsstatus.py:79 +#: PiFinder/ui/location_list.py:137 views/location_form.html:6 #: views/locations.html:117 msgid "Location Name" msgstr "Nom de lieu" -#: PiFinder/ui/callbacks.py:315 PiFinder/ui/gpsstatus.py:82 +#: PiFinder/ui/callbacks.py:302 PiFinder/ui/gpsstatus.py:82 msgid "Loc {number}" msgstr "Loc {number}" -#: PiFinder/ui/callbacks.py:346 +#: PiFinder/ui/callbacks.py:329 msgid "Time: {time}" msgstr "Temps: {time}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:364 +#: PiFinder/ui/callbacks.py:347 msgid "" "{date}\n" "{time}" @@ -515,7 +459,7 @@ msgstr "" "{time}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:384 +#: PiFinder/ui/callbacks.py:367 msgid "" "User object created\n" "{name}" @@ -524,7 +468,7 @@ msgstr "" "{name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:514 +#: PiFinder/ui/callbacks.py:497 msgid "" "Checking GPS\n" "config..." @@ -533,7 +477,7 @@ msgstr "" "GPS..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:519 +#: PiFinder/ui/callbacks.py:502 msgid "" "GPS config\n" "updated" @@ -542,7 +486,7 @@ msgstr "" "mise à jour" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:521 +#: PiFinder/ui/callbacks.py:504 msgid "" "GPS config\n" "OK" @@ -551,7 +495,7 @@ msgstr "" "OK" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:524 +#: PiFinder/ui/callbacks.py:507 msgid "" "GPS config\n" "failed" @@ -559,13 +503,13 @@ msgstr "" "Config GPS\n" "échouée" -#: PiFinder/ui/chart.py:92 PiFinder/ui/menu_structure.py:543 +#: PiFinder/ui/chart.py:50 PiFinder/ui/menu_structure.py:596 msgid "Settings" msgstr "Réglages" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: chart corner label, e.g. "Zenith up" — keep short -#: PiFinder/ui/chart.py:273 +#: PiFinder/ui/chart.py:125 msgid "{label} up" msgstr "{label} haut" @@ -585,36 +529,33 @@ msgid "dd" msgstr "jj" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:141 +#: PiFinder/ui/dateentry.py:130 msgid "Enter Local Date" msgstr "Entrez date locale" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:159 PiFinder/ui/timeentry.py:164 +#: PiFinder/ui/dateentry.py:148 PiFinder/ui/timeentry.py:135 msgid " Done" msgstr " Fait" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:173 PiFinder/ui/locationentry.py:232 -#: PiFinder/ui/timeentry.py:178 +#: PiFinder/ui/dateentry.py:155 PiFinder/ui/locationentry.py:222 +#: PiFinder/ui/timeentry.py:142 +msgid " Cancel" +msgstr " Annuler" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/dateentry.py:162 PiFinder/ui/locationentry.py:232 +#: PiFinder/ui/timeentry.py:149 msgid "󰍴 Delete/Previous" msgstr "󰍴 Effacer/Préc." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:236 PiFinder/ui/locationentry.py:346 -#: PiFinder/ui/polar_align.py:525 PiFinder/ui/timeentry.py:252 +#: PiFinder/ui/dateentry.py:219 PiFinder/ui/locationentry.py:346 +#: PiFinder/ui/polar_align.py:516 PiFinder/ui/timeentry.py:217 msgid "Cancelled" msgstr "Annulé" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:250 PiFinder/ui/timeentry.py:270 -msgid "" -"Set location\n" -"first" -msgstr "" -"Définir d'abord\n" -"la position" - #: PiFinder/ui/equipment.py:39 msgid "No telescope selected" msgstr "Pas de telescope choisi" @@ -661,7 +602,8 @@ msgid "Precise" msgstr "Précis" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/gpsstatus.py:45 views/gps.html:77 views/network.html:81 +#: PiFinder/ui/gpsstatus.py:45 PiFinder/ui/sqm_correction.py:71 +#: views/gps.html:77 views/network.html:81 msgid "Save" msgstr "Sauvegarder" @@ -716,8 +658,8 @@ msgstr "pour un verouillage rapide" msgid "Lock Type:" msgstr "Type verrou:" -#: PiFinder/ui/gpsstatus.py:211 PiFinder/ui/menu_structure.py:437 -#: PiFinder/ui/menu_structure.py:469 views/network.html:74 +#: PiFinder/ui/gpsstatus.py:211 PiFinder/ui/menu_structure.py:490 +#: PiFinder/ui/menu_structure.py:522 views/network.html:74 msgid "None" msgstr "Aucun" @@ -772,7 +714,7 @@ msgid "From: {location_source}" msgstr "Depuis: {location_source}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:28 PiFinder/ui/menu_structure.py:1257 +#: PiFinder/ui/location_list.py:28 PiFinder/ui/menu_structure.py:1276 msgid "Load" msgstr "Charger" @@ -794,12 +736,12 @@ msgid "Loaded: {name}" msgstr "Chargé : {name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:128 +#: PiFinder/ui/location_list.py:129 msgid "Deleted: {name}" msgstr "Supprimé : {name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:148 +#: PiFinder/ui/location_list.py:150 msgid "" "Renamed to:\n" "{name}" @@ -808,7 +750,7 @@ msgstr "" "{name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:187 +#: PiFinder/ui/location_list.py:189 msgid "No locations" msgstr "Aucune position" @@ -859,7 +801,7 @@ msgstr "󰍴 Effacer 󰐕 E/O" # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/locationentry.py:312 PiFinder/ui/locationentry.py:324 -#: PiFinder/ui/menu_structure.py:1172 +#: PiFinder/ui/menu_structure.py:1194 msgid "Enter Coords" msgstr "Entrer coords" @@ -939,706 +881,689 @@ msgid "Telescope" msgstr "Télescope" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:31 +#: PiFinder/ui/menu_structure.py:30 msgid "Language: de" msgstr "Langue: de" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:32 +#: PiFinder/ui/menu_structure.py:31 msgid "Language: en" msgstr "Langue: en" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:33 +#: PiFinder/ui/menu_structure.py:32 msgid "Language: es" msgstr "Langue: es" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:34 +#: PiFinder/ui/menu_structure.py:33 msgid "Language: fr" msgstr "Langue: fr" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:35 +#: PiFinder/ui/menu_structure.py:34 msgid "Language: zh" msgstr "Langue: zh" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:46 +#: PiFinder/ui/menu_structure.py:45 msgid "Start" msgstr "Démarrer" -#: PiFinder/ui/menu_structure.py:51 +#: PiFinder/ui/menu_structure.py:50 msgid "Focus" msgstr "Mise au point" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:55 +#: PiFinder/ui/menu_structure.py:54 msgid "Align" msgstr "Aligner" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:61 +#: PiFinder/ui/menu_structure.py:60 msgid "Align (Day)" msgstr "Alignement (jour)" -#: PiFinder/ui/menu_structure.py:67 PiFinder/ui/menu_structure.py:1163 +#: PiFinder/ui/menu_structure.py:66 PiFinder/ui/menu_structure.py:1185 msgid "GPS Status" msgstr "Status du GPS" -#: PiFinder/ui/menu_structure.py:73 +#: PiFinder/ui/menu_structure.py:72 msgid "Chart" msgstr "Cartes" -#: PiFinder/ui/menu_structure.py:79 views/obs_session_log.html:7 +#: PiFinder/ui/menu_structure.py:78 views/obs_session_log.html:7 #: views/obs_sessions.html:11 views/obs_sessions.html:25 msgid "Objects" msgstr "Objets" -#: PiFinder/ui/menu_structure.py:84 +#: PiFinder/ui/menu_structure.py:83 msgid "All Filtered" msgstr "Tous filtres" -#: PiFinder/ui/menu_structure.py:89 +#: PiFinder/ui/menu_structure.py:88 msgid "By Catalog" msgstr "Par catalogue" -#: PiFinder/ui/menu_structure.py:94 PiFinder/ui/menu_structure.py:303 +#: PiFinder/ui/menu_structure.py:93 PiFinder/ui/menu_structure.py:302 msgid "Planets" msgstr "Planètes" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:100 PiFinder/ui/menu_structure.py:307 +#: PiFinder/ui/menu_structure.py:99 PiFinder/ui/menu_structure.py:306 msgid "Comets" msgstr "Comètes" -#: PiFinder/ui/menu_structure.py:106 PiFinder/ui/menu_structure.py:189 -#: PiFinder/ui/menu_structure.py:311 PiFinder/ui/menu_structure.py:369 +#: PiFinder/ui/menu_structure.py:105 PiFinder/ui/menu_structure.py:188 +#: PiFinder/ui/menu_structure.py:310 PiFinder/ui/menu_structure.py:368 msgid "NGC" msgstr "NGC" -#: PiFinder/ui/menu_structure.py:112 PiFinder/ui/menu_structure.py:183 -#: PiFinder/ui/menu_structure.py:315 PiFinder/ui/menu_structure.py:365 +#: PiFinder/ui/menu_structure.py:111 PiFinder/ui/menu_structure.py:182 +#: PiFinder/ui/menu_structure.py:314 PiFinder/ui/menu_structure.py:364 msgid "Messier" msgstr "Messier" -#: PiFinder/ui/menu_structure.py:118 PiFinder/ui/menu_structure.py:319 +#: PiFinder/ui/menu_structure.py:117 PiFinder/ui/menu_structure.py:318 msgid "DSO..." msgstr "DSO..." -#: PiFinder/ui/menu_structure.py:123 PiFinder/ui/menu_structure.py:325 +#: PiFinder/ui/menu_structure.py:122 PiFinder/ui/menu_structure.py:324 msgid "Abell Pn" msgstr "Abell Pn" -#: PiFinder/ui/menu_structure.py:129 PiFinder/ui/menu_structure.py:329 +#: PiFinder/ui/menu_structure.py:128 PiFinder/ui/menu_structure.py:328 msgid "Arp Galaxies" msgstr "Arp Galaxies" -#: PiFinder/ui/menu_structure.py:135 PiFinder/ui/menu_structure.py:333 +#: PiFinder/ui/menu_structure.py:134 PiFinder/ui/menu_structure.py:332 msgid "Barnard" msgstr "Barnard" -#: PiFinder/ui/menu_structure.py:141 PiFinder/ui/menu_structure.py:337 +#: PiFinder/ui/menu_structure.py:140 PiFinder/ui/menu_structure.py:336 msgid "Caldwell" msgstr "Caldwell" -#: PiFinder/ui/menu_structure.py:147 PiFinder/ui/menu_structure.py:341 +#: PiFinder/ui/menu_structure.py:146 PiFinder/ui/menu_structure.py:340 msgid "Collinder" msgstr "Collinder" -#: PiFinder/ui/menu_structure.py:153 PiFinder/ui/menu_structure.py:345 +#: PiFinder/ui/menu_structure.py:152 PiFinder/ui/menu_structure.py:344 msgid "E.G. Globs" msgstr "E.G. Globs" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:159 PiFinder/ui/menu_structure.py:349 +#: PiFinder/ui/menu_structure.py:158 PiFinder/ui/menu_structure.py:348 msgid "Harris Globs" msgstr "Harris Globs" -#: PiFinder/ui/menu_structure.py:165 PiFinder/ui/menu_structure.py:353 +#: PiFinder/ui/menu_structure.py:164 PiFinder/ui/menu_structure.py:352 msgid "Herschel 400" msgstr "Herschel 400" -#: PiFinder/ui/menu_structure.py:171 PiFinder/ui/menu_structure.py:357 +#: PiFinder/ui/menu_structure.py:170 PiFinder/ui/menu_structure.py:356 msgid "IC" msgstr "IC" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:177 PiFinder/ui/menu_structure.py:361 +#: PiFinder/ui/menu_structure.py:176 PiFinder/ui/menu_structure.py:360 msgid "Lynga Opn Cl" msgstr "Lynga Opn Cl" -#: PiFinder/ui/menu_structure.py:195 PiFinder/ui/menu_structure.py:373 +#: PiFinder/ui/menu_structure.py:194 PiFinder/ui/menu_structure.py:372 msgid "Sharpless" msgstr "Sharpless" -#: PiFinder/ui/menu_structure.py:201 PiFinder/ui/menu_structure.py:377 +#: PiFinder/ui/menu_structure.py:200 PiFinder/ui/menu_structure.py:376 msgid "TAAS 200" msgstr "TAAS 200" -#: PiFinder/ui/menu_structure.py:209 PiFinder/ui/menu_structure.py:383 +#: PiFinder/ui/menu_structure.py:208 PiFinder/ui/menu_structure.py:382 msgid "Stars..." msgstr "Etoiles" -#: PiFinder/ui/menu_structure.py:214 PiFinder/ui/menu_structure.py:389 +#: PiFinder/ui/menu_structure.py:213 PiFinder/ui/menu_structure.py:388 msgid "Bright Named" msgstr "Brillantes" -#: PiFinder/ui/menu_structure.py:220 PiFinder/ui/menu_structure.py:393 +#: PiFinder/ui/menu_structure.py:219 PiFinder/ui/menu_structure.py:392 msgid "SAC Doubles" msgstr "SAC Doubles" -#: PiFinder/ui/menu_structure.py:226 PiFinder/ui/menu_structure.py:397 +#: PiFinder/ui/menu_structure.py:225 PiFinder/ui/menu_structure.py:396 msgid "SAC Asterisms" msgstr "SAC Asterismes" -#: PiFinder/ui/menu_structure.py:232 PiFinder/ui/menu_structure.py:401 +#: PiFinder/ui/menu_structure.py:231 PiFinder/ui/menu_structure.py:400 msgid "SAC Red Stars" msgstr "SAC Etoiles Rouges" -#: PiFinder/ui/menu_structure.py:238 PiFinder/ui/menu_structure.py:405 +#: PiFinder/ui/menu_structure.py:237 PiFinder/ui/menu_structure.py:404 msgid "RASC Doubles" msgstr "RASC Doubles" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:244 +#: PiFinder/ui/menu_structure.py:243 msgid "WDS Doubles" msgstr "WDS Doubles" -#: PiFinder/ui/menu_structure.py:250 PiFinder/ui/menu_structure.py:409 +#: PiFinder/ui/menu_structure.py:249 PiFinder/ui/menu_structure.py:408 msgid "TLK 90 Variables" msgstr "TLK 90 Variables" -#: PiFinder/ui/menu_structure.py:260 +#: PiFinder/ui/menu_structure.py:259 msgid "Recent" msgstr "Récent" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:266 PiFinder/ui/obs_list.py:70 +#: PiFinder/ui/menu_structure.py:265 PiFinder/ui/obs_list.py:70 msgid "Obs Lists" msgstr "Listes obs." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:270 +#: PiFinder/ui/menu_structure.py:269 msgid "Custom" msgstr "Personnalisé" -#: PiFinder/ui/menu_structure.py:275 +#: PiFinder/ui/menu_structure.py:274 msgid "Name Search" msgstr "Rech. par Nom" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:279 +#: PiFinder/ui/menu_structure.py:278 msgid "Set Filters" msgstr "Définir filtres" -#: PiFinder/ui/menu_structure.py:285 +#: PiFinder/ui/menu_structure.py:284 msgid "Reset All" msgstr "RàZ tout" -#: PiFinder/ui/menu_structure.py:290 PiFinder/ui/menu_structure.py:1278 -#: PiFinder/ui/menu_structure.py:1289 PiFinder/ui/software.py:435 -#: PiFinder/ui/software.py:531 +#: PiFinder/ui/menu_structure.py:289 PiFinder/ui/menu_structure.py:1297 +#: PiFinder/ui/menu_structure.py:1308 msgid "Confirm" msgstr "Confirmation" -#: PiFinder/ui/menu_structure.py:293 PiFinder/ui/menu_structure.py:1279 -#: PiFinder/ui/menu_structure.py:1292 PiFinder/ui/software.py:380 -#: PiFinder/ui/software.py:435 PiFinder/ui/software.py:529 -#: views/edit_eyepiece.html:54 views/edit_instrument.html:108 -#: views/equipment.html:58 views/location_form.html:77 views/locations.html:187 +#: PiFinder/ui/menu_structure.py:292 PiFinder/ui/menu_structure.py:1298 +#: PiFinder/ui/menu_structure.py:1311 PiFinder/ui/software.py:208 +#: PiFinder/ui/sqm_correction.py:70 views/edit_eyepiece.html:54 +#: views/edit_instrument.html:108 views/equipment.html:58 +#: views/location_form.html:77 views/locations.html:187 #: views/locations.html:200 views/network.html:46 views/network.html:83 #: views/network_item.html:19 views/tools.html:97 msgid "Cancel" msgstr "Abandon" -#: PiFinder/ui/menu_structure.py:297 +#: PiFinder/ui/menu_structure.py:296 msgid "Catalogs" msgstr "Catalogues" -#: PiFinder/ui/menu_structure.py:417 +#: PiFinder/ui/menu_structure.py:416 msgid "Type" msgstr "Type" -#: PiFinder/ui/menu_structure.py:463 +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/menu_structure.py:430 +msgid "Cluster/Neb" +msgstr "Amas/Nébuleuse" + +#: PiFinder/ui/menu_structure.py:442 +msgid "P. Nebula" +msgstr "Nébuleuse Planétaire" + +#: PiFinder/ui/menu_structure.py:454 +msgid "Double Str" +msgstr "Etoile Double" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/menu_structure.py:458 +msgid "Triple Str" +msgstr "Étoile Triple" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/menu_structure.py:478 +msgid "Unknown" +msgstr "Inconnu" + +#: PiFinder/ui/menu_structure.py:484 views/locations.html:65 +msgid "Altitude" +msgstr "Altitude" + +#: PiFinder/ui/menu_structure.py:516 msgid "Magnitude" msgstr "Magnitude" -#: PiFinder/ui/menu_structure.py:515 PiFinder/ui/menu_structure.py:525 +#: PiFinder/ui/menu_structure.py:568 PiFinder/ui/menu_structure.py:578 msgid "Observed" msgstr "Observé" -#: PiFinder/ui/menu_structure.py:521 +#: PiFinder/ui/menu_structure.py:574 msgid "Any" msgstr "Tous" -#: PiFinder/ui/menu_structure.py:529 +#: PiFinder/ui/menu_structure.py:582 msgid "Not Observed" msgstr "Non Observé" -#: PiFinder/ui/menu_structure.py:548 +#: PiFinder/ui/menu_structure.py:601 msgid "User Pref..." msgstr "Pref. Utilisateur" -#: PiFinder/ui/menu_structure.py:553 +#: PiFinder/ui/menu_structure.py:606 msgid "Key Bright" msgstr "Brillance Touche" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:594 -msgid "Volume" -msgstr "Volume" +#: PiFinder/ui/menu_structure.py:647 +msgid "Sleep Time" +msgstr "Mise en Sommeil" -#: PiFinder/ui/menu_structure.py:600 PiFinder/ui/menu_structure.py:615 -#: PiFinder/ui/menu_structure.py:647 PiFinder/ui/menu_structure.py:671 -#: PiFinder/ui/menu_structure.py:790 PiFinder/ui/menu_structure.py:814 -#: PiFinder/ui/menu_structure.py:838 PiFinder/ui/menu_structure.py:862 -#: PiFinder/ui/menu_structure.py:893 PiFinder/ui/menu_structure.py:909 -#: PiFinder/ui/menu_structure.py:1125 PiFinder/ui/menu_structure.py:1231 -#: PiFinder/ui/menu_structure.py:1247 +#: PiFinder/ui/menu_structure.py:653 PiFinder/ui/menu_structure.py:685 +#: PiFinder/ui/menu_structure.py:709 PiFinder/ui/menu_structure.py:828 +#: PiFinder/ui/menu_structure.py:852 PiFinder/ui/menu_structure.py:876 +#: PiFinder/ui/menu_structure.py:900 PiFinder/ui/menu_structure.py:931 +#: PiFinder/ui/menu_structure.py:947 PiFinder/ui/menu_structure.py:1147 +#: PiFinder/ui/menu_structure.py:1250 PiFinder/ui/menu_structure.py:1266 msgid "Off" msgstr "Arret" -#: PiFinder/ui/menu_structure.py:609 -msgid "Sleep Time" -msgstr "Mise en Sommeil" - -#: PiFinder/ui/menu_structure.py:641 +#: PiFinder/ui/menu_structure.py:679 msgid "Menu Anim" msgstr "Anim. Menu" -#: PiFinder/ui/menu_structure.py:651 PiFinder/ui/menu_structure.py:675 +#: PiFinder/ui/menu_structure.py:689 PiFinder/ui/menu_structure.py:713 msgid "Fast" msgstr "Rapide" -#: PiFinder/ui/menu_structure.py:655 PiFinder/ui/menu_structure.py:679 -#: PiFinder/ui/menu_structure.py:798 PiFinder/ui/menu_structure.py:822 -#: PiFinder/ui/menu_structure.py:846 PiFinder/ui/menu_structure.py:1139 +#: PiFinder/ui/menu_structure.py:693 PiFinder/ui/menu_structure.py:717 +#: PiFinder/ui/menu_structure.py:836 PiFinder/ui/menu_structure.py:860 +#: PiFinder/ui/menu_structure.py:884 PiFinder/ui/menu_structure.py:1161 msgid "Medium" msgstr "Moyen" -#: PiFinder/ui/menu_structure.py:659 PiFinder/ui/menu_structure.py:683 +#: PiFinder/ui/menu_structure.py:697 PiFinder/ui/menu_structure.py:721 msgid "Slow" msgstr "Lent" -#: PiFinder/ui/menu_structure.py:665 +#: PiFinder/ui/menu_structure.py:703 msgid "Scroll Speed" msgstr "Vitesse défilement" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:689 +#: PiFinder/ui/menu_structure.py:727 msgid "Search Input" msgstr "Saisie recherche" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:696 +#: PiFinder/ui/menu_structure.py:734 msgid "Multi-Tap" msgstr "Multi-Tap" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:700 +#: PiFinder/ui/menu_structure.py:738 msgid "T9" msgstr "T9" -#: PiFinder/ui/menu_structure.py:706 +#: PiFinder/ui/menu_structure.py:744 msgid "Az Arrows" msgstr "Fleches AZ" -#: PiFinder/ui/menu_structure.py:713 +#: PiFinder/ui/menu_structure.py:751 msgid "Default" msgstr "Par défaut" -#: PiFinder/ui/menu_structure.py:717 +#: PiFinder/ui/menu_structure.py:755 msgid "Reverse" msgstr "A l'envers" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:723 +#: PiFinder/ui/menu_structure.py:761 msgid "Language" msgstr "Langue" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:730 +#: PiFinder/ui/menu_structure.py:768 msgid "English" msgstr "Anglais" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:734 +#: PiFinder/ui/menu_structure.py:772 msgid "German" msgstr "Allemand" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:738 +#: PiFinder/ui/menu_structure.py:776 msgid "French" msgstr "Français" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:742 +#: PiFinder/ui/menu_structure.py:780 msgid "Spanish" msgstr "Espagnol" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:746 +#: PiFinder/ui/menu_structure.py:784 msgid "Chinese" msgstr "Chinois" -#: PiFinder/ui/menu_structure.py:754 +#: PiFinder/ui/menu_structure.py:792 msgid "Chart..." msgstr "Carte..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:760 +#: PiFinder/ui/menu_structure.py:798 msgid "Coordinate Sys." msgstr "Sys. coordonnées" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:766 +#: PiFinder/ui/menu_structure.py:804 msgid "Horizontal" msgstr "Horizontal" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:770 +#: PiFinder/ui/menu_structure.py:808 msgid "EQ (Auto)" msgstr "EQ (Auto)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:774 +#: PiFinder/ui/menu_structure.py:812 msgid "EQ (North-up)" msgstr "EQ (Nord en haut)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:778 +#: PiFinder/ui/menu_structure.py:816 msgid "EQ (South-up)" msgstr "EQ (Sud en haut)" -#: PiFinder/ui/menu_structure.py:784 +#: PiFinder/ui/menu_structure.py:822 msgid "Reticle" msgstr "Réticule" -#: PiFinder/ui/menu_structure.py:794 PiFinder/ui/menu_structure.py:818 -#: PiFinder/ui/menu_structure.py:842 PiFinder/ui/menu_structure.py:1135 +#: PiFinder/ui/menu_structure.py:832 PiFinder/ui/menu_structure.py:856 +#: PiFinder/ui/menu_structure.py:880 PiFinder/ui/menu_structure.py:1157 msgid "Low" msgstr "Bas" -#: PiFinder/ui/menu_structure.py:802 PiFinder/ui/menu_structure.py:826 -#: PiFinder/ui/menu_structure.py:850 PiFinder/ui/menu_structure.py:1143 +#: PiFinder/ui/menu_structure.py:840 PiFinder/ui/menu_structure.py:864 +#: PiFinder/ui/menu_structure.py:888 PiFinder/ui/menu_structure.py:1165 msgid "High" msgstr "Haut" -#: PiFinder/ui/menu_structure.py:808 +#: PiFinder/ui/menu_structure.py:846 msgid "Constellation" msgstr "Constéllation" -#: PiFinder/ui/menu_structure.py:832 +#: PiFinder/ui/menu_structure.py:870 msgid "DSO Display" msgstr "Affichage DSO" -#: PiFinder/ui/menu_structure.py:856 +#: PiFinder/ui/menu_structure.py:894 msgid "RA/DEC Disp." msgstr "Affichage RA/Dec" -#: PiFinder/ui/menu_structure.py:866 +#: PiFinder/ui/menu_structure.py:904 msgid "HH:MM" msgstr "HH:MM" -#: PiFinder/ui/menu_structure.py:870 +#: PiFinder/ui/menu_structure.py:908 msgid "Degrees" msgstr "Degrés" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:878 +#: PiFinder/ui/menu_structure.py:916 msgid "Image..." msgstr "Image..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:883 +#: PiFinder/ui/menu_structure.py:921 msgid "NSEW Labels" msgstr "Repères NSEO" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:889 PiFinder/ui/menu_structure.py:905 -#: PiFinder/ui/menu_structure.py:1235 PiFinder/ui/menu_structure.py:1251 +#: PiFinder/ui/menu_structure.py:927 PiFinder/ui/menu_structure.py:943 +#: PiFinder/ui/menu_structure.py:1254 PiFinder/ui/menu_structure.py:1270 msgid "On" msgstr "Activé" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:899 +#: PiFinder/ui/menu_structure.py:937 msgid "Object Size" msgstr "Taille objet" -#: PiFinder/ui/menu_structure.py:917 +#: PiFinder/ui/menu_structure.py:955 msgid "Camera Exp" msgstr "Expo. Caméra" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:925 +#: PiFinder/ui/menu_structure.py:963 msgid "Auto" msgstr "Auto" -#: PiFinder/ui/menu_structure.py:930 +#: PiFinder/ui/menu_structure.py:968 msgid "0.025s" msgstr "0.025s" -#: PiFinder/ui/menu_structure.py:934 +#: PiFinder/ui/menu_structure.py:972 msgid "0.05s" msgstr "0.05s" -#: PiFinder/ui/menu_structure.py:938 +#: PiFinder/ui/menu_structure.py:976 msgid "0.1s" msgstr "0.1s" -#: PiFinder/ui/menu_structure.py:942 +#: PiFinder/ui/menu_structure.py:980 msgid "0.2s" msgstr "0.2s" -#: PiFinder/ui/menu_structure.py:946 +#: PiFinder/ui/menu_structure.py:984 msgid "0.4s" msgstr "0.4s" -#: PiFinder/ui/menu_structure.py:950 +#: PiFinder/ui/menu_structure.py:988 msgid "0.8s" msgstr "0.8s" -#: PiFinder/ui/menu_structure.py:954 +#: PiFinder/ui/menu_structure.py:992 msgid "1s" msgstr "1s" -#: PiFinder/ui/menu_structure.py:960 +#: PiFinder/ui/menu_structure.py:998 msgid "WiFi Mode" msgstr "Mode Wifi" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:966 +#: PiFinder/ui/menu_structure.py:1004 msgid "Client Mode" msgstr "Mode client" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:971 +#: PiFinder/ui/menu_structure.py:1009 msgid "AP Mode" msgstr "Mode AP" -#: PiFinder/ui/menu_structure.py:978 views/edit_instrument.html:61 +#: PiFinder/ui/menu_structure.py:1016 views/edit_instrument.html:61 #: views/equipment.html:73 msgid "Mount Type" msgstr "Type de Monture" -#: PiFinder/ui/menu_structure.py:985 views/edit_instrument.html:57 +#: PiFinder/ui/menu_structure.py:1023 views/edit_instrument.html:57 msgid "Alt/Az" msgstr "Alt/Az" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:989 views/edit_instrument.html:58 +#: PiFinder/ui/menu_structure.py:1027 views/edit_instrument.html:58 msgid "Equatorial" msgstr "Équatorial" -#: PiFinder/ui/menu_structure.py:1001 +#: PiFinder/ui/menu_structure.py:1039 msgid "PiFinder Type" msgstr "Type de PiFinder" -#: PiFinder/ui/menu_structure.py:1008 +#: PiFinder/ui/menu_structure.py:1046 msgid "Left" msgstr "Gauche" -#: PiFinder/ui/menu_structure.py:1012 +#: PiFinder/ui/menu_structure.py:1050 msgid "Right" msgstr "Droit" -#: PiFinder/ui/menu_structure.py:1016 +#: PiFinder/ui/menu_structure.py:1054 msgid "Straight" msgstr "Face" -#: PiFinder/ui/menu_structure.py:1020 +#: PiFinder/ui/menu_structure.py:1058 msgid "Flat v3" msgstr "Plat v3" -#: PiFinder/ui/menu_structure.py:1024 +#: PiFinder/ui/menu_structure.py:1062 msgid "Flat v2" msgstr "Plat v2" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1028 +#: PiFinder/ui/menu_structure.py:1066 msgid "AS Bloom" msgstr "AS Bloom" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1032 -msgid "AS Heart" -msgstr "AS Heart" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1036 -msgid "Rev4 Left" -msgstr "Rev4 Gauche" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1040 -msgid "Rev4 Right" -msgstr "Rev4 Droite" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1044 -msgid "Rev4 Straight" -msgstr "Rev4 Face" - -#: PiFinder/ui/menu_structure.py:1050 +#: PiFinder/ui/menu_structure.py:1072 msgid "Camera Type" msgstr "Type Caméra" -#: PiFinder/ui/menu_structure.py:1056 +#: PiFinder/ui/menu_structure.py:1078 msgid "v2 - imx477" msgstr "v2 - imx477" -#: PiFinder/ui/menu_structure.py:1061 +#: PiFinder/ui/menu_structure.py:1083 msgid "v3 - imx296" msgstr "v3 - imx296" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1066 +#: PiFinder/ui/menu_structure.py:1088 msgid "v3 - imx462" msgstr "v3 - imx462" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1077 -# AI-TRANSLATED (claude): needs human review -msgid "Lens" -msgstr "Objectif" - -#: PiFinder/ui/menu_structure.py:1086 -msgid "12mm" -msgstr "12mm" - -#: PiFinder/ui/menu_structure.py:1090 -msgid "16mm" -msgstr "16mm" - -#: PiFinder/ui/menu_structure.py:1094 -msgid "25mm" -msgstr "25mm" - -#: PiFinder/ui/menu_structure.py:1100 views/gps.html:6 +#: PiFinder/ui/menu_structure.py:1095 views/gps.html:6 msgid "GPS Settings" msgstr "Réglages GPS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1078 +#: PiFinder/ui/menu_structure.py:1100 msgid "GPS Type" msgstr "Type de GPS" -#: PiFinder/ui/menu_structure.py:1086 +#: PiFinder/ui/menu_structure.py:1108 msgid "UBlox" msgstr "UBlox" -#: PiFinder/ui/menu_structure.py:1090 +#: PiFinder/ui/menu_structure.py:1112 msgid "GPSD (generic)" msgstr "GPSD (generique)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1096 +#: PiFinder/ui/menu_structure.py:1118 msgid "GPS Baud Rate" msgstr "Débit GPS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1104 +#: PiFinder/ui/menu_structure.py:1126 msgid "9600 (standard)" msgstr "9600 (standard)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1108 +#: PiFinder/ui/menu_structure.py:1130 msgid "115200 (UBlox-10)" msgstr "115200 (UBlox-10)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1118 +#: PiFinder/ui/menu_structure.py:1140 msgid "IMU Sensit." msgstr "Sensib. IMU" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1129 +#: PiFinder/ui/menu_structure.py:1151 msgid "Very Low" msgstr "Très faible" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1155 +#: PiFinder/ui/menu_structure.py:1177 msgid "Status" msgstr "État" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1158 +#: PiFinder/ui/menu_structure.py:1180 msgid "Place & Time" msgstr "Lieu & Heure" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1167 +#: PiFinder/ui/menu_structure.py:1189 msgid "Set Location" msgstr "Définir position" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1176 views/locations.html:86 +#: PiFinder/ui/menu_structure.py:1198 views/locations.html:86 msgid "Load Location" msgstr "Charger position" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1180 views/location_form.html:76 +#: PiFinder/ui/menu_structure.py:1202 views/location_form.html:76 msgid "Save Location" msgstr "Sauv. position" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1186 +#: PiFinder/ui/menu_structure.py:1208 msgid "Set Time/Date" msgstr "Régler date/heure" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1193 +#: PiFinder/ui/menu_structure.py:1212 msgid "Reset Location" msgstr "RàZ position" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1195 +#: PiFinder/ui/menu_structure.py:1214 msgid "Reset Time/Date" msgstr "RàZ date/heure" -#: PiFinder/ui/menu_structure.py:1200 +#: PiFinder/ui/menu_structure.py:1219 msgid "Console" msgstr "Console" -#: PiFinder/ui/menu_structure.py:1201 +#: PiFinder/ui/menu_structure.py:1220 msgid "Software Upd" msgstr "Mise à jour" -#: PiFinder/ui/menu_structure.py:1204 +#: PiFinder/ui/menu_structure.py:1223 msgid "Experimental" msgstr "Expèrimental" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1209 +#: PiFinder/ui/menu_structure.py:1228 msgid "Polar Align" msgstr "Mise en station" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1214 +#: PiFinder/ui/menu_structure.py:1233 msgid "Dev Tools" msgstr "Outils dév." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1219 +#: PiFinder/ui/menu_structure.py:1238 msgid "Telemetry" msgstr "Télémétrie" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1224 +#: PiFinder/ui/menu_structure.py:1243 msgid "Record" msgstr "Enregistrer" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1241 +#: PiFinder/ui/menu_structure.py:1260 msgid "Images" msgstr "Images" -#: PiFinder/ui/menu_structure.py:1267 +#: PiFinder/ui/menu_structure.py:1286 msgid "Power" msgstr "Allumage" -#: PiFinder/ui/menu_structure.py:1273 +#: PiFinder/ui/menu_structure.py:1292 msgid "Shutdown" msgstr "Arrêt" @@ -1657,139 +1582,143 @@ msgstr "ANNULER" msgid "No Object Found" msgstr "Objet introuvable" -#: PiFinder/ui/object_details.py:238 PiFinder/ui/object_details.py:245 +#: PiFinder/ui/object_details.py:231 PiFinder/ui/object_details.py:238 msgid "Mag:{obj_mag}" msgstr "Mag:{obj_mag}" #. TRANSLATORS: object info magnitude -#: PiFinder/ui/object_details.py:241 +#: PiFinder/ui/object_details.py:234 msgid "Sz:{size}" msgstr "Taille:{size}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:367 +#: PiFinder/ui/object_details.py:360 msgid "  Not Logged" msgstr "  Non enregistré" -#: PiFinder/ui/object_details.py:369 +#: PiFinder/ui/object_details.py:362 msgid "  {logs} Logs" -msgstr "  {logs} Logs" +msgstr " {logs} Logs" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:415 PiFinder/ui/polar_align.py:444 +#: PiFinder/ui/object_details.py:408 PiFinder/ui/polar_align.py:444 msgid "No solve" msgstr "Pas de résolution" -#: PiFinder/ui/object_details.py:421 +#: PiFinder/ui/object_details.py:414 msgid "yet{elipsis}" msgstr "maintenant{elipsis}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:435 +#: PiFinder/ui/object_details.py:428 msgid "Searching" msgstr "Recherche" -#: PiFinder/ui/object_details.py:441 +#: PiFinder/ui/object_details.py:434 msgid "for GPS{elipsis}" msgstr "par GPS{elipsis}" -#: PiFinder/ui/object_details.py:455 PiFinder/ui/object_details.py:483 +#: PiFinder/ui/object_details.py:448 PiFinder/ui/object_details.py:476 msgid "Calculating" msgstr "Calcul en cours" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:461 +#: PiFinder/ui/object_details.py:454 msgid "positions" msgstr "positions" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:489 +#: PiFinder/ui/object_details.py:482 msgid "position" msgstr "position" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:573 +#: PiFinder/ui/object_details.py:566 msgid "Contrast Reserve" msgstr "Réserve de contraste" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:599 +#: PiFinder/ui/object_details.py:592 msgid "No contrast data" msgstr "Pas de données CR" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:607 +#: PiFinder/ui/object_details.py:600 msgid "CR measures object" msgstr "RC mesure visibilité" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 1 -#: PiFinder/ui/object_details.py:610 +#: PiFinder/ui/object_details.py:603 msgid "visibility based on" msgstr "basée sur" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 2 -#: PiFinder/ui/object_details.py:613 +#: PiFinder/ui/object_details.py:606 msgid "sky brightness," msgstr "luminosité ciel," # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 3 -#: PiFinder/ui/object_details.py:616 +#: PiFinder/ui/object_details.py:609 msgid "telescope, and EP." msgstr "télescope et oculaire." -#: PiFinder/ui/object_details.py:692 +#: PiFinder/ui/object_details.py:685 msgid "Too Far" msgstr "Trop loin" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:717 +#: PiFinder/ui/object_details.py:710 msgid "LOG" msgstr "LOG" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:165 +#: PiFinder/ui/object_list.py:134 msgid "Refresh" msgstr "Actualiser" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:171 +#: PiFinder/ui/object_list.py:140 msgid "Sort" msgstr "Trier" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:175 PiFinder/ui/object_list.py:879 +#: PiFinder/ui/object_list.py:144 PiFinder/ui/object_list.py:819 msgid "Nearest" msgstr "Plus proche" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:179 PiFinder/ui/object_list.py:885 +#: PiFinder/ui/object_list.py:148 PiFinder/ui/object_list.py:825 msgid "Standard" msgstr "Standard" -#: PiFinder/ui/object_list.py:184 +#: PiFinder/ui/object_list.py:153 msgid "Filter" msgstr "Filtre" -#: PiFinder/ui/object_list.py:289 PiFinder/ui/software.py:572 +#: PiFinder/ui/object_list.py:244 msgid "Downloading..." msgstr "Téléchargement..." -#: PiFinder/ui/object_list.py:296 +#: PiFinder/ui/object_list.py:251 msgid "No GPS lock" msgstr "Pas de GPS" -#: PiFinder/ui/object_list.py:303 +#: PiFinder/ui/object_list.py:258 msgid "Calculating..." msgstr "Calcul..." -#: PiFinder/ui/object_list.py:312 PiFinder/ui/software.py:750 +#: PiFinder/ui/object_list.py:264 views/locations.html:66 +msgid "Error" +msgstr "Erreur" + +#: PiFinder/ui/object_list.py:267 msgid "Loading..." msgstr "Chargement..." -#: PiFinder/ui/object_list.py:319 +#: PiFinder/ui/object_list.py:274 msgid "" "Sorting by\n" "{sort_order}" @@ -1797,46 +1726,46 @@ msgstr "" "Classement par\n" "\"\"{sort_order}" -#: PiFinder/ui/object_list.py:320 PiFinder/ui/object_list.py:890 +#: PiFinder/ui/object_list.py:275 PiFinder/ui/object_list.py:830 msgid "RA" msgstr "AD" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:322 PiFinder/ui/object_list.py:639 +#: PiFinder/ui/object_list.py:277 PiFinder/ui/object_list.py:579 #: views/obs_session_log.html:21 msgid "Catalog" msgstr "Catalogue" -#: PiFinder/ui/object_list.py:324 PiFinder/ui/object_list.py:641 +#: PiFinder/ui/object_list.py:279 PiFinder/ui/object_list.py:581 msgid "Nearby" msgstr "Plus proche" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:596 +#: PiFinder/ui/object_list.py:543 msgid "No objects" msgstr "Aucun objet" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:602 +#: PiFinder/ui/object_list.py:549 msgid "match filter" msgstr "selon le filtre" -#: PiFinder/ui/object_list.py:625 +#: PiFinder/ui/object_list.py:565 msgid "{catalog_info_1} obj" msgstr "{catalog_info_1} obj" #. TRANSLATORS: number of objects in object list -#: PiFinder/ui/object_list.py:628 +#: PiFinder/ui/object_list.py:568 msgid ", {catalog_info_2}d old" msgstr ", {catalog_info_2}d ancien" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:638 +#: PiFinder/ui/object_list.py:578 msgid "Sort: {sort_order}" msgstr "Tri: {sort_order}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:932 +#: PiFinder/ui/object_list.py:872 msgid "Refreshing..." msgstr "Actualisation..." @@ -1869,12 +1798,12 @@ msgid "STATS" msgstr "DÉTAILS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:228 PiFinder/ui/polar_align.py:549 +#: PiFinder/ui/polar_align.py:228 PiFinder/ui/polar_align.py:540 msgid "Need GPS lock" msgstr "GPS requis" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:234 PiFinder/ui/polar_align.py:551 +#: PiFinder/ui/polar_align.py:234 PiFinder/ui/polar_align.py:542 msgid "Rotate more" msgstr "Tourne plus" @@ -2004,8 +1933,8 @@ msgstr "résol. caméra." # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: hint bar; {icon} is the MINUS button glyph #. TRANSLATORS: hint bar; {icon} is the SQUARE button glyph -#: PiFinder/ui/polar_align.py:398 PiFinder/ui/polar_align.py:470 -#: PiFinder/ui/polar_align.py:500 +#: PiFinder/ui/polar_align.py:398 PiFinder/ui/polar_align.py:461 +#: PiFinder/ui/polar_align.py:491 msgid "{icon} BACK" msgstr "{icon} RETOUR" @@ -2025,7 +1954,7 @@ msgid "bad" msgstr "mauv." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:414 PiFinder/ui/polar_align.py:481 +#: PiFinder/ui/polar_align.py:414 PiFinder/ui/polar_align.py:472 msgid "pt" msgstr "pt" @@ -2037,76 +1966,91 @@ msgid "{square} REDO {minus} CANCEL" msgstr "{square} REFAIRE {minus} ANNULER" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:468 +#: PiFinder/ui/polar_align.py:459 msgid "No result yet" msgstr "Pas de rés." -#: PiFinder/ui/polar_align.py:477 +#: PiFinder/ui/polar_align.py:468 msgid "RA/Dec" msgstr "RA/Dec" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:477 +#: PiFinder/ui/polar_align.py:468 msgid "3-axis" msgstr "3 axes" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:484 +#: PiFinder/ui/polar_align.py:475 msgid "Fit" msgstr "Ajust." -#: PiFinder/ui/polar_align.py:485 +#: PiFinder/ui/polar_align.py:476 msgid "Alt" msgstr "Alt" -#: PiFinder/ui/polar_align.py:486 +#: PiFinder/ui/polar_align.py:477 msgid "Az" msgstr "Az" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:487 +#: PiFinder/ui/polar_align.py:478 msgid "Axis" msgstr "Axe" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:497 +#: PiFinder/ui/polar_align.py:488 msgid "time" msgstr "temps" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:497 +#: PiFinder/ui/polar_align.py:488 msgid "sec" msgstr "sec" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:547 +#: PiFinder/ui/polar_align.py:538 msgid "Need 2 points" msgstr "2 points requis" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:561 PiFinder/ui/polar_align.py:568 +#: PiFinder/ui/polar_align.py:552 PiFinder/ui/polar_align.py:559 msgid "Roll Off" msgstr "Sans Roll" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:561 PiFinder/ui/polar_align.py:568 +#: PiFinder/ui/polar_align.py:552 PiFinder/ui/polar_align.py:559 msgid "Roll On" msgstr "Avec Roll" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:576 +#: PiFinder/ui/polar_align.py:567 msgid "No points" msgstr "Aucun point" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:582 +#: PiFinder/ui/polar_align.py:573 msgid "Dropped point" msgstr "Point retiré" -#: PiFinder/ui/preview.py:93 +#: PiFinder/ui/preview.py:79 msgid "Exposure" msgstr "Exposition" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:304 +msgid "keep going" +msgstr "continuer" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:354 +msgid "det {n}" +msgstr "dét {n}" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:438 +msgid "Zoom x{zoom_number}" +msgstr "Zoom x{zoom_number}" + #: PiFinder/ui/radec_entry.py:516 msgid "Full" msgstr "plein" @@ -2147,187 +2091,127 @@ msgid "RA/DEC" msgstr "RA/Dec" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:213 -msgid "No release found" -msgstr "Aucune version trouvée" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:219 PiFinder/ui/software.py:632 -msgid "System Upgrade" -msgstr "MàJ système" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:259 +#: PiFinder/ui/software.py:87 msgid "Updating..." msgstr "Mise à jour..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:261 +#: PiFinder/ui/software.py:89 msgid "Ok! Restarting" msgstr "Ok! Redémarrage" -#: PiFinder/ui/software.py:264 +#: PiFinder/ui/software.py:92 msgid "Error on Upd" msgstr "Erreur de mise à jour" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:271 +#: PiFinder/ui/software.py:99 msgid "Wifi Mode: {mode}" msgstr "Mode Wifi : {mode}" -#: PiFinder/ui/software.py:279 +#: PiFinder/ui/software.py:107 msgid "Current Version" msgstr "Version courante" -#: PiFinder/ui/software.py:295 +#: PiFinder/ui/software.py:123 msgid "Release Version" msgstr "Version disponible" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:317 +#: PiFinder/ui/software.py:145 msgid "WiFi must be" msgstr "WiFi doit être en" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:323 +#: PiFinder/ui/software.py:151 msgid "client mode" msgstr "mode client" -#: PiFinder/ui/software.py:336 +#: PiFinder/ui/software.py:164 msgid "Checking for" msgstr "Vérification" -#: PiFinder/ui/software.py:342 +#: PiFinder/ui/software.py:170 msgid "updates{elipsis}" msgstr "Mise à jour {elipsis}" -#: PiFinder/ui/software.py:358 +#: PiFinder/ui/software.py:186 msgid "No Update" msgstr "Pas de mise à jour" -#: PiFinder/ui/software.py:364 +#: PiFinder/ui/software.py:192 msgid "needed" msgstr "Necessaire" -#: PiFinder/ui/software.py:374 +#: PiFinder/ui/software.py:202 msgid "Update Now" msgstr "Mise à jour maintenant" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:444 -msgid "Major Upgrade" -msgstr "MàJ majeure" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:464 -msgid "IRREVERSIBLE" -msgstr "IRRÉVERSIBLE" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:473 -msgid "Download: {size}MB" -msgstr "Téléch. : {size}MB" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:481 -msgid "Power + WiFi req" -msgstr "Alim. + WiFi requis" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:492 -msgid "No checksum avail." -msgstr "Pas de somme ctrl." - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:552 -msgid "Starting..." -msgstr "Démarrage..." - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:594 PiFinder/ui/software.py:599 -msgid "Not supported" -msgstr "Non pris en charge" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:604 -msgid "Failed: " -msgstr "Échec : " - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:735 -msgid "Could not load" -msgstr "Échec lecture" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:741 -msgid "release notes" -msgstr "notes version" - #: PiFinder/ui/sqm.py:25 msgid "SQM" msgstr "SQM" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:41 -msgid "CALIB" +#: PiFinder/ui/sqm.py:42 +msgid "CAL" msgstr "ÉTAL." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:47 -msgid "SWEEP" -msgstr "SCAN" +#: PiFinder/ui/sqm.py:48 +msgid "CORRECT" +msgstr "CORRECT" -#: PiFinder/ui/sqm.py:100 +#: PiFinder/ui/sqm.py:101 msgid "NO SQM DATA" msgstr "AUCUNE DONNÉE SQM" -#: PiFinder/ui/sqm.py:128 PiFinder/ui/sqm.py:215 +#: PiFinder/ui/sqm.py:129 PiFinder/ui/sqm.py:216 msgid "mag/arcsec²" msgstr "mag/arcsec²" -#: PiFinder/ui/sqm.py:147 PiFinder/ui/sqm.py:250 +#: PiFinder/ui/sqm.py:148 PiFinder/ui/sqm.py:252 msgid "Bortle {bc}" msgstr "Bortle {bc}" -#: PiFinder/ui/sqm.py:156 +#: PiFinder/ui/sqm.py:157 msgid "BACK" msgstr "RETOUR" -#: PiFinder/ui/sqm.py:157 +#: PiFinder/ui/sqm.py:158 msgid "SCROLL" msgstr "DÉFILER" -#: PiFinder/ui/sqm.py:170 +#: PiFinder/ui/sqm.py:171 msgid "{s}s ago" msgstr "il y a {s}s" -#: PiFinder/ui/sqm.py:172 +#: PiFinder/ui/sqm.py:173 msgid "{m}m ago" msgstr "il y a {m}m" -#: PiFinder/ui/sqm.py:256 +#: PiFinder/ui/sqm.py:258 msgid "DETAILS" msgstr "DÉTAILS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:311 +#: PiFinder/ui/sqm.py:316 msgid "SQM Calibration" msgstr "Étalonnage SQM" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:323 +#: PiFinder/ui/sqm.py:328 msgid "SQM Sweep" msgstr "Balayage SQM" -#: PiFinder/ui/sqm.py:345 +#: PiFinder/ui/sqm.py:350 msgid "Excellent Dark-Sky Site" msgstr "Site au Ciel Noir Excellent" -#: PiFinder/ui/sqm.py:349 +#: PiFinder/ui/sqm.py:354 msgid "The zodiacal light is visible and colorful. Gegenschein readily visible." msgstr "La lumière zodiacale est visible et colorée. Gegenschein bien visible." -#: PiFinder/ui/sqm.py:352 +#: PiFinder/ui/sqm.py:357 msgid "" "The Scorpius and Sagittarius regions of the Milky Way cast obvious " "shadows." @@ -2335,21 +2219,21 @@ msgstr "" "Les régions du Scorpion et du Sagittaire de la Voie Lactée projettent des" " ombres visibles." -#: PiFinder/ui/sqm.py:355 +#: PiFinder/ui/sqm.py:360 msgid "M33 is a direct naked-eye object. Airglow readily visible." msgstr "M33 est visible à l'œil nu. Airglow bien visible." -#: PiFinder/ui/sqm.py:356 +#: PiFinder/ui/sqm.py:361 msgid "Abundant stars make faint constellations hard to distinguish." msgstr "" "L'abondance d'étoiles rend les constellations faibles difficiles à " "distinguer." -#: PiFinder/ui/sqm.py:361 +#: PiFinder/ui/sqm.py:366 msgid "Typical Truly Dark Site" msgstr "Site Véritablement Noir Typique" -#: PiFinder/ui/sqm.py:365 +#: PiFinder/ui/sqm.py:370 msgid "" "The zodiacal light is distinctly yellowish and bright enough to cast " "shadows at dusk and dawn." @@ -2357,25 +2241,25 @@ msgstr "" "La lumière zodiacale est distinctement jaunâtre et assez brillante pour " "projeter des ombres au crépuscule et à l'aube." -#: PiFinder/ui/sqm.py:368 +#: PiFinder/ui/sqm.py:373 msgid "Clouds appear as dark silhouettes against the sky." msgstr "Les nuages apparaissent comme des silhouettes sombres sur le ciel." -#: PiFinder/ui/sqm.py:369 +#: PiFinder/ui/sqm.py:374 msgid "The summer Milky Way is highly structured. M33 easily visible." msgstr "La Voie Lactée d'été est très structurée. M33 facilement visible." -#: PiFinder/ui/sqm.py:374 +#: PiFinder/ui/sqm.py:379 msgid "Rural Sky" msgstr "Ciel Rural" -#: PiFinder/ui/sqm.py:378 +#: PiFinder/ui/sqm.py:383 msgid "The zodiacal light is striking in spring and autumn, color still visible." msgstr "" "La lumière zodiacale est frappante au printemps et en automne, la couleur" " encore visible." -#: PiFinder/ui/sqm.py:381 +#: PiFinder/ui/sqm.py:386 msgid "" "Some light pollution at horizon. Clouds illuminated near horizon, dark " "overhead." @@ -2383,29 +2267,29 @@ msgstr "" "Pollution lumineuse à l'horizon. Nuages éclairés près de l'horizon, " "sombres au zénith." -#: PiFinder/ui/sqm.py:384 +#: PiFinder/ui/sqm.py:389 msgid "The summer Milky Way still appears complex." msgstr "La Voie Lactée d'été apparaît encore complexe." -#: PiFinder/ui/sqm.py:385 +#: PiFinder/ui/sqm.py:390 msgid "Several Messier objects remain naked-eye visible." msgstr "Plusieurs objets Messier restent visibles à l'œil nu." -#: PiFinder/ui/sqm.py:390 +#: PiFinder/ui/sqm.py:395 msgid "Brighter Rural" msgstr "Rural Plus Lumineux" -#: PiFinder/ui/sqm.py:394 +#: PiFinder/ui/sqm.py:399 msgid "Zodiacal light still visible but doesn't extend halfway to zenith." msgstr "" "La lumière zodiacale encore visible mais ne s'étend pas jusqu'à mi-chemin" " du zénith." -#: PiFinder/ui/sqm.py:397 +#: PiFinder/ui/sqm.py:402 msgid "Light pollution domes apparent in multiple directions." msgstr "Dômes de pollution lumineuse apparents dans plusieurs directions." -#: PiFinder/ui/sqm.py:398 +#: PiFinder/ui/sqm.py:403 msgid "" "The Milky Way well above the horizon is still impressive, but lacks " "detail." @@ -2413,127 +2297,127 @@ msgstr "" "La Voie Lactée bien au-dessus de l'horizon est encore impressionnante, " "mais manque de détails." -#: PiFinder/ui/sqm.py:401 +#: PiFinder/ui/sqm.py:406 msgid "M33 difficult to see." msgstr "M33 difficile à voir." -#: PiFinder/ui/sqm.py:406 +#: PiFinder/ui/sqm.py:411 msgid "Semi-Suburban/Transition Sky" msgstr "Ciel Semi-Suburbain/Transition" -#: PiFinder/ui/sqm.py:410 +#: PiFinder/ui/sqm.py:415 msgid "Clouds have a grayish glow at zenith and appear bright toward city domes." msgstr "" "Les nuages ont une lueur grisâtre au zénith et apparaissent lumineux vers" " les dômes urbains." -#: PiFinder/ui/sqm.py:413 +#: PiFinder/ui/sqm.py:418 msgid "Milky Way only vaguely visible 10-15° above horizon." msgstr "" "La Voie Lactée vaguement visible seulement à 10-15° au-dessus de " "l'horizon." -#: PiFinder/ui/sqm.py:414 +#: PiFinder/ui/sqm.py:419 msgid "Great Rift observable overhead." msgstr "La Grande Faille observable au zénith." -#: PiFinder/ui/sqm.py:419 +#: PiFinder/ui/sqm.py:424 msgid "Suburban Sky" msgstr "Ciel Suburbain" -#: PiFinder/ui/sqm.py:423 +#: PiFinder/ui/sqm.py:428 msgid "Only hints of zodiacal light seen on best nights in autumn and spring." msgstr "" "Seulement des traces de lumière zodiacale visibles les meilleures nuits " "en automne et au printemps." -#: PiFinder/ui/sqm.py:426 +#: PiFinder/ui/sqm.py:431 msgid "Light pollution visible in most, if not all, directions." msgstr "Pollution lumineuse visible dans la plupart, sinon toutes les directions." -#: PiFinder/ui/sqm.py:427 +#: PiFinder/ui/sqm.py:432 msgid "Clouds noticeably brighter than the sky." msgstr "Les nuages visiblement plus lumineux que le ciel." -#: PiFinder/ui/sqm.py:428 +#: PiFinder/ui/sqm.py:433 msgid "Milky Way invisible near horizon, looks washed out overhead." msgstr "La Voie Lactée invisible près de l'horizon, semble délavée au zénith." -#: PiFinder/ui/sqm.py:433 +#: PiFinder/ui/sqm.py:438 msgid "Bright Suburban Sky" msgstr "Ciel Suburbain Lumineux" -#: PiFinder/ui/sqm.py:437 +#: PiFinder/ui/sqm.py:442 msgid "The zodiacal light is invisible." msgstr "La lumière zodiacale est invisible." -#: PiFinder/ui/sqm.py:438 +#: PiFinder/ui/sqm.py:443 msgid "Light pollution makes sky within 35° of horizon glow grayish white." msgstr "" "La pollution lumineuse fait briller le ciel jusqu'à 35° de l'horizon d'un" " blanc grisâtre." -#: PiFinder/ui/sqm.py:441 +#: PiFinder/ui/sqm.py:446 msgid "The Milky Way is only visible near the zenith. M33 undetectable." msgstr "La Voie Lactée est visible seulement près du zénith. M33 indétectable." -#: PiFinder/ui/sqm.py:444 +#: PiFinder/ui/sqm.py:449 msgid "M31 modestly apparent. Surroundings easily visible." msgstr "M31 modestement visible. L'environnement facilement visible." -#: PiFinder/ui/sqm.py:449 +#: PiFinder/ui/sqm.py:454 msgid "Suburban/Urban Transition" msgstr "Transition Suburbain/Urbain" -#: PiFinder/ui/sqm.py:453 +#: PiFinder/ui/sqm.py:458 msgid "Light pollution makes the entire sky light gray." msgstr "La pollution lumineuse rend tout le ciel gris clair." -#: PiFinder/ui/sqm.py:454 +#: PiFinder/ui/sqm.py:459 msgid "Strong light sources evident in all directions." msgstr "Sources lumineuses fortes évidentes dans toutes les directions." -#: PiFinder/ui/sqm.py:455 +#: PiFinder/ui/sqm.py:460 msgid "The Milky Way is nearly or totally invisible." msgstr "La Voie Lactée est presque ou totalement invisible." -#: PiFinder/ui/sqm.py:456 +#: PiFinder/ui/sqm.py:461 msgid "M31 and M44 may be glimpsed, but with no detail." msgstr "M31 et M44 peuvent être aperçus, mais sans détail." -#: PiFinder/ui/sqm.py:461 +#: PiFinder/ui/sqm.py:466 msgid "City Sky" msgstr "Ciel Urbain" -#: PiFinder/ui/sqm.py:465 +#: PiFinder/ui/sqm.py:470 msgid "The sky is light gray or orange—one can easily read." msgstr "Le ciel est gris clair ou orange—on peut facilement lire." -#: PiFinder/ui/sqm.py:466 +#: PiFinder/ui/sqm.py:471 msgid "Stars forming recognizable patterns may vanish entirely." msgstr "" "Les étoiles formant des motifs reconnaissables peuvent disparaître " "complètement." -#: PiFinder/ui/sqm.py:467 +#: PiFinder/ui/sqm.py:472 msgid "Only bright Messier objects can be detected with telescopes." msgstr "" "Seuls les objets Messier brillants peuvent être détectés avec des " "télescopes." -#: PiFinder/ui/sqm.py:472 +#: PiFinder/ui/sqm.py:477 msgid "Inner-City Sky" msgstr "Ciel du Centre-Ville" -#: PiFinder/ui/sqm.py:476 +#: PiFinder/ui/sqm.py:481 msgid "The sky is brilliantly lit." msgstr "Le ciel est brillamment éclairé." -#: PiFinder/ui/sqm.py:477 +#: PiFinder/ui/sqm.py:482 msgid "Many stars forming constellations invisible." msgstr "Nombreuses étoiles formant les constellations sont invisibles." -#: PiFinder/ui/sqm.py:478 +#: PiFinder/ui/sqm.py:483 msgid "" "Only the Moon, planets, bright satellites, and a few of the brightest " "star clusters observable." @@ -2542,36 +2426,59 @@ msgstr "" " amas d'étoiles les plus brillants sont observables." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:69 -msgid "Stop replay" -msgstr "Arrêter relecture" +#: PiFinder/ui/sqm_correction.py:45 PiFinder/ui/sqm_correction.py:87 +msgid "SQM Correction" +msgstr "Correction SQM" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:93 -msgid "" -"No integrator\n" -"queue" -msgstr "" -"Pas de file\n" -"intégrateur" +#: PiFinder/ui/sqm_correction.py:72 +msgid "Del" +msgstr "Suppr." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:99 -msgid "" -"Replay\n" -"stopped" -msgstr "" -"Relecture\n" -"arrêtée" +#: PiFinder/ui/sqm_correction.py:106 +msgid "Original: {sqm:.2f}" +msgstr "Original: {sqm:.2f}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:105 -msgid "" -"Replay\n" -"started" -msgstr "" -"Relecture\n" -"démarrée" +#: PiFinder/ui/sqm_correction.py:115 +msgid "Corrected:" +msgstr "Corrigé:" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:211 +msgid "Enter a value" +msgstr "Entrez une valeur" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:213 +msgid "Range: 10-23" +msgstr "Plage: 10-23" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:218 +msgid "Saving..." +msgstr "Sauvegarde..." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:225 +msgid "Saved: {filename}" +msgstr "Sauveg.: {filename}" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:232 +msgid "Save failed" +msgstr "Échec sauvegarde" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:340 +msgid "Saving {label}..." +msgstr "Sauveg. {label}..." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/telemetry_list.py:69 +msgid "Stop replay" +msgstr "Arrêter relecture" # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/text_menu.py:61 @@ -2612,6 +2519,40 @@ msgstr "hh" msgid "ss" msgstr "ss" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/timeentry.py:116 +msgid "Enter Local Time" +msgstr "Entrez heure locale" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:527 +msgid "" +"Network settings updated — no restart needed. This device is now " +"reachable at http://{host}.local. If you changed the host name, the " +"previous address stops working, so reconnect there." +msgstr "" +"Paramètres réseau mis à jour — aucun redémarrage nécessaire. Cet appareil est maintenant accessible à l'adresse http://{host}.local. Si vous avez modifié le nom d'hôte, l'adresse précédente cesse de fonctionner ; reconnectez-vous à la nouvelle adresse." + +# AI-TRANSLATED (claude): needs human review +#: views/network.html:39 +msgid "Update" +msgstr "Mettre à jour" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/menu_structure.py:626 +msgid "Volume" +msgstr "Volume" + +# AI-TRANSLATED (claude): needs human review +msgid "Low battery\nat {pct}%" +msgstr "Batterie faible\nà {pct}%" + +# AI-TRANSLATED (claude): needs human review +msgid "Low battery\nShutting down" +msgstr "Batterie faible\nArrêt" + +#~ msgid "Integrator" +#~ msgstr "" # AI-TRANSLATED (claude): needs human review #: views/advanced.html:7 msgid "GPS location lock" @@ -2956,6 +2897,16 @@ msgstr "Gestion des lieux" msgid "Add New Location" msgstr "Ajouter un lieu" +# AI-TRANSLATED (claude): needs human review +#: views/locations.html:63 +msgid "Latitude" +msgstr "Latitude" + +# AI-TRANSLATED (claude): needs human review +#: views/locations.html:64 +msgid "Longitude" +msgstr "Longitude" + # AI-TRANSLATED (claude): needs human review #: views/locations.html:89 msgid "Set as Default" @@ -2992,37 +2943,37 @@ msgid "This action cannot be undone." msgstr "Cette action est irréversible." # AI-TRANSLATED (claude): needs human review -#: views/locations.html:435 views/locations.html:523 views/locations.html:580 +#: views/locations.html:428 views/locations.html:516 views/locations.html:565 msgid "This field is required" msgstr "Ce champ est obligatoire" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:437 +#: views/locations.html:430 msgid "Must be a valid number" msgstr "Doit être un nombre valide" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:441 +#: views/locations.html:434 msgid "Must be between -90 and 90" msgstr "Doit être entre -90 et 90" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:444 +#: views/locations.html:437 msgid "Must be between -180 and 180" msgstr "Doit être entre -180 et 180" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:447 +#: views/locations.html:440 msgid "Must be between -1000 and 10000 meters" msgstr "Doit être entre -1000 et 10000 mètres" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:450 +#: views/locations.html:443 msgid "Must be between 0 and 10000 meters" msgstr "Doit être entre 0 et 10000 mètres" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:548 +#: views/locations.html:533 msgid "Please fix the validation errors before saving" msgstr "Veuillez corriger les erreurs avant d'enregistrer" @@ -3384,9 +3335,6 @@ msgstr "" "Ceci utilisera le fichier fourni pour restaurer vos données. Cela " "écrasera les préférences et observations existantes. Êtes-vous sûr ?" -#~ msgid "Integrator" -#~ msgstr "" - # AI-TRANSLATED (claude): needs human review #~ msgid "AE Algo" #~ msgstr "Algo AE" @@ -3433,84 +3381,3 @@ msgstr "" #~ msgid "T9 Search" #~ msgstr "Rech. T9" -# AI-TRANSLATED (claude): needs human review -#~ msgid "Cluster/Neb" -#~ msgstr "Amas/Nébuleuse" - -#~ msgid "P. Nebula" -#~ msgstr "Nébuleuse Planétaire" - -#~ msgid "Double Str" -#~ msgstr "Etoile Double" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Triple Str" -#~ msgstr "Étoile Triple" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Unknown" -#~ msgstr "Inconnu" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "keep going" -#~ msgstr "continuer" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "det {n}" -#~ msgstr "dét {n}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Zoom x{zoom_number}" -#~ msgstr "Zoom x{zoom_number}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "CORRECT" -#~ msgstr "CORRECT" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "SQM Correction" -#~ msgstr "Correction SQM" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Del" -#~ msgstr "Suppr." - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Original: {sqm:.2f}" -#~ msgstr "Original: {sqm:.2f}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Corrected:" -#~ msgstr "Corrigé:" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Enter a value" -#~ msgstr "Entrez une valeur" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Range: 10-23" -#~ msgstr "Plage: 10-23" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saving..." -#~ msgstr "Sauvegarde..." - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saved: {filename}" -#~ msgstr "Sauveg.: {filename}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Save failed" -#~ msgstr "Échec sauvegarde" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saving {label}..." -#~ msgstr "Sauveg. {label}..." - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Enter Local Time" -#~ msgstr "Entrez heure locale" - -#~ msgid "Download: {}MB" -#~ msgstr "" - diff --git a/python/locale/zh/LC_MESSAGES/messages.mo b/python/locale/zh/LC_MESSAGES/messages.mo index f36c2899b..a571df718 100644 Binary files a/python/locale/zh/LC_MESSAGES/messages.mo and b/python/locale/zh/LC_MESSAGES/messages.mo differ diff --git a/python/locale/zh/LC_MESSAGES/messages.po b/python/locale/zh/LC_MESSAGES/messages.po index bb0bc7db0..9fde4cf23 100644 --- a/python/locale/zh/LC_MESSAGES/messages.po +++ b/python/locale/zh/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-07-30 18:03-0700\n" +"POT-Creation-Date: 2026-06-22 09:17-0700\n" "PO-Revision-Date: 2025-02-05 15:00+0800\n" "Last-Translator: ClawdPulse\n" "Language: zh_CN\n" @@ -21,7 +21,7 @@ msgstr "" msgid "No Image" msgstr "无图像" -#: PiFinder/main.py:703 +#: PiFinder/main.py:648 msgid "" "Degraded\n" "Check Status" @@ -29,7 +29,7 @@ msgstr "" "性能下降\n" "检查状态" -#: PiFinder/main.py:810 +#: PiFinder/main.py:750 msgid "" "Catalogs\n" "Fully Loaded" @@ -37,318 +37,267 @@ msgstr "" "星表\n" "已完全加载" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:860 -msgid "" -"Low battery\n" -"Shutting down" -msgstr "" -"电量不足\n" -"正在关机" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/main.py:893 -msgid "" -"Low battery\n" -"at {pct}%" -msgstr "" -"电量不足\n" -"剩余 {pct}%" - -#: PiFinder/obj_types.py:10 +#: PiFinder/obj_types.py:7 PiFinder/ui/menu_structure.py:422 msgid "Galaxy" msgstr "星系" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:11 +#: PiFinder/obj_types.py:8 PiFinder/ui/menu_structure.py:426 msgid "Open Cluster" msgstr "疏散星团" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:12 -msgid "Cluster + Neb" -msgstr "星团+星云" - -#. TRANSLATORS: Object type -#: PiFinder/obj_types.py:13 +#: PiFinder/obj_types.py:9 PiFinder/ui/menu_structure.py:434 msgid "Globular" msgstr "球状星团" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:14 +#: PiFinder/obj_types.py:10 PiFinder/ui/menu_structure.py:438 msgid "Nebula" msgstr "星云" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:15 -msgid "Planetary" -msgstr "行星状星云" - -#. TRANSLATORS: Object type -#: PiFinder/obj_types.py:16 +#: PiFinder/obj_types.py:11 PiFinder/ui/menu_structure.py:446 msgid "Dark Nebula" msgstr "暗星云" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:17 -msgid "Star" -msgstr "恒星" +#: PiFinder/obj_types.py:12 +msgid "Planetary" +msgstr "行星状星云" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:18 -msgid "Double star" -msgstr "双星" +#: PiFinder/obj_types.py:13 +msgid "Cluster + Neb" +msgstr "星团+星云" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:19 -msgid "Triple star" -msgstr "三合星" +#: PiFinder/obj_types.py:14 PiFinder/ui/menu_structure.py:466 +msgid "Asterism" +msgstr "星群" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:20 +#: PiFinder/obj_types.py:15 PiFinder/ui/menu_structure.py:462 msgid "Knot" msgstr "节" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:21 -msgid "Asterism" -msgstr "星群" +#: PiFinder/obj_types.py:16 +msgid "Triple star" +msgstr "三合星" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:22 -msgid "Planet" -msgstr "行星" +#: PiFinder/obj_types.py:17 +msgid "Double star" +msgstr "双星" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:23 -msgid "Comet" -msgstr "彗星" +#: PiFinder/obj_types.py:18 PiFinder/ui/menu_structure.py:450 +msgid "Star" +msgstr "恒星" #. TRANSLATORS: Object type -#: PiFinder/obj_types.py:24 +#: PiFinder/obj_types.py:19 msgid "Unkn" msgstr "未知" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:54 -#, python-format -msgid "%s is required" -msgstr "%s 为必填项" +#. TRANSLATORS: Object type +#: PiFinder/obj_types.py:20 PiFinder/ui/menu_structure.py:470 +msgid "Planet" +msgstr "行星" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:58 -#, python-format -msgid "%s must be a number" -msgstr "%s 必须是数字" +#. TRANSLATORS: Object type +#: PiFinder/obj_types.py:21 PiFinder/ui/menu_structure.py:474 +msgid "Comet" +msgstr "彗星" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:231 +#: PiFinder/server.py:219 msgid "Locked" msgstr "已锁定" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:231 +#: PiFinder/server.py:219 msgid "Not Locked" msgstr "未锁定" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:253 views/base.html:17 views/base.html:28 +#: PiFinder/server.py:241 views/base.html:17 views/base.html:28 msgid "Home" msgstr "首页" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:281 PiFinder/server.py:289 views/login.html:31 +#: PiFinder/server.py:269 PiFinder/server.py:277 views/login.html:31 msgid "Login" msgstr "登录" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:283 +#: PiFinder/server.py:271 msgid "Invalid Password" msgstr "密码错误" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:295 views/base.html:18 views/base.html:29 +#: PiFinder/server.py:283 views/base.html:18 views/base.html:29 msgid "Remote" msgstr "遥控" -#: PiFinder/server.py:301 PiFinder/ui/menu_structure.py:995 +#: PiFinder/server.py:289 PiFinder/ui/menu_structure.py:1033 msgid "Advanced" msgstr "高级" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:310 +#: PiFinder/server.py:298 msgid "Network" msgstr "网络" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:328 +#: PiFinder/server.py:316 msgid "GPS" msgstr "GPS" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:362 PiFinder/server.py:415 PiFinder/server.py:468 +#: PiFinder/server.py:350 PiFinder/server.py:401 PiFinder/server.py:452 #: views/base.html:21 views/base.html:32 msgid "Locations" msgstr "位置列表" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:372 PiFinder/server.py:432 views/locations.html:63 -msgid "Latitude" -msgstr "纬度" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:373 PiFinder/server.py:433 views/locations.html:64 -msgid "Longitude" -msgstr "经度" - -#: PiFinder/server.py:374 PiFinder/server.py:434 -#: PiFinder/ui/menu_structure.py:431 views/locations.html:65 -msgid "Altitude" -msgstr "高度" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:376 PiFinder/server.py:436 PiFinder/ui/object_list.py:309 -#: views/locations.html:66 -msgid "Error" -msgstr "错误" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:382 PiFinder/server.py:442 +#: PiFinder/server.py:368 PiFinder/server.py:426 msgid "Location name is required" msgstr "位置名称为必填项" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:384 PiFinder/server.py:444 +#: PiFinder/server.py:370 PiFinder/server.py:428 msgid "Latitude must be between -90 and 90" msgstr "纬度须在-90到90之间" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:386 PiFinder/server.py:446 +#: PiFinder/server.py:372 PiFinder/server.py:430 msgid "Longitude must be between -180 and 180" msgstr "经度须在-180到180之间" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:389 PiFinder/server.py:449 +#: PiFinder/server.py:375 PiFinder/server.py:433 msgid "Altitude must be between -1000 and 10000 meters" msgstr "海拔须在-1000到10000米之间" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:392 PiFinder/server.py:452 +#: PiFinder/server.py:378 PiFinder/server.py:436 msgid "Error must be between 0 and 10000 meters" msgstr "误差须在0到10000米之间" -#: PiFinder/server.py:539 PiFinder/ui/menu_structure.py:1283 +#: PiFinder/server.py:523 PiFinder/ui/menu_structure.py:1302 msgid "Restart" msgstr "重启" -#: PiFinder/server.py:550 PiFinder/server.py:559 PiFinder/server.py:563 -#: PiFinder/server.py:567 PiFinder/server.py:922 -#: PiFinder/ui/menu_structure.py:1151 views/base.html:23 views/base.html:34 +#: PiFinder/server.py:534 PiFinder/server.py:543 PiFinder/server.py:547 +#: PiFinder/server.py:551 PiFinder/server.py:906 +#: PiFinder/ui/menu_structure.py:1173 views/base.html:23 views/base.html:34 #: views/tools.html:6 msgid "Tools" msgstr "工具" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:551 +#: PiFinder/server.py:535 msgid "You must fill in all password fields" msgstr "请填写所有密码字段" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:559 +#: PiFinder/server.py:543 msgid "Password Changed" msgstr "密码已修改" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:563 +#: PiFinder/server.py:547 msgid "Incorrect current password" msgstr "当前密码错误" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:567 +#: PiFinder/server.py:551 msgid "New passwords do not match" msgstr "两次输入的新密码不一致" -#: PiFinder/server.py:592 PiFinder/server.py:603 PiFinder/server.py:620 -#: PiFinder/server.py:702 PiFinder/server.py:758 PiFinder/server.py:771 -#: PiFinder/server.py:844 PiFinder/server.py:857 -#: PiFinder/ui/menu_structure.py:1156 views/base.html:22 views/base.html:33 +#: PiFinder/server.py:576 PiFinder/server.py:587 PiFinder/server.py:604 +#: PiFinder/server.py:686 PiFinder/server.py:742 PiFinder/server.py:755 +#: PiFinder/server.py:828 PiFinder/server.py:841 +#: PiFinder/ui/menu_structure.py:1178 views/base.html:22 views/base.html:33 #: views/equipment.html:6 msgid "Equipment" msgstr "设备" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:609 +#: PiFinder/server.py:593 msgid "set as active instrument." msgstr "已设为当前望远镜。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:626 +#: PiFinder/server.py:610 msgid "set as active eyepiece." msgstr "已设为当前目镜。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:704 +#: PiFinder/server.py:688 msgid "Equipment Imported, restart your PiFinder to use this new data" msgstr "设备已导入,重启PiFinder后生效" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:720 +#: PiFinder/server.py:704 msgid "Edit Eyepiece" msgstr "编辑目镜" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:760 +#: PiFinder/server.py:744 msgid "Eyepiece added, restart your PiFinder to use" msgstr "目镜已添加,重启PiFinder后生效" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:773 +#: PiFinder/server.py:757 msgid "Eyepiece Deleted, restart your PiFinder to remove from menu" msgstr "目镜已删除,重启PiFinder后从菜单移除" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:798 +#: PiFinder/server.py:782 msgid "Edit Instrument" msgstr "编辑望远镜" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:846 +#: PiFinder/server.py:830 msgid "Instrument Added, restart your PiFinder to use" msgstr "望远镜已添加,重启PiFinder后生效" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:859 +#: PiFinder/server.py:843 msgid "Instrument Deleted, restart your PiFinder to remove from menu" msgstr "望远镜已删除,重启PiFinder后从菜单移除" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:887 views/base.html:20 views/base.html:31 +#: PiFinder/server.py:871 views/base.html:20 views/base.html:31 msgid "Observations" msgstr "观测记录" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:916 +#: PiFinder/server.py:900 msgid "Session Log" msgstr "观测日志" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:927 PiFinder/server.py:1025 views/base.html:24 +#: PiFinder/server.py:911 PiFinder/server.py:1010 views/base.html:24 #: views/base.html:35 msgid "Logs" msgstr "日志" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:1025 +#: PiFinder/server.py:1010 msgid "Error creating log archive" msgstr "创建日志压缩包失败" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:1082 views/restart_pifinder.html:6 +#: PiFinder/server.py:1067 views/restart_pifinder.html:6 msgid "Restarting PiFinder" msgstr "正在重启PiFinder" # AI-TRANSLATED (claude): needs human review -#: PiFinder/server.py:1144 +#: PiFinder/server.py:1129 msgid "Restart PiFinder" msgstr "重启PiFinder" @@ -378,21 +327,21 @@ msgstr "{icon} 选择恒星" msgid "{icon} SAVE / 0 CANCEL" msgstr "{icon} 保存 / 0 取消" -#: PiFinder/ui/align.py:279 PiFinder/ui/chart.py:421 +#: PiFinder/ui/align.py:279 PiFinder/ui/chart.py:273 msgid "Can't plot" msgstr "无法绘制" -#: PiFinder/ui/align.py:288 PiFinder/ui/chart.py:430 PiFinder/ui/log.py:166 -#: PiFinder/ui/object_list.py:331 PiFinder/ui/object_list.py:351 +#: PiFinder/ui/align.py:288 PiFinder/ui/chart.py:282 PiFinder/ui/log.py:166 +#: PiFinder/ui/object_list.py:286 PiFinder/ui/object_list.py:306 msgid "No Solve Yet" msgstr "尚未解析" -#: PiFinder/ui/align.py:399 PiFinder/ui/object_details.py:682 +#: PiFinder/ui/align.py:399 PiFinder/ui/object_details.py:675 msgid "Aligning..." msgstr "对齐中..." #: PiFinder/ui/align.py:407 PiFinder/ui/align_daytime.py:310 -#: PiFinder/ui/object_details.py:690 +#: PiFinder/ui/object_details.py:683 msgid "Aligned!" msgstr "已对齐!" @@ -420,13 +369,7 @@ msgstr "{icon} 保存 0 取消" msgid "AUTO" msgstr "自动" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/base.py:300 PiFinder/ui/dateentry.py:166 -#: PiFinder/ui/locationentry.py:222 PiFinder/ui/timeentry.py:171 -msgid " Cancel" -msgstr " 取消" - -#: PiFinder/ui/callbacks.py:52 +#: PiFinder/ui/callbacks.py:51 msgid "" "Options for\n" "DIY PiFinders" @@ -434,37 +377,37 @@ msgstr "" "DIY PiFinder\n" "选项" -#: PiFinder/ui/callbacks.py:67 +#: PiFinder/ui/callbacks.py:66 msgid "Filters Reset" msgstr "滤镜已重置" -#: PiFinder/ui/callbacks.py:80 PiFinder/ui/menu_structure.py:1202 +#: PiFinder/ui/callbacks.py:79 PiFinder/ui/menu_structure.py:1221 msgid "Test Mode" msgstr "测试模式" -#: PiFinder/ui/callbacks.py:174 +#: PiFinder/ui/callbacks.py:161 msgid "Shutting Down" msgstr "正在关机" -#: PiFinder/ui/callbacks.py:183 PiFinder/ui/callbacks.py:191 +#: PiFinder/ui/callbacks.py:170 PiFinder/ui/callbacks.py:178 msgid "Restarting..." msgstr "正在重启..." -#: PiFinder/ui/callbacks.py:196 PiFinder/ui/callbacks.py:202 -#: PiFinder/ui/callbacks.py:208 +#: PiFinder/ui/callbacks.py:183 PiFinder/ui/callbacks.py:189 +#: PiFinder/ui/callbacks.py:195 msgid "Switching cam" msgstr "切换相机" -#: PiFinder/ui/callbacks.py:246 +#: PiFinder/ui/callbacks.py:233 msgid "WiFi to AP" msgstr "WiFi转AP" -#: PiFinder/ui/callbacks.py:252 +#: PiFinder/ui/callbacks.py:239 msgid "WiFi to Client" msgstr "WiFi转客户端" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:275 +#: PiFinder/ui/callbacks.py:262 msgid "" "{lat:.2f}, {lon:.2f}\n" "{alt}m alt" @@ -473,22 +416,22 @@ msgstr "" "海拔{alt}m" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:282 +#: PiFinder/ui/callbacks.py:269 msgid "Location Reset" msgstr "重置位置" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:287 +#: PiFinder/ui/callbacks.py:274 msgid "Time/Date Reset" msgstr "重置时间/日期" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:294 +#: PiFinder/ui/callbacks.py:281 msgid "No location lock" msgstr "无位置锁定" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:308 +#: PiFinder/ui/callbacks.py:295 msgid "" "Saved\n" "{name}" @@ -496,22 +439,22 @@ msgstr "" "已保存\n" "{name}" -#: PiFinder/ui/callbacks.py:312 PiFinder/ui/gpsstatus.py:79 -#: PiFinder/ui/location_list.py:135 views/location_form.html:6 +#: PiFinder/ui/callbacks.py:299 PiFinder/ui/gpsstatus.py:79 +#: PiFinder/ui/location_list.py:137 views/location_form.html:6 #: views/locations.html:117 msgid "Location Name" msgstr "位置名称" -#: PiFinder/ui/callbacks.py:315 PiFinder/ui/gpsstatus.py:82 +#: PiFinder/ui/callbacks.py:302 PiFinder/ui/gpsstatus.py:82 msgid "Loc {number}" msgstr "位置 {number}" -#: PiFinder/ui/callbacks.py:346 +#: PiFinder/ui/callbacks.py:329 msgid "Time: {time}" msgstr "时间: {time}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:364 +#: PiFinder/ui/callbacks.py:347 msgid "" "{date}\n" "{time}" @@ -520,7 +463,7 @@ msgstr "" "{time}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:384 +#: PiFinder/ui/callbacks.py:367 msgid "" "User object created\n" "{name}" @@ -529,7 +472,7 @@ msgstr "" "{name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:514 +#: PiFinder/ui/callbacks.py:497 msgid "" "Checking GPS\n" "config..." @@ -538,7 +481,7 @@ msgstr "" "配置..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:519 +#: PiFinder/ui/callbacks.py:502 msgid "" "GPS config\n" "updated" @@ -547,7 +490,7 @@ msgstr "" "已更新" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:521 +#: PiFinder/ui/callbacks.py:504 msgid "" "GPS config\n" "OK" @@ -556,7 +499,7 @@ msgstr "" "正常" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/callbacks.py:524 +#: PiFinder/ui/callbacks.py:507 msgid "" "GPS config\n" "failed" @@ -564,13 +507,13 @@ msgstr "" "GPS配置\n" "失败" -#: PiFinder/ui/chart.py:92 PiFinder/ui/menu_structure.py:543 +#: PiFinder/ui/chart.py:50 PiFinder/ui/menu_structure.py:596 msgid "Settings" msgstr "设置" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: chart corner label, e.g. "Zenith up" — keep short -#: PiFinder/ui/chart.py:273 +#: PiFinder/ui/chart.py:125 msgid "{label} up" msgstr "{label}朝上" @@ -591,36 +534,33 @@ msgid "dd" msgstr "dd" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:141 +#: PiFinder/ui/dateentry.py:130 msgid "Enter Local Date" msgstr "输入本地日期" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:159 PiFinder/ui/timeentry.py:164 +#: PiFinder/ui/dateentry.py:148 PiFinder/ui/timeentry.py:135 msgid " Done" msgstr " 完成" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:173 PiFinder/ui/locationentry.py:232 -#: PiFinder/ui/timeentry.py:178 +#: PiFinder/ui/dateentry.py:155 PiFinder/ui/locationentry.py:222 +#: PiFinder/ui/timeentry.py:142 +msgid " Cancel" +msgstr " 取消" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/dateentry.py:162 PiFinder/ui/locationentry.py:232 +#: PiFinder/ui/timeentry.py:149 msgid "󰍴 Delete/Previous" msgstr "󰍴 删除/上一步" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:236 PiFinder/ui/locationentry.py:346 -#: PiFinder/ui/polar_align.py:525 PiFinder/ui/timeentry.py:252 +#: PiFinder/ui/dateentry.py:219 PiFinder/ui/locationentry.py:346 +#: PiFinder/ui/polar_align.py:516 PiFinder/ui/timeentry.py:217 msgid "Cancelled" msgstr "已取消" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/dateentry.py:250 PiFinder/ui/timeentry.py:270 -msgid "" -"Set location\n" -"first" -msgstr "" -"请先\n" -"设置位置" - #: PiFinder/ui/equipment.py:39 msgid "No telescope selected" msgstr "未选择望远镜" @@ -665,7 +605,8 @@ msgstr "精确" msgid "Precise" msgstr "精准" -#: PiFinder/ui/gpsstatus.py:45 views/gps.html:77 views/network.html:81 +#: PiFinder/ui/gpsstatus.py:45 PiFinder/ui/sqm_correction.py:71 +#: views/gps.html:77 views/network.html:81 msgid "Save" msgstr "保存" @@ -722,8 +663,8 @@ msgstr "以加快锁定速度" msgid "Lock Type:" msgstr "锁定类型:" -#: PiFinder/ui/gpsstatus.py:211 PiFinder/ui/menu_structure.py:437 -#: PiFinder/ui/menu_structure.py:469 views/network.html:74 +#: PiFinder/ui/gpsstatus.py:211 PiFinder/ui/menu_structure.py:490 +#: PiFinder/ui/menu_structure.py:522 views/network.html:74 msgid "None" msgstr "无" @@ -787,7 +728,7 @@ msgid "From: {location_source}" msgstr "来源: {location_source}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:28 PiFinder/ui/menu_structure.py:1257 +#: PiFinder/ui/location_list.py:28 PiFinder/ui/menu_structure.py:1276 msgid "Load" msgstr "加载" @@ -809,12 +750,12 @@ msgid "Loaded: {name}" msgstr "已加载: {name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:128 +#: PiFinder/ui/location_list.py:129 msgid "Deleted: {name}" msgstr "已删除: {name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:148 +#: PiFinder/ui/location_list.py:150 msgid "" "Renamed to:\n" "{name}" @@ -823,7 +764,7 @@ msgstr "" "{name}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/location_list.py:187 +#: PiFinder/ui/location_list.py:189 msgid "No locations" msgstr "无位置记录" @@ -874,7 +815,7 @@ msgstr "󰍴 删除 󰐕 东/西" # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/locationentry.py:312 PiFinder/ui/locationentry.py:324 -#: PiFinder/ui/menu_structure.py:1172 +#: PiFinder/ui/menu_structure.py:1194 msgid "Enter Coords" msgstr "输入坐标" @@ -962,687 +903,668 @@ msgid "Telescope" msgstr "望远镜" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:31 +#: PiFinder/ui/menu_structure.py:30 msgid "Language: de" msgstr "语言: 德语" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:32 +#: PiFinder/ui/menu_structure.py:31 msgid "Language: en" msgstr "语言: 英语" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:33 +#: PiFinder/ui/menu_structure.py:32 msgid "Language: es" msgstr "语言: 西班牙语" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:34 +#: PiFinder/ui/menu_structure.py:33 msgid "Language: fr" msgstr "语言: 法语" -#: PiFinder/ui/menu_structure.py:35 +#: PiFinder/ui/menu_structure.py:34 msgid "Language: zh" msgstr "语言: 中文" -#: PiFinder/ui/menu_structure.py:46 +#: PiFinder/ui/menu_structure.py:45 msgid "Start" msgstr "开始" -#: PiFinder/ui/menu_structure.py:51 +#: PiFinder/ui/menu_structure.py:50 msgid "Focus" msgstr "对焦" -#: PiFinder/ui/menu_structure.py:55 +#: PiFinder/ui/menu_structure.py:54 msgid "Align" msgstr "对齐" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:61 +#: PiFinder/ui/menu_structure.py:60 msgid "Align (Day)" msgstr "对齐 (日间)" -#: PiFinder/ui/menu_structure.py:67 PiFinder/ui/menu_structure.py:1163 +#: PiFinder/ui/menu_structure.py:66 PiFinder/ui/menu_structure.py:1185 msgid "GPS Status" msgstr "GPS状态" -#: PiFinder/ui/menu_structure.py:73 +#: PiFinder/ui/menu_structure.py:72 msgid "Chart" msgstr "星图" -#: PiFinder/ui/menu_structure.py:79 views/obs_session_log.html:7 +#: PiFinder/ui/menu_structure.py:78 views/obs_session_log.html:7 #: views/obs_sessions.html:11 views/obs_sessions.html:25 msgid "Objects" msgstr "天体" -#: PiFinder/ui/menu_structure.py:84 +#: PiFinder/ui/menu_structure.py:83 msgid "All Filtered" msgstr "全部筛选" -#: PiFinder/ui/menu_structure.py:89 +#: PiFinder/ui/menu_structure.py:88 msgid "By Catalog" msgstr "按星表" -#: PiFinder/ui/menu_structure.py:94 PiFinder/ui/menu_structure.py:303 +#: PiFinder/ui/menu_structure.py:93 PiFinder/ui/menu_structure.py:302 msgid "Planets" msgstr "行星" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:100 PiFinder/ui/menu_structure.py:307 +#: PiFinder/ui/menu_structure.py:99 PiFinder/ui/menu_structure.py:306 msgid "Comets" msgstr "彗星" -#: PiFinder/ui/menu_structure.py:106 PiFinder/ui/menu_structure.py:189 -#: PiFinder/ui/menu_structure.py:311 PiFinder/ui/menu_structure.py:369 +#: PiFinder/ui/menu_structure.py:105 PiFinder/ui/menu_structure.py:188 +#: PiFinder/ui/menu_structure.py:310 PiFinder/ui/menu_structure.py:368 msgid "NGC" msgstr "NGC" -#: PiFinder/ui/menu_structure.py:112 PiFinder/ui/menu_structure.py:183 -#: PiFinder/ui/menu_structure.py:315 PiFinder/ui/menu_structure.py:365 +#: PiFinder/ui/menu_structure.py:111 PiFinder/ui/menu_structure.py:182 +#: PiFinder/ui/menu_structure.py:314 PiFinder/ui/menu_structure.py:364 msgid "Messier" msgstr "梅西耶" -#: PiFinder/ui/menu_structure.py:118 PiFinder/ui/menu_structure.py:319 +#: PiFinder/ui/menu_structure.py:117 PiFinder/ui/menu_structure.py:318 msgid "DSO..." msgstr "深空天体..." -#: PiFinder/ui/menu_structure.py:123 PiFinder/ui/menu_structure.py:325 +#: PiFinder/ui/menu_structure.py:122 PiFinder/ui/menu_structure.py:324 msgid "Abell Pn" msgstr "阿贝尔行星状星云" -#: PiFinder/ui/menu_structure.py:129 PiFinder/ui/menu_structure.py:329 +#: PiFinder/ui/menu_structure.py:128 PiFinder/ui/menu_structure.py:328 msgid "Arp Galaxies" msgstr "Arp星系" -#: PiFinder/ui/menu_structure.py:135 PiFinder/ui/menu_structure.py:333 +#: PiFinder/ui/menu_structure.py:134 PiFinder/ui/menu_structure.py:332 msgid "Barnard" msgstr "巴纳德" -#: PiFinder/ui/menu_structure.py:141 PiFinder/ui/menu_structure.py:337 +#: PiFinder/ui/menu_structure.py:140 PiFinder/ui/menu_structure.py:336 msgid "Caldwell" msgstr "考德威尔" -#: PiFinder/ui/menu_structure.py:147 PiFinder/ui/menu_structure.py:341 +#: PiFinder/ui/menu_structure.py:146 PiFinder/ui/menu_structure.py:340 msgid "Collinder" msgstr "科林德" -#: PiFinder/ui/menu_structure.py:153 PiFinder/ui/menu_structure.py:345 +#: PiFinder/ui/menu_structure.py:152 PiFinder/ui/menu_structure.py:344 msgid "E.G. Globs" msgstr "ESO球状星团" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:159 PiFinder/ui/menu_structure.py:349 +#: PiFinder/ui/menu_structure.py:158 PiFinder/ui/menu_structure.py:348 msgid "Harris Globs" msgstr "哈里斯球状星团" -#: PiFinder/ui/menu_structure.py:165 PiFinder/ui/menu_structure.py:353 +#: PiFinder/ui/menu_structure.py:164 PiFinder/ui/menu_structure.py:352 msgid "Herschel 400" msgstr "赫歇尔400" -#: PiFinder/ui/menu_structure.py:171 PiFinder/ui/menu_structure.py:357 +#: PiFinder/ui/menu_structure.py:170 PiFinder/ui/menu_structure.py:356 msgid "IC" msgstr "IC" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:177 PiFinder/ui/menu_structure.py:361 +#: PiFinder/ui/menu_structure.py:176 PiFinder/ui/menu_structure.py:360 msgid "Lynga Opn Cl" msgstr "Lynga疏散星团" -#: PiFinder/ui/menu_structure.py:195 PiFinder/ui/menu_structure.py:373 +#: PiFinder/ui/menu_structure.py:194 PiFinder/ui/menu_structure.py:372 msgid "Sharpless" msgstr "夏普利斯" -#: PiFinder/ui/menu_structure.py:201 PiFinder/ui/menu_structure.py:377 +#: PiFinder/ui/menu_structure.py:200 PiFinder/ui/menu_structure.py:376 msgid "TAAS 200" msgstr "TAAS 200" -#: PiFinder/ui/menu_structure.py:209 PiFinder/ui/menu_structure.py:383 +#: PiFinder/ui/menu_structure.py:208 PiFinder/ui/menu_structure.py:382 msgid "Stars..." msgstr "恒星..." -#: PiFinder/ui/menu_structure.py:214 PiFinder/ui/menu_structure.py:389 +#: PiFinder/ui/menu_structure.py:213 PiFinder/ui/menu_structure.py:388 msgid "Bright Named" msgstr "亮星" -#: PiFinder/ui/menu_structure.py:220 PiFinder/ui/menu_structure.py:393 +#: PiFinder/ui/menu_structure.py:219 PiFinder/ui/menu_structure.py:392 msgid "SAC Doubles" msgstr "SAC双星" -#: PiFinder/ui/menu_structure.py:226 PiFinder/ui/menu_structure.py:397 +#: PiFinder/ui/menu_structure.py:225 PiFinder/ui/menu_structure.py:396 msgid "SAC Asterisms" msgstr "SAC星群" -#: PiFinder/ui/menu_structure.py:232 PiFinder/ui/menu_structure.py:401 +#: PiFinder/ui/menu_structure.py:231 PiFinder/ui/menu_structure.py:400 msgid "SAC Red Stars" msgstr "SAC红巨星" -#: PiFinder/ui/menu_structure.py:238 PiFinder/ui/menu_structure.py:405 +#: PiFinder/ui/menu_structure.py:237 PiFinder/ui/menu_structure.py:404 msgid "RASC Doubles" msgstr "RASC双星" -#: PiFinder/ui/menu_structure.py:244 +#: PiFinder/ui/menu_structure.py:243 msgid "WDS Doubles" msgstr "WDS双星" -#: PiFinder/ui/menu_structure.py:250 PiFinder/ui/menu_structure.py:409 +#: PiFinder/ui/menu_structure.py:249 PiFinder/ui/menu_structure.py:408 msgid "TLK 90 Variables" msgstr "TLK 90变星" -#: PiFinder/ui/menu_structure.py:260 +#: PiFinder/ui/menu_structure.py:259 msgid "Recent" msgstr "最近" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:266 PiFinder/ui/obs_list.py:70 +#: PiFinder/ui/menu_structure.py:265 PiFinder/ui/obs_list.py:70 msgid "Obs Lists" msgstr "观测列表" -#: PiFinder/ui/menu_structure.py:270 +#: PiFinder/ui/menu_structure.py:269 msgid "Custom" msgstr "自定义" -#: PiFinder/ui/menu_structure.py:275 +#: PiFinder/ui/menu_structure.py:274 msgid "Name Search" msgstr "名称搜索" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:279 +#: PiFinder/ui/menu_structure.py:278 msgid "Set Filters" msgstr "设置筛选" -#: PiFinder/ui/menu_structure.py:285 +#: PiFinder/ui/menu_structure.py:284 msgid "Reset All" msgstr "全部重置" -#: PiFinder/ui/menu_structure.py:290 PiFinder/ui/menu_structure.py:1278 -#: PiFinder/ui/menu_structure.py:1289 PiFinder/ui/software.py:435 -#: PiFinder/ui/software.py:531 +#: PiFinder/ui/menu_structure.py:289 PiFinder/ui/menu_structure.py:1297 +#: PiFinder/ui/menu_structure.py:1308 msgid "Confirm" msgstr "确认" -#: PiFinder/ui/menu_structure.py:293 PiFinder/ui/menu_structure.py:1279 -#: PiFinder/ui/menu_structure.py:1292 PiFinder/ui/software.py:380 -#: PiFinder/ui/software.py:435 PiFinder/ui/software.py:529 -#: views/edit_eyepiece.html:54 views/edit_instrument.html:108 -#: views/equipment.html:58 views/location_form.html:77 views/locations.html:187 +#: PiFinder/ui/menu_structure.py:292 PiFinder/ui/menu_structure.py:1298 +#: PiFinder/ui/menu_structure.py:1311 PiFinder/ui/software.py:208 +#: PiFinder/ui/sqm_correction.py:70 views/edit_eyepiece.html:54 +#: views/edit_instrument.html:108 views/equipment.html:58 +#: views/location_form.html:77 views/locations.html:187 #: views/locations.html:200 views/network.html:46 views/network.html:83 #: views/network_item.html:19 views/tools.html:97 msgid "Cancel" msgstr "取消" -#: PiFinder/ui/menu_structure.py:297 +#: PiFinder/ui/menu_structure.py:296 msgid "Catalogs" msgstr "星表" -#: PiFinder/ui/menu_structure.py:417 +#: PiFinder/ui/menu_structure.py:416 msgid "Type" msgstr "类型" -#: PiFinder/ui/menu_structure.py:463 +#: PiFinder/ui/menu_structure.py:430 +msgid "Cluster/Neb" +msgstr "星团/星云" + +#: PiFinder/ui/menu_structure.py:442 +msgid "P. Nebula" +msgstr "行星状星云" + +#: PiFinder/ui/menu_structure.py:454 +msgid "Double Str" +msgstr "双星" + +#: PiFinder/ui/menu_structure.py:458 +msgid "Triple Str" +msgstr "三合星" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/menu_structure.py:478 +msgid "Unknown" +msgstr "未知" + +#: PiFinder/ui/menu_structure.py:484 views/locations.html:65 +msgid "Altitude" +msgstr "高度" + +#: PiFinder/ui/menu_structure.py:516 msgid "Magnitude" msgstr "星等" -#: PiFinder/ui/menu_structure.py:515 PiFinder/ui/menu_structure.py:525 +#: PiFinder/ui/menu_structure.py:568 PiFinder/ui/menu_structure.py:578 msgid "Observed" msgstr "已观测" -#: PiFinder/ui/menu_structure.py:521 +#: PiFinder/ui/menu_structure.py:574 msgid "Any" msgstr "任意" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:529 +#: PiFinder/ui/menu_structure.py:582 msgid "Not Observed" msgstr "未观测" -#: PiFinder/ui/menu_structure.py:548 +#: PiFinder/ui/menu_structure.py:601 msgid "User Pref..." msgstr "用户偏好..." -#: PiFinder/ui/menu_structure.py:553 +#: PiFinder/ui/menu_structure.py:606 msgid "Key Bright" msgstr "按键亮度" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:594 -msgid "Volume" -msgstr "音量" +#: PiFinder/ui/menu_structure.py:647 +msgid "Sleep Time" +msgstr "休眠时间" -#: PiFinder/ui/menu_structure.py:600 PiFinder/ui/menu_structure.py:615 -#: PiFinder/ui/menu_structure.py:647 PiFinder/ui/menu_structure.py:671 -#: PiFinder/ui/menu_structure.py:790 PiFinder/ui/menu_structure.py:814 -#: PiFinder/ui/menu_structure.py:838 PiFinder/ui/menu_structure.py:862 -#: PiFinder/ui/menu_structure.py:893 PiFinder/ui/menu_structure.py:909 -#: PiFinder/ui/menu_structure.py:1125 PiFinder/ui/menu_structure.py:1231 -#: PiFinder/ui/menu_structure.py:1247 +#: PiFinder/ui/menu_structure.py:653 PiFinder/ui/menu_structure.py:685 +#: PiFinder/ui/menu_structure.py:709 PiFinder/ui/menu_structure.py:828 +#: PiFinder/ui/menu_structure.py:852 PiFinder/ui/menu_structure.py:876 +#: PiFinder/ui/menu_structure.py:900 PiFinder/ui/menu_structure.py:931 +#: PiFinder/ui/menu_structure.py:947 PiFinder/ui/menu_structure.py:1147 +#: PiFinder/ui/menu_structure.py:1250 PiFinder/ui/menu_structure.py:1266 msgid "Off" msgstr "关闭" -#: PiFinder/ui/menu_structure.py:609 -msgid "Sleep Time" -msgstr "休眠时间" - -#: PiFinder/ui/menu_structure.py:641 +#: PiFinder/ui/menu_structure.py:679 msgid "Menu Anim" msgstr "菜单动画" -#: PiFinder/ui/menu_structure.py:651 PiFinder/ui/menu_structure.py:675 +#: PiFinder/ui/menu_structure.py:689 PiFinder/ui/menu_structure.py:713 msgid "Fast" msgstr "快" -#: PiFinder/ui/menu_structure.py:655 PiFinder/ui/menu_structure.py:679 -#: PiFinder/ui/menu_structure.py:798 PiFinder/ui/menu_structure.py:822 -#: PiFinder/ui/menu_structure.py:846 PiFinder/ui/menu_structure.py:1139 +#: PiFinder/ui/menu_structure.py:693 PiFinder/ui/menu_structure.py:717 +#: PiFinder/ui/menu_structure.py:836 PiFinder/ui/menu_structure.py:860 +#: PiFinder/ui/menu_structure.py:884 PiFinder/ui/menu_structure.py:1161 msgid "Medium" msgstr "中" -#: PiFinder/ui/menu_structure.py:659 PiFinder/ui/menu_structure.py:683 +#: PiFinder/ui/menu_structure.py:697 PiFinder/ui/menu_structure.py:721 msgid "Slow" msgstr "慢" -#: PiFinder/ui/menu_structure.py:665 +#: PiFinder/ui/menu_structure.py:703 msgid "Scroll Speed" msgstr "滚动速度" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:689 +#: PiFinder/ui/menu_structure.py:727 msgid "Search Input" msgstr "搜索输入" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:696 +#: PiFinder/ui/menu_structure.py:734 msgid "Multi-Tap" msgstr "多击输入" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:700 +#: PiFinder/ui/menu_structure.py:738 msgid "T9" msgstr "T9" -#: PiFinder/ui/menu_structure.py:706 +#: PiFinder/ui/menu_structure.py:744 msgid "Az Arrows" msgstr "方位箭头" -#: PiFinder/ui/menu_structure.py:713 +#: PiFinder/ui/menu_structure.py:751 msgid "Default" msgstr "默认" -#: PiFinder/ui/menu_structure.py:717 +#: PiFinder/ui/menu_structure.py:755 msgid "Reverse" msgstr "反向" -#: PiFinder/ui/menu_structure.py:723 +#: PiFinder/ui/menu_structure.py:761 msgid "Language" msgstr "语言" -#: PiFinder/ui/menu_structure.py:730 +#: PiFinder/ui/menu_structure.py:768 msgid "English" msgstr "英语" -#: PiFinder/ui/menu_structure.py:734 +#: PiFinder/ui/menu_structure.py:772 msgid "German" msgstr "德语" -#: PiFinder/ui/menu_structure.py:738 +#: PiFinder/ui/menu_structure.py:776 msgid "French" msgstr "法语" -#: PiFinder/ui/menu_structure.py:742 +#: PiFinder/ui/menu_structure.py:780 msgid "Spanish" msgstr "西班牙语" -#: PiFinder/ui/menu_structure.py:746 +#: PiFinder/ui/menu_structure.py:784 msgid "Chinese" msgstr "中文" -#: PiFinder/ui/menu_structure.py:754 +#: PiFinder/ui/menu_structure.py:792 msgid "Chart..." msgstr "星图..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:760 +#: PiFinder/ui/menu_structure.py:798 msgid "Coordinate Sys." msgstr "坐标系" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:766 +#: PiFinder/ui/menu_structure.py:804 msgid "Horizontal" msgstr "地平坐标" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:770 +#: PiFinder/ui/menu_structure.py:808 msgid "EQ (Auto)" msgstr "EQ (自动)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:774 +#: PiFinder/ui/menu_structure.py:812 msgid "EQ (North-up)" msgstr "EQ (北上)" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:778 +#: PiFinder/ui/menu_structure.py:816 msgid "EQ (South-up)" msgstr "EQ (南上)" -#: PiFinder/ui/menu_structure.py:784 +#: PiFinder/ui/menu_structure.py:822 msgid "Reticle" msgstr "十字丝" -#: PiFinder/ui/menu_structure.py:794 PiFinder/ui/menu_structure.py:818 -#: PiFinder/ui/menu_structure.py:842 PiFinder/ui/menu_structure.py:1135 +#: PiFinder/ui/menu_structure.py:832 PiFinder/ui/menu_structure.py:856 +#: PiFinder/ui/menu_structure.py:880 PiFinder/ui/menu_structure.py:1157 msgid "Low" msgstr "低" -#: PiFinder/ui/menu_structure.py:802 PiFinder/ui/menu_structure.py:826 -#: PiFinder/ui/menu_structure.py:850 PiFinder/ui/menu_structure.py:1143 +#: PiFinder/ui/menu_structure.py:840 PiFinder/ui/menu_structure.py:864 +#: PiFinder/ui/menu_structure.py:888 PiFinder/ui/menu_structure.py:1165 msgid "High" msgstr "高" -#: PiFinder/ui/menu_structure.py:808 +#: PiFinder/ui/menu_structure.py:846 msgid "Constellation" msgstr "星座" -#: PiFinder/ui/menu_structure.py:832 +#: PiFinder/ui/menu_structure.py:870 msgid "DSO Display" msgstr "深空天体显示" -#: PiFinder/ui/menu_structure.py:856 +#: PiFinder/ui/menu_structure.py:894 msgid "RA/DEC Disp." msgstr "RA/Dec显示" -#: PiFinder/ui/menu_structure.py:866 +#: PiFinder/ui/menu_structure.py:904 msgid "HH:MM" msgstr "时:分" -#: PiFinder/ui/menu_structure.py:870 +#: PiFinder/ui/menu_structure.py:908 msgid "Degrees" msgstr "度数" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:878 +#: PiFinder/ui/menu_structure.py:916 msgid "Image..." msgstr "图像..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:883 +#: PiFinder/ui/menu_structure.py:921 msgid "NSEW Labels" msgstr "东南西北标签" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:889 PiFinder/ui/menu_structure.py:905 -#: PiFinder/ui/menu_structure.py:1235 PiFinder/ui/menu_structure.py:1251 +#: PiFinder/ui/menu_structure.py:927 PiFinder/ui/menu_structure.py:943 +#: PiFinder/ui/menu_structure.py:1254 PiFinder/ui/menu_structure.py:1270 msgid "On" msgstr "开启" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:899 +#: PiFinder/ui/menu_structure.py:937 msgid "Object Size" msgstr "目标尺寸" -#: PiFinder/ui/menu_structure.py:917 +#: PiFinder/ui/menu_structure.py:955 msgid "Camera Exp" msgstr "相机曝光" -#: PiFinder/ui/menu_structure.py:925 +#: PiFinder/ui/menu_structure.py:963 msgid "Auto" msgstr "自动" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:930 +#: PiFinder/ui/menu_structure.py:968 msgid "0.025s" msgstr "0.025s" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:934 +#: PiFinder/ui/menu_structure.py:972 msgid "0.05s" msgstr "0.05s" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:938 +#: PiFinder/ui/menu_structure.py:976 msgid "0.1s" msgstr "0.1s" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:942 +#: PiFinder/ui/menu_structure.py:980 msgid "0.2s" msgstr "0.2s" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:946 +#: PiFinder/ui/menu_structure.py:984 msgid "0.4s" msgstr "0.4s" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:950 +#: PiFinder/ui/menu_structure.py:988 msgid "0.8s" msgstr "0.8s" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:954 +#: PiFinder/ui/menu_structure.py:992 msgid "1s" msgstr "1s" -#: PiFinder/ui/menu_structure.py:960 +#: PiFinder/ui/menu_structure.py:998 msgid "WiFi Mode" msgstr "WiFi模式" -#: PiFinder/ui/menu_structure.py:966 +#: PiFinder/ui/menu_structure.py:1004 msgid "Client Mode" msgstr "客户端模式" -#: PiFinder/ui/menu_structure.py:971 +#: PiFinder/ui/menu_structure.py:1009 msgid "AP Mode" msgstr "AP模式" -#: PiFinder/ui/menu_structure.py:978 views/edit_instrument.html:61 +#: PiFinder/ui/menu_structure.py:1016 views/edit_instrument.html:61 #: views/equipment.html:73 msgid "Mount Type" msgstr "望远镜类型" -#: PiFinder/ui/menu_structure.py:985 views/edit_instrument.html:57 +#: PiFinder/ui/menu_structure.py:1023 views/edit_instrument.html:57 msgid "Alt/Az" msgstr "Alt/Az" -#: PiFinder/ui/menu_structure.py:989 views/edit_instrument.html:58 +#: PiFinder/ui/menu_structure.py:1027 views/edit_instrument.html:58 msgid "Equatorial" msgstr "赤道式" -#: PiFinder/ui/menu_structure.py:1001 +#: PiFinder/ui/menu_structure.py:1039 msgid "PiFinder Type" msgstr "PiFinder类型" -#: PiFinder/ui/menu_structure.py:1008 +#: PiFinder/ui/menu_structure.py:1046 msgid "Left" msgstr "左" -#: PiFinder/ui/menu_structure.py:1012 +#: PiFinder/ui/menu_structure.py:1050 msgid "Right" msgstr "右" -#: PiFinder/ui/menu_structure.py:1016 +#: PiFinder/ui/menu_structure.py:1054 msgid "Straight" msgstr "直" -#: PiFinder/ui/menu_structure.py:1020 +#: PiFinder/ui/menu_structure.py:1058 msgid "Flat v3" msgstr "平板v3" -#: PiFinder/ui/menu_structure.py:1024 +#: PiFinder/ui/menu_structure.py:1062 msgid "Flat v2" msgstr "平板v2" -#: PiFinder/ui/menu_structure.py:1028 +#: PiFinder/ui/menu_structure.py:1066 msgid "AS Bloom" msgstr "AS Bloom" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1032 -msgid "AS Heart" -msgstr "AS Heart" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1036 -msgid "Rev4 Left" -msgstr "Rev4 左" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1040 -msgid "Rev4 Right" -msgstr "Rev4 右" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1044 -msgid "Rev4 Straight" -msgstr "Rev4 直" - -#: PiFinder/ui/menu_structure.py:1050 +#: PiFinder/ui/menu_structure.py:1072 msgid "Camera Type" msgstr "相机类型" -#: PiFinder/ui/menu_structure.py:1056 +#: PiFinder/ui/menu_structure.py:1078 msgid "v2 - imx477" msgstr "v2 - imx477" -#: PiFinder/ui/menu_structure.py:1061 +#: PiFinder/ui/menu_structure.py:1083 msgid "v3 - imx296" msgstr "v3 - imx296" -#: PiFinder/ui/menu_structure.py:1066 +#: PiFinder/ui/menu_structure.py:1088 msgid "v3 - imx462" msgstr "v3 - imx462" -#: PiFinder/ui/menu_structure.py:1077 -# AI-TRANSLATED (claude): needs human review -msgid "Lens" -msgstr "镜头" - -#: PiFinder/ui/menu_structure.py:1086 -msgid "12mm" -msgstr "12mm" - -#: PiFinder/ui/menu_structure.py:1090 -msgid "16mm" -msgstr "16mm" - -#: PiFinder/ui/menu_structure.py:1094 -msgid "25mm" -msgstr "25mm" - -#: PiFinder/ui/menu_structure.py:1100 views/gps.html:6 +#: PiFinder/ui/menu_structure.py:1095 views/gps.html:6 msgid "GPS Settings" msgstr "GPS设置" -#: PiFinder/ui/menu_structure.py:1078 +#: PiFinder/ui/menu_structure.py:1100 msgid "GPS Type" msgstr "GPS类型" -#: PiFinder/ui/menu_structure.py:1086 +#: PiFinder/ui/menu_structure.py:1108 msgid "UBlox" msgstr "UBlox" -#: PiFinder/ui/menu_structure.py:1090 +#: PiFinder/ui/menu_structure.py:1112 msgid "GPSD (generic)" msgstr "GPSD (通用)" -#: PiFinder/ui/menu_structure.py:1096 +#: PiFinder/ui/menu_structure.py:1118 msgid "GPS Baud Rate" msgstr "GPS波特率" -#: PiFinder/ui/menu_structure.py:1104 +#: PiFinder/ui/menu_structure.py:1126 msgid "9600 (standard)" msgstr "9600 (标准)" -#: PiFinder/ui/menu_structure.py:1108 +#: PiFinder/ui/menu_structure.py:1130 msgid "115200 (UBlox-10)" msgstr "115200 (UBlox-10)" -#: PiFinder/ui/menu_structure.py:1118 +#: PiFinder/ui/menu_structure.py:1140 msgid "IMU Sensit." msgstr "IMU灵敏度" -#: PiFinder/ui/menu_structure.py:1129 +#: PiFinder/ui/menu_structure.py:1151 msgid "Very Low" msgstr "很低" -#: PiFinder/ui/menu_structure.py:1155 +#: PiFinder/ui/menu_structure.py:1177 msgid "Status" msgstr "状态" -#: PiFinder/ui/menu_structure.py:1158 +#: PiFinder/ui/menu_structure.py:1180 msgid "Place & Time" msgstr "位置和时间" -#: PiFinder/ui/menu_structure.py:1167 +#: PiFinder/ui/menu_structure.py:1189 msgid "Set Location" msgstr "设置位置" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1176 views/locations.html:86 +#: PiFinder/ui/menu_structure.py:1198 views/locations.html:86 msgid "Load Location" msgstr "加载位置" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1180 views/location_form.html:76 +#: PiFinder/ui/menu_structure.py:1202 views/location_form.html:76 msgid "Save Location" msgstr "保存位置" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1186 +#: PiFinder/ui/menu_structure.py:1208 msgid "Set Time/Date" msgstr "设置时间/日期" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1193 +#: PiFinder/ui/menu_structure.py:1212 msgid "Reset Location" msgstr "重置位置" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1195 +#: PiFinder/ui/menu_structure.py:1214 msgid "Reset Time/Date" msgstr "重置时间/日期" -#: PiFinder/ui/menu_structure.py:1200 +#: PiFinder/ui/menu_structure.py:1219 msgid "Console" msgstr "控制台" -#: PiFinder/ui/menu_structure.py:1201 +#: PiFinder/ui/menu_structure.py:1220 msgid "Software Upd" msgstr "软件更新" -#: PiFinder/ui/menu_structure.py:1204 +#: PiFinder/ui/menu_structure.py:1223 msgid "Experimental" msgstr "实验功能" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1209 +#: PiFinder/ui/menu_structure.py:1228 msgid "Polar Align" msgstr "极轴校准" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1214 +#: PiFinder/ui/menu_structure.py:1233 msgid "Dev Tools" msgstr "开发工具" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1219 +#: PiFinder/ui/menu_structure.py:1238 msgid "Telemetry" msgstr "遥测" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1224 +#: PiFinder/ui/menu_structure.py:1243 msgid "Record" msgstr "录制" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/menu_structure.py:1241 +#: PiFinder/ui/menu_structure.py:1260 msgid "Images" msgstr "图像" -#: PiFinder/ui/menu_structure.py:1267 +#: PiFinder/ui/menu_structure.py:1286 msgid "Power" msgstr "电源" -#: PiFinder/ui/menu_structure.py:1273 +#: PiFinder/ui/menu_structure.py:1292 msgid "Shutdown" msgstr "关机" @@ -1662,150 +1584,155 @@ msgid "No Object Found" msgstr "未找到天体" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:238 PiFinder/ui/object_details.py:245 +#: PiFinder/ui/object_details.py:231 PiFinder/ui/object_details.py:238 msgid "Mag:{obj_mag}" msgstr "星等:{obj_mag}" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: object info magnitude -#: PiFinder/ui/object_details.py:241 +#: PiFinder/ui/object_details.py:234 msgid "Sz:{size}" msgstr "大小:{size}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:367 +#: PiFinder/ui/object_details.py:360 msgid "  Not Logged" msgstr "  未记录" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:369 +#: PiFinder/ui/object_details.py:362 msgid "  {logs} Logs" msgstr "  {logs} 条记录" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:415 PiFinder/ui/polar_align.py:444 +#: PiFinder/ui/object_details.py:408 PiFinder/ui/polar_align.py:444 msgid "No solve" msgstr "未解析" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:421 +#: PiFinder/ui/object_details.py:414 msgid "yet{elipsis}" msgstr "尚未{elipsis}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:435 +#: PiFinder/ui/object_details.py:428 msgid "Searching" msgstr "搜索中" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:441 +#: PiFinder/ui/object_details.py:434 msgid "for GPS{elipsis}" msgstr "等待GPS{elipsis}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:455 PiFinder/ui/object_details.py:483 +#: PiFinder/ui/object_details.py:448 PiFinder/ui/object_details.py:476 msgid "Calculating" msgstr "计算中" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:461 +#: PiFinder/ui/object_details.py:454 msgid "positions" msgstr "个位置" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:489 +#: PiFinder/ui/object_details.py:482 msgid "position" msgstr "位置" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:573 +#: PiFinder/ui/object_details.py:566 msgid "Contrast Reserve" msgstr "对比度余量" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:599 +#: PiFinder/ui/object_details.py:592 msgid "No contrast data" msgstr "无对比度数据" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:607 +#: PiFinder/ui/object_details.py:600 msgid "CR measures object" msgstr "CR衡量天体" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 1 -#: PiFinder/ui/object_details.py:610 +#: PiFinder/ui/object_details.py:603 msgid "visibility based on" msgstr "基于以下条件的" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 2 -#: PiFinder/ui/object_details.py:613 +#: PiFinder/ui/object_details.py:606 msgid "sky brightness," msgstr "天空亮度," # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: Contrast reserve explanation line 3 -#: PiFinder/ui/object_details.py:616 +#: PiFinder/ui/object_details.py:609 msgid "telescope, and EP." msgstr "望远镜和目镜。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:692 +#: PiFinder/ui/object_details.py:685 msgid "Too Far" msgstr "距离过远" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_details.py:717 +#: PiFinder/ui/object_details.py:710 msgid "LOG" msgstr "记录" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:165 +#: PiFinder/ui/object_list.py:134 msgid "Refresh" msgstr "刷新" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:171 +#: PiFinder/ui/object_list.py:140 msgid "Sort" msgstr "排序" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:175 PiFinder/ui/object_list.py:879 +#: PiFinder/ui/object_list.py:144 PiFinder/ui/object_list.py:819 msgid "Nearest" msgstr "最近" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:179 PiFinder/ui/object_list.py:885 +#: PiFinder/ui/object_list.py:148 PiFinder/ui/object_list.py:825 msgid "Standard" msgstr "标准" -#: PiFinder/ui/object_list.py:184 +#: PiFinder/ui/object_list.py:153 msgid "Filter" msgstr "筛选" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:289 PiFinder/ui/software.py:572 +#: PiFinder/ui/object_list.py:244 msgid "Downloading..." msgstr "下载中..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:296 +#: PiFinder/ui/object_list.py:251 msgid "No GPS lock" msgstr "无GPS锁定" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:303 +#: PiFinder/ui/object_list.py:258 msgid "Calculating..." msgstr "计算中..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:312 PiFinder/ui/software.py:750 +#: PiFinder/ui/object_list.py:264 views/locations.html:66 +msgid "Error" +msgstr "错误" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/object_list.py:267 msgid "Loading..." msgstr "加载中..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:319 +#: PiFinder/ui/object_list.py:274 msgid "" "Sorting by\n" "{sort_order}" @@ -1814,49 +1741,49 @@ msgstr "" "{sort_order}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:320 PiFinder/ui/object_list.py:890 +#: PiFinder/ui/object_list.py:275 PiFinder/ui/object_list.py:830 msgid "RA" msgstr "赤经" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:322 PiFinder/ui/object_list.py:639 +#: PiFinder/ui/object_list.py:277 PiFinder/ui/object_list.py:579 #: views/obs_session_log.html:21 msgid "Catalog" msgstr "星表" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:324 PiFinder/ui/object_list.py:641 +#: PiFinder/ui/object_list.py:279 PiFinder/ui/object_list.py:581 msgid "Nearby" msgstr "附近" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:596 +#: PiFinder/ui/object_list.py:543 msgid "No objects" msgstr "无天体" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:602 +#: PiFinder/ui/object_list.py:549 msgid "match filter" msgstr "符合筛选条件" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:625 +#: PiFinder/ui/object_list.py:565 msgid "{catalog_info_1} obj" msgstr "{catalog_info_1} 个天体" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: number of objects in object list -#: PiFinder/ui/object_list.py:628 +#: PiFinder/ui/object_list.py:568 msgid ", {catalog_info_2}d old" msgstr ", {catalog_info_2}天前" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:638 +#: PiFinder/ui/object_list.py:578 msgid "Sort: {sort_order}" msgstr "排序: {sort_order}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/object_list.py:932 +#: PiFinder/ui/object_list.py:872 msgid "Refreshing..." msgstr "刷新中..." @@ -1889,12 +1816,12 @@ msgid "STATS" msgstr "统计" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:228 PiFinder/ui/polar_align.py:549 +#: PiFinder/ui/polar_align.py:228 PiFinder/ui/polar_align.py:540 msgid "Need GPS lock" msgstr "需要GPS定位" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:234 PiFinder/ui/polar_align.py:551 +#: PiFinder/ui/polar_align.py:234 PiFinder/ui/polar_align.py:542 msgid "Rotate more" msgstr "再旋转一些" @@ -2024,8 +1951,8 @@ msgstr "解析。" # AI-TRANSLATED (claude): needs human review #. TRANSLATORS: hint bar; {icon} is the MINUS button glyph #. TRANSLATORS: hint bar; {icon} is the SQUARE button glyph -#: PiFinder/ui/polar_align.py:398 PiFinder/ui/polar_align.py:470 -#: PiFinder/ui/polar_align.py:500 +#: PiFinder/ui/polar_align.py:398 PiFinder/ui/polar_align.py:461 +#: PiFinder/ui/polar_align.py:491 msgid "{icon} BACK" msgstr "{icon} 返回" @@ -2045,7 +1972,7 @@ msgid "bad" msgstr "不良" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:414 PiFinder/ui/polar_align.py:481 +#: PiFinder/ui/polar_align.py:414 PiFinder/ui/polar_align.py:472 msgid "pt" msgstr "点" @@ -2057,77 +1984,93 @@ msgid "{square} REDO {minus} CANCEL" msgstr "{square} 重做 {minus} 取消" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:468 +#: PiFinder/ui/polar_align.py:459 msgid "No result yet" msgstr "暂无结果" -#: PiFinder/ui/polar_align.py:477 +#: PiFinder/ui/polar_align.py:468 msgid "RA/Dec" msgstr "RA/Dec" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:477 +#: PiFinder/ui/polar_align.py:468 msgid "3-axis" msgstr "三轴" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:484 +#: PiFinder/ui/polar_align.py:475 msgid "Fit" msgstr "拟合" -#: PiFinder/ui/polar_align.py:485 +#: PiFinder/ui/polar_align.py:476 msgid "Alt" msgstr "Alt" -#: PiFinder/ui/polar_align.py:486 +#: PiFinder/ui/polar_align.py:477 msgid "Az" msgstr "Az" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:487 +#: PiFinder/ui/polar_align.py:478 msgid "Axis" msgstr "轴" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:497 +#: PiFinder/ui/polar_align.py:488 msgid "time" msgstr "时间" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:497 +#: PiFinder/ui/polar_align.py:488 msgid "sec" msgstr "秒" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:547 +#: PiFinder/ui/polar_align.py:538 msgid "Need 2 points" msgstr "需要2个点" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:561 PiFinder/ui/polar_align.py:568 +#: PiFinder/ui/polar_align.py:552 PiFinder/ui/polar_align.py:559 msgid "Roll Off" msgstr "忽略滚转" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:561 PiFinder/ui/polar_align.py:568 +#: PiFinder/ui/polar_align.py:552 PiFinder/ui/polar_align.py:559 msgid "Roll On" msgstr "用滚转" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:576 +#: PiFinder/ui/polar_align.py:567 msgid "No points" msgstr "无点" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/polar_align.py:582 +#: PiFinder/ui/polar_align.py:573 msgid "Dropped point" msgstr "已删除点" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/preview.py:93 +#: PiFinder/ui/preview.py:79 msgid "Exposure" msgstr "曝光" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:304 +msgid "keep going" +msgstr "继续" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:354 +#, fuzzy +msgid "det {n}" +msgstr "检测 {n}" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/preview.py:438 +msgid "Zoom x{zoom_number}" +msgstr "缩放 x{zoom_number}" + # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/radec_entry.py:516 msgid "Full" @@ -2169,465 +2112,428 @@ msgid "RA/DEC" msgstr "RA/Dec" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:213 -msgid "No release found" -msgstr "未找到版本" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:219 PiFinder/ui/software.py:632 -msgid "System Upgrade" -msgstr "系统升级" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:259 +#: PiFinder/ui/software.py:87 msgid "Updating..." msgstr "更新中..." # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:261 +#: PiFinder/ui/software.py:89 msgid "Ok! Restarting" msgstr "完成!重启中" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:264 +#: PiFinder/ui/software.py:92 msgid "Error on Upd" msgstr "更新出错" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:271 +#: PiFinder/ui/software.py:99 msgid "Wifi Mode: {mode}" msgstr "WiFi模式: {mode}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:279 +#: PiFinder/ui/software.py:107 msgid "Current Version" msgstr "当前版本" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:295 +#: PiFinder/ui/software.py:123 msgid "Release Version" msgstr "发布版本" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:317 +#: PiFinder/ui/software.py:145 msgid "WiFi must be" msgstr "WiFi须处于" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:323 +#: PiFinder/ui/software.py:151 msgid "client mode" msgstr "客户端模式" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:336 +#: PiFinder/ui/software.py:164 msgid "Checking for" msgstr "正在检查" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:342 +#: PiFinder/ui/software.py:170 msgid "updates{elipsis}" msgstr "更新{elipsis}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:358 +#: PiFinder/ui/software.py:186 msgid "No Update" msgstr "无更新" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:364 +#: PiFinder/ui/software.py:192 msgid "needed" msgstr "可用" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:374 +#: PiFinder/ui/software.py:202 msgid "Update Now" msgstr "立即更新" -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:444 -msgid "Major Upgrade" -msgstr "重大升级" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:464 -msgid "IRREVERSIBLE" -msgstr "不可逆" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:473 -msgid "Download: {size}MB" -msgstr "下载:{size}MB" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:481 -msgid "Power + WiFi req" -msgstr "需电源+WiFi" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:492 -msgid "No checksum avail." -msgstr "无校验和" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:552 -msgid "Starting..." -msgstr "正在启动..." - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:594 PiFinder/ui/software.py:599 -msgid "Not supported" -msgstr "不支持" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:604 -msgid "Failed: " -msgstr "失败:" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:735 -msgid "Could not load" -msgstr "无法加载" - -# AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/software.py:741 -msgid "release notes" -msgstr "发行说明" - #: PiFinder/ui/sqm.py:25 msgid "SQM" msgstr "SQM" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:41 -msgid "CALIB" +#: PiFinder/ui/sqm.py:42 +msgid "CAL" msgstr "校准" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:47 -msgid "SWEEP" -msgstr "扫描" +#: PiFinder/ui/sqm.py:48 +msgid "CORRECT" +msgstr "修正" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:100 +#: PiFinder/ui/sqm.py:101 msgid "NO SQM DATA" msgstr "无SQM数据" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:128 PiFinder/ui/sqm.py:215 +#: PiFinder/ui/sqm.py:129 PiFinder/ui/sqm.py:216 msgid "mag/arcsec²" msgstr "mag/arcsec²" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:147 PiFinder/ui/sqm.py:250 +#: PiFinder/ui/sqm.py:148 PiFinder/ui/sqm.py:252 msgid "Bortle {bc}" msgstr "Bortle {bc}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:156 +#: PiFinder/ui/sqm.py:157 msgid "BACK" msgstr "返回" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:157 +#: PiFinder/ui/sqm.py:158 msgid "SCROLL" msgstr "滚动" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:170 +#: PiFinder/ui/sqm.py:171 msgid "{s}s ago" msgstr "{s}秒前" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:172 +#: PiFinder/ui/sqm.py:173 msgid "{m}m ago" msgstr "{m}分钟前" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:256 +#: PiFinder/ui/sqm.py:258 msgid "DETAILS" msgstr "详情" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:311 +#: PiFinder/ui/sqm.py:316 msgid "SQM Calibration" msgstr "SQM校准" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:323 +#: PiFinder/ui/sqm.py:328 msgid "SQM Sweep" msgstr "SQM扫描" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:345 +#: PiFinder/ui/sqm.py:350 msgid "Excellent Dark-Sky Site" msgstr "极佳暗天空地点" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:349 +#: PiFinder/ui/sqm.py:354 msgid "The zodiacal light is visible and colorful. Gegenschein readily visible." msgstr "黄道光清晰可见且色彩丰富。对日照轻易可见。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:352 +#: PiFinder/ui/sqm.py:357 msgid "" "The Scorpius and Sagittarius regions of the Milky Way cast obvious " "shadows." msgstr "银河天蝎座和人马座区域投下明显的阴影。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:355 +#: PiFinder/ui/sqm.py:360 msgid "M33 is a direct naked-eye object. Airglow readily visible." msgstr "M33可直接裸眼观测。气辉清晰可见。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:356 +#: PiFinder/ui/sqm.py:361 msgid "Abundant stars make faint constellations hard to distinguish." msgstr "繁星密布,暗淡星座难以辨认。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:361 +#: PiFinder/ui/sqm.py:366 msgid "Typical Truly Dark Site" msgstr "典型真正暗天空地点" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:365 +#: PiFinder/ui/sqm.py:370 msgid "" "The zodiacal light is distinctly yellowish and bright enough to cast " "shadows at dusk and dawn." msgstr "黄道光呈明显黄色,晨昏时亮度足以投影。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:368 +#: PiFinder/ui/sqm.py:373 msgid "Clouds appear as dark silhouettes against the sky." msgstr "云层在夜空中呈现为暗色轮廓。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:369 +#: PiFinder/ui/sqm.py:374 msgid "The summer Milky Way is highly structured. M33 easily visible." msgstr "夏季银河结构丰富。M33轻易可见。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:374 +#: PiFinder/ui/sqm.py:379 msgid "Rural Sky" msgstr "乡村天空" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:378 +#: PiFinder/ui/sqm.py:383 msgid "The zodiacal light is striking in spring and autumn, color still visible." msgstr "春秋季黄道光醒目,仍可见色彩。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:381 +#: PiFinder/ui/sqm.py:386 msgid "" "Some light pollution at horizon. Clouds illuminated near horizon, dark " "overhead." msgstr "地平线有少量光污染。近地平线云层被照亮,头顶较暗。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:384 +#: PiFinder/ui/sqm.py:389 msgid "The summer Milky Way still appears complex." msgstr "夏季银河仍呈现丰富细节。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:385 +#: PiFinder/ui/sqm.py:390 msgid "Several Messier objects remain naked-eye visible." msgstr "多个梅西耶天体仍可裸眼观测。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:390 +#: PiFinder/ui/sqm.py:395 msgid "Brighter Rural" msgstr "较亮乡村天空" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:394 +#: PiFinder/ui/sqm.py:399 msgid "Zodiacal light still visible but doesn't extend halfway to zenith." msgstr "黄道光仍可见但未延伸至天顶一半。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:397 +#: PiFinder/ui/sqm.py:402 msgid "Light pollution domes apparent in multiple directions." msgstr "多个方向可见光污染穹顶。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:398 +#: PiFinder/ui/sqm.py:403 msgid "" "The Milky Way well above the horizon is still impressive, but lacks " "detail." msgstr "地平线以上银河仍壮观,但缺乏细节。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:401 +#: PiFinder/ui/sqm.py:406 msgid "M33 difficult to see." msgstr "M33难以观测。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:406 +#: PiFinder/ui/sqm.py:411 msgid "Semi-Suburban/Transition Sky" msgstr "近郊/过渡天空" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:410 +#: PiFinder/ui/sqm.py:415 msgid "Clouds have a grayish glow at zenith and appear bright toward city domes." msgstr "天顶云层呈灰色发光,朝向城市方向云层明亮。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:413 +#: PiFinder/ui/sqm.py:418 msgid "Milky Way only vaguely visible 10-15° above horizon." msgstr "银河仅在地平线10-15°处隐约可见。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:414 +#: PiFinder/ui/sqm.py:419 msgid "Great Rift observable overhead." msgstr "大裂谷可在头顶观测到。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:419 +#: PiFinder/ui/sqm.py:424 msgid "Suburban Sky" msgstr "郊区天空" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:423 +#: PiFinder/ui/sqm.py:428 msgid "Only hints of zodiacal light seen on best nights in autumn and spring." msgstr "仅在秋春最佳夜晚能隐约见到黄道光。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:426 +#: PiFinder/ui/sqm.py:431 msgid "Light pollution visible in most, if not all, directions." msgstr "几乎所有方向均可见光污染。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:427 +#: PiFinder/ui/sqm.py:432 msgid "Clouds noticeably brighter than the sky." msgstr "云层明显比天空更亮。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:428 +#: PiFinder/ui/sqm.py:433 msgid "Milky Way invisible near horizon, looks washed out overhead." msgstr "银河在地平线附近不可见,头顶处也显得黯淡。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:433 +#: PiFinder/ui/sqm.py:438 msgid "Bright Suburban Sky" msgstr "明亮郊区天空" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:437 +#: PiFinder/ui/sqm.py:442 msgid "The zodiacal light is invisible." msgstr "黄道光不可见。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:438 +#: PiFinder/ui/sqm.py:443 msgid "Light pollution makes sky within 35° of horizon glow grayish white." msgstr "光污染使地平线35°内的天空呈灰白色发光。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:441 +#: PiFinder/ui/sqm.py:446 msgid "The Milky Way is only visible near the zenith. M33 undetectable." msgstr "银河仅在天顶附近可见。M33无法探测。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:444 +#: PiFinder/ui/sqm.py:449 msgid "M31 modestly apparent. Surroundings easily visible." msgstr "M31隐约可见。周围环境清晰可辨。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:449 +#: PiFinder/ui/sqm.py:454 msgid "Suburban/Urban Transition" msgstr "郊区/城区过渡" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:453 +#: PiFinder/ui/sqm.py:458 msgid "Light pollution makes the entire sky light gray." msgstr "光污染使整个天空呈浅灰色。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:454 +#: PiFinder/ui/sqm.py:459 msgid "Strong light sources evident in all directions." msgstr "各方向均有明显强光源。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:455 +#: PiFinder/ui/sqm.py:460 msgid "The Milky Way is nearly or totally invisible." msgstr "银河几乎或完全不可见。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:456 +#: PiFinder/ui/sqm.py:461 msgid "M31 and M44 may be glimpsed, but with no detail." msgstr "M31和M44或许可瞥见,但无细节。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:461 +#: PiFinder/ui/sqm.py:466 msgid "City Sky" msgstr "城市天空" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:465 +#: PiFinder/ui/sqm.py:470 msgid "The sky is light gray or orange—one can easily read." msgstr "天空呈浅灰或橙色,足以轻松阅读。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:466 +#: PiFinder/ui/sqm.py:471 msgid "Stars forming recognizable patterns may vanish entirely." msgstr "组成可辨星座的恒星可能完全消失。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:467 +#: PiFinder/ui/sqm.py:472 msgid "Only bright Messier objects can be detected with telescopes." msgstr "仅能用望远镜探测到亮梅西耶天体。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:472 +#: PiFinder/ui/sqm.py:477 msgid "Inner-City Sky" msgstr "市区天空" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:476 +#: PiFinder/ui/sqm.py:481 msgid "The sky is brilliantly lit." msgstr "天空灯火通明。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:477 +#: PiFinder/ui/sqm.py:482 msgid "Many stars forming constellations invisible." msgstr "组成星座的众多恒星不可见。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/sqm.py:478 +#: PiFinder/ui/sqm.py:483 msgid "" "Only the Moon, planets, bright satellites, and a few of the brightest " "star clusters observable." msgstr "仅能观测月球、行星、亮卫星及少数最亮星团。" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:69 -msgid "Stop replay" -msgstr "停止回放" +#: PiFinder/ui/sqm_correction.py:45 PiFinder/ui/sqm_correction.py:87 +msgid "SQM Correction" +msgstr "SQM修正" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:93 -msgid "" -"No integrator\n" -"queue" -msgstr "" -"无积分器\n" -"队列" +#: PiFinder/ui/sqm_correction.py:72 +msgid "Del" +msgstr "删除" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:99 -msgid "" -"Replay\n" -"stopped" -msgstr "" -"回放\n" -"已停止" +#: PiFinder/ui/sqm_correction.py:106 +msgid "Original: {sqm:.2f}" +msgstr "原始值: {sqm:.2f}" # AI-TRANSLATED (claude): needs human review -#: PiFinder/ui/telemetry_list.py:105 -msgid "" -"Replay\n" -"started" -msgstr "" -"回放\n" -"已开始" +#: PiFinder/ui/sqm_correction.py:115 +msgid "Corrected:" +msgstr "修正后:" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:211 +msgid "Enter a value" +msgstr "输入数值" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:213 +msgid "Range: 10-23" +msgstr "范围: 10-23" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:218 +msgid "Saving..." +msgstr "保存中..." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:225 +msgid "Saved: {filename}" +msgstr "已保存: {filename}" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:232 +msgid "Save failed" +msgstr "保存失败" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/sqm_correction.py:340 +msgid "Saving {label}..." +msgstr "保存{label}..." + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/telemetry_list.py:69 +msgid "Stop replay" +msgstr "停止回放" # AI-TRANSLATED (claude): needs human review #: PiFinder/ui/text_menu.py:61 @@ -2669,6 +2575,40 @@ msgstr "hh" msgid "ss" msgstr "ss" +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/timeentry.py:116 +msgid "Enter Local Time" +msgstr "输入本地时间" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/server.py:527 +msgid "" +"Network settings updated — no restart needed. This device is now " +"reachable at http://{host}.local. If you changed the host name, the " +"previous address stops working, so reconnect there." +msgstr "" +"网络设置已更新——无需重启。现在可通过 http://{host}.local 访问本设备。如果您更改了主机名,之前的地址将失效,请在新地址重新连接。" + +# AI-TRANSLATED (claude): needs human review +#: views/network.html:39 +msgid "Update" +msgstr "更新" + +# AI-TRANSLATED (claude): needs human review +#: PiFinder/ui/menu_structure.py:626 +msgid "Volume" +msgstr "音量" + +# AI-TRANSLATED (claude): needs human review +msgid "Low battery\nat {pct}%" +msgstr "电量不足\n剩余 {pct}%" + +# AI-TRANSLATED (claude): needs human review +msgid "Low battery\nShutting down" +msgstr "电量不足\n正在关机" + +#~ msgid "Integrator" +#~ msgstr "" # AI-TRANSLATED (claude): needs human review #: views/advanced.html:7 msgid "GPS location lock" @@ -3009,6 +2949,16 @@ msgstr "位置管理" msgid "Add New Location" msgstr "添加新位置" +# AI-TRANSLATED (claude): needs human review +#: views/locations.html:63 +msgid "Latitude" +msgstr "纬度" + +# AI-TRANSLATED (claude): needs human review +#: views/locations.html:64 +msgid "Longitude" +msgstr "经度" + # AI-TRANSLATED (claude): needs human review #: views/locations.html:89 msgid "Set as Default" @@ -3045,37 +2995,37 @@ msgid "This action cannot be undone." msgstr "此操作无法撤销。" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:435 views/locations.html:523 views/locations.html:580 +#: views/locations.html:428 views/locations.html:516 views/locations.html:565 msgid "This field is required" msgstr "此项为必填" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:437 +#: views/locations.html:430 msgid "Must be a valid number" msgstr "必须是有效数字" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:441 +#: views/locations.html:434 msgid "Must be between -90 and 90" msgstr "必须在-90到90之间" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:444 +#: views/locations.html:437 msgid "Must be between -180 and 180" msgstr "必须在-180到180之间" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:447 +#: views/locations.html:440 msgid "Must be between -1000 and 10000 meters" msgstr "必须在-1000到10000米之间" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:450 +#: views/locations.html:443 msgid "Must be between 0 and 10000 meters" msgstr "必须在0到10000米之间" # AI-TRANSLATED (claude): needs human review -#: views/locations.html:548 +#: views/locations.html:533 msgid "Please fix the validation errors before saving" msgstr "保存前请修正验证错误" @@ -3419,9 +3369,6 @@ msgid "" "overwrite any existing preference and observations. Are you sure?" msgstr "这将使用所提供的文件恢复你的用户数据,并覆盖现有的偏好设置和观测记录。确定继续吗?" -#~ msgid "Integrator" -#~ msgstr "" - #~ msgid "AE Algo" #~ msgstr "AE算法" @@ -3464,82 +3411,3 @@ msgstr "这将使用所提供的文件恢复你的用户数据,并覆盖现有 #~ msgid "T9 Search" #~ msgstr "T9搜索" -#~ msgid "Cluster/Neb" -#~ msgstr "星团/星云" - -#~ msgid "P. Nebula" -#~ msgstr "行星状星云" - -#~ msgid "Double Str" -#~ msgstr "双星" - -#~ msgid "Triple Str" -#~ msgstr "三合星" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Unknown" -#~ msgstr "未知" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "keep going" -#~ msgstr "继续" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "det {n}" -#~ msgstr "检测 {n}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Zoom x{zoom_number}" -#~ msgstr "缩放 x{zoom_number}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "CORRECT" -#~ msgstr "修正" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "SQM Correction" -#~ msgstr "SQM修正" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Del" -#~ msgstr "删除" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Original: {sqm:.2f}" -#~ msgstr "原始值: {sqm:.2f}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Corrected:" -#~ msgstr "修正后:" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Enter a value" -#~ msgstr "输入数值" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Range: 10-23" -#~ msgstr "范围: 10-23" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saving..." -#~ msgstr "保存中..." - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saved: {filename}" -#~ msgstr "已保存: {filename}" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Save failed" -#~ msgstr "保存失败" - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Saving {label}..." -#~ msgstr "保存{label}..." - -# AI-TRANSLATED (claude): needs human review -#~ msgid "Enter Local Time" -#~ msgstr "输入本地时间" - -#~ msgid "Download: {}MB" -#~ msgstr "" - 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..5f27170ef 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,4 +1,144 @@ +[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", + # HEALPix tile queries for the Gaia deep-chart catalog (star_catalog.py) + "healpy", + "pillow", + "pandas", + "skyfield", + "timezonefinder", + "pytz", + # Plate solver (the `tetra3` import package). Pinned to a cedar-solve rev via + # [tool.uv.sources]; its stale numpy<2/Pillow<9 caps are relaxed in + # [tool.uv.dependency-metadata] so it resolves against the env's numpy 2.x. + "cedar-solve", + # Web / RPC + "grpcio", + "protobuf", + "flask", + "flask-babel", + "waitress", + "requests", + "pyjwt", + "aiofiles", + "json5", + "jsonschema", + "libarchive-c", + "tqdm", + # System / IPC bindings + "pygobject; platform_machine == 'aarch64'", # libnm bindings — device-only (dev uses sys_utils_fake) + "dbus-python; platform_machine == 'aarch64'", + "av", + "smbus2", + "spidev; platform_machine == 'aarch64'", + # sh 2.x changed the API; PiFinder targets the 1.x interface. + "sh>=1.14,<2", + "gpsdclient", + "dataclasses-json", + "pydeepskylog", + "python-pam; sys_platform == 'linux'", + # 0.3.0a0 (prerelease) is the version that builds on 3.13; its setup.py + # and .so lookups are patched in the uv2nix override (see uv-python.nix). + "python-libinput==0.3.0a0; platform_machine == 'aarch64'", + # Display + "luma-oled", + "luma-lcd", + # Hardware / camera + "rpi-gpio; platform_machine == 'aarch64'", + "rpi-hardware-pwm; platform_machine == 'aarch64'", + "adafruit-blinka; sys_platform == 'linux'", + "adafruit-circuitpython-bno055; sys_platform == 'linux'", + "adafruit-extended-bus; sys_platform == 'linux'", + "picamera2; platform_machine == 'aarch64'", + "pidng; sys_platform == 'linux'", + "simplejpeg", + "python-prctl; platform_machine == 'aarch64'", + "videodev2; sys_platform == 'linux'", +] + +[dependency-groups] +dev = [ + "pytest", + "mypy", + "luma-emulator", + # pyhotkey -> pynput -> evdev (sdist, kernel headers): only buildable where + # the uv2nix override supplies headers (the device). Dev keyboard input on + # x86 uses the pygame path instead (docs/adr/0004-pygame-keyboard...). + "pyhotkey; platform_machine == 'aarch64'", + "selenium", + # pandas reads the Steinicke .xls catalog through xlrd (test_steinicke_parsing). + "xlrd>=2.0.1", + # mypy type stubs (main tracks these in requirements_dev.txt; here they + # ride the uv dev group) + "types-pytz", + # pynput drags evdev (sdist, kernel headers) — keep it off x86 dev boxes + "pynput; platform_machine == 'aarch64'", + "types-pynput", + "types-requests", + "types-aiofiles", + "types-tqdm", + "pandas-stubs", + "types-waitress", +] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +# Dependency-only (virtual) workspace — nothing here is installed into the env +# (the PiFinder source is deployed separately via pkgs/pifinder-src.nix). Declare +# an empty package set so uv2nix's build of the root produces an empty wheel +# instead of tripping setuptools flat-layout discovery on views/locale/PiFinder. +[tool.setuptools] +packages = [] + +[tool.uv] +# Dependency-only workspace: the PiFinder source itself is deployed separately +# (pkgs/pifinder-src.nix), so uv must not try to build/install this root project. +package = false +# Deployment + dev are both Linux (aarch64 Pi, x86_64 dev); keep the lock focused. +environments = ["sys_platform == 'linux'", "sys_platform == 'darwin'"] + +# Plate solver source: pinned cedar-solve rev (smroid upstream for now; point this +# at our fork here when we need to carry patches). +[tool.uv.sources] +cedar-solve = { git = "https://github.com/smroid/cedar-solve", rev = "d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" } + +# python-libinput's setup.py imports the removed `imp` module, so uv cannot +# execute it to discover metadata. Declare it statically; the actual build is +# patched in the uv2nix override. +[[tool.uv.dependency-metadata]] +name = "python-libinput" +version = "0.3.0a0" +requires-dist = ["cffi"] + +# python-prctl's setup.py aborts without libcap headers present; it has no +# runtime Python deps. libcap is supplied by the uv2nix build override. +[[tool.uv.dependency-metadata]] +name = "python-prctl" +version = "1.8.1" +requires-dist = [] + +# cedar-solve's published metadata caps numpy<2 / Pillow<9, but those bounds are +# stale: it runs against the env's numpy 2.x / Pillow 12 (as it did when vendored +# as a git submodule). Override so uv resolves it without dragging the env back. +[[tool.uv.dependency-metadata]] +name = "cedar-solve" +version = "0.5.1" +requires-dist = ["numpy", "pillow", "scipy"] + [tool.ruff] +builtins = ["_"] + # Exclude a variety of commonly ignored directories. exclude = [ ".bzr", @@ -27,18 +167,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 +196,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 +227,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 +260,54 @@ extra_checks = true [[tool.mypy.overrides]] module = [ 'board', - 'adafruit_bno055', 'adafruit_bus_device.*', + 'adafruit_bno055', + 'adafruit_extended_bus', 'scipy.*', 'luma.*', 'skyfield.*', 'sh.*', 'sklearn.*', - 'pam.*', 'PyHotKey.*', - 'PiFinder.tetra3.*', - 'quaternion', - 'tetra3.*', 'grpc', 'ceder_detect_pb2', 'RPi.*', 'picamera2', 'bottle', 'libinput', + 'pytz', + 'aiofiles', + 'requests', + 'tqdm', + 'pandas', + 'rpi_hardware_pwm', + 'gpsdclient', + 'timezonefinder', + 'pydeepskylog.*', + 'dbus', + 'pam', + 'pam.*', + 'quaternion', + 'gi', + 'gi.*', + 'pynput.*', + 'waitress', + 'google.*', + 'cedar_detect_pb2', + 'cedar_detect_pb2_grpc', ] ignore_missing_imports = true ignore_errors = true +# tetra3 / cedar-solve is an external (untyped) dependency. Treat it as opaque: +# skip following its imports so mypy never analyses it or its transitive deps +# (protobuf, grpc stubs, …). Without this, `mypy --install-types` tries to +# pip-install those stubs, which fails in the pip-less Nix dev env. +[[tool.mypy.overrides]] +module = ['tetra3', 'tetra3.*'] +follow_imports = "skip" +ignore_missing_imports = true + [tool.pytest.ini_options] pythonpath = ["."] testpaths = [ diff --git a/python/requirements.txt b/python/requirements.txt deleted file mode 100644 index 796057bdf..000000000 --- a/python/requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -adafruit-blinka==8.12.0 -adafruit-circuitpython-bno055 -cheroot==10.0.0 -Flask==3.0.3 -flask-babel==4.0.0 -waitress==3.0.1 -dataclasses_json==0.6.7 -gpsdclient==1.3.2 -grpcio==1.64.1 -json5==0.9.25 -luma.oled==3.12.0 -luma.lcd==2.11.0 -numpy==1.26.4 -numpy-quaternion==2023.0.4 -pam==0.2.0 -pandas==2.0.3 -pillow==10.4.0 -pydeepskylog==1.6 -pyerfa==2.0.1.5 -pyjwt==2.8.0 -python-libinput==0.3.0a0 -pytz==2022.7.1 -requests==2.28.2 -rpi-hardware-pwm==0.1.4 -scipy -scikit-learn==1.2.2 -sh==1.14.3 -skyfield==1.45 -timezonefinder==6.1.9 -tqdm==4.65.0 -protobuf==4.25.2 -aiofiles==24.1.0 diff --git a/python/requirements_dev.txt b/python/requirements_dev.txt deleted file mode 100644 index 7e864ae70..000000000 --- a/python/requirements_dev.txt +++ /dev/null @@ -1,24 +0,0 @@ -# dev requirements -luma.emulator==1.5.0 -PyHotKey==1.5.2 -ruff==0.4.8 -nox==2024.4.15 -mypy==1.10.0 -pytest==8.2.2 -pygame==2.6.1 -pre-commit==3.7.1 -Babel==2.16.0 -xlrd==2.0.2 -selenium==4.15.0 -types-pytz==2022.7.1 -pynput -types-pynput -types-requests==2.28.2 -types-aiofiles -types-tqdm==4.65.0 -pandas-stubs -types-waitress -# Test-only: drift guard for the .pifinder JSON Schema (test_pifinder_schema.py) -jsonschema==4.23.0 -# Pin to avoid pyobjc 12.0 which has macOS 15 build issues -pyobjc-framework-Quartz==11.1; sys_platform == "darwin" diff --git a/python/scripts/migration_progress b/python/scripts/migration_progress deleted file mode 100755 index 4d87f1758..000000000 Binary files a/python/scripts/migration_progress and /dev/null differ diff --git a/python/scripts/migration_progress.c b/python/scripts/migration_progress.c deleted file mode 100644 index 3b0a37319..000000000 --- a/python/scripts/migration_progress.c +++ /dev/null @@ -1,579 +0,0 @@ -/* - * migration_progress - OLED progress display for PiFinder initramfs - * - * Drives supported SPI OLED displays to show migration progress. - * Designed to be statically compiled and included in the initramfs. - * - * Usage: migration_progress - * percent: 0-100 - * message: status text (max ~20 chars fits on screen) - * - * Examples: - * migration_progress 0 1 22 "Starting..." - * migration_progress 45 10 22 "Moving data" - * migration_progress 100 22 22 "Complete!" - * - * Hardware: SPI0.0, DC=GPIO24, RST=GPIO25, BGR565 - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define MAX_WIDTH 176 -#define MAX_HEIGHT 176 -#define SPI_DEVICE "/dev/spidev0.0" -#define SPI_SPEED 40000000 -#define GPIO_DC 24 -#define GPIO_RST 25 - -/* BGR565 colors */ -#define COL_BLACK 0x0000 -#define COL_WHITE 0xFFFF -#define COL_RED 0x001F /* BGR565: blue=0, green=0, red=31 */ -#define COL_GREEN 0x07E0 -#define COL_DKGRAY 0x4208 -#define COL_DKRED 0x0010 - -static int spi_fd = -1; -static int gpio_fd = -1; -static struct gpio_v2_line_request dc_req; -static struct gpio_v2_line_request rst_req; -static uint16_t framebuf[MAX_WIDTH * MAX_HEIGHT]; - -enum oled_controller { - CTRL_SSD1351, - CTRL_SSD1333, -}; - -static enum oled_controller controller = CTRL_SSD1351; -static int display_width = 128; -static int display_height = 128; - -/* 5x7 bitmap font - ASCII 32-126 */ -static const uint8_t font5x7[][5] = { - {0x00,0x00,0x00,0x00,0x00}, /* space */ - {0x00,0x00,0x5F,0x00,0x00}, /* ! */ - {0x00,0x07,0x00,0x07,0x00}, /* " */ - {0x14,0x7F,0x14,0x7F,0x14}, /* # */ - {0x24,0x2A,0x7F,0x2A,0x12}, /* $ */ - {0x23,0x13,0x08,0x64,0x62}, /* % */ - {0x36,0x49,0x55,0x22,0x50}, /* & */ - {0x00,0x05,0x03,0x00,0x00}, /* ' */ - {0x00,0x1C,0x22,0x41,0x00}, /* ( */ - {0x00,0x41,0x22,0x1C,0x00}, /* ) */ - {0x08,0x2A,0x1C,0x2A,0x08}, /* * */ - {0x08,0x08,0x3E,0x08,0x08}, /* + */ - {0x00,0x50,0x30,0x00,0x00}, /* , */ - {0x08,0x08,0x08,0x08,0x08}, /* - */ - {0x00,0x60,0x60,0x00,0x00}, /* . */ - {0x20,0x10,0x08,0x04,0x02}, /* / */ - {0x3E,0x51,0x49,0x45,0x3E}, /* 0 */ - {0x00,0x42,0x7F,0x40,0x00}, /* 1 */ - {0x42,0x61,0x51,0x49,0x46}, /* 2 */ - {0x21,0x41,0x45,0x4B,0x31}, /* 3 */ - {0x18,0x14,0x12,0x7F,0x10}, /* 4 */ - {0x27,0x45,0x45,0x45,0x39}, /* 5 */ - {0x3C,0x4A,0x49,0x49,0x30}, /* 6 */ - {0x01,0x71,0x09,0x05,0x03}, /* 7 */ - {0x36,0x49,0x49,0x49,0x36}, /* 8 */ - {0x06,0x49,0x49,0x29,0x1E}, /* 9 */ - {0x00,0x36,0x36,0x00,0x00}, /* : */ - {0x00,0x56,0x36,0x00,0x00}, /* ; */ - {0x00,0x08,0x14,0x22,0x41}, /* < */ - {0x14,0x14,0x14,0x14,0x14}, /* = */ - {0x41,0x22,0x14,0x08,0x00}, /* > */ - {0x02,0x01,0x51,0x09,0x06}, /* ? */ - {0x32,0x49,0x79,0x41,0x3E}, /* @ */ - {0x7E,0x11,0x11,0x11,0x7E}, /* A */ - {0x7F,0x49,0x49,0x49,0x36}, /* B */ - {0x3E,0x41,0x41,0x41,0x22}, /* C */ - {0x7F,0x41,0x41,0x22,0x1C}, /* D */ - {0x7F,0x49,0x49,0x49,0x41}, /* E */ - {0x7F,0x09,0x09,0x01,0x01}, /* F */ - {0x3E,0x41,0x41,0x51,0x32}, /* G */ - {0x7F,0x08,0x08,0x08,0x7F}, /* H */ - {0x00,0x41,0x7F,0x41,0x00}, /* I */ - {0x20,0x40,0x41,0x3F,0x01}, /* J */ - {0x7F,0x08,0x14,0x22,0x41}, /* K */ - {0x7F,0x40,0x40,0x40,0x40}, /* L */ - {0x7F,0x02,0x04,0x02,0x7F}, /* M */ - {0x7F,0x04,0x08,0x10,0x7F}, /* N */ - {0x3E,0x41,0x41,0x41,0x3E}, /* O */ - {0x7F,0x09,0x09,0x09,0x06}, /* P */ - {0x3E,0x41,0x51,0x21,0x5E}, /* Q */ - {0x7F,0x09,0x19,0x29,0x46}, /* R */ - {0x46,0x49,0x49,0x49,0x31}, /* S */ - {0x01,0x01,0x7F,0x01,0x01}, /* T */ - {0x3F,0x40,0x40,0x40,0x3F}, /* U */ - {0x1F,0x20,0x40,0x20,0x1F}, /* V */ - {0x7F,0x20,0x18,0x20,0x7F}, /* W */ - {0x63,0x14,0x08,0x14,0x63}, /* X */ - {0x03,0x04,0x78,0x04,0x03}, /* Y */ - {0x61,0x51,0x49,0x45,0x43}, /* Z */ - {0x00,0x00,0x7F,0x41,0x41}, /* [ */ - {0x02,0x04,0x08,0x10,0x20}, /* \ */ - {0x41,0x41,0x7F,0x00,0x00}, /* ] */ - {0x04,0x02,0x01,0x02,0x04}, /* ^ */ - {0x40,0x40,0x40,0x40,0x40}, /* _ */ - {0x00,0x01,0x02,0x04,0x00}, /* ` */ - {0x20,0x54,0x54,0x54,0x78}, /* a */ - {0x7F,0x48,0x44,0x44,0x38}, /* b */ - {0x38,0x44,0x44,0x44,0x20}, /* c */ - {0x38,0x44,0x44,0x48,0x7F}, /* d */ - {0x38,0x54,0x54,0x54,0x18}, /* e */ - {0x08,0x7E,0x09,0x01,0x02}, /* f */ - {0x08,0x14,0x54,0x54,0x3C}, /* g */ - {0x7F,0x08,0x04,0x04,0x78}, /* h */ - {0x00,0x44,0x7D,0x40,0x00}, /* i */ - {0x20,0x40,0x44,0x3D,0x00}, /* j */ - {0x00,0x7F,0x10,0x28,0x44}, /* k */ - {0x00,0x41,0x7F,0x40,0x00}, /* l */ - {0x7C,0x04,0x18,0x04,0x78}, /* m */ - {0x7C,0x08,0x04,0x04,0x78}, /* n */ - {0x38,0x44,0x44,0x44,0x38}, /* o */ - {0x7C,0x14,0x14,0x14,0x08}, /* p */ - {0x08,0x14,0x14,0x18,0x7C}, /* q */ - {0x7C,0x08,0x04,0x04,0x08}, /* r */ - {0x48,0x54,0x54,0x54,0x20}, /* s */ - {0x04,0x3F,0x44,0x40,0x20}, /* t */ - {0x3C,0x40,0x40,0x20,0x7C}, /* u */ - {0x1C,0x20,0x40,0x20,0x1C}, /* v */ - {0x3C,0x40,0x30,0x40,0x3C}, /* w */ - {0x44,0x28,0x10,0x28,0x44}, /* x */ - {0x0C,0x50,0x50,0x50,0x3C}, /* y */ - {0x44,0x64,0x54,0x4C,0x44}, /* z */ - {0x00,0x08,0x36,0x41,0x00}, /* { */ - {0x00,0x00,0x7F,0x00,0x00}, /* | */ - {0x00,0x41,0x36,0x08,0x00}, /* } */ - {0x08,0x08,0x2A,0x1C,0x08}, /* ~ */ -}; - -static void msleep(int ms) -{ - struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L }; - nanosleep(&ts, NULL); -} - -static int gpio_request_line(int chip_fd, int pin, struct gpio_v2_line_request *req) -{ - struct gpio_v2_line_request r = {0}; - r.offsets[0] = pin; - r.num_lines = 1; - r.config.flags = GPIO_V2_LINE_FLAG_OUTPUT; - snprintf(r.consumer, sizeof(r.consumer), "migration"); - - if (ioctl(chip_fd, GPIO_V2_GET_LINE_IOCTL, &r) < 0) { - perror("GPIO_V2_GET_LINE_IOCTL"); - return -1; - } - *req = r; - return 0; -} - -static void gpio_set(struct gpio_v2_line_request *req, int value) -{ - struct gpio_v2_line_values vals = {0}; - vals.bits = value ? 1 : 0; - vals.mask = 1; - ioctl(req->fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals); -} - -static void spi_write(const uint8_t *data, size_t len) -{ - /* Chunk large transfers - SPI driver may limit to 4KB */ - const size_t chunk_size = 4096; - while (len > 0) { - size_t this_len = len > chunk_size ? chunk_size : len; - struct spi_ioc_transfer tr = {0}; - tr.tx_buf = (unsigned long)data; - tr.len = this_len; - tr.speed_hz = SPI_SPEED; - tr.bits_per_word = 8; - ioctl(spi_fd, SPI_IOC_MESSAGE(1), &tr); - data += this_len; - len -= this_len; - } -} - -static void oled_cmd(uint8_t cmd) -{ - gpio_set(&dc_req, 0); - spi_write(&cmd, 1); -} - -static void oled_data(const uint8_t *data, size_t len) -{ - gpio_set(&dc_req, 1); - spi_write(data, len); -} - -static void oled_cmd_data(uint8_t cmd, uint8_t data) -{ - oled_cmd(cmd); - oled_data(&data, 1); -} - -static int display_on = 0; - -static void detect_display(void) -{ - const char *display_class = getenv("MIGRATION_DISPLAY_CLASS"); - const char *display_resolution = getenv("MIGRATION_DISPLAY_RESOLUTION"); - - controller = CTRL_SSD1351; - display_width = 128; - display_height = 128; - - if (display_class && strstr(display_class, "SSD1333")) { - controller = CTRL_SSD1333; - display_width = 176; - display_height = 176; - return; - } - - if (display_resolution && strcmp(display_resolution, "176x176") == 0) { - controller = CTRL_SSD1333; - display_width = 176; - display_height = 176; - } -} - -static void oled_reset(void) -{ - gpio_set(&rst_req, 1); - msleep(10); - gpio_set(&rst_req, 0); - msleep(10); - gpio_set(&rst_req, 1); - msleep(10); -} - -static void oled_common_init(int mux_ratio, uint8_t remap) -{ - oled_reset(); - - /* SSD13xx 65k-color OLED setup. */ - oled_cmd_data(0xFD, 0x12); /* Unlock */ - oled_cmd_data(0xFD, 0xB1); /* Unlock commands */ - - oled_cmd(0xAE); /* Display off */ - oled_cmd_data(0xB3, 0xF1); /* Clock divider */ - oled_cmd_data(0xCA, (uint8_t)mux_ratio); /* Mux ratio */ - - oled_cmd(0x15); /* Column address */ - uint8_t col[2] = {0x00, (uint8_t)(display_width - 1)}; - oled_data(col, 2); - - oled_cmd(0x75); /* Row address */ - uint8_t row[2] = {0x00, (uint8_t)(display_height - 1)}; - oled_data(row, 2); - - oled_cmd_data(0xA0, remap); /* Remap/color depth */ - oled_cmd_data(0xA1, 0x00); /* Start line */ - oled_cmd_data(0xA2, 0x00); /* Display offset */ - oled_cmd_data(0xB5, 0x00); /* GPIO */ - oled_cmd_data(0xAB, 0x01); /* Function select */ - oled_cmd_data(0xB1, 0x32); /* Precharge */ - - oled_cmd(0xB4); /* VSL */ - uint8_t vsl[3] = {0xA0, 0xB5, 0x55}; - oled_data(vsl, 3); - - oled_cmd_data(0xBE, 0x05); /* VCOMH */ - oled_cmd_data(0xC7, 0x0F); /* Master contrast */ - oled_cmd_data(0xB6, 0x01); /* Precharge2 */ - oled_cmd(0xA6); /* Normal display */ - - /* Display ON (0xAF) happens after first framebuffer flush. */ -} - -static void oled_init(void) -{ - if (controller == CTRL_SSD1333) - oled_common_init(0xAF, 0x74); - else - oled_common_init(0x7F, 0x74); -} - -static void oled_flush(void) -{ - /* Set contrast before first frame (matching luma) */ - if (!display_on) { - oled_cmd(0xC1); /* Contrast */ - uint8_t contrast[3] = {0xFF, 0xFF, 0xFF}; - oled_data(contrast, 3); - } - - oled_cmd(0x15); - uint8_t col[2] = {0x00, (uint8_t)(display_width - 1)}; - oled_data(col, 2); - - oled_cmd(0x75); - uint8_t row[2] = {0x00, (uint8_t)(display_height - 1)}; - oled_data(row, 2); - - oled_cmd(0x5C); /* Write RAM */ - - /* Send framebuffer as big-endian 16-bit pixels */ - uint8_t buf[MAX_WIDTH * MAX_HEIGHT * 2]; - int pixels = display_width * display_height; - for (int i = 0; i < pixels; i++) { - buf[i * 2] = framebuf[i] >> 8; - buf[i * 2 + 1] = framebuf[i] & 0xFF; - } - oled_data(buf, (size_t)pixels * 2); - - /* Turn display on after first frame */ - if (!display_on) { - oled_cmd(0xAF); /* Display on */ - display_on = 1; - } -} - -static void fb_clear(uint16_t color) -{ - for (int i = 0; i < display_width * display_height; i++) - framebuf[i] = color; -} - -static void fb_pixel(int x, int y, uint16_t color) -{ - if (x >= 0 && x < display_width && y >= 0 && y < display_height) - framebuf[y * display_width + x] = color; -} - -static void fb_rect(int x, int y, int w, int h, uint16_t color) -{ - for (int j = y; j < y + h && j < display_height; j++) - for (int i = x; i < x + w && i < display_width; i++) - fb_pixel(i, j, color); -} - -static void fb_char(int x, int y, char c, uint16_t color, int scale) -{ - if (c < 32 || c > 126) - c = '?'; - const uint8_t *glyph = font5x7[c - 32]; - for (int col = 0; col < 5; col++) { - uint8_t bits = glyph[col]; - for (int row = 0; row < 7; row++) { - if (bits & (1 << row)) { - for (int sy = 0; sy < scale; sy++) - for (int sx = 0; sx < scale; sx++) - fb_pixel(x + col * scale + sx, - y + row * scale + sy, color); - } - } - } -} - -static void fb_string(int x, int y, const char *s, uint16_t color, int scale) -{ - int cx = x; - while (*s) { - fb_char(cx, y, *s, color, scale); - cx += 6 * scale; /* 5px char + 1px gap */ - s++; - } -} - -/* Center a string horizontally */ -static void fb_string_centered(int y, const char *s, uint16_t color, int scale) -{ - int len = strlen(s); - int px_width = len * 6 * scale - scale; /* subtract trailing gap */ - int x = (display_width - px_width) / 2; - if (x < 0) x = 0; - fb_string(x, y, s, color, scale); -} - -/* Center a string, shrinking the scale (and finally truncating) so it always - * fits the display width. Used for the variable-length stage name. */ -static void fb_string_centered_fit(int y, const char *s, uint16_t color, int max_scale) -{ - int len = strlen(s); - for (int scale = max_scale; scale >= 1; scale--) { - if (len * 6 * scale - scale <= display_width) { - fb_string_centered(y, s, color, scale); - return; - } - } - /* Too long even at scale 1: render a head that fits. */ - int max_chars = (display_width + 1) / 6; - char buf[64]; - if (max_chars > (int)sizeof(buf) - 1) - max_chars = (int)sizeof(buf) - 1; - int n = len < max_chars ? len : max_chars; - memcpy(buf, s, (size_t)n); - buf[n] = '\0'; - fb_string_centered(y, buf, color, 1); -} - -static void draw_progress(int percent, const char *stage, int stage_num, int stage_total) -{ - if (percent < 0) percent = 0; - if (percent > 100) percent = 100; - - fb_clear(COL_BLACK); - - int scale = display_width >= 160 ? 2 : 1; - int margin = display_width >= 160 ? 14 : 10; - int banner_h = display_width >= 160 ? 18 : 12; - int title_y = display_width >= 160 ? 28 : 18; - int subtitle_y = display_width >= 160 ? 55 : 38; - int stage_y = display_width >= 160 ? 76 : 52; - int bar_y = display_width >= 160 ? 94 : 65; - int pct_y = display_width >= 160 ? 116 : 82; - int stage_name_y = display_width >= 160 ? 145 : 105; - int wait_y = display_height - (8 * scale); - - /* Warning banner at top: solid bright bar with black text for contrast */ - fb_rect(0, 0, display_width, banner_h, COL_RED); - fb_string_centered(3, "DO NOT POWER OFF", COL_BLACK, scale); - - /* Title */ - fb_string_centered(title_y, "NixOS", COL_RED, 2); - fb_string_centered(subtitle_y, "Migration", COL_RED, scale); - - /* Stage indicator (e.g., "3/7") */ - if (stage_total > 0) { - char stage_str[32]; - snprintf(stage_str, sizeof(stage_str), "Stage %d/%d", stage_num, stage_total); - fb_string_centered(stage_y, stage_str, COL_DKGRAY, scale); - } - - /* Progress bar */ - int bar_x = margin; - int bar_w = display_width - (margin * 2); - int bar_h = display_width >= 160 ? 16 : 12; - - /* Border */ - fb_rect(bar_x, bar_y, bar_w, 1, COL_DKGRAY); - fb_rect(bar_x, bar_y + bar_h - 1, bar_w, 1, COL_DKGRAY); - fb_rect(bar_x, bar_y, 1, bar_h, COL_DKGRAY); - fb_rect(bar_x + bar_w - 1, bar_y, 1, bar_h, COL_DKGRAY); - - /* Fill */ - int fill_w = (bar_w - 4) * percent / 100; - if (fill_w > 0) - fb_rect(bar_x + 2, bar_y + 2, fill_w, bar_h - 4, COL_RED); - - /* Dark red background for unfilled */ - int unfill_x = bar_x + 2 + fill_w; - int unfill_w = (bar_w - 4) - fill_w; - if (unfill_w > 0) - fb_rect(unfill_x, bar_y + 2, unfill_w, bar_h - 4, COL_DKRED); - - /* Percentage */ - char pct_str[8]; - snprintf(pct_str, sizeof(pct_str), "%d%%", percent); - fb_string_centered(pct_y, pct_str, COL_RED, 2); - - /* Current stage name */ - if (stage && *stage) - fb_string_centered_fit(stage_name_y, stage, COL_RED, scale); - - /* Bottom warning */ - fb_string_centered(wait_y, "Please wait...", COL_DKGRAY, scale); - - oled_flush(); -} - -static int hw_init(void) -{ - /* Open SPI */ - spi_fd = open(SPI_DEVICE, O_RDWR); - if (spi_fd < 0) { - perror("open spi"); - return -1; - } - - uint8_t mode = SPI_MODE_0; - uint8_t bits = 8; - uint32_t speed = SPI_SPEED; - ioctl(spi_fd, SPI_IOC_WR_MODE, &mode); - ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &bits); - ioctl(spi_fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed); - - /* Open GPIO chip */ - gpio_fd = open("/dev/gpiochip0", O_RDWR); - if (gpio_fd < 0) { - perror("open gpiochip0"); - return -1; - } - - if (gpio_request_line(gpio_fd, GPIO_DC, &dc_req) < 0) - return -1; - if (gpio_request_line(gpio_fd, GPIO_RST, &rst_req) < 0) - return -1; - - detect_display(); - oled_init(); - return 0; -} - -static void hw_cleanup(void) -{ - if (dc_req.fd > 0) close(dc_req.fd); - if (rst_req.fd > 0) close(rst_req.fd); - if (gpio_fd >= 0) close(gpio_fd); - if (spi_fd >= 0) close(spi_fd); -} - -/* Persistent mode: initialise the panel once, then redraw in place for each - * " " line read from stdin. - * Because the panel is never reset between frames, the display updates without - * blanking to black. Returns on EOF (writer closed). */ -static void serve_loop(void) -{ - char line[256]; - while (fgets(line, sizeof(line), stdin)) { - int percent = 0, stage_num = 0, stage_total = 0, name_off = 0; - if (sscanf(line, "%d %d %d %n", &percent, &stage_num, &stage_total, - &name_off) < 3) - continue; - char *name = line + name_off; - size_t len = strlen(name); - while (len > 0 && (name[len - 1] == '\n' || name[len - 1] == '\r')) - name[--len] = '\0'; - draw_progress(percent, name, stage_num, stage_total); - } -} - -int main(int argc, char *argv[]) -{ - int serve = (argc >= 2 && strcmp(argv[1], "--serve") == 0); - - if (!serve && argc < 5) { - fprintf(stderr, "Usage: %s --serve\n", argv[0]); - fprintf(stderr, " read ' '\n"); - fprintf(stderr, " lines from stdin and redraw in place (no flicker)\n"); - fprintf(stderr, " or: %s \n", argv[0]); - fprintf(stderr, " draw a single frame and exit\n"); - fprintf(stderr, "\nExample: %s 50 3 7 'Extracting system'\n", argv[0]); - return 1; - } - - if (hw_init() < 0) { - fprintf(stderr, "Hardware init failed\n"); - hw_cleanup(); - return 1; - } - - if (serve) { - serve_loop(); - } else { - draw_progress(atoi(argv[1]), argv[4], atoi(argv[2]), atoi(argv[3])); - } - - hw_cleanup(); - return 0; -} diff --git a/python/scripts/nixos_migration_calc.py b/python/scripts/nixos_migration_calc.py deleted file mode 100755 index d3500bdc7..000000000 --- a/python/scripts/nixos_migration_calc.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -""" -Pre-flight checks for NixOS migration. - -Validates hardware requirements before migration can proceed. -Run on the Pi to verify it meets minimum specs. - -Usage: python3 nixos_migration_calc.py [--json] --display-class CLASS --display-resolution WxH -""" - -import argparse -import json -import platform -import re -import shutil -import subprocess -import sys -from pathlib import Path - -MIN_RAM_MB = 1800 # 2GB Pi reports ~1849MB due to GPU memory reservation -MIN_SD_GB = 16 -REQUIRED_MODEL = "Raspberry Pi 4" -# Must match the initramfs progress renderer, not just the main PiFinder UI. -SUPPORTED_DISPLAYS = { - "DisplaySSD1351": "128x128", - "DisplaySSD1333": "176x176", -} -# The initramfs script hardcodes these paths and unconditionally extends -# partition 2 to fill the disk. Migration only supports the stock layout. -SD_DISK = "/dev/mmcblk0" -EXPECTED_BOOT = "/dev/mmcblk0p1" -EXPECTED_ROOT = "/dev/mmcblk0p2" -EXPECTED_PARTITION_COUNT = 2 - - -def get_model() -> str: - """Read Pi model from device-tree.""" - try: - return Path("/proc/device-tree/model").read_text().rstrip("\x00").strip() - except OSError: - return "Unknown" - - -def get_ram_mb() -> int: - """Get total RAM in MB from /proc/meminfo.""" - try: - text = Path("/proc/meminfo").read_text() - match = re.search(r"MemTotal:\s+(\d+)\s+kB", text) - if match: - return int(match.group(1)) // 1024 - except OSError: - pass - return 0 - - -def get_sd_size_gb() -> float: - """Get SD card size in GB (root device).""" - try: - result = subprocess.run( - ["lsblk", "-b", "-d", "-n", "-o", "SIZE", "/dev/mmcblk0"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return int(result.stdout.strip()) / (1024**3) - except (OSError, ValueError): - pass - return 0.0 - - -def get_free_space_gb(path: str = "/home/pifinder") -> float: - """Get free space in GB at the given path.""" - try: - usage = shutil.disk_usage(path) - return usage.free / (1024**3) - except OSError: - return 0.0 - - -def get_root_source() -> str: - """Device backing the / mount, e.g. /dev/mmcblk0p2.""" - try: - result = subprocess.run( - ["findmnt", "-no", "SOURCE", "/"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return result.stdout.strip() - except OSError: - pass - return "" - - -def get_partition_count(disk: str = SD_DISK) -> int: - """Number of partitions on the SD disk node (excludes the disk itself).""" - try: - result = subprocess.run( - ["lsblk", "-no", "NAME", "-l", disk], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return 0 - lines = [line for line in result.stdout.splitlines() if line.strip()] - return max(len(lines) - 1, 0) - except OSError: - return 0 - - -def get_wifi_mode() -> str: - """Detect WiFi mode.""" - wifi_status = Path("/home/pifinder/PiFinder/wifi_status.txt") - try: - return wifi_status.read_text().strip() - except OSError: - return "Unknown" - - -def normalize_resolution(value: str) -> str: - """Normalize a live UI resolution string to WIDTHxHEIGHT.""" - match = re.fullmatch(r"\s*(\d+)\s*[x,]\s*(\d+)\s*", value) - if not match: - return value.strip() - return f"{int(match.group(1))}x{int(match.group(2))}" - - -def is_pi4() -> bool: - """Check if running on a Raspberry Pi 4.""" - model = get_model() - return REQUIRED_MODEL in model - - -def check_all(display_class: str = "", display_resolution: str = "") -> dict: - """Run all pre-flight checks. Returns dict with results.""" - model = get_model() - ram_mb = get_ram_mb() - sd_gb = get_sd_size_gb() - free_gb = get_free_space_gb() - wifi = get_wifi_mode() - display_class = display_class.strip() or "Unknown" - display_resolution = normalize_resolution(display_resolution) or "Unknown" - display_ok = SUPPORTED_DISPLAYS.get(display_class) == display_resolution - display_ok = display_ok or ( - "SSD1333" in display_class and display_resolution == "176x176" - ) - root_source = get_root_source() - partition_count = get_partition_count() - boot_present = Path(EXPECTED_BOOT).is_block_device() - layout_ok = ( - root_source == EXPECTED_ROOT - and boot_present - and partition_count == EXPECTED_PARTITION_COUNT - ) - - checks = { - "model": model, - "is_pi4": REQUIRED_MODEL in model, - "ram_mb": ram_mb, - "ram_ok": ram_mb >= MIN_RAM_MB, - "sd_gb": round(sd_gb, 1), - "sd_ok": sd_gb >= MIN_SD_GB, - "free_gb": round(free_gb, 1), - "free_ok": free_gb >= 1.5, - "wifi_mode": wifi, - "wifi_ok": wifi == "Client", - "display_class": display_class, - "display_resolution": display_resolution, - "display_ok": display_ok, - "root_source": root_source, - "partition_count": partition_count, - "boot_present": boot_present, - "layout_ok": layout_ok, - "arch": platform.machine(), - } - checks["all_ok"] = all( - [ - checks["is_pi4"], - checks["ram_ok"], - checks["sd_ok"], - checks["free_ok"], - checks["wifi_ok"], - checks["display_ok"], - checks["layout_ok"], - ] - ) - return checks - - -def main(): - parser = argparse.ArgumentParser(description="NixOS migration pre-flight checks") - parser.add_argument("--json", action="store_true", help="Output as JSON") - parser.add_argument( - "--display-class", - default="", - help="Live PiFinder display class name from the running UI", - ) - parser.add_argument( - "--display-resolution", - default="", - help="Live PiFinder logical display resolution as WIDTHxHEIGHT", - ) - args = parser.parse_args() - - checks = check_all(args.display_class, args.display_resolution) - - if args.json: - print(json.dumps(checks, indent=2)) - sys.exit(0 if checks["all_ok"] else 1) - - print(f"Model: {checks['model']}") - print(f" Pi 4: {'OK' if checks['is_pi4'] else 'FAIL'}") - print(f"RAM: {checks['ram_mb']} MB") - print(f" >= {MIN_RAM_MB}MB: {'OK' if checks['ram_ok'] else 'FAIL'}") - print(f"SD Card: {checks['sd_gb']} GB") - print(f" >= {MIN_SD_GB}GB: {'OK' if checks['sd_ok'] else 'FAIL'}") - print(f"Free Space: {checks['free_gb']} GB") - print(f" >= 1.5GB: {'OK' if checks['free_ok'] else 'FAIL'}") - print(f"WiFi Mode: {checks['wifi_mode']}") - print(f" Client: {'OK' if checks['wifi_ok'] else 'FAIL'}") - print(f"Display: {checks['display_class']} {checks['display_resolution']}") - print( - f" initramfs renderer supported: " - f"{'OK' if checks['display_ok'] else 'FAIL'}" - ) - print(f"Root: {checks['root_source'] or 'Unknown'}") - print(f"Partitions: {checks['partition_count']} on {SD_DISK}") - print( - f" stock SD layout ({EXPECTED_BOOT} + {EXPECTED_ROOT}, 2 partitions): " - f"{'OK' if checks['layout_ok'] else 'FAIL'}" - ) - print(f"Arch: {checks['arch']}") - print() - if checks["all_ok"]: - print("All checks PASSED - migration can proceed") - else: - print("Some checks FAILED - migration cannot proceed") - - sys.exit(0 if checks["all_ok"] else 1) - - -if __name__ == "__main__": - main() diff --git a/python/tests/data/asteroids_fixture.txt b/python/tests/data/asteroids_fixture.txt new file mode 100644 index 000000000..df7508e5d --- /dev/null +++ b/python/tests/data/asteroids_fixture.txt @@ -0,0 +1,10 @@ +00001 3.35 0.15 K25BL 231.53975 73.29974 80.24963 10.58789 0.0795763 0.21429712 2.7656157 0 MPO950947 7369 126 1801-2025 0.69 M-v 30k MPC 0000 (1) Ceres +00002 4.11 0.15 K25BL 211.52977 310.93340 172.88859 34.92833 0.2306430 0.21379713 2.7699258 0 MPO950947 8934 124 1804-2025 0.64 M-c 28k MPC 0000 (2) Pallas +00003 5.19 0.15 K25BL 217.59095 247.88367 169.81989 12.98604 0.2558258 0.22579938 2.6708791 0 MPO937415 7565 118 1804-2025 0.67 M-v 3Ek MPC 0000 (3) Juno +00004 3.25 0.15 K25BL 26.80968 151.53712 103.70232 7.14406 0.0901676 0.27158812 2.3615413 0 MPO925791 7543 112 1821-2025 0.69 M-p 18k MPC 0000 (4) Vesta +00005 6.97 0.15 K25BL 133.86760 359.34517 141.44862 5.35925 0.1875086 0.23826852 2.5768646 0 MPO950947 3339 89 1845-2025 0.79 M-v 3Ek MPC 0000 (5) Astraea +00006 5.61 0.15 K25BL 352.56367 239.69622 138.61473 14.73615 0.2022301 0.26092182 2.4254693 0 MPO950947 6086 105 1848-2025 0.65 M-v 3Ek MPC 0000 (6) Hebe +00007 5.67 0.15 K25BL 61.72502 145.48204 259.49459 5.51881 0.2302133 0.26733389 2.3865290 0 MPO950947 5322 91 1848-2025 0.69 M-v 3Ek MPC 0000 (7) Iris +00008 6.61 0.15 K25BL 198.90078 285.42673 110.84339 5.89033 0.1563337 0.30177489 2.2013072 0 MPO937415 2806 95 1847-2025 0.78 M-v 3Ek MPC 0000 (8) Flora +66146 14.36 0.15 K25BL 74.76748 84.99252 101.87869 5.41525 0.4836695 1.41022421 0.7875484 0 MPO888142 2190 26 1982-2024 0.65 M-v 3Ek MPC 0000 (66146) +F2637 17.87 0.15 K25BL 245.95188 16.59972 96.45769 16.72221 0.2085330 1.22534637 0.8648952 0 E2023-R01 363 11 1997-2022 0.81 M-v 3Ek MPC 0000 (152637) diff --git a/python/tests/test_asteroids.py b/python/tests/test_asteroids.py new file mode 100644 index 000000000..935855520 --- /dev/null +++ b/python/tests/test_asteroids.py @@ -0,0 +1,135 @@ +"""Asteroid source parsing, propagation, photometry, and apparition tests.""" + +import math +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np +import pytest +from skyfield.constants import GM_SUN_Pitjeva_2005_km3_s2 as GM_SUN +from skyfield.data import mpc + +import PiFinder.asteroids as asteroids +from PiFinder.calc_utils import sf_utils + + +FIXTURE = Path(__file__).parent / "data" / "asteroids_fixture.txt" +DT = datetime(2026, 7, 15, tzinfo=timezone.utc) + + +@pytest.fixture(autouse=True) +def observer(): + sf_utils.set_location(50.85, 4.35, 50.0) + + +def angular_separation_arcsec(ra1, dec1, ra2, dec2): + r1, d1, r2, d2 = map(math.radians, (ra1, dec1, ra2, dec2)) + a = ( + math.sin((d2 - d1) / 2) ** 2 + + math.cos(d1) * math.cos(d2) * math.sin((r2 - r1) / 2) ** 2 + ) + return math.degrees(2 * math.asin(min(1.0, math.sqrt(a)))) * 3600.0 + + +@pytest.mark.unit +def test_loads_standard_mpcorb_file_with_stable_numbers(): + dataframe = asteroids.load_asteroids_dataframe([FIXTURE]) + assert len(dataframe) == 10 + assert list(dataframe.number[:4]) == [1, 2, 3, 4] + assert dataframe.number.iloc[-1] == 152637 + assert asteroids.minor_planet_name(dataframe.designation.iloc[0]) == "Ceres" + assert asteroids.minor_planet_name(dataframe.designation.iloc[-1]) == "152637" + + +@pytest.mark.unit +def test_vectorized_positions_match_skyfield_per_object_oracle(): + dataframe = asteroids.load_asteroids_dataframe([FIXTURE]).iloc[:8] + calculated = asteroids._calculate_dataframe( + dataframe, DT, include_apparitions=False + ) + time = sf_utils.ts.from_datetime(DT) + sun = sf_utils.eph["sun"] + for _, row in dataframe.iterrows(): + asteroid = sun + mpc.mpcorb_orbit(row, sf_utils.ts, GM_SUN) + topocentric = (asteroid - sf_utils.observer_loc).at(time) + one_hour_later = sf_utils.ts.tt_jd(time.tt + 1.0 / 24.0) + topocentric_later = (asteroid - sf_utils.observer_loc).at(one_hour_later) + heliocentric = (asteroid - sun).at(time) + ra, dec, earth_distance = topocentric.radec(sf_utils.ts.J2000) + sun_distance = heliocentric.distance() + item = calculated[int(row.number)] + separation = angular_separation_arcsec( + ra.degrees, dec.degrees, item["radec"][0], item["radec"][1] + ) + assert separation < 0.05 + assert item["earth_distance"] == pytest.approx(earth_distance.au, rel=1e-7) + assert item["sun_distance"] == pytest.approx(sun_distance.au, rel=1e-7) + later_ra, later_dec, _ = topocentric_later.radec(sf_utils.ts.J2000) + oracle_motion = angular_separation_arcsec( + ra.degrees, dec.degrees, later_ra.degrees, later_dec.degrees + ) + assert item["angular_motion_arcsec_per_hour"] == pytest.approx( + oracle_motion, abs=0.01 + ) + + +@pytest.mark.unit +def test_hg_magnitude_is_h_at_unit_distances_and_zero_phase(): + magnitude = asteroids.hg_magnitude( + np.array([8.5]), + np.array([0.15]), + np.array([1.0]), + np.array([1.0]), + np.array([0.0]), + ) + assert magnitude[0] == pytest.approx(8.5) + + +@pytest.mark.unit +def test_apparition_reports_vesta_opposition_and_nearby_peak(): + dataframe = asteroids.load_asteroids_dataframe([FIXTURE]) + vesta = dataframe[dataframe.number == 4] + result = asteroids._calculate_dataframe(vesta, DT)[4] + assert result["opposition_kind"] == "Opposition" + assert result["opposition_date"].isoformat() == "2026-10-13" + assert abs((result["peak_date"] - result["opposition_date"]).days) <= 90 + assert result["peak_magnitude"] <= result["mag"] + + +@pytest.mark.unit +def test_next_apparition_skips_just_passed_day_zero_opposition(): + index, is_opposition = asteroids._next_apparition_index( + np.array([180.0, 175.0, 160.0, 170.0, 179.0, 170.0]) + ) + assert index == 4 + assert is_opposition + + +@pytest.mark.unit +def test_next_apparition_skips_day_zero_greatest_elongation(): + index, is_opposition = asteroids._next_apparition_index( + np.array([120.0, 110.0, 80.0, 100.0, 125.0, 100.0]) + ) + assert index == 4 + assert not is_opposition + + +@pytest.mark.unit +def test_visibility_cut_rejects_non_finite_and_dim_objects(): + dataframe = asteroids.load_asteroids_dataframe([FIXTURE]).iloc[:2].copy() + dataframe.loc[dataframe.index[0], "magnitude_H"] = np.nan + dataframe.loc[dataframe.index[1], "magnitude_H"] = 99.0 + assert asteroids._calculate_dataframe(dataframe, DT) == {} + + +@pytest.mark.unit +def test_calc_asteroids_without_observer_is_empty(): + saved_location = sf_utils.observer_loc + saved_last = sf_utils._last_location + try: + sf_utils.observer_loc = None + sf_utils._last_location = None + assert asteroids.calc_asteroids(DT, [FIXTURE]) == {} + finally: + sf_utils.observer_loc = saved_location + sf_utils._last_location = saved_last diff --git a/python/tests/test_bringup.py b/python/tests/test_bringup.py index b336b8da6..dea4320a3 100644 --- a/python/tests/test_bringup.py +++ b/python/tests/test_bringup.py @@ -426,6 +426,7 @@ def test_shutdown_command_matches_sys_utils(): source = (PYTHON_DIR / "PiFinder" / "sys_utils.py").read_text() assert ( 'sh.sudo("shutdown", "now")' in source + or '_run(["sudo", "shutdown", "now"])' in source ), "sys_utils.shutdown changed -- update SHUTDOWN_COMMAND" assert bringup.SHUTDOWN_COMMAND == ("sudo", "shutdown", "now") diff --git a/python/tests/test_build_identity.py b/python/tests/test_build_identity.py new file mode 100644 index 000000000..7ec10f702 --- /dev/null +++ b/python/tests/test_build_identity.py @@ -0,0 +1,110 @@ +import json +import os + +import pytest + +from PiFinder import utils + + +def _write_build(tmp_path, monkeypatch, **fields): + f = tmp_path / "current-build.json" + f.write_text(json.dumps(fields)) + monkeypatch.setattr(utils, "current_build_json", f) + return f + + +def _fake_running(monkeypatch, store_path): + """Pretend the booted system resolves to store_path (None = off-device).""" + monkeypatch.setattr(utils, "running_system_store_path", lambda: store_path) + + +@pytest.mark.unit +class TestGetVersion: + def test_missing_file_is_unknown(self, tmp_path, monkeypatch): + monkeypatch.setattr(utils, "current_build_json", tmp_path / "nope.json") + assert utils.get_version() == "Unknown" + + def test_label_returned_when_build_is_running(self, tmp_path, monkeypatch): + store = "/nix/store/abc12345-nixos-system-pifinder" + _write_build(tmp_path, monkeypatch, store_path=store, version="PR362-005abc") + _fake_running(monkeypatch, store) + assert utils.get_version() == "PR362-005abc" + + def test_off_device_trusts_the_label(self, tmp_path, monkeypatch): + # No /run/current-system to compare against — don't cry stale. + _write_build( + tmp_path, monkeypatch, store_path="/nix/store/x-nixos-system", version="v1" + ) + _fake_running(monkeypatch, None) + assert utils.get_version() == "v1" + + def test_stale_label_falls_back_to_running_hash(self, tmp_path, monkeypatch): + # current-build.json names the selected build; the device booted another. + _write_build( + tmp_path, + monkeypatch, + store_path="/nix/store/selected00-nixos-system-pifinder", + version="PR362-005abc", + ) + _fake_running(monkeypatch, "/nix/store/running99-nixos-system-pifinder") + # Never assert the label the device isn't running; report the real build. + assert utils.get_version() == "running9" + + def test_stale_and_no_running_path_is_unknown(self, tmp_path, monkeypatch): + _write_build( + tmp_path, + monkeypatch, + store_path="/nix/store/selected00-nixos-system", + version="PR362-005abc", + ) + # build_is_running -> False (mismatch), running unknown -> Unknown, not a lie. + monkeypatch.setattr(utils, "build_is_running", lambda p: False) + _fake_running(monkeypatch, None) + assert utils.get_version() == "Unknown" + + +@pytest.mark.unit +class TestBuildIsRunning: + def test_direct_match(self, monkeypatch): + store = "/nix/store/base00-nixos-system-pifinder" + _fake_running(monkeypatch, store) + assert utils.build_is_running(store) is True + + def test_mismatch(self, monkeypatch): + _fake_running(monkeypatch, "/nix/store/other-nixos-system") + assert utils.build_is_running("/nix/store/base00-nixos-system") is False + + def test_none_running_assumes_match(self, monkeypatch): + _fake_running(monkeypatch, None) + assert utils.build_is_running("/nix/store/base00-nixos-system") is True + + def test_empty_store_path_is_false(self, monkeypatch): + _fake_running(monkeypatch, "/nix/store/x") + assert utils.build_is_running("") is False + assert utils.build_is_running(None) is False + + def test_running_camera_specialisation_matches_base(self, tmp_path, monkeypatch): + # The device boots /specialisation/, a distinct store path; + # the recorded base must still count as running. + base = tmp_path / "base-nixos-system" + spec_target = tmp_path / "specialised00-nixos-system" + spec_target.mkdir() + (base / "specialisation").mkdir(parents=True) + os.symlink(spec_target, base / "specialisation" / "imx462") + _fake_running(monkeypatch, os.path.realpath(spec_target)) + assert utils.build_is_running(str(base)) is True + + +@pytest.mark.unit +class TestRunningSystemStorePath: + def test_none_when_not_a_symlink(self, tmp_path, monkeypatch): + monkeypatch.setattr(utils, "running_system_link", tmp_path / "absent") + assert utils.running_system_store_path() is None + + def test_none_when_not_a_store_path(self, tmp_path, monkeypatch): + target = tmp_path / "somewhere" + target.mkdir() + link = tmp_path / "current-system" + os.symlink(target, link) + monkeypatch.setattr(utils, "running_system_link", link) + assert utils.running_system_store_path() is None diff --git a/python/tests/test_cat_images.py b/python/tests/test_cat_images.py deleted file mode 100644 index df6901e1f..000000000 --- a/python/tests/test_cat_images.py +++ /dev/null @@ -1,297 +0,0 @@ -import math -import pytest -from PiFinder.cat_images import ( - cardinal_vectors, - size_overlay_points, - vertex_overlay_points, -) -from PiFinder.composite_object import SizeObject - - -def approx_pt(pt, abs=1e-6): - return pytest.approx(pt, abs=abs) - - -# --- cardinal_vectors --- - - -@pytest.mark.unit -class TestCardinalVectors: - def test_no_rotation(self): - """image_rotate=0: POSS north-up, east-left → N at (0, -1), E at (-1, 0).""" - (nx, ny), (ex, ey) = cardinal_vectors(0) - assert (nx, ny) == approx_pt((0, -1)) - assert (ex, ey) == approx_pt((-1, 0)) - - def test_180_rotation(self): - """image_rotate=180: N flips to (0, 1), E to (1, 0).""" - (nx, ny), (ex, ey) = cardinal_vectors(180) - assert (nx, ny) == approx_pt((0, 1)) - assert (ex, ey) == approx_pt((1, 0)) - - def test_90_rotation(self): - """image_rotate=90 turns the image CCW: N at (-1, 0), E at (0, 1).""" - (nx, ny), (ex, ey) = cardinal_vectors(90) - assert (nx, ny) == approx_pt((-1, 0)) - assert (ex, ey) == approx_pt((0, 1)) - - def test_flip_mirrors_x(self): - """flip negates x components of both vectors.""" - (nx, ny), (ex, ey) = cardinal_vectors(0, fx=-1) - assert (nx, ny) == approx_pt((0, -1)) - assert (ex, ey) == approx_pt((1, 0)) - - def test_flop_mirrors_y(self): - """flop negates y components of both vectors.""" - (nx, ny), (ex, ey) = cardinal_vectors(0, fy=-1) - assert (nx, ny) == approx_pt((0, 1)) - assert (ex, ey) == approx_pt((-1, 0)) - - def test_flip_and_flop(self): - """Both flip and flop: equivalent to 180° rotation of vectors.""" - (nx, ny), (ex, ey) = cardinal_vectors(0, fx=-1, fy=-1) - assert (nx, ny) == approx_pt((0, 1)) - assert (ex, ey) == approx_pt((1, 0)) - - def test_orthogonality(self): - """N and E should always be perpendicular.""" - for angle in [0, 45, 90, 135, 180, 270]: - 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}" - - def test_unit_length(self): - """N and E vectors should have unit length.""" - for angle in [0, 30, 45, 90, 180, 270]: - (nx, ny), (ex, ey) = cardinal_vectors(angle) - assert math.hypot(nx, ny) == pytest.approx(1) - assert math.hypot(ex, ey) == pytest.approx(1) - - -# --- size_overlay_points --- - - -@pytest.mark.unit -class TestSizeOverlayPoints: - def test_single_extent_returns_none(self): - """1 extent → None (caller uses native ellipse).""" - assert size_overlay_points([100], 0, 0, 1.0, 64, 64) is None - - def test_empty_returns_none(self): - assert size_overlay_points([], 0, 0, 1.0, 64, 64) is None - - def test_two_extents_point_count(self): - """2 extents → 36-point ellipse polygon.""" - pts = size_overlay_points([120, 60], 0, 0, 1.0, 64, 64) - assert len(pts) == 36 - - def test_two_extents_centered(self): - """Ellipse centroid should be at (cx, cy).""" - cx, cy = 64, 64 - pts = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) - avg_x = sum(p[0] for p in pts) / len(pts) - avg_y = sum(p[1] for p in pts) / len(pts) - assert avg_x == pytest.approx(cx, abs=0.1) - assert avg_y == pytest.approx(cy, abs=0.1) - - def test_two_extents_symmetry(self): - """No rotation, no PA: major axis aligned with North (vertical).""" - cx, cy = 64, 64 - pts = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) - xs = [p[0] - cx for p in pts] - ys = [p[1] - cy for p in pts] - # PA=0 → major axis along North → vertical - assert max(abs(x) for x in xs) == pytest.approx(30, abs=0.5) - assert max(abs(y) for y in ys) == pytest.approx(60, abs=0.5) - - def test_two_extents_rotation(self): - """90° image rotation moves major axis from vertical to horizontal.""" - cx, cy = 64, 64 - pts = size_overlay_points([120, 60], 0, 90, 1.0, cx, cy) - xs = [p[0] - cx for p in pts] - ys = [p[1] - cy for p in pts] - # 90° rotation: North moves to +X, major axis now horizontal - assert max(abs(x) for x in xs) == pytest.approx(60, abs=0.5) - assert max(abs(y) for y in ys) == pytest.approx(30, abs=0.5) - - def test_position_angle(self): - """PA=90 matches a 90° image rotation (both turn N toward E on screen).""" - cx, cy = 64, 64 - pts_rot = size_overlay_points([120, 60], 0, 90, 1.0, cx, cy) - pts_pa = size_overlay_points([120, 60], 90, 0, 1.0, cx, cy) - for a, b in zip(pts_rot, pts_pa): - assert a[0] == pytest.approx(b[0], abs=1e-6) - assert a[1] == pytest.approx(b[1], abs=1e-6) - - def test_pa90_aligns_with_east(self): - """PA=90° major axis must align with the East vector from cardinal_vectors.""" - cx, cy = 64, 64 - for rot in [0, 90, 180, 270]: - _, (ex, ey) = cardinal_vectors(rot) - pts = size_overlay_points([200, 40], 90, rot, 1.0, cx, cy) - dists = [(p[0] - cx, p[1] - cy) for p in pts] - farthest = max(dists, key=lambda d: math.hypot(*d)) - direction = ( - farthest[0] / math.hypot(*farthest), - 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}" - - def test_pa0_aligns_with_north(self): - """PA=0 major axis must align with the North vector from cardinal_vectors.""" - cx, cy = 64, 64 - for rot in [0, 90, 180, 270]: - (nx, ny), _ = cardinal_vectors(rot) - pts = size_overlay_points([200, 40], 0, rot, 1.0, cx, cy) - # Find the point farthest from center — should be along North - dists = [(p[0] - cx, p[1] - cy) for p in pts] - farthest = max(dists, key=lambda d: math.hypot(*d)) - direction = ( - farthest[0] / math.hypot(*farthest), - farthest[1] / math.hypot(*farthest), - ) - # 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}" - - def test_flip_mirrors_x(self): - """fx=-1 mirrors all points horizontally around cx.""" - cx, cy = 64, 64 - pts_normal = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy) - pts_flip = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy, fx=-1) - for a, b in zip(pts_normal, pts_flip): - assert a[0] - cx == pytest.approx(-(b[0] - cx), abs=1e-6) - assert a[1] == pytest.approx(b[1], abs=1e-6) - - def test_flop_mirrors_y(self): - """fy=-1 mirrors all points vertically around cy.""" - cx, cy = 64, 64 - pts_normal = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy) - pts_flop = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy, fy=-1) - for a, b in zip(pts_normal, pts_flop): - assert a[0] == pytest.approx(b[0], abs=1e-6) - assert a[1] - cy == pytest.approx(-(b[1] - cy), abs=1e-6) - - def test_three_extents_point_count(self): - """3+ extents → polygon with len(extents) points.""" - pts = size_overlay_points([100, 80, 60, 90], 0, 0, 1.0, 64, 64) - assert len(pts) == 4 - - def test_px_per_arcsec_scaling(self): - """Doubling px_per_arcsec doubles the distance from center.""" - cx, cy = 64, 64 - pts1 = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) - pts2 = size_overlay_points([120, 60], 0, 0, 2.0, cx, cy) - for a, b in zip(pts1, pts2): - assert (b[0] - cx) == pytest.approx(2 * (a[0] - cx), abs=1e-6) - assert (b[1] - cy) == pytest.approx(2 * (a[1] - cy), abs=1e-6) - - -# --- SizeObject vertex mode --- - - -@pytest.mark.unit -class TestSizeObjectVertices: - def test_from_vertices_stores_nested_pairs(self): - verts = [[10.0, 20.0], [10.1, 20.1], [10.2, 20.0]] - s = SizeObject.from_vertices(verts) - assert s.extents == verts - assert s.position_angle == 0.0 - - def test_is_vertices_true(self): - s = SizeObject.from_vertices([[10.0, 20.0], [10.1, 20.1]]) - assert s.is_vertices is True - - def test_is_vertices_false_for_numeric(self): - s = SizeObject.from_arcsec(100, 50) - assert s.is_vertices is False - - def test_is_vertices_false_for_empty(self): - s = SizeObject([]) - assert s.is_vertices is False - - def test_max_extent_arcsec_same_dec(self): - """Two points at same dec, 1° apart in RA at dec=0.""" - s = SizeObject.from_vertices([[10.0, 0.0], [11.0, 0.0]]) - expected = 3600.0 # 1 degree = 3600 arcsec - assert s.max_extent_arcsec == pytest.approx(expected, rel=1e-3) - - def test_max_extent_arcsec_same_ra(self): - """Two points at same RA, 0.5° apart in dec.""" - s = SizeObject.from_vertices([[10.0, 20.0], [10.0, 20.5]]) - expected = 1800.0 # 0.5 degree - assert s.max_extent_arcsec == pytest.approx(expected, rel=1e-3) - - def test_max_extent_arcsec_numeric_fallback(self): - s = SizeObject.from_arcsec(100, 200, 150) - assert s.max_extent_arcsec == 200 - - def test_to_display_string_vertices(self): - """Vertex mode shows ~span format.""" - s = SizeObject.from_vertices([[10.0, 20.0], [10.0, 20.5]]) - display = s.to_display_string() - assert display.startswith("~") - assert "'" in display # 1800 arcsec = 30 arcmin - - def test_json_roundtrip(self): - verts = [[10.0, 20.0], [10.1, 20.1]] - s = SizeObject.from_vertices(verts) - s2 = SizeObject.from_json(s.to_json()) - assert s2.is_vertices is True - assert s2.extents == verts - - -# --- vertex_overlay_points --- - - -@pytest.mark.unit -class TestVertexOverlayPoints: - def test_center_vertex_at_center(self): - """A vertex at the object center projects to (cx, cy).""" - pts = vertex_overlay_points([[10.0, 20.0]], 10.0, 20.0, 0, 1.0, 64, 64) - assert len(pts) == 1 - assert pts[0][0] == pytest.approx(64, abs=0.1) - assert pts[0][1] == pytest.approx(64, abs=0.1) - - def test_offset_vertex_north(self): - """A vertex 100" north of center should appear above center (lower y).""" - dec_offset = 100.0 / 3600.0 # 100 arcsec in degrees - pts = vertex_overlay_points( - [[10.0, 20.0 + dec_offset]], 10.0, 20.0, 0, 1.0, 64, 64 - ) - # image_rotate=0: POSS has N at top of raw image but after - # the 180+roll rotation in get_display_image, here we test - # raw projection - assert len(pts) == 1 - # With image_rotate=0 and no flip, north (positive dec) goes to negative dy - assert pts[0][1] < 64 - - def test_two_vertices_produce_two_points(self): - pts = vertex_overlay_points( - [[10.0, 20.0], [10.01, 20.01]], 10.0, 20.0, 0, 1.0, 64, 64 - ) - assert len(pts) == 2 - - def test_scaling(self): - """Doubling px_per_arcsec doubles offset from center.""" - dec_off = 100.0 / 3600.0 - pts1 = vertex_overlay_points( - [[10.0, 20.0 + dec_off]], 10.0, 20.0, 0, 1.0, 64, 64 - ) - pts2 = vertex_overlay_points( - [[10.0, 20.0 + dec_off]], 10.0, 20.0, 0, 2.0, 64, 64 - ) - dx1 = pts1[0][0] - 64 - dy1 = pts1[0][1] - 64 - dx2 = pts2[0][0] - 64 - dy2 = pts2[0][1] - 64 - assert dx2 == pytest.approx(2 * dx1, abs=0.1) - assert dy2 == pytest.approx(2 * dy1, abs=0.1) diff --git a/python/tests/test_catalog_filter_cache.py b/python/tests/test_catalog_filter_cache.py index e27898f62..9de1be44b 100644 --- a/python/tests/test_catalog_filter_cache.py +++ b/python/tests/test_catalog_filter_cache.py @@ -157,6 +157,33 @@ def test_object_mutation_applied_after_mark_dirty(catalog): assert _sequences(catalog.filter_objects()) == [1, 3] +@pytest.mark.unit +def test_dynamic_content_change_refilters_only_changed_catalog(): + changed = Catalog("DYN", "dynamic") + unchanged = Catalog("STATIC", "static") + changed.add_object(_make_obj(1, mag=5.0, catalog_code="DYN")) + unchanged.add_object(_make_obj(1, mag=5.0, catalog_code="STATIC")) + catalog_filter = CatalogFilter(shared_state=FakeSharedState(), magnitude=10.0) + catalogs = Catalogs([changed, unchanged]) + catalogs.set_catalog_filter(catalog_filter) + catalogs.filter_catalogs() + changed_first = changed.get_filtered_objects() + unchanged_first = unchanged.get_filtered_objects() + dirty_time = catalog_filter.dirty_time + + changed.get_objects()[0].mag = MagnitudeObject([15.0]) + changed.invalidate_filter_cache() + catalog_filter.mark_catalog_content_dirty() + assert catalog_filter.is_dirty() + catalogs.filter_catalogs() + + assert catalog_filter.dirty_time == dirty_time + assert changed.get_filtered_objects() is not changed_first + assert changed.get_filtered_objects() == [] + assert unchanged.get_filtered_objects() is unchanged_first + assert not catalog_filter.is_dirty() + + def _make_catalogs(cat: Catalog, shared_state, **filter_kwargs) -> Catalogs: catalogs = Catalogs([cat]) catalogs.set_catalog_filter( diff --git a/python/tests/test_config_asteroid_migration.py b/python/tests/test_config_asteroid_migration.py new file mode 100644 index 000000000..c72a10a89 --- /dev/null +++ b/python/tests/test_config_asteroid_migration.py @@ -0,0 +1,47 @@ +"""One-time migration for persisted asteroid filter selections.""" + +import json + +import pytest + +from PiFinder import utils +from PiFinder.config import Config + + +def write_config_files(tmp_path, saved): + (tmp_path / "default_config.json").write_text( + json.dumps( + { + "filter.selected_catalogs": ["NGC", "MP"], + "filter.object_types": ["Gx", "AS"], + } + ) + ) + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "config.json").write_text(json.dumps(saved)) + return data_dir + + +@pytest.mark.unit +def test_existing_saved_filters_gain_asteroids_once(tmp_path, monkeypatch): + data_dir = write_config_files( + tmp_path, + { + "filter.selected_catalogs": ["NGC"], + "filter.object_types": ["Gx"], + }, + ) + monkeypatch.setattr(utils, "data_dir", data_dir) + monkeypatch.setattr(utils, "pifinder_dir", tmp_path) + + config = Config() + assert config.get_option("filter.selected_catalogs") == ["NGC", "MP"] + assert config.get_option("filter.object_types") == ["Gx", "AS"] + + # Once migrated, an explicit user choice remains authoritative. + config.set_option("filter.selected_catalogs", ["NGC"]) + config.set_option("filter.object_types", ["Gx"]) + reloaded = Config() + assert reloaded.get_option("filter.selected_catalogs") == ["NGC"] + assert reloaded.get_option("filter.object_types") == ["Gx"] diff --git a/python/tests/test_download_utils.py b/python/tests/test_download_utils.py new file mode 100644 index 000000000..a0ee567b8 --- /dev/null +++ b/python/tests/test_download_utils.py @@ -0,0 +1,103 @@ +"""Transactional runtime catalog download tests.""" + +from pathlib import Path + +import pytest +import requests + +from PiFinder import download_utils + + +class FakeResponse: + def __init__(self, chunks, headers=None): + self.chunks = chunks + self.headers = headers or {} + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size): + return iter(self.chunks) + + +@pytest.mark.unit +def test_atomic_download_replaces_only_after_validation(tmp_path, monkeypatch): + destination = tmp_path / "catalog.txt" + destination.write_bytes(b"old catalog") + monkeypatch.setattr( + download_utils.requests, + "get", + lambda *args, **kwargs: FakeResponse( + [b"new ", b"catalog"], {"content-length": "11"} + ), + ) + observed_during_validation = [] + + def validate(path: Path): + observed_during_validation.append(destination.read_bytes()) + assert path.read_bytes() == b"new catalog" + + progress = [] + result = download_utils.download_atomic( + "https://example.test/catalog", + destination, + progress_callback=progress.append, + validator=validate, + ) + assert result.success + assert observed_during_validation == [b"old catalog"] + assert destination.read_bytes() == b"new catalog" + assert progress[0] == 0 + assert progress[-1] == 100 + + +@pytest.mark.unit +def test_failed_validation_preserves_old_catalog(tmp_path, monkeypatch): + destination = tmp_path / "catalog.txt" + destination.write_bytes(b"known good") + monkeypatch.setattr( + download_utils.requests, + "get", + lambda *args, **kwargs: FakeResponse([b"broken"]), + ) + + def reject(_path): + raise ValueError("bad catalog") + + result = download_utils.download_atomic( + "https://example.test/catalog", destination, validator=reject + ) + assert not result.success + assert destination.read_bytes() == b"known good" + assert not list(tmp_path.glob("*.tmp")) + + +@pytest.mark.unit +def test_unknown_content_length_reports_indeterminate_progress(tmp_path, monkeypatch): + monkeypatch.setattr( + download_utils.requests, + "get", + lambda *args, **kwargs: FakeResponse([b"content"]), + ) + progress = [] + result = download_utils.download_atomic( + "https://example.test/catalog", + tmp_path / "catalog.txt", + progress_callback=progress.append, + ) + assert result.success + assert progress == [None, 100] + + +@pytest.mark.unit +def test_network_failure_preserves_old_catalog(tmp_path, monkeypatch): + destination = tmp_path / "catalog.txt" + destination.write_bytes(b"old") + + def fail(*args, **kwargs): + raise requests.Timeout("timed out") + + monkeypatch.setattr(download_utils.requests, "get", fail) + result = download_utils.download_atomic("https://example.test/catalog", destination) + assert not result.success + assert destination.read_bytes() == b"old" diff --git a/python/tests/test_dynamic_catalogs.py b/python/tests/test_dynamic_catalogs.py new file mode 100644 index 000000000..aae205b5e --- /dev/null +++ b/python/tests/test_dynamic_catalogs.py @@ -0,0 +1,143 @@ +"""Dynamic catalog identity, refresh retention, and progress-state tests.""" + +import datetime + +import pytest + +from PiFinder.asteroid_catalog import AsteroidCatalog +from PiFinder.catalog_base import CatalogState +from PiFinder.catalogs import Catalog +from PiFinder.comet_catalog import CometCatalog +from PiFinder.composite_object import CompositeObject + + +class ReadySharedState: + def altaz_ready(self): + return True + + def datetime(self): + return datetime.datetime(2026, 7, 15, tzinfo=datetime.timezone.utc) + + +def initialized_catalog(cls, code): + catalog = cls.__new__(cls) + Catalog.__init__(catalog, code, "test") + catalog.shared_state = ReadySharedState() + catalog._last_state = CatalogState.READY + catalog._is_downloading = False + catalog.download_progress = None + catalog.calculation_progress = None + catalog.initialized = True + return catalog + + +@pytest.mark.unit +def test_asteroid_object_uses_stable_number_and_structured_metadata(): + catalog = initialized_catalog(AsteroidCatalog, "MP") + asteroid = { + "number": 4, + "name": "Vesta", + "radec": (20.0, 5.0), + "mag": 6.5, + "earth_distance": 1.2, + "sun_distance": 2.2, + "angular_motion_arcsec_per_hour": 42.3, + "opposition_kind": "Opposition", + "opposition_date": datetime.date(2026, 10, 13), + "peak_magnitude": 6.4, + "peak_date": datetime.date(2026, 10, 12), + } + obj = catalog._make_object(asteroid) + assert obj.catalog_code == "MP" + assert obj.obj_type == "AS" + assert obj.sequence == 4 + assert obj.names == ["Vesta"] + assert obj.earth_distance_au == 1.2 + assert obj.opposition_date.isoformat() == "2026-10-13" + assert obj.description.splitlines()[:2] == [ + "Opp: 2026-10-13", + "Peak 6.4: 2026-10-12", + ] + assert obj.description.splitlines()[-1] == 'Motion: 42.3"/h' + + +@pytest.mark.unit +def test_asteroid_catalog_labels_annual_edition_instead_of_file_age(tmp_path): + catalog = initialized_catalog(AsteroidCatalog, "MP") + catalog.data_directory = tmp_path + (tmp_path / "Soft00Bright-2026.txt").touch() + assert catalog.get_data_label() == "MPC 2026" + + +@pytest.mark.unit +def test_asteroid_edition_label_uses_filename_before_gps_time(tmp_path): + catalog = initialized_catalog(AsteroidCatalog, "MP") + catalog.data_directory = tmp_path + catalog.shared_state.datetime = lambda: None + (tmp_path / "Soft00Bright-2026.txt").touch() + assert catalog.get_data_label() == "MPC 2026" + + +@pytest.mark.unit +def test_asteroid_edition_label_is_empty_without_gps_or_file(tmp_path): + catalog = initialized_catalog(AsteroidCatalog, "MP") + catalog.data_directory = tmp_path + catalog.shared_state.datetime = lambda: None + assert catalog.get_data_label() is None + + +@pytest.mark.unit +def test_asteroid_source_year_is_not_selected_before_gps(monkeypatch): + catalog = AsteroidCatalog.__new__(AsteroidCatalog) + catalog.shared_state = type("NoGpsState", (), {"altaz_ready": lambda self: False})() + monkeypatch.setattr( + "PiFinder.asteroid_catalog.asteroids.check_asteroid_download_needed", + lambda *_args, **_kwargs: pytest.fail("source year selected without GPS"), + ) + catalog._refresh_sources() + + +@pytest.mark.unit +def test_populated_asteroid_catalog_reports_download_progress(): + catalog = initialized_catalog(AsteroidCatalog, "MP") + catalog.add_object(CompositeObject(catalog_code="MP", sequence=4)) + catalog._is_downloading = True + catalog.download_progress = 42 + status = catalog.get_status() + assert catalog.get_count() == 1 + assert status.current == CatalogState.DOWNLOADING + assert status.data == {"progress": 42} + + +@pytest.mark.unit +def test_comet_refresh_keeps_old_objects_while_downloading(monkeypatch): + catalog = initialized_catalog(CometCatalog, "CM") + catalog.add_object(CompositeObject(catalog_code="CM", sequence=1)) + catalog._is_downloading = True + catalog.download_progress = 33 + status = catalog.get_status() + assert status.current == CatalogState.DOWNLOADING + assert status.data == {"progress": 33} + catalog._is_downloading = False + catalog.download_progress = None + monkeypatch.setattr( + "PiFinder.comet_catalog.comets.check_if_comet_download_needed", + lambda *_args, **_kwargs: (True, "new data"), + ) + + def download(): + assert catalog.get_count() == 1 + return False + + catalog._download_once = download + + class ImmediateThread: + def __init__(self, target, **kwargs): + self.target = target + + def start(self): + self.target() + + monkeypatch.setattr("PiFinder.comet_catalog.threading.Thread", ImmediateThread) + catalog.refresh() + assert catalog.get_count() == 1 diff --git a/python/tests/test_equipment.py b/python/tests/test_equipment.py index 40cdd6059..ffe31506b 100644 --- a/python/tests/test_equipment.py +++ b/python/tests/test_equipment.py @@ -1,7 +1,5 @@ import pytest -from PIL import Image -from PiFinder import cat_images from PiFinder.equipment import Equipment, Telescope @@ -43,47 +41,3 @@ def test_flop_only(self): active_telescope_index=0, ) assert equipment.active_telescope_image_orientation() == (False, True) - - -def _marker_image() -> Image.Image: - """A small asymmetric image so every mirror actually moves a pixel.""" - img = Image.new("RGB", (4, 4), (0, 0, 0)) - img.putpixel((0, 0), (255, 255, 255)) - return img - - -def _data(img: Image.Image): - return list(img.getdata()) - - -@pytest.mark.unit -class TestOrientImage: - """cat_images._orient_image applies flip/flop after the baseline rotate.""" - - def test_flags_apply_the_right_transposes_after_baseline(self): - src = _marker_image() - # Baseline: 180 rotate only (no roll, no mirrors) - base = cat_images._orient_image(src, 0, False, False) - - flipped = cat_images._orient_image(src, 0, True, False) - flopped = cat_images._orient_image(src, 0, False, True) - both = cat_images._orient_image(src, 0, True, True) - - # flip == top-to-bottom mirror of the baseline - assert _data(flipped) == _data(base.transpose(Image.FLIP_TOP_BOTTOM)) - # flop == left-to-right mirror of the baseline - assert _data(flopped) == _data(base.transpose(Image.FLIP_LEFT_RIGHT)) - # both == flip + flop of the baseline - assert _data(both) == _data( - base.transpose(Image.FLIP_TOP_BOTTOM).transpose(Image.FLIP_LEFT_RIGHT) - ) - - def test_each_combo_is_distinct(self): - src = _marker_image() - results = [ - _data(cat_images._orient_image(src, 0, flip, flop)) - for flip in (False, True) - for flop in (False, True) - ] - # All four flag combinations move the marker to a different place. - assert len({tuple(r) for r in results}) == 4 diff --git a/python/tests/test_gps_ubx_parser.py b/python/tests/test_gps_ubx_parser.py index 58806d456..38f5bba63 100644 --- a/python/tests/test_gps_ubx_parser.py +++ b/python/tests/test_gps_ubx_parser.py @@ -49,22 +49,24 @@ def test_svinfo_field_alignment(parser): @pytest.mark.unit -def test_svinfo_only_code_locked_counted_as_seen(parser): - # Idle channels (e.g. SBAS) and cold-start acquisition candidates - # (quality < 4 with an estimated cno) must not inflate the seen count. +def test_svinfo_seen_needs_acquired_signal(parser): + # Search candidates (quality 1, estimated cno) and idle channels must + # not inflate the seen count, but satellites being acquired (quality + # 2-3) must count so the display climbs instead of flapping to zero + # during marginal re-acquisition. payload = make_svinfo_payload( [ - (0, 14, 0x0D, 4, 26, 30, 90), - (7, 25, 0x00, 2, 9, 0, 0), - (11, 120, 0x10, 1, 0, 0, 0), - (5, 193, 0x10, 1, 0, 0, 0), + (0, 14, 0x0D, 4, 26, 30, 90), # code locked, used + (7, 25, 0x00, 2, 9, 0, 0), # signal acquired: seen + (3, 30, 0x00, 1, 12, 0, 0), # search candidate: not seen + (11, 120, 0x10, 1, 0, 0, 0), # idle SBAS channel: not seen ] ) result = parser._parse_nav_svinfo(payload) - assert result["nSat"] == 1 + assert result["nSat"] == 2 assert result["uSat"] == 1 - assert result["satellites"][0]["id"] == 14 + assert [s["id"] for s in result["satellites"]] == [14, 25] @pytest.mark.unit @@ -104,7 +106,7 @@ def test_nav_sat_used_from_svused_bit(parser): [ (0, 17, 27, 45, 180, 0x0C), # quality 4, used (0, 13, 15, -5, 300, 0x04), # quality 4, tracked but not used - (0, 25, 9, 0, 0, 0x02), # acquisition candidate: not seen + (0, 25, 9, 0, 0, 0x01), # search candidate: not seen (6, 3, 0, 0, 0, 0x01), # searching, no signal: not seen ] ) diff --git a/python/tests/test_hardware_detect.py b/python/tests/test_hardware_detect.py index 72acb546d..3b4b61a7d 100644 --- a/python/tests/test_hardware_detect.py +++ b/python/tests/test_hardware_detect.py @@ -1,10 +1,10 @@ """ Unit tests for hardware_detect. -The I2C bus is faked: a stand-in ``board`` whose ``I2C().scan()`` returns -a chosen address list, so the BQ25895 presence probe can be exercised -both ways without real hardware. The no-blinka path (board is None) must -degrade to all-False capabilities. +The I2C bus is faked: a stand-in ``get_i2c`` factory whose bus ``scan()`` +returns a chosen address list, so the BQ25895 presence probe can be +exercised both ways without real hardware. The no-blinka path (get_i2c is +None) must degrade to all-False capabilities. """ import pytest @@ -29,46 +29,47 @@ def scan(self): return list(self._addresses) -class FakeBoard: - """Stand-in for the ``board`` module: ``I2C()`` returns a FakeI2C.""" +def fake_get_i2c(addresses): + """Stand-in for ``i2c_bus.get_i2c``: returns a FakeI2C factory.""" - def __init__(self, addresses): - self._addresses = addresses + def factory(): + return FakeI2C(addresses) - def I2C(self): - return FakeI2C(self._addresses) + return factory @pytest.mark.unit def test_i2c_present_true(monkeypatch): - monkeypatch.setattr(hardware_detect, "board", FakeBoard([0x28, BQ25895_ADDRESS])) + monkeypatch.setattr( + hardware_detect, "get_i2c", fake_get_i2c([0x28, BQ25895_ADDRESS]) + ) assert hardware_detect.i2c_present(BQ25895_ADDRESS) is True @pytest.mark.unit def test_i2c_present_false(monkeypatch): - monkeypatch.setattr(hardware_detect, "board", FakeBoard([0x28, 0x77])) + monkeypatch.setattr(hardware_detect, "get_i2c", fake_get_i2c([0x28, 0x77])) assert hardware_detect.i2c_present(BQ25895_ADDRESS) is False @pytest.mark.unit def test_detect_capabilities_present(monkeypatch): - monkeypatch.setattr(hardware_detect, "board", FakeBoard([BQ25895_ADDRESS])) + monkeypatch.setattr(hardware_detect, "get_i2c", fake_get_i2c([BQ25895_ADDRESS])) caps = hardware_detect.detect_capabilities() assert caps.has_bq25895 is True @pytest.mark.unit def test_detect_capabilities_absent(monkeypatch): - monkeypatch.setattr(hardware_detect, "board", FakeBoard([0x28])) + monkeypatch.setattr(hardware_detect, "get_i2c", fake_get_i2c([0x28])) caps = hardware_detect.detect_capabilities() assert caps.has_bq25895 is False @pytest.mark.unit def test_detect_capabilities_no_blinka(monkeypatch): - """No blinka / no bus (board is None) -> all-False, no exception.""" - monkeypatch.setattr(hardware_detect, "board", None) + """No blinka / no bus (get_i2c is None) -> all-False, no exception.""" + monkeypatch.setattr(hardware_detect, "get_i2c", None) caps = hardware_detect.detect_capabilities() assert caps.has_bq25895 is False # The raw probe surfaces the failure; detect_capabilities swallows it. diff --git a/python/tests/test_limiting_magnitude.py b/python/tests/test_limiting_magnitude.py new file mode 100644 index 000000000..0d5ab6ccd --- /dev/null +++ b/python/tests/test_limiting_magnitude.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +Unit tests for limiting magnitude calculations using Feijth & Comello formula +""" + +import pytest +from PiFinder.object_images.gaia_chart import GaiaChartGenerator + + +class TestFeijthComelloFormula: + """Test the Feijth & Comello limiting magnitude formula""" + + def test_reference_calculation(self): + """ + Test with Schaefer's reference values + + Reference from astrobasics.de: + If Schaefer's result is used with mv = 6.04, D = 25, d = 4, M = 400 + and t = 0.54 the following limiting magnitude results: 13.36 + + Formula: mg = mv - 2 + 2.5 × log₁₀(√(D² - d²) × M × t) + """ + mv = 6.04 # Naked eye limiting magnitude + D = 25.0 # Aperture in cm + d = 4.0 # Obstruction diameter in cm + M = 400.0 # Magnification + t = 0.54 # Transmission + + result = GaiaChartGenerator.feijth_comello_limiting_magnitude(mv, D, d, M, t) + + # Should be 13.36 according to reference (allow 0.1 mag tolerance) + assert abs(result - 13.36) < 0.1, f"Expected ~13.36, got {result:.2f}" + + def test_unobstructed_telescope(self): + """Test with no central obstruction (refractor/unobstructed Newtonian)""" + mv = 6.0 + D = 20.0 # 200mm aperture + d = 0.0 # No obstruction + M = 100.0 + t = 0.85 + + result = GaiaChartGenerator.feijth_comello_limiting_magnitude(mv, D, d, M, t) + + # Should give reasonable result (12-14 range for 200mm scope) + assert 10.0 < result < 15.0, f"Result {result:.2f} outside expected range" + + def test_higher_magnification_improves_lm(self): + """ + Test that higher magnification improves limiting magnitude + (darkens sky background, improving contrast) + """ + mv = 6.0 + D = 20.0 + d = 0.0 + t = 0.85 + + lm_40x = GaiaChartGenerator.feijth_comello_limiting_magnitude(mv, D, d, 40.0, t) + lm_100x = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, d, 100.0, t + ) + lm_200x = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, d, 200.0, t + ) + + # Higher magnification should give better (larger number) limiting magnitude + assert lm_100x > lm_40x, f"100x ({lm_100x:.2f}) should be > 40x ({lm_40x:.2f})" + assert ( + lm_200x > lm_100x + ), f"200x ({lm_200x:.2f}) should be > 100x ({lm_100x:.2f})" + + def test_larger_aperture_improves_lm(self): + """Test that larger aperture improves limiting magnitude""" + mv = 6.0 + d = 0.0 + M = 100.0 + t = 0.85 + + lm_80mm = GaiaChartGenerator.feijth_comello_limiting_magnitude(mv, 8.0, d, M, t) + lm_150mm = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, 15.0, d, M, t + ) + lm_250mm = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, 25.0, d, M, t + ) + + # Larger aperture should give better limiting magnitude + assert ( + lm_150mm > lm_80mm + ), f"150mm ({lm_150mm:.2f}) should be > 80mm ({lm_80mm:.2f})" + assert ( + lm_250mm > lm_150mm + ), f"250mm ({lm_250mm:.2f}) should be > 150mm ({lm_150mm:.2f})" + + def test_obstruction_reduces_lm(self): + """Test that central obstruction reduces limiting magnitude""" + mv = 6.0 + D = 20.0 + M = 100.0 + t = 0.85 + + lm_no_obstruction = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, 0.0, M, t + ) + lm_with_obstruction = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, 5.0, M, t + ) + + # Obstruction should reduce limiting magnitude + assert ( + lm_no_obstruction > lm_with_obstruction + ), f"Unobstructed ({lm_no_obstruction:.2f}) should be > obstructed ({lm_with_obstruction:.2f})" + + def test_better_transmission_improves_lm(self): + """Test that better transmission improves limiting magnitude""" + mv = 6.0 + D = 20.0 + d = 0.0 + M = 100.0 + + lm_poor_transmission = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, d, M, 0.50 + ) + lm_good_transmission = GaiaChartGenerator.feijth_comello_limiting_magnitude( + mv, D, d, M, 0.85 + ) + + # Better transmission should give better limiting magnitude + assert ( + lm_good_transmission > lm_poor_transmission + ), f"Good transmission ({lm_good_transmission:.2f}) should be > poor ({lm_poor_transmission:.2f})" + + def test_darker_sky_improves_naked_eye_lm(self): + """ + Test that darker sky (higher mv) improves telescopic limiting magnitude + Since telescopic LM builds on naked eye LM + """ + D = 20.0 + d = 0.0 + M = 100.0 + t = 0.85 + + lm_bright_sky = GaiaChartGenerator.feijth_comello_limiting_magnitude( + 5.0, D, d, M, t + ) + lm_dark_sky = GaiaChartGenerator.feijth_comello_limiting_magnitude( + 6.5, D, d, M, t + ) + + # Darker sky should give better limiting magnitude + assert ( + lm_dark_sky > lm_bright_sky + ), f"Dark sky ({lm_dark_sky:.2f}) should be > bright sky ({lm_bright_sky:.2f})" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/tests/test_menu_struct.py b/python/tests/test_menu_struct.py index 39894427d..12fee0c33 100644 --- a/python/tests/test_menu_struct.py +++ b/python/tests/test_menu_struct.py @@ -47,6 +47,7 @@ def test_important_catalog_entries_exist(): assert "Planets" in catalog_names assert "Comets" in catalog_names + assert "Asteroids" in catalog_names assert "NGC" in catalog_names assert "Messier" in catalog_names diff --git a/python/tests/test_nearby.py b/python/tests/test_nearby.py index 0316ab613..5e3e095d8 100644 --- a/python/tests/test_nearby.py +++ b/python/tests/test_nearby.py @@ -1,19 +1,23 @@ """ -Unit tests for ``ClosestObjectsFinder.get_objects_within_radius`` -- the -radius (angular-distance) query the chart uses to find catalog objects that -fall inside the current field, distinct from the k-NN ``get_closest_objects`` -used by the object-list "Nearby" sort. - -The BallTree is built from ``[ra_rad, dec_rad]`` rows with the haversine -metric (the pre-existing convention). Along the ``ra=0`` meridian the haversine -distance reduces to exactly ``|dec|`` in radians, so these tests place objects -at ``ra=0`` and vary dec to assert an exact great-circle radius in degrees. +Unit tests for ``ClosestObjectsFinder`` -- both the radius (angular-distance) +query the chart uses to find catalog objects inside the current field, and the +k-NN ``get_closest_objects`` behind the object-list "Nearby" sort. + +The BallTree is built from ``[dec_rad, ra_rad]`` rows with the haversine +metric, because sklearn's haversine reads dimension 0 as latitude. Objects that +share a meridian are the one case where the axis order cannot be observed +(the metric degenerates to ``|dec1 - dec2|``), so every ordering assertion here +places objects at *different* RAs and checks against an independently computed +great-circle separation. High-declination cases are included: that is where a +swapped axis order goes most badly wrong. See ADR 0029. """ +import math + import pytest from PiFinder.composite_object import CompositeObject -from PiFinder.nearby import ClosestObjectsFinder +from PiFinder.nearby import ClosestObjectsFinder, great_circle_degrees def _obj(object_id, ra, dec, catalog_code="NGC"): @@ -22,6 +26,15 @@ def _obj(object_id, ra, dec, catalog_code="NGC"): ) +def _separation(ra_a, dec_a, ra_b, dec_b): + """Great-circle separation in degrees, computed independently of nearby.py.""" + ra_a, dec_a, ra_b, dec_b = (math.radians(v) for v in (ra_a, dec_a, ra_b, dec_b)) + cos_sep = math.sin(dec_a) * math.sin(dec_b) + math.cos(dec_a) * math.cos( + dec_b + ) * math.cos(ra_a - ra_b) + return math.degrees(math.acos(max(-1.0, min(1.0, cos_sep)))) + + @pytest.mark.unit class TestGetObjectsWithinRadius: def test_empty_finder_returns_empty(self): @@ -68,3 +81,88 @@ def test_deduplicates_by_object_id_with_catalog_precedence(self): result = finder.get_objects_within_radius(0.0, 0.0, 1.0) assert len(result) == 1 assert result[0].catalog_code == "M" + + def test_radius_is_angular_not_per_axis_at_high_dec(self): + # At dec +80 a 10 deg RA offset is only ~1.7 deg on the sky, while a + # 10 deg dec offset is a full 10 deg. A per-axis or axis-swapped + # metric cannot get both of these right at once. + finder = ClosestObjectsFinder() + pointing = (0.0, 80.0) + close_in_ra = _obj(1, 10.0, 80.0) + far_in_dec = _obj(2, 0.0, 70.0) + finder.calculate_objects_balltree([close_in_ra, far_in_dec]) + + assert _separation(*pointing, 10.0, 80.0) < 2.0 + assert _separation(*pointing, 0.0, 70.0) == pytest.approx(10.0) + + result = finder.get_objects_within_radius(*pointing, 5.0) + assert {o.object_id for o in result} == {1} + + +@pytest.mark.unit +class TestGetClosestObjects: + def test_ranks_by_true_angular_separation(self): + # The case that exposes a swapped (lat, lon) axis order: object 1 is + # genuinely closest, but is 10 deg away in RA while object 2 is 10 deg + # away in dec. Swapping the axes ranks object 2 first. + finder = ClosestObjectsFinder() + pointing = (0.0, 60.0) + objects = [ + _obj(1, 10.0, 60.0), # ~5.0 deg + _obj(2, 0.0, 50.0), # 10.0 deg + _obj(3, 90.0, 60.0), # ~41.4 deg + _obj(4, 180.0, 62.0), # 58.0 deg + ] + finder.calculate_objects_balltree(objects) + + expected = [ + o.object_id + for o in sorted(objects, key=lambda o: _separation(*pointing, o.ra, o.dec)) + ] + assert expected == [1, 2, 3, 4] + + result = finder.get_closest_objects(*pointing) + assert [o.object_id for o in result] == expected + + def test_n_caps_the_result(self): + finder = ClosestObjectsFinder() + objects = [_obj(i, i * 3.0, 30.0) for i in range(1, 11)] + finder.calculate_objects_balltree(objects) + + result = finder.get_closest_objects(3.0, 30.0, n=3) + assert [o.object_id for o in result] == [1, 2, 3] + + def test_n_larger_than_catalog_is_clamped(self): + finder = ClosestObjectsFinder() + finder.calculate_objects_balltree([_obj(1, 0.0, 0.0), _obj(2, 5.0, 5.0)]) + + assert len(finder.get_closest_objects(0.0, 0.0, n=100)) == 2 + + def test_empty_finder_returns_empty(self): + assert ClosestObjectsFinder().get_closest_objects(0.0, 0.0) == [] + + +@pytest.mark.unit +class TestGreatCircleDegrees: + @pytest.mark.parametrize( + "ra_a, dec_a, ra_b, dec_b", + [ + (0.0, 0.0, 0.0, 10.0), + (0.0, 60.0, 10.0, 60.0), + (359.5, 0.0, 0.5, 0.0), # across the RA wrap + (10.0, 89.0, 190.0, 89.0), # over the pole + (12.0, -30.0, 200.0, 45.0), + ], + ) + def test_matches_independent_formula(self, ra_a, dec_a, ra_b, dec_b): + assert great_circle_degrees(ra_a, dec_a, ra_b, dec_b) == pytest.approx( + _separation(ra_a, dec_a, ra_b, dec_b), abs=1e-9 + ) + + def test_ra_wrap_is_a_short_hop_not_a_full_turn(self): + # The per-axis test this replaces read abs(359.5 - 0.5) == 359. + assert great_circle_degrees(359.5, 0.0, 0.5, 0.0) == pytest.approx(1.0) + + def test_ra_degrees_shrink_with_declination(self): + assert great_circle_degrees(0.0, 80.0, 1.0, 80.0) < 0.2 + assert great_circle_degrees(0.0, 0.0, 1.0, 0.0) == pytest.approx(1.0) diff --git a/python/tests/test_net_policy.py b/python/tests/test_net_policy.py new file mode 100644 index 000000000..d9f0ecde3 --- /dev/null +++ b/python/tests/test_net_policy.py @@ -0,0 +1,135 @@ +import pytest + +from PiFinder.net_policy_core import ( + AP_DOWN, + AP_UP, + CLIENT_RETRY_SECONDS, + GRACE_SECONDS, + PolicyState, + Snapshot, + decide, +) + + +def _snap(**overrides): + snap = Snapshot( + forced_ap=False, + eth_connected=False, + wifi_client_active=False, + ap_active=False, + ap_stations=0, + ) + for key, value in overrides.items(): + setattr(snap, key, value) + return snap + + +@pytest.mark.unit +class TestForcedAp: + def test_brings_ap_up(self): + assert decide(_snap(forced_ap=True), PolicyState(), 100.0) == AP_UP + + def test_noop_when_already_up(self): + snap = _snap(forced_ap=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + def test_forced_ap_wins_over_ethernet(self): + snap = _snap(forced_ap=True, eth_connected=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + +@pytest.mark.unit +class TestWiredPriority: + def test_ap_dropped_when_wired(self): + snap = _snap(eth_connected=True, ap_active=True) + assert decide(snap, PolicyState(), 100.0) == AP_DOWN + + def test_noop_when_wired_and_no_ap(self): + assert decide(_snap(eth_connected=True), PolicyState(), 100.0) is None + + def test_wired_resets_grace_timer(self): + state = PolicyState(disconnected_since=50.0) + decide(_snap(eth_connected=True), state, 100.0) + assert state.disconnected_since is None + + +@pytest.mark.unit +class TestWifiClient: + def test_noop_when_client_connected(self): + snap = _snap(wifi_client_active=True) + assert decide(snap, PolicyState(), 100.0) is None + + def test_client_resets_grace_timer(self): + state = PolicyState(disconnected_since=50.0) + decide(_snap(wifi_client_active=True), state, 100.0) + assert state.disconnected_since is None + + +@pytest.mark.unit +class TestGraceFallback: + def test_no_immediate_ap(self): + state = PolicyState() + assert decide(_snap(), state, 100.0) is None + assert state.disconnected_since == 100.0 + + def test_ap_up_after_grace(self): + state = PolicyState() + decide(_snap(), state, 100.0) + assert decide(_snap(), state, 100.0 + GRACE_SECONDS) == AP_UP + + def test_no_ap_before_grace(self): + state = PolicyState() + decide(_snap(), state, 100.0) + assert decide(_snap(), state, 100.0 + GRACE_SECONDS - 1) is None + + def test_reconnect_within_grace_cancels_fallback(self): + state = PolicyState() + decide(_snap(), state, 100.0) + decide(_snap(wifi_client_active=True), state, 110.0) + # Disconnect again much later: the grace clock must restart. + assert decide(_snap(), state, 500.0) is None + assert state.disconnected_since == 500.0 + + +@pytest.mark.unit +class TestIdleApClientRetry: + def _aged_state(self, now): + """State as it looks after the AP has been up for a while.""" + return PolicyState(last_client_retry=now - CLIENT_RETRY_SECONDS) + + def test_idle_ap_retries_client(self): + now = 1000.0 + snap = _snap(ap_active=True, ap_stations=0) + assert decide(snap, self._aged_state(now), now) == AP_DOWN + + def test_occupied_ap_never_dropped(self): + now = 1000.0 + snap = _snap(ap_active=True, ap_stations=2) + assert decide(snap, self._aged_state(now), now) is None + + def test_no_retry_before_interval(self): + now = 1000.0 + state = PolicyState(last_client_retry=now - CLIENT_RETRY_SECONDS + 5) + assert decide(_snap(ap_active=True), state, now) is None + + def test_first_observation_arms_but_does_not_retry(self): + # An externally-activated AP (e.g. at boot) must not be dropped on + # the daemon's first look; the retry clock starts then. + state = PolicyState() + assert decide(_snap(ap_active=True), state, 1000.0) is None + assert state.last_client_retry == 1000.0 + + def test_retry_then_fallback_restores_ap(self): + # Full cycle: idle AP dropped for a retry, no client appears, + # grace expires, AP comes back. + now = 1000.0 + state = self._aged_state(now) + assert decide(_snap(ap_active=True, ap_stations=0), state, now) == AP_DOWN + assert decide(_snap(), state, now + GRACE_SECONDS) == AP_UP + + def test_retry_then_client_joins(self): + now = 1000.0 + state = self._aged_state(now) + assert decide(_snap(ap_active=True, ap_stations=0), state, now) == AP_DOWN + assert decide(_snap(wifi_client_active=True), state, now + 10) is None + assert state.disconnected_since is None diff --git a/python/tests/test_nixos_migration_wifi.py b/python/tests/test_nixos_migration_wifi.py deleted file mode 100644 index 9b621fb45..000000000 --- a/python/tests/test_nixos_migration_wifi.py +++ /dev/null @@ -1,321 +0,0 @@ -import re - -import pytest - -from PiFinder.nixos_migration_wifi import ( - Network, - _parse_ssid, - build_keyfile, - emit_keyfiles, - escape_keyfile_value, - parse_wpa_supplicant_conf, - sanitize_filename, - ssid_to_bytelist, -) - - -UUID_V4_RE = re.compile( - r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" -) - - -@pytest.mark.unit -class TestParseWpaSupplicantConf: - def test_empty(self): - assert parse_wpa_supplicant_conf("") == [] - - def test_single_wpa_network(self): - conf = """ - ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev - update_config=1 - country=BE - - network={ - ssid="APME" - psk="hunter12" - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("APME", "hunter12")] - - def test_open_network_has_no_psk(self): - conf = """ - network={ - ssid="OpenNet" - key_mgmt=NONE - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("OpenNet", None)] - - def test_multiple_networks_preserve_order(self): - conf = """ - network={ - ssid="first" - psk="pw1" - } - network={ - ssid="second" - psk="pw2" - } - network={ - ssid="third" - psk="pw3" - } - """ - nets = parse_wpa_supplicant_conf(conf) - assert [n.ssid for n in nets] == ["first", "second", "third"] - - def test_hex_psk_preserved_verbatim(self): - hex_psk = "a" * 64 - conf = f""" - network={{ - ssid="HexPsk" - psk={hex_psk} - }} - """ - result = parse_wpa_supplicant_conf(conf) - assert result == [Network("HexPsk", hex_psk)] - - def test_unquoted_ssid_kept(self): - # Some configs use unquoted SSIDs for short alphanumerics. - conf = """ - network={ - ssid=plain - psk="pw" - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("plain", "pw")] - - def test_ssid_with_special_chars(self): - conf = """ - network={ - ssid="0x20" - psk="pw" - } - network={ - ssid="hackerspace.gent" - psk="pw2" - } - """ - nets = parse_wpa_supplicant_conf(conf) - assert nets == [ - Network("0x20", "pw"), - Network("hackerspace.gent", "pw2"), - ] - - def test_network_without_ssid_skipped(self): - conf = """ - network={ - psk="orphan" - } - network={ - ssid="valid" - psk="pw" - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("valid", "pw")] - - def test_comments_and_blank_lines_ignored(self): - conf = """ - # global comment - ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev - - network={ - # inner comment - ssid="commented" - psk="pw" # trailing comment - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("commented", "pw")] - - -@pytest.mark.unit -class TestSsidToBytelist: - def test_ascii(self): - assert ssid_to_bytelist("APME") == "65;80;77;69;" - - def test_bytes_are_decimal(self): - # The whole point of this function — NM only parses DECIMAL byte - # lists; hex (with or without 0x prefix) is silently kept as a - # literal-string SSID and the network name is mangled. - assert ssid_to_bytelist("apollo") == "97;112;111;108;108;111;" - - def test_utf8_bytes(self): - # SSID can contain non-ASCII; should be encoded as utf-8 bytes - assert ssid_to_bytelist("é") == "195;169;" - - def test_empty(self): - assert ssid_to_bytelist("") == "" - - def test_non_utf8_bytes_round_trip(self): - # A hex wpa ssid holding non-UTF-8 bytes survives parse -> encode. - assert ssid_to_bytelist(_parse_ssid("ff00")) == "255;0;" - - -@pytest.mark.unit -class TestParseSsidValue: - def test_quoted_is_plain_string(self): - assert _parse_ssid('"apollo"') == "apollo" - - def test_unquoted_hex_is_decoded(self): - # wpa_supplicant stores SSIDs with special characters as unquoted - # hex strings; these must be decoded, not used as the name. - assert _parse_ssid("61706f6c6c6f") == "apollo" - - def test_quoted_hex_lookalike_stays_verbatim(self): - # A network genuinely NAMED like a hex string is quoted in - # wpa_supplicant, so it must not be decoded. - assert _parse_ssid('"61706f"') == "61706f" - - def test_non_hex_unquoted_stays_verbatim(self): - assert _parse_ssid("abc") == "abc" - - def test_hex_ssid_end_to_end(self): - conf = """ - network={ - ssid=61706f6c6c6f - psk="hunter12" - } - """ - assert parse_wpa_supplicant_conf(conf) == [Network("apollo", "hunter12")] - - -@pytest.mark.unit -class TestEscapeKeyfileValue: - def test_plain(self): - assert escape_keyfile_value("hunter12") == "hunter12" - - def test_semicolon_escaped(self): - assert escape_keyfile_value("a;b") == "a\\;b" - - def test_backslash_escaped(self): - assert escape_keyfile_value("a\\b") == "a\\\\b" - - def test_backslash_before_semicolon(self): - # Backslash must be escaped first so we don't double-escape the - # semicolon escape sequence we just produced. - assert escape_keyfile_value("\\;") == "\\\\\\;" - - -@pytest.mark.unit -class TestSanitizeFilename: - def test_plain(self): - assert sanitize_filename("APME") == "APME" - - def test_dot_preserved(self): - assert sanitize_filename("hackerspace.gent") == "hackerspace.gent" - - def test_slash_replaced(self): - assert sanitize_filename("a/b") == "a_b" - - def test_pathy_chars_replaced(self): - assert sanitize_filename("../etc/passwd") == ".._etc_passwd" - - def test_empty_becomes_wifi(self): - assert sanitize_filename("") == "wifi" - - def test_dot_becomes_wifi(self): - assert sanitize_filename(".") == "wifi" - - def test_dotdot_becomes_wifi(self): - assert sanitize_filename("..") == "wifi" - - -@pytest.mark.unit -class TestBuildKeyfile: - def test_contains_required_sections(self): - body = build_keyfile("APME", "hunter12", connection_uuid="fixed-uuid") - assert "[connection]" in body - assert "[wifi]" in body - assert "[wifi-security]" in body - assert "[ipv4]" in body - assert "[ipv6]" in body - - def test_uuid_present(self): - body = build_keyfile("APME", "pw", connection_uuid="abc-123") - assert "uuid=abc-123" in body - - def test_uuid_generated_when_not_provided(self): - body = build_keyfile("APME", "pw") - match = re.search(r"^uuid=(.+)$", body, re.MULTILINE) - assert match - assert UUID_V4_RE.match(match.group(1)) - - def test_ssid_encoded_as_decimal_bytelist(self): - body = build_keyfile("APME", "pw", connection_uuid="x") - assert "ssid=65;80;77;69;" in body - - def test_open_network_omits_security(self): - body = build_keyfile("OpenNet", None, connection_uuid="x") - assert "[wifi-security]" not in body - assert "key-mgmt" not in body - assert "psk=" not in body - - def test_empty_psk_treated_as_open(self): - body = build_keyfile("OpenNet", "", connection_uuid="x") - assert "[wifi-security]" not in body - - def test_psk_with_semicolon_escaped(self): - body = build_keyfile("S", "p;w", connection_uuid="x") - assert "psk=p\\;w" in body - - def test_psk_with_backslash_escaped(self): - body = build_keyfile("S", "p\\w", connection_uuid="x") - assert "psk=p\\\\w" in body - - def test_ipv4_method_auto(self): - body = build_keyfile("S", "pw", connection_uuid="x") - assert "[ipv4]\nmethod=auto" in body - - def test_id_uses_ssid(self): - body = build_keyfile("MyNet", "pw", connection_uuid="x") - assert "id=MyNet" in body - - -@pytest.mark.unit -class TestEmitKeyfiles: - def test_writes_one_file_per_network(self, tmp_path): - nets = [Network("a", "pw"), Network("b", None), Network("c", "pw3")] - written = emit_keyfiles(nets, tmp_path) - assert len(written) == 3 - assert {p.name for p in written} == { - "a.nmconnection", - "b.nmconnection", - "c.nmconnection", - } - - def test_file_mode_is_600(self, tmp_path): - emit_keyfiles([Network("a", "pw")], tmp_path) - mode = (tmp_path / "a.nmconnection").stat().st_mode & 0o777 - assert mode == 0o600 - - def test_creates_output_dir(self, tmp_path): - target = tmp_path / "nested" / "wifi" - emit_keyfiles([Network("a", "pw")], target) - assert (target / "a.nmconnection").exists() - - def test_collision_suffix(self, tmp_path): - # Two SSIDs that sanitize to the same filename - nets = [Network("a/b", "pw"), Network("a.b", "pw")] - # sanitize: "a/b" -> "a_b", "a.b" -> "a.b" (different) — pick another pair - nets = [Network("a/b", "pw"), Network("a;b", "pw")] - # Both sanitize to "a_b" - written = emit_keyfiles(nets, tmp_path) - names = sorted(p.name for p in written) - assert names == ["a_b.nmconnection", "a_b_2.nmconnection"] - - def test_each_file_gets_unique_uuid(self, tmp_path): - nets = [Network("a", "pw"), Network("b", "pw")] - emit_keyfiles(nets, tmp_path) - u1 = (tmp_path / "a.nmconnection").read_text() - u2 = (tmp_path / "b.nmconnection").read_text() - uuid1 = re.search(r"^uuid=(.+)$", u1, re.MULTILINE).group(1) - uuid2 = re.search(r"^uuid=(.+)$", u2, re.MULTILINE).group(1) - assert uuid1 != uuid2 - assert UUID_V4_RE.match(uuid1) - assert UUID_V4_RE.match(uuid2) - - def test_deterministic_with_injected_uuid(self, tmp_path): - nets = [Network("a", "pw")] - emit_keyfiles(nets, tmp_path, uuid_factory=lambda: "fixed-uuid-1234") - body = (tmp_path / "a.nmconnection").read_text() - assert "uuid=fixed-uuid-1234" in body diff --git a/python/tests/test_nixos_upgrade.py b/python/tests/test_nixos_upgrade.py new file mode 100644 index 000000000..8de7c9c3b --- /dev/null +++ b/python/tests/test_nixos_upgrade.py @@ -0,0 +1,433 @@ +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"] + + +def _setup_activation(tmp_path, monkeypatch, camera_type, specialisations): + """Fixture for activate_system: fake store path, persisted camera, and + captured command()/arm_trial_marker() calls.""" + store = tmp_path / "store" / "new-system" + store.mkdir(parents=True) + (store / "bin").mkdir() + for cam in specialisations: + (store / "specialisation" / cam / "bin").mkdir(parents=True) + + camera_file = tmp_path / "camera-type" + if camera_type is not None: + camera_file.write_text(camera_type + "\n") + monkeypatch.setattr(nixos_upgrade, "CAMERA_TYPE_FILE", camera_file) + + calls = [] + monkeypatch.setattr( + nixos_upgrade, "command", lambda args, **kw: calls.append(list(args)) + ) + armed = [] + monkeypatch.setattr(nixos_upgrade, "arm_trial_marker", armed.append) + monkeypatch.setattr(nixos_upgrade, "write_status", lambda *_a, **_k: None) + return store, calls, armed + + +@pytest.mark.unit +def test_activate_boots_specialisation_even_when_old_base_matches( + tmp_path, monkeypatch +): + """Regression: device persisted imx477 while upgrading from an imx477-BASE + build onto an imx462-base build. Comparing against the old build's base + (--default-camera imx477) concluded 'camera is the base' and booted the + new imx462 base, killing the camera. The decision must instead ask the + NEW store path whether it carries a specialisation for the camera.""" + store, calls, armed = _setup_activation(tmp_path, monkeypatch, "imx477", ["imx477"]) + + nixos_upgrade.activate_system(str(store), "imx477") + + spec = store / "specialisation" / "imx477" + assert armed == [spec] + assert [str(spec / "bin/switch-to-configuration"), "boot"] in calls + assert calls[-1][-1] == "imx477" + + +@pytest.mark.unit +def test_activate_base_branch_when_no_specialisation(tmp_path, monkeypatch): + store, calls, armed = _setup_activation(tmp_path, monkeypatch, "imx462", ["imx477"]) + + nixos_upgrade.activate_system(str(store), "imx477") + + assert armed == [nixos_upgrade.Path(str(store))] + assert [str(store / "bin/switch-to-configuration"), "boot"] in calls + assert calls[-1][-1] == "imx462" + + +@pytest.mark.unit +def test_set_extlinux_default_prefers_new_builds_helper(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr( + nixos_upgrade, "command", lambda args, **kw: calls.append(list(args)) + ) + store = tmp_path / "sys" + helper = store / "sw" / "bin" / "set-extlinux-default" + helper.parent.mkdir(parents=True) + helper.write_text("#!/bin/sh\n") + + nixos_upgrade.set_extlinux_default("imx477", str(store)) + assert calls[-1] == [str(helper), "imx477"] + + nixos_upgrade.set_extlinux_default("imx477", str(tmp_path / "missing")) + assert calls[-1] == ["set-extlinux-default", "imx477"] diff --git a/python/tests/test_obj_types_docs.py b/python/tests/test_obj_types_docs.py index 73a97eda3..38e678fc6 100644 --- a/python/tests/test_obj_types_docs.py +++ b/python/tests/test_obj_types_docs.py @@ -14,8 +14,9 @@ from pathlib import Path import pytest +from PIL import Image -from PiFinder.obj_types import OBJ_TYPES +from PiFinder.obj_types import OBJ_TYPE_MARKERS, OBJ_TYPES _ROOT = Path(__file__).resolve().parents[2] _README = _ROOT / "docs/ax/catalog/obslist-formats/README.md" @@ -58,3 +59,13 @@ def test_default_config_object_types_match_obj_types(): assert set(config["filter.object_types"]) == set( OBJ_TYPES ), "default_config.json 'filter.object_types' is out of sync with OBJ_TYPES." + + +@pytest.mark.unit +def test_marker_mappings_have_11px_assets_and_asteroid_is_distinct(): + assert OBJ_TYPE_MARKERS["AS"] != OBJ_TYPE_MARKERS["Ast"] + for marker_name in set(OBJ_TYPE_MARKERS.values()): + marker_path = _ROOT / "markers" / f"mrk_{marker_name}.png" + assert marker_path.exists(), f"missing marker asset: {marker_path.name}" + with Image.open(marker_path) as marker: + assert marker.size == (11, 11) diff --git a/python/tests/test_object_image_orientation.py b/python/tests/test_object_image_orientation.py new file mode 100644 index 000000000..eb97c110b --- /dev/null +++ b/python/tests/test_object_image_orientation.py @@ -0,0 +1,153 @@ +"""Sky-orientation contract shared by POSS images and Gaia charts.""" + +from types import SimpleNamespace + +import numpy as np +import pytest +from PIL import Image, ImageDraw + +from PiFinder.object_images.gaia_chart import GaiaChartGenerator +from PiFinder.object_images.image_utils import eyepiece_image_rotation +from PiFinder.object_images.poss_provider import POSSImageProvider + + +RESOLUTION = (100, 100) +CENTER_RA = 120.0 +CENTER_DEC = 0.0 +FOV_DEG = 1.0 +SKY_OFFSET_DEG = 0.25 + + +def _marker_position(image): + red = np.asarray(image)[:, :, 0] + ys, xs = np.nonzero(red) + assert len(xs), "orientation marker was lost during rendering" + weights = red[ys, xs].astype(float) + return np.average(xs, weights=weights), np.average(ys, weights=weights) + + +def _sky_coordinate(direction): + if direction == "north": + return CENTER_RA, CENTER_DEC + SKY_OFFSET_DEG + return CENTER_RA + SKY_OFFSET_DEG, CENTER_DEC + + +def _render_gaia_marker(direction, roll, flip, flop, eyepiece_baseline=True): + ra, dec = _sky_coordinate(direction) + stars = np.array([[ra, dec, 1.0]]) + generator = GaiaChartGenerator.__new__(GaiaChartGenerator) + return generator.render_chart( + stars, + CENTER_RA, + CENTER_DEC, + FOV_DEG, + RESOLUTION, + rotation=eyepiece_image_rotation(roll) if eyepiece_baseline else roll, + flip_image=flip, + flop_image=flop, + ) + + +def _render_poss_marker(monkeypatch, direction, roll, flip, flop, obstruction): + # A survey plate is North-up/East-left before telescope transforms. + source = Image.new("RGB", (1024, 1024)) + offset = round(1024 * SKY_OFFSET_DEG / FOV_DEG) + cx = cy = 512 + if direction == "north": + marker_center = (cx, cy - offset) + else: + marker_center = (cx - offset, cy) + draw = ImageDraw.Draw(source) + x, y = marker_center + draw.rectangle((x - 3, y - 3, x + 3, y + 3), fill="white") + + monkeypatch.setattr( + "PiFinder.object_images.poss_provider.Image.open", + lambda _path: source.copy(), + ) + provider = POSSImageProvider() + monkeypatch.setattr(provider, "_resolve_image_name", lambda *_args, **_kwargs: "x") + + telescope = SimpleNamespace( + obstruction_perc=obstruction, + flip_image=flip, + flop_image=flop, + ) + config = SimpleNamespace( + equipment=SimpleNamespace(active_telescope=telescope), + ) + display = SimpleNamespace( + fov_res=RESOLUTION[0], + resX=RESOLUTION[0], + resY=RESOLUTION[1], + colors=SimpleNamespace( + red_image=Image.new("RGB", RESOLUTION, (255, 0, 0)), + ), + ) + catalog_object = SimpleNamespace(catalog_code="T", sequence="1", names=[]) + return provider.get_image( + catalog_object, + "test", + FOV_DEG, + roll, + display, + burn_in=False, + config_object=config, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("direction", ["north", "east"]) +@pytest.mark.parametrize("roll", [0.0, 37.0, 90.0, 180.0, 270.0]) +@pytest.mark.parametrize("obstruction", [0.0, 17.0]) +@pytest.mark.parametrize( + ("flip", "flop"), + [(False, False), (True, False), (False, True), (True, True)], +) +def test_gaia_and_poss_share_sky_orientation( + monkeypatch, direction, roll, obstruction, flip, flop +): + """The generated chart must put sky directions where a survey plate does.""" + gaia = _render_gaia_marker(direction, roll, flip, flop) + poss = _render_poss_marker(monkeypatch, direction, roll, flip, flop, obstruction) + + gaia_x, gaia_y = _marker_position(gaia) + poss_x, poss_y = _marker_position(poss) + assert gaia_x == pytest.approx(poss_x, abs=1.5) + assert gaia_y == pytest.approx(poss_y, abs=1.5) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("roll", "north_quadrant", "east_quadrant"), + [ + (0.0, "up", "left"), + (90.0, "left", "down"), + (180.0, "down", "right"), + (270.0, "right", "up"), + ], +) +def test_positive_roll_follows_the_plate_solve_definition( + roll, north_quadrant, east_quadrant +): + """Positive roll turns celestial North counter-clockwise from image up.""" + expected_signs = { + "up": (0, -1), + "down": (0, 1), + "left": (-1, 0), + "right": (1, 0), + } + center_x, center_y = (size / 2 for size in RESOLUTION) + + positions = {} + for direction in ("north", "east"): + image = _render_gaia_marker( + direction, roll, False, False, eyepiece_baseline=False + ) + x, y = _marker_position(image) + dx = int(np.sign(x - center_x)) + dy = int(np.sign(y - center_y)) + positions[direction] = (dx, dy) + + assert positions["north"] == expected_signs[north_quadrant] + assert positions["east"] == expected_signs[east_quadrant] diff --git a/python/tests/test_object_list_progress.py b/python/tests/test_object_list_progress.py new file mode 100644 index 000000000..e93b37515 --- /dev/null +++ b/python/tests/test_object_list_progress.py @@ -0,0 +1,47 @@ +"""Compact progress-bar rendering used by populated dynamic catalogs.""" + +from types import SimpleNamespace + +import pytest +from PIL import Image, ImageDraw + +from PiFinder.ui.object_list import UIObjectList + + +class Colors: + def get(self, value): + return (value, 0, 0) + + +def progress_list(): + ui = UIObjectList.__new__(UIObjectList) + ui.display = SimpleNamespace(width=128) + ui.fonts = SimpleNamespace(bold=SimpleNamespace(height=9)) + ui.colors = Colors() + ui.line_position = lambda _line: 12 + image = Image.new("RGB", (128, 32)) + ui.draw = ImageDraw.Draw(image) + return ui, image + + +@pytest.mark.unit +def test_determinate_progress_bar_draws_outline_and_half_fill(): + ui, image = progress_list() + ui._draw_download_progress(50, 255) + # Right-aligned 32px bar on a 128px display; midpoint is filled but its + # far-right interior remains empty. + assert image.getpixel((100, 18))[0] == 255 + assert image.getpixel((122, 18))[0] == 0 + + +@pytest.mark.unit +def test_indeterminate_progress_bar_draws_activity(monkeypatch): + ui, image = progress_list() + monkeypatch.setattr("PiFinder.ui.object_list.time.monotonic", lambda: 0.0) + ui._draw_download_progress(None, 255) + red_pixels = sum( + image.getpixel((x, y))[0] > 0 + for x in range(image.width) + for y in range(image.height) + ) + assert red_pixels > 2 * 32 # outline plus a moving interior segment diff --git a/python/tests/test_object_list_sorting.py b/python/tests/test_object_list_sorting.py new file mode 100644 index 000000000..33672f39a --- /dev/null +++ b/python/tests/test_object_list_sorting.py @@ -0,0 +1,68 @@ +"""Object-list sorting for general and solar-system metadata.""" + +import datetime +from unittest.mock import Mock + +import pytest + +import PiFinder.i18n # noqa: F401 +from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.ui.object_list import SortOrder, UIObjectList, _sort_objects + + +def obj(sequence, mag, distance=None, opposition=None): + return CompositeObject( + catalog_code="MP", + sequence=sequence, + mag=MagnitudeObject([mag]), + earth_distance_au=distance, + opposition_date=opposition, + ) + + +@pytest.mark.unit +def test_brightest_sort_is_generic(): + objects = [obj(1, 12.0), obj(2, 7.0), obj(3, 10.0)] + assert [item.sequence for item in _sort_objects(objects, SortOrder.BRIGHTEST)] == [ + 2, + 3, + 1, + ] + + +@pytest.mark.unit +def test_earth_distance_sort_puts_unknown_last(): + objects = [obj(1, 1, 2.5), obj(2, 1, None), obj(3, 1, 0.4)] + assert [ + item.sequence for item in _sort_objects(objects, SortOrder.EARTH_DISTANCE) + ] == [3, 1, 2] + + +@pytest.mark.unit +def test_opposition_sort_puts_unknown_last(): + objects = [ + obj(1, 1, opposition=datetime.date(2027, 2, 1)), + obj(2, 1), + obj(3, 1, opposition=datetime.date(2026, 9, 1)), + ] + assert [item.sequence for item in _sort_objects(objects, SortOrder.OPPOSITION)] == [ + 3, + 1, + 2, + ] + + +@pytest.mark.unit +def test_automatic_list_resort_does_not_show_toast(): + ui = UIObjectList.__new__(UIObjectList) + ui.current_sort = SortOrder.CATALOG_SEQUENCE + ui._menu_items = [] + ui._menu_items_sorted = [] + ui._current_item_index = 0 + ui.message = Mock() + ui.update = Mock() + + ui.sort(show_message=False) + + ui.message.assert_not_called() + assert ui.update.called diff --git a/python/tests/test_observed_identity.py b/python/tests/test_observed_identity.py index 316d6601d..d45ba1b1a 100644 --- a/python/tests/test_observed_identity.py +++ b/python/tests/test_observed_identity.py @@ -8,10 +8,16 @@ whose listing no longer resolves. """ +import sqlite3 + import pytest +import PiFinder.utils as utils from PiFinder.composite_object import CompositeObject -from PiFinder.db.observations_db import ObservationsDatabase +from PiFinder.db.observations_db import ( + ObservationsDatabase, + _observed_identity_caches, +) # M 31 and NGC 224 are the same sky object; NGC 7000 is unrelated. LISTING_TO_OBJECT_ID = {("M", 31): 42, ("NGC", 224): 42, ("NGC", 7000): 77} @@ -30,6 +36,21 @@ def _resolve_listings(self, object_id): listing for listing, oid in LISTING_TO_OBJECT_ID.items() if oid == object_id ] + def _identity_cache_key(self): + # Unit tests don't build the separate objects database used on-device. + return self.db_path.resolve(), self.db_path.resolve() + + def _query_observed_identities(self): + listings = { + (row["catalog"], row["sequence"]) for row in self.get_observed_objects() + } + object_ids = { + LISTING_TO_OBJECT_ID[listing] + for listing in listings + if listing in LISTING_TO_OBJECT_ID + } + return listings, object_ids + def _obj(catalog_code: str, sequence: int, object_id: int) -> CompositeObject: return CompositeObject( @@ -48,6 +69,13 @@ def obs_db(tmp_path): db.close() +@pytest.fixture(autouse=True) +def clear_identity_cache(): + _observed_identity_caches.clear() + yield + _observed_identity_caches.clear() + + @pytest.mark.unit def test_logging_marks_sibling_listing_in_session(obs_db): _log(obs_db, "M", 31) @@ -68,6 +96,76 @@ def test_observed_status_derives_by_object_id_after_restart(tmp_path): reopened.close() +@pytest.mark.unit +def test_identity_query_runs_once_then_process_cache_is_reused(tmp_path, monkeypatch): + path = tmp_path / "observations.db" + db = MappedObservationsDatabase(path) + _log(db, "M", 31) + db.close() + _observed_identity_caches.clear() + + calls = 0 + original = MappedObservationsDatabase._query_observed_identities + + def counted_query(self): + nonlocal calls + calls += 1 + return original(self) + + monkeypatch.setattr( + MappedObservationsDatabase, "_query_observed_identities", counted_query + ) + + first = MappedObservationsDatabase(path) + second = MappedObservationsDatabase(path) + + assert calls == 1 + assert first.observed_objects_cache is second.observed_objects_cache + assert first.observed_object_ids is second.observed_object_ids + first.close() + second.close() + + +@pytest.mark.unit +def test_real_identity_query_resolves_all_logged_listings_at_once( + tmp_path, monkeypatch +): + objects_path = tmp_path / "objects.db" + conn = sqlite3.connect(objects_path) + conn.execute( + """ + CREATE TABLE catalog_objects ( + id INTEGER PRIMARY KEY, + object_id INTEGER, + catalog_code TEXT, + sequence INTEGER, + description TEXT + ) + """ + ) + conn.executemany( + "INSERT INTO catalog_objects" + " (object_id, catalog_code, sequence, description) VALUES (?, ?, ?, ?)", + [(42, "M", 31, "Andromeda"), (42, "NGC", 224, "")], + ) + conn.commit() + conn.close() + monkeypatch.setattr(utils, "pifinder_db", objects_path) + + observations_path = tmp_path / "observations.db" + db = ObservationsDatabase(observations_path) + _log(db, "M", 31) + db.close() + _observed_identity_caches.clear() + + reopened = ObservationsDatabase(observations_path) + + assert reopened.observed_objects_cache == {("M", 31)} + assert reopened.observed_object_ids == {42} + assert reopened.check_logged(_obj("NGC", 224, 42)) is True + reopened.close() + + @pytest.mark.unit def test_virtual_objects_key_per_listing(obs_db): # Virtual objects share the -1 default (and session-minted negative diff --git a/python/tests/test_obslist_formats.py b/python/tests/test_obslist_formats.py index 1e76a5df7..0460ff131 100644 --- a/python/tests/test_obslist_formats.py +++ b/python/tests/test_obslist_formats.py @@ -272,6 +272,8 @@ def _read_type(type_str): assert _read_type("Planetary Nebula") == "PN" assert _read_type("open star cluster") == "OC" + assert _read_type("asteroid") == "AS" + assert _read_type("minor planet") == "AS" # PiFinder codes (from our own v1.0 exports) pass through unchanged assert _read_type("Gx") == "Gx" # Unknown strings become '?' so the default Type filter still shows them diff --git a/python/tests/test_radec_entry.py b/python/tests/test_radec_entry.py index 447f43056..1eaa01138 100644 --- a/python/tests/test_radec_entry.py +++ b/python/tests/test_radec_entry.py @@ -1,5 +1,5 @@ import pytest -from unittest.mock import Mock +from unittest.mock import patch from PiFinder.ui.radec_entry import ( CoordinateState, CoordinateEntryLogic, @@ -71,68 +71,68 @@ def test_with_dec_sign_toggled(self): class TestBlinkingCursor: - """Test the BlinkingCursor with time injection""" + """Test the BlinkingCursor against a controlled clock.""" def test_blinking_cursor_visibility(self): """Test cursor blinking with mocked time""" - mock_time = Mock() - mock_time.return_value = 0.0 + with patch("PiFinder.ui.radec_entry.time") as mock_time: + # Cursor records start_time at construction. + mock_time.time.return_value = 0.0 + cursor = BlinkingCursor(blink_interval=1.0) - cursor = BlinkingCursor(blink_interval=1.0, time_provider=mock_time) + # At start (t=0), cursor should be visible + mock_time.time.return_value = 0.0 + assert cursor.is_visible() - # At start (t=0), cursor should be visible - mock_time.return_value = 0.0 - assert cursor.is_visible() + # At t=0.5, still visible (within first half of cycle) + mock_time.time.return_value = 0.5 + assert cursor.is_visible() - # At t=0.5, still visible (within first half of cycle) - mock_time.return_value = 0.5 - assert cursor.is_visible() + # At t=1.0, should be invisible (second half of cycle) + mock_time.time.return_value = 1.0 + assert not cursor.is_visible() - # At t=1.0, should be invisible (second half of cycle) - mock_time.return_value = 1.0 - assert not cursor.is_visible() + # At t=1.5, still invisible + mock_time.time.return_value = 1.5 + assert not cursor.is_visible() - # At t=1.5, still invisible - mock_time.return_value = 1.5 - assert not cursor.is_visible() - - # At t=2.0, visible again (new cycle) - mock_time.return_value = 2.0 - assert cursor.is_visible() + # At t=2.0, visible again (new cycle) + mock_time.time.return_value = 2.0 + assert cursor.is_visible() class TestCoordinateConverter: - """Test coordinate conversion with dependency injection""" + """Test coordinate conversion, with calc_utils patched at the module level.""" def test_hms_dms_conversion(self): """Test HMS/DMS to decimal degree conversion""" - mock_calc_utils = Mock() - mock_calc_utils.ra_to_deg.return_value = 150.0 # 10h 0m 0s - mock_calc_utils.dec_to_deg.return_value = 30.0 # +30d 0m 0s + with patch("PiFinder.ui.radec_entry.calc_utils") as mock_calc_utils: + mock_calc_utils.ra_to_deg.return_value = 150.0 # 10h 0m 0s + mock_calc_utils.dec_to_deg.return_value = 30.0 # +30d 0m 0s - converter = CoordinateConverter(mock_calc_utils) - ra_deg, dec_deg = converter.hms_dms_to_degrees( - ["10", "0", "0", "30", "0", "0"], "+" - ) + converter = CoordinateConverter() + ra_deg, dec_deg = converter.hms_dms_to_degrees( + ["10", "0", "0", "30", "0", "0"], "+" + ) - assert ra_deg == 150.0 - assert dec_deg == 30.0 - mock_calc_utils.ra_to_deg.assert_called_with(10, 0, 0) - mock_calc_utils.dec_to_deg.assert_called_with(30, 0, 0) + assert ra_deg == 150.0 + assert dec_deg == 30.0 + mock_calc_utils.ra_to_deg.assert_called_with(10, 0, 0) + mock_calc_utils.dec_to_deg.assert_called_with(30, 0, 0) def test_hms_dms_negative_dec(self): """Test HMS/DMS with negative declination""" - mock_calc_utils = Mock() - mock_calc_utils.ra_to_deg.return_value = 150.0 - mock_calc_utils.dec_to_deg.return_value = 30.0 + with patch("PiFinder.ui.radec_entry.calc_utils") as mock_calc_utils: + mock_calc_utils.ra_to_deg.return_value = 150.0 + mock_calc_utils.dec_to_deg.return_value = 30.0 - converter = CoordinateConverter(mock_calc_utils) - ra_deg, dec_deg = converter.hms_dms_to_degrees( - ["10", "0", "0", "30", "0", "0"], "-" - ) + converter = CoordinateConverter() + ra_deg, dec_deg = converter.hms_dms_to_degrees( + ["10", "0", "0", "30", "0", "0"], "-" + ) - assert ra_deg == 150.0 - assert dec_deg == -30.0 + assert ra_deg == 150.0 + assert dec_deg == -30.0 def test_mixed_format_conversion(self): """Test Mixed format (hours/degrees) conversion""" @@ -315,24 +315,24 @@ def test_deletion_hms_dms(self): def test_coordinate_conversion_integration(self): """Test coordinate conversion through the logic""" - mock_calc_utils = Mock() - mock_calc_utils.ra_to_deg.return_value = 150.0 - mock_calc_utils.dec_to_deg.return_value = 30.0 + with patch("PiFinder.ui.radec_entry.calc_utils") as mock_calc_utils: + mock_calc_utils.ra_to_deg.return_value = 150.0 + mock_calc_utils.dec_to_deg.return_value = 30.0 - logic = CoordinateEntryLogic(calc_utils_provider=mock_calc_utils) + logic = CoordinateEntryLogic() - # Set up some coordinates manually for testing - logic._state = logic._state.with_field_updated(0, "10") - logic._state = logic._state.with_field_updated(1, "0") - logic._state = logic._state.with_field_updated(2, "0") - logic._state = logic._state.with_field_updated(3, "30") - logic._state = logic._state.with_field_updated(4, "0") - logic._state = logic._state.with_field_updated(5, "0") + # Set up some coordinates manually for testing + logic._state = logic._state.with_field_updated(0, "10") + logic._state = logic._state.with_field_updated(1, "0") + logic._state = logic._state.with_field_updated(2, "0") + logic._state = logic._state.with_field_updated(3, "30") + logic._state = logic._state.with_field_updated(4, "0") + logic._state = logic._state.with_field_updated(5, "0") - ra_deg, dec_deg = logic.get_coordinates() + ra_deg, dec_deg = logic.get_coordinates() - assert ra_deg == 150.0 - assert dec_deg == 30.0 + assert ra_deg == 150.0 + assert dec_deg == 30.0 class TestFormatConfig: @@ -406,12 +406,23 @@ def test_get_default_fields(self): assert len(decimal_fields) == 2 +class _FakeDisplay: + """Minimal display_class stub: LayoutConfig only reads these three.""" + + class fonts: + class base: + height = 11 # base font height on the 128 panel + + titlebar_height = 16 + resX = 128 + + class TestLayoutConfig: """Test layout configuration constants""" def test_layout_constants(self): """Test that layout constants are defined""" - layout = LayoutConfig() + layout = LayoutConfig(_FakeDisplay()) assert hasattr(layout, "FIELD_HEIGHT") assert hasattr(layout, "FIELD_WIDTH") @@ -431,44 +442,44 @@ class TestIntegration: def test_full_coordinate_entry_workflow(self): """Test complete workflow from input to coordinate conversion""" - mock_calc_utils = Mock() - mock_calc_utils.ra_to_deg.return_value = 150.0 # 10h - mock_calc_utils.dec_to_deg.return_value = 45.0 # +45d + with patch("PiFinder.ui.radec_entry.calc_utils") as mock_calc_utils: + mock_calc_utils.ra_to_deg.return_value = 150.0 # 10h + mock_calc_utils.dec_to_deg.return_value = 45.0 # +45d - logic = CoordinateEntryLogic(calc_utils_provider=mock_calc_utils) + logic = CoordinateEntryLogic() - # Enter coordinates: 10h 30m 0s, +45d 15m 0s + # Enter coordinates: 10h 30m 0s, +45d 15m 0s - # RA hours - logic.handle_numeric_input(1) - logic.handle_numeric_input(0) # Auto-advances to next field + # RA hours + logic.handle_numeric_input(1) + logic.handle_numeric_input(0) # Auto-advances to next field - # RA minutes - logic.handle_numeric_input(3) - logic.handle_numeric_input(0) # Auto-advances + # RA minutes + logic.handle_numeric_input(3) + logic.handle_numeric_input(0) # Auto-advances - # RA seconds (skip - leave as 0) - logic.move_to_next_field() # Move to DEC degrees + # RA seconds (skip - leave as 0) + logic.move_to_next_field() # Move to DEC degrees - # DEC degrees - logic.handle_numeric_input(4) - logic.handle_numeric_input(5) # Auto-advances + # DEC degrees + logic.handle_numeric_input(4) + logic.handle_numeric_input(5) # Auto-advances - # DEC minutes - logic.handle_numeric_input(1) - logic.handle_numeric_input(5) # Auto-advances + # DEC minutes + logic.handle_numeric_input(1) + logic.handle_numeric_input(5) # Auto-advances - # Skip DEC seconds + # Skip DEC seconds - # Get final coordinates - ra_deg, dec_deg = logic.get_coordinates() + # Get final coordinates + ra_deg, dec_deg = logic.get_coordinates() - # Verify mock was called correctly - mock_calc_utils.ra_to_deg.assert_called_with(10, 30, 0) - mock_calc_utils.dec_to_deg.assert_called_with(45, 15, 0) + # Verify mock was called correctly + mock_calc_utils.ra_to_deg.assert_called_with(10, 30, 0) + mock_calc_utils.dec_to_deg.assert_called_with(45, 15, 0) - assert ra_deg == 150.0 - assert dec_deg == 45.0 + assert ra_deg == 150.0 + assert dec_deg == 45.0 def test_format_switching_preserves_state(self): """Test that switching formats and back preserves state where possible""" 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 2aefc08dd..f27746674 100644 --- a/python/tests/test_software.py +++ b/python/tests/test_software.py @@ -1,15 +1,24 @@ -from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch import pytest import requests +# Installs the _() gettext builtin the UI modules rely on; must precede ui imports. +import PiFinder.i18n # noqa: F401 from PiFinder.ui.software import ( - update_needed, - _strip_markdown, - _fetch_migration_config, + UISoftware, + UPDATE_MANIFEST_URL, + _annotate_trunk_entries, + _entry_detail, + _entry_row_parts, _fetch_update_manifest, - _migration_version_info_from_manifest, - _UNLOCK_SEQUENCE, + _format_age, + _load_cached_manifest, + _parse_manifest, + _save_cached_manifest, + _strip_markdown, + update_needed, ) @@ -46,15 +55,6 @@ def test_unknown_returns_true(self): assert update_needed("2.4.0", "Unknown") is True -@pytest.mark.unit -class TestUnlockSequence: - def test_sequence_length(self): - assert len(_UNLOCK_SEQUENCE) == 7 - - def test_sequence_content(self): - assert _UNLOCK_SEQUENCE == ["square"] * 7 - - @pytest.mark.unit class TestStripMarkdown: def test_removes_headings(self): @@ -99,189 +99,505 @@ def _mock_invalid_json_response(status_code=200): @pytest.mark.unit -class TestFetchMigrationConfig: +class TestFetchUpdateManifest: @patch("PiFinder.ui.software.requests.get") - def test_returns_dict_when_gate_open_and_url_set(self, mock_get): - payload = {"nixos_for_everyone": True, "nixos_url": _NIXOS_URL} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_migration_config() == payload + def test_parses_manifest_channels(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = { + "schema": 1, + "channels": { + "stable": [ + { + "kind": "release", + "label": "v3.1.0", + "title": "PiFinder v3.1.0", + "version": "3.1.0", + "store_path": "/nix/store/aaa-nixos-system-pifinder", + "available": True, + } + ], + "beta": [], + "unstable": [ + { + "kind": "trunk", + "label": "nixos-abc1234", + "title": "nixos branch", + "version": "nixos-abc1234", + "store_path": "/nix/store/bbb-nixos-system-pifinder", + "available": True, + }, + { + "kind": "pr", + "label": "PR#42-def5678", + "title": "Fix star matching algorithm", + "version": "PR#42-def5678", + "store_path": "/nix/store/ccc-nixos-system-pifinder", + "available": True, + }, + ], + }, + } + mock_get.return_value = mock_resp - @patch("PiFinder.ui.software.requests.get") - def test_returns_dict_when_gate_closed(self, mock_get): - # Gate check is the caller's job; fetch just parses the JSON. - payload = {"nixos_for_everyone": False} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_migration_config() == payload + channels = _fetch_update_manifest() - @patch("PiFinder.ui.software.requests.get") - def test_returns_dict_without_url(self, mock_get): - # The tarball comes from the manifest now, so the gate no longer needs a - # nixos_url — only the nixos_for_everyone flag matters to the caller. - payload = {"nixos_for_everyone": True} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_migration_config() == payload + assert channels["stable"][0]["ref"] == "/nix/store/aaa-nixos-system-pifinder" + assert channels["stable"][0]["channel"] == "stable" + assert channels["unstable"][0]["is_trunk"] is True + assert channels["unstable"][1]["label"] == "PR#42-def5678" + mock_get.assert_called_once_with(UPDATE_MANIFEST_URL, timeout=10) @patch("PiFinder.ui.software.requests.get") - def test_returns_none_on_http_error(self, mock_get): - mock_get.return_value = _mock_json_response( - {"nixos_for_everyone": True, "nixos_url": _NIXOS_URL}, status_code=404 - ) - assert _fetch_migration_config() is None + def test_unavailable_manifest_entry_has_no_ref(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = { + "schema": 1, + "channels": { + "stable": [], + "beta": [], + "unstable": [ + { + "kind": "trunk", + "label": "main", + "title": "main branch", + "version": "main", + "store_path": None, + "available": False, + "reason": "no build", + } + ], + }, + } + mock_get.return_value = mock_resp - @patch("PiFinder.ui.software.requests.get") - def test_returns_none_on_connection_error(self, mock_get): - mock_get.side_effect = requests.exceptions.ConnectionError - assert _fetch_migration_config() is None + channels = _fetch_update_manifest() - @patch("PiFinder.ui.software.requests.get") - def test_returns_none_on_timeout(self, mock_get): - mock_get.side_effect = requests.exceptions.Timeout - assert _fetch_migration_config() is None + entry = channels["unstable"][0] + assert entry["ref"] is None + assert entry["unavailable"] is True + assert entry["subtitle"] == "main branch (no build)" @patch("PiFinder.ui.software.requests.get") - def test_returns_none_on_malformed_json(self, mock_get): - mock_get.return_value = _mock_invalid_json_response() - assert _fetch_migration_config() is None + def test_invalid_store_path_is_unavailable(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = { + "schema": 1, + "channels": { + "stable": [], + "beta": [], + "unstable": [ + { + "kind": "pr", + "label": "PR#42-abcdef0", + "title": "Bad build", + "version": "PR#42-abcdef0", + "store_path": "not-a-store-path", + "available": True, + } + ], + }, + } + mock_get.return_value = mock_resp + + channels = _fetch_update_manifest() + + entry = channels["unstable"][0] + assert entry["ref"] is None + assert entry["unavailable"] is True + assert entry["subtitle"] == "Bad build (invalid build)" @patch("PiFinder.ui.software.requests.get") - def test_returns_none_when_payload_is_not_object(self, mock_get): - mock_get.return_value = _mock_json_response(["nixos_for_everyone"]) - assert _fetch_migration_config() is None - - -def _migration_entry(version="3.0.0", available=True, with_urls=True): - entry = {"version": version, "available": available} - if with_urls: - base = f"https://example.invalid/releases/download/v{version}" - entry["migration_url"] = f"{base}/pifinder-migration-v{version}.tar.zst" - entry["migration_sha256_url"] = ( - f"{base}/pifinder-migration-v{version}.tar.zst.sha256" - ) - return entry + def test_rejects_unknown_schema(self, mock_get): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"schema": 99, "channels": {}} + mock_get.return_value = mock_resp + with pytest.raises(ValueError): + _fetch_update_manifest() -def _manifest(stable=None, beta=None, unstable=None): - return { - "channels": { - "stable": stable or [], - "beta": beta or [], - "unstable": unstable or [], - } + +@pytest.mark.unit +def test_unstable_list_hides_exact_running_build(): + # The running build is hidden from unstable by store-path identity; a + # rebuilt PR (same number, new store path) is a real upgrade and stays. + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["unstable"] + ui._channel_index = 0 + ui._software_version = "PR#1-abcdef0" + ui._channels = { + "unstable": [ + { + "label": "nixos-trunk", + "version": "nixos-trunk", + "is_trunk": True, + "ref": "/nix/store/bbb-trunk", + }, + { + "label": "PR#1-abcdef0", + "version": "PR#1-abcdef0", + "ref": "/nix/store/aaa-running", + }, + ] } + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-running", + ): + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["nixos-trunk"] + @pytest.mark.unit -class TestFetchUpdateManifest: - @patch("PiFinder.ui.software.requests.get") - def test_returns_dict(self, mock_get): - payload = {"channels": {}} - mock_get.return_value = _mock_json_response(payload) - assert _fetch_update_manifest() == payload +def test_stable_list_filters_current_build_by_store_path(): + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["stable"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "stable": [ + {"label": "v3.0.0", "version": "3.0.0", "ref": "/nix/store/aaa-current"}, + {"label": "v3.1.0", "version": "3.1.0", "ref": "/nix/store/bbb-next"}, + ] + } - @patch("PiFinder.ui.software.requests.get") - def test_none_on_http_error(self, mock_get): - mock_get.return_value = _mock_json_response({}, status_code=500) - assert _fetch_update_manifest() is None + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-current", + ): + ui._refresh_version_list() - @patch("PiFinder.ui.software.requests.get") - def test_none_on_malformed_json(self, mock_get): - mock_get.return_value = _mock_invalid_json_response() - assert _fetch_update_manifest() is None + assert [entry["label"] for entry in ui._version_list] == ["v3.1.0"] -def _mock_head_response(size_bytes=None, status_code=200): - resp = MagicMock() - resp.status_code = status_code - resp.headers = {} if size_bytes is None else {"Content-Length": str(size_bytes)} - return resp +@pytest.mark.unit +def test_recut_release_with_same_version_stays_visible(): + # A re-cut release reuses its version/label but is a different store path + # — it must be offered as an upgrade, not hidden by a version-string match. + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["beta"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "beta": [ + {"label": "v3.0.0-beta", "version": "3.0.0", "ref": "/nix/store/bbb-recut"}, + ] + } + + with patch( + "PiFinder.ui.software._current_store_path", + return_value="/nix/store/aaa-old-build", + ): + ui._refresh_version_list() + + assert [entry["label"] for entry in ui._version_list] == ["v3.0.0-beta"] -# Selection also HEADs the chosen tarball for its size, so requests.head is -# stubbed throughout (never hit the network from tests). @pytest.mark.unit -@patch( - "PiFinder.ui.software.requests.head", - side_effect=requests.exceptions.ConnectionError, -) -class TestMigrationVersionInfoFromManifest: - @patch("PiFinder.ui.software.requests.get") - def test_prefers_stable(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest( - stable=[_migration_entry("3.0.0")], - beta=[_migration_entry("3.1.0-beta")], - unstable=[_migration_entry("nixos-abc")], - ) - ) - info = _migration_version_info_from_manifest() - assert info["version"] == "3.0.0" - assert info["type"] == "upgrade" - assert info["migration_url"].endswith("pifinder-migration-v3.0.0.tar.zst") - assert info["migration_sha256_url"].endswith(".sha256") +def test_unknown_current_build_hides_nothing(): + ui = UISoftware.__new__(UISoftware) + ui._channel_names = ["stable"] + ui._channel_index = 0 + ui._software_version = "3.0.0" + ui._channels = { + "stable": [ + {"label": "v3.0.0", "version": "3.0.0", "ref": "/nix/store/aaa"}, + ] + } - @patch("PiFinder.ui.software.requests.get") - def test_includes_size_when_head_succeeds(self, mock_get, mock_head): - mock_get.return_value = _mock_json_response( - _manifest(stable=[_migration_entry("3.0.0")]) - ) - mock_head.side_effect = None - mock_head.return_value = _mock_head_response(size_bytes=300 * 1024 * 1024) - info = _migration_version_info_from_manifest() - assert info["migration_size_mb"] == 300 + with patch("PiFinder.ui.software._current_store_path", return_value=None): + ui._refresh_version_list() - @patch("PiFinder.ui.software.requests.get") - def test_omits_size_when_head_fails(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest(stable=[_migration_entry("3.0.0")]) - ) - info = _migration_version_info_from_manifest() - assert "migration_size_mb" not in info + assert [entry["label"] for entry in ui._version_list] == ["v3.0.0"] - @patch("PiFinder.ui.software.requests.get") - def test_falls_back_to_beta_when_stable_empty(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest(beta=[_migration_entry("3.1.0-beta")]) - ) - assert _migration_version_info_from_manifest()["version"] == "3.1.0-beta" - @patch("PiFinder.ui.software.requests.get") - def test_falls_back_to_unstable_last(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest(unstable=[_migration_entry("nixos-abc")]) +@pytest.mark.unit +def test_unavailable_version_has_no_install_option(): + ui = UISoftware.__new__(UISoftware) + ui._phase = "browse" + ui._focus = "list" + ui._list_index = 0 + ui._version_list = [ + { + "label": "main", + "version": "main", + "subtitle": "main branch (no build)", + "unavailable": True, + } + ] + + ui.key_right() + + assert ui._phase == "confirm" + assert ui._confirm_options == ["Cancel"] + + +def _iso_ago(**delta) -> str: + return (datetime.now(timezone.utc) - timedelta(**delta)).isoformat() + + +@pytest.mark.unit +class TestFormatAge: + def test_minutes(self): + assert _format_age(_iso_ago(minutes=5)) == "5m ago" + + def test_hours(self): + assert _format_age(_iso_ago(hours=3, minutes=10)) == "3h ago" + + def test_days(self): + assert _format_age(_iso_ago(days=2, hours=1)) == "2d ago" + + def test_future_timestamp_clamps_to_zero(self): + assert _format_age(_iso_ago(minutes=-5)) == "0m ago" + + def test_none(self): + assert _format_age(None) is None + + def test_garbage(self): + assert _format_age("not-a-date") is None + + +@pytest.mark.unit +class TestEntryRowParts: + def test_pr_row_leads_with_bare_number(self): + prefix, text = _entry_row_parts( + { + "kind": "pr", + "number": 534, + "label": "PR#534-2692406", + "title": "feat(catalog): add observable asteroids", + } ) - assert _migration_version_info_from_manifest()["version"] == "nixos-abc" + assert prefix == "534 " + assert text == "feat(catalog): add observable asteroids" + + def test_trunk_row_shows_branch_name(self): + prefix, text = _entry_row_parts( + { + "kind": "trunk", + "is_trunk": True, + "label": "nixos-d1657e6", + "source_ref": "nixos", + } + ) + assert prefix == "• " + assert text == "nixos" - @patch("PiFinder.ui.software.requests.get") - def test_skips_unavailable_entries(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest( - stable=[_migration_entry("3.0.0", available=False)], - beta=[_migration_entry("3.1.0-beta")], - ) + def test_release_row_shows_label(self): + prefix, text = _entry_row_parts( + {"kind": "release", "label": "v3.0.0-beta", "title": "PiFinder v3.0.0-beta"} ) - assert _migration_version_info_from_manifest()["version"] == "3.1.0-beta" + assert prefix == "" + assert text == "v3.0.0-beta" - @patch("PiFinder.ui.software.requests.get") - def test_skips_entries_without_migration_tarball(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response( - _manifest( - stable=[_migration_entry("3.0.0", with_urls=False)], - beta=[_migration_entry("3.1.0-beta")], - ) + +@pytest.mark.unit +class TestEntryDetail: + def test_unavailable_reason_wins_over_age(self): + entry = { + "unavailable": True, + "subtitle": "Broken build (not in cache)", + "built_at": _iso_ago(minutes=5), + } + assert _entry_detail(entry) == "Broken build (not in cache)" + + def test_build_age_and_short_hash_when_available(self): + entry = { + "subtitle": "Some title", + "built_at": _iso_ago(minutes=12), + "source_sha": "2692406bff684a56a2acce1febffb015aad72038", + } + assert _entry_detail(entry) == "built 12m ago · 2692406" + + def test_build_age_without_hash(self): + entry = {"subtitle": "Some title", "built_at": _iso_ago(minutes=12)} + assert _entry_detail(entry) == "built 12m ago" + + def test_hash_without_build_age(self): + entry = {"subtitle": "Some title", "source_sha": "d1657e66b6ca4e14"} + assert _entry_detail(entry) == "d1657e6" + + def test_falls_back_to_subtitle_without_build_info(self): + assert _entry_detail({"subtitle": "Some title"}) == "Some title" + + +@pytest.mark.unit +class TestTrunkNixosMarker: + def _manifest_with_trunk(self, **extra): + trunk = { + "kind": "trunk", + "label": "main-abc1234", + "title": "main branch", + "version": "main-abc1234", + "source_repo": "brickbots/PiFinder", + "source_ref": "main", + "store_path": "/nix/store/aaa-nixos-system-pifinder", + "available": True, + } + trunk.update(extra) + return {"schema": 1, "channels": {"unstable": [trunk]}} + + def test_parse_drops_trunk_marked_non_nixos(self): + channels = _parse_manifest(self._manifest_with_trunk(nixos_branch=False)) + assert channels["unstable"] == [] + + def test_parse_keeps_trunk_marked_nixos(self): + channels = _parse_manifest(self._manifest_with_trunk(nixos_branch=True)) + assert channels["unstable"][0]["is_trunk"] is True + + def test_parse_keeps_unannotated_trunk(self): + channels = _parse_manifest(self._manifest_with_trunk()) + assert len(channels["unstable"]) == 1 + + @patch("PiFinder.ui.software.requests.head") + def test_annotate_marks_nixos_branch(self, mock_head): + mock_head.return_value = MagicMock(status_code=200) + manifest = self._manifest_with_trunk() + _annotate_trunk_entries(manifest) + assert manifest["channels"]["unstable"][0]["nixos_branch"] is True + url = mock_head.call_args[0][0] + assert url == ( + "https://raw.githubusercontent.com/brickbots/PiFinder/main/flake.nix" ) - assert _migration_version_info_from_manifest()["version"] == "3.1.0-beta" - @patch("PiFinder.ui.software.requests.get") - def test_none_when_no_migration_entries(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response(_manifest()) - assert _migration_version_info_from_manifest() is None + @patch("PiFinder.ui.software.requests.head") + def test_annotate_marks_non_nixos_branch(self, mock_head): + mock_head.return_value = MagicMock(status_code=404) + manifest = self._manifest_with_trunk() + _annotate_trunk_entries(manifest) + assert manifest["channels"]["unstable"][0]["nixos_branch"] is False - @patch("PiFinder.ui.software.requests.get") - def test_none_on_network_error(self, mock_get, _mock_head): - mock_get.side_effect = requests.exceptions.ConnectionError - assert _migration_version_info_from_manifest() is None + @patch("PiFinder.ui.software.requests.head") + def test_annotate_leaves_entry_alone_on_network_error(self, mock_head): + mock_head.side_effect = requests.exceptions.ConnectionError + manifest = self._manifest_with_trunk() + _annotate_trunk_entries(manifest) + assert "nixos_branch" not in manifest["channels"]["unstable"][0] - @patch("PiFinder.ui.software.requests.get") - def test_none_when_channels_missing(self, mock_get, _mock_head): - mock_get.return_value = _mock_json_response({"schema": 1}) - assert _migration_version_info_from_manifest() is None + +@pytest.mark.unit +class TestManifestCache: + def test_round_trip(self, tmp_path): + manifest = {"schema": 1, "channels": {"stable": [], "beta": [], "unstable": []}} + cache = tmp_path / "update_manifest.json" + with patch("PiFinder.ui.software.MANIFEST_CACHE_PATH", cache): + _save_cached_manifest(manifest) + assert _load_cached_manifest() == manifest + + def test_missing_cache_returns_none(self, tmp_path): + cache = tmp_path / "update_manifest.json" + with patch("PiFinder.ui.software.MANIFEST_CACHE_PATH", cache): + assert _load_cached_manifest() is None + + def test_wrong_schema_returns_none(self, tmp_path): + cache = tmp_path / "update_manifest.json" + cache.write_text('{"schema": 99}') + with patch("PiFinder.ui.software.MANIFEST_CACHE_PATH", cache): + assert _load_cached_manifest() is None + + def test_corrupt_cache_returns_none(self, tmp_path): + cache = tmp_path / "update_manifest.json" + cache.write_text("{nope") + with patch("PiFinder.ui.software.MANIFEST_CACHE_PATH", cache): + assert _load_cached_manifest() is None + + +@pytest.mark.unit +class TestManualRefresh: + def _ui(self, phase): + ui = UISoftware.__new__(UISoftware) + ui._phase = phase + ui._key_buffer = [] + ui._elipsis_count = 30 + return ui + + def test_square_refetches_in_browse(self): + ui = self._ui("browse") + with patch.object(UISoftware, "_start_refresh") as mock_start: + ui.key_square() + mock_start.assert_called_once() + assert ui._phase == "browse" + + def test_square_retries_from_offline_via_loading(self): + ui = self._ui("offline") + with patch.object(UISoftware, "_start_refresh") as mock_start: + ui.key_square() + mock_start.assert_called_once() + assert ui._phase == "loading" + + def test_square_does_not_refetch_in_confirm(self): + ui = self._ui("confirm") + with patch.object(UISoftware, "_start_refresh") as mock_start: + ui.key_square() + mock_start.assert_not_called() + + +@pytest.mark.unit +class TestListRollbackTargets: + def _targets(self, value): + ui = UISoftware.__new__(UISoftware) + with patch("PiFinder.ui.software.sys_utils") as mock_sys: + mock_sys.list_rollback_targets.return_value = value + return ui._list_rollback_targets() + + def test_valid_entries_pass_through(self): + entries = [{"label": "gen 42", "ref": "/nix/store/aaa"}] + assert self._targets(entries) == entries + + def test_non_dict_and_unlabeled_entries_dropped(self): + entries = [ + "garbage", + {"ref": "/nix/store/aaa"}, + {"label": 7}, + {"label": "gen 42"}, + ] + assert self._targets(entries) == [{"label": "gen 42"}] + + def test_non_iterable_result_yields_empty(self): + assert self._targets(None) == [] + + +@pytest.mark.unit +class TestConsumeRefreshResult: + def _ui(self, phase): + ui = UISoftware.__new__(UISoftware) + ui._phase = phase + ui._checking = True + ui._check_failed = False + ui._refresh_result = None + return ui + + def test_success_moves_loading_to_browse(self): + ui = self._ui("loading") + ui._refresh_result = ("ok", {"stable": [], "beta": [], "unstable": []}) + with patch.object(UISoftware, "_apply_manifest") as mock_apply: + ui._consume_refresh_result() + assert ui._phase == "browse" + assert ui._checking is False + assert ui._check_failed is False + mock_apply.assert_called_once() + + def test_failure_without_cache_or_rollback_goes_offline(self): + ui = self._ui("loading") + ui._refresh_result = ("error", None) + with patch.object(UISoftware, "_list_rollback_targets", return_value=[]): + ui._consume_refresh_result() + assert ui._phase == "offline" + assert ui._checking is False + + def test_failure_with_cached_list_only_flags(self): + ui = self._ui("browse") + ui._refresh_result = ("error", None) + ui._consume_refresh_result() + assert ui._phase == "browse" + assert ui._check_failed is True + + def test_no_result_is_a_noop(self): + ui = self._ui("browse") + ui._consume_refresh_result() + assert ui._checking is True diff --git a/python/tests/test_solver_shmem.py b/python/tests/test_solver_shmem.py index 0a536da4a..e54bb2043 100644 --- a/python/tests/test_solver_shmem.py +++ b/python/tests/test_solver_shmem.py @@ -1,11 +1,16 @@ -"""Regression test for stale cedar_detect shared-memory cleanup. +"""Regression tests for cedar_detect shared-memory recovery on restart. -A solver process that is killed leaks its POSIX shmem segment; the next -PFCedarDetectClient must clear it at startup instead of dying on -FileExistsError on every solve. +All cedar clients share one hard-coded segment name, so a second client on +the same host (e.g. an offline analysis script) can leave the server's +cached fd pointing at its own frozen image — the live solver then "solves" +that frame forever. A solver restart must always recover: clear whatever +segment exists at startup, and force the server to reopen the fresh one on +the first request. A killed solver similarly leaks its segment; the next +client must clear it instead of dying on FileExistsError on every solve. """ from multiprocessing import shared_memory +from types import SimpleNamespace import pytest @@ -38,3 +43,31 @@ def test_clear_stale_shmem_unlinks_leaked_segment(monkeypatch): stray.unlink() except FileNotFoundError: pass + + +@pytest.mark.unit +def test_alloc_shmem_requests_reopen_on_fresh_segment(monkeypatch): + # Restart recovery: the first allocation after startup must return True + # so the request sets reopen_shmem and the server drops a cached fd that + # may point at another client's (possibly unlinked) segment. Upstream's + # _alloc_shmem returns False here — only PFCedarDetectClient's override + # guarantees a restart always resynchronizes solver and server. + name = "/cedar_detect_image_pftest_fresh" + monkeypatch.setattr( + "tetra3.cedar_detect_client.shared_memory", + SimpleNamespace( + SharedMemory=lambda _name, create=False, size=0: ( + shared_memory.SharedMemory(name, create=create, size=size) + ) + ), + ) + + client = object.__new__(PFCedarDetectClient) + client._shmem = None + client._shmem_size = 0 + try: + assert client._alloc_shmem(16) is True # fresh segment → reopen + assert client._alloc_shmem(16) is False # unchanged → no reopen + assert client._alloc_shmem(32) is True # resized → reopen + finally: + client._del_shmem() diff --git a/python/tests/test_solver_sqm.py b/python/tests/test_solver_sqm.py index 50dbd1a01..a513b974d 100644 --- a/python/tests/test_solver_sqm.py +++ b/python/tests/test_solver_sqm.py @@ -334,3 +334,36 @@ def test_radiometer_duplicate_sequence_not_refed(self): ) # Same camera frame seen twice by the loop: fed exactly once. assert black_level.add_sample.call_count == 1 + + def test_imx462_publishes_frame_paired_colour_and_tracker_diagnostics(self): + shared_state, calc, black_level, sample = self._radiometer_harness() + sample.update( + { + "background_per_pixel": 280.0, + "background_red": 275.0, + "background_blue": 265.0, + } + ) + accumulator = solver.RadiometerAccumulator() + airglow = solver.AirglowTracker("imx462") + assert solver.update_radiometric_sqm( + shared_state, + calc, + accumulator, + sample, + now=1001.0, + black_level_tracker=black_level, + airglow_tracker=airglow, + ) + details = shared_state.set_sqm_details.call_args[0][0] + diagnostic = details["airglow_diagnostic"] + assert diagnostic["valid"] is True + assert diagnostic["pedestal"] == 237.5 + assert diagnostic["pedestal_source"] == "tracked_or_calibrated" + assert details["pedestal"] == 237.5 + assert details["skyglow_floor"] == diagnostic["correction_adu_per_sec"] + assert details["radiometric_zero_point"] == 15.19 + assert details["window_airglow"]["samples"][0] == diagnostic + stored = details["window_radiometer"]["samples"][0] + assert stored["paired_pedestal"] == 237.5 + assert stored["paired_radiometric_zero_point"] == 15.19 diff --git a/python/tests/test_sqm.py b/python/tests/test_sqm.py index db139af78..f39d764a6 100644 --- a/python/tests/test_sqm.py +++ b/python/tests/test_sqm.py @@ -57,7 +57,7 @@ def test_extinction_increases_toward_horizon(self): for i in range(len(extinctions) - 1): assert ( extinctions[i] < extinctions[i + 1] - ), f"Extinction at {altitudes[i]}° should be less than at {altitudes[i + 1]}°" + ), f"Extinction at {altitudes[i]}° should be less than at {altitudes[i+1]}°" def test_extinction_minimum_is_at_zenith(self): """Test that zenith (90°) has zero extinction (ASTAP convention)""" @@ -163,7 +163,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_star_catalog.py b/python/tests/test_star_catalog.py new file mode 100644 index 000000000..532747a39 --- /dev/null +++ b/python/tests/test_star_catalog.py @@ -0,0 +1,117 @@ +import struct +import shutil +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from PiFinder.object_images.star_catalog import CompressedIndex, GaiaStarCatalog + + +def build_v3_index(runs, version=3): + """Build a v3 run-length-encoded index file image. + + Format (see CompressedIndex): + header: = 1024 * 1024 + assert rec.images_capped is True + finally: + rec.stop() + + def test_header_snapshots_sqm_calibration(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + ss = _make_shared_state() + ss.camera_type.return_value = "imx462" + rec.start(_make_cfg(), ss) + try: + cfg_hdr = json.loads(rec._buffer[0])["cfg"] + assert cfg_hdr["camera_type"] == "imx462" + cal = cfg_hdr["sqm_calibration"] + # full profile is captured, not a hand-picked subset + assert "radiometric_zero_point" in cal["profile"] + assert "pixel_pitch_um" in cal["profile"] + assert "default_lens_key" in cal["profile"] + assert "bias_offset" in cal["profile"] + # Airglow constants only exist on branches carrying the model. + if telemetry_module.airglow is not None: + assert cal["airglow"]["red_response"] == 4.07 + finally: + rec.stop() + + def test_header_calibration_mono_has_no_airglow(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + ss = _make_shared_state() + ss.camera_type.return_value = "imx296" # mono: no airglow entry + rec.start(_make_cfg(), ss) + try: + cal = json.loads(rec._buffer[0])["cfg"]["sqm_calibration"] + assert "profile" in cal + assert "airglow" not in cal + finally: + rec.stop() + + def test_header_calibration_none_for_unknown_camera(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + ss = _make_shared_state() + ss.camera_type.return_value = "no_such_cam" + rec.start(_make_cfg(), ss) + try: + cfg_hdr = json.loads(rec._buffer[0])["cfg"] + assert cfg_hdr["camera_type"] == "no_such_cam" + assert cfg_hdr["sqm_calibration"] is None + finally: + rec.stop() + + def test_sections_default_all_on(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + rec.start(_make_cfg(), _make_shared_state()) + try: + assert rec.sections == { + "imu": True, + "sqm": True, + "solve": True, + "target": True, + "images": True, + } + finally: + rec.stop() + + def test_imu_section_off_skips_imu_only(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + rec.start( + _make_cfg(telemetry_sections=["sqm", "solve", "target"]), + _make_shared_state(), + ) + try: + base = len(rec._buffer) + rec.record_imu(_make_imu_sample(moving=True)) + assert len(rec._buffer) == base # IMU gated off + rec.record_radio( + { + "sequence": 1, + "captured_at": 100.0, + "exposure_sec": 1.0, + "background_per_pixel": 515.0, + "background_mad": 28.0, + "background_gradient": 12.0, + } + ) + assert len(rec._buffer) == base + 1 # SQM still records + finally: + rec.stop() + + def test_sqm_section_off_skips_radio(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + rec.start( + _make_cfg(telemetry_sections=["imu", "solve", "target"]), + _make_shared_state(), + ) + try: + base = len(rec._buffer) + rec.record_radio( + { + "sequence": 1, + "captured_at": 100.0, + "exposure_sec": 1.0, + "background_per_pixel": 515.0, + } + ) + assert len(rec._buffer) == base + finally: + rec.stop() + + def test_solve_section_off_skips_solve(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + rec.start( + _make_cfg(telemetry_sections=["imu", "sqm", "target"]), + _make_shared_state(), + ) + try: + base = len(rec._buffer) + result = rec.record_solve(_make_successful_solve()) + assert result is None + assert len(rec._buffer) == base + finally: + rec.stop() + + def test_target_section_off_skips_target(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + rec.start( + _make_cfg(telemetry_sections=["imu", "sqm", "solve"]), + _make_shared_state(), + ) + try: + base = len(rec._buffer) + target = MagicMock() + target.object_id = 42 + target.display_name = "M31" + target.ra = 10.68 + target.dec = 41.27 + rec.record_target(target, alt=45.0, az=90.0) + assert len(rec._buffer) == base + finally: + rec.stop() + + def test_apply_sections_updates_live_recording(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + rec = TelemetryRecorder() + rec.start(_make_cfg(), _make_shared_state()) + try: + base = len(rec._buffer) + rec.record_imu(_make_imu_sample(moving=True, timestamp=1000.0)) + assert len(rec._buffer) == base + 1 # recorded while on + + rec.apply_sections( + _make_cfg(telemetry_sections=["sqm", "solve", "target"]) + ) + assert rec.sections["imu"] is False + # Distinct timestamp so IMU dedup can't mask the section gate. + rec.record_imu(_make_imu_sample(moving=True, timestamp=1001.0)) + assert len(rec._buffer) == base + 1 # now gated off + finally: + rec.stop() + def test_record_imu_when_enabled(self, tmp_path): with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): rec = TelemetryRecorder() @@ -774,6 +1026,59 @@ def test_handle_command_record_on(self, tmp_path): finally: mgr.stop() + def test_record_radio_stamps_published_sqm_and_floor(self, tmp_path): + """The manager pulls the last published SQM/floor from shared state.""" + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + ss = _make_shared_state() + ss.sqm.return_value = MagicMock(value=20.9) + ss.sqm_details.return_value = {"skyglow_floor": 51.5} + cq = queue.Queue() + mgr = TelemetryManager(_make_cfg(telemetry_record=True), ss, cq) + try: + mgr.record_radio( + { + "sequence": 1, + "captured_at": 100.0, + "exposure_sec": 1.0, + "background_per_pixel": 515.0, + "background_mad": 28.0, + "background_gradient": 12.0, + "background_red": 540.0, + } + ) + event = json.loads(mgr._recorder._buffer[-1]) + assert event["e"] == "radio" + assert event["red"] == 540.0 + assert event["sqm"] == 20.9 + assert event["floor"] == 51.5 + finally: + mgr.stop() + + def test_record_radio_survives_missing_sqm_state(self, tmp_path): + """No SQM published yet: sqm/floor log as None, recording continues.""" + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + ss = _make_shared_state() + ss.sqm.return_value = None + ss.sqm_details.return_value = None + cq = queue.Queue() + mgr = TelemetryManager(_make_cfg(telemetry_record=True), ss, cq) + try: + mgr.record_radio( + { + "sequence": 1, + "captured_at": 100.0, + "exposure_sec": 1.0, + "background_per_pixel": 515.0, + "background_mad": 28.0, + "background_gradient": 12.0, + } + ) + event = json.loads(mgr._recorder._buffer[-1]) + assert event["sqm"] is None + assert event["floor"] is None + finally: + mgr.stop() + def test_handle_command_record_off(self, tmp_path): with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): cq = queue.Queue() @@ -982,10 +1287,10 @@ def test_record_solve_delegates(self): def test_record_solve_sends_image_command(self, tmp_path): with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): cam_q = queue.Queue() - cfg = _make_cfg(telemetry_images=True) - mgr = TelemetryManager(cfg, _make_shared_state(), queue.Queue(), cam_q) - mgr._recorder.start(_make_cfg(), _make_shared_state()) - mgr._recorder.images_enabled = True + mgr = TelemetryManager( + _make_cfg(), _make_shared_state(), queue.Queue(), cam_q + ) + mgr._recorder.start(_make_cfg(), _make_shared_state()) # images section on try: mgr.record_solve(_make_successful_solve()) msg = cam_q.get_nowait() @@ -993,6 +1298,45 @@ def test_record_solve_sends_image_command(self, tmp_path): finally: mgr.stop() + def test_record_solve_no_image_once_cap_reached(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + cam_q = queue.Queue() + console_q = queue.Queue() + mgr = TelemetryManager(_make_cfg(), _make_shared_state(), console_q, cam_q) + mgr._recorder.start( + _make_cfg(telemetry_max_session_mb=1), _make_shared_state() + ) + try: + # Push the session past the cap, then re-measure. + (mgr._recorder.get_session_dir() / "img_1.png").write_bytes( + b"x" * 1_200_000 + ) + mgr._recorder._refresh_session_bytes() + assert mgr._recorder.images_capped + + mgr.record_solve(_make_successful_solve()) + assert cam_q.empty() # no further frames requested + assert console_q.get_nowait() == "Telemetry: Size cap, frames off" + finally: + mgr.stop() + + def test_record_solve_no_image_when_images_section_off(self, tmp_path): + with patch("PiFinder.telemetry.TELEMETRY_DIR", tmp_path / "telemetry"): + cam_q = queue.Queue() + mgr = TelemetryManager( + _make_cfg(), _make_shared_state(), queue.Queue(), cam_q + ) + # Solves on, Images off: solve recorded but no frame saved. + mgr._recorder.start( + _make_cfg(telemetry_sections=["imu", "sqm", "solve", "target"]), + _make_shared_state(), + ) + try: + mgr.record_solve(_make_successful_solve()) + assert cam_q.empty() + finally: + mgr.stop() + def test_record_imu_delegates(self): mgr = TelemetryManager(_make_cfg(), _make_shared_state(), queue.Queue()) mgr._recorder = MagicMock() diff --git a/python/tests/test_ui_modules.py b/python/tests/test_ui_modules.py index 49c4110c8..2d294f746 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 @@ -83,11 +83,12 @@ # Dynamic-only module classes (pushed at runtime, never as static tree nodes). from PiFinder.ui.object_details import UIObjectDetails +from PiFinder.ui.object_list import SortOrder, UIObjectList +from PiFinder.nearby import NEAREST_LIST_CAP from PiFinder.ui.log import UILog from PiFinder.ui.dateentry import UIDateEntry from PiFinder.ui.sqm_calibration import UISQMCalibration from PiFinder.ui.sqm_sweep import UISQMSweep -from PiFinder.ui.software import UIMigrationConfirm, UIMigrationProgress # --------------------------------------------------------------------------- # @@ -121,7 +122,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] = { - "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 @@ -182,8 +187,6 @@ def _node_id(node) -> str: "UIDateEntry", "UISQMCalibration", "UISQMSweep", - "UIMigrationConfirm", - "UIMigrationProgress", ] @@ -219,23 +222,6 @@ def _build_dynamic_item_definition(spec_id: str, sample_object) -> dict: if spec_id == "UISQMSweep": # sqm.py:302 return {"name": "SQM Sweep", "class": UISQMSweep, "label": "sqm_sweep"} - if spec_id == "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 @@ -248,7 +234,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) @@ -355,8 +345,31 @@ def _no_comet_download(): """ import PiFinder.comets as comets - with mock.patch.object( - comets, "comet_data_download", return_value=(False, None, None) + with ( + mock.patch.object( + comets, + "check_if_comet_download_needed", + return_value=(False, "test environment"), + ), + mock.patch.object( + comets, "comet_data_download", return_value=(False, None, None) + ), + ): + yield + + +@pytest.fixture(scope="session", autouse=True) +def _no_asteroid_download(): + """Keep the UI harness hermetic while building AsteroidCatalog.""" + import PiFinder.asteroids as asteroids + + with ( + mock.patch.object( + asteroids, + "check_asteroid_download_needed", + return_value=(False, "test environment"), + ), + mock.patch.object(asteroids, "download_asteroid_year"), ): yield @@ -428,7 +441,7 @@ def camera_image(): @pytest.fixture(scope="session") -def catalogs(_no_comet_download) -> Iterator[Catalogs]: +def catalogs(_sandbox_data_dir, _no_comet_download) -> Iterator[Catalogs]: """Build the real catalogs once from the bundled DB. Teardown stops the perpetual catalog timers. The planet and comet catalogs @@ -559,10 +572,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: @@ -681,6 +691,68 @@ def test_object_details_tracks_target(display, camera_image, catalogs): assert shared_state.ui_state().target() is obj_b +@pytest.mark.integration +def test_nearby_sort_navigation_bounded_by_ranked_window( + display, camera_image, catalogs +): + """Navigation stays inside the Nearby window, which is shorter than the catalog. + + The Nearby sort ranks at most ``NEAREST_LIST_CAP`` objects, so the list the + carousel draws is far shorter than the filtered catalog behind it. Long-DOWN + scrolls to the last row and RIGHT opens it, so both must be bounded by the + ranked window rather than by the source list -- otherwise the cursor lands + on rows that do not exist and opening one raises IndexError. + """ + cfg = Config() + shared_state = _make_shared_state("warm") + command_queues = _make_command_queues() + catalog_filter = CatalogFilter(shared_state=shared_state) + catalog_filter.load_from_config(cfg) + catalogs.set_catalog_filter(catalog_filter) + + module = UIObjectList( + display, + camera_image, + shared_state, + command_queues, + cfg, + catalogs, + item_definition={ + "name": "All Filtered", + "class": UIObjectList, + "objects": "catalogs.filtered", + }, + add_to_stack=lambda item_definition: None, + ) + + module.current_sort = SortOrder.NEAREST + module.sort() + assert module.current_sort == SortOrder.NEAREST, "warm state should have a solve" + + ranked = len(module._menu_items_sorted) + assert ranked <= NEAREST_LIST_CAP + # The point of the test: the source list is longer than the ranked window. + assert len(module._menu_items) > ranked + + # The header still advertises the catalog behind the screen, not the window. + assert module.catalog_info_1 == str(len(module._menu_items)) + + module.key_long_down() + assert module._current_item_index == ranked - 1 + + # Opening the last row must not index past the ranked window. + module.key_right() + module.update() + + state = module.serialize_ui_state() + assert "error" not in state + assert state["total_items"] == ranked + assert ( + state["current_item"] + == module._menu_items_sorted[module._current_item_index].display_name + ) + + @pytest.mark.integration def test_all_ui_modules_covered(): """Fail if a UIModule subclass is reached by neither discovery path. 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_remote.py b/python/tests/website/test_web_remote.py index 63548afa5..7d2117bee 100644 --- a/python/tests/website/test_web_remote.py +++ b/python/tests/website/test_web_remote.py @@ -406,7 +406,7 @@ def test_remote_nav_right(driver): press_keys_and_validate( driver, - "RDDD", + "RDDDD", expected_values={ "ui_type": "UITextMenu", "title": "By Catalog", @@ -542,7 +542,7 @@ def test_remote_backtotop(driver): press_keys_and_validate( driver, - "RDRDDDR31RW", # W = 1s wait to let async key callback and UI state update settle + "RDRDDDDR31RW", # W = 1s wait to let async key callback and UI state update settle expected_values={ "ui_type": "UIObjectDetails", "object": {"display_name": "M 31"}, @@ -567,7 +567,7 @@ def test_remote_markingmenu(driver): press_keys_and_validate( driver, - "RDRDDDR31RL", + "RDRDDDDR31RL", expected_values={ "current_item": "M 31", "display_mode": "LOCATE", @@ -644,7 +644,7 @@ def test_remote_recent(driver): # Navigate to M31 object details press_keys_and_validate( driver, - "RDRDDDR31RW", # W = 1s wait to let async key callback and UI state update settle + "RDRDDDDR31RW", # W = 1s wait to let async key callback and UI state update settle expected_values={ "ui_type": "UIObjectDetails", "object": {"display_name": "M 31"}, diff --git a/python/tests/website/test_web_remote_objects.py b/python/tests/website/test_web_remote_objects.py index 0006704ce..2f6f29b5a 100644 --- a/python/tests/website/test_web_remote_objects.py +++ b/python/tests/website/test_web_remote_objects.py @@ -17,7 +17,7 @@ This file adds coverage for the remaining Objects sub-items: - Objects > All Filtered - - Objects > By Catalog > Planets, Comets, NGC + - Objects > By Catalog > Planets, Comets, Asteroids, NGC - Objects > By Catalog > DSO... (nested submenu) - Objects > By Catalog > Stars... (nested submenu) - Objects > Custom (UIRADecEntry) @@ -34,10 +34,11 @@ By Catalog submenu (0-indexed): 0: Planets (UIObjectList, catalog "PL") 1: Comets (UIObjectList, catalog "CM") - 2: NGC (UIObjectList, catalog "NGC") - 3: Messier (UIObjectList, catalog "M") ← already tested - 4: DSO... (nested UITextMenu submenu) - 5: Stars... (nested UITextMenu submenu) + 2: Asteroids (UIObjectList, catalog "MP") + 3: NGC (UIObjectList, catalog "NGC") + 4: Messier (UIObjectList, catalog "M") ← already tested + 5: DSO... (nested UITextMenu submenu) + 6: Stars... (nested UITextMenu submenu) Key sequences from navigate_to_root_menu() (lands on Objects in root menu): R → enter Objects submenu (now at All Filtered, index 0) @@ -145,10 +146,10 @@ def test_objects_by_catalog_ngc(driver): navigate_to_root_menu(driver) # R = Objects submenu; D = By Catalog; R = enter By Catalog at Planets (0) - # DD = NGC (2); R = enter + # DDD = NGC (3); R = enter press_keys_and_validate( driver, - "RDRDDR", + "RDRDDDR", { "ui_type": "UIObjectList", "title": "NGC", @@ -158,6 +159,24 @@ def test_objects_by_catalog_ngc(driver): press_keys(driver, "ZL") # back to root +@pytest.mark.web +def test_objects_by_catalog_asteroids(driver): + """Objects > By Catalog > Asteroids opens the Asteroids object list.""" + login_to_remote(driver) + navigate_to_root_menu(driver) + + press_keys_and_validate( + driver, + "RDRDDR", + { + "ui_type": "UIObjectList", + "title": "Asteroids", + }, + ) + + press_keys(driver, "ZL") + + # --------------------------------------------------------------------------- # Objects > By Catalog > DSO... (nested submenu) # --------------------------------------------------------------------------- @@ -170,10 +189,10 @@ def test_objects_by_catalog_dso_submenu_entry(driver): navigate_to_root_menu(driver) # R = Objects submenu; D = By Catalog; R = enter By Catalog at Planets (0) - # DDDD = DSO... (4); R = enter + # DDDDD = DSO... (5); R = enter press_keys_and_validate( driver, - "RDRDDDDR", + "RDRDDDDDR", { "ui_type": "UITextMenu", "title": "DSO...", @@ -191,7 +210,7 @@ def test_objects_by_catalog_dso_first_item_is_abell(driver): press_keys_and_validate( driver, - "RDRDDDDR", + "RDRDDDDDR", { "ui_type": "UITextMenu", "title": "DSO...", @@ -212,7 +231,7 @@ def test_objects_by_catalog_dso_enter_catalog(driver): # Enter DSO..., navigate to Caldwell (DDDR), enter press_keys_and_validate( driver, - "RDRDDDDR", # enter DSO... + "RDRDDDDDR", # enter DSO... {"ui_type": "UITextMenu", "title": "DSO..."}, ) press_keys_and_validate( @@ -238,12 +257,12 @@ def test_objects_by_catalog_stars_submenu_entry(driver): login_to_remote(driver) navigate_to_root_menu(driver) - # By Catalog: ..., DSO...(4), Stars...(5) + # By Catalog: ..., DSO...(5), Stars...(6) # R = Objects submenu; D = By Catalog; R = enter By Catalog at Planets (0) - # DDDDD = Stars... (5); R = enter + # DDDDDD = Stars... (6); R = enter press_keys_and_validate( driver, - "RDRDDDDDR", + "RDRDDDDDDR", { "ui_type": "UITextMenu", "title": "Stars...", @@ -261,7 +280,7 @@ def test_objects_by_catalog_stars_first_item_is_bright_named(driver): press_keys_and_validate( driver, - "RDRDDDDDR", + "RDRDDDDDDR", { "ui_type": "UITextMenu", "title": "Stars...", 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 index bda020730..08af87ff9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,3 +1,1856 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = "==3.13.*" +resolution-markers = [ + "sys_platform == 'linux'", + "sys_platform == 'darwin'", +] +supported-markers = [ + "sys_platform == 'linux'", + "sys_platform == 'darwin'", +] + +[manifest] + +[[manifest.dependency-metadata]] +name = "cedar-solve" +version = "0.5.1" +requires-dist = ["numpy", "pillow", "scipy"] + +[[manifest.dependency-metadata]] +name = "python-libinput" +version = "0.3.0a0" +requires-dist = ["cffi"] + +[[manifest.dependency-metadata]] +name = "python-prctl" +version = "1.8.1" + +[[package]] +name = "adafruit-blinka" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-circuitpython-typing" }, + { 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-extended-bus" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adafruit-blinka" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/1c/26ccaf2d7a3fd3d16504173df591973b9f1de5520b0b4d81af038b9e4ed3/adafruit-extended-bus-1.0.2.tar.gz", hash = "sha256:f34e3c114e274e5aa475673794019af1ab438df6e45141e16201b6a8c4bea3ea", size = 18966, upload-time = "2021-06-14T15:03:48.711Z" } + +[[package]] +name = "adafruit-platformdetect" +version = "3.88.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/92/3d991a9e322855be20c2df771b632ed81f1640b43a9969524765da23f4af/adafruit_platformdetect-3.88.0.tar.gz", hash = "sha256:dc2188ddb348bfd2a02a9533263294cc0f1762bd9f6b3b20866547d98820cd71", size = 49558, upload-time = "2026-02-24T19:01:07.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/c4/32572c051f1554d73633a802447321bff2a2332ef4210d47de807afd26c7/adafruit_platformdetect-3.88.0-py3-none-any.whl", hash = "sha256:69e694d80d551c6cb8e39f731e6ee0de1f135e64cffee0e2a665b1f9579c10d7", size = 26964, upload-time = "2026-02-24T19:01:05.646Z" }, +] + +[[package]] +name = "adafruit-pureio" +version = "1.1.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/b7/f1672435116822079bbdab42163f9e6424769b7db778873d95d18c085230/Adafruit_PureIO-1.1.11.tar.gz", hash = "sha256:c4cfbb365731942d1f1092a116f47dfdae0aef18c5b27f1072b5824ad5ea8c7c", size = 35511, upload-time = "2023-05-25T19:01:34.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/9d/28e9d12f36e13c5f2acba3098187b0e931290ecd1d8df924391b5ad2db19/Adafruit_PureIO-1.1.11-py3-none-any.whl", hash = "sha256:281ab2099372cc0decc26326918996cbf21b8eed694ec4764d51eefa029d324e", size = 10678, upload-time = "2023-05-25T19:01:32.397Z" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, +] + +[[package]] +name = "astropy" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy-iers-data" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyerfa" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/c4/21be4313ddfde5f60e0607fd307f367b9e0f0bf153a89b10cbd036dd8cfd/astropy-8.0.1.tar.gz", hash = "sha256:45ca31d5b91fa294cd590a4791a32db94de7f9c8a343155f4d5877baa82351da", size = 7152500, upload-time = "2026-07-05T07:24:48.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/80/69a84af0b35d55a859cde38e16b71553840a156bdf7dc4b767ec2b2d2829/astropy-8.0.1-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:24813c764d5ea111e7f716127121e22a0cfe233ca1eac2ed23c4a3848390610e", size = 6635649, upload-time = "2026-07-05T07:24:31.957Z" }, + { url = "https://files.pythonhosted.org/packages/a6/34/074f367d5699a1008b24c50acc1539f05f2cfc881b1901f4b110d57eae20/astropy-8.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:222f0b9837e79fd3d101e6d4e4579e3aa98c490000bb01e35cd32fd3e3c12bf5", size = 6607950, upload-time = "2026-07-05T07:24:34.148Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/d29035da0a08e3b0b03d63abe9ce69b900d8ef7863dc557a2e34ebf56a4e/astropy-8.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a4044396c15969bb029b648521f7c25d294e982dbd23e7c2c7dd328178b8b98", size = 10414634, upload-time = "2026-07-05T07:24:36.129Z" }, + { url = "https://files.pythonhosted.org/packages/fa/14/6f4427419f8a02e1d0a885fb64d8d4454c5d443627aa7cb1a3a36e16abb7/astropy-8.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa11d56855e10107ea2231a6b6a33dbf1edbea6890adf34634c1f1d8f25c5a5a", size = 10452095, upload-time = "2026-07-05T07:24:38.424Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ef/a5cf36a402c4511a776405f12d0b35196f55f4312ce407012a2fbcf1e4e0/astropy-8.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b482bc6c57c966e6c6234410a1d9afbcf92bcac858cf287812f8c99ddc3fafc", size = 10407732, upload-time = "2026-07-05T07:24:40.774Z" }, +] + +[[package]] +name = "astropy-iers-data" +version = "0.2026.7.13.0.54.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/f2/8e52b70264edc5664c069939224405c6e9085fa3388403b8313303a62b82/astropy_iers_data-0.2026.7.13.0.54.2.tar.gz", hash = "sha256:d86e32e95e98a86f83b5b073627442924a0f45cf61a7828da4f565a17d726c2f", size = 1941259, upload-time = "2026-07-13T00:54:51.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/54/121240c06fff63d358f2a96187e927d05628fdf933a84246151954e95c7a/astropy_iers_data-0.2026.7.13.0.54.2-py3-none-any.whl", hash = "sha256:0f0b22f43d0917d78382f35ef13acd72600752d65289a3f91e39ea67653264cd", size = 1996278, upload-time = "2026-07-13T00:54:49.386Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "av" +version = "17.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/e3/477fa20578c284abeda08d91b63ee9abaebc93445d8feeb989d3d444bae1/av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3", size = 4288546, upload-time = "2026-06-07T05:52:55.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/87/8036b5c781bc3639ea04ef42d4e26da253bd4bd4311d8705b6a1c8824047/av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41", size = 22460847, upload-time = "2026-06-07T05:52:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/dfdf6fc7b17814b50d0aa9e7a7e37b87be91be3890f44b0d525433cd1fd1/av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9", size = 18159115, upload-time = "2026-06-07T05:52:06.64Z" }, + { url = "https://files.pythonhosted.org/packages/ad/13/64f6c466471cea225b8b2f4cdc51a571f8a286984b55a08d169b932fda5d/av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847", size = 33224427, upload-time = "2026-06-07T05:52:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/77/43/96b35170bf2e64e00a41748c6400ff73232dc0fc62ded283679fb07c7fe0/av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f", size = 35370183, upload-time = "2026-06-07T05:52:11.959Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b3/8e8b4b6498731bfbd88e8399a756543f8088f1bd33d08eab678b5aebe728/av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702", size = 24459265, upload-time = "2026-06-07T05:52:14.66Z" }, + { url = "https://files.pythonhosted.org/packages/14/ac/ceb84b7553db21f1143d817245c560d9267168e1e58b1a8eeae2b62c4d04/av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f", size = 34283709, upload-time = "2026-06-07T05:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/4115fd84148c9a1cf365096694be6ac882fd3cd3cdb7a2f35e71fecf1631/av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0", size = 25397573, upload-time = "2026-06-07T05:52:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/92e52d5ed0e0b84d9d93e52b4338c2713d8a44082b8696e6516fdae7c4e4/av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e", size = 36451495, upload-time = "2026-06-07T05:52:22.581Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "binho-host-adapter" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyserial" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/36/29b7b896e83e195fac6d64ccff95c0f24a18ee86e7437a22e60e0331d90a/binho-host-adapter-0.1.6.tar.gz", hash = "sha256:1e6da7a84e208c13b5f489066f05774bff1d593d0f5bf1ca149c2b8e83eae856", size = 10068, upload-time = "2020-06-04T19:38:11.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/6b/0f13486003aea3eb349c2946b7ec9753e7558b78e35d22c938062a96959c/binho_host_adapter-0.1.6-py3-none-any.whl", hash = "sha256:f71ca176c1e2fc1a5dce128beb286da217555c6c7c805f2ed282a6f3507ec277", size = 10540, upload-time = "2020-06-04T19:38:10.612Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cbor2" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/af/473c241e41c142ea06ebef8d1f660fa6ff928fb97210e7bec8ee5974f8cd/cbor2-6.1.2.tar.gz", hash = "sha256:6b43037a66947dee5af0abb1a4c3a13b3abac5a4a3f32f9771efbbcd030fd909", size = 86760, upload-time = "2026-06-02T19:01:29.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/dc/bc045c8f36317e4e5f7a60d94b36833139909fc32e3a65f44bc61a36def0/cbor2-6.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f1aa38c422d87ea61849b2a823b10b64053fb4da8763f19ac78ea9a69d682b2a", size = 408846, upload-time = "2026-06-02T19:00:55.476Z" }, + { url = "https://files.pythonhosted.org/packages/2b/36/d66f5f0dd98ecbdcfc7da1fbd423f7b3782a27719f0062a560476f00b334/cbor2-6.1.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ff7d0bd8ff432832338a8d2430aee34f8a082342480ff537c0ba90e2b8ff7894", size = 454624, upload-time = "2026-06-02T19:00:56.744Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/4884b9cf03db14dc5007825d5d1bf8678a75c49d4268d8e0c1c6e9580104/cbor2-6.1.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c1eedf3290d88a5f663bd8b4b8f0f0e2103d0594c293fa5f4e62e53100972309", size = 466585, upload-time = "2026-06-02T19:00:58.209Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/36a15beb3915f56a79d6e9213c6d40c0f5cb90cd3462923f555d78068847/cbor2-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3049b04bddf9a5a2d0e5bb25dccdaf4552fcaf607b404e249d4f78f010fcc7d0", size = 521678, upload-time = "2026-06-02T19:00:59.524Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3f/e899313371ebeb7a191d751de97ccd8242abc24bbc9d8e2c58e04475cfb0/cbor2-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96eb687a62040401668f06a85de8f47361ef44574de1493899e0ec678109fc04", size = 534044, upload-time = "2026-06-02T19:01:00.875Z" }, +] + +[[package]] +name = "cedar-solve" +version = "0.5.1" +source = { git = "https://github.com/smroid/cedar-solve?rev=d8ff1d857a363c88917fd8e126ab90e24b1cfbcc#d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" } +dependencies = [ + { name = "numpy" }, + { 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/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { 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/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h3" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/1c/12f1e2842d6493de4dd8244538c30a556712e9a6b25c5151a0e0e522a67e/h3-4.5.0.tar.gz", hash = "sha256:a1e279a1674fc799445c710e35bc4b1b388a406c881d8b5e59a9b8bebeb5bb43", size = 180838, upload-time = "2026-05-30T00:59:24.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a9/bb36156db3a1f9eebb27de9c72d1229c69a643bc5bf9f59cbc05a8b0a634/h3-4.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44f9eee75985ecf06af82cfbce5fe7a0fd1cae73bee53d15155fe8fdb165578a", size = 843578, upload-time = "2026-05-30T00:59:07.415Z" }, + { url = "https://files.pythonhosted.org/packages/00/d0/4256f2515f8dd1a322e95a7a5f4174ecc405098f8b217d1d29767989c171/h3-4.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8fe70eef1c122e7465f3b9c57f793fa1a6885cf067be3a83423c0f30c0d80c", size = 1007119, upload-time = "2026-05-30T00:59:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/a60d26681ac540788c4ef656960084c9cbf4c24657f7e7347f07e97ba27f/h3-4.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df23f9ff0a9ff9c6195f48ebc8fb8fc6d50c2025ec37649991749d5282a2950f", size = 1059523, upload-time = "2026-05-30T00:59:09.854Z" }, + { url = "https://files.pythonhosted.org/packages/de/76/6e2eab23667a6ee153e3c369fb6fb793d4b09c81030495da989e8e5bf66d/h3-4.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e8af93363b9b14fe1797a2557b22bb158b1be7696f145ea8ee6f8b9315860fa", size = 1069134, upload-time = "2026-05-30T00:59:11.235Z" }, +] + +[[package]] +name = "healpy" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/4e/6f5328f375f88c8c38314f24f82b7e1486ec0358c557779500382db1503c/healpy-1.19.0.tar.gz", hash = "sha256:28e839cb885a23d36c77fc3423a3cb9271a07fda94085bd12fc329f941130ec5", size = 4075006, upload-time = "2025-12-02T08:27:19.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7d/769d1f59ba6491cd835ac143639fdc59e5ade735c5aa14fd4f5cd133a30f/healpy-1.19.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:97155fe60e8309caa610cee6a26028219f181314dc8ad33517070ec48ea316f7", size = 1724949, upload-time = "2025-12-02T08:26:59.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/99/5264236d706f22d24268ea55a049822870e15c52929c065556cb9e47d379/healpy-1.19.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:70171ba093c9d7740d7248560609f6d8c17e6e1b9543e7b732f092b2a03dda55", size = 2590717, upload-time = "2025-12-02T08:27:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/97/c0/d3ce7ecbb821e4d2914bf9a0dfbf79cf2fcffe02e059d88b24b54693a943/healpy-1.19.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:82b1f40ce8a982bf209a06e0f48b389ff1cbcd2e1524f12a2a78e92e600907b2", size = 2590479, upload-time = "2025-12-02T08:27:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/314bf8c493701b5842cdd74a1b5b34cea1522444042efad5b7cb17b8d159/healpy-1.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:086bae2510ac60f7cf9bbb520e1d1b8864cc8fca74b46b16e24857e74d08f896", size = 8198878, upload-time = "2025-12-02T08:27:03.119Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d4/a60ed9a50768ff5e896dd94d878496ae16767925ea32c49d5a4189ab818a/healpy-1.19.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:920c0a1c6749c05c8ad9522a5a2630f7bc83124c5742ef50f91b9f5e6a1bdcc7", size = 8205835, upload-time = "2025-12-02T08:27:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/5f4c989b53423bbad07c91a4b9d52de5cde9f1154e3d2b145b397e1b8cd8/healpy-1.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:26e3e6c50f2d256c9218f0fb0624406a93c4f22bc66deebf51a0f31fd5594b89", size = 8991846, upload-time = "2025-12-02T08:27:06.123Z" }, + { url = "https://files.pythonhosted.org/packages/d5/df/00636a5d2f1141b1fa9646069436c4fc68b35ac2c53ef520f8812b900f05/healpy-1.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bc3c243985abf1f1b5d91da12a23ceed49181dd9d0e46ed362babdc92c814aa8", size = 9325719, upload-time = "2025-12-02T08:27:08.138Z" }, +] + +[[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/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, +] + +[[package]] +name = "luma-core" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbor2" }, + { 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/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +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/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, +] + +[[package]] +name = "numpy-quaternion" +version = "2024.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { 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/83/60/135ab1c887344d4f1e70a4025733b2e9e19f2349ac21106b713911620a40/numpy_quaternion-2024.0.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0cd3f2256debaffd8407d1829c1ced71d3b910e75245152d1be673fcb7f23f08", size = 86875, upload-time = "2025-11-24T18:51:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/4f/0b/26ba2dbfe74da3a956828dee249483fdb90d0f1b08b2a55872ff20e12736/numpy_quaternion-2024.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:070df97e1ad59d6a41b4759ddba53e214488de63c948f144c3c61c9374a64f8d", size = 61668, upload-time = "2025-11-24T18:51:57.82Z" }, + { url = "https://files.pythonhosted.org/packages/af/2b/bb67708f88beea90bddc74229ac7eaa1d9c38c7415179e16a6cba013f5b3/numpy_quaternion-2024.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87f062c9258baa5c00ee3dc8c90553314668551d8893d500e101eae25fc4826f", size = 55901, upload-time = "2025-11-24T18:51:50.39Z" }, + { url = "https://files.pythonhosted.org/packages/fe/21/562f81ebae486f6068c12a2be7523c08c2a21110ed0773a1d38088248109/numpy_quaternion-2024.0.13-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b5596e429f3c15d736f23380c7d054903e44fdd4d07a2b9af6f75ec6c9acfe4c", size = 190859, upload-time = "2025-11-24T18:51:47.196Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/3fe4f7957d6d79d508ff99a62951034bd19956f4d2c98ada26d5575ff3cc/numpy_quaternion-2024.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1771afd3abe8477adc0af1fe7b2542eaebf31fafa64a134059e21f8b606d2f0e", size = 185263, upload-time = "2025-11-24T18:51:38.218Z" }, + { url = "https://files.pythonhosted.org/packages/77/77/77e84011dbe4bb08504e45052d7177efeed90cc66139922b6de467a9a4a3/numpy_quaternion-2024.0.13-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:eb438f4d9ab1fd1697f884be0840944d0c55dd934065811473237451b96ec2e2", size = 87811, upload-time = "2025-11-24T18:51:39.625Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3b/343695fc743d2a0814ef9a221a52733c576abe08d17f3795e5b84bfa8355/numpy_quaternion-2024.0.13-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e0a299e25b6d874b4cd169afd0cf4b703a4ddc8817513eb425fa5a6488001116", size = 62102, upload-time = "2025-11-24T18:51:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c5/6a105c9a4ffbe3ec51cc45ed916f0a0d9aae35dc9a8dfd29e8cd66122043/numpy_quaternion-2024.0.13-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:41a84c60e50533f833c536475dc49bce4e53a5c100c639cf503f37216b49c8f8", size = 56447, upload-time = "2025-11-24T18:51:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/61/1b/17612dc0517f30ea2679de936975a5c6c827e299ddcbb2b7cc9471b3de10/numpy_quaternion-2024.0.13-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:af4ee46bd834a822c200fb1dc8dc15bee8fa97e2286227fdd0acf243e6974fb9", size = 196462, upload-time = "2025-11-24T18:51:36.13Z" }, + { url = "https://files.pythonhosted.org/packages/ef/08/57ea9c58700211ef7ecbb30ee3bb076c9f9a4f12595f270b4cf5ea2da1e9/numpy_quaternion-2024.0.13-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77a6c9c10de8636cf3b7706045ef21edfc09746f73d4adb883b525ddcb19823a", size = 193631, upload-time = "2025-11-24T18:52:09.318Z" }, +] + +[[package]] +name = "openexr" +version = "3.4.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/ca/b7aa434bcde0e7222683415ee15121fdd5cf9b0eb6c34f81d83603cc36ae/openexr-3.4.12.tar.gz", hash = "sha256:877da800b30146e5e29851da2a80147883244966f5b2e932e04f1f1a06ff4fc7", size = 25610803, upload-time = "2026-05-25T02:08:10.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/dc/be431b0b11551b3700c539e8b1296475accdfec2c665b9fe708fddf7b27d/openexr-3.4.12-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:3031ed47d3a579d3fa6998d0a6dad22012ce9fd6a33485fa5339ca4a079d0665", size = 2159902, upload-time = "2026-05-25T02:07:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/01/3c/6b520d85f0e37ca01e88601e744472f4768652748a4e7c9f2933c1ba914b/openexr-3.4.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:553f4267221490e846b7f63edcca807fac1757cd242a49063f048fd0e757029c", size = 1023864, upload-time = "2026-05-25T02:07:28.398Z" }, + { url = "https://files.pythonhosted.org/packages/49/43/93762fdef22afb648748910b2c7a4ce58b04a1f3e5d4bdb7fd071b32617b/openexr-3.4.12-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4dc03aa88a57d47c49780bee40c4f0febabf8ed150e2bd60e55f3ee6bea62493", size = 1167448, upload-time = "2026-05-25T02:07:30.381Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f6/36f53b26114955df4c996abb96edb7fc6112fa1db52e30daf1c64b6f9ab1/openexr-3.4.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8be14c4794f4ad19112fbbed7767c5bf6216435f27cb42a499cdab0f3d26562", size = 2159502, upload-time = "2026-05-25T02:07:33.944Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +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/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, +] + +[[package]] +name = "pandas-stubs" +version = "3.0.3.260530" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +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", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-bno055", marker = "sys_platform == 'linux'" }, + { name = "adafruit-extended-bus", marker = "sys_platform == 'linux'" }, + { name = "aiofiles" }, + { name = "av" }, + { name = "cedar-solve" }, + { name = "dataclasses-json" }, + { name = "dbus-python", marker = "platform_machine == 'aarch64'" }, + { name = "flask" }, + { name = "flask-babel" }, + { name = "gpsdclient" }, + { name = "grpcio" }, + { name = "healpy" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "libarchive-c" }, + { name = "luma-lcd" }, + { name = "luma-oled" }, + { name = "numpy" }, + { name = "numpy-quaternion" }, + { name = "pandas" }, + { name = "picamera2", marker = "platform_machine == 'aarch64'" }, + { name = "pidng", marker = "sys_platform == 'linux'" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pydeepskylog" }, + { name = "pyerfa" }, + { name = "pygobject", marker = "platform_machine == 'aarch64'" }, + { name = "pyjwt" }, + { name = "python-libinput", marker = "platform_machine == 'aarch64'" }, + { name = "python-pam", marker = "sys_platform == 'linux'" }, + { name = "python-prctl", marker = "platform_machine == 'aarch64'" }, + { name = "pytz" }, + { name = "requests" }, + { name = "rpi-gpio", marker = "platform_machine == 'aarch64'" }, + { name = "rpi-hardware-pwm", marker = "platform_machine == 'aarch64'" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sh" }, + { name = "simplejpeg" }, + { name = "skyfield" }, + { name = "smbus2" }, + { name = "spidev", marker = "platform_machine == 'aarch64'" }, + { name = "timezonefinder" }, + { name = "tqdm" }, + { name = "videodev2", marker = "sys_platform == 'linux'" }, + { name = "waitress" }, +] + +[package.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", marker = "sys_platform == 'linux'" }, + { name = "adafruit-circuitpython-bno055", marker = "sys_platform == 'linux'" }, + { name = "adafruit-extended-bus", marker = "sys_platform == 'linux'" }, + { name = "aiofiles" }, + { name = "av" }, + { name = "cedar-solve", git = "https://github.com/smroid/cedar-solve?rev=d8ff1d857a363c88917fd8e126ab90e24b1cfbcc" }, + { name = "dataclasses-json" }, + { name = "dbus-python", marker = "platform_machine == 'aarch64'" }, + { name = "flask" }, + { name = "flask-babel" }, + { name = "gpsdclient" }, + { name = "grpcio" }, + { name = "healpy" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "libarchive-c" }, + { name = "luma-lcd" }, + { name = "luma-oled" }, + { name = "numpy" }, + { name = "numpy-quaternion" }, + { name = "pandas" }, + { name = "picamera2", marker = "platform_machine == 'aarch64'" }, + { name = "pidng", marker = "sys_platform == 'linux'" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pydeepskylog" }, + { name = "pyerfa" }, + { name = "pygobject", marker = "platform_machine == 'aarch64'" }, + { name = "pyjwt" }, + { name = "python-libinput", marker = "platform_machine == 'aarch64'", specifier = "==0.3.0a0" }, + { name = "python-pam", marker = "sys_platform == 'linux'" }, + { name = "python-prctl", marker = "platform_machine == 'aarch64'" }, + { name = "pytz" }, + { name = "requests" }, + { name = "rpi-gpio", marker = "platform_machine == 'aarch64'" }, + { name = "rpi-hardware-pwm", marker = "platform_machine == 'aarch64'" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sh", specifier = ">=1.14,<2" }, + { name = "simplejpeg" }, + { name = "skyfield" }, + { name = "smbus2" }, + { name = "spidev", marker = "platform_machine == 'aarch64'" }, + { name = "timezonefinder" }, + { name = "tqdm" }, + { name = "videodev2", marker = "sys_platform == 'linux'" }, + { name = "waitress" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "luma-emulator" }, + { name = "mypy" }, + { name = "pandas-stubs" }, + { name = "pyhotkey", marker = "platform_machine == 'aarch64'" }, + { name = "pynput", marker = "platform_machine == 'aarch64'" }, + { name = "pytest" }, + { name = "selenium" }, + { name = "types-aiofiles" }, + { name = "types-pynput" }, + { name = "types-pytz" }, + { name = "types-requests" }, + { name = "types-tqdm" }, + { name = "types-waitress" }, + { name = "xlrd", specifier = ">=2.0.1" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pycairo" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydeepskylog" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +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/7d/d9/3448a57cb5bd19950de6d6ab08bd8fbb3df60baa71726de91d73d76c481b/pyerfa-2.0.1.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b282d7c60c4c47cf629c484c17ac504fcb04abd7b3f4dfcf53ee042afc3a5944", size = 341818, upload-time = "2024-11-11T15:22:16.467Z" }, + { url = "https://files.pythonhosted.org/packages/11/4a/31a363370478b63c6289a34743f2ba2d3ae1bd8223e004d18ab28fb92385/pyerfa-2.0.1.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be1aeb70390dd03a34faf96749d5cabc58437410b4aab7213c512323932427df", size = 329370, upload-time = "2024-11-11T15:22:17.829Z" }, + { url = "https://files.pythonhosted.org/packages/cb/96/b6210fc624123c8ae13e1eecb68fb75e3f3adff216d95eee1c7b05843e3e/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0603e8e1b839327d586c8a627cdc634b795e18b007d84f0cda5500a0908254e", size = 692794, upload-time = "2024-11-11T15:22:19.429Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e0/050018d855d26d3c0b4a7d1b2ed692be758ce276d8289e2a2b44ba1014a5/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e43c7194e3242083f2350b46c09fd4bf8ba1bcc0ebd1460b98fc47fe2389906", size = 738711, upload-time = "2024-11-11T15:22:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f5/ff91ee77308793ae32fa1e1de95e9edd4551456dd888b4e87c5938657ca5/pyerfa-2.0.1.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:07b80cd70701f5d066b1ac8cce406682cfcd667a1186ec7d7ade597239a6021d", size = 722966, upload-time = "2024-11-11T15:22:21.905Z" }, +] + +[[package]] +name = "pyftdi" +version = "0.57.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyserial" }, + { 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/e1/91/718acf3e2a9d08a6ddcc96bd02a6f63c99ee7ba14afeaff2a51c987df0b9/pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2", size = 13090765, upload-time = "2024-09-29T14:27:02.377Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/9cb315de851a7682d9c7568a41ea042ee98d668cb8deadc1dafcab6116f0/pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171", size = 12381704, upload-time = "2024-09-29T14:27:10.228Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8f/617a1196e31ae3b46be6949fbaa95b8c93ce15e0544266198c2266cc1b4d/pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b", size = 13581091, upload-time = "2024-09-29T11:30:27.653Z" }, + { url = "https://files.pythonhosted.org/packages/3b/87/2851a564e40a2dad353f1c6e143465d445dab18a95281f9ea458b94f3608/pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b", size = 14273844, upload-time = "2024-09-29T11:40:04.138Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/aa23aa2e70bcba42c989c02e7228273c30f3b44b9b264abb93eaeff43ad7/pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c", size = 13951197, upload-time = "2024-09-29T11:40:06.785Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pygobject" +version = "3.56.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo" }, +] +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 = "pyobjc-framework-applicationservices", marker = "sys_platform != 'linux'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform != 'linux'" }, + { 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 = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/6e/8e928d5e3025529ed92c6eb5fd88a5e6e485cc6df945c541f29b4af7f2c6/pyobjc_framework_applicationservices-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8749290f796e6cca341d443769b79329dde5d157bcc4413c1f7fdb68ea4a8e48", size = 32782, upload-time = "2026-06-19T16:05:40.284Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/c7b5a31777fe2ce7c07b9c16941ff4fbf0a150bf755164d96228d32ccb4f/pyobjc_framework_applicationservices-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9ee11677fbd6a0987234814c7dde88ffd11242e8c1f76952e6654ea07f2370ac", size = 33048, upload-time = "2026-06-19T16:05:41.308Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/8c/154e8f34923b24aade64a20eca2b759f8f67e109654308103080751f246f/pyobjc_framework_coretext-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5c5a3c6e2d905a17efb15572dad97ce582feeab5c3b92537015445e0e0bb46de", size = 30116, upload-time = "2026-06-19T16:09:52.219Z" }, + { url = "https://files.pythonhosted.org/packages/01/61/f53458c8f7fe74008e342946eca1fa82b777b284d4e13d8bd2e3e5724cab/pyobjc_framework_coretext-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5c979058c77df8cd3dac5fd7db4c484f9886fbe09e2687bfaf269a856f631f78", size = 30659, upload-time = "2026-06-19T16:09:53.034Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, +] + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iniconfig" }, + { 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 = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, +] + +[[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/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, +] + +[[package]] +name = "rpi-gpio" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/0f/10b524a12b3445af1c607c27b2f5ed122ef55756e29942900e5c950735f2/RPi.GPIO-0.7.1.tar.gz", hash = "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70", size = 29090, upload-time = "2022-02-06T15:15:06.022Z" } + +[[package]] +name = "rpi-hardware-pwm" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/32/ecd3e230a806c7894a13780a1c7d614f0d316d85cde7a2256626e2af2c45/rpi_hardware_pwm-0.3.1.tar.gz", hash = "sha256:dcb2627ab1248a9c532c86e013914416c55f11bd70976dd6ec6ecfd1109b0fe8", size = 5261, upload-time = "2026-02-09T17:10:41.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/17/c8d4d2efa5bb1af38644a6202c26e4d990d30cf6124ace32f33e7c488e9e/rpi_hardware_pwm-0.3.1-py3-none-any.whl", hash = "sha256:ad0f7f3e8ec83dd76a552cff92ea1dbb1bf773210316519ea66e3e85d3ac9ae0", size = 5112, upload-time = "2026-02-09T17:10:41.003Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { 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/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, +] + +[[package]] +name = "selenium" +version = "4.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { 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/02/0f/daf4a70829be7c1550b914c98b3abbd15404d00899835432ae8d4a9be502/sgp4-2.25-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c4d4eab0f2c94aad3a0ab0bedd59f2137484af5480a3b40df8e4ab5a1fbc6b86", size = 162974, upload-time = "2025-08-04T18:02:02.816Z" }, + { url = "https://files.pythonhosted.org/packages/27/88/af20e342590c3ede18cc8dc6a1e1da708f576e1a97dcb69e2870e739ae21/sgp4-2.25-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2822ca25f3724694bfced16cad8b3018678bee47fa3baf4eea20876d0e35ad33", size = 161957, upload-time = "2025-08-04T18:02:03.835Z" }, + { url = "https://files.pythonhosted.org/packages/6e/14/81f0df0cc39bdc95336a6f5834c84a6e5f79b5e728918cb9dadff3278017/sgp4-2.25-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7beca36492eb6d20ef15eeedd9520b8af4fa0cbaaae46a9269d5a2e7c8e56e46", size = 236195, upload-time = "2025-08-04T18:02:05.121Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a7/3740791f656d9b7ad78da7c0d9f6f842a18642fead2d26b2d69fb701892e/sgp4-2.25-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e9dfd18cacf6bfb1faad29c89a6cec98a642558f805851080dea9c394520db2", size = 232992, upload-time = "2025-08-04T18:02:06.086Z" }, + { url = "https://files.pythonhosted.org/packages/62/45/0e35398ef8d4b07ecfa9f7f680e183b2b6af9215a56af34f9e621c29b495/sgp4-2.25-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5789b7add136362684dfcbf0862919f8c3018f74ab11a05a9964edd5fdd4d2a7", size = 235584, upload-time = "2025-08-04T18:02:07.152Z" }, + { url = "https://files.pythonhosted.org/packages/3a/47/8231e3d4a88341316ec8d0eb98d3a8a972477d8b038555259522735a8371/sgp4-2.25-py3-none-any.whl", hash = "sha256:4f39ecf6c2663109fed04adfe9982815ac83893271b521d92d5b186820f8c78e", size = 137376, upload-time = "2026-04-27T18:29:23.71Z" }, +] + +[[package]] +name = "sh" +version = "1.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/09/89c28aaf2a49f226fef8587c90c6386bd2cc03a0295bc4ff7fc6ee43c01d/sh-1.14.3.tar.gz", hash = "sha256:e4045b6c732d9ce75d571c79f5ac2234edd9ae4f5fa9d59b09705082bdca18c7", size = 62851, upload-time = "2022-07-18T07:17:50.947Z" } + +[[package]] +name = "simplejpeg" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/64/da60f0ba80570f9a36c9b6e055f4364bda2c547715296d5773d2ea6d5a60/simplejpeg-1.9.0.tar.gz", hash = "sha256:5ac7d9489eeb812c2e7ea5c283994a29d9fefdfe5ed7b86c09d485e0dd366689", size = 3965764, upload-time = "2025-10-10T10:58:08.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/32/c2d5baa4af82551feae9082d1800c7c7e96586f67292dad4e1442298ad34/simplejpeg-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52b4e8e0d68caa3e0962415daff12df2911df36a697e53a75878a45e9e34e9ad", size = 423518, upload-time = "2025-10-10T10:57:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/84/97/6a4018d4c1c980d9f4c48c29d3d6bfaeb18444dd8e82997246c9950fb79a/simplejpeg-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:475d1932f50264d63dbc752678b5a6629ed8c6b0f5edfbe4e9cd7881d5f8a1f1", size = 400574, upload-time = "2025-10-10T10:57:46.475Z" }, + { url = "https://files.pythonhosted.org/packages/88/8b/d8ca384f1362371d61690d7460d3ae4cec4a5a25d9eb06cd15623de3725a/simplejpeg-1.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0c375130f73bb08229a3ded392d84ee2d916b3e87e7ec5d2ac4e47b7144346a", size = 448142, upload-time = "2025-10-10T10:57:47.894Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0a/58d6d8e997ee01486cfcfd4406a74638f2f63bb65122694b10411dadf1d5/simplejpeg-1.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d00feb1cc0348aba0a41db6dbda4db468db92099b1b3d473159e6f68aa990795", size = 406252, upload-time = "2025-10-10T10:57:49.158Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "skyfield" +version = "1.54" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { 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..9d61cf398 100644 --- a/python/views/network.html +++ b/python/views/network.html @@ -4,6 +4,9 @@
{{ _('Network Settings') }}
+ {% if status_message %} +

{{ status_message }}

+ {% endif %}
@@ -33,17 +36,7 @@
{{ _('Network Settings') }}
- -
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/shell.nix b/shell.nix new file mode 100644 index 000000000..d17bd1da0 --- /dev/null +++ b/shell.nix @@ -0,0 +1,34 @@ +# Classic-nix entry point for the devShell, used by direnv (.envrc: `use nix`). +# +# `use flake` would copy the entire working tree (~1.3 GB: astro_data, images, +# docs, python/.venv, …) into /nix/store on every flake change, because nix +# hashes the whole flake source. The devShell only needs the flake files, +# nixos/, and python/ (sans .venv) — so build a filtered copy (~25 MB) and +# evaluate the flake through flake-compat with that as its source. +# +# CI and remote builds keep evaluating the flake directly via github: refs; +# they fetch this file with the repository but do not evaluate it. +let + flake-compat = builtins.fetchTarball { + url = "https://github.com/edolstra/flake-compat/archive/5edf11c44bc78a0d334f6334cdaf7d60d732daab.tar.gz"; + sha256 = "0yqfa6rx8md81bcn4szfp0hjq2f3h9i8zjzhqqyfqdkrj5559nmw"; + }; + + # Only what the devShell evaluation actually reads. + wanted = [ "flake.nix" "flake.lock" "nixos" "python" ]; + + src = builtins.path { + path = ./.; + name = "pifinder-devshell-src"; + filter = path: type: + let + rel = builtins.substring (builtins.stringLength (toString ./. + "/")) + (builtins.stringLength path) (toString path); + top = builtins.head (builtins.split "/" rel); + in + builtins.elem top wanted && rel != "python/.venv"; + }; + + flake = import flake-compat { inherit src; }; +in +flake.defaultNix.devShells.${builtins.currentSystem}.default 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" +}