├── .gitignore ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE.md ├── README.md ├── about.md ├── build.sh ├── building.md ├── cedar ├── bin │ └── cedar-box-server ├── cedar-aim ├── data │ └── default_database.npz └── tetra3_server ├── create_cedar_image ├── README.txt ├── add_rpi_user.py ├── cedar-ap-power.service ├── cedar-ap-setup.py ├── cedar-ap-setup.service ├── create_userconf.py ├── customize-pi-image.sh ├── enable_ssh.py ├── install-cedar-ap-setup.py ├── install_cedar.sh ├── modify_cmdline.py ├── modify_swap.py ├── mount_img.py ├── resize_fs.py ├── set_hostname.py └── update_upgrade_chroot.py ├── elements ├── Cargo.toml ├── build.rs └── src │ ├── astro_util.rs │ ├── cedar_sky_trait.rs │ ├── image_utils.rs │ ├── lib.rs │ ├── proto │ ├── cedar.proto │ ├── cedar_common.proto │ ├── cedar_sky.proto │ └── google │ │ └── protobuf │ │ ├── duration.proto │ │ └── timestamp.proto │ ├── reservoir_sampler.rs │ ├── solver_trait.rs │ ├── value_stats.rs │ └── wifi_trait.rs ├── notes.txt ├── polar_align.md ├── release_notes.md ├── run.sh ├── run └── demo_images │ ├── bright_star_align.jpg │ ├── daytime_align.jpg │ └── readme.txt ├── server ├── Cargo.toml └── src │ ├── activity_led.rs │ ├── bin │ ├── cedar_box_server.rs │ └── test_image_rotate.rs │ ├── calibrator.rs │ ├── cedar_server.rs │ ├── detect_engine.rs │ ├── lib.rs │ ├── motion_estimator.rs │ ├── polar_analyzer.rs │ ├── position_reporter.rs │ ├── rate_estimator.rs │ └── solve_engine.rs └── todo.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # Added by cargo 17 | 18 | /target 19 | /Cargo.lock 20 | 21 | # Added by smr 22 | *~ 23 | 24 | **/cedar_log.txt 25 | **/cedar_ui_prefs.binpb 26 | 27 | bin/cedar-box-server 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome contributions to this project! 4 | 5 | ## License for Contributions 6 | 7 | By contributing to this project, you agree that your contributions will be 8 | licensed under the same [Functional Source License](LICENSE.md) that covers the 9 | project. 10 | 11 | ## How to Contribute 12 | 13 | 1. Fork the repository 14 | 2. Create a feature branch 15 | 3. Make your changes 16 | 4. Submit a pull request 17 | 18 | Please ensure your code follows the existing style and includes appropriate 19 | tests where applicable. 20 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | resolver = "2" 4 | members = [ 5 | "elements", 6 | "server", 7 | ] 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## Notice 2 | 3 | Copyright 2024 Steven Rosenthal smr@dt3.org 4 | 5 | All files in this directory (and subdirectories thereof) are subject 6 | to the license terms described in this file. 7 | 8 | The Software is available to be licensed under different terms; please 9 | contact the copyright holder (Steven Rosenthal smr@dt3.org) to discuss. 10 | 11 | 12 | # Functional Source License, Version 1.1, MIT Future License 13 | 14 | ## Abbreviation 15 | 16 | FSL-1.1-MIT 17 | 18 | ## Terms and Conditions 19 | 20 | ### Licensor ("We") 21 | 22 | The party offering the Software under these Terms and Conditions. 23 | 24 | ### The Software 25 | 26 | The "Software" is each version of the software that we make available under 27 | these Terms and Conditions, as indicated by our inclusion of these Terms and 28 | Conditions with the Software. 29 | 30 | ### License Grant 31 | 32 | Subject to your compliance with this License Grant and the Patents, 33 | Redistribution and Trademark clauses below, we hereby grant you the right to 34 | use, copy, modify, create derivative works, publicly perform, publicly display 35 | and redistribute the Software for any Permitted Purpose identified below. 36 | 37 | ### Permitted Purpose 38 | 39 | A Permitted Purpose is any purpose other than a Competing Use. A Competing Use 40 | means making the Software available to others in a commercial product or 41 | service that: 42 | 43 | 1. substitutes for the Software; 44 | 45 | 2. substitutes for any other product or service we offer using the Software 46 | that exists as of the date we make the Software available; or 47 | 48 | 3. offers the same or substantially similar functionality as the Software. 49 | 50 | Permitted Purposes specifically include using the Software: 51 | 52 | 1. for your internal use and access; 53 | 54 | 2. for non-commercial education; 55 | 56 | 3. for non-commercial research; and 57 | 58 | 4. in connection with professional services that you provide to a licensee 59 | using the Software in accordance with these Terms and Conditions. 60 | 61 | ### Patents 62 | 63 | To the extent your use for a Permitted Purpose would necessarily infringe our 64 | patents, the license grant above includes a license under our patents. If you 65 | make a claim against any party that the Software infringes or contributes to 66 | the infringement of any patent, then your patent license to the Software ends 67 | immediately. 68 | 69 | ### Redistribution 70 | 71 | The Terms and Conditions apply to all copies, modifications and derivatives of 72 | the Software. 73 | 74 | If you redistribute any copies, modifications or derivatives of the Software, 75 | you must include a copy of or a link to these Terms and Conditions and not 76 | remove any copyright notices provided in or with the Software. 77 | 78 | ### Disclaimer 79 | 80 | THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR 81 | IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR 82 | PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. 83 | 84 | IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE 85 | SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, 86 | EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. 87 | 88 | ### Trademarks 89 | 90 | Except for displaying the License Details and identifying us as the origin of 91 | the Software, you have no right under these Terms and Conditions to use our 92 | trademarks, trade names, service marks or product names. 93 | 94 | ## Grant of Future License 95 | 96 | We hereby irrevocably grant you an additional license to use the Software under 97 | the MIT license that is effective on the fifth anniversary of the date we make 98 | the Software available. On or after that date, you may use the Software under 99 | the MIT license, in which case the following will apply: 100 | 101 | Permission is hereby granted, free of charge, to any person obtaining a copy of 102 | this software and associated documentation files (the "Software"), to deal in 103 | the Software without restriction, including without limitation the rights to 104 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 105 | of the Software, and to permit persons to whom the Software is furnished to do 106 | so, subject to the following conditions: 107 | 108 | The above copyright notice and this permission notice shall be included in all 109 | copies or substantial portions of the Software. 110 | 111 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 112 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 113 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 114 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 115 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 116 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 117 | SOFTWARE. 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cedar-server 2 | 3 | Cedar-server integrates several Cedar™ components to implement a plate-solving 4 | electronic finder for showing where in the sky your telescope is pointed. 5 | 6 | Cedar-server: 7 | * Acquires images (cedar-camera) 8 | * Detects and centroids stars (cedar-detect) 9 | * Plate solves (cedar-solve) 10 | * Serves a gRPC endpoint used by clients such as Cedar Aim to present 11 | a user interface 12 | 13 | For more information about Cedar-server, see [about.md](about.md). For 14 | installation and running instructions, see [building.md](building.md). 15 | 16 | Please join the [Cedar Discord]() server 17 | for discussions around Cedar-server and other related topics. 18 | -------------------------------------------------------------------------------- /about.md: -------------------------------------------------------------------------------- 1 | # About Cedar-server 2 | 3 | Cedar™ is an electronic finder that uses a real-time plate solving pipeline to 4 | help you aim your telescope. 5 | 6 | Cedar-server's design provides rich functionality, high performance, reliable 7 | operation, and simple usage. To the greatest extent possible, all internal 8 | settings and calibrations are performed automatically so the user does not need 9 | to endlessly tweak multiple knobs in a hunt for good results. 10 | 11 | ## Processing pipeline 12 | 13 | Cedar carries out its activities in a pipelined fashion: 14 | 15 | * Stage 0: image sensor is integrating exposure N. 16 | 17 | * Stage 1: camera module is converting exposure N-1 from RAW to 8-bit monochrome. 18 | 19 | * Stage 2: Cedar-detect algorithm is procesing exposure N-2: 20 | * removing hot pixels 21 | * binning to a favorable resolution for detecting stars 22 | * detecting stars 23 | * finding centroids for detected stars 24 | 25 | * Stage 3: Cedar-solve algorithm is plate solving star centroids extracted 26 | from exposure N-3. 27 | 28 | * Stage 4: Upon client request, Cedar-server logic serves information from 29 | exposure N-4. 30 | 31 | Cedar executes all stages of the pipeline concurrently. This is a good fit 32 | to the Raspberry Pi, which uses a quad core processor so the pipeline stages run 33 | in parallel. 34 | 35 | 36 | ## Operating modes 37 | 38 | Cedar has two primary modes of operation: Setup and Aim. 39 | 40 | ### Setup 41 | 42 | Setup mode is what the user first sees in the Cedar-aim app. Setup mode 43 | provides: 44 | 45 | * Visual support for focusing the camera. This is presented in the Cedar-aim 46 | user interface as a magnified view of the brightest star in the central region 47 | of the field of view (FOV), acquired and displayed at a high refresh rate. 48 | 49 | * "Any star" boresight alignment. The user centers the central brightest star in 50 | the telescope view and then taps a button to capture the x/y position of the 51 | telescope boresight. 52 | 53 | * Daylight boresight alignment (coming soon). In daylight, the user aims the 54 | telescope at a landmark such as a distant light pole or corner of a building, 55 | then taps on the corresponding item on a magnified view of the central region 56 | of the FOV. 57 | 58 | ### Aim 59 | 60 | Aim mode (referred to as Operate mode in the Cedar-server code) is the main 61 | operating mode, where: 62 | 63 | * Plate solves are done continuously, updating the RA/Dec information on the 64 | Cedar-aim UI and optionally SkySafari. 65 | 66 | * Move to target: see below. 67 | 68 | * Polar alignment (coming soon). Cedar provides assistance for the "declination 69 | drift" method of polar alignment, using the high precision of plate solutions 70 | and integration over time to provide polar axis pointing corrections. 71 | 72 | ## Calibrations 73 | 74 | When the user transitions from Setup mode to Aim mode, Cedar takes the 75 | opportunity to perform a series of calibrations, on the presumption that the 76 | camera is pointed at a star field and is well focused. The calibrations are as 77 | follows, taking several seconds in total: 78 | 79 | * Camera offset (black level): To avoid black crush. 80 | 81 | * Exposure duration: Cedar-detect is used to see how many stars are detected 82 | with a starting exposure duration. The exposure duration is adjusted upward or 83 | downward to obtain a desired number of stars (typically 20). 84 | 85 | * Lens characteristics: Cedar does a trial plate solve, passing null constraints 86 | for field of view and lens distortion, with a generous solve timeout. Given 87 | the calibrated exposure duration, there should be a proper number of 88 | centroids, leading to a good initial solution, even if slow because of the 89 | lack of FOV constraint. The actual FOV and lens distortion parameters are 90 | obtained and used for subsequent Aim mode plate solves, yielding faster 91 | solves. 92 | 93 | * Solver parameters: The trial plate solve in the previous calibration step also 94 | yields a sense of how long solves take on this system, allowing a suitable 95 | solve timeout to be determined for Aim mode solves. In addition, the residual 96 | centroid position error (even after using the calibrated lens distortion) is 97 | captured to allow determination of the plate solver's 'match_max_error' 98 | parameter (coming soon). 99 | 100 | ## Moving to target 101 | 102 | Cedar-server can provide push-to guidance for a given sky target. The user 103 | initiates this by using SkySafari or entering RA/Dec manually in Cedar-aim UI 104 | (coming soon). 105 | 106 | Cedar decomposes the needed telescope motion in terms of the telescope's mount 107 | axes, either north-south and east-west (equatorial mount) or up-down and 108 | clockwise-counterclockwise (alt-az mount). 109 | 110 | After centering the telescope on the target, before exiting move-to mode, the 111 | user can direct Cedar-server to refine its boresight alignment. 112 | 113 | ## Camera handling 114 | 115 | Cedar-server currently supports ASI cameras over USB (tested with ASI120mm 116 | mini), and Raspberry Pi cameras via the CSI ribbon cable (tested with Rpi High 117 | Quality camera). 118 | 119 | ### Resolutions 120 | 121 | Cedar-server has been tested with cameras ranging in resolution from 1.2 122 | megapixel to 12.3 megapixel. Cedar-server logic automatically uses varying 123 | amounts of software binning to obtain a reduced resolution suitable for 124 | Cedar-detect's star detection algorithms; note that centroiding is always done 125 | on the full resolution original image for best accuracy. 126 | 127 | ### Gain 128 | 129 | Cedar-server operates each kind of camera at its optimal gain setting. The 130 | optimal gain is taken to be the lowest gain that yields RMS noise of around 0.5 131 | ADU on a dark image taken at a typical plate solving exposure time. The optimal 132 | gain values are determined by offline trials and are baked into the Cedar-camera 133 | library. 134 | 135 | ### Color camera 136 | 137 | When a color camera (such as the Rpi HQ camera) is used, the Cedar-camera 138 | library does not debayer to color or monochrome, but instead returns the full 139 | resolution RAW bayer mosaic image as 8 bits intensity value (linear) per photosite. 140 | 141 | Retaining the image in RAW form allows Cedar-detect to accurately detect and 142 | remove hot pixels (see below). The software binning used prior to star detection 143 | acts as a simplistic but effective conversion to monochrome. 144 | 145 | ### Readout format 146 | 147 | All logic in Cedar-detect and Cedar-server use 8-bit linear pixel intensity 148 | encoding. The Cedar-camera library either configures the camera for 8-bit RAW 149 | readout, or if only 10- or 12-bit RAW readout is supported, the Cedar-camera 150 | library converts to 8-bit RAW. 151 | 152 | ### Hot pixel detection 153 | 154 | As mentioned earlier, the camera readout is full-resolution RAW with no 155 | debayering (if color). Any noise reduction modes provided by the camera driver 156 | are disabled. 157 | 158 | Cedar-detect examines each pixel and its left/right neighbors to detect 159 | hot pixels. A pixel is hot if: 160 | 161 | * It is brighter than the local background by an amount that is significant 162 | w.r.t. the noise, and 163 | 164 | * The pixel's left+right pixels have low background-corrected intensity 165 | compared to the center pixel. 166 | 167 | The rationale is that hot pixels are isolated, whereas genuine star images are 168 | spread out over many pixels, so the neighbors of a star's central pixel will 169 | also be relatively bright. See Cedar-detect (`gate_star_1d()` function in 170 | algorithm.rs) for details. 171 | 172 | When a hot pixel is detected, it is replaced by the mean of its left/right 173 | neighbors. For efficiency, Cedar-detect combines hot pixel processing with the 174 | binning used prior to star detection. 175 | 176 | ## Auto exposure 177 | 178 | The optimum exposure time depends on many factors: 179 | 180 | * Camera model 181 | 182 | * Lens attached (focal length, f/ratio) 183 | 184 | * Sky conditions 185 | 186 | * Cedar operating mode 187 | 188 | Instead of requiring the user to set the camera exposure time, Cedar-server 189 | automatically adjusts the exposure time in a mode-specific fashion. 190 | 191 | ### Daylight alignment mode 192 | 193 | In daylight alignment mode (coming soon), the camera is pointed at a terrestrial 194 | scene before dark. Here Cedar-server adjusts the exposure time to achieve good 195 | brightness of the central region of the field of view. 196 | 197 | ### Setup mode (focusing) 198 | 199 | To achieve a high frame rate during focusing, Cedar-server underexposes the 200 | brightest star of the central region of the field of view. A crop of the 201 | brightest star is then stretched for display. 202 | 203 | ### Aim mode (plate solving) 204 | 205 | When plate solving, Cedar-server adjust the exposure time to achieve a desired 206 | number of detected stars (see below). 207 | 208 | ## Speed vs accuracy slider 209 | 210 | Reliable plate solving requires a good number of correctly detected stars with 211 | reasonably accurate relative brightness ranking. Cedar-solve can succeed with as 212 | few as 6 detected stars, but is much more reliable at above 10 stars. In 213 | practice using 20 detected stars yields solid solve results; using more than 20 214 | stars provides little added benefit but incurs longer exposure times. 215 | 216 | The number of detected stars is influenced by: 217 | 218 | * Exposure time. A longer exposure produces higher signal-to-noise and thus allows 219 | additional fainter stars to be detected. 220 | 221 | * Noise-relative detection threshold. A "sigma multiple" parameter governs the 222 | sensitivity of Cedar-detect. A high sigma value yields fewer star detections 223 | but very few false positives; a lower sigma value allows fainter stars to be 224 | detected but also causes some noise fluctuations to be mistaken for stars. 225 | 226 | So we have potentially three parameters to present to the user: 227 | 228 | 1. Desired number of detected stars for plate solving. 229 | 230 | 2. Exposure time. 231 | 232 | 3. Detection "sigma multiple" parameter. 233 | 234 | These are interrelated, as items 2 and 3 together influence the number of 235 | star detections, which relates to item 1. 236 | 237 | Instead of having these knobs, Cedar-server instead provides a simple speed vs. 238 | accuracy knob with three settings: 239 | 240 | * Balanced: Baseline values (see below) for desired number of stars and detection sigma 241 | are used. 242 | 243 | * Faster: Baseline values for star count and detection sigma are multiplied by 0.7. 244 | 245 | * Accurate: Baseline values for star count and detection sigma are multiplied by 1.4. 246 | 247 | In each case, auto-exposure logic determines the exposure time to acheive the 248 | desired number of stars. 249 | 250 | In the "faster" case, we are seeking fewer stars so Cedar-server will use 251 | shorter exposures. Furthermore, the lowered sigma value allows the exposure time 252 | to be lowered yet more because it is "easier" to detect stars (plus false 253 | positives). 254 | 255 | In the "accurate" case, we are seeking more stars at a higher sigma threshold, 256 | so Cedar-server will use longer exposures. By detecting more stars with fewer 257 | false positives, plate solutions are more robust and the resulting astrometric 258 | accuracy will be increased due to the larger number of matches. 259 | 260 | The baseline value for desired number of stars is 20; the baseline value for 261 | detection sigma multiple is 8. These can be overridden on the Cedar-server 262 | command line. 263 | 264 | ## Motion analysis 265 | 266 | Cedar-server includes logic that tracks the plate solutions over time, allowing 267 | additional functionality to be synthesized. A basic concept is "dwell 268 | detection", where successive plate solutions yielding (nearly) unchanging 269 | results allow Cedar-server to infer that the telescope is not moving. 270 | 271 | ### Mount type determination 272 | 273 | During a dwell, if the declination value is unchanging and the right ascension 274 | is changing at the sidereal rate, Cedar-server can infer that the telescope 275 | mount is non-motorized. 276 | 277 | If the RA and Dec are both unchanging, Cedar-server can infer that the telescope 278 | mount is tracking, e.g. equatorial with clock drive. 279 | 280 | ### Adaptive frame rate 281 | 282 | With a sensitive camera such as the ASI120mm mini and a fast lens, Cedar-server 283 | can run at frame rates in the range of 10-30Hz. This causes high CPU usage 284 | because the processing pipeline keeps the multiple Rpi cores busy. 285 | 286 | (coming soon) To improve battery life, Cedar-server reduces the frame rate when 287 | a dwell persists for more than a few seconds. Once motion is again detected, 288 | Cedar-server returns to its high frame rate. 289 | 290 | ### Polar alignment 291 | 292 | When dwelling with a clock-driven equatorial mount that is accurately polar 293 | aligned, the RA/Dec values will be perfectly stationary. However, if the polar 294 | alignment is off, a "drift" in the declination value will be observed; generally 295 | the RA drift is small unless the polar axis is grossly misalgined. 296 | 297 | Cedar-server measures the declination drift rate during dwells and uses this 298 | information, along with knowledge of where the telescope is pointed relative to 299 | the celestial equator and meridian, to quantitatively determine how the polar 300 | alignment should be corrected. 301 | 302 | See [Canburytech.net](https://canburytech.net/DriftAlign/index.html) for a detailed 303 | explanation of how declination drift is used to correct polar alignment. 304 | 305 | ## SkySafari integration 306 | 307 | Cedar-server implements the [Ascom Alpaca](https://ascom-standards.org/About/Index.htm) 308 | protocol to present itself as a "telescope" that reports its RA/Dec and responds 309 | to slew requests. This allows the user to connect SkySafari to Cedar-server, 310 | after which SkySafari shows the telescope's position and allows the user to 311 | initiate moving to a target. 312 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check for the --release flag 4 | if [[ "$1" == "--release" ]]; then 5 | release_flag="--release" 6 | fi 7 | 8 | # Build with Cargo 9 | cargo build $release_flag 10 | 11 | # Determine the path to the built program (assumes standard Cargo structure) 12 | if [[ -z "$release_flag" ]]; then 13 | binary_path="target/debug/cedar-box-server" 14 | else 15 | binary_path="target/release/cedar-box-server" 16 | fi 17 | 18 | # Copy binary out so it survives 'cargo clean'. 19 | mkdir -p cedar/bin 20 | cp "$binary_path" cedar/bin 21 | 22 | # Set capabilities. 23 | caps="cap_sys_time,cap_dac_override,cap_chown,cap_fowner,cap_net_bind_service+ep" 24 | sudo setcap "$caps" "$binary_path" 25 | sudo setcap "$caps" cedar/bin/cedar-box-server 26 | -------------------------------------------------------------------------------- /building.md: -------------------------------------------------------------------------------- 1 | # Building and running Cedar 2 | 3 | Cedar is a client-server system. Cedar-server runs on your Raspberry Pi and 4 | hosts the camera, image processing algorithms, and the plate solving logic. 5 | 6 | The client is the Cedar-aim web app that runs on your mobile phone and runs 7 | Cedar's user interface. 8 | 9 | ## Supported platforms 10 | 11 | These instructions are for building and running Cedar-server on a Raspberry Pi 4 12 | or 5 (or 3, but this model is a bit slow for Cedar) running Bookworm. For 13 | building, at least 4GB RAM is recommended; for running, at least 1GB RAM is 14 | recommended. 15 | 16 | The Cedar-aim web app works with both Android and IOS devices (phones/tablets) 17 | and also laptops (Windows/Mac/Linux). Basically, anything with a modern web 18 | browser can run Cedar-aim. 19 | 20 | # Using the pre-built SD card image 21 | 22 | You can burn a pre-built image to your SD card (32GB or larger) which will boot 23 | your Rpi directly into running Cedar. This is by far the easiest way to get 24 | started! 25 | 26 | ## Download and burn 27 | 28 | First: download the SD card image 29 | [cedar_rpi_2025_may_26.img.gz](https://storage.googleapis.com/cs-astro-files/cedar_rpi_2025_may_26.img.gz) 30 | to your computer. 31 | 32 | Second: burn an SD card (32GB or larger) with the image file you just downloaded 33 | using the [Raspberry Pi Imager](https://www.raspberrypi.com/software). Follow 34 | these steps: 35 | 36 | 1. Choose Device: ignore this. 37 | 38 | 2. Under Choose OS, scroll to the bottom and pick Use Custom. Select the .img 39 | file you downloaded above. 40 | 41 | 3. Click Choose Storage and select your SD card. 42 | 43 | 4. IMPORTANT! Choose 'NO' on the 'Would you like to apply OS customization 44 | settings?' The SD card image already has the appropriate customizations and 45 | applying customizations here could break something. 46 | 47 | 5. Raspberry Pi Imager will burn and verify your SD card. 48 | 49 | As an alternative to the above, you can use 50 | [balenaEtcher](https://etcher.balena.io/) which bypasses the questions/answers 51 | and just burns the SD card image. 52 | 53 | ## Using the pre-built SD card image 54 | 55 | With the pre-built SD card you just burned, your Rpi is set up as follows: 56 | 57 | * SSH is enabled, in case you want to poke around. Username is 'cedar', password 58 | is 'cedar'. 59 | * The Rpi puts up its own Wi-Fi hot spot. The SSID is 'cedar-xxx', password is 60 | 'cedar123'. 61 | 62 | Insert the SD card and power up your Rpi4. Wait a minute or two, then on your phone, 63 | tablet, or laptop, join the 'cedar' Wi-Fi network (password is 'cedar123'). 64 | 65 | Now, in your device's web browser, navigate to '192.168.4.1'. You should 66 | see Cedar's "setup" mode screen where the camera image is shown (assuming you 67 | have a camera connected!) for focusing and aligning. 68 | 69 | See below for how to set up SkySafari to work with Cedar. 70 | 71 | # Building from source 72 | 73 | If you're more adventurous, you can start with a fresh Rpi OS install and build 74 | Cedar yourself. 75 | 76 | ## Initial steps 77 | 78 | These instructions assume you've set up a Raspberry Pi 4 (or 5) with the Bookworm 79 | version of Raspberry Pi OS. Make sure you've done the following: 80 | 81 | ``` 82 | sudo apt update; sudo apt full-upgrade 83 | sudo apt install git pip protobuf-compiler libjpeg-dev zlib1g-dev libcamera-dev libclang-dev 84 | sudo apt install python3-grpcio python3-grpc-tools 85 | sudo apt install i2c-tools 86 | ``` 87 | 88 | Before going further, if your Rpi has only 1GB of RAM, you'll need to expand its 89 | swap space. Edit `/etc/dphys-swapfile` and change `CONF_SWAPSIZE=200` to 90 | `CONF_SWAPSIZE=2048`. After saving the file, restart your Rpi. 91 | 92 | ### Clone repos 93 | 94 | To build and run Cedar, you will need to clone all of the following repos, all 95 | available at [github/smroid](https://github.com/smroid): 96 | 97 | * asi_camera2: Rust wrapper for the ASI camera SDK. 98 | * cedar-aim: Dart/Flutter web app. This is Cedar's user interface. 99 | * cedar-camera: Cedar's abstraction for interfacing to cameras. 100 | * cedar-detect: Cedar's image processing algorithms: background estimation, 101 | noise estimation, hot pixel repair, software binning, star detection, star 102 | centroiding. 103 | * cedar-server: The server-side integration of Cedar's functionality. 104 | * cedar-solve: Our fork of Tetra3 with significant performance and reliability 105 | improvements. 106 | * tetra3_server: A gRPC encapsulation allowing Rust code to invoke Cedar-solve. 107 | 108 | You must clone these repos into sibling directories, for example 109 | `/home/cedar/projects/cedar-camera`, `/home/cedar/projects/cedar-detect`, 110 | `/home/cedar/projects/cedar-server`, etc. 111 | 112 | If `/home/cedar/projects` is your current directory, you can execute the commands: 113 | 114 | ``` 115 | git clone https://github.com/smroid/asi_camera2.git 116 | git clone https://github.com/smroid/cedar-aim.git 117 | git clone https://github.com/smroid/cedar-camera.git 118 | git clone https://github.com/smroid/cedar-detect.git 119 | git clone https://github.com/smroid/cedar-server.git 120 | git clone https://github.com/smroid/cedar-solve.git 121 | git clone https://github.com/smroid/tetra3_server.git 122 | ``` 123 | 124 | ### Build Cedar-aim 125 | 126 | Cedar-aim is implemented in Flutter and requires some initial setup 127 | to get the Flutter SDK: 128 | 129 | ``` 130 | sudo apt update 131 | sudo apt install snapd 132 | ``` 133 | 134 | At this point you need to reboot: `sudo reboot now` After rebooting, run: 135 | 136 | ``` 137 | sudo snap install snapd 138 | sudo snap install flutter --classic 139 | ``` 140 | 141 | Run `flutter doctor` to finalize Flutter installation and verify Flutter SDK is 142 | present. Flutter doctor will complain about a missing Android toolchain and maybe 143 | about Chrome; these aren't needed. 144 | 145 | Now that you have the Flutter SDK, it's time to build the Cedar-aim web app. 146 | First: 147 | 148 | ``` 149 | dart pub global activate protoc_plugin 150 | ``` 151 | 152 | Add `/home/cedar/.pub-cache/bin` to your `PATH` environment variable. 153 | 154 | ``` 155 | ./build.sh 156 | ``` 157 | 158 | ### Setup Cedar-solve 159 | 160 | Cedar-solve is implemented in Python and requires some initial setup. 161 | 162 | In the root directory of cedar-solve (e.g. `/home/cedar/projects/cedar-solve`), run 163 | the `setup.sh` script. 164 | 165 | ### Set up tetra3_server component 166 | 167 | In the root directory of tetra3_server (e.g. `/home/cedar/projects/tetra3_server`), do 168 | the following: 169 | 170 | ``` 171 | cd python 172 | python -m grpc_tools.protoc -I../proto --python_out=. --pyi_out=. --grpc_python_out=. ../proto/tetra3.proto 173 | ``` 174 | 175 | ### Enable ASI camera 176 | 177 | If you are using an ASI camera, go to the asi_camera2 project directory and run 178 | the `install.sh` script. You can skip this if you are using a Raspberry Pi 179 | camera. 180 | 181 | ### Build Cedar-server 182 | 183 | You will need to install the Rust toolchain if you don't have it already. Follow 184 | the instructions at the [Install Rust](https://www.rust-lang.org/tools/install) 185 | site. 186 | 187 | Now build Cedar-server: 188 | 189 | ``` 190 | cd cedar-server 191 | ./build.sh --release 192 | ``` 193 | 194 | This builds Cedar-server and all of its dependencies. Rust crates are downloaded 195 | and built as needed. The initial build takes around a half hour on a Rpi 4 and 196 | well over an hour on a Rpi 3. The Rpi 5 is much faster for this! 197 | 198 | ### Run Cedar-server 199 | 200 | You can start the Cedar-server at the command line as follows: 201 | 202 | ``` 203 | cd cedar-server/run 204 | source ../../cedar-solve/.cedar_venv/bin/activate 205 | ../cedar/bin/cedar-box-server 206 | ``` 207 | 208 | If things are working correctly, the output will be similar to: 209 | 210 | ``` 211 | INFO cedar_server: Using Tetra3 server "../../tetra3_server/python/tetra3_server.py" listening at "/tmp/cedar.sock" 212 | INFO Camera camera_manager.cpp:325 libcamera v0.3.2+99-1230f78d 213 | INFO RPI vc4.cpp:446 Registered camera /base/soc/i2c0mux/i2c@1/imx477@1a to Unicam device /dev/media4 and ISP device /dev/media1 214 | INFO Camera camera_manager.cpp:325 libcamera v0.3.2+99-1230f78d 215 | INFO RPI vc4.cpp:446 Registered camera /base/soc/i2c0mux/i2c@1/imx477@1a to Unicam device /dev/media4 and ISP device /dev/media1 216 | INFO cedar_server: Using camera imx477 4056x3040 217 | INFO cedar_server: Cedar-Box 218 | INFO cedar_server: Copyright (c) 2024 Steven Rosenthal smr@dt3.org. 219 | Licensed for non-commercial use. 220 | See LICENSE.md at https://github.com/smroid/cedar-server 221 | INFO cedar_server: Cedar server version "0.8.1" running on Raspberry Pi 4 Model B Rev 1.0/Debian GNU/Linux 12 (bookworm) 222 | INFO cedar_server::tetra3_subprocess: Tetra3 subprocess started 223 | WARN cedar_server::tetra3_subprocess: Loading database from: /home/cedar/projects/cedar-solve/tetra3/data/default_database.npz 224 | WARN cedar_server: Could not read file "./cedar_ui_prefs.binpb": Os { code: 2, kind: NotFound, message: "No such file or directory" } 225 | INFO cedar_server: Listening at 0.0.0.0:80 226 | INFO ascom_alpaca::server: Bound Alpaca server bound_addr=[::]:11111 227 | ``` 228 | 229 | Here's what's happening: 230 | 231 | * Cedar-server is using `/tmp/cedar.sock` to communicate with the Tetra3 server. 232 | 233 | * The imx477 camera is detected. This is the Rpi High Quality camera. 234 | 235 | * The `tetra3_subprocess` stars up and loads the pattern database `default_database.npz`. 236 | 237 | * Cedar's preferences file was not found. This file will be created when the Cedar-aim 238 | app first saves its settings. 239 | 240 | * Cedar-server is listening at port 80 for connections from the Cedar-aim client app. 241 | 242 | * Cedar-server is serving the Ascom Alpaca protocol, allowing SkySafari to connect 243 | to the "telescope" emulated by Cedar-server. 244 | 245 | ### Run Cedar-aim 246 | 247 | On a phone, tablet, or computer that is on the same network as the Raspberry Pi 248 | that is running Cedar-server, use a web browser to navigate to the IP address of 249 | your Rpi. This will be like `192.168.4.1`, depending on how your Rpi is set up 250 | on the network. 251 | 252 | If you're successful, you'll see the Cedar-aim setup screen. TODO: add screenshot. 253 | 254 | ### Setup SkySafari 255 | 256 | If you have SkySafari 7 Plus or Pro, you can connect it to Cedar. To do so, 257 | follow these steps: 258 | 259 | 1. Make sure your phone or tablet is on Cedar's wifi network. 260 | 261 | 2. With Cedar-server running, start SkySafari. 262 | 263 | 3. Menu..Settings 264 | 265 | 4. Telescope Presets 266 | 267 | 5. Add Preset 268 | 269 | 6. ASCOM Alpaca Connection 270 | 271 | 7. Choose Auto-Detect, and press Scan Network For Devices button. After a delay 272 | it should show CedarTelescopeEmulator in the DEVICES section. If this fails, 273 | try Manual Configuration with your Raspberry Pi's IP address and press the 274 | Check IP and Port For Devices button. If successful, it will show 275 | CedarTelescopeEmulator in DEVICES. 276 | 277 | 8. Next 278 | 279 | 9. Edit the Preset Name if desired. 280 | 281 | 10. Change ReadoutRate to 10 per second 282 | 283 | 11. Save Preset 284 | 285 | 12. On the main SkySafari screen, tap the Scope icon. Press Connect. 286 | 287 | 13. On the main screen, tap the `Observe` icon, choose `Scope Display`, and 288 | either enable `Telrad circles` or configure field of view indicators 289 | appropriate for your telescope setup. 290 | 291 | Once you've succeeded in connecting SkySafari to Cedar (yay!), the SkySafari 292 | screen will display a reticle showing your telescope's current position as 293 | determined by Cedar's plate solving. If there is no plate solution, the 294 | telescope position will "wiggle" as an indication that it is currently unknown. 295 | 296 | ## Next steps 297 | 298 | Congratulations (hopefully)! You have successfully run Cedar-server, connected 299 | to it with Cedar-aim, and (optionally) configured SkySafari to work with 300 | Cedar-server. 301 | 302 | There are some follow-up steps you'll need to address to be able to use Cedar 303 | with your telescope in the field. 304 | 305 | ### Mount camera to telescope 306 | 307 | The camera used by Cedar needs to be attached to your telescope, pointed 308 | in the same direction as the telescope. There are two approaches, depending 309 | on what kind of camera you have. 310 | 311 | #### USB camera 312 | 313 | If you are using a USB camera such as the ASI120mm mini, you can use a ring 314 | mount to attach the camera to your scope. The Raspberry Pi running Cedar be 315 | anywhere, with the USB cable running up to the camera, or you can also attach 316 | the Raspberry Pi to the telescope if you prefer. 317 | 318 | CAUTION! Be sure to use a USB2 port (black) on your Raspberry Pi, not a USB3 319 | port (blue). USB3 is known to cause WiFi interference. 320 | 321 | #### Raspberry Pi camera 322 | 323 | A Raspberry Pi camera such as the HQ camera connects to the Rpi with a short and 324 | delicate ribbon cable. You will thus need some kind of box to hold both the Rpi 325 | and the camera, such that when the box is attached to the telescope the camera 326 | will be pointed in the same direction as the scope. 327 | 328 | This is an excellent job for a 3d printer. We've posted the ["Cedar 329 | Box"](https://www.thingiverse.com/thing:6995142) case design that accommodates a 330 | Raspberry Pi 4 or 5 along with the HQ camera or IMX296 camera module. The Cedar 331 | Box attaches to the Synta/Vixen dovetail finder scope mount that many telescopes 332 | have. 333 | 334 | ### Setup Raspberry Pi Wi-Fi hotspot 335 | 336 | CAUTION! If to this point you've been connected to your Rpi over Wi-Fi, please 337 | switch to an Ethernet connection. The steps in this section will disrupt your 338 | Rpi's connection to Wi-Fi, because this section configures your Rpi to put up 339 | its own Wi-Fi hotspot. 340 | 341 | The Cedar-aim client must connect over the network to Cedar-server running on 342 | the Rpi. If you're observing from your home's rear deck you might be able to use 343 | your home Wi-Fi, but if you're at a deep sky site your Rpi will need to provide 344 | its own Wi-Fi hotspot. 345 | 346 | On Bookworm, https://forums.raspberrypi.com/viewtopic.php?t=357998 has good 347 | information on how to set up a Wi-Fi access point using NetworkManager. Here 348 | are the steps that worked for me: 349 | 350 | ``` 351 | sudo systemctl disable dnsmasq 352 | sudo systemctl stop dnsmasq 353 | sudo nmcli con delete cedar-ap 354 | sudo nmcli con add type wifi ifname wlan0 mode ap con-name cedar-ap ssid cedar autoconnect true 355 | sudo nmcli con modify cedar-ap 802-11-wireless.band bg 356 | sudo nmcli con modify cedar-ap 802-11-wireless.channel 9 357 | sudo nmcli con modify cedar-ap ipv4.method shared ipv4.address 192.168.4.1/24 358 | sudo nmcli con modify cedar-ap ipv6.method disabled 359 | sudo nmcli con modify cedar-ap wifi-sec.key-mgmt wpa-psk 360 | sudo nmcli con modify cedar-ap wifi-sec.psk "cedar123" 361 | sudo nmcli con modify cedar-ap 802-11-wireless.powersave disable 362 | sudo nmcli con up cedar-ap 363 | ``` 364 | 365 | If you want to change the ssid name or channel number, you can edit 366 | `/etc/NetworkManager/system-connections/cedar-ap.nmconnection`. After 367 | changing the `channel` and/or `ssid`, restart the network manger service: 368 | 369 | ``` 370 | sudo systemctl restart NetworkManager 371 | ``` 372 | 373 | ### Set up service 374 | 375 | If you want Cedar-server to start automatically when you power up your 376 | Rpi, you can set up a systemd configuration to do this. 377 | 378 | Create a file `/lib/systemd/system/cedar.service` with: 379 | 380 | ``` 381 | [Unit] 382 | Description=Cedar Server 383 | Wants=NetworkManager.service network-online.target 384 | 385 | [Service] 386 | User=cedar 387 | WorkingDirectory=/home/cedar/projects/cedar-server/run 388 | Type=simple 389 | ExecStart=/bin/bash -c '. /home/cedar/projects/cedar-solve/.cedar_venv/bin/activate && /home/cedar/projects/cedar-server/cedar/bin/cedar-box-server' 390 | 391 | [Install] 392 | WantedBy=multi-user.target 393 | ``` 394 | 395 | Use `sudo systemctl [start/stop/status/enable/disable] cedar.service` to 396 | control the service. 397 | -------------------------------------------------------------------------------- /cedar/bin/cedar-box-server: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smroid/cedar-server/966b61671c0bd3d68b3292afd0b8ba289a30ffa0/cedar/bin/cedar-box-server -------------------------------------------------------------------------------- /cedar/cedar-aim: -------------------------------------------------------------------------------- 1 | ../../cedar-aim -------------------------------------------------------------------------------- /cedar/data/default_database.npz: -------------------------------------------------------------------------------- 1 | ../../../cedar-solve/tetra3/data/default_database.npz -------------------------------------------------------------------------------- /cedar/tetra3_server: -------------------------------------------------------------------------------- 1 | ../../tetra3_server/ -------------------------------------------------------------------------------- /create_cedar_image/README.txt: -------------------------------------------------------------------------------- 1 | Scripts to create a Raspberry Pi sdcard image for Cedar. 2 | 3 | Ingredients: 4 | 5 | Input: 6 | 7 | e.g. /mnt/nas/cs-astro/rpi_os_images/2024-11-19-raspios-bookworm-arm64-lite.img 8 | 9 | customize-pi-image.sh: Creates customized_rpi_for_cedar.img from the Rpi OS .img 10 | file, applying various customizations and modifications in preparation for 11 | installing Cedar. 12 | 13 | install_cedar.sh: Copies customized RPI image and installs Cedar to create 14 | cedar.img. The various Cedar repos must have been setup and built already. 15 | -------------------------------------------------------------------------------- /create_cedar_image/add_rpi_user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import subprocess 4 | import crypt 5 | import shutil 6 | from pathlib import Path 7 | 8 | # Generated by Anthropic Claude. 9 | 10 | def generate_password_hash(password): 11 | """Generate a SHA-512 password hash.""" 12 | # Using SHA-512 method 13 | return crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512)) 14 | 15 | def add_user_to_passwd(rootfs_path, username, uid, gid): 16 | """Add user entry to /etc/passwd file.""" 17 | passwd_path = os.path.join(rootfs_path, 'etc/passwd') 18 | entry = f"{username}:x:{uid}:{gid}:,,,:/home/{username}:/bin/bash\n" 19 | 20 | with open(passwd_path, 'a') as f: 21 | f.write(entry) 22 | 23 | def add_user_to_shadow(rootfs_path, username, password_hash): 24 | """Add user entry to /etc/shadow file.""" 25 | shadow_path = os.path.join(rootfs_path, 'etc/shadow') 26 | entry = f"{username}:{password_hash}:19755:0:99999:7:::\n" 27 | 28 | with open(shadow_path, 'a') as f: 29 | f.write(entry) 30 | 31 | def add_user_group(rootfs_path, username, gid): 32 | """Add primary user group entry to /etc/group.""" 33 | group_path = os.path.join(rootfs_path, 'etc/group') 34 | entry = f"{username}:x:{gid}:\n" 35 | 36 | with open(group_path, 'a') as f: 37 | f.write(entry) 38 | 39 | def add_user_to_supplementary_groups(rootfs_path, username): 40 | """Add user to standard Raspberry Pi supplementary groups.""" 41 | groups = [ 42 | 'adm', 'dialout', 'cdrom', 'sudo', 'audio', 'video', 43 | 'plugdev', 'games', 'users', 'input', 'render', 44 | 'netdev', 'gpio', 'i2c', 'spi' 45 | ] 46 | 47 | group_file = os.path.join(rootfs_path, 'etc/group') 48 | 49 | with open(group_file, 'r') as f: 50 | lines = f.readlines() 51 | 52 | modified_lines = [] 53 | for line in lines: 54 | parts = line.strip().split(':') 55 | if parts[0] in groups: 56 | if len(parts) >= 4 and parts[3]: 57 | # Group already has members 58 | members = parts[3].split(',') 59 | if username not in members: 60 | members.append(username) 61 | parts[3] = ','.join(members) 62 | else: 63 | # Group has no members yet 64 | parts[3] = username 65 | line = ':'.join(parts) + '\n' 66 | modified_lines.append(line) 67 | 68 | with open(group_file, 'w') as f: 69 | f.writelines(modified_lines) 70 | 71 | def create_home_directory(rootfs_path, username, uid, gid): 72 | """Create and setup home directory for the new user.""" 73 | home_path = os.path.join(rootfs_path, f'home/{username}') 74 | skel_path = os.path.join(rootfs_path, 'etc/skel') 75 | 76 | # Create home directory 77 | os.makedirs(home_path, exist_ok=True) 78 | 79 | # Copy skel files 80 | for item in os.listdir(skel_path): 81 | s = os.path.join(skel_path, item) 82 | d = os.path.join(home_path, item) 83 | if os.path.isdir(s): 84 | shutil.copytree(s, d, dirs_exist_ok=True) 85 | else: 86 | shutil.copy2(s, d) 87 | 88 | # Set ownership 89 | for root, dirs, files in os.walk(home_path): 90 | for d in dirs: 91 | os.chown(os.path.join(root, d), uid, gid) 92 | for f in files: 93 | os.chown(os.path.join(root, f), uid, gid) 94 | 95 | os.chown(home_path, uid, gid) 96 | os.chmod(home_path, 0o755) 97 | 98 | def configure_sudo_nopasswd(rootfs_path, username): 99 | """Configure sudo to not require password for the user.""" 100 | sudoers_dir = os.path.join(rootfs_path, 'etc/sudoers.d') 101 | sudo_file = os.path.join(sudoers_dir, f'010_{username}-nopasswd') 102 | 103 | # Ensure the directory exists 104 | os.makedirs(sudoers_dir, exist_ok=True) 105 | 106 | # Create the sudoers file 107 | with open(sudo_file, 'w') as f: 108 | f.write(f'{username} ALL=(ALL) NOPASSWD: ALL\n') 109 | 110 | # Set correct permissions (440) 111 | os.chmod(sudo_file, 0o440) 112 | print(f"Configured NOPASSWD sudo access for {username}") 113 | 114 | def add_user(rootfs_path, username, password, uid, gid): 115 | """Add a new user to the mounted Raspberry Pi image.""" 116 | if not os.path.isdir(rootfs_path): 117 | raise ValueError(f"Root filesystem path {rootfs_path} does not exist") 118 | 119 | print(f"Creating user {username}") 120 | 121 | # Generate password hash 122 | password_hash = generate_password_hash(password) 123 | 124 | # Add user entries to system files 125 | add_user_to_passwd(rootfs_path, username, uid, gid) 126 | print("Added user to passwd file") 127 | 128 | add_user_to_shadow(rootfs_path, username, password_hash) 129 | print("Added user to shadow file") 130 | 131 | add_user_group(rootfs_path, username, gid) 132 | print("Created primary group") 133 | 134 | add_user_to_supplementary_groups(rootfs_path, username) 135 | print("Added user to supplementary groups") 136 | 137 | # Setup home directory 138 | create_home_directory(rootfs_path, username, uid, gid) 139 | print("Created home directory") 140 | 141 | # Configure sudo without password 142 | configure_sudo_nopasswd(rootfs_path, username) 143 | print("Configured sudo access") 144 | 145 | def main(): 146 | ROOTFS_PATH = "/mnt/part2" 147 | USERNAME = "cedar" 148 | PASSWORD = "cedar" 149 | UID = 1001 150 | GID = 1001 151 | 152 | try: 153 | add_user(ROOTFS_PATH, USERNAME, PASSWORD, UID, GID) 154 | print(f"Successfully added user {USERNAME}") 155 | except Exception as e: 156 | print(f"Error adding user: {e}") 157 | 158 | if __name__ == "__main__": 159 | main() 160 | -------------------------------------------------------------------------------- /create_cedar_image/cedar-ap-power.service: -------------------------------------------------------------------------------- 1 | # This service definition is to be copied onto the target at its 2 | # /etc/systemd/system/cedar-ap-power.service 3 | 4 | [Unit] 5 | Description=Set WiFi TX Power 6 | After=network-online.target 7 | Wants=network-online.target 8 | 9 | [Service] 10 | Type=oneshot 11 | ExecStartPre=/bin/sleep 10 12 | ExecStart=/sbin/iwconfig wlan0 txpower 10 13 | RemainAfterExit=yes 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /create_cedar_image/cedar-ap-setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | import logging 5 | from pathlib import Path 6 | import random 7 | import time 8 | 9 | # Generated by Anthropic Claude. 10 | 11 | # This script is to be copied onto the target at its 12 | # /usr/local/sbin/cedar-ap-setup.py 13 | 14 | # Set up logging 15 | logging.basicConfig( 16 | level=logging.INFO, 17 | format='%(asctime)s - %(levelname)s - %(message)s', 18 | handlers=[ 19 | logging.FileHandler('/var/log/cedar-ap-setup.log'), 20 | logging.StreamHandler(sys.stdout) 21 | ] 22 | ) 23 | 24 | def get_serial_number(): 25 | """Get the Raspberry Pi serial number, trying multiple methods.""" 26 | try: 27 | # Try /proc/cpuinfo first 28 | with open('/proc/cpuinfo', 'r') as f: 29 | for line in f: 30 | if line.startswith('Serial'): 31 | serial = line.split(':')[1].strip() 32 | logging.info(f"Found serial number: {serial}") 33 | return serial[-3:] # Last 3 chars 34 | 35 | logging.error("Could not find serial number by any method") 36 | return None 37 | 38 | except Exception as e: 39 | logging.error(f"Error getting serial number: {e}") 40 | return None 41 | 42 | def setup_access_point(): 43 | """Configure and enable the WiFi access point.""" 44 | serial_suffix = get_serial_number() 45 | if not serial_suffix: 46 | logging.error("Failed to get serial number - cannot continue") 47 | return False 48 | 49 | new_ssid = f"cedar-{serial_suffix}" 50 | channel = random.choice([1, 6, 11]) # Randomly select channel 51 | logging.info(f"Setting up access point with SSID: {new_ssid} on channel {channel}") 52 | 53 | try: 54 | # Stop and disable dnsmasq 55 | subprocess.run(['systemctl', 'disable', 'dnsmasq'], check=False) 56 | subprocess.run(['systemctl', 'stop', 'dnsmasq'], check=False) 57 | 58 | # Complete reset of NetworkManager 59 | logging.info("Resetting NetworkManager completely") 60 | subprocess.run(['systemctl', 'stop', 'NetworkManager'], check=True) 61 | subprocess.run(['rm', '-f', '/var/lib/NetworkManager/NetworkManager.state'], 62 | check=True) 63 | subprocess.run( 64 | ['rm', '-f', '/etc/NetworkManager/system-connections/cedar-ap.nmconnection'], 65 | check=False) 66 | subprocess.run(['rm', '-rf', '/var/lib/NetworkManager/'], check=False) 67 | 68 | # Make sure WiFi is unblocked and up 69 | logging.info("Ensuring WiFi is unblocked and up") 70 | subprocess.run(['rfkill', 'unblock', 'all'], check=True) 71 | subprocess.run(['ip', 'link', 'set', 'wlan0', 'up'], check=True) 72 | 73 | # Add IP address to ensure interface is working 74 | logging.info("Setting manual IP to ensure interface is working") 75 | subprocess.run(['ip', 'addr', 'add', '192.168.4.1/24', 'dev', 'wlan0'], check=False) 76 | 77 | # Start NetworkManager fresh 78 | logging.info("Starting NetworkManager") 79 | subprocess.run(['systemctl', 'start', 'NetworkManager'], check=True) 80 | time.sleep(5) 81 | 82 | # Create AP connection with all parameters at once 83 | logging.info(f"Creating access point with SSID {new_ssid}") 84 | subprocess.run([ 85 | 'nmcli', 'con', 'add', 'type', 'wifi', 'ifname', 'wlan0', 'mode', 'ap', 86 | 'con-name', 'cedar-ap', 'ssid', new_ssid, 87 | 'ipv4.method', 'shared', 'ipv4.addresses', '192.168.4.1/24', 88 | 'ipv6.method', 'disabled', 89 | '802-11-wireless.band', 'bg', '802-11-wireless.channel', str(channel), 90 | 'wifi-sec.key-mgmt', 'wpa-psk', 'wifi-sec.psk', 'cedar123', 91 | 'connection.autoconnect', 'true' 92 | ], check=True) 93 | 94 | # Try to bring up the connection 95 | logging.info("Activating access point") 96 | result = subprocess.run(['nmcli', 'con', 'up', 'cedar-ap'], 97 | capture_output=True, text=True) 98 | 99 | if result.returncode != 0: 100 | logging.error(f"Error bringing up access point: {result.stderr}") 101 | return False 102 | 103 | # Create flag file to indicate successful setup 104 | Path('/etc/cedar-ap-configured').touch() 105 | logging.info("Access point setup completed successfully") 106 | return True 107 | 108 | except subprocess.CalledProcessError as e: 109 | logging.error(f"Command failed: {e}") 110 | return False 111 | except Exception as e: 112 | logging.error(f"Unexpected error: {e}") 113 | return False 114 | 115 | 116 | if __name__ == '__main__': 117 | logging.info("Starting cedar-ap setup script") 118 | if setup_access_point(): 119 | sys.exit(0) 120 | else: 121 | sys.exit(1) 122 | -------------------------------------------------------------------------------- /create_cedar_image/cedar-ap-setup.service: -------------------------------------------------------------------------------- 1 | # This service definition is to be copied onto the target at its 2 | # /etc/systemd/system/cedar-ap-setup.service 3 | 4 | [Unit] 5 | Description=Cedar AP Setup Service 6 | After=NetworkManager.service 7 | Wants=NetworkManager.service 8 | ConditionPathExists=!/etc/cedar-ap-configured 9 | 10 | [Service] 11 | Type=oneshot 12 | ExecStart=/usr/local/sbin/cedar-ap-setup.py 13 | RemainAfterExit=yes 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /create_cedar_image/create_userconf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import crypt 4 | 5 | # Generated by Anthropic Claude. 6 | 7 | def create_userconf(boot_path, username, password): 8 | """ 9 | Create userconf.txt in the boot partition 10 | """ 11 | try: 12 | # Generate SHA-512 password hash 13 | password_hash = crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512)) 14 | 15 | # Create userconf.txt 16 | userconf_path = os.path.join(boot_path, 'userconf.txt') 17 | with open(userconf_path, 'w') as f: 18 | f.write(f'{username}:{password_hash}') 19 | 20 | print(f"Created userconf.txt for user '{username}'") 21 | 22 | except Exception as e: 23 | print(f"Error creating userconf.txt: {e}") 24 | raise 25 | 26 | def main(): 27 | BOOT_PATH = "/mnt/part1" 28 | USERNAME = "pi" 29 | PASSWORD = "raspberry" 30 | 31 | try: 32 | create_userconf(BOOT_PATH, USERNAME, PASSWORD) 33 | print("Successfully created userconf.txt") 34 | except Exception as e: 35 | print(f"Failed to create userconf.txt: {e}") 36 | exit(1) 37 | 38 | if __name__ == "__main__": 39 | main() 40 | -------------------------------------------------------------------------------- /create_cedar_image/customize-pi-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright (c) 2025 Steven Rosenthal smr@dt3.org 4 | # See LICENSE file in root directory for license terms. 5 | 6 | set -e # Exit on any error 7 | 8 | if [ "$#" -ne 2 ]; then 9 | echo "Usage: $0 " 10 | exit 1 11 | fi 12 | 13 | SOURCE_IMG_FILE="$1" 14 | CUSTOMIZED_RPI_FILE="$2" 15 | 16 | if [ ! -f "$SOURCE_IMG_FILE" ]; then 17 | echo "Error: File $SOURCE_IMG_FILE does not exist" 18 | exit 1 19 | fi 20 | 21 | # This script sets up the Pi OS for Cedar, but does not install any 22 | # of the Cedar components. 23 | 24 | echo "Setting up Raspberry Pi OS image for use with Cedar" 25 | 26 | echo "Copying to $CUSTOMIZED_RPI_FILE" 27 | cp $SOURCE_IMG_FILE $CUSTOMIZED_RPI_FILE 28 | 29 | echo "Extending $CUSTOMIZED_RPI_FILE" 30 | dd if=/dev/zero bs=1M count=2500 >> $CUSTOMIZED_RPI_FILE 31 | echo "Resizing filesystem" 32 | sudo parted $CUSTOMIZED_RPI_FILE resizepart 2 100% 33 | sudo python resize_fs.py $CUSTOMIZED_RPI_FILE 34 | 35 | echo 36 | echo "Mounting target partitions" 37 | sudo python mount_img.py $CUSTOMIZED_RPI_FILE 38 | BOOT_PATH="/mnt/part1" 39 | ROOTFS_PATH="/mnt/part2" 40 | 41 | echo 42 | echo "Update/upgrade OS" 43 | sudo python update_upgrade_chroot.py 44 | 45 | echo 46 | echo "Update boot cmdline.txt" 47 | sudo python modify_cmdline.py 48 | 49 | echo 50 | echo "Expand swap" 51 | sudo python modify_swap.py 52 | 53 | echo 54 | echo "Enable i2c" 55 | sudo sed -i 's/#dtparam=i2c_arm=on/dtparam=i2c_arm=on/g' $BOOT_PATH/config.txt 56 | echo "i2c-dev" | sudo tee -a $ROOTFS_PATH/etc/modules 57 | 58 | echo 59 | echo "Add user 'pi'" 60 | sudo python create_userconf.py 61 | 62 | echo 63 | echo "Add user 'cedar'" 64 | sudo python add_rpi_user.py 65 | 66 | echo 67 | echo "Set hostname to 'cedar'" 68 | sudo python set_hostname.py 69 | 70 | echo 71 | echo "Enable ssh" 72 | sudo python enable_ssh.py 73 | 74 | echo 75 | echo "Setup WiFi access point on first boot" 76 | sudo python install-cedar-ap-setup.py 77 | 78 | echo 79 | echo "Un-mounting target partitions" 80 | sudo python mount_img.py --cleanup $CUSTOMIZED_RPI_FILE 81 | 82 | echo 83 | echo "Done! Next step is install_cedar.sh" 84 | -------------------------------------------------------------------------------- /create_cedar_image/enable_ssh.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | # Generated by Anthropic Claude. 5 | 6 | def enable_ssh_service(rootfs_path): 7 | """ 8 | Enable SSH service in root filesystem 9 | """ 10 | try: 11 | # Create the symlink for SSH service 12 | service_path = os.path.join( 13 | rootfs_path, 'etc/systemd/system/multi-user.target.wants') 14 | ssh_service = 'ssh.service' 15 | ssh_service_link = os.path.join(service_path, ssh_service) 16 | ssh_service_target = os.path.join( 17 | '/', 'lib', 'systemd', 'system', ssh_service) 18 | 19 | # Create systemd symlink if it doesn't exist 20 | if not os.path.exists(ssh_service_link): 21 | os.makedirs(service_path, exist_ok=True) 22 | os.symlink(ssh_service_target, ssh_service_link) 23 | print(f"Enabled SSH service in root filesystem") 24 | else: 25 | print("SSH service already enabled in root filesystem") 26 | 27 | except Exception as e: 28 | print(f"Error enabling SSH service: {e}") 29 | 30 | def main(): 31 | ROOTFS_PATH = "/mnt/part2" 32 | 33 | try: 34 | enable_ssh_service(ROOTFS_PATH) 35 | print("SSH configuration completed successfully") 36 | except Exception as e: 37 | print(f"Error configuring SSH: {e}") 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /create_cedar_image/install-cedar-ap-setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import shutil 3 | 4 | def install_ap_setup(root_mount): 5 | # Create directories if needed 6 | sbin_path = Path(root_mount) / 'usr/local/sbin' 7 | systemd_path = Path(root_mount) / 'etc/systemd/system' 8 | sbin_path.mkdir(parents=True, exist_ok=True) 9 | systemd_path.mkdir(parents=True, exist_ok=True) 10 | 11 | # Copy the files 12 | shutil.copy2('cedar-ap-setup.py', sbin_path / 'cedar-ap-setup.py') 13 | shutil.copy2('cedar-ap-setup.service', systemd_path / 'cedar-ap-setup.service') 14 | shutil.copy2('cedar-ap-power.service', systemd_path / 'cedar-ap-power.service') 15 | 16 | # Ensure script is executable 17 | script_path = sbin_path / 'cedar-ap-setup.py' 18 | script_path.chmod(0o755) 19 | 20 | # After copying the files, enable the services 21 | enable_path = Path(root_mount) / 'etc/systemd/system/multi-user.target.wants' 22 | enable_path.mkdir(parents=True, exist_ok=True) 23 | enable_link1 = enable_path / 'cedar-ap-setup.service' 24 | enable_link1.symlink_to('../cedar-ap-setup.service') 25 | enable_link2 = enable_path / 'cedar-ap-power.service' 26 | enable_link2.symlink_to('../cedar-ap-power.service') 27 | 28 | def main(): 29 | ROOTFS_PATH = "/mnt/part2" 30 | 31 | try: 32 | install_ap_setup(ROOTFS_PATH) 33 | print(f"Successfully installed AP setup") 34 | except Exception as e: 35 | print(f"Error installing AP setup: {e}") 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /create_cedar_image/install_cedar.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright (c) 2025 Steven Rosenthal smr@dt3.org 4 | # See LICENSE file in root directory for license terms. 5 | 6 | set -e # Exit on any error 7 | 8 | if [ "$#" -ne 3 ]; then 9 | echo "Usage: $0 " 10 | exit 1 11 | fi 12 | 13 | RPI_IMG_FILE="$1" 14 | CEDAR_IMG_FILE="$2" 15 | REPOS_PATH="$3" 16 | 17 | echo 18 | echo "Creating Cedar image from customized Rpi image" 19 | cp $RPI_IMG_FILE $CEDAR_IMG_FILE 20 | 21 | # We need stuff in this virtual env for one of our Python invocations. 22 | source $REPOS_PATH/cedar-solve/.cedar_venv/bin/activate 23 | 24 | echo 25 | echo "Mounting target partitions" 26 | sudo python mount_img.py $CEDAR_IMG_FILE 27 | 28 | ROOT=/mnt/part2 29 | HOMEDIR=$ROOT/home/cedar 30 | 31 | echo 32 | echo "Creating directories" 33 | sudo mkdir -p $HOMEDIR/run/demo_images 34 | sudo mkdir -p $HOMEDIR/cedar/bin 35 | sudo mkdir -p $HOMEDIR/cedar/data 36 | sudo mkdir -p $HOMEDIR/cedar/cedar-aim/cedar_flutter/build 37 | 38 | echo 39 | echo "Copying Cedar server binary" 40 | sudo cp $REPOS_PATH/cedar-server/cedar/bin/cedar-box-server $HOMEDIR/cedar/bin/cedar-box-server 41 | 42 | echo 43 | echo "Copying demo images" 44 | sudo cp $REPOS_PATH/cedar-server/run/demo_images/* $HOMEDIR/run/demo_images 45 | 46 | echo 47 | echo "Copying Cedar-Solve component" 48 | sudo cp -R $REPOS_PATH/cedar-solve $HOMEDIR/cedar 49 | 50 | echo 51 | echo "Create virtual env" 52 | sudo rm -rf $HOMEDIR/cedar/cedar-solve/.cedar_venv 53 | sudo chroot $ROOT /bin/bash << 'EOF' 54 | python -m venv /home/cedar/cedar/cedar-solve/.cedar_venv 55 | source /home/cedar/cedar/cedar-solve/.cedar_venv/bin/activate 56 | cd /home/cedar/cedar/cedar-solve 57 | python -m pip install --upgrade grpcio 58 | python -m pip install -e ".[dev,docs,cedar-detect]" 59 | deactivate 60 | EOF 61 | 62 | echo 63 | echo "Copying Tetra3 server" 64 | sudo cp -R $REPOS_PATH/tetra3_server $HOMEDIR/cedar 65 | 66 | echo 67 | echo "Copying Cedar-Aim web app" 68 | sudo cp -R $REPOS_PATH/cedar-aim/cedar_flutter/build/web \ 69 | $HOMEDIR/cedar/cedar-aim/cedar_flutter/build 70 | 71 | echo 72 | echo "Copying Cedar Solve database" 73 | sudo cp $REPOS_PATH/cedar-solve/tetra3/data/default_database.npz $HOMEDIR/cedar/data 74 | 75 | echo 76 | echo "Setup service to run Cedar server at startup" 77 | sudo bash -c "cat > $ROOT/lib/systemd/system/cedar.service <") 105 | sys.exit(1) 106 | 107 | image_path = args[0] 108 | if not os.path.exists(image_path): 109 | print(f"Image file {image_path} not found") 110 | sys.exit(1) 111 | 112 | mounter = ImageMounter(image_path) 113 | 114 | if not cleanup_mode: 115 | try: 116 | if not mounter.setup_loop_devices(): 117 | sys.exit(1) 118 | 119 | if not mounter.mount_partitions(readonly_mode): 120 | mounter.cleanup() 121 | sys.exit(1) 122 | 123 | print("\nMounting completed successfully!") 124 | print("Loop devices:", mounter.loop_devices) 125 | print("Mount points:", mounter.mount_points) 126 | print("\nTo unmount when finished, run:") 127 | print(f"sudo python3 {sys.argv[0]} --cleanup {image_path}") 128 | 129 | except KeyboardInterrupt: 130 | print("\nOperation interrupted by user") 131 | mounter.cleanup() 132 | sys.exit(1) 133 | else: 134 | # Handle cleanup mode 135 | mounter.cleanup() 136 | sys.exit(0) 137 | 138 | if __name__ == "__main__": 139 | main() 140 | -------------------------------------------------------------------------------- /create_cedar_image/resize_fs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import os 4 | import re 5 | import sys 6 | import time 7 | from contextlib import contextmanager 8 | 9 | class DeviceManager: 10 | def __init__(self, image_path): 11 | self.image_path = image_path 12 | self.loop_devices = [] 13 | 14 | def setup_loop_devices(self): 15 | """Run kpartx and capture the loop device names""" 16 | try: 17 | result = subprocess.run(['kpartx', '-v', '-a', self.image_path], 18 | capture_output=True, text=True, check=True) 19 | 20 | # Parse kpartx output to get loop devices 21 | # Example output line: "add map loop5p1 (253:0): 0 524288 linear 7:5 8192" 22 | loop_matches = re.finditer(r'add map (loop\dp\d)', result.stdout) 23 | self.loop_devices = [match.group(1) for match in loop_matches] 24 | 25 | if len(self.loop_devices) < 2: 26 | raise RuntimeError( 27 | f"Expected at least 2 partitions, found {len(self.loop_devices)}") 28 | 29 | print(f"Created loop devices: {', '.join(self.loop_devices)}") 30 | return True 31 | 32 | except subprocess.CalledProcessError as e: 33 | print(f"Error running kpartx: {e}") 34 | print(f"kpartx stderr: {e.stderr}") 35 | return False 36 | 37 | def cleanup(self): 38 | """Remove loop devices""" 39 | try: 40 | subprocess.run(['kpartx', '-d', self.image_path], check=True) 41 | print("Removed loop devices") 42 | except subprocess.CalledProcessError as e: 43 | print(f"Error removing loop devices: {e}") 44 | 45 | def get_root_partition(self): 46 | """Get the root partition device (typically the second one)""" 47 | if len(self.loop_devices) < 2: 48 | raise RuntimeError("No root partition found") 49 | # Return the second partition (index 1) 50 | return f"/dev/mapper/{self.loop_devices[1]}" 51 | 52 | def resize_filesystem(image_path): 53 | """Resize the filesystem on the root partition""" 54 | manager = DeviceManager(image_path) 55 | 56 | try: 57 | # Setup loop devices 58 | if not manager.setup_loop_devices(): 59 | return False 60 | 61 | # Get root partition 62 | root_dev = manager.get_root_partition() 63 | print(f"Root partition device: {root_dev}") 64 | 65 | # Wait a moment for devices to settle 66 | time.sleep(1) 67 | 68 | # Run filesystem check 69 | print("Running filesystem check...") 70 | try: 71 | subprocess.run(['e2fsck', '-f', root_dev], check=False) 72 | except subprocess.CalledProcessError as e: 73 | # e2fsck returns non-zero for various states, don't treat as error 74 | print(f"e2fsck returned: {e.returncode}") 75 | 76 | # Resize filesystem 77 | print("Resizing filesystem...") 78 | result = subprocess.run(['resize2fs', root_dev], 79 | capture_output=True, text=True) 80 | print(result.stdout) 81 | if result.stderr: 82 | print(result.stderr) 83 | 84 | print("Filesystem operations completed successfully") 85 | return True 86 | 87 | except Exception as e: 88 | print(f"Error during filesystem operations: {e}") 89 | return False 90 | 91 | finally: 92 | # Always try to cleanup 93 | manager.cleanup() 94 | 95 | def main(): 96 | if len(sys.argv) != 2: 97 | print("Usage: sudo python3 resize_fs.py ") 98 | sys.exit(1) 99 | 100 | image_path = sys.argv[1] 101 | 102 | if not os.path.exists(image_path): 103 | print(f"Image file {image_path} not found") 104 | sys.exit(1) 105 | 106 | success = resize_filesystem(image_path) 107 | sys.exit(0 if success else 1) 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /create_cedar_image/set_hostname.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Script to set Raspberry Pi hostname to 'cedar' and configure for cedar.local mDNS 4 | 5 | Usage: 6 | sudo python3 set_hostname.py /path/to/mounted/rootfs 7 | """ 8 | 9 | import os 10 | import sys 11 | import subprocess 12 | import re 13 | 14 | # Generated by Anthropic Claude. 15 | 16 | def set_hostname(rootfs_path, hostname="cedar"): 17 | """ 18 | Set the hostname in the mounted Raspberry Pi image 19 | """ 20 | # Validate inputs 21 | if not os.path.isdir(rootfs_path): 22 | print(f"Error: {rootfs_path} is not a valid directory") 23 | return False 24 | 25 | # Set the hostname file 26 | hostname_path = os.path.join(rootfs_path, "etc/hostname") 27 | try: 28 | with open(hostname_path, 'w') as f: 29 | f.write(f"{hostname}\n") 30 | print(f"Successfully set hostname to '{hostname}'") 31 | except Exception as e: 32 | print(f"Failed to write hostname: {e}") 33 | return False 34 | 35 | # Update the hosts file 36 | hosts_path = os.path.join(rootfs_path, "etc/hosts") 37 | try: 38 | if os.path.exists(hosts_path): 39 | with open(hosts_path, 'r') as f: 40 | hosts_content = f.read() 41 | 42 | # Replace the existing raspberry pi hostname line or add a new one if not found 43 | if re.search(r'127\.0\.1\.1\s+\S+', hosts_content): 44 | hosts_content = re.sub(r'127\.0\.1\.1\s+\S+', 45 | f'127.0.1.1\t{hostname}', hosts_content) 46 | else: 47 | hosts_content += f"\n127.0.1.1\t{hostname}\n" 48 | 49 | with open(hosts_path, 'w') as f: 50 | f.write(hosts_content) 51 | print(f"Successfully updated hosts file") 52 | else: 53 | # Create a new hosts file if it doesn't exist 54 | with open(hosts_path, 'w') as f: 55 | f.write("127.0.0.1\tlocalhost\n") 56 | f.write(f"127.0.1.1\t{hostname}\n") 57 | print(f"Created new hosts file") 58 | except Exception as e: 59 | print(f"Failed to update hosts file: {e}") 60 | return False 61 | 62 | # Enable Avahi daemon (assuming it's already installed) 63 | try: 64 | # Create the systemd symlink directory if it doesn't exist 65 | systemd_dir = os.path.join(rootfs_path, 66 | "etc/systemd/system/multi-user.target.wants") 67 | os.makedirs(systemd_dir, exist_ok=True) 68 | 69 | # Check if Avahi service file exists 70 | avahi_service = os.path.join(rootfs_path, 71 | "lib/systemd/system/avahi-daemon.service") 72 | if os.path.exists(avahi_service): 73 | # Create the symlink to enable the service if it doesn't already exist 74 | target_link = os.path.join(systemd_dir, "avahi-daemon.service") 75 | if not os.path.exists(target_link): 76 | # Use relative symlink 77 | try: 78 | os.symlink("../../../lib/systemd/system/avahi-daemon.service", 79 | target_link) 80 | print("Enabled Avahi daemon service") 81 | except Exception as e: 82 | print(f"Warning: Failed to create symlink: {e}") 83 | else: 84 | print("Avahi daemon already enabled") 85 | else: 86 | print("Warning: Avahi daemon service file not found. Ensure avahi-daemon " 87 | "is installed in the image.") 88 | except Exception as e: 89 | print(f"Failed to enable Avahi: {e}") 90 | return False 91 | 92 | return True 93 | 94 | def main(): 95 | ROOTFS_PATH = "/mnt/part2" 96 | if set_hostname(ROOTFS_PATH): 97 | print(f"\nSuccessfully configured hostname to 'cedar.local'") 98 | print(f"After first boot, the Raspberry Pi should be accessible as 'cedar.local'") 99 | return 0 100 | else: 101 | print(f"\nError: Failed to configure hostname") 102 | return 1 103 | 104 | if __name__ == "__main__": 105 | sys.exit(main()) 106 | -------------------------------------------------------------------------------- /create_cedar_image/update_upgrade_chroot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import subprocess 4 | import shutil 5 | import time 6 | from contextlib import contextmanager 7 | 8 | # Generated by Anthropic Claude. 9 | 10 | class ChrootUpdateError(Exception): 11 | """Custom exception for chroot update errors""" 12 | pass 13 | 14 | def remount_host_filesystems(): 15 | """Remount essential filesystems on host after chroot""" 16 | try: 17 | # Ensure /dev/pts exists 18 | os.makedirs('/dev/pts', exist_ok=True) 19 | 20 | # Remount devpts 21 | subprocess.run( 22 | ['mount', '-t', 'devpts', '-o', 'gid=5,mode=620', 'devpts', '/dev/pts'], 23 | check=True 24 | ) 25 | print("Remounted devpts filesystem") 26 | 27 | except subprocess.CalledProcessError as e: 28 | print(f"Warning: Failed to remount filesystems: {e}") 29 | except Exception as e: 30 | print(f"Warning: Unexpected error during filesystem remount: {e}") 31 | 32 | @contextmanager 33 | def mount_context(rootfs_path): 34 | """Context manager to handle mounting and unmounting of necessary filesystems""" 35 | mounted_paths = [] 36 | 37 | try: 38 | # Define mount points 39 | mount_points = [ 40 | ('proc', os.path.join(rootfs_path, 'proc'), '-tproc'), 41 | ('sys', os.path.join(rootfs_path, 'sys'), '--rbind'), 42 | ('dev', os.path.join(rootfs_path, 'dev'), '--rbind'), 43 | ('devpts', os.path.join(rootfs_path, 'dev/pts'), '-tdevpts'), 44 | ('run', os.path.join(rootfs_path, 'run'), '--rbind') 45 | ] 46 | 47 | # Perform mounts 48 | for src, dest, option in mount_points: 49 | if option in ['-tproc', '-tdevpts']: 50 | subprocess.run(['mount', option, src, dest], check=True) 51 | else: 52 | subprocess.run(['mount', option, f'/{src}', dest], check=True) 53 | mounted_paths.append(dest) 54 | print(f"Mounted {src} at {dest}") 55 | 56 | # Copy resolv.conf for DNS resolution 57 | shutil.copy2('/etc/resolv.conf', os.path.join(rootfs_path, 'etc/resolv.conf')) 58 | print("Copied resolv.conf for DNS resolution") 59 | 60 | yield 61 | 62 | finally: 63 | # Unmount everything in reverse order 64 | for path in reversed(mounted_paths): 65 | max_attempts = 3 66 | attempt = 0 67 | while attempt < max_attempts: 68 | try: 69 | subprocess.run(['umount', '-l', path], check=True) 70 | print(f"Unmounted {path}") 71 | break 72 | except subprocess.CalledProcessError: 73 | attempt += 1 74 | if attempt < max_attempts: 75 | print(f"Retrying unmount of {path}") 76 | time.sleep(1) 77 | else: 78 | print(f"Warning: Failed to unmount {path}") 79 | 80 | def run_chroot_command(rootfs_path, command): 81 | """Run a command in chroot environment""" 82 | result = subprocess.run(['chroot', rootfs_path] + command, 83 | capture_output=True, 84 | text=True) 85 | 86 | print(f"Command output:") 87 | print(result.stdout) 88 | 89 | if result.stderr: 90 | print(f"Command errors:") 91 | print(result.stderr) 92 | 93 | if result.returncode != 0: 94 | raise ChrootUpdateError( 95 | f"Command {' '.join(command)} failed with return code {result.returncode}") 96 | 97 | def update_system(rootfs_path): 98 | """Perform system update in chroot environment""" 99 | if not os.path.isdir(rootfs_path): 100 | raise ValueError(f"Root filesystem path {rootfs_path} does not exist") 101 | 102 | if os.geteuid() != 0: 103 | raise PermissionError("This script must be run as root") 104 | 105 | print(f"Starting system update in {rootfs_path}") 106 | 107 | with mount_context(rootfs_path): 108 | try: 109 | # Update package lists 110 | print("\nUpdating package lists...") 111 | run_chroot_command(rootfs_path, ['apt', 'update']) 112 | 113 | # Perform full upgrade 114 | print("\nPerforming full upgrade...") 115 | run_chroot_command(rootfs_path, ['apt', 'full-upgrade', '-y']) 116 | 117 | # While we're at it... 118 | print("\nInstalling mDNS/Bonjour support...") 119 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'avahi-daemon']) 120 | 121 | print("\nInstalling math support...") 122 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'libblas-dev']) 123 | run_chroot_command(rootfs_path, 124 | ['apt', 'install', '-y', 'libatlas-base-dev:armhf']) 125 | run_chroot_command(rootfs_path, 126 | ['apt', 'install', '-y', 'libatlas-base-dev']) 127 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'liblapack-dev']) 128 | 129 | print("\nInstalling libssl-dev...") 130 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'libssl-dev']) 131 | 132 | print("\nInstalling i2c tools...") 133 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'i2c-tools']) 134 | 135 | print("\nInstalling python deps and grpc...") 136 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'build-essential']) 137 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'python3-dev']) 138 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'cmake']) 139 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'pkg-config']) 140 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'libjpeg-dev']) 141 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'zlib1g-dev']) 142 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'libfreetype6-dev']) 143 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'liblcms2-dev']) 144 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'libopenjp2-7-dev']) 145 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'libtiff5-dev']) 146 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'tk-dev']) 147 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'libharfbuzz-dev']) 148 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'libfribidi-dev']) 149 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'python3-grpcio']) 150 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'python3-grpc-tools']) 151 | run_chroot_command(rootfs_path, ['apt', 'install', '-y', 'pip']) 152 | 153 | # Clean up 154 | print("\nCleaning up...") 155 | run_chroot_command(rootfs_path, ['apt', 'clean']) 156 | 157 | print("\nSystem update completed successfully") 158 | 159 | except ChrootUpdateError as e: 160 | print(f"\nError during update: {e}") 161 | raise 162 | except Exception as e: 163 | print(f"\nUnexpected error: {e}") 164 | raise 165 | 166 | # After chroot is done and filesystems are unmounted, remount host filesystems 167 | print("\nRestoring host system mounts...") 168 | remount_host_filesystems() 169 | 170 | def main(): 171 | ROOTFS_PATH = "/mnt/part2" 172 | 173 | try: 174 | update_system(ROOTFS_PATH) 175 | except Exception as e: 176 | print(f"Failed to update system: {e}") 177 | exit(1) 178 | 179 | if __name__ == "__main__": 180 | main() 181 | -------------------------------------------------------------------------------- /elements/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cedar-elements" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | approx = "0.5.1" 8 | astro = "2.0.0" 9 | canonical-error = "0.1.0" 10 | chrono = "0.4.31" 11 | fast_image_resize = "4.0.0" 12 | image = "0.25.2" 13 | imageproc = "0.25.0" 14 | log = "0.4.27" 15 | medians = "3.0.5" 16 | nalgebra = "0.33.0" 17 | prost = "0.12.3" 18 | prost-types = "0.12.3" 19 | rand = "0.8.5" 20 | rolling-stats = "0.7.0" 21 | statistical = "1.0.0" 22 | tonic = "0.11" 23 | 24 | [build-dependencies] 25 | tonic-build = "0.11" 26 | prost-build = "0.12.3" 27 | -------------------------------------------------------------------------------- /elements/build.rs: -------------------------------------------------------------------------------- 1 | use prost_build; 2 | 3 | fn main() -> Result<(), Box> { 4 | let mut config = prost_build::Config::new(); 5 | config.protoc_arg("--experimental_allow_proto3_optional"); 6 | 7 | tonic_build::configure().compile_with_config( 8 | config, 9 | &["src/proto/cedar.proto", "src/proto/cedar_sky.proto", 10 | "src/proto/cedar_common.proto"], &["src/proto"])?; 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /elements/src/cedar_sky_trait.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use std::time::SystemTime; 5 | 6 | use crate::cedar_common::CelestialCoord; 7 | use crate::cedar_sky::{CatalogDescription, CatalogEntryKey, 8 | CatalogEntry, Constellation, 9 | ObjectType, Ordering, SelectedCatalogEntry}; 10 | use crate::cedar::LatLong; 11 | use canonical_error::CanonicalError; 12 | 13 | pub struct LocationInfo { 14 | pub observer_location: LatLong, 15 | pub observing_time: SystemTime, 16 | } 17 | 18 | pub trait CedarSkyTrait { 19 | /// Populate solar system objects into the catalog as of the given 20 | /// `timestamp`. The object positions as of `timestamp` are used for 21 | /// `max_distance`, `min_elevation`, and `sky_location` constraint matching 22 | /// in `query_catalog_entries()`. 23 | fn initialize_solar_system(&mut self, timestamp: SystemTime); 24 | 25 | fn get_catalog_descriptions(&self) -> Vec; 26 | fn get_object_types(&self) -> Vec; 27 | fn get_constellations(&self) -> Vec; 28 | 29 | /// Returns the selected catalog entries, plus the number of entries left 30 | /// off because of `limit_result`. Returned solar system objects' current 31 | /// position are determined using `location_info` if provided, current time 32 | /// otherwise. 33 | fn query_catalog_entries(&self, 34 | max_distance: Option, 35 | min_elevation: Option, 36 | faintest_magnitude: Option, 37 | match_catalog_label: bool, 38 | catalog_label: &Vec, 39 | match_object_type_label: bool, 40 | object_type_label: &Vec, 41 | text_search: Option, 42 | ordering: Option, 43 | decrowd_distance: Option, 44 | limit_result: Option, 45 | sky_location: Option, 46 | location_info: Option) 47 | -> Result<(Vec, usize), CanonicalError>; 48 | 49 | /// Return the selected catalog entry. If it is a solar system object the 50 | /// current position is calculated using `timestamp`. 51 | fn get_catalog_entry(&mut self, 52 | entry_key: CatalogEntryKey, 53 | timestamp: SystemTime) 54 | -> Result; 55 | } 56 | -------------------------------------------------------------------------------- /elements/src/image_utils.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use fast_image_resize::images::Image as FastImage; 5 | use fast_image_resize::{FilterType, Resizer, ResizeOptions, 6 | ResizeAlg::Interpolation as FastInterp}; 7 | 8 | use image::{GrayImage, ImageBuffer, Luma}; 9 | use image::imageops; 10 | use imageproc::geometric_transformations::{Interpolation, rotate_about_center}; 11 | 12 | fn compute_lut(min_pixel_value: u8, 13 | mut peak_pixel_value: u8, 14 | gamma: f32) -> [u8; 256] { 15 | if peak_pixel_value < min_pixel_value { 16 | peak_pixel_value = min_pixel_value; 17 | } 18 | let mut lut: [u8; 256] = [0; 256]; 19 | let scale = 256.0 / ((peak_pixel_value - min_pixel_value) as f32).powf(gamma); 20 | for n in 0..=255 { 21 | if n < min_pixel_value { 22 | lut[n as usize] = 0; 23 | continue; 24 | } 25 | if n >= peak_pixel_value { 26 | lut[n as usize] = 255; 27 | continue; 28 | } 29 | let mut scaled = scale * ((n - min_pixel_value) as f32).powf(gamma); 30 | if scaled < 0.0 { 31 | scaled = 0.0; 32 | } else if scaled > 255.0 { 33 | scaled = 255.0; 34 | } 35 | lut[n as usize] = scaled as u8; 36 | } 37 | lut 38 | } 39 | 40 | // Copy the image, mapping min_pixel_value..peak_pixel_value to 0..255 by 41 | // applying a gamma and scale factor. 42 | pub fn scale_image( 43 | image: &GrayImage, min_pixel_value: u8, peak_pixel_value: u8, gamma: f32) 44 | -> GrayImage { 45 | let lut = compute_lut(min_pixel_value, peak_pixel_value, gamma); 46 | 47 | // Apply the lut. 48 | let out_vec: Vec = image.as_raw().iter().map(|x| lut[*x as usize]).collect(); 49 | 50 | let (width, height) = image.dimensions(); 51 | GrayImage::from_raw(width, height, out_vec).unwrap() 52 | } 53 | 54 | // In-place variant of scale_image(). 55 | pub fn scale_image_mut( 56 | image: &mut GrayImage, min_pixel_value: u8, peak_pixel_value: u8, gamma: f32) { 57 | let lut = compute_lut(min_pixel_value, peak_pixel_value, gamma); 58 | 59 | for pixel in image.pixels_mut() { 60 | pixel[0] = lut[pixel[0] as usize]; 61 | } 62 | } 63 | 64 | // Some cameras have a problem where some rows have a noise-induced level 65 | // offset. This function heuristically normalizes each row to have similar 66 | // black level. 67 | pub fn normalize_rows_mut(image: &mut GrayImage) { 68 | for y in 0..image.height() { 69 | let mut min_value = 255_u8; 70 | for x in 0..image.width() { 71 | let value = image.get_pixel(x as u32, y as u32).0[0]; 72 | if value < min_value { 73 | min_value = value; 74 | } 75 | } 76 | for x in 0..image.width() { 77 | let value = image.get_pixel_mut(x as u32, y as u32); 78 | value[0] -= min_value; 79 | } 80 | } 81 | } 82 | 83 | // Tool for rotating an image and performing related coordinate transforms. 84 | #[derive(Clone)] 85 | pub struct ImageRotator { 86 | angle_rad: f64, 87 | sin_term: f64, 88 | cos_term: f64, 89 | size_ratio: f64, 90 | } 91 | impl ImageRotator { 92 | // `angle` degrees, positive is counter-clockwise. 93 | // The supplied `width` and `height` must match the values passed to 94 | // rotate_image() and transform_xxx(), allowing for scaling up and down (but 95 | // not changing the aspect ratio). 96 | pub fn new(width: u32, height: u32, angle: f64) -> Self { 97 | let angle_rad = angle.to_radians(); 98 | let sin_term = angle_rad.sin(); 99 | let cos_term = angle_rad.cos(); 100 | 101 | // Take the origin to be at the center of the image rectangle, and 102 | // express the coordinates of each rectangle vertex. 103 | let w = width; 104 | let h = height; 105 | let p1 = ( 0.5 * w as f64, 0.5 * h as f64); 106 | let p2 = (-0.5 * w as f64, 0.5 * h as f64); 107 | let p3 = (-0.5 * w as f64, -0.5 * h as f64); 108 | let p4 = ( 0.5 * w as f64, -0.5 * h as f64); 109 | 110 | // Find the rotated rectangle's vertices. 111 | let p1_rot = Self::rotate_vector(p1.0, p1.1, sin_term, cos_term); 112 | let p2_rot = Self::rotate_vector(p2.0, p2.1, sin_term, cos_term); 113 | let p3_rot = Self::rotate_vector(p3.0, p3.1, sin_term, cos_term); 114 | let p4_rot = Self::rotate_vector(p4.0, p4.1, sin_term, cos_term); 115 | 116 | // Compute the horizontal and vertical extent of the rotated rectangle. 117 | let mut x_min = 0.0_f64; 118 | let mut x_max = 0.0_f64; 119 | let mut y_min = 0.0_f64; 120 | let mut y_max = 0.0_f64; 121 | for p in [p1_rot, p2_rot, p3_rot, p4_rot] { 122 | let (x, y) = p; 123 | x_min = x_min.min(x); 124 | x_max = x_max.max(x); 125 | y_min = y_min.min(y); 126 | y_max = y_max.max(y); 127 | } 128 | let w_rot = x_max - x_min; 129 | let h_rot = y_max - y_min; 130 | 131 | // One or both of the rotated width or height will be larger than the 132 | // original width/height. Find out how much we need to scale down the 133 | // rotated rectangle to fit within the original dimensions. 134 | let w_ratio = w_rot / w as f64; 135 | let h_ratio = h_rot / h as f64; 136 | let ratio = w_ratio.max(h_ratio); 137 | assert!(ratio >= 1.0); 138 | 139 | // Note that we don't store the width and height. The caller passes the 140 | // run-time width/height into the rotate_image() and transform_xxx 141 | // methods, because the run-time coordinate transform requests might be 142 | // for a different scaling of the image size. 143 | ImageRotator{angle_rad, sin_term, cos_term, size_ratio: ratio} 144 | } 145 | 146 | pub fn angle(&self) -> f64 { 147 | self.angle_rad.to_degrees() 148 | } 149 | 150 | // Returns >= 1.0, factor by which image was shrunk when rotating. 151 | pub fn size_ratio(&self) -> f64 { 152 | self.size_ratio 153 | } 154 | 155 | // The returned image has the same dimensions as the argument; the input image 156 | // is shrunk as needed such that the rotated image fits within the original 157 | // dimensions. 158 | // The `fill` value is used to fill in pixels outside of the shrunk/rotated 159 | // image. 160 | pub fn rotate_image(&self, image: &GrayImage, fill: u8) -> GrayImage 161 | { 162 | let (w, h) = image.dimensions(); 163 | let ratio = self.size_ratio; 164 | 165 | // Pad the image before rotating and shrinking. 166 | let padded_w = w as f64 * ratio + 0.5; 167 | let padded_h = h as f64 * ratio + 0.5; 168 | let mut new_img = ImageBuffer::from_pixel(padded_w as u32, padded_h as u32, 169 | Luma::([fill])); 170 | let border_w = (padded_w - w as f64) / 2.0; 171 | let border_h = (padded_h - h as f64) / 2.0; 172 | let x_offset = border_w as i64; 173 | let y_offset = border_h as i64; 174 | imageops::replace(&mut new_img, image, x_offset, y_offset); 175 | 176 | let rotated_image = rotate_about_center( 177 | &new_img, 178 | -1.0 * self.angle_rad as f32, 179 | // Almost as fast as Nearest, with much higher visual quality. 180 | Interpolation::Bilinear, 181 | Luma::([fill])); 182 | 183 | // Convert GrayImage to FastImage for fast_image_resize. 184 | let src_img = FastImage::from_vec_u8(padded_w as u32, padded_h as u32, 185 | rotated_image.into_raw(), 186 | fast_image_resize::PixelType::U8).unwrap(); 187 | // Shrink the image. 188 | let mut resizer = Resizer::new(); 189 | let mut dst_img = FastImage::new(w, h, src_img.pixel_type()); 190 | resizer.resize( 191 | &src_img, &mut dst_img, 192 | &ResizeOptions::new().resize_alg(FastInterp( 193 | // Almost as fast as Box, with higher visual quality. 194 | FilterType::Hamming))).unwrap(); 195 | 196 | let resized_img = GrayImage::from_raw(w, h, dst_img.into_vec()).unwrap(); 197 | resized_img 198 | } 199 | 200 | // Given (x, y), the image coordinates in the original image, returns the 201 | // coordinates of the downscaled/rotated image within the output image. 202 | pub fn transform_to_rotated(&self, x: f64, y: f64, 203 | width: u32, height: u32) -> (f64, f64) { 204 | // The x, y origin is upper-left corner. Change to center-based 205 | // coordinates. 206 | let x_cen = x - (width as f64 / 2.0); 207 | let y_cen = (height as f64 / 2.0) - y; 208 | 209 | // Rotate according to the transform. 210 | let (x_cen_rot, y_cen_rot) = 211 | Self::rotate_vector(x_cen, y_cen, self.sin_term, self.cos_term); 212 | 213 | // Scale down. 214 | let x_cen_rot_scaled = x_cen_rot / self.size_ratio; 215 | let y_cen_rot_scaled = y_cen_rot / self.size_ratio; 216 | 217 | // Move back to corner-based origin. 218 | (x_cen_rot_scaled + (width as f64 / 2.0), 219 | (height as f64 / 2.0) - y_cen_rot_scaled) 220 | } 221 | 222 | // Given (x, y), the image coordinates in the output image after 223 | // downscaling/rotating, returns the coordinates within the original 224 | // image (prior to downscaling/rotating). 225 | pub fn transform_from_rotated(&self, x: f64, y: f64, 226 | width: u32, height: u32) -> (f64, f64) { 227 | // The x, y origin is upper-left corner. Change to center-based 228 | // coordinates. 229 | let x_cen = x - (width as f64 / 2.0); 230 | let y_cen = (height as f64 / 2.0) - y; 231 | 232 | // De-rotate according to the transform. 233 | let (x_cen_rot, y_cen_rot) = 234 | Self::rotate_vector(x_cen, y_cen, -1.0 * self.sin_term, self.cos_term); 235 | 236 | // Scale up. 237 | let x_cen_rot_scaled = x_cen_rot * self.size_ratio; 238 | let y_cen_rot_scaled = y_cen_rot * self.size_ratio; 239 | 240 | // Move back to corner-based origin. 241 | (x_cen_rot_scaled + (width as f64 / 2.0), 242 | (height as f64 / 2.0) - y_cen_rot_scaled) 243 | } 244 | 245 | fn rotate_vector(x: f64, y: f64, sin_term: f64, cos_term: f64) -> (f64, f64) { 246 | (x * cos_term - y * sin_term, 247 | x * sin_term + y * cos_term) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /elements/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | pub mod astro_util; 5 | pub mod cedar_sky_trait; 6 | pub mod image_utils; 7 | pub mod reservoir_sampler; 8 | pub mod solver_trait; 9 | pub mod value_stats; 10 | pub mod wifi_trait; 11 | 12 | pub mod cedar { 13 | // The string specified here must match the proto package name. 14 | tonic::include_proto!("cedar"); 15 | } 16 | pub mod cedar_common { 17 | // The string specified here must match the proto package name. 18 | tonic::include_proto!("cedar_common"); 19 | } 20 | pub mod cedar_sky { 21 | // The string specified here must match the proto package name. 22 | tonic::include_proto!("cedar_sky"); 23 | } 24 | -------------------------------------------------------------------------------- /elements/src/proto/cedar_common.proto: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | // Definitions shared between cedar.proto and cedar_sky.proto, to avoid 5 | // cyclic dependency. 6 | 7 | syntax = "proto3"; 8 | 9 | package cedar_common; 10 | 11 | message CelestialCoord { 12 | double ra = 1; // Degrees, 0..360. 13 | double dec = 2; // Degrees, -90..90. 14 | } 15 | -------------------------------------------------------------------------------- /elements/src/proto/cedar_sky.proto: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | syntax = "proto3"; 5 | 6 | import "cedar_common.proto"; 7 | 8 | package cedar_sky; 9 | 10 | // Describes various constraints that must all be satisfied for a sky catalog 11 | // entry to be returned by QueryCatalogEntries() RPC. 12 | message QueryCatalogRequest { 13 | // Constraints relative to information about the sky objects themselves. 14 | CatalogEntryMatch catalog_entry_match = 1; 15 | 16 | // Distance from the current telescope boresight position in the sky. Ignored 17 | // if Cedar has no plate solution. 18 | optional double max_distance = 2; // Degrees. 19 | 20 | // Elevation relative to the current horizon. Ignored if Cedar does not know 21 | // the observer location and current time. 22 | optional double min_elevation = 3; // Degrees. 23 | 24 | // If two objects from the same catalog satisfy the criteria, and are 25 | // within this angular distance of each other, only one is returned. The 26 | // brighter object is returned. If omitted, no decrowding is done. 27 | optional double decrowd_distance = 5; // Arcsec. 28 | 29 | optional Ordering ordering = 6; // Default is to order by brightness. 30 | 31 | // If given, caps the number of `entries` in QueryCatalogResponse. 32 | optional int32 limit_result = 7; 33 | 34 | // If given, applies a text match constraint. The server canonicalizes the 35 | // given string, removing dangerous characters, tokenizing it, etc. Each token 36 | // is treated as a prefix search term, and multiple token terms are combined 37 | // with implicit AND; order is not significant. Thus, |andr gal| and 38 | // |gal andr| both match "Andromeda Galaxy". 39 | // Note that when `text_search` is given, the `catalog_entry_match`, 40 | // `max_distance`, and `min_elevation` constraints are ignored. 41 | optional string text_search = 8; 42 | } 43 | 44 | // Specifies what intrinsic criteria to apply when matching catalog entries. 45 | message CatalogEntryMatch { 46 | // Limiting magnitude. If provided, objects fainter than the limit are 47 | // excluded. 48 | optional int32 faintest_magnitude = 1; 49 | 50 | // If true, `catalog_label` is used to match catalog(s). 51 | bool match_catalog_label = 4; 52 | // What catalog(s) to search. 53 | repeated string catalog_label = 2; 54 | 55 | // If true, `object_type_label` is used to match object type(s). 56 | bool match_object_type_label = 5; 57 | // What object type(s) to search. Note: if empty, no filtering on object 58 | // type is done. 59 | repeated string object_type_label = 3; 60 | } 61 | 62 | enum Ordering { 63 | UNSPECIFIED = 0; 64 | 65 | // Brightest first. 66 | BRIGHTNESS = 1; 67 | 68 | // Closest first. If no plate solution is available, reverts to brightness 69 | // ordering. 70 | SKY_LOCATION = 2; 71 | 72 | // Highest first. If observer geolocation is unknown, reverts to brightness 73 | // ordering. 74 | ELEVATION = 3; 75 | 76 | // TODO: MARATHON: time until setting to within min_elevation. 77 | } 78 | 79 | message QueryCatalogResponse { 80 | // The catalog entries that satisfy the QueryCatalogRequest criteria. 81 | repeated SelectedCatalogEntry entries = 1; 82 | 83 | // If `limit_result` is specified in QueryCatalogRequest, this will 84 | // be the number of entries that were truncated after the limit was 85 | // reached. 86 | int32 truncated_count = 2; 87 | } 88 | 89 | message SelectedCatalogEntry { 90 | CatalogEntry entry = 1; 91 | 92 | // Other entries, if any, that were suppressed due to `dedup_distance` in the 93 | // Cedar sky implementation. 94 | repeated CatalogEntry deduped_entries = 2; 95 | 96 | // Other entries, if any, that were suppressed due to `decrowd_distance` in 97 | // the QueryCatalogRequest. 98 | repeated CatalogEntry decrowded_entries = 3; 99 | 100 | // Computed information, available if observer location/time is known. 101 | 102 | // Altitude (degrees, relative to the local horizon). 103 | optional double altitude = 4; 104 | 105 | // Azimuth (degrees, positive clockwise from north). 106 | optional double azimuth = 5; 107 | 108 | // TODO: if below horizon, rising time; if above horizon, setting time. 109 | } 110 | 111 | message CatalogEntry { 112 | // These two fields combine to be globally unique entry label, e.g. 'M51', 113 | // 'NGC3982'. 114 | string catalog_label = 1; // M, NGC, etc. 115 | string catalog_entry = 2; // 51, 3982, etc. 116 | 117 | cedar_common.CelestialCoord coord = 3; 118 | optional Constellation constellation = 4; 119 | ObjectType object_type = 5; 120 | 121 | double magnitude = 6; // Apparent magnitude. 122 | optional string angular_size = 7; // Arc minutes. Usually numeric. 123 | 124 | optional string common_name = 8; // Albireo, Horsehead, Crab Nebula, etc. 125 | optional string notes = 9; 126 | } 127 | 128 | message CatalogDescription { 129 | string label = 1; // M, NGC, etc. 130 | string name = 2; // Messier, New General Catalog, etc. 131 | string description = 3; 132 | string source = 4; 133 | optional string copyright = 5; 134 | optional string license = 6; 135 | } 136 | 137 | message CatalogDescriptionResponse { 138 | repeated CatalogDescription catalog_descriptions = 1; 139 | } 140 | 141 | message ObjectType { 142 | string label = 1; // Nebula, galaxy, double star, etc. 143 | string broad_category = 2; // e.g. 'cluster', whereas label might be 144 | // 'open cluster' or 'globular cluster' etc. 145 | } 146 | 147 | message ObjectTypeResponse { 148 | repeated ObjectType object_types = 1; 149 | } 150 | 151 | message Constellation { 152 | string label = 1; // e.g. Psc. 153 | string name = 2; // e.g. Pisces. 154 | } 155 | 156 | message ConstellationResponse { 157 | repeated Constellation constellations = 1; 158 | } 159 | 160 | message CatalogEntryKey { 161 | string cat_label = 1; // Corresponds to catalog_description.label. PL, AST, 162 | // or COM. 163 | string entry = 2; // Corresponds to catalog_entry.entry field. 164 | } 165 | -------------------------------------------------------------------------------- /elements/src/proto/google/protobuf/duration.proto: -------------------------------------------------------------------------------- 1 | // Protocol Buffers - Google's data interchange format 2 | // Copyright 2008 Google Inc. All rights reserved. 3 | // https://developers.google.com/protocol-buffers/ 4 | // 5 | // Redistribution and use in source and binary forms, with or without 6 | // modification, are permitted provided that the following conditions are 7 | // met: 8 | // 9 | // * Redistributions of source code must retain the above copyright 10 | // notice, this list of conditions and the following disclaimer. 11 | // * Redistributions in binary form must reproduce the above 12 | // copyright notice, this list of conditions and the following disclaimer 13 | // in the documentation and/or other materials provided with the 14 | // distribution. 15 | // * Neither the name of Google Inc. nor the names of its 16 | // contributors may be used to endorse or promote products derived from 17 | // this software without specific prior written permission. 18 | // 19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | syntax = "proto3"; 32 | 33 | package google.protobuf; 34 | 35 | option cc_enable_arenas = true; 36 | option go_package = "google.golang.org/protobuf/types/known/durationpb"; 37 | option java_package = "com.google.protobuf"; 38 | option java_outer_classname = "DurationProto"; 39 | option java_multiple_files = true; 40 | option objc_class_prefix = "GPB"; 41 | option csharp_namespace = "Google.Protobuf.WellKnownTypes"; 42 | 43 | // A Duration represents a signed, fixed-length span of time represented 44 | // as a count of seconds and fractions of seconds at nanosecond 45 | // resolution. It is independent of any calendar and concepts like "day" 46 | // or "month". It is related to Timestamp in that the difference between 47 | // two Timestamp values is a Duration and it can be added or subtracted 48 | // from a Timestamp. Range is approximately +-10,000 years. 49 | // 50 | // # Examples 51 | // 52 | // Example 1: Compute Duration from two Timestamps in pseudo code. 53 | // 54 | // Timestamp start = ...; 55 | // Timestamp end = ...; 56 | // Duration duration = ...; 57 | // 58 | // duration.seconds = end.seconds - start.seconds; 59 | // duration.nanos = end.nanos - start.nanos; 60 | // 61 | // if (duration.seconds < 0 && duration.nanos > 0) { 62 | // duration.seconds += 1; 63 | // duration.nanos -= 1000000000; 64 | // } else if (duration.seconds > 0 && duration.nanos < 0) { 65 | // duration.seconds -= 1; 66 | // duration.nanos += 1000000000; 67 | // } 68 | // 69 | // Example 2: Compute Timestamp from Timestamp + Duration in pseudo code. 70 | // 71 | // Timestamp start = ...; 72 | // Duration duration = ...; 73 | // Timestamp end = ...; 74 | // 75 | // end.seconds = start.seconds + duration.seconds; 76 | // end.nanos = start.nanos + duration.nanos; 77 | // 78 | // if (end.nanos < 0) { 79 | // end.seconds -= 1; 80 | // end.nanos += 1000000000; 81 | // } else if (end.nanos >= 1000000000) { 82 | // end.seconds += 1; 83 | // end.nanos -= 1000000000; 84 | // } 85 | // 86 | // Example 3: Compute Duration from datetime.timedelta in Python. 87 | // 88 | // td = datetime.timedelta(days=3, minutes=10) 89 | // duration = Duration() 90 | // duration.FromTimedelta(td) 91 | // 92 | // # JSON Mapping 93 | // 94 | // In JSON format, the Duration type is encoded as a string rather than an 95 | // object, where the string ends in the suffix "s" (indicating seconds) and 96 | // is preceded by the number of seconds, with nanoseconds expressed as 97 | // fractional seconds. For example, 3 seconds with 0 nanoseconds should be 98 | // encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should 99 | // be expressed in JSON format as "3.000000001s", and 3 seconds and 1 100 | // microsecond should be expressed in JSON format as "3.000001s". 101 | // 102 | message Duration { 103 | // Signed seconds of the span of time. Must be from -315,576,000,000 104 | // to +315,576,000,000 inclusive. Note: these bounds are computed from: 105 | // 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years 106 | int64 seconds = 1; 107 | 108 | // Signed fractions of a second at nanosecond resolution of the span 109 | // of time. Durations less than one second are represented with a 0 110 | // `seconds` field and a positive or negative `nanos` field. For durations 111 | // of one second or more, a non-zero value for the `nanos` field must be 112 | // of the same sign as the `seconds` field. Must be from -999,999,999 113 | // to +999,999,999 inclusive. 114 | int32 nanos = 2; 115 | } 116 | -------------------------------------------------------------------------------- /elements/src/proto/google/protobuf/timestamp.proto: -------------------------------------------------------------------------------- 1 | // Protocol Buffers - Google's data interchange format 2 | // Copyright 2008 Google Inc. All rights reserved. 3 | // https://developers.google.com/protocol-buffers/ 4 | // 5 | // Redistribution and use in source and binary forms, with or without 6 | // modification, are permitted provided that the following conditions are 7 | // met: 8 | // 9 | // * Redistributions of source code must retain the above copyright 10 | // notice, this list of conditions and the following disclaimer. 11 | // * Redistributions in binary form must reproduce the above 12 | // copyright notice, this list of conditions and the following disclaimer 13 | // in the documentation and/or other materials provided with the 14 | // distribution. 15 | // * Neither the name of Google Inc. nor the names of its 16 | // contributors may be used to endorse or promote products derived from 17 | // this software without specific prior written permission. 18 | // 19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | syntax = "proto3"; 32 | 33 | package google.protobuf; 34 | 35 | option cc_enable_arenas = true; 36 | option go_package = "google.golang.org/protobuf/types/known/timestamppb"; 37 | option java_package = "com.google.protobuf"; 38 | option java_outer_classname = "TimestampProto"; 39 | option java_multiple_files = true; 40 | option objc_class_prefix = "GPB"; 41 | option csharp_namespace = "Google.Protobuf.WellKnownTypes"; 42 | 43 | // A Timestamp represents a point in time independent of any time zone or local 44 | // calendar, encoded as a count of seconds and fractions of seconds at 45 | // nanosecond resolution. The count is relative to an epoch at UTC midnight on 46 | // January 1, 1970, in the proleptic Gregorian calendar which extends the 47 | // Gregorian calendar backwards to year one. 48 | // 49 | // All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap 50 | // second table is needed for interpretation, using a [24-hour linear 51 | // smear](https://developers.google.com/time/smear). 52 | // 53 | // The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By 54 | // restricting to that range, we ensure that we can convert to and from [RFC 55 | // 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings. 56 | // 57 | // # Examples 58 | // 59 | // Example 1: Compute Timestamp from POSIX `time()`. 60 | // 61 | // Timestamp timestamp; 62 | // timestamp.set_seconds(time(NULL)); 63 | // timestamp.set_nanos(0); 64 | // 65 | // Example 2: Compute Timestamp from POSIX `gettimeofday()`. 66 | // 67 | // struct timeval tv; 68 | // gettimeofday(&tv, NULL); 69 | // 70 | // Timestamp timestamp; 71 | // timestamp.set_seconds(tv.tv_sec); 72 | // timestamp.set_nanos(tv.tv_usec * 1000); 73 | // 74 | // Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`. 75 | // 76 | // FILETIME ft; 77 | // GetSystemTimeAsFileTime(&ft); 78 | // UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime; 79 | // 80 | // // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z 81 | // // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z. 82 | // Timestamp timestamp; 83 | // timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL)); 84 | // timestamp.set_nanos((INT32) ((ticks % 10000000) * 100)); 85 | // 86 | // Example 4: Compute Timestamp from Java `System.currentTimeMillis()`. 87 | // 88 | // long millis = System.currentTimeMillis(); 89 | // 90 | // Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000) 91 | // .setNanos((int) ((millis % 1000) * 1000000)).build(); 92 | // 93 | // Example 5: Compute Timestamp from Java `Instant.now()`. 94 | // 95 | // Instant now = Instant.now(); 96 | // 97 | // Timestamp timestamp = 98 | // Timestamp.newBuilder().setSeconds(now.getEpochSecond()) 99 | // .setNanos(now.getNano()).build(); 100 | // 101 | // Example 6: Compute Timestamp from current time in Python. 102 | // 103 | // timestamp = Timestamp() 104 | // timestamp.GetCurrentTime() 105 | // 106 | // # JSON Mapping 107 | // 108 | // In JSON format, the Timestamp type is encoded as a string in the 109 | // [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the 110 | // format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z" 111 | // where {year} is always expressed using four digits while {month}, {day}, 112 | // {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional 113 | // seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution), 114 | // are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone 115 | // is required. A proto3 JSON serializer should always use UTC (as indicated by 116 | // "Z") when printing the Timestamp type and a proto3 JSON parser should be 117 | // able to accept both UTC and other timezones (as indicated by an offset). 118 | // 119 | // For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past 120 | // 01:30 UTC on January 15, 2017. 121 | // 122 | // In JavaScript, one can convert a Date object to this format using the 123 | // standard 124 | // [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) 125 | // method. In Python, a standard `datetime.datetime` object can be converted 126 | // to this format using 127 | // [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with 128 | // the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use 129 | // the Joda Time's [`ISODateTimeFormat.dateTime()`]( 130 | // http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime() 131 | // ) to obtain a formatter capable of generating timestamps in this format. 132 | // 133 | message Timestamp { 134 | // Represents seconds of UTC time since Unix epoch 135 | // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to 136 | // 9999-12-31T23:59:59Z inclusive. 137 | int64 seconds = 1; 138 | 139 | // Non-negative fractions of a second at nanosecond resolution. Negative 140 | // second values with fractions must still have non-negative nanos values 141 | // that count forward in time. Must be from 0 to 999,999,999 142 | // inclusive. 143 | int32 nanos = 2; 144 | } 145 | -------------------------------------------------------------------------------- /elements/src/reservoir_sampler.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use rand::{Rng, SeedableRng}; 5 | use rand::rngs::SmallRng; 6 | 7 | pub struct ReservoirSampler { 8 | reservoir: Vec, 9 | capacity: usize, 10 | rng: SmallRng, 11 | add_count: usize, 12 | } 13 | 14 | impl ReservoirSampler { 15 | pub fn new(capacity: usize) -> Self { 16 | ReservoirSampler { 17 | reservoir: Vec::with_capacity(capacity), 18 | capacity, 19 | rng: SmallRng::seed_from_u64(42), 20 | add_count: 0, 21 | } 22 | } 23 | 24 | // Returns: 25 | // bool: whether the item was added to the ReservoirSampler. 26 | // Option: populated if an item was removed from the ReservoirSampler. 27 | pub fn add(&mut self, item: T) -> (bool, Option) { 28 | self.add_count += 1; 29 | if self.reservoir.len() < self.capacity { 30 | self.reservoir.push(item); 31 | return (true, None); 32 | } 33 | let j = self.rng.gen_range(0..self.add_count); 34 | if j >= self.capacity { 35 | return (false, None); 36 | } 37 | // Replace: keep new sample and return the disarded sample. 38 | (true, Some(std::mem::replace(&mut self.reservoir[j], item))) 39 | } 40 | 41 | pub fn count(&self) -> usize { 42 | self.reservoir.len() 43 | } 44 | 45 | pub fn samples(&self) -> &Vec { 46 | &self.reservoir 47 | } 48 | 49 | // Resets as if newly constructed. 50 | pub fn clear(&mut self) { 51 | self.reservoir.clear(); 52 | self.add_count = 0; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /elements/src/solver_trait.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use std::time::Duration; 5 | 6 | use canonical_error::CanonicalError; 7 | use tonic::async_trait; 8 | 9 | use crate::cedar_common::CelestialCoord; 10 | use crate::cedar::{ImageCoord, PlateSolution}; 11 | 12 | #[derive(Debug, Default)] 13 | pub struct SolveExtension { 14 | // See tetra3.py for descriptions of fields. 15 | pub target_pixel: Option>, 16 | pub target_sky_coord: Option>, 17 | pub return_matches: bool, 18 | pub return_catalog: bool, 19 | pub return_rotation_matrix: bool, 20 | } 21 | 22 | #[derive(Debug, Default)] 23 | pub struct SolveParams { 24 | // See tetra3.py for descriptions of fields. 25 | 26 | // Estimated horizontal field of view, and the maximum tolerance (both in 27 | // degrees). None means solve blindly over the span of FOVs supported by 28 | // the pattern database. 29 | pub fov_estimate: Option<(f64, f64)>, 30 | 31 | pub match_radius: Option, // Defaults to 0.01. 32 | pub match_threshold: Option, // Defaults to 1e-5. 33 | pub solve_timeout: Option, // Default determined by implementation. 34 | pub distortion: Option, 35 | pub match_max_error: Option, // Defaults to pattern_max_error from database. 36 | } 37 | 38 | // See tetra3.py in cedar-solve for description of args. 39 | // If SolveResult is not returned, an error is returned: 40 | // NotFound: no match was found. 41 | // DeadlineExceeded: the params.solve_timeout was reached. 42 | // DeadlineExceeded: the solve operation was canceled. This should return 43 | // CancelledError, but CanonicalError does not provide that. 44 | // InvalidArgument: too few centroids were provided. 45 | #[async_trait] 46 | pub trait SolverTrait { 47 | // Note: this can take up to several seconds in the Python/Numpy 48 | // implementation (50ms typical). 49 | async fn solve_from_centroids(&self, 50 | star_centroids: &Vec, 51 | width: usize, height: usize, 52 | extension: &SolveExtension, 53 | params: &SolveParams) 54 | -> Result; 55 | 56 | // Requests that the current solve_from_centroids() operation, if any, 57 | // terminate soon. Returns without waiting for the cancel to take effect. 58 | fn cancel(&self); 59 | 60 | // Returns the default SolveParams::solve_timeout value. 61 | fn default_timeout(&self) -> Duration; 62 | 63 | // Note: Equivalents for Tetra3's transform_to_image_coords() and 64 | // transform_to_celestial_coords() can be found in 65 | // cedar_server/src/astro_util.rs. 66 | } 67 | -------------------------------------------------------------------------------- /elements/src/value_stats.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use medians::Medianf64; 5 | use rolling_stats; 6 | use statistical; 7 | 8 | use crate::cedar; 9 | 10 | pub struct ValueStatsAccumulator { 11 | pub value_stats: cedar::ValueStats, 12 | 13 | // State for `recent`. 14 | circular_buffer: CircularBuffer, 15 | 16 | // State for `session`. 17 | rolling_stats: rolling_stats::Stats, 18 | } 19 | 20 | impl ValueStatsAccumulator { 21 | pub fn new(capacity: usize) -> Self { 22 | Self { 23 | value_stats: cedar::ValueStats { 24 | recent: Some(cedar::DescriptiveStats{..Default::default()}), 25 | session: Some(cedar::DescriptiveStats{..Default::default()}), 26 | }, 27 | circular_buffer: CircularBuffer::new(capacity), 28 | rolling_stats: rolling_stats::Stats::::new(), 29 | } 30 | } 31 | 32 | pub fn add_value(&mut self, value: f64) { 33 | self.circular_buffer.push(value); 34 | self.rolling_stats.update(value); 35 | 36 | let recent_values = self.circular_buffer.unordered_contents(); 37 | let recent_stats = self.value_stats.recent.as_mut().unwrap(); 38 | recent_stats.min = 39 | *recent_values.iter().min_by(|a, b| a.total_cmp(b)).unwrap(); 40 | recent_stats.max = 41 | *recent_values.iter().max_by(|a, b| a.total_cmp(b)).unwrap(); 42 | recent_stats.mean = statistical::mean(recent_values); 43 | if recent_values.len() > 1 { 44 | recent_stats.stddev = statistical::standard_deviation( 45 | recent_values, Some(recent_stats.mean)); 46 | } 47 | recent_stats.median = Some(recent_values.medf_unchecked()); 48 | recent_stats.median_absolute_deviation = 49 | Some(recent_values.madf(recent_stats.median.unwrap())); 50 | 51 | let session_stats = self.value_stats.session.as_mut().unwrap(); 52 | session_stats.min = self.rolling_stats.min; 53 | session_stats.max = self.rolling_stats.max; 54 | session_stats.mean = self.rolling_stats.mean; 55 | session_stats.stddev = self.rolling_stats.std_dev; 56 | // No median or median_absolute_deviation for session_stats. 57 | } 58 | 59 | pub fn reset_session(&mut self) { 60 | self.value_stats.session = Some(cedar::DescriptiveStats{..Default::default()}); 61 | self.rolling_stats = rolling_stats::Stats::::new(); 62 | } 63 | } 64 | 65 | // We use a Vec to implement a ring buffer. We don't use VecDeque or 66 | // similar because we want a view of all elements as a single slice, and we 67 | // don't care about their order (VecDeque provides a slice view, but as two 68 | // slices to represent ordering). 69 | // 70 | // Implementation adapted from 71 | // https://stackoverflow.com/questions/67841977/which-rust-structure-does-this 72 | #[derive(Debug)] 73 | struct CircularBuffer { 74 | start: usize, 75 | data: Vec, 76 | } 77 | 78 | impl CircularBuffer { 79 | pub fn new(capacity: usize) -> Self { 80 | Self { 81 | start: 0, 82 | data: Vec::with_capacity(capacity), 83 | } 84 | } 85 | 86 | pub fn push(&mut self, item: f64) { 87 | if self.data.len() < self.data.capacity() { 88 | self.data.push(item); 89 | } else { 90 | self.data[self.start] = item; 91 | self.start += 1; 92 | self.start %= self.data.capacity(); 93 | } 94 | } 95 | 96 | pub fn unordered_contents(&self) -> &[f64] { 97 | self.data.as_slice() 98 | } 99 | } 100 | 101 | #[cfg(test)] 102 | mod tests { 103 | extern crate approx; 104 | use approx::assert_abs_diff_eq; 105 | use super::*; 106 | 107 | #[test] 108 | fn test_circular_buffer() { 109 | let mut cb = CircularBuffer::new(3); 110 | assert_eq!(cb.unordered_contents(), &[] as &[f64]); 111 | 112 | cb.push(4.0); 113 | assert_eq!(cb.unordered_contents(), [4.0]); 114 | 115 | cb.push(5.0); 116 | cb.push(6.0); 117 | assert_eq!(cb.unordered_contents(), [4.0, 5.0, 6.0]); 118 | 119 | cb.push(7.0); 120 | assert_eq!(cb.unordered_contents(), [7.0, 5.0, 6.0]); 121 | } 122 | 123 | #[test] 124 | fn test_value_stats_accumulator() { 125 | let mut vsa = ValueStatsAccumulator::new(3); 126 | 127 | // Empty accumulator (just constructed). 128 | let recent = vsa.value_stats.recent.as_ref().unwrap(); 129 | assert_eq!(recent.min, 0.0); 130 | assert_eq!(recent.max, 0.0); 131 | assert_eq!(recent.mean, 0.0); 132 | assert_eq!(recent.stddev, 0.0); 133 | assert_eq!(recent.median, None); 134 | assert_eq!(recent.median_absolute_deviation, None); 135 | let session = vsa.value_stats.session.as_ref().unwrap(); 136 | assert_eq!(session.min, 0.0); 137 | assert_eq!(session.max, 0.0); 138 | assert_eq!(session.mean, 0.0); 139 | assert_eq!(session.stddev, 0.0); 140 | assert_eq!(session.median, None); 141 | assert_eq!(session.median_absolute_deviation, None); 142 | 143 | vsa.add_value(1.5); 144 | vsa.add_value(3.5); 145 | let recent = vsa.value_stats.recent.as_ref().unwrap(); 146 | assert_eq!(recent.min, 1.5); 147 | assert_eq!(recent.max, 3.5); 148 | assert_eq!(recent.mean, 2.5); 149 | assert_abs_diff_eq!(recent.stddev, 1.41, epsilon = 0.01); 150 | assert_eq!(recent.median, Some(2.5)); 151 | assert_eq!(recent.median_absolute_deviation, Some(1.0)); 152 | let session = vsa.value_stats.session.as_ref().unwrap(); 153 | assert_eq!(session.min, 1.5); 154 | assert_eq!(session.max, 3.5); 155 | assert_eq!(session.mean, 2.5); 156 | assert_abs_diff_eq!(session.stddev, 1.41, epsilon = 0.01); 157 | assert_eq!(session.median, None); 158 | assert_eq!(session.median_absolute_deviation, None); 159 | 160 | // reset_session() clears session stats but not recent stats. 161 | vsa.reset_session(); 162 | let recent = vsa.value_stats.recent.as_ref().unwrap(); 163 | assert_eq!(recent.min, 1.5); 164 | assert_eq!(recent.max, 3.5); 165 | assert_eq!(recent.mean, 2.5); 166 | assert_abs_diff_eq!(recent.stddev, 1.41, epsilon = 0.01); 167 | assert_eq!(recent.median, Some(2.5)); 168 | assert_eq!(recent.median_absolute_deviation, Some(1.0)); 169 | let session = vsa.value_stats.session.as_ref().unwrap(); 170 | assert_eq!(session.min, 0.0); 171 | assert_eq!(session.max, 0.0); 172 | assert_eq!(session.mean, 0.0); 173 | assert_eq!(session.stddev, 0.0); 174 | assert_eq!(session.median, None); 175 | assert_eq!(session.median_absolute_deviation, None); 176 | } 177 | 178 | } // mod tests. 179 | -------------------------------------------------------------------------------- /elements/src/wifi_trait.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use canonical_error::CanonicalError; 5 | 6 | pub trait WifiTrait { 7 | fn channel(&self) -> i32; 8 | fn ssid(&self) -> String; 9 | fn psk(&self) -> String; 10 | 11 | /// Updates the specified fields of this WiFi access point. Passing 12 | /// 'None' leaves the corresponding field unmodified. 13 | fn update_access_point(&mut self, 14 | channel: Option, 15 | ssid: Option<&str>, 16 | psk: Option<&str>) -> Result<(), CanonicalError>; 17 | } 18 | -------------------------------------------------------------------------------- /notes.txt: -------------------------------------------------------------------------------- 1 | How to automatically set the 'sigma' parameter for Cedar-detect? Idea: acquire a 2 | test image, use Cedar-detect with a default sigma value to find stars, and run 3 | Tetra3 to plate solve it. 4 | 5 | Next, using the same test image, run Cedar-detect using a sweep of sigma values. 6 | For each sigma value evaluate the star candidates as follows: 7 | * Using the plate solution, convert the candidate's x/y to ra/dec and look 8 | for a sufficiently nearby star in a suitably magnitude-limited catalogue. 9 | * Tally up the number of star candidates S that correspond to a catalogue star 10 | vs the number of star candidates N that do not match the catalogue. 11 | * F = N / (S + N) is the fraction of Cedar-detect candidates that are spurious 12 | (noise-induced) detections. 13 | 14 | When sigma is high, F (fraction of spurious candidates) will be low. As sigma 15 | is lowered, S + N will increase (overall more Cedar-detect candidates) but the 16 | fraction F of bad candidates will also rise. 17 | 18 | We can define a maximum tolerable F value (say 0.01?) and use this to determine 19 | the minimum sigma value to be used when the slider is towards "speed". When the 20 | slider is moved toward "quality" we can raise the sigma value along with the 21 | exposure times). 22 | 23 | 24 | Feedback in focus mode: we display the number of detected stars using a slider 25 | for a bar graph. Improvements to consider: 26 | 27 | * Over a window of the most recent N frames, determine which stars are detected 28 | in (nearly) all of the frames (based on x/y proximity between frames). Display 29 | the count of these "robustly detected" stars. This count will not include stars 30 | at the edge of detectability nor false positives (noise). 31 | -------------------------------------------------------------------------------- /polar_align.md: -------------------------------------------------------------------------------- 1 | # Polar alignment advice 2 | 3 | Cedar can help you polar align your clock-driven equatorial mount. It does 4 | this by telling you how far you need to raise or lower your telescope's polar 5 | axis, and how much you need to move the polar axis to the left or right. 6 | 7 | Currently this feature is a bit of an Easter egg; a future version of Cedar will 8 | make the polar alignment feature more discoverable. 9 | 10 | ## Prerequisites 11 | 12 | In order to enable Cedar's polar alignment support, the following conditions 13 | must all be met: 14 | 15 | * Cedar must know your geographic location. Open Cedar Aim's menu, and look for 16 | the item that is either "Location unknown" or "Location `` ``". If the 17 | location is unknown, tap on it to bring up a world map to enter your 18 | approximate location. 19 | 20 | * Your telescope mount must be equatorial type and must be clock-driven at 21 | sidereal rate. 22 | 23 | * Your telescope mount must be roughly polar aligned, say to within 15 or 20 24 | degrees of the celestial pole in both altitude and azimuth. 25 | 26 | * Cedar must be seeing the sky and producing plate solutions. 27 | 28 | When these prerequisites are met, you can request polar alignment advice. You do 29 | this separately for azimuth (adjusting polar axis left or right) and altitude 30 | (adjusting polar axis up or down), as described next. 31 | 32 | ## Triggering polar alignment advice 33 | 34 | ### Azimuth 35 | 36 | To find out whether you need to move your mount's polar axis to the left or to 37 | the right, you trigger Cedar's polar alignment azimuth advice. You do this by 38 | pointing your telescope to the celestial equator (zero declination, plus or 39 | minus 15 degrees), and to the meridian (zero hour angle, plus or minus one 40 | hour). Note that the hour angle is given under the RA/Dec values on Cedar's aim 41 | mode screen. 42 | 43 | Once you've moved your telescope to the zone around the meridian at the 44 | celestial equator, leave the telescope motionless (aside from the clock drive). 45 | Within a few seconds Polar Align "az" information will appear, with an estimate 46 | of the number of degrees left or right that you need to move your mount's polar 47 | axis. The longer you let the telescope sit the more accurate the estimate will 48 | become. 49 | 50 | Move your polar axis left or right by the indicated amount, and wait again to 51 | see if the azimuth error has diminished. You can easily achieve a small fraction 52 | of a degree error within a minute or so. 53 | 54 | ### Altitude 55 | 56 | To find out whether you need to move your mount's polar axis up or down, you 57 | trigger Cedar's polar alignment altitude advice. You do this by pointing your 58 | telescope along the celestial equator, either near the east horizon or near the 59 | west horizon (within two hours in each case). 60 | 61 | Now leave the telescope motionless, and soon you'll see Polar Align information, 62 | this time with the "alt" error estimate. Wait a bit for the estimate to firm up, 63 | then move your mount's polar axis up or down by the indicated amount. Repeat a 64 | couple of times until the altitude error has been reduced to near zero. 65 | 66 | At this point you might want to repeat the Azimuth polar alignment (pointing at 67 | celestial equator at meridian); it might have changed a bit due to the polar 68 | altitude adjustment. 69 | -------------------------------------------------------------------------------- /release_notes.md: -------------------------------------------------------------------------------- 1 | # July 2024 2 | 3 | Cedar-server version: 0.1.0 4 | 5 | Initial public release. 6 | 7 | # August 28 2024 8 | 9 | Cedar-server version: 0.2.0 10 | 11 | Major updates: 12 | 13 | * Expand saved preferences to cover more items, such as observer location. 14 | 15 | * Fix "white flash" that occurs when slewing. 16 | 17 | * Improve SkySafari integration: 18 | * Enable "Sync" feature 19 | * Accept observer geolocation information 20 | 21 | # September 7 2024 22 | 23 | Cedar-server version: 0.3.0 24 | 25 | Major updates: 26 | 27 | * New "daylight" alignment mode. During the day, point the telescope at a 28 | distant landmark, tap Cedar's new "zoom" button, and then tap on the screen 29 | to tell Cedar what the telescope is aimed at. 30 | 31 | * Basic vs. advanced functionality. 32 | 33 | * Removed exposure time control. This is now entirely automatic. 34 | 35 | * Text size setting. 36 | 37 | * Improved night vision theme. Deeper reds, darker panel backgrounds. 38 | 39 | * Replaced slider for showing number of detected stars with a circular gauge. 40 | Tapping the gauge brings up performance stats. 41 | 42 | * In setup screen, added enable/disable for focus aids. 43 | 44 | # September 21 2024 45 | 46 | Cedar-server version: 0.4.0 47 | 48 | Major updates: 49 | 50 | * 'About' screen giving information about Cedar server and calibration 51 | results. 52 | 53 | * Minor UI improvements such as better tap target sizes. 54 | 55 | * UI alert for lack of server connectivity or lack of detected camera. 56 | 57 | * Improved menu item styling. 58 | 59 | * Listen on port 80 (and also 8080 as before). 60 | 61 | * "Demo mode" allowing selectable image files to be used instead of camera. 62 | 63 | * Use a "run" directory instead of "src" directory. 64 | 65 | * Use Raspberry Pi activity LED to convey Cedar status. 66 | * blinking: waiting for client to connect 67 | * off: client is connected 68 | 69 | When Cedar is not running, the activity LED reverts to reflecting 70 | SD card activity 71 | 72 | * Add restart option in addition to shutdown. 73 | 74 | # October 12 2024 75 | 76 | Cedar-server version: 0.5.0 77 | 78 | * Add preference for inverting camera image (rotate 180degrees). 79 | 80 | * Fix exposure calibration logic bug. 81 | 82 | * Telescope boresight no longer restricted to central third of image. 83 | 84 | * Redesign setup mode: focus, then align. Focus and align have 85 | on-screen prompts; align highlights 3 brightest detections. 86 | 87 | * Daylight mode is now applicable to focus as well as align. 88 | 89 | * Remove speed/accuracy slider. 90 | 91 | * Fix push-to directions layout problems. 92 | 93 | # November 15 2024 94 | 95 | Cedar-server version: 0.6.0 96 | 97 | * Improve server and network reliability. 98 | 99 | * Adjust camera gain in daytime focus and align modes. 100 | 101 | * Fix bug leading to very long calibration delays. 102 | 103 | * Change to rolling logs. 104 | 105 | # November 17 2024 106 | 107 | Cedar-server version: 0.6.1 108 | 109 | * Bug fixes. 110 | 111 | # December 15 2024 112 | 113 | Cedar-server version: 0.7.0 114 | 115 | * Preference for left vs right handed control placement. 116 | 117 | * Compact layout for sky location display. 118 | * Can tap to bring up comprehensive information. 119 | * Can designate preferred display: ra/dec vs alt/az 120 | 121 | * Add preference for screen always on (app only). 122 | 123 | * In Setup alignment mode, rotate displayed image to orient zenith up. 124 | 125 | * Pinch zoom to change text size. 126 | 127 | * Eliminate network-releated hangs (hopefully) trying to fetch fonts. 128 | 129 | * Add star count and image noise to performance popup. 130 | 131 | * Fix missing alt/az vs. equatorial mount in preferences. 132 | 133 | # January 7 2025 134 | 135 | Cedar-server version: 0.8.0 136 | 137 | * Fix bugs in calibration cancellation logic. 138 | 139 | * Remove camera temperature attribute. 140 | 141 | * Fix bug in Rpi camera logic regarding discarding images after setting change. 142 | 143 | * In align screen, ensure that bright star is highlighted as alignment target 144 | even if it is so overexposed that it is not detected as a star. 145 | 146 | * Fix bug where activity light would blink when Cedar Aim is inactive because 147 | user is interacting instead with SkySafari. 148 | 149 | * Reduce geolocation map resolution. 150 | 151 | # January 13 2025 152 | 153 | Cedar-server version: 0.8.1 154 | 155 | * Fix bug that was hiding geolocation button. 156 | 157 | * Update camera logic for Rpi5 compressed raw. 158 | 159 | # February 15 2025 160 | 161 | Cedar-server version: 0.9.0 162 | 163 | * Fixes for high resolution camera modules such as the RPi HQ camera: 164 | * When downsampling image for display on phone, preserve star brightness. 165 | * Fix hit testing in alignment screen to make it easier to select 166 | alignment star. 167 | * Adjust size of focus assist inset image. 168 | 169 | * Improve threading and locking. 170 | 171 | * Fix bugs causing 100% CPU usage even when updating at low frequency. 172 | 173 | * Fix TelescopePosition implementation to avoid "split updates" in 174 | SkySafari, eliminating occasional spurious position jumps. 175 | 176 | * When calibrating plate solver, use longer exposure to get more stars 177 | for improved FOV/distortion estimates. 178 | 179 | * Goto mode: 180 | * Show "Re-align" button only when close to target. 181 | * Adjust slew directions text block placement. 182 | * Add small icons for up/down and clockwise/counterclockwise. 183 | 184 | * Reorganize Rust package structure under cedar-server component. 185 | 186 | * Refactor Tetra3 dependency. 187 | * Move subprocess management into tetra3_server directory. 188 | * Implement new SolverTrait to use Tetra3 (Cedar-solve). 189 | * Update protobuf types so that Cedar does not directly depend 190 | on Tetra3 protobuf types. 191 | 192 | # February 20 2025 193 | 194 | Cedar-server version: 0.9.1 195 | 196 | * Fixes bug where plate solve failures could cause SkySafari to 197 | stop getting updates until Cedar Aim app is resumed. 198 | 199 | * Increases maximum exposure time for color cameras to 2 seconds. 200 | 201 | # May 26 2025 202 | 203 | Cedar-server version: 0.9.3 204 | 205 | * Update plate solver match_threshold to 1e-5, reducing the chance of 206 | a false solution. 207 | 208 | * Fix hanging behavior in solver. 209 | 210 | * Improve auto-exposure algorithms. 211 | 212 | * Always rotate image to zenith up when not in focus or daytime align mode. 213 | 214 | * If calibration fails, stay in current mode (focus or daytime align). 215 | 216 | * Other calibration fixes. 217 | 218 | * Add interstitial explanation screens for startup, focus, and align. 219 | 220 | * Broaden target tap tolerance in align mode. 221 | 222 | * Increase text sizes, tweak layouts. 223 | 224 | * Improve formatting of push-to directions. 225 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check for the --release flag 4 | if [[ "$1" == "--release" ]]; then 5 | release_flag="--release" 6 | shift 7 | fi 8 | 9 | ./build.sh $release_flag 10 | 11 | . ../cedar-solve/.cedar_venv/bin/activate 12 | cd run 13 | 14 | # Start the binary we just built. 15 | ../cedar/bin/cedar-box-server "$@" 16 | -------------------------------------------------------------------------------- /run/demo_images/bright_star_align.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smroid/cedar-server/966b61671c0bd3d68b3292afd0b8ba289a30ffa0/run/demo_images/bright_star_align.jpg -------------------------------------------------------------------------------- /run/demo_images/daytime_align.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smroid/cedar-server/966b61671c0bd3d68b3292afd0b8ba289a30ffa0/run/demo_images/daytime_align.jpg -------------------------------------------------------------------------------- /run/demo_images/readme.txt: -------------------------------------------------------------------------------- 1 | Images in this directory are available to view in Cedar Aim's demo 2 | mode. 3 | -------------------------------------------------------------------------------- /server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cedar-server" 3 | version = "0.9.3" 4 | edition = "2021" 5 | 6 | [[bin]] # Bin to run the Cedar Box gRPC server. 7 | name = "cedar-box-server" 8 | path = "src/bin/cedar_box_server.rs" 9 | 10 | [dependencies] 11 | approx = "0.5.1" 12 | ascom-alpaca = { version = "1.0.0-beta.3", features = ["server", "telescope"] } 13 | async-trait = "0.1.77" 14 | axum = "0.6.20" 15 | canonical-error = "0.1.0" 16 | cedar-camera = { version = "0.4.0", path = "../../cedar-camera" } 17 | cedar_detect = { version = "0.8.0", path = "../../cedar-detect" } 18 | cedar-elements = { path = "../elements" } 19 | chrono = "0.4.31" 20 | clap = "4.5.23" 21 | ctrlc = "3.4.2" 22 | env_logger = "0.11.5" 23 | futures = "0.3.30" 24 | glob = "0.3.1" 25 | hyper = { version = "0.14.31", features = ["http1", "http2"] } 26 | image = "0.25.2" 27 | imageproc = "0.25.0" 28 | log = "0.4.19" 29 | nix = { version = "0.28.0", features = ["time"] } 30 | pico-args = "0.5.0" 31 | prost = "0.12.3" 32 | prost-types = "0.12.3" 33 | tetra3_server = { version = "0.1.0", path = "../../tetra3_server" } 34 | tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] } 35 | tonic = "0.11" 36 | tonic-web = "0.11.0" 37 | tower = { version = "0.4.13", features = ["full"] } 38 | tower-http = { version = "0.4.3", features = ["fs", "cors"] } 39 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 40 | tracing-appender = "0.2.3" 41 | -------------------------------------------------------------------------------- /server/src/activity_led.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use std::fs; 5 | use std::sync::{Arc, Mutex}; 6 | use std::sync::atomic::{AtomicBool, Ordering}; 7 | use std::thread::{JoinHandle, sleep, spawn}; 8 | use std::time::{Duration, SystemTime}; 9 | 10 | pub struct ActivityLed { 11 | // Our state, shared between ActivityLed methods and the worker thread. 12 | state: Arc>, 13 | 14 | // Executes worker(). 15 | worker_thread: Option>, 16 | } 17 | 18 | // State shared between worker thread and the ActivityLed methods. 19 | struct SharedState { 20 | // Set by stop(); the worker thread exits when it sees this. 21 | stop_request: bool, 22 | 23 | // Set by received_rpc(). 24 | received_rpc: bool, 25 | } 26 | 27 | // The ActivityLed controls the state of the Raspberry Pi activity LED. By 28 | // default, this LED is configured by the Rpi to indicated system "disk" 29 | // activity. 30 | // 31 | // When ActivityLed is constructed, it takes over the Raspberry Pi activity LED 32 | // and manages it in three states: 33 | // 34 | // * Idle: the LED is blinked on and off at 1hz. This occurs when ActivityLed 35 | // has been created but received_rpc() has not not been called recently. 36 | // * Connected: the LED is turned off. This occurs when received_rpc() is being 37 | // called often enough. 38 | // * Released: The LED is re-configured back to the Raspberry Pi default, where 39 | // it indicates "disk" activity. This occurs when the stop() method is called. 40 | 41 | impl ActivityLed { 42 | // Initiates the activity LED to blinking at 1hz. 43 | pub fn new(got_signal: Arc) -> Self { 44 | let mut activity_led = ActivityLed{ 45 | state: Arc::new(Mutex::new( 46 | SharedState{ 47 | stop_request: false, 48 | received_rpc: false, 49 | })), 50 | worker_thread: None, 51 | }; 52 | let cloned_state = activity_led.state.clone(); 53 | let cloned_got_signal = got_signal.clone(); 54 | activity_led.worker_thread = 55 | Some(spawn(|| { 56 | ActivityLed::worker(cloned_state, cloned_got_signal); 57 | })); 58 | activity_led 59 | } 60 | 61 | // Indicates that Cedar has received an RPC from a client. We turn the 62 | // activity LED off; if too much time occurs without received_rpc() being 63 | // called again, we will resume blinking the LED at 1hz. 64 | pub fn received_rpc(&self) { 65 | self.state.lock().unwrap().received_rpc = true; 66 | } 67 | 68 | // Releases the activity LED back to its OS-defined "disk" activity 69 | // indicator. Currently there is no way to transition out of the released 70 | // state after stop() is called. 71 | pub fn stop(&mut self) { 72 | self.state.lock().unwrap().stop_request = true; 73 | self.worker_thread.take().unwrap().join().unwrap(); 74 | } 75 | 76 | fn worker(state: Arc>, got_signal: Arc) { 77 | // Raspberry Pi 5 reverses the control signal to the ACT led. 78 | let processor_model = 79 | fs::read_to_string("/sys/firmware/devicetree/base/model").unwrap() 80 | .trim_end_matches('\0').to_string(); 81 | let is_rpi5 = processor_model.contains("Raspberry Pi 5"); 82 | let off_value = if is_rpi5 { "1" } else { "0" }; 83 | let on_value = if is_rpi5 { "0" } else { "1" }; 84 | 85 | // https://www.jeffgeerling.com/blogs/jeff-geerling/controlling-pwr-act-leds-raspberry-pi 86 | let brightness_path = "/sys/class/leds/ACT/brightness"; 87 | let trigger_path = "/sys/class/leds/ACT/trigger"; 88 | 89 | let blink_delay = Duration::from_millis(500); 90 | 91 | // How often we look for received_rpc() when we're in the ConnectedOff state. 92 | let connected_poll = Duration::from_millis(500); 93 | 94 | // How long we can go without received_rpc() before we revert to Idle 95 | // state. 96 | let connected_timeout = Duration::from_secs(5); 97 | 98 | let mut last_rpc_time = SystemTime::now(); 99 | 100 | enum LedState { 101 | IdleOff, 102 | IdleOn, 103 | ConnectedOff, 104 | } 105 | let mut led_state = LedState::IdleOff; 106 | fs::write(brightness_path, off_value).unwrap(); 107 | 108 | fn process_received_rpc(state: &Arc>, 109 | last_rpc_time: &mut SystemTime) -> bool { 110 | let mut locked_state = state.lock().unwrap(); 111 | let received_rpc = locked_state.received_rpc; 112 | if received_rpc { 113 | *last_rpc_time = SystemTime::now(); 114 | locked_state.received_rpc = false; 115 | } 116 | received_rpc 117 | } 118 | 119 | loop { 120 | if state.lock().unwrap().stop_request { 121 | break; 122 | } 123 | if got_signal.load(Ordering::Relaxed) { 124 | break; 125 | } 126 | match led_state { 127 | LedState::IdleOff => { 128 | sleep(blink_delay); 129 | if process_received_rpc(&state, &mut last_rpc_time) { 130 | fs::write(brightness_path, off_value).unwrap(); 131 | led_state = LedState::ConnectedOff; 132 | continue; 133 | } 134 | fs::write(brightness_path, on_value).unwrap(); 135 | led_state = LedState::IdleOn; 136 | }, 137 | LedState::IdleOn => { 138 | sleep(blink_delay); 139 | if process_received_rpc(&state, &mut last_rpc_time) { 140 | fs::write(brightness_path, off_value).unwrap(); 141 | led_state = LedState::ConnectedOff; 142 | continue; 143 | } 144 | fs::write(brightness_path, off_value).unwrap(); 145 | led_state = LedState::IdleOff; 146 | }, 147 | LedState::ConnectedOff => { 148 | sleep(connected_poll); 149 | if process_received_rpc(&state, &mut last_rpc_time) { 150 | continue; 151 | } 152 | let elapsed = SystemTime::now().duration_since(last_rpc_time); 153 | if let Err(_e) = elapsed { 154 | // This can happen when the client sends a time update 155 | // to Cedar server. 156 | last_rpc_time = SystemTime::now(); // Start countdown fresh. 157 | } else { 158 | if *elapsed.as_ref().unwrap() > connected_timeout { 159 | // Revert to Idle state. 160 | fs::write(brightness_path, on_value).unwrap(); 161 | led_state = LedState::IdleOn; 162 | } 163 | } 164 | }, 165 | }; 166 | } 167 | // Revert LED back to system default state (disk activity). 168 | fs::write(trigger_path, "mmc0").unwrap(); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /server/src/bin/cedar_box_server.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use pico_args::Arguments; 5 | 6 | use cedar_server::cedar_server::server_main; 7 | 8 | fn main() { 9 | server_main( 10 | "Cedar-Box", 11 | "Copyright (c) 2024 Steven Rosenthal smr@dt3.org.\n\ 12 | Licensed for non-commercial use.\n\ 13 | See LICENSE.md at https://github.com/smroid/cedar-server", 14 | /*flutter_app_path=*/"../cedar/cedar-aim/cedar_flutter/build/web", 15 | /*get_dependencies=*/ 16 | |_pargs: Arguments| { (None, None, None) }); 17 | } 18 | -------------------------------------------------------------------------------- /server/src/bin/test_image_rotate.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use std::path::PathBuf; 5 | use std::time::Instant; 6 | 7 | use clap::Parser; 8 | use env_logger; 9 | use image::ImageReader; 10 | use log::{info, warn}; 11 | 12 | use cedar_elements::image_utils::ImageRotator; 13 | 14 | /// Test program for rotating an image. 15 | #[derive(Parser, Debug)] 16 | #[command(author, version, about, long_about=None)] 17 | struct Args { 18 | /// Path of the image file to process. 19 | #[arg(short, long)] 20 | input: String, 21 | 22 | /// Rotation angle, degrees. 23 | #[arg(short, long)] 24 | angle: f64, 25 | 26 | /// Fill value. 27 | #[arg(short, long, default_value_t = 128)] 28 | fill: u8, 29 | } 30 | 31 | fn main() { 32 | env_logger::Builder::from_env( 33 | env_logger::Env::default().default_filter_or("info")).init(); 34 | let args = Args::parse(); 35 | 36 | let input = args.input.as_str(); 37 | info!("Processing {}", input); 38 | let input_path = PathBuf::from(&input); 39 | let mut output_path = PathBuf::from("."); 40 | output_path.push(input_path.file_name().unwrap()); 41 | output_path.set_extension("bmp"); 42 | 43 | let img = match ImageReader::open(&input_path).unwrap().decode() { 44 | Ok(img) => img, 45 | Err(e) => { 46 | warn!("Skipping {:?} due to: {:?}", input_path, e); 47 | return; 48 | }, 49 | }; 50 | let input_img = img.to_luma8(); 51 | let (width, height) = input_img.dimensions(); 52 | let image_rotator = ImageRotator::new(width, height, args.angle); 53 | let rotate_start = Instant::now(); 54 | let output_img = image_rotator.rotate_image(&input_img, args.fill); 55 | let elapsed = rotate_start.elapsed(); 56 | info!("Rotated in {:?}", elapsed); 57 | 58 | let (rot_x, rot_y) = image_rotator.transform_to_rotated(0.0, 0.0, width, height); 59 | info!("Original 0,0 transforms to {:.2},{:.2}", rot_x, rot_y); 60 | 61 | let (x, y) = image_rotator.transform_from_rotated(rot_x, rot_y, width, height); 62 | info!("Transforms back to {:.2},{:.2}", x, y); 63 | 64 | output_img.save(output_path).unwrap(); 65 | } 66 | -------------------------------------------------------------------------------- /server/src/calibrator.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use std::ops::Deref; 5 | use std::sync::{Arc, Mutex}; 6 | use std::time::Duration; 7 | 8 | use image::GrayImage; 9 | use imageproc::stats::histogram; 10 | use log::warn; 11 | 12 | use cedar_camera::abstract_camera::{AbstractCamera, CapturedImage, Offset}; 13 | use canonical_error::{CanonicalError, 14 | aborted_error, failed_precondition_error, internal_error}; 15 | use cedar_detect::algorithm::{StarDescription, 16 | estimate_noise_from_image, get_stars_from_image}; 17 | use cedar_detect::histogram_funcs::stats_for_histogram; 18 | use cedar_elements::solver_trait::{ 19 | SolveExtension, SolveParams, SolverTrait}; 20 | use cedar_elements::cedar::ImageCoord; 21 | 22 | pub struct Calibrator { 23 | camera: Arc>>, 24 | 25 | // Determines whether rows are normalized to have the same dark level. 26 | normalize_rows: bool, 27 | } 28 | 29 | impl Calibrator { 30 | pub fn new(camera: Arc>>, 31 | normalize_rows: bool) -> Self{ 32 | Calibrator{camera, normalize_rows} 33 | } 34 | 35 | pub fn replace_camera( 36 | &mut self, camera: Arc>>) 37 | { 38 | self.camera = camera.clone(); 39 | } 40 | 41 | // Leaves camera set to the returned calibrated offset value. 42 | pub async fn calibrate_offset( 43 | &self, cancel_calibration: Arc>) 44 | -> Result { 45 | // Goal: find the minimum camera offset setting that avoids 46 | // black crush (too many zero-value pixels). 47 | // 48 | // Assumption: camera is pointed at sky which is mostly dark. 49 | // 50 | // Approach: 51 | // * Use 1ms exposures. 52 | // * Starting at offset=0, as long as >0.1% of pixels have zero 53 | // value, increase the offset. 54 | if *cancel_calibration.lock().unwrap() { 55 | return Err(aborted_error("Cancelled during calibrate_offset().")); 56 | } 57 | 58 | // Set offset before changing exposure; if we can't set offset this 59 | // lets us avoid changing the exposure only to have to restore it. 60 | self.camera.lock().await.set_offset(Offset::new(0))?; 61 | 62 | // Restore the exposure duration that we change here. 63 | let _restore_exposure = RestoreExposure::new(self.camera.clone()).await; 64 | self.camera.lock().await.set_exposure_duration(Duration::from_millis(1))?; 65 | let (width, height) = self.camera.lock().await.dimensions(); 66 | let total_pixels = width * height; 67 | 68 | let max_offset = 20; 69 | let mut prev_frame_id: Option = None; 70 | let mut num_zero_pixels = 0; 71 | for mut offset in 0..=max_offset { 72 | if *cancel_calibration.lock().unwrap() { 73 | return Err(aborted_error("Cancelled during calibrate_offset().")); 74 | } 75 | self.camera.lock().await.set_offset(Offset::new(offset))?; 76 | let (captured_image, frame_id) = 77 | Self::capture_image(self.camera.clone(), prev_frame_id).await?; 78 | prev_frame_id = Some(frame_id); 79 | let channel_histogram = histogram(captured_image.image.deref()); 80 | let histo = channel_histogram.channels[0]; 81 | num_zero_pixels = histo[0]; 82 | if num_zero_pixels < (total_pixels / 1000) as u32 { 83 | if offset < max_offset { 84 | offset += 1; // One more for good measure. 85 | } 86 | return Ok(Offset::new(offset)); 87 | } 88 | } 89 | Err(failed_precondition_error(format!("Still have {} zero pixels at offset={}", 90 | num_zero_pixels, max_offset).as_str())) 91 | } 92 | 93 | async fn capture_image( 94 | camera: Arc>>, 95 | frame_id: Option) -> Result<(CapturedImage, i32), CanonicalError> 96 | { 97 | // Don't hold camera lock for the entirety of the time waiting for 98 | // the next image. 99 | loop { 100 | let capture = 101 | match camera.lock().await.try_capture_image(frame_id).await 102 | { 103 | Ok(c) => c, 104 | Err(e) => { return Err(e); } 105 | }; 106 | if capture.is_none() { 107 | tokio::time::sleep(Duration::from_millis(1)).await; 108 | continue; 109 | } 110 | let (image, id) = capture.unwrap(); 111 | if !image.params_accurate { 112 | // Wait until image data is accurate w.r.t. the current camera 113 | // settings. 114 | continue; 115 | } 116 | return Ok((image, id)); 117 | } 118 | } 119 | 120 | // Leaves camera set to the returned calibrated exposure duration. If an 121 | // error occurs, the camera's exposure duration is restored to its value on 122 | // entry. 123 | pub async fn calibrate_exposure_duration( 124 | &self, 125 | initial_exposure_duration: Duration, 126 | max_exposure_duration: Duration, 127 | star_count_goal: i32, 128 | detection_binning: u32, detection_sigma: f64, 129 | cancel_calibration: Arc>) 130 | -> Result { 131 | // Goal: find the camera exposure duration that yields the desired 132 | // number of detected stars. 133 | // 134 | // Assumption: camera is focused and pointed at sky with stars. The 135 | // passed `initial_exposure_duration` is expected to yield at least one 136 | // star. 137 | // 138 | // Approach: 139 | // * Using the `initial_exposure_duration` 140 | // * Grab an image. 141 | // * Detect the stars. 142 | // * If close enough to the goal, scale the exposure duration and 143 | // return it. 144 | // * If not close to the goal, scale the exposure duration and 145 | // do one more exposure/detect/scale. 146 | if *cancel_calibration.lock().unwrap() { 147 | return Err(aborted_error( 148 | "Cancelled during calibrate_exposure_duration().")); 149 | } 150 | 151 | // If we fail, restore the exposure duration that we change here. 152 | let mut restore_exposure = RestoreExposure::new(self.camera.clone()).await; 153 | 154 | self.camera.lock().await.set_exposure_duration(initial_exposure_duration)?; 155 | let (_, mut stars, frame_id, mut histogram) = self.acquire_image_get_stars( 156 | /*frame_id=*/None, detection_binning, detection_sigma, 157 | cancel_calibration.clone()).await?; 158 | 159 | let mut num_stars_detected = stars.len(); 160 | // >1 if we have more stars than goal; <1 if fewer stars than goal. 161 | let mut star_goal_fraction = 162 | f64::max(num_stars_detected as f64, 1.0) / star_count_goal as f64; 163 | let mut scaled_exposure_duration_secs = 164 | initial_exposure_duration.as_secs_f64() / star_goal_fraction; 165 | if star_goal_fraction > 0.8 && star_goal_fraction < 1.2 { 166 | // Close enough to goal, the scaled exposure time is good. 167 | let exp = Duration::from_secs_f64(scaled_exposure_duration_secs); 168 | self.camera.lock().await.set_exposure_duration(exp)?; 169 | restore_exposure.deactivate(); 170 | return Ok(exp); 171 | } 172 | if *cancel_calibration.lock().unwrap() { 173 | return Err(aborted_error( 174 | "Cancelled during calibrate_exposure_duration().")); 175 | } 176 | 177 | // Iterate with the refined exposure duration. 178 | if scaled_exposure_duration_secs >= max_exposure_duration.as_secs_f64() { 179 | // We've saturated available exposure time latitude based on detected 180 | // star count (or lack thereof). Keep things sane by adjusting the 181 | // overall scene exposure. 182 | let stats = stats_for_histogram(&histogram); 183 | let mean = if stats.mean < 1.0 { 1.0 } else { stats.mean }; 184 | // Push image towards moderately low level. 185 | let correction_factor = 32.0 / mean; 186 | scaled_exposure_duration_secs = 187 | initial_exposure_duration.as_secs_f64() * correction_factor; 188 | } 189 | self.camera.lock().await.set_exposure_duration( 190 | Duration::from_secs_f64(scaled_exposure_duration_secs))?; 191 | (_, stars, _, histogram) = self.acquire_image_get_stars( 192 | Some(frame_id), detection_binning, detection_sigma, 193 | cancel_calibration.clone()).await?; 194 | 195 | num_stars_detected = stars.len(); 196 | // >1 if we have more stars than goal; <1 if fewer stars than goal. 197 | star_goal_fraction = 198 | f64::max(num_stars_detected as f64, 1.0) / star_count_goal as f64; 199 | scaled_exposure_duration_secs /= star_goal_fraction; 200 | if star_goal_fraction > 0.8 && star_goal_fraction < 1.2 { 201 | // Close enough to goal, the scaled exposure time is good. 202 | let exp = Duration::from_secs_f64(scaled_exposure_duration_secs); 203 | self.camera.lock().await.set_exposure_duration(exp)?; 204 | restore_exposure.deactivate(); 205 | return Ok(exp); 206 | } 207 | if *cancel_calibration.lock().unwrap() { 208 | return Err(aborted_error( 209 | "Cancelled during calibrate_exposure_duration().")); 210 | } 211 | 212 | // Iterate one more time. 213 | if scaled_exposure_duration_secs >= max_exposure_duration.as_secs_f64() { 214 | // We've saturated available exposure time latitude based on detected 215 | // star count (or lack thereof). Keep things sane by adjusting the 216 | // overall scene exposure. 217 | 218 | // Back out the scaling based on star count. 219 | scaled_exposure_duration_secs *= star_goal_fraction; 220 | 221 | let stats = stats_for_histogram(&histogram); 222 | let mean = if stats.mean < 1.0 { 1.0 } else { stats.mean }; 223 | // Push image towards moderately low level. 224 | let correction_factor = 64.0 / mean; 225 | scaled_exposure_duration_secs *= correction_factor; 226 | } 227 | self.camera.lock().await.set_exposure_duration( 228 | Duration::from_secs_f64(scaled_exposure_duration_secs))?; 229 | (_, stars, _, _) = self.acquire_image_get_stars( 230 | Some(frame_id), detection_binning, detection_sigma, 231 | cancel_calibration.clone()).await?; 232 | 233 | num_stars_detected = stars.len(); 234 | if num_stars_detected < (star_count_goal / 5) as usize { 235 | return Err(failed_precondition_error( 236 | format!("Too few stars detected ({})", num_stars_detected).as_str())) 237 | } 238 | star_goal_fraction = 239 | f64::max(num_stars_detected as f64, 1.0) / star_count_goal as f64; 240 | if star_goal_fraction > 0.8 && star_goal_fraction < 1.2 { 241 | // Close enough to goal, the scaled exposure time is good. 242 | let exp = Duration::from_secs_f64(scaled_exposure_duration_secs); 243 | self.camera.lock().await.set_exposure_duration(exp)?; 244 | restore_exposure.deactivate(); 245 | return Ok(exp); 246 | } 247 | if star_goal_fraction < 0.5 || star_goal_fraction > 2.0 { 248 | warn!("Exposure time calibration diverged, goal fraction {}", 249 | star_goal_fraction); 250 | } 251 | 252 | scaled_exposure_duration_secs /= star_goal_fraction; 253 | if scaled_exposure_duration_secs > max_exposure_duration.as_secs_f64() { 254 | self.camera.lock().await.set_exposure_duration(max_exposure_duration)?; 255 | restore_exposure.deactivate(); 256 | return Ok(max_exposure_duration); 257 | } 258 | let exp = Duration::from_secs_f64(scaled_exposure_duration_secs); 259 | self.camera.lock().await.set_exposure_duration(exp)?; 260 | restore_exposure.deactivate(); 261 | Ok(exp) 262 | } 263 | 264 | // Exposure duration is the result of calibrate_exposure_duration(). 265 | // Result is (FOV (degrees), lens distortion, match_max_error, 266 | // solve duration). 267 | // Errors: 268 | // NotFound: no plate solution was found. 269 | // DeadlineExceeded: the solve operation timed out. 270 | // Aborted: the calibration was canceled. 271 | // InvalidArgument: too few stars were found. 272 | pub async fn calibrate_optical( 273 | &self, 274 | solver: Arc>, 275 | detection_binning: u32, detection_sigma: f64, 276 | cancel_calibration: Arc>) 277 | -> Result<(f64, f64, f64, Duration), CanonicalError> { 278 | // Goal: find the field of view, lens distortion, match_max_error solver 279 | // parameter, and representative plate solve time. 280 | // 281 | // Assumption: camera is focused and pointed at sky with stars. 282 | // 283 | // Approach: 284 | // * Grab an image, detect the stars. 285 | // * Do a plate solution with no FOV estimate and no distortion estimate. 286 | // Use a generous match_max_error value and the default (generous) 287 | // solve_timeout. 288 | // * Use the plate solution to obtain FOV and lens distortion, and determine 289 | // an appropriate match_max_error value. 290 | // * Do another plate solution with the known FOV, lens distortion, and 291 | // match_max_error to obtain a representative solution time. 292 | 293 | let (image, stars, _, _) = self.acquire_image_get_stars( 294 | /*frame_id=*/None, detection_binning, detection_sigma, 295 | cancel_calibration.clone()).await?; 296 | let (width, height) = image.dimensions(); 297 | if *cancel_calibration.lock().unwrap() { 298 | return Err(aborted_error("Cancelled during calibrate_optical().")); 299 | } 300 | 301 | // Set up solve arguments. 302 | let solve_extension = SolveExtension::default(); 303 | let mut solve_params = SolveParams{ 304 | fov_estimate: None, // Initially blind w.r.t. FOV. 305 | distortion: Some(0.0), 306 | match_max_error: Some(0.005), 307 | ..Default::default() 308 | }; 309 | let mut star_centroids = Vec::::with_capacity(stars.len()); 310 | for star in &stars { 311 | star_centroids.push(ImageCoord{x: star.centroid_x, 312 | y: star.centroid_y}); 313 | } 314 | let plate_solution = solver.lock().await.solve_from_centroids( 315 | &star_centroids, 316 | width as usize, height as usize, 317 | &solve_extension, &solve_params).await?; 318 | 319 | if *cancel_calibration.lock().unwrap() { 320 | return Err(aborted_error("Cancelled during calibrate_optical().")); 321 | } 322 | 323 | let fov = plate_solution.fov; // Degrees. 324 | let distortion = plate_solution.distortion.unwrap(); 325 | 326 | // Use the 90th percentile error residual as a basis for determining the 327 | // 'match_max_error' argument to the solver. 328 | let p90_error_deg = plate_solution.p90_error / 3600.0; // Degrees. 329 | let p90_err_frac = p90_error_deg / fov; // As fraction of FOV. 330 | let match_max_error = p90_err_frac * 2.0; 331 | 332 | // Do another solve with now-known FOV, distortion, and 333 | // match_max_error, to get a more representative solve_duration. 334 | solve_params.fov_estimate = Some((fov, fov / 10.0)); 335 | solve_params.distortion = Some(distortion); 336 | solve_params.match_max_error = Some(match_max_error); 337 | 338 | let plate_solution2 = match solver.lock().await.solve_from_centroids( 339 | &star_centroids, 340 | width as usize, height as usize, 341 | &solve_extension, &solve_params).await 342 | { 343 | Ok(ps) => ps, 344 | Err(e) => { 345 | return Err(internal_error( 346 | &format!("Unexpected error during repeated plate solve: {:?}", e))); 347 | } 348 | }; 349 | let solve_duration = std::time::Duration::try_from( 350 | plate_solution2.solve_time.unwrap()).unwrap(); 351 | 352 | return Ok((fov, distortion, match_max_error, solve_duration)); 353 | } 354 | 355 | async fn acquire_image_get_stars( 356 | &self, frame_id: Option, 357 | detection_binning: u32, detection_sigma: f64, 358 | cancel_calibration: Arc>) 359 | -> Result<(Arc, Vec, i32, [u32; 256]), 360 | CanonicalError> 361 | { 362 | let (captured_image, frame_id) = 363 | Self::capture_image(self.camera.clone(), frame_id).await?; 364 | if *cancel_calibration.lock().unwrap() { 365 | return Err(aborted_error( 366 | "Cancelled during calibrate_exposure_duration().")); 367 | } 368 | // Run CedarDetect on the image. 369 | let image = &captured_image.image; 370 | let noise_estimate = estimate_noise_from_image(&image); 371 | let (stars, _, _, histogram) = 372 | get_stars_from_image(&image, noise_estimate, detection_sigma, 373 | self.normalize_rows, detection_binning, 374 | /*detect_hot_pixels*/true, 375 | /*return_binned_image=*/false); 376 | Ok((image.clone(), stars, frame_id, histogram)) 377 | } 378 | } 379 | 380 | // RAII gadget for saving/restoring camera exposure time. 381 | struct RestoreExposure { 382 | camera: Arc>>, 383 | exp_duration: Duration, 384 | do_restore: bool, 385 | } 386 | impl RestoreExposure { 387 | async fn new(camera: Arc>>) -> Self { 388 | let locked_camera = camera.lock().await; 389 | RestoreExposure{ 390 | camera: camera.clone(), 391 | exp_duration: locked_camera.get_exposure_duration(), 392 | do_restore: true, 393 | } 394 | } 395 | 396 | // Turn off restoration of the saved exposure time. 397 | fn deactivate(&mut self) { 398 | self.do_restore = false; 399 | } 400 | 401 | async fn restore(&mut self) { 402 | if self.do_restore { 403 | let mut locked_camera = self.camera.lock().await; 404 | locked_camera.set_exposure_duration(self.exp_duration).unwrap(); 405 | } 406 | } 407 | } 408 | impl Drop for RestoreExposure { 409 | fn drop(&mut self) { 410 | // https://stackoverflow.com/questions/71541765/rust-async-drop 411 | futures::executor::block_on(self.restore()); 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /server/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | pub mod activity_led; 5 | pub mod calibrator; 6 | pub mod cedar_server; 7 | pub mod detect_engine; 8 | pub mod motion_estimator; 9 | pub mod polar_analyzer; 10 | pub mod position_reporter; 11 | pub mod rate_estimator; 12 | pub mod solve_engine; 13 | -------------------------------------------------------------------------------- /server/src/motion_estimator.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use log::{debug, warn}; 5 | use std::time::{Duration, SystemTime}; 6 | 7 | use crate::rate_estimator::RateEstimation; 8 | use cedar_elements::cedar_common::CelestialCoord; 9 | 10 | pub struct MotionEstimate { 11 | // Estimated rate of RA boresight movement eastward (positive) or westward 12 | // (negative). Unit is degrees per second. 13 | pub ra_rate: f64, 14 | // Estimate of the RMS error in `ra_rate`. 15 | pub ra_rate_error: f64, 16 | 17 | // Estimated rate of DEC boresight movement northward (positive) or southward 18 | // (negative). Unit is degrees per second. 19 | pub dec_rate: f64, 20 | // Estimate of the RMS error in `ra_rate`. 21 | pub dec_rate_error: f64, 22 | } 23 | 24 | #[derive(Debug, PartialEq)] 25 | enum State { 26 | // MotionEstimator is newly constructed, or too much time has passed without 27 | // a position passed to add(). 28 | Unknown, 29 | 30 | // While Unknown, a call to add() received a position. Alternately, while 31 | // Moving, Stopped, or SteadyRate, another add() with a very different 32 | // position was received. 33 | Moving, 34 | 35 | // While Moving, a call to add() received a position very similar to the 36 | // previous position, consistent with a fixed mount (position moving at 37 | // sidereal rate) or a tracking mount (position nearly motionless in ra/dec) 38 | // that is motionless (i.e. tracking the sky but not slewing). 39 | Stopped, 40 | 41 | // From Stopped, the next add()ed position is consistent with the previous 42 | // point, for either a tracking or fixed mount. We continue in SteadyRate 43 | // as long as newly add()ed positions are consistent with the existing rate 44 | // estimates. 45 | SteadyRate, 46 | } 47 | 48 | pub struct MotionEstimator { 49 | // The current state of this MotionEstimator. 50 | state: State, 51 | 52 | // How long we tolerate lack of position updates before reverting to Unknown 53 | // state. 54 | gap_tolerance: Duration, 55 | 56 | // When in SteadyRate, how long we tolerate (and discard) position updates 57 | // not consistent with `ra_rate` and `dec_rate` before reverting to Moving 58 | // state. 59 | bump_tolerance: Duration, 60 | 61 | // Time/position passed to most recent add() call. Updated only for add() calls 62 | // with non-None position arg. 63 | prev_time: Option, 64 | prev_position: Option, 65 | 66 | // Tracking rate estimation, used when add()ed positions are consistent with 67 | // a motionless fixed mount or tracking mount. Present only when SteadyRate. 68 | ra_rate: Option, 69 | dec_rate: Option, 70 | } 71 | 72 | impl MotionEstimator { 73 | // `gap_tolerance` The amount of time add() calls can have position=None 74 | // before our state reverts to Unknown. 75 | pub fn new(gap_tolerance: Duration, bump_tolerance: Duration) -> Self { 76 | MotionEstimator{ 77 | state: State::Unknown, 78 | gap_tolerance, bump_tolerance, 79 | prev_time: None, 80 | prev_position: None, 81 | ra_rate: None, 82 | dec_rate: None, 83 | } 84 | } 85 | 86 | // `time` Time at which the image corresponding to `boresight_position` was 87 | // captured. Should not be earlier than `time` passed to previous add() 88 | // call. 89 | // `position` A successfully plate-solved determination of the telescope's 90 | // aim point as of `time`. None if there was no solution (perhaps 91 | // because the telescope is slewing). 92 | // `position_rmse` If `position` is provided, this will be the RMS error (in 93 | // arcseconds) of the plate solution. This represents the noise level 94 | // associated with `position`. 95 | pub fn add(&mut self, time: SystemTime, position: Option, 96 | position_rmse: Option) { 97 | let prev_time = self.prev_time; 98 | let prev_pos = self.prev_position.clone(); 99 | if position.is_some() { 100 | self.prev_time = Some(time); 101 | self.prev_position = position.clone(); 102 | } 103 | if prev_time.is_none() { 104 | assert!(prev_pos.is_none()); 105 | if position.is_some() { 106 | // This is the first call to add() with a position. 107 | self.set_state(State::Moving); 108 | } 109 | return; 110 | } 111 | let prev_time = prev_time.unwrap(); 112 | if time <= prev_time { 113 | // This can happen when the client updates the server's system time. 114 | if time <= prev_time - Duration::from_secs(10) { 115 | warn!("Time arg regressed from {:?} to {:?}", prev_time, time); 116 | } 117 | return; 118 | } 119 | 120 | if position.is_none() { 121 | if self.state == State::Unknown { 122 | return; 123 | } 124 | // Has gap persisted for too long? 125 | if time.duration_since(prev_time).unwrap() > self.gap_tolerance { 126 | self.set_state(State::Unknown); 127 | self.ra_rate = None; 128 | self.dec_rate = None; 129 | } 130 | return; 131 | } 132 | let position = position.unwrap(); 133 | let position_rmse = position_rmse.unwrap() as f64 / 3600.0; // arcsec->deg. 134 | let prev_pos = prev_pos.unwrap(); 135 | match self.state { 136 | State::Unknown => { 137 | self.set_state(State::Moving); 138 | }, 139 | State::Moving => { 140 | // Compare new position/time to previous position/time. 141 | if Self::is_stopped(time, &position, position_rmse, prev_time, &prev_pos) { 142 | self.set_state(State::Stopped); 143 | } 144 | }, 145 | State::Stopped => { 146 | // Compare new position/time to previous position/time. Are we still stopped? 147 | // TODO: require a few add() calls in Stopped before advancing to SteadyRate? 148 | if Self::is_stopped(time, &position, position_rmse, prev_time, &prev_pos) { 149 | // Enter SteadyRate and initialize ra/dec RateEstimation objects with the 150 | // current and previous positions/times. 151 | self.set_state(State::SteadyRate); 152 | self.ra_rate = Some(RateEstimation::new( 153 | 1000, prev_time, prev_pos.ra as f64)); 154 | self.ra_rate.as_mut().unwrap().add( 155 | time, position.ra as f64, position_rmse); 156 | self.dec_rate = 157 | Some(RateEstimation::new( 158 | 1000, prev_time, prev_pos.dec as f64)); 159 | self.dec_rate.as_mut().unwrap().add( 160 | time, position.dec as f64, position_rmse); 161 | } else { 162 | self.set_state(State::Moving); 163 | } 164 | }, 165 | State::SteadyRate => { 166 | let ra_rate = &mut self.ra_rate.as_mut().unwrap(); 167 | let dec_rate = &mut self.dec_rate.as_mut().unwrap(); 168 | if ra_rate.fits_trend(time, position.ra as f64, /*sigma=*/10.0) && 169 | dec_rate.fits_trend(time, position.dec as f64, /*sigma=*/10.0) 170 | { 171 | ra_rate.add(time, position.ra as f64, position_rmse); 172 | dec_rate.add(time, position.dec as f64, position_rmse); 173 | } else { 174 | // Has rate trend violation persisted for too long? 175 | if time.duration_since(ra_rate.last_time()).unwrap() > self.bump_tolerance { 176 | self.set_state(State::Moving); 177 | self.ra_rate = None; 178 | self.dec_rate = None; 179 | } 180 | } 181 | }, 182 | } 183 | } 184 | 185 | fn set_state(&mut self, state: State) { 186 | debug!("state -> {:?}", state); 187 | self.state = state; 188 | } 189 | 190 | /// Returns the current MotionEstimate, if any. If the boresight is not 191 | /// dwelling (relatively motionless), None is returned. 192 | pub fn get_estimate(&self) -> Option { 193 | if self.state != State::SteadyRate { 194 | return None; 195 | } 196 | let ra_rate = &self.ra_rate.as_ref().unwrap(); 197 | let dec_rate = &self.dec_rate.as_ref().unwrap(); 198 | if ra_rate.count() < 3 { 199 | None 200 | } else { 201 | Some(MotionEstimate{ra_rate: ra_rate.slope(), 202 | ra_rate_error: ra_rate.rate_interval_bound(), 203 | dec_rate: dec_rate.slope(), 204 | dec_rate_error: dec_rate.rate_interval_bound()} 205 | ) 206 | } 207 | } 208 | 209 | // pos_rmse: position error estimate in degrees. 210 | fn is_stopped(time: SystemTime, pos: &CelestialCoord, pos_rmse: f64, 211 | prev_time: SystemTime, prev_pos: &CelestialCoord) -> bool { 212 | let elapsed_secs = 213 | time.duration_since(prev_time).unwrap().as_secs_f64(); 214 | 215 | // Max movement rate below which we are considered to be stopped. 216 | let max_rate = f64::max(pos_rmse as f64 * 8.0, Self::SIDEREAL_RATE * 2.0); 217 | 218 | let dec_rate = Self::dec_change(prev_pos.dec, pos.dec) / elapsed_secs; 219 | if dec_rate.abs() > max_rate { 220 | return false; 221 | } 222 | let ra_rate = Self::ra_change(prev_pos.ra, pos.ra) / elapsed_secs; 223 | ra_rate.abs() <= max_rate 224 | } 225 | 226 | const SIDEREAL_RATE: f64 = 15.04 / 3600.0; // Degrees per second. 227 | 228 | // Computes the change in declination between `prev_dec` and `cur_dec`. All 229 | // are in degrees. 230 | fn dec_change(prev_dec: f64, cur_dec: f64) -> f64 { 231 | cur_dec - prev_dec 232 | } 233 | 234 | // Computes the change in right ascension between `prev_ra` and `cur_ra`. 235 | // Care is taken when crossing the 360..0 boundary. 236 | // All are in degrees. 237 | fn ra_change(mut prev_ra: f64, mut cur_ra: f64) -> f64 { 238 | if prev_ra < 45.0 && cur_ra > 315.0 { 239 | prev_ra += 360.0; 240 | } 241 | if cur_ra < 45.0 && prev_ra > 315.0 { 242 | cur_ra += 360.0; 243 | } 244 | cur_ra - prev_ra 245 | } 246 | } 247 | 248 | #[cfg(test)] 249 | mod tests { 250 | // extern crate approx; 251 | // use approx::assert_abs_diff_eq; 252 | use super::*; 253 | 254 | #[test] 255 | fn test_dec_change() { 256 | assert_eq!(MotionEstimator::dec_change(10.0, 15.0), 5.0); 257 | assert_eq!(MotionEstimator::dec_change(-10.0, 15.0), 25.0); 258 | assert_eq!(MotionEstimator::dec_change(15.0, 10.0), -5.0); 259 | } 260 | 261 | #[test] 262 | fn test_ra_change() { 263 | assert_eq!(MotionEstimator::ra_change(10.0, 15.0), 5.0); 264 | assert_eq!(MotionEstimator::ra_change(350.0, 355.0), 5.0); 265 | assert_eq!(MotionEstimator::ra_change(355.0, 360.0), 5.0); 266 | assert_eq!(MotionEstimator::ra_change(356.0, 1.0), 5.0); 267 | 268 | assert_eq!(MotionEstimator::ra_change(15.0, 10.0), -5.0); 269 | assert_eq!(MotionEstimator::ra_change(355.0, 350.0), -5.0); 270 | assert_eq!(MotionEstimator::ra_change(360.0, 355.0), -5.0); 271 | assert_eq!(MotionEstimator::ra_change(1.0, 356.0), -5.0); 272 | } 273 | 274 | } // mod tests. 275 | -------------------------------------------------------------------------------- /server/src/polar_analyzer.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | // Module to estimate polar axis (mis)alignment. 5 | // See http://celestialwonders.com/articles/polaralignment/MeasuringAlignmentError.html 6 | 7 | use log::{debug}; 8 | 9 | use cedar_elements::cedar::{ErrorBoundedValue, PolarAlignAdvice}; 10 | use cedar_elements::cedar_common::CelestialCoord; 11 | use crate::motion_estimator::MotionEstimate; 12 | 13 | pub struct PolarAnalyzer { 14 | polar_align_advice: PolarAlignAdvice, 15 | } 16 | 17 | impl PolarAnalyzer { 18 | pub fn new() -> Self { 19 | PolarAnalyzer{ 20 | polar_align_advice: PolarAlignAdvice{azimuth_correction: None, 21 | altitude_correction: None}, 22 | } 23 | } 24 | 25 | // This function should be called when the following conditions are all met: 26 | // * There is a plate solution (valid boresight_pos). 27 | // * The date/time and observer geographic location is known (valid hour_angle, 28 | // latitude). 29 | // When certain other conditions are met, this function updates the 30 | // `polar_align_advice` state. 31 | pub fn process_solution(&mut self, boresight_pos: &CelestialCoord, hour_angle: f64, 32 | latitude: f64, motion_estimate: &Option) { 33 | self.polar_align_advice.azimuth_correction = None; 34 | self.polar_align_advice.altitude_correction = None; 35 | if motion_estimate.is_none() { 36 | debug!("Not updating polar alignment advice: not dwelling"); 37 | return; 38 | } 39 | let motion_estimate = motion_estimate.as_ref().unwrap(); 40 | // `hour_angle` and `latitude` args: degrees. 41 | const SIDEREAL_RATE: f64 = 15.04 / 3600.0; // Degrees per second. 42 | // If we're on a tracking equatorial mount that is at least roughly 43 | // polar-aligned, the ra_rate will be close to zero. 44 | if motion_estimate.ra_rate.abs() > SIDEREAL_RATE * 0.3 { 45 | debug!("Not updating polar alignment advice: excessive ra_rate {}arcsec/sec", 46 | motion_estimate.ra_rate * 3600.0); 47 | return; 48 | } 49 | let dec_rate = motion_estimate.dec_rate; // Positive is northward drift. 50 | let dec_rate_error = motion_estimate.dec_rate_error; 51 | assert!(dec_rate_error >= 0.0); 52 | 53 | // Degrees (plus or minus) within which the declination must be zero for 54 | // polar alignment to be evaluated. 55 | const DEC_TOLERANCE: f64 = 15.0; 56 | 57 | // Hours (plus or minus) around the meridian for polar alignment azimuth 58 | // evaluation; hours (doubled) above east or west horizon for polar alignment 59 | // elevation evaluation. 60 | const HA_TOLERANCE: f64 = 1.0; 61 | 62 | let dec = boresight_pos.dec; 63 | if dec > DEC_TOLERANCE || dec < -DEC_TOLERANCE { 64 | debug!("Not updating polar alignment advice: declination {}deg", dec); 65 | return; 66 | } 67 | 68 | // Adjust sidereal rate for declination. 69 | let adjusted_sidereal_rate = SIDEREAL_RATE * dec.to_radians().cos(); 70 | 71 | // Compute the angle formed by the declination drift rate at a right angle 72 | // to the adjusted_sidereal_rate. Degrees. 73 | let mut dec_drift_angle = (dec_rate / adjusted_sidereal_rate).atan().to_degrees(); 74 | let mut dec_drift_angle_error = 75 | (dec_rate_error / adjusted_sidereal_rate).atan().to_degrees(); 76 | assert!(dec_drift_angle_error >= 0.0); 77 | 78 | // `hour_angle` arg is in degrees. 79 | let ha_hours = hour_angle / 15.0; 80 | if ha_hours > -HA_TOLERANCE && ha_hours < HA_TOLERANCE { 81 | // Near meridian. We can estimate polar alignment azimuth deviation by 82 | // declination drift method. 83 | 84 | // Adjust for deviation from optimal HA. 85 | let ha_correction = hour_angle.to_radians().cos(); 86 | dec_drift_angle /= ha_correction; 87 | dec_drift_angle_error /= ha_correction; 88 | assert!(dec_drift_angle_error >= 0.0); 89 | 90 | // We project the azimuth_correction angle to the local horizontal. 91 | let latitude_correction = latitude.to_radians().cos(); 92 | 93 | // We express polar axis azimuth correction as positive angle (clockwise 94 | // looking down at mount from above) or negative angle 95 | // (counter-clockwise), rather than in terms of east or west. This value 96 | // is thus independent of northern/southern hemisphere. 97 | let az_corr = -dec_drift_angle / latitude_correction; 98 | let az_corr_error = dec_drift_angle_error / latitude_correction; 99 | 100 | self.polar_align_advice.azimuth_correction = 101 | Some(ErrorBoundedValue{value: az_corr, error: az_corr_error}); 102 | return; 103 | } 104 | 105 | // Degrees. 106 | let mut altitude_correction; 107 | if ha_hours > -6.0 && ha_hours < -6.0 + 2.0 * HA_TOLERANCE { 108 | // Close to rising horizon. We can estimate polar alignmwent 109 | // elevation deviation by declination drift method. 110 | 111 | // Adjust for deviation from optimal HA. 112 | let ha_correction = (hour_angle - -90.0).to_radians().cos(); 113 | dec_drift_angle /= ha_correction; 114 | dec_drift_angle_error /= ha_correction; 115 | assert!(dec_drift_angle_error >= 0.0); 116 | 117 | // Northern hemisphere: 118 | // Boresight drifting south (star drifting north in FOV): polar axis too high. 119 | // Boresight drifting north (star drifting south in FOV): polar axis too low. 120 | altitude_correction = dec_drift_angle; 121 | } else if ha_hours < 6.0 && ha_hours > 6.0 - 2.0 * HA_TOLERANCE { 122 | // Close to setting horizon. We can estimate polar alignmwent 123 | // elevation deviation by declination drift method. 124 | 125 | // Adjust for deviation from optimal HA. 126 | let ha_correction = (hour_angle - 90.0).to_radians().cos(); 127 | dec_drift_angle /= ha_correction; 128 | dec_drift_angle_error /= ha_correction; 129 | assert!(dec_drift_angle_error >= 0.0); 130 | 131 | // Northern hemisphere: 132 | // Boresight drifting south (star drifting north in FOV): polar axis too low. 133 | // Boresight drifting north (star drifting sourth in FOV): polar axis too high. 134 | altitude_correction = -dec_drift_angle; 135 | } else { 136 | debug!("Not updating polar alignment advice: hour angle {}h", ha_hours); 137 | return; 138 | } 139 | let altitude_correction_error = dec_drift_angle_error; 140 | if latitude < 0.0 { 141 | // Southern hemisphere: reverse sense of altitude guidance. 142 | altitude_correction = -altitude_correction; 143 | } 144 | self.polar_align_advice.altitude_correction = 145 | Some(ErrorBoundedValue{value: altitude_correction, 146 | error: altitude_correction_error}); 147 | } 148 | 149 | pub fn get_polar_align_advice(&self) -> PolarAlignAdvice { 150 | self.polar_align_advice.clone() 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /server/src/position_reporter.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use std::sync::{Arc, Mutex}; 5 | use std::time::SystemTime; 6 | 7 | use log::debug; 8 | 9 | use ascom_alpaca::{ASCOMError, ASCOMErrorCode, ASCOMResult, Server}; 10 | use ascom_alpaca::api::{AlignmentMode, Axis, CargoServerInfo, 11 | Device, EquatorialSystem, Telescope}; 12 | use async_trait::async_trait; 13 | 14 | #[derive(Default, Debug)] 15 | pub struct TelescopePosition { 16 | // The telescope's boresight position is determined by Cedar. 17 | pub boresight_ra: f64, // 0..360 18 | pub boresight_dec: f64, // -90..90 19 | // If true, boresight_ra/boresight_dec are current. If false, they are stale. 20 | pub boresight_valid: bool, 21 | 22 | // SkySafari calls right_ascension() followed by declination(). The 23 | // right_ascension() call saves the Dec corresponding to the RA it returns, 24 | // so the subsequent call to declination() will return a consistent value. 25 | snapshot_dec: Option, 26 | 27 | // A slew is initiated by SkySafari. The slew can be terminated either by 28 | // SkySafari or Cedar. 29 | pub slew_target_ra: f64, // 0..360 30 | pub slew_target_dec: f64, // -90..90 31 | pub slew_active: bool, 32 | 33 | // The "Set Time & Location" option must be enabled in the SkySafari 34 | // telescope preset options. These values are set by SkySafari and are 35 | // consumed (set to None) by Cedar server. 36 | pub site_latitude: Option, // -90..90 37 | pub site_longitude: Option, // -180..180, positive east. 38 | 39 | // These values are set by SkySafari and are consumed (set to None) by 40 | // Cedar server. 41 | pub sync_ra: Option, // 0..360 42 | pub sync_dec: Option, // -90..90 43 | 44 | // SkySafari doesn't seem to use these. 45 | pub target_ra: f64, // 0..360 46 | pub target_dec: f64, // -90..90 47 | 48 | // SkySafari doesn't seem to use this. 49 | pub utc_date: Option, 50 | } 51 | 52 | impl TelescopePosition { 53 | pub fn new() -> Self { 54 | // Sky Safari doesn't display (0.0, 0.0). 55 | TelescopePosition{boresight_ra: 180.0, boresight_dec: 0.0, ..Default::default()} 56 | } 57 | } 58 | 59 | struct Callback(Box); 60 | 61 | impl std::fmt::Debug for Callback { 62 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 63 | write!(f, "Callback") 64 | } 65 | } 66 | 67 | #[derive(Default, Debug)] 68 | struct MyTelescope { 69 | telescope_position: Arc>, 70 | 71 | // SkySafari does not provide a way to signal that the boresight ra/dec 72 | // values are not valid. We instead "animate" the reported ra/dec position 73 | // when it is invalid. 74 | updates_while_invalid: Mutex, 75 | 76 | // Called whenever SkySafari obtains our right_ascension(). 77 | callback: Option, 78 | } 79 | 80 | impl MyTelescope { 81 | // cb: function to be called whenever SkySafari interrogates our position. 82 | pub fn new(telescope_position: Arc>, 83 | cb: Box) -> Self { 84 | MyTelescope{ telescope_position, 85 | updates_while_invalid: Mutex::new(0), 86 | callback: Some(Callback(cb)) } 87 | } 88 | 89 | fn value_not_set_error(msg: &str) -> ASCOMError { 90 | ASCOMError{code: ASCOMErrorCode::VALUE_NOT_SET, 91 | message: std::borrow::Cow::Owned(msg.to_string())} 92 | } 93 | } 94 | 95 | #[async_trait] 96 | impl Device for MyTelescope { 97 | fn static_name(&self) -> &str { "CedarTelescopeEmulator" } 98 | fn unique_id(&self) -> &str { "CedarTelescopeEmulator-42" } 99 | 100 | async fn connected(&self) -> ASCOMResult { Ok(true) } 101 | async fn set_connected(&self, _connected: bool) -> ASCOMResult { Ok(()) } 102 | } 103 | 104 | #[async_trait] 105 | impl Telescope for MyTelescope { 106 | async fn alignment_mode(&self) -> ASCOMResult { 107 | debug!("alignment_mode"); 108 | // TODO: update if settings is alt/az. 109 | Ok(AlignmentMode::Polar) 110 | } 111 | 112 | async fn equatorial_system(&self) -> ASCOMResult { 113 | debug!("equatorial_system"); 114 | Ok(EquatorialSystem::J2000) 115 | } 116 | 117 | // Hours. 118 | async fn right_ascension(&self) -> ASCOMResult { 119 | debug!("right_ascension"); 120 | if let Some(ref cb) = self.callback { 121 | cb.0(); 122 | } 123 | let mut locked_position = self.telescope_position.lock().unwrap(); 124 | locked_position.snapshot_dec = Some(locked_position.boresight_dec); 125 | Ok(locked_position.boresight_ra / 15.0) 126 | } 127 | // Degrees. 128 | async fn declination(&self) -> ASCOMResult { 129 | debug!("declination"); 130 | let mut locked_position = self.telescope_position.lock().unwrap(); 131 | let snapshot_dec = locked_position.snapshot_dec.take(); 132 | if locked_position.boresight_valid { 133 | return if snapshot_dec.is_some() { 134 | Ok(snapshot_dec.unwrap()) 135 | } else { 136 | Ok(locked_position.boresight_dec) 137 | }; 138 | } 139 | // Sky Safari does not respond to error returns. To indicate 140 | // the position data is stale, we "wiggle" the position. 141 | let mut locked_updates = self.updates_while_invalid.lock().unwrap(); 142 | *locked_updates += 1; 143 | if *locked_updates & 1 == 0 { 144 | if locked_position.boresight_dec > 0.0 { 145 | Ok(locked_position.boresight_dec - 0.1) 146 | } else { 147 | Ok(locked_position.boresight_dec + 0.1) 148 | } 149 | } else { 150 | Ok(locked_position.boresight_dec) 151 | } 152 | } 153 | 154 | async fn can_move_axis(&self, _axis: Axis) -> ASCOMResult { 155 | debug!("can_move_axis"); 156 | Ok(false) 157 | } 158 | // Even though we define 'can_move_axis()' as false, SkySafari still 159 | // offers axis movement UI that calls move_axis(). 160 | async fn move_axis(&self, _axis: Axis, _rate: f64) -> ASCOMResult { 161 | debug!("move_axis"); 162 | Ok(()) // Silently ignore. 163 | } 164 | 165 | async fn set_site_latitude(&self, site_lat: f64) -> ASCOMResult { 166 | debug!("set_site_latitude {}", site_lat); 167 | let mut locked_position = self.telescope_position.lock().unwrap(); 168 | locked_position.site_latitude = Some(site_lat); 169 | Ok(()) 170 | } 171 | async fn site_latitude(&self) -> ASCOMResult { 172 | debug!("site_latitude"); 173 | let locked_position = self.telescope_position.lock().unwrap(); 174 | match locked_position.site_latitude { 175 | Some(sl) => { Ok(sl) }, 176 | None => { Err(Self::value_not_set_error("")) } 177 | } 178 | } 179 | async fn set_site_longitude(&self, site_lon: f64) -> ASCOMResult { 180 | debug!("set_site_longitude {}", site_lon); 181 | let mut locked_position = self.telescope_position.lock().unwrap(); 182 | locked_position.site_longitude = Some(site_lon); 183 | Ok(()) 184 | } 185 | async fn site_longitude(&self) -> ASCOMResult { 186 | debug!("site_longitude"); 187 | let locked_position = self.telescope_position.lock().unwrap(); 188 | match locked_position.site_longitude { 189 | Some(sl) => { Ok(sl) }, 190 | None => { Err(Self::value_not_set_error("")) } 191 | } 192 | } 193 | 194 | // SkySafari doesn't seem to use the utc date methods.. 195 | async fn set_utc_date(&self, utc_date: SystemTime) -> ASCOMResult { 196 | debug!("set_utc_date {:?}", utc_date); 197 | let mut locked_position = self.telescope_position.lock().unwrap(); 198 | locked_position.utc_date = Some(utc_date); 199 | Ok(()) 200 | } 201 | async fn utc_date(&self) -> ASCOMResult { 202 | debug!("utc_date"); 203 | let locked_position = self.telescope_position.lock().unwrap(); 204 | match locked_position.utc_date { 205 | Some(ud) => { Ok(ud) }, 206 | None => { Err(Self::value_not_set_error("")) } 207 | } 208 | } 209 | 210 | // SkySafari doesn't seem to use the 'target' methods. 211 | async fn set_target_declination(&self, target_dec: f64) -> ASCOMResult { 212 | debug!("set_target_declination {}", target_dec); 213 | let mut locked_position = self.telescope_position.lock().unwrap(); 214 | locked_position.target_dec = target_dec; 215 | Ok(()) 216 | } 217 | async fn target_declination(&self) -> ASCOMResult { 218 | debug!("target_declination"); 219 | let locked_position = self.telescope_position.lock().unwrap(); 220 | Ok(locked_position.target_dec) 221 | } 222 | async fn set_target_right_ascension(&self, target_ra: f64) -> ASCOMResult { 223 | debug!("set_target_right_ascension {}", target_ra); 224 | let mut locked_position = self.telescope_position.lock().unwrap(); 225 | locked_position.target_ra = target_ra * 15.0; 226 | Ok(()) 227 | } 228 | async fn target_right_ascension(&self) -> ASCOMResult { 229 | debug!("target_right_ascension"); 230 | let locked_position = self.telescope_position.lock().unwrap(); 231 | Ok(locked_position.target_ra / 15.0) 232 | } 233 | async fn slew_to_target_async(&self) -> ASCOMResult { 234 | debug!("slew_to_target_async"); 235 | let mut locked_position = self.telescope_position.lock().unwrap(); 236 | locked_position.slew_active = true; 237 | Ok(()) 238 | } 239 | 240 | async fn can_slew_async(&self) -> ASCOMResult { 241 | debug!("can_slew_async"); 242 | Ok(true) 243 | } 244 | async fn slew_to_coordinates_async(&self, right_ascension: f64, declination: f64) 245 | -> ASCOMResult { 246 | debug!("slew_to_coordinates_async {} {}", right_ascension, declination); 247 | let mut locked_position = self.telescope_position.lock().unwrap(); 248 | locked_position.slew_target_ra = right_ascension * 15.0; 249 | locked_position.slew_target_dec = declination; 250 | locked_position.slew_active = true; 251 | Ok(()) 252 | } 253 | async fn slewing(&self) -> ASCOMResult { 254 | let locked_position = self.telescope_position.lock().unwrap(); 255 | Ok(locked_position.slew_active) 256 | } 257 | async fn abort_slew(&self) -> ASCOMResult { 258 | debug!("abort_slew"); 259 | let mut locked_position = self.telescope_position.lock().unwrap(); 260 | locked_position.slew_active = false; 261 | Ok(()) 262 | } 263 | 264 | async fn can_sync(&self) -> ASCOMResult { 265 | debug!("can_sync"); 266 | Ok(true) 267 | } 268 | async fn sync_to_coordinates(&self, right_ascension: f64, declination: f64) 269 | -> ASCOMResult { 270 | debug!("sync_to_coordinates {} {}", right_ascension, declination); 271 | let mut locked_position = self.telescope_position.lock().unwrap(); 272 | locked_position.sync_ra = Some(right_ascension * 15.0); 273 | locked_position.sync_dec = Some(declination); 274 | Ok(()) 275 | } 276 | 277 | async fn tracking(&self) -> ASCOMResult { 278 | debug!("tracking"); 279 | // TODO: sense whether solve results are fixed or moving at sideral rate. 280 | Ok(false) 281 | } 282 | async fn can_set_tracking(&self) -> ASCOMResult { 283 | debug!("can_set_tracking"); 284 | Ok(false) 285 | } 286 | } 287 | 288 | // cb: function to be called whenever SkySafari interrogates our position. 289 | pub fn create_alpaca_server(telescope_position: Arc>, 290 | cb: Box) -> Server { 291 | let mut server = Server { 292 | info: CargoServerInfo!(), 293 | ..Default::default() 294 | }; 295 | server.listen_addr.set_port(11111); 296 | server.devices.register(MyTelescope::new(telescope_position, cb)); 297 | server 298 | } 299 | -------------------------------------------------------------------------------- /server/src/rate_estimator.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Steven Rosenthal smr@dt3.org 2 | // See LICENSE file in root directory for license terms. 3 | 4 | use log::warn; 5 | use std::time::{Duration, SystemTime}; 6 | 7 | use cedar_elements::reservoir_sampler::ReservoirSampler; 8 | 9 | struct DataPoint { 10 | x: SystemTime, 11 | y: f64, 12 | } 13 | 14 | // Models a one-dimension time series (float values as a function of time) 15 | // assuming a constant rate of change. The rate is estimated from observations, 16 | // and an estimate of the rate's uncertainty is derived from a measurement of 17 | // the data's noise. 18 | pub struct RateEstimation { 19 | // Time of the first data point to be add()ed. 20 | first: SystemTime, 21 | 22 | // Time of most recent data point to be add()ed. 23 | last: SystemTime, 24 | 25 | // The retained subset of data points that have been add()ed. 26 | reservoir: ReservoirSampler, 27 | 28 | // The linear regression's slope. This is the rate of change in y per second 29 | // of SystemTime (x) change. 30 | slope: f64, 31 | 32 | // The linear regression's y intercept. 33 | intercept: f64, 34 | 35 | // Estimate of RMS deviation of y values compared to the linear regression 36 | // trend. 37 | y_noise: f64, 38 | 39 | // Estimate of the standard error of the slope value. 40 | slope_noise: f64, 41 | 42 | // Allows part of add() logic to be incremental. 43 | x_sum: f64, 44 | y_sum: f64, 45 | } 46 | 47 | impl RateEstimation { 48 | // Creates a new RateEstimation and add()s the first observation to it. 49 | // `capacity` governs how many add()ed points are kept to compute the rate 50 | // estimation. Note that even though we retain a finite number of points, 51 | // the estimated `slope` continues to improve over time as the time span of 52 | // added values increases. 53 | pub fn new(capacity: usize, time: SystemTime, value: f64) -> Self { 54 | let mut re = RateEstimation { 55 | first: time, 56 | last: SystemTime::UNIX_EPOCH, 57 | reservoir: ReservoirSampler::::new(capacity), 58 | slope: 0.0, 59 | intercept: 0.0, 60 | y_noise: 0.0, 61 | slope_noise: 0.0, 62 | x_sum: 0.0, 63 | y_sum: 0.0, 64 | }; 65 | re.add(time, value, 0.0); 66 | re 67 | } 68 | 69 | // Successive calls to add() must have increasing `time` arg values. 70 | pub fn add(&mut self, time: SystemTime, value: f64, noise_estimate: f64) { 71 | if time <= self.last { 72 | // This can happen when the client updates the server's system time. 73 | if time <= self.last - Duration::from_secs(10) { 74 | warn!("Time arg regressed from {:?} to {:?}", self.last, time); 75 | } 76 | self.last = time; 77 | return; 78 | } 79 | self.last = time; 80 | let (added, removed) = self.reservoir.add(DataPoint{x: time, y: value}); 81 | if let Some(removed) = removed { 82 | let x = removed.x.duration_since(SystemTime::UNIX_EPOCH).unwrap() 83 | .as_secs_f64(); 84 | self.x_sum -= x; 85 | self.y_sum -= removed.y; 86 | } 87 | if added { 88 | self.x_sum += 89 | time.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs_f64(); 90 | self.y_sum += value; 91 | } 92 | let count = self.reservoir.count(); 93 | if count < 2 { 94 | return; 95 | } 96 | let count = count as f64; 97 | let x_mean = self.x_sum / count; 98 | let y_mean = self.y_sum / count; 99 | 100 | let mut num = 0.0_f64; 101 | let mut den = 0.0_f64; 102 | for sample in self.reservoir.samples() { 103 | let x = sample.x.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs_f64(); 104 | num += (x - x_mean) * (sample.y - y_mean); 105 | den += (x - x_mean) * (x - x_mean); 106 | } 107 | // `den` will be non-zero because we require the `time` arg to be non-stationary. 108 | assert!(den > 0.0); 109 | self.slope = num / den; 110 | let first_x = 111 | self.first.duration_since(SystemTime::UNIX_EPOCH).unwrap() 112 | .as_secs_f64(); 113 | self.intercept = y_mean - self.slope * (x_mean - first_x); 114 | 115 | let mut y_variance = 0.0_f64; 116 | for sample in self.reservoir.samples() { 117 | let y_reg = self.estimate_value(sample.x); 118 | y_variance += (sample.y - y_reg) * (sample.y - y_reg); 119 | } 120 | let adjusted_y_variance = f64::max(y_variance, noise_estimate * noise_estimate); 121 | self.y_noise = (adjusted_y_variance / count).sqrt(); 122 | self.slope_noise = ((1.0 / (count - 2.0)) * adjusted_y_variance / den).sqrt(); 123 | } 124 | 125 | pub fn count(&self) -> usize { 126 | self.reservoir.count() 127 | } 128 | 129 | // Returns the `time` of the most recent `add()` call. 130 | pub fn last_time(&self) -> SystemTime { 131 | self.last 132 | } 133 | 134 | // Determines if the given data point is on-trend, within `sigma` multiple of 135 | // the model's noise. 136 | // `time` must not be earlier than the first add()ed data point. 137 | // If count() is less than 3, returns true. 138 | pub fn fits_trend(&self, time: SystemTime, value: f64, sigma: f64) -> bool { 139 | if self.count() < 3 { 140 | return true; 141 | } 142 | let regression_estimate = self.estimate_value(time); 143 | let deviation = (value - regression_estimate).abs(); 144 | deviation < sigma * self.y_noise 145 | } 146 | 147 | fn estimate_value(&self, time: SystemTime) -> f64 { 148 | let x = time.duration_since(self.first).unwrap().as_secs_f64(); 149 | (self.intercept + x * self.slope).into() 150 | } 151 | 152 | // Returns estimated rate of change in value per second of time. 153 | // count() must be at least 2. 154 | pub fn slope(&self) -> f64 { 155 | assert!(self.count() > 1); 156 | self.slope.into() 157 | } 158 | 159 | // This bound is an estimate of the +/- range of slope() within which the 160 | // true rate is likely to be. 161 | pub fn rate_interval_bound(&self) -> f64 { 162 | assert!(self.count() > 2); 163 | self.slope_noise 164 | } 165 | } 166 | 167 | #[cfg(test)] 168 | mod tests { 169 | extern crate approx; 170 | use approx::assert_abs_diff_eq; 171 | use super::*; 172 | 173 | #[test] 174 | fn test_rate_estimation() { 175 | let mut time = SystemTime::now(); 176 | // Create with first point. 177 | let mut re = RateEstimation::new(5, time, 1.0); 178 | assert_eq!(re.count(), 1); 179 | 180 | // Add a second point, one second later and 0.1 higher. 181 | time += Duration::from_secs(1); 182 | assert!(re.fits_trend(time, 1.1, /*sigma=*/1.0)); 183 | re.add(time, 1.1, 0.1); 184 | assert_eq!(re.count(), 2); 185 | assert_abs_diff_eq!(re.slope(), 0.1, epsilon = 0.001); 186 | 187 | // Add a third point, slightly displaced from the trend. 188 | time += Duration::from_secs(1); 189 | assert!(re.fits_trend(time, 1.22, /*sigma=*/1.0)); 190 | re.add(time, 1.22, 0.1); 191 | assert_eq!(re.count(), 3); 192 | assert_abs_diff_eq!(re.slope(), 0.11, epsilon = 0.001); 193 | assert_abs_diff_eq!(re.rate_interval_bound(), 0.07, epsilon = 0.01); 194 | 195 | // Fourth point. 196 | time += Duration::from_secs(1); 197 | assert!(!re.fits_trend(time, 1.25, /*sigma=*/1.0)); 198 | assert!(re.fits_trend(time, 1.31, /*sigma=*/1.0)); 199 | } 200 | 201 | } // mod tests. 202 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | Default pattern database: 2 | * update for proper density 3 | * go down to 5deg? Helps 10deg fields that are partially obstructed. Doc this. 4 | * tune for Hopper 5 | * separate (broader) database for DIY version 6 | 7 | auto-detect alt-az vs equatorial (with manual override) 8 | 9 | Calibrate button. 10 | 11 | Base python scripting support for working with Cedar grpc server. 12 | 13 | Mic button on keyboard for voice input. Can work offline? Good for 14 | "en gee cee oh five six" utterances? 15 | 16 | Make polar advice data block tappable, zooms to larger dialog. 17 | * polar advice can just be an "idea" icon. 18 | 19 | Mobile app: support split screen; support picture-in-picture. 20 | 21 | Different on-screen prompt on connection loss vs. never having connection. 22 | Loss reasons: check server power (battery drained?), check phone's wifi 23 | connection (sometimes phone reverts to cell connection). 24 | 25 | "serve engine" pipeline stage. Moves some logic out of cedar_server.rs. 26 | 27 | Update "connection lost" message for app (instead of web app). 28 | 29 | Ability to access (and download) saved images in Cedar Aim. 30 | Maybe save them to web side in the first place? 31 | High-quality jpg with exif info. 32 | 33 | Tetra3 34 | * use brightness ratio (broadly quantized) of most separated stars in 35 | pattern as additional discriminant (post hash lookup? or incorporate 36 | into hash?) 37 | 38 | 39 | time display: 40 | * local time, UTC 41 | * time to sunset/sunrise, astro twilight 42 | * moonrise/set 43 | * display all? selectable? 44 | 45 | torture case: cap on 46 | * don't time out UI 47 | * don't cause led to blink 48 | * put up screen suggesting to check lens cap 49 | 50 | Why need to disconnect/reconnect wifi? Symptom is that wifi 51 | icon in phone status bar disappears even though wifi still 52 | connected (but not providing internet) 53 | * Android app: control fallback to cell data. 54 | * User workaround: airplane mode, then enable wifi 55 | 56 | Option to unset observer location (long press?) 57 | 58 | Catalog 59 | * text search: preprocess input, e.g. the following should all locate 60 | NGC0426 (and maybe others): 61 | NGC0426 62 | NGC426 63 | NGC 426 64 | NGC 0426 65 | N426 66 | 67 | visual feedback on camera errors 68 | visual feedback on connectivity errors 69 | visual feedback on SkySafari connection 70 | 71 | add (tm) at appropriate places for Cedar 72 | 73 | "happy" sound on "align" button press. 74 | 75 | Remove refresh rate control. Go full speed; when dwelling for more than N 76 | seconds drop to 5hz. 77 | 78 | Pacifier/busy indicator for capturing/downloading image 79 | 80 | eq vs alt/az setting 81 | * convey to SkySafari 82 | 83 | Support Stellarium telescope control 84 | 85 | Cedar Pole Align 86 | * top-level mode? 87 | * alert if no observer location 88 | * alert if not tracking mount (or not roughly polar aligned) 89 | * guidance for where to point scope 90 | * king method? 91 | Polar alignment technique: 92 | https://www.sharpcap.co.uk/sharpcap/features/polar-alignment 93 | https://github.com/ThemosTsikas/PhotoPolarAlign 94 | https://www.considine.net/aplanatic/align.htm 95 | 96 | create IOS app 97 | create Android app 98 | * option to download APK file from Cedar server for sideloading. 99 | * benefits (for both): access to location info; switching 100 | app to SkySafari? Screen wake lock. 101 | * control over wifi on phone? 102 | 103 | Cedar Journal 104 | * capture Cedar Sky goto requests (and whether target was reached) 105 | * capture RA/DEC when long-enough dwell detected 106 | TBD 107 | 108 | Cedar Sky 109 | Remaining work: 110 | * add more catalogs 111 | - Caldwell (list, not catalog-- affects display logic re naming) 112 | - Arp, Hickson (lists, not catalogs) 113 | - double stars 114 | - stars down to some limit 115 | - what else? 116 | * find non-python solution for asteroids/comets 117 | * way to add new solar system object, e.g. comet 118 | - cloud-based? 119 | * meridian ordering (advanced) 120 | * identify constellation of boresight 121 | * identify constellation of planet 122 | * constellation lines 123 | 124 | Push-to ra/dec button: pops up text entry field 125 | 126 | remote ops, proxied via phone 127 | * update cedar server 128 | * download logs 129 | * send bug reports 130 | 131 | "advanced" mode enable 132 | * polar alignment advice 133 | 134 | bug report 135 | 136 | interoperation with SkySafari 137 | * goto vs. pushto 138 | * switch apps? 139 | * split screen? 140 | * picture-in-picture? 141 | * support LX200 (also for Nexus DSC) 142 | 143 | help system 144 | * long press? (GestureDetector wrapped around a widget) 145 | 146 | vignetting calibration 147 | 148 | Wifi management 149 | * switching wifi mode-- setting in web UI? 150 | * bluetooth? disable if not using? 151 | 152 | FITS support for saved images? Other formats? 153 | 154 | logging 155 | * observer log is separate 156 | * move cedar_log.txt to sane directory (cmd line arg?) 157 | 158 | motion classification 159 | * adjust update interval 160 | 161 | UI: 162 | * operation controls 163 | - save image: add confirmation+rename 164 | 165 | Help page 166 | * mostly redirects attention to long-press aids? 167 | 168 | Sounds for various actions/events in app? 169 | --------------------------------------------------------------------------------