diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..414c23482 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,102 @@ +name: Build PiFinder NixOS + +on: + push: + branches: [main] + pull_request: + types: [labeled, synchronize, opened] + workflow_dispatch: + +jobs: + build: + if: | + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'preview') + # Prefer self-hosted aarch64 runner (Pi5), fallback to ubuntu with QEMU + runs-on: ${{ vars.RUNNER_LABELS || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@v4 + + - name: Install Nix (self-hosted) + if: runner.arch == 'ARM64' + run: | + if ! command -v nix &> /dev/null; then + curl -L https://nixos.org/nix/install | sh -s -- --daemon + fi + + - uses: cachix/install-nix-action@v27 + if: runner.arch != 'ARM64' + with: + extra_nix_config: | + extra-platforms = aarch64-linux + extra-system-features = big-parallel + + - uses: cachix/cachix-action@v15 + with: + name: pifinder + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Register QEMU binfmt for aarch64 + if: runner.arch != 'ARM64' + run: sudo apt-get update && sudo apt-get install -y qemu-user-static + + - name: Build NixOS system closure + run: | + # Native build on aarch64, cross-compile via QEMU on x86_64 + SYSTEM_FLAG="" + if [[ "$(uname -m)" != "aarch64" ]]; then + SYSTEM_FLAG="--system aarch64-linux" + fi + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + $SYSTEM_FLAG \ + -L --no-link + + - name: Push to Cachix + if: github.event_name != 'pull_request' + run: | + SYSTEM_FLAG="" + if [[ "$(uname -m)" != "aarch64" ]]; then + SYSTEM_FLAG="--system aarch64-linux" + fi + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + $SYSTEM_FLAG \ + --json | jq -r '.[].outputs.out' | cachix push pifinder + + update-unstable: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update unstable channel + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DATE=$(date -I) + + # Update main entry + jq --arg d "$DATE" \ + '.channels.unstable.versions = [{version:"main", ref:"main", date:$d, notes:"Latest development"}]' \ + versions.json > tmp.json && mv tmp.json versions.json + + # Add all preview-labeled PRs (no limit) + gh pr list --label preview --json number,title,headRefOid | \ + jq --arg d "$DATE" '.[] | {version:("PR #" + (.number|tostring)), ref:.headRefOid, date:$d, notes:.title}' | \ + jq -s '.' > prs.json + + jq --slurpfile prs prs.json '.channels.unstable.versions += $prs[0]' \ + versions.json > tmp.json && mv tmp.json versions.json + + rm prs.json + + - name: Commit versions.json + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add versions.json + git diff --staged --quiet || git commit -m "chore: update unstable" + git push diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..d2a38a2b7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version (e.g., 2.5.0)' + required: true + notes: + description: 'Release notes' + required: true + type: + description: 'Release type' + type: choice + options: + - stable + - beta + default: stable + source_branch: + description: 'Source branch (default: main, use release/X.Y for hotfixes)' + required: false + default: 'main' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_branch }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create tag + run: | + TAG="v${{ inputs.version }}" + [[ "${{ inputs.type }}" == "beta" ]] && TAG="${TAG}-beta" + git tag "$TAG" + git push origin "$TAG" + echo "TAG=$TAG" >> $GITHUB_ENV + + - uses: cachix/install-nix-action@v27 + with: + extra_nix_config: | + extra-platforms = aarch64-linux + extra-system-features = big-parallel + + - uses: cachix/cachix-action@v15 + with: + name: pifinder + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + - name: Register QEMU binfmt for aarch64 + run: sudo apt-get update && sudo apt-get install -y qemu-user-static + + - name: Build and push closure to Cachix + run: | + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --system aarch64-linux -L + nix build .#nixosConfigurations.pifinder.config.system.build.toplevel \ + --system aarch64-linux --json | jq -r '.[].outputs.out' | cachix push pifinder + + - name: Build SD image + run: | + nix build .#images.pifinder \ + --system aarch64-linux \ + -L -o result + + - name: Update versions.json on main + run: | + # versions.json always lives on main, even for hotfix releases + git fetch origin main + git checkout main + + DATE=$(date -I) + CHANNEL="${{ inputs.type }}" + VERSION="${{ inputs.version }}" + NOTES="${{ inputs.notes }}" + REF="$TAG" + + jq --arg v "$VERSION" --arg r "$REF" --arg d "$DATE" --arg n "$NOTES" --arg c "$CHANNEL" \ + '.channels[$c].versions = ([{version:$v, ref:$r, date:$d, notes:$n}] + .channels[$c].versions)[:3]' \ + versions.json > tmp.json && mv tmp.json versions.json + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add versions.json + git commit -m "release: $TAG" + git push origin main + + - name: Upload SD image artifact + uses: actions/upload-artifact@v4 + with: + name: pifinder-sd-image + path: result/sd-image/*.img.zst + retention-days: 90 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ env.TAG }} + name: PiFinder ${{ env.TAG }} + body: ${{ inputs.notes }} + prerelease: ${{ inputs.type == 'beta' }} + files: result/sd-image/*.img.zst diff --git a/RELEASE.md b/RELEASE.md index d2e1135c5..10b3633c6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,38 +1,65 @@ -# PiFinder v2.5.0 Release Notes +# PiFinder v2.4.0 Release Notes -## New Features +## Major New Features -### T9 Input Support -Added T9-style text input for searching object catalogs using the hardware keypad. Type object names by pressing number keys to quickly filter catalog searches, just like texting on an old phone. Includes cached digit mapping for fast performance. +### Sky Quality Meter (SQM) - Experimental +A new Sky Quality Meter feature measures sky brightness and displays the corresponding Bortle scale classification. This helps observers assess observing conditions at their location. +- Real-time SQM measurement with Bortle class display +- Calibration UI for accurate readings across different camera configurations +- Camera profiles for IMX296 and other supported sensors +- Rotating constellation/SQM display in title bar -### Harris Globular Cluster Catalog -New catalog loader for the Harris Globular Cluster catalog, adding 147 globular clusters to the searchable database. +### Camera Auto Exposure +Automatic exposure control using a PID-based algorithm that adapts to changing sky conditions. +- Asymmetric tuning for responsive exposure adjustments +- Exposure sweep functionality for calibration +- SNR-based thresholds derived from camera profiles +- Visual exposure overlay in preview mode -### Stellarium+ Mobile Support -Added support for connecting to Stellarium+ Mobile, including the ACK command needed for reliable connections without timeouts. +### Cedar-Detect System Service +Cedar-Detect now runs as a dedicated system service rather than a subprocess, improving stability and resource management. This change is transparent to users but provides better crash recovery and memory handling. -### Chinese (zh) Locale -Full Chinese language translation with a custom Sarasa Mono font for proper CJK character rendering. Chinese can be selected from the language settings menu. +## Improvements -## Bug Fixes +### GPS +- **Configurable baud rate**: GPS baud rate can now be configured via the Advanced settings menu (#345) +- **Reorganized GPS settings**: GPS options now grouped under Settings > Advanced > GPS Settings +- **GPSD improvements**: Fixed lock_type handling for GPSD-based GPS messages (#358) +- **Early dongle fix**: Fixed issue where some GPS dongles never reported sky data (#373) + +### Catalogs +- **WDS catalog**: Improved loading speed with background loading for better UI responsiveness (#352, #355) +- **Async search**: Search is now asynchronous for improved responsiveness +- **Comet catalog**: Better refresh and download handling with non-blocking updates (#353) +- **Bright stars**: Fixed off-by-one error in bright stars catalog -- **GPSD + Cedar crash fix**: Fixed an issue where early GPS dongles that never reported sky data could stall the GPS process. Also fixed auth-related solver crashes with Cedar. -- **Stellarium J2000 epoch fix**: Corrected coordinate epoch handling when connected to Stellarium, which uses J2000 rather than JNOW. -- **Double update bug**: Fixed an issue in the update script that could cause updates to run twice. -- **Eyepiece sorting**: Eyepieces are now always sorted by focal length (magnification) in the equipment list. -- **Push-to display fix**: Fixed a display issue in the push-to screen introduced by the Chinese locale update. +### User Interface +- **EQ mode**: Push-to now uses +/- buttons rather than arrows for clearer directional guidance +- **Settings reorganization**: Advanced settings (PiFinder Type, Camera Type, GPS Settings) now grouped under an "Advanced" submenu +- **Preview cleanup**: Removed background subtraction and gamma functions from preview +- **Experimental menu**: Moved higher in menu structure for easier access -## Developer Improvements +## Bug Fixes -- **Fake sys_utils environment toggle**: Added `PIFINDER_USE_FAKE_SYS_UTILS` environment variable for deterministic local development and testing without hardware dependencies. -- **Solver log level**: Reduced solver log verbosity for cleaner output. +- Fixed preview crash when marking menu items are missing +- Fixed crash when screenshot title contains a slash +- Fixed OSX logging levels by applying log config in each subprocess +- Fixed various solver stability issues with improved error handling +- Fixed typo in SkySafari documentation +- Fixed typo in menu ## Hardware & Documentation -- Added a `pi_mount_noinserts.stl` variant for cases without heat-set inserts. -- Removed references to the discontinued HQ camera and assembled kit version. -- Various typo and spelling fixes throughout the documentation. +- Adjusted GPS antenna holder sizing +- Improved case tolerances and new dovetail design +- Updated shroud with tighter tolerance +- Added instructions to build guide for testing LEDs and buttons +- Clarified DIY vs Assembled parts in case documentation + +## Migration Notes + +This release includes a migration script (`migration_source/v2.4.0.sh`) that sets up the Cedar-Detect system service. The migration will run automatically during the update process. --- -**Full Changelog**: 19 commits from release to main +**Full Changelog**: 40 commits from release to main diff --git a/case/v3/common/pi_mount.stl b/case/v3/common/pi_mount.stl index 68501b0a2..c0b709da3 100644 Binary files a/case/v3/common/pi_mount.stl and b/case/v3/common/pi_mount.stl differ diff --git a/docs/source/BOM.rst b/docs/source/BOM.rst index 69a4762e8..0920848ca 100644 --- a/docs/source/BOM.rst +++ b/docs/source/BOM.rst @@ -96,7 +96,7 @@ These are the bigger items/assemblies which you'll need to purchase to include i - Other lenses might work here, but something fast with a 10deg FOV is ideal You can use either the imx296 or imx462 module from innomaker. They both -perform about the same so choose the least expensive/easiest to get model +perform about the same so choose the least expesive/easiest to get model in your particular location. Case hardware @@ -132,6 +132,6 @@ In addition to the 3d printed parts detailed in the :doc:`Build Guide`_. This is the lower-cost version without RTC, but it has a 5000mah battery which should provide about 5 hours of run time. +If you'd like to have a fully stand-alone unit with integrated rechargeable battery, there are instructing in the build guide for integrating a `PiSugar S plus `_. This is the lower-cost version without RTC, but it has a 5000mah battery which should provide about 5 hours of run time. diff --git a/docs/source/build_guide.rst b/docs/source/build_guide.rst index 55266eb00..a9b3d65c8 100644 --- a/docs/source/build_guide.rst +++ b/docs/source/build_guide.rst @@ -12,7 +12,7 @@ Welcome to the PiFinder build guide! This guide is split into three main parts, PiFinder UI Hat ======================== -A key part of the PiFinder is a custom 'Hat' which matches the general form factor of the Raspberry Pi and connects to its GPIO header. It contains the switches, screen and Inertial Measurement Unit along with keypad backlight components +A key part of the PiFinder is a custom 'Hat' which matches the general form factor of the Raspberry Pi and connects to it's GPIO header. It contains the switches, screen and Inertial Measurement Unit along with keypad backlight components It's all through-hole components, so should be approachable to even beginners... but the component build order is important as some items block access to others. @@ -33,7 +33,7 @@ Polarity matters here, so mind the direction. The longer lead of the LED should .. image:: ../../images/build_guide/led_build_03.jpeg -Take your time and make sure each is positioned well. They should be pretty uniform, but little inconsistencies don't matter too much. I like to place them all in the board, and then tape them in place. +Take you time and make sure each is positioned well. They should be pretty uniform, but little inconsistencies don't matter too much. I like to place them all in the board, and then tape them in place. .. image:: images/build_guide/ui_module_2.jpeg @@ -48,7 +48,7 @@ When satisfied, solder the remaining legs and clip the leads up to a single pair .. image:: images/build_guide/ui_module_5.jpeg -The two resistors and transistor are next. R2 is the vertical oriented 330ohm part and R1 is the 22ohm oriented horizontally. Direction does not matter with these, but it's important for the transistor. Check the photo below for orientation and make sure this is bent flat against the PCB and the resistors are low. Solder them from the back and clip the leads once you've verified they look good. +The two resistors and transitor are next. R2 is the vertical oriented 330ohm part and R1 is the 22ohm oriented horizontally. Direction does not matter with these, but it's important for the transistor. Check the photo below for orientation and make sure this is bent flat against the PCB and the resistors are low. Solder them from the back and clip the leads once you've verified they look good. .. image:: images/build_guide/ui_module_6a.jpeg @@ -105,7 +105,7 @@ The GPS header is next. The modules come with a yellow header, but any will do. IMU ------------------------ -The Inertial Measurement unit is next. The IMU has an annoyingly bright green LED on it, which you will either want to paint over with a few layers of black nail polish, or you can use your soldering iron to destroy it. It can be handled after it's soldered if you forget, but it's much easier beforehand. See the image below to ID the offending component. +The Inertial Measurement unit is next. The IMU has an annoyingly bright green LED on it, which you will either want to paint over with a few laywers of black nail polish, or you can use your soldering iron to destroy it. It can be handled after it's soldered if you forget, but it's much easier before hand. See the image below to ID the offending component. .. image:: ../../images/build_guide/adafruit_IMU.png :target: ../../images/build_guide/adafruit_IMU.png @@ -149,7 +149,7 @@ To make the top plate fit a bit better and look tidier, I suggest sanding back o .. image:: ../../images/build_guide/IMG_4652.jpeg :target: ../../images/build_guide/IMG_4652.jpeg - :alt: Cut/Sand tabs on display + :alt: Cut/Sand tabs on displya It's not a bad idea to test fit the screen with the header installed and the top-plate in place. Everything should fit nicely and be square. @@ -215,7 +215,7 @@ part can have force applied as the hat is installed and removed. .. image:: images/build_guide/ui_module_17.jpeg -After you have all the pins soldered, it's a good time to insert the SD card and power it up to double check everything is working +After you have all the pins soldrerd, it's a good time to insert the SD card and power it up to double check everything is working .. image:: images/build_guide/ui_module_18.jpeg @@ -228,7 +228,7 @@ There you go! The PiFinder hat is fully assembled and you can move on to printi Configurations Overview ======================== -There are three different ways to build a PiFinder allowing it to be conveniently used on a variety of telescopes. +There are three different ways to build a PiFinder allowing it to be convieniently used on a variety of telescopes. .. list-table:: @@ -245,7 +245,7 @@ There are three different ways to build a PiFinder allowing it to be convenientl Flat -Any configuration can technically work with any scope, but since the camera always needs to face the sky the different configurations allow the screen and keyboard to be placed for easy access. The Left and Right configurations are primarily for newtonian style scopes, like dobsonians, which have the focuser perpendicular to the light path. +Any configuration can technically work with any scope, but since the camera always needs to face the sky the different configurations allow the screen and keyboard to be placed for easy access. The Left and Right configruations are primarily for newtonian style scopes, like dobsonians, which have the focuser perpendicular to the light path. The Flat configuration places the keypad and screen in easy reach for refractors, SCT's and other rear-focuser scopes. When the scope is pointed upward, the screen is tilted towards you for quick access. @@ -337,7 +337,7 @@ Back ^^^^^^^^^ The back piece holds the camera for left/right builds and reinforces the PiMount and Bottom piece to -help keep everything square and sturdy. It needs six inserts; four to mount the camera and two in the bottom +help keep everything squar and sturdy. It needs six inserts; four to mount the camera and two in the bottom edge to connect with the bottom piece .. image:: images/build_guide/parts_7.jpeg @@ -349,7 +349,7 @@ Dovetail Bottom The dovetail bottom has two inserts to receive the longer 12mm screws which allow angle adjustment. These inserts are placed in the side opposite where the top piece connects. The screws pass through the top piece and part of the bottom before engaging with the inserts. This makes this assembly strong enough to hold the set angle with the screws -sufficiently tightened. +sufficiently tightend. .. image:: images/build_guide/parts_8.jpeg :target: images/build_guide/parts_8.jpeg @@ -492,7 +492,7 @@ Assembly Overview From here on out you'll need the M2.5 screws, stand-offs, and thumbscrews along with the 3d printed parts, UI hat and other bits like the camera, lens and GPS unit. Most of the photos in this part of the guide show a build with the PiSugar, but if you are powering the PiFinder in some other way, the assembly is almost identical. -*In all cases, don't over tighten the hardware!* There is no need and you could end up damaging the 3d printed pieces, inserts or screws. Once they feel snug, that's probably enough force. The case forms a rigid assembly once everything is in place and will easily support the camera and other bits. +*In all cases, don't over tighten the hardware!* There is no need and you could end up damaging the 3d printed pieces, inserts or screws. Once they feel snug, that's probably enough force. The case forms a ridged assembly once everything is in place and will easily support the camera and other bits. Pi Mounting --------------------------- @@ -527,14 +527,14 @@ Snip the zip-ties off and you are ready to move on. Camera Prep --------------------------- -The new v3 camera may come with one of two different lens holders already installed. No matter +The new v3 camera may come with one of two different lens holders aready installed. No matter which your camera has you'll be removing and replacing it. .. image:: images/v25_upgrade/v25_upgrade_11.jpeg Some cameras have pin headers installed, if you have one of these, you'll need to clip them as close as reasonable to the board. It can help here to remove the black plastic portion by pulling it with -a pair of pliers. Alternatively, you can just cut through it to get as close to the PCB as possible. +a pair of pliers. Alternatively, you can just cut through it to get as close to the PCB as possilble. Take care not to clip any of the surrounding components. .. image:: images/v25_upgrade/v25_upgrade_12.jpeg @@ -557,8 +557,8 @@ there are holes there to help get started. Tighten the screws down against the .. image:: images/v25_upgrade/v25_upgrade_15.jpeg Flip the camera assembly over and thread in the lens. Be slow and careful here. With gentle force -the lens should slide in a few MM to get everything aligned and stop. When it stops, check to make sure it seems -straight and start screwing it into place. To get focus about right, You'll want a 6mm gap (pictured below) between the +the lens should slide in a few MM to get everything align and stop. When it stops, check to make sure it seems +straight and start screwing it into place. To get focus about right, You'll want a 6mm gap (picured below) between the top of the lens holder and the bottom of the lip on the lens. Don't fret too much about it as you'll do final focus under the stars. @@ -589,7 +589,7 @@ Return to the Raspberry Pi assembly and thread the camera cable through as shown .. important:: If you are using the recommended S Plus unit, now is the time to make sure you've got it all prepared. - * Turn the 'Auto Startup' switch on the bottom of the unit to OFF. Having this in the ON position will prevent i2c from working and the IMU will not be used. See the image below: The switch is outlined in orange, and the photo shows the correct OFF position. + * Turn the 'Auto Startup' switch on the bottom of the unit to OFF. Having this in the ON position will prevent i2c from working and the IMU will not be used. See the image below: The switch is outlined in orange, and the photos shows the correct OFF position. * The blue power light on the PiSugar board is very bright. You'll definitely want to cover it with some black nail polish or use a soldering iron to destroy it. Plug it in to the battery and turn it on to make sure it's subdued. Check the image below for the position of this LED. It's already blacked out with nail polish in the photo, but the orange arrow indicates which one you'll want to cover. @@ -627,7 +627,7 @@ The combined PiSugar/RPI stack then gets secured to the PI Mount using the 20mm - .. figure:: images/build_guide/right_3.jpeg - Secured with stand offs + Secured wiith stand offs @@ -696,7 +696,7 @@ Now it's time to mount the camera module. You'll need the module, camera tray a .. note:: The images here show an older back piece and camera tray. New kits have a back piece - with two holes which match the camera holder. In this simpler arrangement the camera + with two holes which match the camera holder. In this simpler arrangment the camera tray is not directly secured to the back piece, but rather has two holes through it. The camera holder is secured with longer screws through the tray into the two holes in the back piece @@ -706,7 +706,7 @@ by sliding the dark-grey piece away from the PCB. Be gentle as this part can br much force. Once the connector is open, slide the cable into the connector using gentle force and making -sure it's well aligned. Take your time and watch the +sure it's well aligned. Take you time and watch the dark-grey clip. It should not close as you are inserting the cable, and if it does, you'll need to re-open it to get the cable to slide in all the way. @@ -768,7 +768,7 @@ manage the camera and GPS cables. The photos below show the left and right conf - .. image:: images/build_guide/right_20.jpeg -The screw holes on the UI Board should line up with three of the four stand-offs. The fourth provides support, but is not used to secure the outer case. Collect up the Shroud, Bezel and cover plate along with three of the 12mm screws for the next steps +The screw holes on the UI Board should line up with three of the four stand-offs. The fourth provides support, but does is not used to secure the outer case. Collect up the Shroud, Bezel and cover plate along with three of the 12mm screws for the next steps .. image:: images/build_guide/common_5.jpeg :target: images/build_guide/common_5.jpeg @@ -815,6 +815,18 @@ and secure the bottom dovetail portion to the top: .. image:: images/build_guide/dovetail_4.jpeg +The final step is to Go ahead and screw on the camera lens. The cap on the Pi HQ camera screws off, +but leave the knurled metal spacer there or the lens will not reach focus properly. + +Gently screw the lens into the camera module. You'll need to hold the module with your hand as you tighten the lens. + + +.. image:: images/build_guide/cam_1.jpeg + :target: images/build_guide/cam_1.jpeg + +.. image:: images/build_guide/cam_2.jpeg + :target: images/build_guide/cam_2.jpeg + That's it! You now have a fully assembled PiFinder! @@ -897,7 +909,7 @@ Next you'll position the camera module and use the longer M2.5 screw to secure i :alt: Assembly Steps -Gently plug in the UI Module, working to tuck the cable underneath it. Take your time and make sure the camera cable is not pinched between the stand-offs and the UI Module. +Gently plug in the UI Module, working to tuck the cable underneath it. Take you time and make sure the camera cable is not pinched between the stand-offs and the UI Module. .. image:: ../../images/build_guide/v1.6/flat/flat_build_guide_10.jpeg diff --git a/docs/source/dev_guide.rst b/docs/source/dev_guide.rst index eb0ed0878..1fb95c80a 100644 --- a/docs/source/dev_guide.rst +++ b/docs/source/dev_guide.rst @@ -24,7 +24,7 @@ The easiest way to get started is to join the `PiFinder Discord server `_ is written in `reStructuredText `_ . The files are located in PiFinders GitHub repository under ``docs/source`` and have -the ending ``.rst``. The documentation is then published to `readthedocs.io `_, when the change is committed +the ending ``.rst``. The documentation is then published to `redthedocs.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. @@ -158,7 +158,7 @@ Internationalization PiFinder uses ``gettext`` and ``pybabel`` for internationalization. You can find the information in folder ``python/locale`` in the repository. This means that strings that need translation must be -enclosed in a call to ``_()`` such as ``_("string that needs translation")``. +enclosed in a call to ``_()`` such als ``_("string that needs translation")``. As we would like to allow users to switch the language of the user interface from the menu, and with-out restarting PiFinder, care must be taken, that translations are performed dynamically, i.e. not at load time of python files. @@ -184,7 +184,7 @@ 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: +In order to run the PiFinder software with the latest translation, you need to run the folloing commands: .. code-block:: @@ -276,7 +276,7 @@ Code Quality Automation ----------------------- The PiFinder codebase includes features for maintaining code quality, -adherence to style guide and for evaluation and testing. These will +adherance to style guide and for evaluation and testing. These will be installed along with the dev dependencies and should be available to run immediately. @@ -287,12 +287,12 @@ 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. -The first time it runs Nox will set up suitable environments for each session +The first time it runs Nox will set up suitible environments for each session it manages and this might take a bit. Subsequent runs will be much faster. To see what sessions are available use ``nox -l`` -To run only a specific session use ``nox -s [session_name]`` +To run only a specfic session use ``nox -s [session_name]`` The defined sessions are: @@ -320,7 +320,7 @@ The defined sessions are: - 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, + Then these are compiled into `.mo`-files. Unfortuntely, 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. @@ -340,7 +340,7 @@ Running/Debugging from the command line When you installed all the dependencies, you like to develop and test your code. You like to see debugging information and all verbose messages. You -probably like to save this information into a file. +probably like to save these informations into a file. Therefore, switch to the ``~/PiFinder/python`` folder and start the PiFinder python program with the command line parameters you need for the certain use case. @@ -357,7 +357,7 @@ 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 PiFinder instances. Simply, because you can not run multiple PiFinder instances in parallel. They would try to access the same hardware, which is not possible. -You can do this e.g. with the following code, which uses awk to kill all running processes of +You can do this e.g. with the following code, which uses awk to kill all runnding processes of PiFinder: .. code-block:: @@ -425,7 +425,7 @@ You enable the debug information output simply by passing the '-x' flag. .......................... Start the PiFinder software with a particular display device. This is useful -for developing on a different posix system like MacOS or Linux. Available options +for devloping on a different posix system like MacOS or Linux. Available options are: - ssd1351 - This is the standard 1.5" OLED screen (DEFAULT) @@ -495,7 +495,7 @@ the IMU is defect or you have a problem on your board. 1. Please check, if the board is soldered all pins correctly and did not shorten anything (spurious lead). 2. If you sourced the parts by you own, it might be, that you bought the wrong - IMU hardware version. You need the 4646 version. On the non-stemma QT versions, + IMU hardware version. You need the 4646 versio. On the non-stemma QT versions, the data pins are switched. `See here on Discord `_. 3. The IMU is defect. diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..c3d9ddf11 --- /dev/null +++ b/flake.nix @@ -0,0 +1,65 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + nixos-hardware.url = "github:NixOS/nixos-hardware"; + }; + + outputs = { self, nixpkgs, nixos-hardware, ... }: let + mkPifinderSystem = { includeSDImage ? false, devMode ? false }: nixpkgs.lib.nixosSystem { + system = "aarch64-linux"; + modules = [ + nixos-hardware.nixosModules.raspberry-pi-4 + ./nixos/hardware.nix + ./nixos/networking.nix + { pifinder.devMode = devMode; } + ./nixos/services.nix + ./nixos/python-env.nix + # Headless — strip X11, fonts, docs, desktop bloat + ({ 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; + hardware.pulseaudio.enable = false; + boot.supportedFilesystems = lib.mkForce ([ "vfat" "ext4" ] ++ lib.optionals devMode [ "nfs" ]); + boot.initrd.availableKernelModules = lib.mkForce [ "mmc_block" "usbhid" "usb_storage" "vc4" ]; + # NFS netboot support (dev mode only) - NFS and ethernet are built into RPi kernel + boot.initrd.supportedFilesystems = lib.mkIf devMode [ "nfs" ]; + boot.initrd.network.enable = devMode; + }) + ] ++ nixpkgs.lib.optionals includeSDImage [ + "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix" + ] ++ nixpkgs.lib.optionals (!includeSDImage) [ + # Minimal filesystem stub for closure builds (CI) + # The SD image module provides real filesystems; this is just for evaluation + ({ lib, ... }: { + fileSystems."/" = { + device = "/dev/disk/by-label/NIXOS_SD"; + fsType = "ext4"; + }; + fileSystems."/boot" = { + device = "/dev/disk/by-label/FIRMWARE"; + fsType = "vfat"; + }; + }) + ]; + }; + in { + nixosConfigurations = { + # Single universal build - camera selected at runtime via /boot/camera.txt + pifinder = mkPifinderSystem {}; + # Dev config (NFS netboot support) + pifinder-dev = mkPifinderSystem { devMode = true; }; + }; + images = { + # Single universal image - camera selected at runtime via /boot/camera.txt + pifinder = (mkPifinderSystem { includeSDImage = true; }).config.system.build.sdImage; + # Dev image (NFS netboot support, larger initrd) + pifinder-dev = (mkPifinderSystem { includeSDImage = true; devMode = true; }).config.system.build.sdImage; + }; + }; +} diff --git a/nixos/hardware.nix b/nixos/hardware.nix new file mode 100644 index 000000000..812e53f68 --- /dev/null +++ b/nixos/hardware.nix @@ -0,0 +1,97 @@ +{ config, lib, pkgs, ... }: +{ + options.pifinder.cameraType = lib.mkOption { + type = lib.types.enum [ "imx296" "imx462" "imx477" ]; + default = "imx296"; + description = "Camera sensor type for PiFinder"; + }; + + config = { + # Only include RPi 4B device tree (not CM4 variants) + hardware.deviceTree.filter = "*rpi-4-b.dtb"; + + # I2C1 (ARM bus) at 10 kHz for BNO055 IMU + hardware.raspberry-pi."4".i2c1 = { + enable = true; + frequency = 10000; + }; + + hardware.deviceTree.overlays = let + # SPI0 — no nixos-hardware option, use custom overlay + spi0Overlay = { + name = "spi0-overlay"; + dtsText = '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &spi0 { status = "okay"; }; + ''; + }; + + # UART3 for GPS on /dev/ttyAMA1 + uart3Overlay = { + name = "uart3-overlay"; + dtsText = '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &uart3 { status = "okay"; }; + ''; + }; + + # PWM on GPIO 13 (function 4) for keypad backlight + # nixos-hardware pwm0 is hardcoded to GPIO 18, so use custom overlay + pwmOverlay = { + name = "pwm-pin13-overlay"; + dtsText = '' + /dts-v1/; + /plugin/; + / { compatible = "brcm,bcm2711"; }; + &gpio { + pwm_pins: pwm_pins { + brcm,pins = <13>; + brcm,function = <4>; + }; + }; + &pwm { status = "okay"; }; + ''; + }; + + # Camera dtoverlay — driver depends on selected camera type + cameraDriver = { + imx296 = "imx296"; + imx462 = "imx290"; + imx477 = "imx477"; + }.${config.pifinder.cameraType}; + + cameraOverlay = { + name = "${cameraDriver}-camera"; + dtboFile = "${pkgs.raspberrypifw}/share/raspberrypi/boot/overlays/${cameraDriver}.dtbo"; + }; + in [ + spi0Overlay + uart3Overlay + pwmOverlay + cameraOverlay + ]; + + # 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=="ttyAMA1", GROUP="dialout", MODE="0660" + ''; + + users.users.pifinder = { + isNormalUser = true; + extraGroups = [ "spi" "i2c" "gpio" "dialout" "video" "networkmanager" ]; + }; + users.groups = { + spi = {}; + i2c = {}; + gpio = {}; + }; + }; +} diff --git a/nixos/networking.nix b/nixos/networking.nix new file mode 100644 index 000000000..888cedc94 --- /dev/null +++ b/nixos/networking.nix @@ -0,0 +1,32 @@ +{ config, lib, pkgs, ... }: +{ + networking = { + hostName = "pifinder"; + networkmanager.enable = true; + wireless.enable = false; # NetworkManager handles WiFi + }; + + # Pre-configured AP profile (activated on demand via nmcli) + environment.etc."NetworkManager/system-connections/PiFinder-AP.nmconnection" = { + text = '' + [connection] + id=PiFinder-AP + type=wifi + 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"; + }; +} diff --git a/nixos/pkgs/cedar-detect.nix b/nixos/pkgs/cedar-detect.nix new file mode 100644 index 000000000..a8ebf1e2b --- /dev/null +++ b/nixos/pkgs/cedar-detect.nix @@ -0,0 +1,13 @@ +{ pkgs }: +pkgs.stdenv.mkDerivation { + pname = "cedar-detect-server"; + version = "0.1.0"; + src = ../../bin; + nativeBuildInputs = [ pkgs.autoPatchelfHook ]; + buildInputs = [ pkgs.stdenv.cc.cc.lib ]; + installPhase = '' + mkdir -p $out/bin + cp cedar-detect-server-aarch64 $out/bin/cedar-detect-server + chmod +x $out/bin/cedar-detect-server + ''; +} diff --git a/nixos/pkgs/pifinder-src.nix b/nixos/pkgs/pifinder-src.nix new file mode 100644 index 000000000..7b8b4b3e6 --- /dev/null +++ b/nixos/pkgs/pifinder-src.nix @@ -0,0 +1,24 @@ +{ pkgs }: +pkgs.stdenv.mkDerivation { + pname = "pifinder-src"; + version = "0.0.1"; + src = ../..; + + phases = [ "installPhase" ]; + + installPhase = '' + mkdir -p $out + + # Python source (the application) + cp -r $src/python $out/python + + # Astronomical data (catalogs, star patterns, etc.) + cp -r $src/astro_data $out/astro_data + + # Default config at repo root level + cp $src/default_config.json $out/default_config.json + + # Version info + cp $src/versions.json $out/versions.json + ''; +} diff --git a/nixos/python-env.nix b/nixos/python-env.nix new file mode 100644 index 000000000..189ce1498 --- /dev/null +++ b/nixos/python-env.nix @@ -0,0 +1,470 @@ +{ config, lib, pkgs, ... }: +let + python = pkgs.python312; + + pifinderPython = python.override { + packageOverrides = self: super: { + # --- Pure Python, trivial packages --- + + sh = self.buildPythonPackage rec { + pname = "sh"; + version = "1.14.3"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-5ARbbHMtnOddVxx59awiNO3Zrk9fqdWbCXBQgr3KGMc="; + }; + doCheck = false; + }; + + gpsdclient = self.buildPythonPackage rec { + pname = "gpsdclient"; + version = "1.3.2"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-cKSWVQqXR9/14OULPJWm4dyrnYQoYJl+lRIHZ+IGCno="; + }; + doCheck = false; + }; + + rpi-hardware-pwm = self.buildPythonPackage rec { + pname = "rpi-hardware-pwm"; + version = "0.3.0"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/be/0c/4308050d8b6bbe24e8e54b38e48b287b1e356efce33cd485ee4387fc92a9/rpi_hardware_pwm-0.3.0.tar.gz"; + hash = "sha256-HshwYzp5XpijEGhWXwZ/gvZKjhZ4BpvPjdcC+i+zGyY="; + }; + doCheck = false; + }; + + dataclasses-json = self.buildPythonPackage rec { + pname = "dataclasses-json"; + version = "0.6.7"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz"; + hash = "sha256-trPlKCZupFuVNSI7xTymRfUgiDPCkinoR7PyahzFX8A="; + }; + nativeBuildInputs = [ self.poetry-core ]; + propagatedBuildInputs = [ + self.marshmallow + self.typing-inspect + ]; + postPatch = '' + substituteInPlace pyproject.toml \ + --replace-fail 'requires = ["poetry-core>=1.2.0", "poetry-dynamic-versioning"]' 'requires = ["poetry-core>=1.2.0"]' \ + --replace-fail 'build-backend = "poetry_dynamic_versioning.backend"' 'build-backend = "poetry.core.masonry.api"' + ''; + doCheck = false; + }; + + # --- C extension packages --- + + RPi-GPIO = self.buildPythonPackage rec { + pname = "RPi.GPIO"; + version = "0.7.1"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-zWHEsDw3tiu6SlrP6phidJwzxhjgKV5+kKpHE/s3O3A="; + }; + doCheck = false; + }; + + # --- Adafruit chain: platformdetect -> pureio -> blinka -> sensors --- + + # CircuitPython typing stubs (required for Python 3.12+ type annotation evaluation) + adafruit-circuitpython-typing = self.buildPythonPackage rec { + pname = "adafruit-circuitpython-typing"; + version = "1.12.3"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/65/a2/40a3440aed2375371507af668570b68523ee01db9c25c47ce5a05883170e/adafruit_circuitpython_typing-1.12.3.tar.gz"; + hash = "sha256-Y/GW+DTkeEK81M+MN6qgxh4a610H8FbIdfwwFs2pGhI="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ self.typing-extensions ]; + doCheck = false; + # Skip runtime dependency check - optional deps are handled by blinka chain + dontCheckRuntimeDeps = true; + }; + + adafruit-platformdetect = self.buildPythonPackage rec { + pname = "Adafruit-PlatformDetect"; + version = "3.73.0"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/3c/83/79eb6746d01d64bd61f02b12a2637fad441f7823a4f540842e0a47dbcfd8/adafruit_platformdetect-3.73.0.tar.gz"; + hash = "sha256-IwkJityP+Hs9mkpdOu6+P3t/VasOE9Get1/6hl82+rg="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + doCheck = false; + }; + + adafruit-pureio = self.buildPythonPackage rec { + pname = "Adafruit-PureIO"; + version = "1.1.11"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/e5/b7/f1672435116822079bbdab42163f9e6424769b7db778873d95d18c085230/Adafruit_PureIO-1.1.11.tar.gz"; + hash = "sha256-xM+7NlcxlC0fEJKhFvR9/a4K7xjFsn8QcrWCStXqjHw="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + doCheck = false; + }; + + adafruit-blinka = self.buildPythonPackage rec { + pname = "Adafruit-Blinka"; + version = "8.47.0"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/4a/30/84193a19683732387ec5f40661b589fcee29e0ab47c1e7dee36fb92efe9b/adafruit_blinka-8.47.0.tar.gz"; + hash = "sha256-Q2qFasw4v5xTRtuMQTuiraledi9qqXp9viOENMy8hRk="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ + self.RPi-GPIO + self.adafruit-platformdetect + self.adafruit-pureio + self.adafruit-circuitpython-typing + ]; + pythonRelaxDeps = true; + pythonRemoveDeps = [ "binho-host-adapter" "pyftdi" "sysv-ipc" ]; + doCheck = false; + }; + + adafruit-circuitpython-busdevice = self.buildPythonPackage rec { + pname = "adafruit-circuitpython-busdevice"; + version = "5.2.9"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/a8/04/cf8d2ebfe0d171b7c8fe3425f1e2e80ed59738855d419e5486f5d2fa9145/adafruit_circuitpython_busdevice-5.2.9.tar.gz"; + hash = "sha256-n5w984UJFBDaxZYZGOR17Ij67X/1Q61tdCCPCMJWZRM="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ + self.adafruit-blinka + self.adafruit-circuitpython-typing + ]; + doCheck = false; + }; + + adafruit-circuitpython-register = self.buildPythonPackage rec { + pname = "adafruit-circuitpython-register"; + version = "1.10.0"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/0f/f1/b7e16545dac1056227ca9c612966ec26d69a04a99df6892aec27a71884af/adafruit_circuitpython_register-1.10.0.tar.gz"; + hash = "sha256-vH6191d2bxAqhyZXPgylwp6h1+UBweN1nGxOnhNmD3o="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ + self.adafruit-blinka + self.adafruit-circuitpython-busdevice + self.adafruit-circuitpython-typing + ]; + doCheck = false; + }; + + adafruit-circuitpython-bno055 = self.buildPythonPackage rec { + pname = "adafruit-circuitpython-bno055"; + version = "5.4.16"; + format = "pyproject"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/8d/20/ad6bb451c5bf228af869bf045d4fc415174e7c042dfc1d998e9c0bc8ad21/adafruit_circuitpython_bno055-5.4.16.tar.gz"; + hash = "sha256-kL/bz689GF/sZxgbzv+bEPQ4F5zQqjl+k4ctSwlK3aA="; + }; + nativeBuildInputs = [ self.setuptools-scm ]; + propagatedBuildInputs = [ + self.adafruit-blinka + self.adafruit-circuitpython-busdevice + self.adafruit-circuitpython-register + self.adafruit-circuitpython-typing + ]; + doCheck = false; + }; + + # --- Display stack: luma.core -> luma.oled, luma.lcd --- + + luma-core = self.buildPythonPackage rec { + pname = "luma.core"; + version = "2.4.2"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-ljwmQWTUN09UnVfbCVmeDKRYzqG9BeFpOYl2Gb5Obb0="; + }; + propagatedBuildInputs = [ + self.pillow + self.smbus2 + self.pyftdi + self.cbor2 + self.deprecated + ]; + doCheck = false; + }; + + luma-oled = self.buildPythonPackage rec { + pname = "luma.oled"; + version = "3.13.0"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-fioNakyWjGSYAlXWgewnkU2avVpmqQGbKJvzrQUMISU="; + }; + propagatedBuildInputs = [ self.luma-core ]; + doCheck = false; + }; + + luma-lcd = self.buildPythonPackage rec { + pname = "luma.lcd"; + version = "2.11.0"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-1GBE6W/TmUPr5Iph51M3FXG+FJekvqlrcuOpxzL77uQ="; + }; + propagatedBuildInputs = [ self.luma-core ]; + doCheck = false; + }; + + # --- DeepSkyLog API --- + + pydeepskylog = self.buildPythonPackage rec { + pname = "pydeepskylog"; + version = "1.6"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-3erm0ASBfPtQ1cngzsqkZUrnKoLNIBu8U1D6iA4ePmE="; + }; + propagatedBuildInputs = [ self.requests ]; + doCheck = false; + }; + + # --- PAM bindings for password verification --- + + python-pam = self.buildPythonPackage rec { + pname = "python-pam"; + version = "2.0.2"; + format = "pyproject"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-lyNSNbqbgtuugGjRCZUIRVlJsnX3cnPKIv29ix+12VA="; + }; + nativeBuildInputs = [ self.setuptools self.six ]; + # python-pam uses ctypes to load libpam.so from __internals.py + postPatch = '' + substituteInPlace src/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"' + ''; + doCheck = false; + }; + + # --- pidng (for picamera2 DNG support) --- + + pidng = self.buildPythonPackage rec { + pname = "pidng"; + version = "4.0.9"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/source/p/pidng/pidng-4.0.9.tar.gz"; + hash = "sha256-Vg6wCAhvinFf2eGrmYgXp9TIUAp/Fhuc5q9asnUB+Cw="; + }; + propagatedBuildInputs = [ self.numpy ]; + doCheck = false; + }; + + # --- simplejpeg (fast JPEG encode/decode for picamera2) --- + # Use prebuilt wheel - source build tries to download libjpeg-turbo + + simplejpeg = self.buildPythonPackage rec { + pname = "simplejpeg"; + version = "1.9.0"; + format = "wheel"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/75/c1/0cbf167e3efa32adfbb0674a3504eb118cc5bdc372a44ee937c30324188e/simplejpeg-1.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl"; + hash = "sha256-CKszfKOybXVi9a1oarjzlm+yBvztYH0kjmk8vFf8U7M="; + }; + propagatedBuildInputs = [ self.numpy ]; + doCheck = false; + }; + + # --- prctl bindings (for picamera2) --- + + python-prctl = self.buildPythonPackage rec { + pname = "python-prctl"; + version = "1.8.1"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/source/p/python-prctl/python-prctl-1.8.1.tar.gz"; + hash = "sha256-tMqaJafU8azk//0fOi5k71II/gX5KfPt1eJwgcp+Z84="; + }; + buildInputs = [ pkgs.libcap ]; + doCheck = false; + }; + + # --- libinput bindings (rebuild: 2026-02-03 fix lib path) --- + + python-libinput = self.buildPythonPackage rec { + pname = "python-libinput"; + version = "0.3.0a0"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-fj08l4aqp5vy8UYBZIWBtGJLaS0/DZGZkC0NCDQhkwI="; + }; + buildInputs = [ pkgs.libinput pkgs.systemd ]; + nativeBuildInputs = [ pkgs.pkg-config ]; + propagatedBuildInputs = [ self.cffi ]; + # imp module removed in Python 3.12; also patch library paths for NixOS + postPatch = '' + 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')" + ''; + doCheck = false; + }; + + v4l2-python3 = self.buildPythonPackage rec { + pname = "v4l2-python3"; + version = "0.3.4"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-YliResgEmsaYcaXg39bYnVXJ5/gOgSwe+LqIeb2hxYc="; + }; + doCheck = false; + }; + + # --- videodev2 (V4L2 ctypes bindings for picamera2) --- + + videodev2 = self.buildPythonPackage rec { + pname = "videodev2"; + version = "0.0.4"; + format = "wheel"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/68/30/4982441a03860ab8f656702d8a2c13d0cf6f56d65bfb78fe288028dcb473/videodev2-0.0.4-py3-none-any.whl"; + hash = "sha256-0196s53bBtUP7Japm/yNW4tSW8fqA3iCWdOGOT8aZLo="; + }; + doCheck = false; + }; + + # --- picamera2 (depends on libcamera with Python bindings) --- + + picamera2 = self.buildPythonPackage rec { + pname = "picamera2"; + version = "0.3.22"; + src = self.fetchPypi { + inherit pname version; + hash = "sha256-iShpgUNCu8uHS7jeehtgWJhEm/UhJjn0bw2qpkbWgy0="; + }; + # Make DrmPreview import optional - pykms (kmsxx Python bindings) not + # available in nixpkgs. PiFinder uses NullPreview anyway. + postPatch = '' + substituteInPlace picamera2/previews/__init__.py \ + --replace-fail 'from .drm_preview import DrmPreview' \ + 'try: + from .drm_preview import DrmPreview +except ImportError: + DrmPreview = None' + ''; + propagatedBuildInputs = [ + self.numpy + self.pillow + self.piexif + self.v4l2-python3 + self.videodev2 # V4L2 ctypes bindings (required by picamera2) + self.pidng # DNG support + self.simplejpeg # Fast JPEG encoding + self.python-prctl # Process control + pkgs.libcamera # needs pycamera enabled (see overlay) + # av, libarchive-c, jsonschema, tqdm are in the main env + ]; + # libcamera Python bindings must be on PYTHONPATH + postFixup = '' + wrapPythonProgramsIn "$out" "$out ${pkgs.libcamera}/lib/python3.12/site-packages" + ''; + doCheck = false; + }; + }; + }; + + env = pifinderPython.withPackages (ps: with ps; [ + # Packages from nixpkgs (already available) + numpy + scipy + scikit-learn + pillow + pandas + grpcio + protobuf + bottle + cheroot + requests + pytz + skyfield + tqdm + pyjwt + aiofiles + json5 + smbus2 + spidev # SPI interface for display + pygobject3 # GLib bindings for NetworkManager + av # PyAV - ffmpeg bindings for picamera2 encoders + dbus-python # D-Bus for hostname/reboot/shutdown + timezonefinder # Timezone lookup from GPS coordinates + jsonschema # For picamera2 configuration validation + libarchive-c # For picamera2 archive handling + + # Custom packaged (from overlay above) + sh + gpsdclient + rpi-hardware-pwm + dataclasses-json + adafruit-blinka + adafruit-circuitpython-bno055 + luma-oled + luma-lcd + python-libinput + python-pam + python-prctl + pidng + simplejpeg + videodev2 # V4L2 ctypes bindings for picamera2 + pydeepskylog + RPi-GPIO + picamera2 + ]); +in { + # libcamera overlay — enable Python bindings for picamera2 + nixpkgs.overlays = [(final: prev: { + libcamera = prev.libcamera.overrideAttrs (old: { + mesonFlags = (old.mesonFlags or []) ++ [ + "-Dpycamera=enabled" + ]; + buildInputs = (old.buildInputs or []) ++ [ + final.python312 + final.python312.pkgs.pybind11 + ]; + }); + })]; + + environment.systemPackages = [ + env + pkgs.gobject-introspection # GI typelibs + pkgs.networkmanager # NM-1.0 typelib for gi.repository.NM + pkgs.libcamera # for picamera2 Python bindings + pkgs.gpsd # for gpsctl (runtime GPS baud rate changes) + ]; + + # 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.12/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..5d4af30a5 --- /dev/null +++ b/nixos/services.nix @@ -0,0 +1,367 @@ +{ 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; }; +in { + options.pifinder = { + devMode = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable development mode (NFS netboot support, etc.)"; + }; + }; + + config = { + # --------------------------------------------------------------------------- + # Cachix binary substituter — Pi downloads pre-built paths, never compiles + # --------------------------------------------------------------------------- + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.nixos.org" + "https://pifinder.cachix.org" + ]; + trusted-public-keys = [ + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + "pifinder.cachix.org-1:ALuxYs8tMU34zwSTWjenI2wpJA+AclmW6H5vyTgnTjc=" + ]; + }; + + # --------------------------------------------------------------------------- + # 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"; + }; + 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" ]; + }; + + # --------------------------------------------------------------------------- + # Tmpfiles — runtime directory for upgrade ref file + # --------------------------------------------------------------------------- + systemd.tmpfiles.rules = [ + "d /run/pifinder 0755 pifinder pifinder -" + ]; + + # --------------------------------------------------------------------------- + # PiFinder source + data directory setup + # --------------------------------------------------------------------------- + system.activationScripts.pifinder-home = lib.stringAfter [ "users" ] '' + # 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" + + # Create writable data directory + mkdir -p /home/pifinder/PiFinder_data + chown pifinder:users /home/pifinder/PiFinder_data + ''; + + # --------------------------------------------------------------------------- + # 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; + } + } + }); + ''; + + security.sudo.extraRules = [{ + users = [ "pifinder" ]; + commands = [ + { 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 restart avahi-daemon.service"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/nixos-rebuild *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/shutdown *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/chpasswd"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/dmesg"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/hostnamectl *"; options = [ "NOPASSWD" ]; } + { command = "/run/current-system/sw/bin/tee /boot/camera.txt"; 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; + }; + }; + + # --------------------------------------------------------------------------- + # 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 = [ pkgs.gpsd ]; # For gpsctl + 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.12/site-packages"; + }; + serviceConfig = { + Type = "idle"; + 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 Safe NixOS Upgrade (test-then-switch) + # --------------------------------------------------------------------------- + systemd.services.pifinder-upgrade = { + description = "PiFinder Safe NixOS Upgrade (test-then-switch)"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + TimeoutStartSec = "10min"; + }; + path = with pkgs; [ nixos-rebuild nix systemd coreutils ]; + script = '' + set -euo pipefail + REF=$(cat /run/pifinder/upgrade-ref 2>/dev/null || echo "release") + # Single universal build - camera is selected at runtime via /boot/camera.txt + FLAKE="github:brickbots/PiFinder/''${REF}#pifinder" + + # Pre-flight: check disk space (need at least 500MB) + AVAIL=$(df --output=avail /nix/store | tail -1) + if [ "$AVAIL" -lt 524288 ]; then + echo "ERROR: Less than 500MB free on /nix/store" + exit 1 + fi + + echo "Upgrading to $FLAKE" + + echo "Phase 1: Download and activate (test mode — bootloader untouched)" + nixos-rebuild test --flake "$FLAKE" --refresh + + echo "Phase 2: Verifying pifinder.service health" + systemctl restart pifinder.service + for i in $(seq 1 24); do + if systemctl is-active --quiet pifinder.service; then + echo "pifinder.service active after $((i * 5))s" + + echo "Phase 3: Persist to bootloader" + nixos-rebuild switch --flake "$FLAKE" + + echo "Phase 4: Cleanup old generations" + nix-env --delete-generations +2 -p /nix/var/nix/profiles/system || true + nix-collect-garbage || true + + echo "Upgrade complete." + exit 0 + fi + sleep 5 + done + + echo "ERROR: pifinder.service not healthy. Rebooting to revert." + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # PiFinder Boot Health Watchdog + # --------------------------------------------------------------------------- + systemd.services.pifinder-watchdog = { + description = "PiFinder Boot Health Watchdog"; + after = [ "multi-user.target" "pifinder.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = with pkgs; [ systemd coreutils ]; + script = '' + set -euo pipefail + REBOOT_MARKER="/var/tmp/pifinder-watchdog-rebooted" + + if [ -f "$REBOOT_MARKER" ]; then + echo "Watchdog already rebooted once. Not retrying." + rm -f "$REBOOT_MARKER" + exit 0 + fi + + echo "Watchdog: waiting up to 90s for pifinder.service..." + for i in $(seq 1 18); do + if systemctl is-active --quiet pifinder.service; then + echo "pifinder.service healthy after $((i * 5))s" + exit 0 + fi + sleep 5 + done + + echo "ERROR: pifinder.service failed. Rolling back..." + touch "$REBOOT_MARKER" + PREV_GEN=$(ls -d /nix/var/nix/profiles/system-*-link 2>/dev/null | sort -t- -k2 -n | tail -2 | head -1) + if [ -n "$PREV_GEN" ]; then + "$PREV_GEN/bin/switch-to-configuration" switch || true + fi + systemctl reboot + ''; + }; + + # --------------------------------------------------------------------------- + # GPSD for GPS receiver - full USB hotplug support + # --------------------------------------------------------------------------- + # Don't use services.gpsd module - it doesn't support hotplug. + # Instead, use gpsd's own systemd units with socket activation. + + # Install gpsd's udev rules (25-gpsd.rules) for USB GPS auto-detection + # Includes u-blox 5/6/7/8/9 and many other GPS receivers + services.udev.packages = [ pkgs.gpsd ]; + + # Install gpsd's systemd units (gpsd.service, gpsd.socket, gpsdctl@.service) + systemd.packages = [ pkgs.gpsd ]; + + # Enable socket activation - gpsd starts when something connects to port 2947 + systemd.sockets.gpsd = { + wantedBy = [ "sockets.target" ]; + }; + + # Configure USBAUTO for gpsdctl (triggered by udev when USB GPS plugs in) + environment.etc."default/gpsd".text = '' + USBAUTO="true" + GPSD_SOCKET="/var/run/gpsd.sock" + ''; + + # Ensure gpsd user/group exist (normally created by services.gpsd module) + users.users.gpsd = { + isSystemUser = true; + group = "gpsd"; + description = "GPSD daemon user"; + }; + users.groups.gpsd = {}; + + # Add UART GPS on boot (ttyAMA3 from uart3 overlay, not auto-detected by udev) + # This runs after gpsd.socket is ready, adding the UART device to gpsd + systemd.services.gpsd-add-uart = { + description = "Add UART GPS to gpsd"; + after = [ "gpsd.socket" "dev-ttyAMA3.device" ]; + requires = [ "gpsd.socket" ]; + wantedBy = [ "multi-user.target" ]; + # BindsTo ensures this stops if ttyAMA3 disappears (though it shouldn't) + bindsTo = [ "dev-ttyAMA3.device" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.gpsd}/sbin/gpsdctl add /dev/ttyAMA3"; + ExecStop = "${pkgs.gpsd}/sbin/gpsdctl remove /dev/ttyAMA3"; + }; + }; + + # --------------------------------------------------------------------------- + # Samba for file sharing (observation data, backups) + # --------------------------------------------------------------------------- + system.stateVersion = "24.11"; + + # --------------------------------------------------------------------------- + # SSH access + # --------------------------------------------------------------------------- + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = true; + PermitRootLogin = "yes"; + }; + }; + + # --------------------------------------------------------------------------- + # Avahi/mDNS for hostname discovery (pifinder.local) + # --------------------------------------------------------------------------- + services.avahi = { + enable = true; + nssmdns4 = true; + publish = { + enable = true; + addresses = true; + domain = true; + workstation = true; + }; + }; + + services.samba = { + enable = true; + settings = { + global = { + workgroup = "WORKGROUP"; + security = "user"; + "map to guest" = "never"; + }; + PiFinder_data = { + path = "/home/pifinder/PiFinder_data"; + browseable = "yes"; + "read only" = "no"; + "valid users" = "pifinder"; + }; + }; + }; + }; # config +} diff --git a/python/PiFinder/auto_exposure.py b/python/PiFinder/auto_exposure.py index cafd21631..eadcc4f61 100644 --- a/python/PiFinder/auto_exposure.py +++ b/python/PiFinder/auto_exposure.py @@ -694,7 +694,7 @@ def from_camera_profile( profile = get_camera_profile(camera_type) # Derive thresholds from camera specs - max_adu = (2**profile.bit_depth) - 1 + max_adu = (2 ** profile.bit_depth) - 1 bias = profile.bias_offset # min_background: bias + margin (2x bias or bias + 8, whichever larger) @@ -726,7 +726,7 @@ def update( current_exposure: int, image: Image.Image, noise_floor: Optional[float] = None, - **kwargs, # Ignore other params (matched_stars, etc.) + **kwargs # Ignore other params (matched_stars, etc.) ) -> Optional[int]: """ Update exposure based on background level. @@ -908,9 +908,7 @@ def _update_pid(self, matched_stars: int, current_exposure: int) -> Optional[int # from crashing exposure when conditions change suddenly # (e.g., going from too many stars to too few stars) if self._last_error is not None: - if (error > 0 and self._last_error < 0) or ( - error < 0 and self._last_error > 0 - ): + if (error > 0 and self._last_error < 0) or (error < 0 and self._last_error > 0): logger.debug( f"PID: Error sign changed ({self._last_error:.0f} → {error:.0f}), resetting integral" ) diff --git a/python/PiFinder/calc_utils.py b/python/PiFinder/calc_utils.py index 112271a3d..0064009c6 100644 --- a/python/PiFinder/calc_utils.py +++ b/python/PiFinder/calc_utils.py @@ -167,6 +167,8 @@ def aim_degrees(shared_state, mount_type, screen_direction, target): ) az_diff = target_az - solution["Az"] az_diff = (az_diff + 180) % 360 - 180 + if screen_direction in ["flat", "as_bloom"]: + az_diff *= -1 alt_diff = target_alt - solution["Alt"] alt_diff = (alt_diff + 180) % 360 - 180 @@ -175,11 +177,8 @@ def aim_degrees(shared_state, mount_type, screen_direction, target): else: # EQ Mount type ra_diff = target.ra - solution["RA"] - ra_diff = (ra_diff + 180) % 360 - 180 # Convert to -180 to +180 - dec_diff = target.dec - solution["Dec"] dec_diff = (dec_diff + 180) % 360 - 180 - return ra_diff, dec_diff return None, None diff --git a/python/PiFinder/camera_interface.py b/python/PiFinder/camera_interface.py index 1ee3fe320..9752f55c0 100644 --- a/python/PiFinder/camera_interface.py +++ b/python/PiFinder/camera_interface.py @@ -9,17 +9,16 @@ """ -from typing import Tuple, Optional -from PIL import Image -import os -import time import datetime -import numpy as np -import queue import logging +import os +import queue +import time +from typing import Tuple, Optional + +from PIL import Image from PiFinder import state_utils, utils -import PiFinder.pointing_model.quaternion_transforms as qt from PiFinder.auto_exposure import ( ExposurePIDController, ExposureSNRController, @@ -57,12 +56,6 @@ def capture_file(self, filename) -> None: def capture_raw_file(self, filename) -> None: pass - def _blank_capture(self): - """ - Returns a properly formated black frame - """ - return Image.new("L", (512, 512), 0) # Black 512x512 image - def capture_bias(self): """ Capture a bias frame for pedestal calculation. @@ -70,7 +63,7 @@ def capture_bias(self): Override in subclasses that support bias frames. Returns Image.Image or np.ndarray depending on implementation. """ - return self._blank_capture() + return Image.new("L", (512, 512), 0) # Black 512x512 image def set_camera_config( self, exposure_time: float, gain: float @@ -178,34 +171,26 @@ def get_image_loop( base_image = base_image.convert( "L" ) # Convert to grayscale to match camera output - time.sleep(0.2) + time.sleep(1) image_end_time = time.time() # check imu to make sure we're still static imu_end = shared_state.imu() # see if we moved during exposure + reading_diff = 0 if imu_start and imu_end: - # Returns the pointing difference between successive IMU quaternions as - # an angle (radians). Note that this also accounts for rotation around the - # scope axis. Returns an angle in radians. - pointing_diff = qt.get_quat_angular_diff( - imu_start["quat"], imu_end["quat"] + reading_diff = ( + abs(imu_start["pos"][0] - imu_end["pos"][0]) + + abs(imu_start["pos"][1] - imu_end["pos"][1]) + + abs(imu_start["pos"][2] - imu_end["pos"][2]) ) - else: - pointing_diff = 0.0 - - # Make image available - 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) + camera_image.paste(base_image) image_metadata = { "exposure_start": image_start_time, "exposure_end": image_end_time, "imu": imu_end, - "imu_delta": np.rad2deg(pointing_diff), + "imu_delta": reading_diff, "exposure_time": self.exposure_time, "gain": self.gain, } @@ -247,29 +232,22 @@ def get_image_loop( if self._auto_exposure_snr is None: # Use camera profile to derive thresholds try: - cam_type = detect_camera_type( - self.get_cam_type() - ) + cam_type = detect_camera_type(self.get_cam_type()) cam_type = f"{cam_type}_processed" - self._auto_exposure_snr = ExposureSNRController.from_camera_profile( - cam_type + self._auto_exposure_snr = ( + ExposureSNRController.from_camera_profile(cam_type) ) except ValueError as e: # Unknown camera, use defaults logger.warning( f"Camera detection failed: {e}, using default SNR thresholds" ) - self._auto_exposure_snr = ( - ExposureSNRController() - ) + self._auto_exposure_snr = ExposureSNRController() # Get adaptive noise floor from shared state - adaptive_noise_floor = ( - self.shared_state.noise_floor() - ) + adaptive_noise_floor = self.shared_state.noise_floor() new_exposure = self._auto_exposure_snr.update( - self.exposure_time, - base_image, - noise_floor=adaptive_noise_floor, + self.exposure_time, base_image, + noise_floor=adaptive_noise_floor ) else: # PID mode: use star-count based controller (default) diff --git a/python/PiFinder/integrator.py b/python/PiFinder/integrator.py index 2457f4872..a56cc46eb 100644 --- a/python/PiFinder/integrator.py +++ b/python/PiFinder/integrator.py @@ -5,65 +5,90 @@ * Checks IMU * Plate solves high-res image -TODO: -- Rename solved --> pointing_estimate (also includes IMU) -- Rename next_image_solved --> new_solve -- Rename last_image_solve --> prev_solve (previous successful solve) -- Simplify program flow and explain in comments at top -- Refactor into class PointingTracker - """ -import datetime import queue import time import copy import logging -import numpy as np -import quaternion # numpy-quaternion from PiFinder import config from PiFinder import state_utils import PiFinder.calc_utils as calc_utils from PiFinder.multiproclogging import MultiprocLogging -from PiFinder.pointing_model.astro_coords import RaDecRoll -from PiFinder.solver import get_initialized_solved_dict -from PiFinder.pointing_model.imu_dead_reckoning import ImuDeadReckoning -import PiFinder.pointing_model.quaternion_transforms as qt +IMU_ALT = 2 +IMU_AZ = 0 logger = logging.getLogger("IMU.Integrator") -# Constants: -# Use IMU tracking if the angle moved is above this -# TODO: May need to adjust this depending on the IMU sensitivity thresholds -IMU_MOVED_ANG_THRESHOLD = np.deg2rad(0.06) + +def imu_moved(imu_a, imu_b): + """ + Compares two IMU states to determine if they are the 'same' + if either is none, returns False + """ + if imu_a is None: + return False + if imu_b is None: + return False + + # figure out the abs difference + diff = ( + abs(imu_a[0] - imu_b[0]) + abs(imu_a[1] - imu_b[1]) + abs(imu_a[2] - imu_b[2]) + ) + if diff > 0.001: + return True + return False def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=False): MultiprocLogging.configurer(log_queue) - """ """ - if is_debug: - logger.setLevel(logging.DEBUG) - logger.debug("Starting Integrator") - try: - # Dict of RA, Dec, etc. initialized to None: - solved = get_initialized_solved_dict() + if is_debug: + logger.setLevel(logging.DEBUG) + logger.debug("Starting Integrator") + + solved = { + "RA": None, + "Dec": None, + "Roll": None, + "camera_center": { + "RA": None, + "Dec": None, + "Roll": None, + "Alt": None, + "Az": None, + }, + "camera_solve": { + "RA": None, + "Dec": None, + "Roll": None, + }, + "Roll_offset": 0, # May/may not be needed - for experimentation + "imu_pos": None, + "Alt": None, + "Az": None, + "solve_source": None, + "solve_time": None, + "cam_solve_time": 0, + "constellation": None, + } cfg = config.Config() - - mount_type = cfg.get_option("mount_type") - logger.debug(f"mount_type = {mount_type}") - - # Set up dead-reckoning tracking by the IMU: - imu_dead_reckoning = ImuDeadReckoning(cfg.get_option("screen_direction")) - # imu_dead_reckoning.set_cam2scope_alignment(q_scope2cam) # TODO: Enable when q_scope2cam is available from alignment + if ( + cfg.get_option("screen_direction") == "left" + or cfg.get_option("screen_direction") == "flat" + or cfg.get_option("screen_direction") == "flat3" + or cfg.get_option("screen_direction") == "straight" + ): + flip_alt_offset = True + else: + flip_alt_offset = False # This holds the last image solve position info # so we can delta for IMU updates last_image_solve = None last_solve_time = time.time() - while True: state_utils.sleep_for_framerate(shared_state) @@ -82,7 +107,6 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa solved = copy.deepcopy(last_image_solve) # If no successful solve yet, keep initial solved dict - # TODO: Create a function to update solve? # Update solve metadata (always needed for auto-exposure) for key in [ "Matches", @@ -97,6 +121,58 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa if next_image_solve.get("RA") is not None: solved.update(next_image_solve) + # Recalculate Alt/Az for NEW successful solve + location = shared_state.location() + dt = shared_state.datetime() + + if location and dt: + # We have position and time/date and a valid solve! + calc_utils.sf_utils.set_location( + location.lat, + location.lon, + location.altitude, + ) + alt, az = calc_utils.sf_utils.radec_to_altaz( + solved["RA"], + solved["Dec"], + dt, + ) + solved["Alt"] = alt + solved["Az"] = az + + alt, az = calc_utils.sf_utils.radec_to_altaz( + solved["camera_center"]["RA"], + solved["camera_center"]["Dec"], + dt, + ) + solved["camera_center"]["Alt"] = alt + solved["camera_center"]["Az"] = az + + # Experimental: For monitoring roll offset + # Estimate the roll offset due misalignment of the + # camera sensor with the Pole-to-Source great circle. + solved["Roll_offset"] = estimate_roll_offset(solved, dt) + # Find the roll at the target RA/Dec. Note that this doesn't include the + # roll offset so it's not the roll that the PiFinder camear sees but the + # roll relative to the celestial pole + roll_target_calculated = calc_utils.sf_utils.radec_to_roll( + solved["RA"], solved["Dec"], dt + ) + # Compensate for the roll offset. This gives the roll at the target + # as seen by the camera. + solved["Roll"] = roll_target_calculated + solved["Roll_offset"] + + # calculate roll for camera center + roll_target_calculated = calc_utils.sf_utils.radec_to_roll( + solved["camera_center"]["RA"], + solved["camera_center"]["Dec"], + dt, + ) + # Compensate for the roll offset. This gives the roll at the target + # as seen by the camera. + solved["camera_center"]["Roll"] = ( + roll_target_calculated + solved["Roll_offset"] + ) # For failed solves, preserve ALL position data from previous solve # Don't recalculate from GPS (causes drift from GPS noise) @@ -111,242 +187,117 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa ) ) shared_state.set_solve_state(True) - # We have a new image solve: Use plate-solving for RA/Dec - update_plate_solve_and_imu(imu_dead_reckoning, solved) else: # Failed solve - clear constellation solved["solve_source"] = "CAM_FAILED" solved["constellation"] = "" - - # Push failed solved immediately - # This ensures auto-exposure sees Matches=0 for failed solves - shared_state.set_solution(solved) shared_state.set_solve_state(False) - elif imu_dead_reckoning.tracking: - # Previous plate-solve exists so use IMU dead-reckoning from - # the last plate solved coordinates. + # Push all camera solves (success and failure) immediately + # This ensures auto-exposure sees Matches=0 for failed solves + shared_state.set_solution(solved) + + # Use IMU dead-reckoning from the last camera solve: + # Check we have an alt/az solve, otherwise we can't use the IMU + elif solved["Alt"]: imu = shared_state.imu() if imu: - update_imu(imu_dead_reckoning, solved, last_image_solve, imu) + dt = shared_state.datetime() + if last_image_solve and last_image_solve["Alt"]: + # If we have alt, then we have a position/time + + # calc new alt/az + lis_imu = last_image_solve["imu_pos"] + imu_pos = imu["pos"] + if imu_moved(lis_imu, imu_pos): + alt_offset = imu_pos[IMU_ALT] - lis_imu[IMU_ALT] + if flip_alt_offset: + alt_offset = ((alt_offset + 180) % 360 - 180) * -1 + else: + alt_offset = (alt_offset + 180) % 360 - 180 + solved["Alt"] = (last_image_solve["Alt"] - alt_offset) % 360 + solved["camera_center"]["Alt"] = ( + last_image_solve["camera_center"]["Alt"] - alt_offset + ) % 360 + + az_offset = imu_pos[IMU_AZ] - lis_imu[IMU_AZ] + az_offset = (az_offset + 180) % 360 - 180 + solved["Az"] = (last_image_solve["Az"] + az_offset) % 360 + solved["camera_center"]["Az"] = ( + last_image_solve["camera_center"]["Az"] + az_offset + ) % 360 + + # N.B. Assumes that location hasn't changed since last solve + # Turn this into RA/DEC + ( + solved["RA"], + solved["Dec"], + ) = calc_utils.sf_utils.altaz_to_radec( + solved["Alt"], solved["Az"], dt + ) + # Calculate the roll at the target RA/Dec and compensate for the offset. + solved["Roll"] = ( + calc_utils.sf_utils.radec_to_roll( + solved["RA"], solved["Dec"], dt + ) + + solved["Roll_offset"] + ) + + # Now for camera centered solve + ( + solved["camera_center"]["RA"], + solved["camera_center"]["Dec"], + ) = calc_utils.sf_utils.altaz_to_radec( + solved["camera_center"]["Alt"], + solved["camera_center"]["Az"], + dt, + ) + # Calculate the roll at the target RA/Dec and compensate for the offset. + solved["camera_center"]["Roll"] = ( + calc_utils.sf_utils.radec_to_roll( + solved["camera_center"]["RA"], + solved["camera_center"]["Dec"], + dt, + ) + + solved["Roll_offset"] + ) + + solved["solve_time"] = time.time() + solved["solve_source"] = "IMU" # Push IMU updates only if newer than last push + # (Camera solves already pushed above at line 185) if ( - solved["RA"] and solved["solve_time"] > last_solve_time - # and solved["solve_source"] == "IMU" + solved["RA"] + and solved["solve_time"] > last_solve_time + and solved.get("solve_source") == "IMU" ): - last_solve_time = time.time() # TODO: solve_time is ambiguous because it's also used for IMU dead-reckoning - - # Set location for roll and altaz calculations. - # TODO: Is it necessary to set location? - # TODO: Altaz doesn't seem to be required for catalogs when in - # EQ mode? Could be disabled in future when in EQ mode? - location = shared_state.location() - dt = shared_state.datetime() - if location: - calc_utils.sf_utils.set_location( - location.lat, location.lon, location.altitude - ) - - # Set the roll so that the chart is displayed appropriately for the mount type - solved["Roll"] = get_roll_by_mount_type( - solved["RA"], solved["Dec"], location, dt, mount_type - ) - - # Update remaining solved keys - # Calculate constellation for current position + last_solve_time = time.time() + # Calculate constellation for IMU dead-reckoning position solved["constellation"] = calc_utils.sf_utils.radec_to_constellation( solved["RA"], solved["Dec"] - ) # TODO: Can the outer brackets be omitted? - - # Set Alt/Az because it's needed for the catalogs for the - # Alt/Az mount type. TODO: Can this be moved to the catalog? - dt = shared_state.datetime() - if location and dt: - solved["Alt"], solved["Az"] = calc_utils.sf_utils.radec_to_altaz( - solved["RA"], solved["Dec"], dt - ) + ) # Push IMU update shared_state.set_solution(solved) shared_state.set_solve_state(True) - except EOFError: logger.error("Main no longer running for integrator") -# ======== Wrapper and helper functions =============================== - - -def update_plate_solve_and_imu(imu_dead_reckoning: ImuDeadReckoning, solved: dict): +def estimate_roll_offset(solved, dt): """ - Wrapper for ImuDeadReckoning.update_plate_solve_and_imu() to - interface angles in degrees to radians. + Estimate the roll offset due to misalignment of the camera sensor with + the mount/scope's coordinate system. The offset is calculated at the + center of the camera's FoV. - This updates the pointing model with the plate-solved coordinates and the - IMU measurements which are assumed to have been taken at the same time. - """ - if (solved["RA"] is None) or (solved["Dec"] is None): - return # No update - else: - # Successfully plate solved & camera pointing exists - if solved["imu_quat"] is None: - q_x2imu = quaternion.quaternion(np.nan) - else: - q_x2imu = solved["imu_quat"] # IMU measurement at the time of plate solving - - # Update: - solved_cam = RaDecRoll() - solved_cam.set_from_deg( - solved["camera_center"]["RA"], - solved["camera_center"]["Dec"], - solved["camera_center"]["Roll"], - ) - imu_dead_reckoning.update_plate_solve_and_imu(solved_cam, q_x2imu) - - # Set alignment. TODO: Do this once at alignment. Move out of here. - set_cam2scope_alignment(imu_dead_reckoning, solved) - - -def update_imu( - imu_dead_reckoning: ImuDeadReckoning, - solved: dict, - last_image_solve: dict, - imu: dict, -): - """ - Updates the solved dictionary using IMU dead-reckoning from the last - solved pointing. + To calculate the roll with offset: roll = calculated_roll + roll_offset """ - if not (last_image_solve and imu_dead_reckoning.tracking): - return # Need all of these to do IMU dead-reckoning - - assert isinstance( - imu["quat"], quaternion.quaternion - ), "Expecting quaternion.quaternion type" # TODO: Can be removed later - q_x2imu = imu["quat"] # Current IMU measurement (quaternion) - - # When moving, switch to tracking using the IMU - angle_moved = qt.get_quat_angular_diff(last_image_solve["imu_quat"], q_x2imu) - if angle_moved > IMU_MOVED_ANG_THRESHOLD: - # Estimate camera pointing using IMU dead-reckoning - logger.debug( - "Track using IMU: Angle moved since last_image_solve = " - "{:}(> threshold = {:}) | IMU quat = ({:}, {:}, {:}, {:})".format( - np.rad2deg(angle_moved), - np.rad2deg(IMU_MOVED_ANG_THRESHOLD), - q_x2imu.w, - q_x2imu.x, - q_x2imu.y, - q_x2imu.z, - ) - ) - - # Dead-reckoning using IMU - imu_dead_reckoning.update_imu(q_x2imu) # Latest IMU measurement - - # Store current camera pointing estimate: - cam_eq = imu_dead_reckoning.get_cam_radec() - ( - solved["camera_center"]["RA"], - solved["camera_center"]["Dec"], - solved["camera_center"]["Roll"], - ) = cam_eq.get_deg(use_none=True) - - # Store the current scope pointing estimate - scope_eq = imu_dead_reckoning.get_scope_radec() - solved["RA"], solved["Dec"], solved["Roll"] = scope_eq.get_deg(use_none=True) - - solved["solve_time"] = time.time() - solved["solve_source"] = "IMU" - - # Logging for states updated in solved: - logger.debug( - "IMU update: scope: RA: {:}, Dec: {:}, Roll: {:}".format( - solved["RA"], solved["Dec"], solved["Roll"] - ) - ) - logger.debug( - "IMU update: camera_center: RA: {:}, Dec: {:}, Roll: {:}".format( - solved["camera_center"]["RA"], - solved["camera_center"]["Dec"], - solved["camera_center"]["Roll"], - ) - ) - - -def set_cam2scope_alignment(imu_dead_reckoning: ImuDeadReckoning, solved: dict): - """ - Set alignment. - TODO: Do this once at alignment - """ - # RA, Dec of camera center:: - solved_cam = RaDecRoll() - solved_cam.set_from_deg( - solved["camera_center"]["RA"], - solved["camera_center"]["Dec"], - solved["camera_center"]["Roll"], + # Calculate the expected roll at the camera center given the RA/Dec of + # of the camera center. + roll_camera_calculated = calc_utils.sf_utils.radec_to_roll( + solved["camera_center"]["RA"], solved["camera_center"]["Dec"], dt ) + roll_offset = solved["camera_center"]["Roll"] - roll_camera_calculated - # RA, Dec of target (where scope is pointing): - solved["Roll"] = 0 # Target roll isn't calculated by Tetra3. Set to zero here - solved_scope = RaDecRoll() - solved_scope.set_from_deg(solved["RA"], solved["Dec"], solved["Roll"]) - - # Set alignment in imu_dead_reckoning - imu_dead_reckoning.set_cam2scope_alignment(solved_cam, solved_scope) - - -def get_roll_by_mount_type( - ra_deg: float, # Right Ascension of the target in degrees - dec_deg: float, # Declination of the target in degrees - location, # astropy EarthLocation object or None - dt: datetime.datetime, # datetime.datetime object or None - mount_type: str, # "Alt/Az" or "EQ" -) -> float: - """ - Returns the roll (in degrees) depending on the mount type so that the chart - is displayed appropriately for the mount type. The RA and Dec of the target - should be provided (in degrees). - - * Alt/Az mount: Display the chart in the horizontal coordinate so that up - in the chart points to the Zenith. - * EQ mount: Display the chart in the equatorial coordinate system with the - NCP up so roll = 0. - - Assumes that location has already been set in calc_utils.sf_utils. - """ - if mount_type == "Alt/Az": - # Altaz mounts: Display chart in horizontal coordinates - if location and dt: - # We have location and time/date (and assume that location has been set) - # Roll at the target RA/Dec in the horizontal frame - roll_deg = calc_utils.sf_utils.radec_to_roll(ra_deg, dec_deg, dt) - - # HACK: - # The IMU direction flips at a certaint point. Could due to a - # an issue in the formula in calc_utils.sf_utils.hadec_to_roll() - # This is a temperary hack for testing. - ha_deg = calc_utils.sf_utils.ra_to_ha(ra_deg, dt) - roll_deg = ( - roll_deg - np.sign(ha_deg) * 180 - ) # In essence, gives: roll_deg = -pa_deg - # End of HACK - else: - # No position or time/date available, so set roll to 0.0 - roll_deg = 0.0 - elif mount_type == "EQ": - # EQ-mounts: Display chart with NCP up so roll = 0.0 - roll_deg = 0.0 - else: - logger.error(f"Unknown mount type: {mount_type}. Cannot set roll.") - roll_deg = 0.0 - - # If location is available, adjust roll for hemisphere: - # Altaz: North up in northern hemisphere, South up in southern hemisphere - # EQ mounts: NCP up in northern hemisphere, SCP up in southern hemisphere - if location: - if location.lat < 0.0: - roll_deg += 180.0 # Southern hemisphere - - return roll_deg + return roll_offset diff --git a/python/PiFinder/solver.py b/python/PiFinder/solver.py index 69a0cb251..4d8b2e21d 100644 --- a/python/PiFinder/solver.py +++ b/python/PiFinder/solver.py @@ -123,13 +123,7 @@ def update_sqm( k: v for k, v in details.items() if k - not in ( - "star_centroids", - "star_mags", - "star_fluxes", - "star_local_backgrounds", - "star_mzeros", - ) + not in ("star_centroids", "star_mags", "star_fluxes", "star_local_backgrounds", "star_mzeros") } shared_state.set_sqm_details(filtered_details) @@ -181,9 +175,7 @@ def _get_stub(self): ) return self._stub - def extract_centroids( - self, image, sigma, max_size, use_binned, detect_hot_pixels=True - ): + def extract_centroids(self, image, sigma, max_size, use_binned, detect_hot_pixels=True): """Override to raise CedarConnectionError on gRPC failure instead of returning empty list.""" import numpy as np from tetra3 import cedar_detect_pb2 @@ -195,14 +187,10 @@ def extract_centroids( # Use shared memory path (same machine) if self._use_shmem: self._alloc_shmem(size=width * height) - shimg = np.ndarray( - np_image.shape, dtype=np_image.dtype, buffer=self._shmem.buf - ) + 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 - ) + im = cedar_detect_pb2.Image(width=width, height=height, shmem_name=self._shmem.name) req = cedar_detect_pb2.CentroidsRequest( input_image=im, sigma=sigma, @@ -219,14 +207,10 @@ def extract_centroids( self._del_shmem() self._use_shmem = False else: - raise CedarConnectionError( - f"Cedar gRPC failed: {err.details()}" - ) from err + raise CedarConnectionError(f"Cedar gRPC failed: {err.details()}") from err if not self._use_shmem: - im = cedar_detect_pb2.Image( - width=width, height=height, image_data=np_image.tobytes() - ) + im = cedar_detect_pb2.Image(width=width, height=height, image_data=np_image.tobytes()) req = cedar_detect_pb2.CentroidsRequest( input_image=im, sigma=sigma, @@ -237,9 +221,7 @@ def extract_centroids( try: centroids_result = self._get_stub().ExtractCentroids(req) except grpc.RpcError as err: - raise CedarConnectionError( - f"Cedar gRPC failed: {err.details()}" - ) from err + raise CedarConnectionError(f"Cedar gRPC failed: {err.details()}") from err tetra_centroids = [] if centroids_result is not None: @@ -261,7 +243,6 @@ def solver( align_result_queue, camera_command_queue, is_debug=False, - max_imu_ang_during_exposure=1.0, # Max allowed turn during exp [degrees] ): MultiprocLogging.configurer(log_queue) logger.debug("Starting Solver") @@ -270,9 +251,34 @@ def solver( ) align_ra = 0 align_dec = 0 - # Dict of RA, Dec, etc. initialized to None: - solved = get_initialized_solved_dict() solution = {} + solved = { + # RA, Dec, Roll solved at the center of the camera FoV + # update by integrator + "camera_center": { + "RA": None, + "Dec": None, + "Roll": None, + "Alt": None, + "Az": None, + }, + # RA, Dec, Roll from the camera, not + # affected by IMU in integrator + "camera_solve": { + "RA": None, + "Dec": None, + "Roll": None, + }, + # RA, Dec, Roll at the target pixel + "RA": None, + "Dec": None, + "Roll": None, + "imu_pos": None, + "solve_time": None, + "cam_solve_time": 0, + "last_solve_attempt": 0, # Timestamp of last solve attempt - tracks exposure_end of last processed image + "last_solve_success": None, # Timestamp of last successful solve + } centroids = [] log_no_stars_found = True @@ -337,8 +343,14 @@ def solver( is_new_image = ( last_image_metadata["exposure_end"] > solved["last_solve_attempt"] ) + is_stationary = last_image_metadata["imu_delta"] < 1 - if is_new_image: + if is_new_image and not is_stationary: + logger.debug( + f"Skipping image - IMU delta {last_image_metadata['imu_delta']:.2f}° >= 1° (moving)" + ) + + if is_new_image and is_stationary: try: img = camera_image.copy() img = img.convert(mode="L") @@ -358,9 +370,7 @@ def solver( np_image, sigma=8, max_size=10, use_binned=True ) except CedarConnectionError as e: - logger.warning( - f"Cedar connection failed: {e}, falling back to tetra3" - ) + logger.warning(f"Cedar connection failed: {e}, falling back to tetra3") centroids = tetra3.get_centroids_from_image(np_image) else: # Cedar not available, use tetra3 @@ -441,25 +451,19 @@ def solver( solved["camera_center"]["Dec"] = solved["Dec"] solved["camera_center"]["Roll"] = solved["Roll"] - # RA, Dec, Roll at the camera center from plate-solve (no IMU compensation) + # RA, Dec, Roll at the center of the camera's not imu: solved["camera_solve"]["RA"] = solved["RA"] solved["camera_solve"]["Dec"] = solved["Dec"] solved["camera_solve"]["Roll"] = solved["Roll"] - # RA, Dec, Roll at the target pixel: - # Replace the camera center RA/Dec with the RA/Dec for the target pixel solved["RA"] = solved["RA_target"] solved["Dec"] = solved["Dec_target"] - - if last_image_metadata.get("imu"): + if last_image_metadata["imu"]: + solved["imu_pos"] = last_image_metadata["imu"]["pos"] solved["imu_quat"] = last_image_metadata["imu"]["quat"] - solved["imu_pos"] = last_image_metadata["imu"].get( - "pos" - ) else: - solved["imu_quat"] = None solved["imu_pos"] = None - + solved["imu_quat"] = None solved["solve_time"] = time.time() solved["cam_solve_time"] = solved["solve_time"] # Mark successful solve - use same timestamp as last_solve_attempt for comparison @@ -525,52 +529,5 @@ def solver( logger.error( f"Active threads: {[t.name for t in threading.enumerate()]}" ) - except Exception: + except Exception as e: pass # Don't let diagnostic logging fail - - -def get_initialized_solved_dict() -> dict: - """ - Returns an initialized 'solved' dictionary with cooridnate and other - information. - - TODO: Update solver_main.py with this - TODO: use RaDecRoll class for the RA, Dec, Roll coordinates here? - TODO: "Alt" and "Az" could be removed but seems to be required by catalogs? - """ - solved = { - # RA, Dec, Roll [deg] of the scope at the target pixel - "RA": None, - "Dec": None, - "Roll": None, - # RA, Dec, Roll [deg] solved at the center of the camera FoV - # update by the IMU in the integrator - "camera_center": { - "RA": None, - "Dec": None, - "Roll": None, - "Alt": None, # NOTE: Altaz needed by catalogs for altaz mounts - "Az": None, - }, - # RA, Dec, Roll [deg] from the camera, not updated by IMU in integrator - "camera_solve": { - "RA": None, - "Dec": None, - "Roll": None, - }, - "imu_pos": None, # IMU euler angles (classic integrator) - "imu_quat": None, # IMU quaternion as numpy quaternion (scalar-first) - "Roll_offset": 0, # Roll offset for classic integrator - # Alt, Az [deg] of scope: - "Alt": None, - "Az": None, - # Diagnostics: - "solve_source": None, # Source of the solve ("CAM", "CAM_FAILED", "IMU") - "solve_time": None, - "cam_solve_time": 0, - "last_solve_attempt": 0, # Timestamp of last solve attempt - tracks exposure_end of last processed image - "last_solve_success": None, # Timestamp of last successful solve - "constellation": None, - } - - return solved diff --git a/python/PiFinder/sqm/sqm.py b/python/PiFinder/sqm/sqm.py index 1ed8c8d6b..82246bd75 100644 --- a/python/PiFinder/sqm/sqm.py +++ b/python/PiFinder/sqm/sqm.py @@ -149,9 +149,7 @@ def _measure_star_flux_with_local_background( # Check for saturation in aperture aperture_pixels = image_patch[aperture_mask] - max_aperture_pixel = ( - np.max(aperture_pixels) if len(aperture_pixels) > 0 else 0 - ) + max_aperture_pixel = np.max(aperture_pixels) if len(aperture_pixels) > 0 else 0 if max_aperture_pixel >= saturation_threshold: # Mark saturated star with flux=-1 to be excluded from mzero calculation @@ -220,7 +218,9 @@ def _calculate_mzero( # Flux-weighted mean: brighter stars contribute more valid_mzeros_arr = np.array(valid_mzeros) valid_fluxes_arr = np.array(valid_fluxes) - weighted_mzero = float(np.average(valid_mzeros_arr, weights=valid_fluxes_arr)) + weighted_mzero = float( + np.average(valid_mzeros_arr, weights=valid_fluxes_arr) + ) return weighted_mzero, mzeros @@ -501,9 +501,7 @@ def calculate( # Following ASTAP: zenith is reference point where extinction = 0 # Only ADDITIONAL extinction below zenith is added: 0.28 * (airmass - 1) # This allows comparing measurements at different altitudes - extinction_for_altitude = self._atmospheric_extinction( - altitude_deg - ) # 0.28*(airmass-1) + extinction_for_altitude = self._atmospheric_extinction(altitude_deg) # 0.28*(airmass-1) # Main SQM value: no extinction correction (raw measurement) sqm_final = sqm_uncorrected diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index e96b1825b..2fc6fd027 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -126,12 +126,12 @@ def __repr__(self): SharedStateObj( power_state=1, solve_state=True, - solution={'RA': 22.86683471463411, 'Dec': 15.347716050003328, + solution={'RA': 22.86683471463411, 'Dec': 15.347716050003328, 'imu_pos': [171.39798541261814, 202.7646132036331, 358.2794741322842], 'solve_time': 1695297930.5532792, 'cam_solve_time': 1695297930.5532837, 'Roll': 306.2951794424281, 'FOV': 10.200729425086111, 'RMSE': 21.995567413046142, 'Matches': 12, 'Prob': 6.987725483613384e-13, 'T_solve': 15.00384000246413, 'RA_target': 22.86683471463411, 'Dec_target': 15.347716050003328, 'T_extract': 75.79255499877036, 'Alt': None, 'Az': None, 'solve_source': 'CAM', 'constellation': 'Psc'}, - imu={'moving': False, 'move_start': 1695297928.69749, 'move_end': 1695297928.764207, - 'status': 3}, + imu={'moving': False, 'move_start': 1695297928.69749, 'move_end': 1695297928.764207, 'pos': [171.39798541261814, 202.7646132036331, 358.2794741322842], + 'start_pos': [171.4009455613444, 202.76321535004726, 358.2587208386012], 'status': 3}, location={'lat': 59.05139745, 'lon': 7.987654, 'altitude': 151.4, 'source': 'GPS', gps_lock': False, 'timezone': 'Europe/Stockholm', 'last_gps_lock': None}, datetime=None, screen=, @@ -239,7 +239,7 @@ def from_json(cls, json_str): class SharedStateObj: def __init__(self): - self.__power_state = 1 # 0 = sleep state, 1 = awake state + self.__power_state = 1 # self.__solve_state # None = No solve attempted yet # True = Valid solve data from either IMU or Camera @@ -251,16 +251,14 @@ def __init__(self): "exposure_end": 0, "exposure_time": 500000, # Default exposure time in microseconds (0.5s) "imu": None, - "imu_delta": 0.0, # Angle between quaternion at start and end of exposure [deg] + "imu_delta": 0, } self.__solution = None self.__sats = None self.__imu = None self.__location: Location = Location() self.__sqm: SQM = SQM() - self.__noise_floor: float = ( - 10.0 # Adaptive noise floor in ADU (default fallback) - ) + self.__noise_floor: float = 10.0 # Adaptive noise floor in ADU (default fallback) self.__sqm_details: dict = {} # Full SQM calculation details for calibration self.__datetime = None self.__datetime_time = None @@ -298,16 +296,7 @@ def power_state(self): return self.__power_state 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. - """ - if v in (0, 1): - self.__power_state = v - else: - logger.error( - f"Invalid value for set_power_state: {v}. power_state not changed." - ) + self.__power_state = v def arch(self): return self.__arch diff --git a/python/PiFinder/sys_utils_nixos.py b/python/PiFinder/sys_utils_nixos.py new file mode 100644 index 000000000..5e9020393 --- /dev/null +++ b/python/PiFinder/sys_utils_nixos.py @@ -0,0 +1,591 @@ +""" +NixOS-native system utilities for PiFinder. + +Replaces sys_utils.py's wpa_supplicant/hostapd/file-editing approach with: +- 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-rebuild for camera switching and software updates +""" +import glob +import os +import subprocess +import socket +import time +import zipfile +import logging +from pathlib import Path +from typing import Optional + +import requests + +import dbus +import pam +from PiFinder import utils + +import gi + +gi.require_version("NM", "1.0") +from gi.repository import GLib, NM # noqa: E402 + +BACKUP_PATH = str(utils.data_dir / "PiFinder_backup.zip") +AP_CONNECTION_NAME = "PiFinder-AP" + +logger = logging.getLogger("SysUtils.NixOS") + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run a command, logging failures. Used only for nixos-rebuild and systemctl.""" + result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + if result.returncode != 0: + logger.error( + "Command %s failed (rc=%d): %s", + cmd, result.returncode, result.stderr.strip(), + ) + return result + + +def _nm_client() -> NM.Client: + """Create a NetworkManager client (synchronous).""" + return NM.Client.new(None) + + +def _nm_run_async(async_fn, *args): + """ + Run an async NM operation synchronously by spinning a local GLib MainLoop. + + Usage: + result = _nm_run_async(client.add_connection_async, profile, True, None) + """ + loop = GLib.MainLoop.new(None, False) + state = {"result": None, "error": None} + + def callback(source, async_result, _user_data): + try: + # The finish method name matches the async method name: + # add_connection_async -> add_connection_finish + # delete_async -> delete_finish + # activate_connection_async -> activate_connection_finish + # deactivate_connection_async -> deactivate_connection_finish + # commit_changes_async -> commit_changes_finish + method_name = async_fn.__name__.replace("_async", "_finish") + finish_fn = getattr(source, method_name) + state["result"] = finish_fn(async_result) + except Exception as e: + state["error"] = e + finally: + loop.quit() + + async_fn(*args, callback, None) + loop.run() + + if state["error"]: + raise state["error"] + return state["result"] + + +def _get_system_bus() -> dbus.SystemBus: + return dbus.SystemBus() + + +# --------------------------------------------------------------------------- +# Network class — WiFi management via NM GLib bindings +# --------------------------------------------------------------------------- + +class Network: + """ + 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: + """Get saved WiFi connections from NetworkManager.""" + self._wifi_networks = [] + network_id = 0 + 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") if ssid_bytes else "" + self._wifi_networks.append({ + "id": network_id, + "ssid": ssid, + "psk": None, + "key_mgmt": "WPA-PSK", + }) + network_id += 1 + + def get_wifi_networks(self): + return self._wifi_networks + + def delete_wifi_network(self, network_id): + """Delete a saved WiFi connection.""" + if network_id < 0 or network_id >= len(self._wifi_networks): + logger.error("Invalid network_id: %d", network_id) + return + ssid = self._wifi_networks[network_id]["ssid"] + for conn in self._client.get_connections(): + if conn.get_id() == ssid: + try: + _nm_run_async(conn.delete_async, None) + except Exception as e: + logger.error("Failed to delete connection '%s': %s", ssid, e) + break + self.populate_wifi_networks() + + def add_wifi_network(self, ssid, key_mgmt, psk=None): + """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) + + try: + _nm_run_async( + self._client.add_and_activate_connection_async, + profile, + self._client.get_device_by_iface("wlan0"), + None, + None, + ) + except Exception as e: + logger.error("Failed to add WiFi network '%s': %s", ssid, e) + + self.populate_wifi_networks() + + def get_ap_name(self) -> str: + """Get the current AP SSID from the PiFinder-AP profile.""" + for conn in self._client.get_connections(): + if conn.get_id() == AP_CONNECTION_NAME: + s_wifi = conn.get_setting_wireless() + if s_wifi: + ssid_bytes = s_wifi.get_ssid() + if ssid_bytes: + return ssid_bytes.get_data().decode("utf-8") + return "PiFinderAP" + + def set_ap_name(self, ap_name: str) -> None: + """Change the AP SSID.""" + if ap_name == self.get_ap_name(): + return + for conn in self._client.get_connections(): + if conn.get_id() == AP_CONNECTION_NAME: + s_wifi = conn.get_setting_wireless() + if s_wifi: + s_wifi.set_property( + NM.SETTING_WIRELESS_SSID, + GLib.Bytes.new(ap_name.encode("utf-8")), + ) + try: + _nm_run_async(conn.commit_changes_async, True, None) + except Exception as e: + logger.error("Failed to update AP SSID: %s", e) + return + + def get_host_name(self) -> str: + return socket.gethostname() + + def get_connected_ssid(self) -> str: + """Returns the SSID of the connected wifi network.""" + if self.wifi_mode() == "AP": + return "" + device = self._client.get_device_by_iface("wlan0") + if device is None: + return "" + ac = device.get_active_connection() + if ac is None: + return "" + conn = ac.get_connection() + if conn is None: + return "" + s_wifi = conn.get_setting_wireless() + if s_wifi is None: + return "" + ssid_bytes = s_wifi.get_ssid() + if ssid_bytes is None: + return "" + return ssid_bytes.get_data().decode("utf-8") + + def set_host_name(self, hostname: str) -> None: + """Set hostname via D-Bus to org.freedesktop.hostname1.""" + if hostname == self.get_host_name(): + return + try: + bus = _get_system_bus() + hostnamed = bus.get_object( + "org.freedesktop.hostname1", + "/org/freedesktop/hostname1", + ) + iface = dbus.Interface(hostnamed, "org.freedesktop.hostname1") + iface.SetStaticHostname(hostname, False) + except dbus.DBusException as e: + logger.error("Failed to set hostname via D-Bus: %s", e) + + def wifi_mode(self) -> str: + return self._wifi_mode + + def set_wifi_mode(self, mode: str) -> None: + if mode == self._wifi_mode: + return + if mode == "AP": + self._activate_connection(AP_CONNECTION_NAME) + elif mode == "Client": + self._deactivate_connection(AP_CONNECTION_NAME) + self._wifi_mode = mode + + 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 + 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 local_ip(self) -> str: + 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 + + +# --------------------------------------------------------------------------- +# Backup / restore (stdlib zipfile) +# --------------------------------------------------------------------------- + +def remove_backup(): + """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("/") + + +# --------------------------------------------------------------------------- +# System control (systemctl subprocess + D-Bus for reboot/shutdown) +# --------------------------------------------------------------------------- + +def restart_pifinder() -> None: + """Restart the PiFinder service.""" + logger.info("SYS: Restarting PiFinder") + _run(["sudo", "systemctl", "restart", "pifinder"]) + + +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"]) + + +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"]) + + +# --------------------------------------------------------------------------- +# Software updates — async upgrade via systemd service +# --------------------------------------------------------------------------- + +UPGRADE_STATE_IDLE = "idle" +UPGRADE_STATE_RUNNING = "running" +UPGRADE_STATE_SUCCESS = "success" +UPGRADE_STATE_FAILED = "failed" + +VERSIONS_URL = ( + "https://raw.githubusercontent.com/mrosseel/PiFinder/release/versions.json" +) + +UPGRADE_REF_FILE = Path("/run/pifinder/upgrade-ref") + + +def fetch_version_manifest() -> Optional[dict]: + """Fetch the channel/version manifest from GitHub.""" + try: + resp = requests.get(VERSIONS_URL, timeout=10) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.error("Failed to fetch version manifest: %s", e) + return None + + +def get_versions_for_channel(channel: str) -> list[dict]: + """Get available versions for a channel. + + Returns list of {version, ref, date, notes}. + """ + manifest = fetch_version_manifest() + if manifest is None: + return [] + return manifest.get("channels", {}).get(channel, {}).get("versions", []) + + +def get_available_channels() -> list[str]: + """Get list of available channel names.""" + manifest = fetch_version_manifest() + if manifest is None: + return ["stable"] + return list(manifest.get("channels", {}).keys()) + + +def start_upgrade(ref: str = "release") -> bool: + """Start pifinder-upgrade.service with a specific git ref. + + Writes the ref to /run/pifinder/upgrade-ref for the service to read. + Returns True if the service was started successfully. + """ + try: + UPGRADE_REF_FILE.write_text(ref) + except OSError as e: + logger.error("Failed to write upgrade ref file: %s", e) + return False + + _run(["sudo", "systemctl", "reset-failed", "pifinder-upgrade.service"]) + result = _run([ + "sudo", "systemctl", "start", "--no-block", + "pifinder-upgrade.service", + ]) + return result.returncode == 0 + + +def get_upgrade_state() -> str: + """Poll upgrade service state.""" + result = _run(["systemctl", "is-active", "pifinder-upgrade.service"]) + status = result.stdout.strip() + if status == "activating": + return UPGRADE_STATE_RUNNING + elif status == "active": + return UPGRADE_STATE_SUCCESS + elif status == "failed": + return UPGRADE_STATE_FAILED + return UPGRADE_STATE_IDLE + + +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 "" + + +def update_software() -> bool: + """Blocking wrapper for backward compatibility (uses default ref).""" + if not start_upgrade(): + return False + while True: + time.sleep(10) + state = get_upgrade_state() + if state == UPGRADE_STATE_SUCCESS: + return True + elif state == UPGRADE_STATE_FAILED: + return False + + +# --------------------------------------------------------------------------- +# Password management (python-pam + chpasswd) +# --------------------------------------------------------------------------- + +def verify_password(username: str, password: str) -> bool: + """Verify a password against PAM.""" + p = pam.pam() + return p.authenticate(username, password, service="login") + + +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 + + +# --------------------------------------------------------------------------- +# Camera switching (nixos-rebuild + reboot) +# --------------------------------------------------------------------------- + +def switch_camera(cam_type: str) -> None: + """ + Switch camera by rebuilding NixOS with the appropriate camera type. + Requires reboot (dtoverlay change). + """ + logger.info("SYS: Switching camera to %s via nixos-rebuild", cam_type) + flake_path = str(utils.home_dir / "PiFinder") + result = _run([ + "sudo", "nixos-rebuild", "switch", + "--flake", f"{flake_path}#pifinder-{cam_type}", + ]) + if result.returncode == 0: + restart_system() + else: + logger.error("SYS: Camera switch rebuild failed: %s", result.stderr) + + +def switch_cam_imx477() -> None: + logger.info("SYS: Switching cam to imx477") + switch_camera("imx477") + + +def switch_cam_imx296() -> None: + logger.info("SYS: Switching cam to imx296") + switch_camera("imx296") + + +def switch_cam_imx462() -> None: + logger.info("SYS: Switching cam to imx462") + switch_camera("imx462") + + +# --------------------------------------------------------------------------- +# GPSD config (declarative on NixOS — no-ops) +# --------------------------------------------------------------------------- + +def check_and_sync_gpsd_config(baud_rate: int) -> bool: + """ + On NixOS, GPSD config is managed declaratively via services.nix. + This is a no-op. + """ + logger.info( + "SYS: GPSD baud rate %d — managed by NixOS configuration", baud_rate + ) + return False + + +def update_gpsd_config(baud_rate: int) -> None: + """On NixOS, GPSD configuration is declarative. This is a no-op.""" + logger.info( + "SYS: GPSD config is managed declaratively on NixOS (baud=%d)", baud_rate + ) diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index a34475bdf..e09880ed8 100644 --- a/python/PiFinder/ui/base.py +++ b/python/PiFinder/ui/base.py @@ -53,11 +53,7 @@ def update(self): self.progress = 0.0 if self.progress < 1.0: self.progress = min(1.0, self.progress + self.fade_speed) - return ( - self._get_text(self.show_sqm), - self._get_text(not self.show_sqm), - self.progress, - ) + return self._get_text(self.show_sqm), self._get_text(not self.show_sqm), self.progress def draw(self, draw, x, y, font, colors, max_brightness=255, inverted=False): """Draw with cross-fade animation. inverted=True for dark text on light bg.""" @@ -66,26 +62,13 @@ def draw(self, draw, x, y, font, colors, max_brightness=255, inverted=False): fade_out = progress < 0.5 t = progress * 2 if fade_out else (progress - 0.5) * 2 if inverted: - brightness = ( - int(max_brightness * t) - if fade_out - else int(max_brightness * (1 - t)) - ) + brightness = int(max_brightness * t) if fade_out else int(max_brightness * (1 - t)) else: - brightness = ( - int(max_brightness * (1 - t)) - if fade_out - else int(max_brightness * t) - ) + brightness = int(max_brightness * (1 - t)) if fade_out else int(max_brightness * t) text = previous if fade_out else current draw.text((x, y), text, font=font, fill=colors.get(brightness)) else: - draw.text( - (x, y), - current, - font=font, - fill=colors.get(0 if inverted else max_brightness), - ) + draw.text((x, y), current, font=font, fill=colors.get(0 if inverted else max_brightness)) class UIModule: @@ -265,24 +248,13 @@ def message(self, message, timeout: float = 2, size=(5, 44, 123, 84)): def _draw_titlebar_rotating_info(self, x, y, fg): """Draw rotating constellation/SQM in title bar (dark text on gray bg).""" self._rotating_display.draw( - self.draw, - x, - y, - self.fonts.bold.font, - self.colors, - max_brightness=64, - inverted=True, + self.draw, x, y, self.fonts.bold.font, self.colors, max_brightness=64, inverted=True ) def draw_rotating_info(self, x=10, y=92, font=None): """Draw rotating constellation/SQM display with cross-fade.""" self._rotating_display.draw( - self.draw, - x, - y, - font or self.fonts.bold.font, - self.colors, - max_brightness=255, + self.draw, x, y, font or self.fonts.bold.font, self.colors, max_brightness=255 ) def screen_update(self, title_bar=True, button_hints=True) -> None: @@ -312,7 +284,7 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: (6, 1), _(self.title), font=self.fonts.bold.font, fill=fg ) imu = self.shared_state.imu() - moving = True if imu and imu["quat"] and imu["moving"] else False + moving = True if imu and imu["pos"] and imu["moving"] else False # GPS status if self.shared_state.altaz_ready(): diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 78878c178..8c1840442 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -227,9 +227,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: diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index e4ee44380..75dfecb0d 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -26,7 +26,6 @@ def _(key: str) -> Any: s = _("Language: en") s = _("Language: es") s = _("Language: fr") -s = _("Language: zh") s = s del s @@ -143,12 +142,6 @@ def _(key: str) -> Any: "objects": "catalog", "value": "EGC", }, - { - "name": _("Harris Globs"), - "class": UIObjectList, - "objects": "catalog", - "value": "Har", - }, { "name": _("Herschel 400"), "class": UIObjectList, @@ -322,10 +315,6 @@ def _(key: str) -> Any: "name": _("E.G. Globs"), "value": "EGC", }, - { - "name": _("Harris Globs"), - "value": "Har", - }, { "name": _("Herschel 400"), "value": "H", @@ -690,22 +679,6 @@ def _(key: str) -> Any: }, ], }, - { - "name": _("T9 Search"), - "class": UITextMenu, - "select": "single", - "config_option": "t9_search", - "items": [ - { - "name": _("Off"), - "value": False, - }, - { - "name": _("On"), - "value": True, - }, - ], - }, { "name": _("Az Arrows"), "class": UITextMenu, @@ -746,10 +719,6 @@ def _(key: str) -> Any: "name": _("Spanish"), "value": "es", }, - { - "name": _("Chinese"), - "value": "zh", - }, ], }, ], @@ -1107,17 +1076,6 @@ def _(key: str) -> Any: "select": "Single", "items": [ {"name": "SQM", "class": UISQM}, - { - "name": _("Integrator"), - "class": UITextMenu, - "select": "single", - "config_option": "imu_integrator", - "post_callback": callbacks.restart_pifinder, - "items": [ - {"name": _("Classic"), "value": "classic"}, - {"name": _("Quaternion"), "value": "quaternion"}, - ], - }, { "name": _("AE Algo"), "class": UITextMenu, diff --git a/python/PiFinder/ui/sqm.py b/python/PiFinder/ui/sqm.py index b225cc8f6..b6e15aab2 100644 --- a/python/PiFinder/ui/sqm.py +++ b/python/PiFinder/ui/sqm.py @@ -265,9 +265,7 @@ def _is_calibrated(self) -> bool: camera_type = self.shared_state.camera_type() camera_type_processed = f"{camera_type}_processed" calibration_file = ( - Path.home() - / "PiFinder_data" - / f"sqm_calibration_{camera_type_processed}.json" + Path.home() / "PiFinder_data" / f"sqm_calibration_{camera_type_processed}.json" ) return calibration_file.exists() diff --git a/python/PiFinder/ui/sqm_calibration.py b/python/PiFinder/ui/sqm_calibration.py index c03b6c92b..fb3eb564f 100644 --- a/python/PiFinder/ui/sqm_calibration.py +++ b/python/PiFinder/ui/sqm_calibration.py @@ -695,9 +695,7 @@ def _analyze_calibration(self): # 2. Compute read noise using temporal variance (not spatial) # Spatial std includes fixed pattern noise (PRNU), which is wrong. # Temporal variance at each pixel measures true read noise. - temporal_variance = np.var( - bias_stack, axis=0 - ) # variance across frames per pixel + temporal_variance = np.var(bias_stack, axis=0) # variance across frames per pixel self.read_noise = float(np.sqrt(np.mean(temporal_variance))) # 3. Compute dark current rate diff --git a/python/PiFinder/ui/sqm_correction.py b/python/PiFinder/ui/sqm_correction.py index ab88f8301..5c29caaa2 100644 --- a/python/PiFinder/ui/sqm_correction.py +++ b/python/PiFinder/ui/sqm_correction.py @@ -229,10 +229,7 @@ def _wait_for_exposure(self, target_exp_us: int, timeout: float = 5.0) -> bool: start = time.time() while time.time() - start < timeout: metadata = self.shared_state.last_image_metadata() - if ( - metadata - and abs(metadata.get("exposure_time", 0) - target_exp_us) < 1000 - ): + if metadata and abs(metadata.get("exposure_time", 0) - target_exp_us) < 1000: # Wait one more frame for image to be ready time.sleep(0.3) return True @@ -293,13 +290,11 @@ def _create_correction_package(self, corrected_sqm: float) -> str: # Get current exposure image_metadata = self.shared_state.last_image_metadata() - current_exp = ( - image_metadata.get("exposure_time", 500000) if image_metadata else 500000 - ) + current_exp = image_metadata.get("exposure_time", 500000) if image_metadata else 500000 # Calculate bracket exposures (in microseconds) max_exp = 1000000 # 1 second max - min_exp = 10000 # 10ms min + min_exp = 10000 # 10ms min exp_above = min(current_exp * 2, max_exp) exp_below = max(current_exp // 2, min_exp) @@ -335,13 +330,11 @@ def _create_correction_package(self, corrected_sqm: float) -> str: files = self._capture_at_exposure(exp_us, corrections_dir, timestamp, label) if files: all_files.append(files) - metadata["bracket_exposures"].append( - { - "label": label, - "exposure_us": exp_us, - "exposure_sec": exp_us / 1_000_000.0, - } - ) + metadata["bracket_exposures"].append({ + "label": label, + "exposure_us": exp_us, + "exposure_sec": exp_us / 1_000_000.0, + }) # Re-enable auto-exposure self.command_queues["camera"].put("set_exp:auto") @@ -351,9 +344,7 @@ def _create_correction_package(self, corrected_sqm: float) -> str: # Add all captured images for files in all_files: if "processed" in files: - zf.write( - corrections_dir / files["processed"], arcname=files["processed"] - ) + zf.write(corrections_dir / files["processed"], arcname=files["processed"]) (corrections_dir / files["processed"]).unlink() if "raw" in files: zf.write(corrections_dir / files["raw"], arcname=files["raw"]) diff --git a/python/PiFinder/ui/textentry.py b/python/PiFinder/ui/textentry.py index 0e41d81b2..67e595437 100644 --- a/python/PiFinder/ui/textentry.py +++ b/python/PiFinder/ui/textentry.py @@ -122,10 +122,6 @@ def __init__(self, *args, **kwargs): self._results_updated = False # Flag to trigger UI refresh self.SEARCH_DEBOUNCE_MS = 250 # milliseconds - @property - def t9_search_enabled(self) -> bool: - return bool(self.config_object.get_option("t9_search", False)) - def draw_text_entry(self): line_text_y = self.text_y + 15 self.draw.line( @@ -284,10 +280,7 @@ def _perform_search(self, search_text, search_version): # Priority catalogs (NGC, IC, M) are loaded first, WDS loads in background # So search will work immediately with those, WDS results appear when loading completes logger.info(f"Starting search for '{search_text}'") - if self.t9_search_enabled: - results = self.catalogs.search_by_t9(search_text) - else: - results = self.catalogs.search_by_text(search_text) + results = self.catalogs.search_by_text(search_text) logger.info(f"Search for '{search_text}' found {len(results)} results") # Only update if this search is still current (not superseded by newer search) @@ -351,15 +344,6 @@ def key_long_minus(self): def key_number(self, number): current_time = time.time() number_key = str(number) - if not self.text_entry_mode and self.t9_search_enabled: - # In T9 mode we simply append the pressed digit - self.last_key_press_time = current_time - self.last_key = number - if number_key in self.keys: - self.char_index = 0 - self.add_char(number_key) - return - # Check if the same key is pressed within a short time if self.last_key == number and self.within_keypress_window(current_time): self.char_index = (self.char_index + 1) % self.keys.get_nr_entries( diff --git a/python/pyproject.toml b/python/pyproject.toml index 05a2b26e6..24fa681a7 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -137,6 +137,10 @@ module = [ 'picamera2', 'bottle', 'libinput', + 'dbus', + 'gi', + 'gi.repository', + 'gi.repository.*', ] ignore_missing_imports = true ignore_errors = true diff --git a/python/tests/test_sqm.py b/python/tests/test_sqm.py index d0a17e784..a66990f74 100644 --- a/python/tests/test_sqm.py +++ b/python/tests/test_sqm.py @@ -234,9 +234,7 @@ def test_calculate_extinction_applied(self): # Check extinction values (ASTAP convention: 0 at zenith) # Pickering airmass at 30° ≈ 1.995, so extinction ≈ 0.28 * 0.995 ≈ 0.279 - assert details_zenith["extinction_for_altitude"] == pytest.approx( - 0.0, abs=0.001 - ) + assert details_zenith["extinction_for_altitude"] == pytest.approx(0.0, abs=0.001) expected_ext_30 = 0.28 * (sqm._pickering_airmass(30.0) - 1) assert details_30deg["extinction_for_altitude"] == pytest.approx( expected_ext_30, abs=0.001 diff --git a/version.txt b/version.txt index 437459cd9..197c4d5c2 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.5.0 +2.4.0 diff --git a/versions.json b/versions.json new file mode 100644 index 000000000..0cb9298fc --- /dev/null +++ b/versions.json @@ -0,0 +1,36 @@ +{ + "channels": { + "stable": { + "description": "Tested releases", + "versions": [ + { + "version": "2.4.0", + "ref": "v2.4.0", + "date": "2025-07-01", + "notes": "Initial NixOS release" + } + ] + }, + "unstable": { + "description": "Latest development", + "versions": [ + { + "version": "2.5.0-dev", + "ref": "main", + "date": "2025-07-01", + "notes": "Development branch" + } + ] + }, + "beta": { + "versions": [ + { + "version": "2.5.0", + "ref": "v2.5.0-beta", + "date": "2026-02-05", + "notes": "flexible upgrades" + } + ] + } + } +}