diff --git a/.github/workflows/mockup-apk.yml b/.github/workflows/android.yml similarity index 59% rename from .github/workflows/mockup-apk.yml rename to .github/workflows/android.yml index 848834e..40256e2 100644 --- a/.github/workflows/mockup-apk.yml +++ b/.github/workflows/android.yml @@ -3,19 +3,19 @@ # The APK is deliberately not committed to the repository: .gitignore excludes # *.apk on purpose, and a binary in git rots. This workflow is where the APK # comes from instead. Every run publishes it as a downloadable artifact, and -# pushing a mockup-v* tag also attaches it to a GitHub release. +# pushing a v* tag also attaches it to a GitHub release. # -# SPDX-License-Identifier: GPL-2.0-or-later +# SPDX-License-Identifier: MPL-2.0 -name: Mockup APK +name: Android on: push: branches: [main] - paths: ['app/mockup/**', '.github/workflows/mockup-apk.yml'] - tags: ['mockup-v*'] + paths: ['app/junco/**', '.github/workflows/mockup-apk.yml'] + tags: ['v*'] pull_request: - paths: ['app/mockup/**', '.github/workflows/mockup-apk.yml'] + paths: ['app/junco/**', '.github/workflows/mockup-apk.yml'] workflow_dispatch: permissions: @@ -43,36 +43,48 @@ jobs: "platforms;android-34" "build-tools;35.0.0" - name: Build APK - run: ./app/mockup/android/build.sh + run: ./app/junco/android/build.sh - name: Check the APK is well formed run: | - APK=app/mockup/android/build/junco-pfd-mockup.apk + APK=app/junco/android/build/junco.apk BT="$ANDROID_HOME/build-tools/35.0.0" test -f "$APK" "$BT/apksigner" verify "$APK" # The whole point of the wrapper is that the web assets ship inside it. - for f in index.html manifest.webmanifest sw.js icon-192.png icon-512.png; do + for f in index.html profile.js net.js ops.js tiles.js airports.js manifest.webmanifest sw.js icon-192.png icon-512.png data/airports.json; do unzip -l "$APK" | grep -q "assets/$f" || { echo "missing assets/$f"; exit 1; } done - "$BT/aapt2" dump badging "$APK" | grep -q "package: name='org.junco.mockup'" + "$BT/aapt2" dump badging "$APK" | grep -q "package: name='com.keylinkit.junco'" echo "APK OK: $(du -h "$APK" | cut -f1)" - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: junco-pfd-mockup-apk - path: app/mockup/android/build/junco-pfd-mockup.apk + name: junco-apk + path: app/junco/android/build/junco.apk if-no-files-found: error + - name: Build release bundle + if: startsWith(github.ref, 'refs/tags/v') + env: + JUNCO_VERSION_NAME: ${{ github.ref_name }} + JUNCO_VERSION_CODE: ${{ github.run_number }} + run: | + # Unsigned here on purpose. The upload key never goes in CI unless you + # deliberately add it as a secret; an unsigned bundle is still useful + # for checking the release path builds. + cd app/junco/android && gradle bundleRelease --no-daemon + - name: Attach to release - if: startsWith(github.ref, 'refs/tags/mockup-v') + if: startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ github.token }} run: | gh release create "${GITHUB_REF_NAME}" \ - app/mockup/android/build/junco-pfd-mockup.apk \ + app/junco/android/build/junco.apk \ + app/junco/android/build/outputs/bundle/release/*.aab \ --title "PFD layout mockup ${GITHUB_REF_NAME}" \ - --notes "Layout mockup only. Not an instrument, not airworthy, not the Junco client. Debug-signed for sideloading; enable install from unknown sources. See app/mockup/README.md." \ + --notes "Layout mockup only. Not an instrument, not airworthy, not the Junco client. Debug-signed for sideloading; enable install from unknown sources. See app/junco/README.md." \ || gh release upload "${GITHUB_REF_NAME}" \ - app/mockup/android/build/junco-pfd-mockup.apk --clobber + app/junco/android/build/junco.apk --clobber diff --git a/.gitignore b/.gitignore index 0f6ad65..9568089 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ logs/ # OS .DS_Store Thumbs.db + +# Android signing — never commit an upload key +*.jks +upload-keystore.properties diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6900d9c..6629f55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,22 +34,32 @@ be declined regardless of its merits elsewhere. ## Licensing your contribution -Code is GPL-2.0-or-later, hardware is CERN-OHL-S-2.0, documentation and -specifications are CC-BY-4.0. By opening a pull request you agree your +Code is MPL-2.0, hardware is CERN-OHL-S-2.0, documentation and specifications +are CC-BY-4.0. By opening a pull request you agree your contribution ships under the license covering that artifact. +Sign your commits off with `git commit -s`, which appends: + + Signed-off-by: Your Name + +That is the Developer Certificate of Origin, the same one the Linux kernel uses. +It is not a copyright assignment and it does not ask you to give anything up. It +is a statement that you wrote the contribution or otherwise have the right to +submit it under this license, which is what makes the project safe to +redistribute, including through an app store. + Every source file starts with: - SPDX-License-Identifier: GPL-2.0-or-later + SPDX-License-Identifier: MPL-2.0 -The "or later" is not optional and not decorative. It is what lets code move -between Junco and MakerPlane's FIX-Gateway and pyEFIS, which are GPL v2 or -later, and it keeps GPLv3 reachable if ArduPilot code is ever wanted. A file -contributed as GPL-2.0-only would break both. +MPL is per-file copyleft. Changing a Junco file means publishing that file's +changes; everything you combine it with stays yours. It is also GPL-compatible, +so code still moves both ways with MakerPlane's FIX-Gateway and pyEFIS. Do not paste in code from a permissively licensed project without checking that -the attribution requirements survive the relicensing, and do not paste in code -from a GPLv3-only project at all. +its attribution requirements survive. Do not paste in GPL code: MPL can be +combined with GPL downstream, but pulling GPL code in would force the whole work +to GPL and put Junco back where it could not ship on iOS. ## Specifications before implementations diff --git a/LICENSE b/LICENSE index 9efa6fb..d0a1fa1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,338 +1,373 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, see . - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Moe Ghoul, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 904a3a8..2799b45 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -39,24 +39,23 @@ assumes someone is there to act on it. ## Why the licenses differ -Firmware is GPL-2.0-or-later, hardware is CERN-OHL-S-2.0, documentation and -specifications are CC-BY-4.0. - -The code and the hardware are copyleft because the purpose of the project is to -put this capability into the world permanently, and a permissive license lets a -better funded fork take the work closed and outrun the original. Anyone may -still build, modify, sell, or fork Junco. What they may not do is stop passing -it on. - -GPL v2 **or later** matches MakerPlane, so code moves in both directions between -this project and FIX-Gateway or pyEFIS without relicensing. The "or later" also -keeps GPLv3 reachable if ArduPilot code is ever wanted for the v2 bus stage. - -CERN-OHL-S is the strongly reciprocal license actually written for hardware -design artifacts. A software license does not cleanly cover board files and -mechanical models. - -The specifications are the deliberate exception and stay CC-BY, so anyone can -implement them in anything, including a closed product. A protocol that cannot -be adopted freely does not outlive its implementation, which is the entire -reason `spec/` exists. +Code is MPL-2.0, hardware is CERN-OHL-S-2.0, documentation and specifications +are CC-BY-4.0. + +The code is weak copyleft rather than strong. Change a Junco file and you publish +that file; combine it with anything you like otherwise, including closed code, +and charge for the result. That is a deliberate loosening from the GPL chosen in +revision 4, and the reason is distribution: GPL cannot ship on Apple's App Store, +because Apple's DRM imposes exactly the further restrictions GPL section 6 +forbids. VLC was pulled over it. MPL-2.0 is what Firefox for iOS ships under. + +MPL is also explicitly GPL-compatible through its Secondary License clause, so +the two-way flow with MakerPlane's FIX-Gateway and pyEFIS that motivated the GPL +choice survives the change intact. + +The hardware stays strongly reciprocal. No app store touches a board file, so +none of the above applies to it. + +The specifications stay CC-BY so anyone can implement them in anything. A +protocol that cannot be adopted freely does not outlive its implementation, which +is the entire reason `spec/` exists. diff --git a/README.md b/README.md index 4a7a67c..e7ba839 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ Target cost is under $180 in parts. Target build is one person with a soldering ## Status -**Pre-Phase 0.** Nothing has flown. Nothing has been built. This repository currently contains requirements and specifications only. +**The app works. The node does not exist yet.** -See [docs/prd.md](docs/prd.md) section 16 for the phase plan and section 15 for the assumptions that only flight testing can resolve. +[`app/junco/`](app/junco/) is an Android electronic flight bag that is useful today with no hardware at all: a primary flight display from the phone's own sensors, FAA sectional and terminal charts, direct-to navigation, 25,000 bundled US airports with frequencies, editable per-aircraft checklists, an automatic logbook, weather, and advisory traffic. Offline by design. See [its README](app/junco/README.md) and [RELEASE.md](app/junco/RELEASE.md). + +The **node** — the sensor hardware this project started as — is **pre-Phase 0**. Nothing has been built and nothing has flown. See [docs/prd.md](docs/prd.md) section 16 for the phase plan and section 15 for the assumptions only flight testing can resolve. [docs/open-questions.md](docs/open-questions.md) lists everything still undecided, in one place, separated into what needs a measurement and what needs a decision. @@ -53,7 +55,7 @@ If you feed Junco data into an autopilot, you have made Junco flight-critical fo | `firmware/` | Node firmware | | `hardware/` | Schematics and board files | | `enclosure/` | Printable enclosure, pitot, static plenum | -| `app/` | Android reference client | +| `app/` | Android application, and the BLE client for the node | | `tools/` | Log recovery and analysis | --- @@ -78,15 +80,15 @@ Different artifacts, different licenses. See [LICENSE](LICENSE), [LICENSE-HARDWA | Artifact | License | SPDX | |---|---|---| -| Firmware, app, tools | GNU GPL v2 or later | `GPL-2.0-or-later` | +| App, firmware, tools | Mozilla Public License 2.0 | `MPL-2.0` | | Board files, enclosure models | CERN-OHL-S-2.0 | `CERN-OHL-S-2.0` | | Documentation and specifications | CC-BY-4.0 | `CC-BY-4.0` | -You may build Junco for yourself. You may sell assembled units. You may fork it, rename it, and compete with it. That is intentional. What you may not do is take it closed: improvements to the firmware and the hardware return to everyone under the same terms. +You may build Junco for yourself. You may sell it. You may fork it, rename it, and compete with it, including commercially. That is intentional. -Firmware is GPL v2 **or later**, which matches MakerPlane exactly, so code moves in both directions between Junco and FIX-Gateway or pyEFIS without friction. Every source file carries: +MPL-2.0 is **weak copyleft, per file**: if you change a Junco file you publish that file's changes, and you may combine it with anything else including closed code. It is the licence Firefox for iOS ships under, which is the point — GPL cannot go on the App Store, and this can. It is also explicitly GPL-compatible, so code still moves both ways with MakerPlane's FIX-Gateway and pyEFIS. Every source file carries: - SPDX-License-Identifier: GPL-2.0-or-later + SPDX-License-Identifier: MPL-2.0 **The specifications stay permissive deliberately.** They are meant to be implemented by anyone, including in closed products, because a protocol nobody is allowed to adopt is a protocol nobody adopts. CAN-FIX is Creative Commons for the same reason. diff --git a/app/README.md b/app/README.md index 3ebfb39..742a801 100644 --- a/app/README.md +++ b/app/README.md @@ -12,6 +12,8 @@ iOS is not on the critical path for v1, but the protocol is deliberately reachab Traffic display, if anyone builds it, is an app-side channel that never involves the node. See docs/prd.md section 22. -`mockup/` holds a PFD layout prototype as an installable web app. It is not the client and is not on the path to being it — it exists to settle layout questions before anyone writes an APK, because a tape range is cheap to change there and expensive here. See its README for what it answers and how to serve it. +`junco/` is the shipping Android application. It began as a layout prototype and outgrew that: it is now an electronic flight bag that is useful with no hardware at all, heading for Google Play. See its README and RELEASE.md, and PRD section 25. + +This directory otherwise describes the BLE client for the node, which is the part of the app still waiting on hardware to exist. This app is the reference client and the only one required in v1, but it is no longer the only client. A build with a panel and a Pi can reach pyEFIS through a FIX-Gateway plugin instead. See docs/prd.md section 24. The primary aircraft has no panel, which is why the phone comes first. diff --git a/app/junco/README.md b/app/junco/README.md new file mode 100644 index 0000000..b6e013a --- /dev/null +++ b/app/junco/README.md @@ -0,0 +1,124 @@ +# Junco — Android electronic flight bag + +An electronic flight bag for ultralight and experimental aircraft, useful today +with no hardware at all. Primary flight display from the phone's own sensors, +FAA sectional and terminal charts, direct-to navigation, 25,000 bundled US +airports with frequencies, per-aircraft checklists, an automatic logbook, +weather, and advisory traffic. Offline by design. + +**It started as a layout study for hardware that did not exist**, and outgrew +that. See `RELEASE.md` for the path to Google Play and PRD section 25 for how +each design rule shows up in it. + +## Questions it was built to answer, and did + +1. **Does design rule 8 survive a real layout?** Twenty small source tags read as + noise. The answer this app proposes is the one real avionics already use: + annunciate the source on the instrument. `PHONE AHRS · ADVISORY` on the + attitude indicator, cyan for node-plumbed channels, magenta for GPS-derived. +2. **Is phone attitude worth showing at all?** PRD section 15 lists this as open. + The attitude here is low-pass filtered because raw phone fusion is too jittery + to look at sitting still on a desk, which is an early data point and not an + encouraging one. +3. **Does an HSI earn its space** on an aircraft that mostly flies straight? + +## Running it + +It works with no sensors at all. On load it flies a scripted circuit, and +dragging anywhere on the display flies it by hand. That is enough to judge +layout, and arguably better than real sensors, because it holds sustained bank +angles you would not produce waving a phone around. + +For **real** attitude the page has to be a top-level HTTPS document. Device +orientation is gated by Permissions Policy, so in a cross-origin iframe it never +fires, and a page cannot grant itself the permission. + +| Where | What you get | +|---|---| +| Opened inside any iframe | Demo flight only. The diagnostics panel says why | +| Desktop Chrome, top level | DevTools → More tools → Sensors drives it | +| Phone over HTTPS, top level | Real attitude, heading, track, ground speed, position | +| Installed to the home screen | The above, plus fullscreen, landscape lock, screen wake lock, and offline | + +The simplest way to get the last row: publish this directory with GitHub Pages +and open it on the phone, then use the browser's *Add to Home Screen*. The +service worker caches the shell on first load, so it runs afterwards with no +signal. + +## As an Android app + +`android/` wraps these files in an APK. It exists for one reason: device +orientation and geolocation are only available to a **secure context**, so a +WebView loading `file://` would silently kill exactly the sensors the app is +for. The wrapper serves the bundled assets over an `https://` origin and +intercepts those requests locally. Nothing leaves the device and no network is +used. + +Build it with `android/build.sh`, which needs `ANDROID_HOME` and build-tools 35 +or newer. It uses the raw SDK tools rather than Gradle, because one activity +with no dependencies does not need a plugin and a version matrix. + +**The APK is not committed.** `.gitignore` excludes `*.apk` deliberately, so the +`App APK` workflow builds it instead: every run uploads it as an artifact, +and pushing a `v*` tag attaches it to a release. + +It is debug-signed, so installing it means allowing unknown sources. That is +appropriate for a app and would not be appropriate for anything else. + +## Weight and balance + +`W&B` on the tab bar. It ships **off**, with no numbers, and stays that way until +you enter values from an actual weighing. + +That is deliberate and it is the only field in the whole app that works this way. +Every other preset value is a plausible starting point you correct later; an +empty weight is not. There is no plausible empty weight for an aircraft nobody +has weighed, and a fabricated one that happens to close the envelope is worse +than a blank page, because it looks like an answer. + +Enter empty weight and arm, max gross, the CG envelope, and a station for each +seat and baggage area, all measured aft of whatever datum your weighing report +used. Loads persist per aircraft, so the pilot's own weight is typed once. + +The **Part 103 check** underneath it tests 14 CFR 103.1 against what the app can +actually see, and says so where it cannot: two of the four numeric limits are +calibrated airspeeds no phone can measure, and the 254 lb empty weight limit +excludes floats and safety devices intended for deployment in a potentially +catastrophic situation, so a ballistic parachute has to come out of the number +before you type it. **Arithmetic aid, not a finding of compliance.** + +Fuel on board is entered here too. It drives endurance, time to reserve, and +fuel remaining at the destination on the main screen. It is a number you typed, +not a measurement, and every readout of it shows how long ago you typed it. + +## What is real and what is invented + +**Real, from the device:** attitude, heading, track, ground speed, GPS altitude, +position. + +**Real, but from you:** empty weight, station arms, loads, and fuel on board. +Marked as entered rather than measured wherever they are used. + +**Simulated:** the entire engine strip, indicated airspeed, pressure altitude, +and vertical speed. Those are node channels and no node exists. Values are +plausible PM-2 cruise numbers. + +**Impossible:** pressure altitude and vertical speed can never come from the +phone, because there is no web API for ambient pressure. PRD section 6 reaches +the same conclusion from a different direction, and this is one more reason the +plumbed plenum is not optional. + +## Why a WebView, and where that stops working + +A web payload in a thin native wrapper builds in seconds, runs identically in a +browser for development, and needs no toolchain to change a tape range. For +everything the app does today, that is the right trade. + +It stops working at the node. Web Bluetooth reaches a peripheral on Android, but +it needs a user gesture per connection, has no background operation, and drops +when the page is suspended, so it cannot hold a link for a two hour flight. When +the node exists, the BLE client belongs in the native layer and the WebView keeps +the display. See PRD section 19. + +`build.sh` is the ten-second debug loop. `android/build.gradle` produces the +signed App Bundle that Google Play requires. Both build the same web payload. diff --git a/app/junco/RELEASE.md b/app/junco/RELEASE.md new file mode 100644 index 0000000..f8b50e7 --- /dev/null +++ b/app/junco/RELEASE.md @@ -0,0 +1,128 @@ +# Publishing Junco to Google Play + +What is done, what you must do, and the two decisions that are permanent. + +## Permanent once you publish + +**Package name: `com.keylinkit.junco`.** I picked `keylinkit.com` because it is a +domain you demonstrably control, which is the convention. **Change it now if you +want something else** — after the first upload it can never change, and shipping +under a domain you do not own is the mistake that cannot be undone. It appears in +`android/build.gradle`, `AndroidManifest.xml`, the Java package path, and +`build.sh`. + +**Your upload key.** Generate it once and never lose it: + +``` +keytool -genkeypair -v -keystore junco-upload.jks -storetype PKCS12 \ + -keyalg RSA -keysize 4096 -validity 10000 -alias upload +``` + +Keep it out of the repository — `.gitignore` already excludes `*.keystore` and +`*.jks`. Back it up somewhere you will still have in ten years. Enrol in **Play +App Signing** when you first upload: Google then holds the app signing key and +your upload key can be reset if lost. Without it, a lost key means you can never +update the app and must publish a new listing. + +## Build the bundle + +Play requires an Android App Bundle, not an APK, so releases go through Gradle. +`android/build.sh` stays for local iteration and still produces a debug APK in +about ten seconds. + +``` +export JUNCO_KEYSTORE=/secure/path/junco-upload.jks +export JUNCO_KEYSTORE_PASSWORD=... +export JUNCO_KEY_ALIAS=upload +export JUNCO_KEY_PASSWORD=... +export JUNCO_VERSION_CODE=1 +export JUNCO_VERSION_NAME=0.1.0 +cd app/junco/android && gradle bundleRelease +``` + +Output: `android/build/outputs/bundle/release/android-release.aab`. + +`versionCode` must increase on every upload and can never be reused. + +## Done already + +- Target SDK 35, min SDK 24 +- No ads, no analytics, no tracking, no network beyond charts, weather, and traffic +- Location used in the foreground only, never in the background, which avoids the + most onerous Play location review +- Safety acknowledgement shown at every launch, as every marine and aviation GPS does +- No account, no login, nothing uploaded +- Offline by design; the airport database and cached charts work with no signal + +## What you must supply + +**Privacy policy at a public URL.** Required for any app requesting location. +Yours is unusually easy to write because it is nearly all "no": position, flight +logs, aircraft profiles and checklists stay on the device; nothing is collected, +transmitted, or shared; chart, weather and traffic requests go directly to +OpenStreetMap, aviationweather.gov and airplanes.live, which see an IP address as +any web request does. + +**Data safety form.** Declare *Location — approximate and precise*, used for App +functionality, **not collected** (it never leaves the device). Answer no to data +sharing and no to data collection everywhere else. Getting this wrong is the most +common cause of a rejected aviation app. + +**Store listing.** + +| Asset | Requirement | +|---|---| +| App icon | 512×512 PNG, 32-bit | +| Feature graphic | 1024×500 PNG or JPEG | +| Phone screenshots | 2–8, at least 1080 px on the short side | +| 7" and 10" tablet screenshots | Recommended; you have a Fold, so use it | +| Short description | 80 characters | +| Full description | 4000 characters | + +`icon-512.png` in this directory is a starting point but is a flat generated +image; a real listing icon deserves better. + +**Content rating questionnaire** and **target audience** — not for children. + +## Say the limits in the listing, not just in the app + +Play reviews apps that could affect safety, and an aviation instrument is one. +Do not oversell. The description should state plainly that it is **advisory +only, not a certified aviation product, not for navigation**, that charts and +airport data may be out of date, and that traffic is incomplete. The first-run +screen already says all of this; the listing should agree with it. A listing that +promises more than the app should deliver is both a rejection risk and a +liability problem. + +## Two things worth thinking about before you press publish + +**Liability.** This is a flight instrument for aircraft with no panel, and +someone will eventually rely on it more than they should. A disclaimer helps and +does not make the question disappear. Worth an hour with someone who knows +aviation product liability before it is public, not after. + +**GPL and Play — I overstated this earlier and want to correct it.** The famous +GPL-versus-app-store problem is Apple's, not Google's: the App Store imposes +usage restrictions that conflict with GPL section 6. Google Play does not impose +equivalent restrictions, GPL apps are distributed there routinely, and the +obligation is simply that source stays available — which it is, in this +repository. + +That holds for outside contributions too. A contributor licensing their work +under GPL-2.0-or-later has already granted everything redistribution through Play +needs. **No contributor licence agreement is required.** A Developer Certificate +of Origin is still worth adopting, not for Play but so every contribution carries +a record that the contributor had the right to submit it. `CONTRIBUTING.md` now +asks for a `Signed-off-by` line. + +The license already does what you asked for: free, and anyone may build on it. +The one thing it adds is that a fork stays open too, which is what you chose in +revision 4 and there is no reason to revisit. + +## Also worth doing before real users arrive + +- A crash reporter that does not phone home, or a plain "copy diagnostics" button +- Test on a genuinely small screen; the layout is built for landscape tablets and + large phones +- Decide what happens when the airport database goes stale, since it is bundled + and only updates when the app does diff --git a/app/junco/airports.js b/app/junco/airports.js new file mode 100644 index 0000000..d6500bf --- /dev/null +++ b/app/junco/airports.js @@ -0,0 +1,117 @@ +/* Junco — airport and frequency database. + * + * Bundled, not fetched. 25,000 US airports with 13,000 frequencies is under + * 600 kB compressed, and an aircraft with an engine problem at 800 feet has no + * signal and no time. The whole point of "nearest airport" is that it works + * when nothing else does, so it ships inside the app. + * + * Source: OurAirports (https://ourairports.com), released into the public + * domain. Community maintained, which means it is more complete than official + * sources for small private strips and occasionally wrong. Frequencies in + * particular are worth confirming against the Chart Supplement before you key + * the mic at a towered field. + * + * Regenerate with tools/build-airports.py. + * + * SPDX-License-Identifier: MPL-2.0 + */ +(function (global) { + "use strict"; + var N = global.JuncoNet; + + // [ident, name, lat, lon, elevFt, typeCode, [[freqType, mhz], ...]] + var DB = null, loading = false, ready = null; + + var TYPE = { S: "Airport", M: "Airport", L: "Airport", W: "Seaplane", H: "Heliport", B: "Balloonport" }; + var FREQ = { + CTAF: "CTAF", UNIC: "UNICOM", TWR: "Tower", GND: "Ground", ATIS: "ATIS", + AWOS: "AWOS", ASOS: "ASOS", APP: "Approach", DEP: "Departure", CNTR: "Center", + CLD: "Clearance", RDO: "Radio", AFIS: "AFIS", MISC: "Other" + }; + + function load() { + if (ready) { return ready; } + loading = true; + ready = fetch("./data/airports.json", { cache: "force-cache" }) + .then(function (r) { return r.json(); }) + .then(function (j) { DB = j; loading = false; return DB; }) + .catch(function () { loading = false; DB = []; return DB; }); + return ready; + } + + function isReady() { return DB !== null; } + function count() { return DB ? DB.length : 0; } + + /** + * Nearest airports to a position. + * Coarse latitude gate first: scanning 25,000 great-circle distances every + * frame would be wasteful, and one degree of latitude is 60 nm. + */ + function nearest(lat, lon, limit, maxNM) { + if (!DB || lat === null) { return []; } + limit = limit || 8; + maxNM = maxNM || 50; + var dLat = maxNM / 60 + 0.05, out = []; + for (var i = 0; i < DB.length; i++) { + var a = DB[i]; + if (a[2] < lat - dLat) { continue; } + if (a[2] > lat + dLat) { break; } // sorted by latitude + var d = N.distNM(lat, lon, a[2], a[3]); + if (d > maxNM) { continue; } + out.push({ + ident: a[0], name: a[1], lat: a[2], lon: a[3], elev: a[4], + kind: TYPE[a[5]] || "Airport", freqs: a[6] || [], + dist: d, brg: N.bearing(lat, lon, a[2], a[3]) + }); + } + out.sort(function (x, y) { return x.dist - y.dist; }); + return out.slice(0, limit); + } + + /** Airports inside a bounding box, for drawing on the map. */ + function inBox(lat0, lon0, lat1, lon1, cap) { + if (!DB) { return []; } + var out = []; + for (var i = 0; i < DB.length; i++) { + var a = DB[i]; + if (a[2] < lat0) { continue; } + if (a[2] > lat1) { break; } + if (a[3] < lon0 || a[3] > lon1) { continue; } + out.push({ ident: a[0], name: a[1], lat: a[2], lon: a[3], kind: a[5] }); + if (out.length >= (cap || 300)) { break; } + } + return out; + } + + function byIdent(id) { + if (!DB || !id) { return null; } + id = String(id).toUpperCase().replace(/^K/, ""); + for (var i = 0; i < DB.length; i++) { + if (DB[i][0] === id || DB[i][0] === "K" + id) { + var a = DB[i]; + return { ident: a[0], name: a[1], lat: a[2], lon: a[3], elev: a[4], freqs: a[6] || [] }; + } + } + return null; + } + + function freqLabel(code) { return FREQ[code] || code; } + + /** CTAF or UNICOM, which is the one a Part 103 pilot actually needs. */ + function primaryFreq(ap) { + if (!ap || !ap.freqs.length) { return null; } + var order = ["CTAF", "UNIC", "TWR", "AFIS", "RDO"]; + for (var i = 0; i < order.length; i++) { + for (var j = 0; j < ap.freqs.length; j++) { + if (ap.freqs[j][0] === order[i]) { return { label: freqLabel(order[i]), mhz: ap.freqs[j][1] }; } + } + } + return { label: freqLabel(ap.freqs[0][0]), mhz: ap.freqs[0][1] }; + } + + global.JuncoAirports = { + load: load, isReady: isReady, count: count, loading: function () { return loading; }, + nearest: nearest, inBox: inBox, byIdent: byIdent, + freqLabel: freqLabel, primaryFreq: primaryFreq + }; +})(this); diff --git a/app/mockup/android/AndroidManifest.xml b/app/junco/android/AndroidManifest.xml similarity index 88% rename from app/mockup/android/AndroidManifest.xml rename to app/junco/android/AndroidManifest.xml index e835198..f957e7c 100644 --- a/app/mockup/android/AndroidManifest.xml +++ b/app/junco/android/AndroidManifest.xml @@ -1,16 +1,17 @@ + android:versionName="0.1.0"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PHONE AHRS · ADVISORY + + + + + + + + + + 28 + MPH + NODE PITOT + + + + + + + 1180 + FT + NODE PLENUM + + + + + + + 0 + + FPM + + + + + + + + + + + + + + ENGINE + + + + + + + + + + + +
+ + + + + + + + + +
+ + + +
+
+
+

Junco — advisory only

+

+ Junco is not a certified aviation product and is not tested to any aviation standard. + It is advisory only.

+
    +
  • Do not use it as a primary flight instrument. Attitude comes from your phone's + sensors, which drift and are affected by vibration and magnetic interference.
  • +
  • Do not remove working mechanical instruments because this duplicates them.
  • +
  • Charts and airport data may be out of date. Sectionals change every 56 days and + this app cannot tell how stale a cached chart is. Frequencies come from a community + database. Confirm both against current official sources.
  • +
  • Traffic is advisory and badly incomplete. Coverage below 1,000–2,000 ft AGL + is often nothing at all, and Part 103 aircraft mostly carry no ADS-B Out. It is not a + see-and-avoid tool.
  • +
  • Logbook entries are drafts. 14 CFR 61.51 places accuracy on you.
  • +
  • You are responsible for the safe conduct of the flight.
  • +
+

Position and flight data stay on this device. Nothing is uploaded, and there is + no account.

+
+ +
+
+
+
+ +
+ +
+
+
+ + + +
+
+
+ + 10 nm + + + + + + + + + + +
+
+ +
+
+
+

Nearest airports

+
+ + +
+
+

Bundled with the app, so this works with no signal — which is the point, + because the moment you need a nearest field is the moment you have neither signal nor time. + Data from OurAirports, public domain and community maintained: more complete than + official sources for small private strips, and occasionally wrong. + Confirm a frequency against the Chart Supplement before you key the mic at a towered + field.

+
+
+
+
+ +
+
+
+

Weight & balance —

+
+

Weight and balance is off for + this aircraft, and it stays off until you enter numbers from an actual weighing. Junco ships + no default empty weight on purpose: a guessed one that happens to close the envelope is worse + than no envelope at all.

+
+ +
+
+ + +
+ + + +
+

Part 103 check

+
+

14 CFR 103.1 in the terms this app can actually see. The 254 lb empty weight + limit excludes floats and safety devices intended for deployment in a potentially + catastrophic situation, so a ballistic parachute does not count against it; Junco cannot + know what you fitted, so subtract those yourself before entering the number. Two of these + limits are speeds nobody can verify from a phone. This is an arithmetic aid, not a finding of + compliance.

+
+ +
+
+
+ +
+
+
+

Checklists —

+
+
+ + + + + +
+
+ + + +

Checklists are stored against the aircraft, so switching aircraft switches + lists. Add your own phases for anything the five standard ones do not cover. Ticks are + deliberately not remembered between sessions — a checklist that recalls being complete + from last week is worse than none.

+
+
+
+
+ +
+
+
+

Logbook

+
+ + + +
+
+ + +

Draft, never filed. Takeoff and landing are detected from GNSS ground + speed against this aircraft's stall speed, so they are proposals. 14 CFR 61.51 puts accuracy on + the pilot — review every entry before it goes in a real logbook.

+
+
+
+
+ +
+
+
+

Weather — nearest reporting station

+
+ + +
+

+
No observation yet.
+
+ DA — + Wind — + Vis — +
+

This is METAR, not ATIS. ATIS is a recorded broadcast that also gives + the active runway and approach in use, and there is no free feed for it. METAR carries the same + weather and does not carry the runway. Calling it ATIS would be a lie you might act on.

+
+ +
+

Traffic — advisory only

+
+ + + 0 +
+

+
+

Do not use this to see and avoid. Coverage is crowdsourced ground + receivers on line of sight, so below roughly 1000–2000 ft AGL there is often nothing at + all — which is where you fly. Latency runs 5–15 seconds. Part 103 aircraft are not + required to carry ADS-B Out and mostly do not, so the traffic most likely to conflict with you + is the traffic least likely to appear here. Per design rule 9 this never raises an alert.

+
+ +
+
+
+ +
+
+ + +
+

Aircraft

+
+ + + +
+
Name
+
Reg
+
+ Engines + CHT/eng + EGT/eng +
+
+ RPM max + CHT max + EGT max +
+
+ Vs0 + Vne + Cruise +
+
+ Fuel cap + Burn +
+
+ Units + + + + + +
+
+

+

Limits and speeds are entered in the display units above. Stored in SI, because + units are a display concern and never travel with the data.

+
+ +
+

Share, back up, transfer

+
+ + + +
+ + +

A profile is a small TOML file. That is the whole sync story. Copy it, + mail it, drop it in your own cloud, hand it to another builder. There is no account and no + server, because a service that has to be paid for is the thing that kills a project when the + maintainer moves on, and a profile carries your registration and home field.

+
+ +
+

Navigation

+
+ + +
+
+ + +
+
+ + +
+ +

HOME is set automatically at takeoff, so "how do I get back to the field + I just left" needs no setup at all. Distance, bearing and time to run appear on the main screen + and as a green pointer on the heading tape. Time to run uses closure along the course rather + than raw ground speed, because crabbing 40° off does not get you there at ground speed.

+
+ +
+

Mounting calibration

+
+ +
+
+ + not set +
+

Park the aircraft level, mount the phone however you like, then press + SET LEVEL. This captures the full three-axis mounting rotation from gravity, so the + display shows aircraft pitch and roll rather than the phone's. Any mount angle works: + vertical on the dash, flat on a kneeboard, or anything between.

+
+ +
+

Display rotation

+
+ + + + + +
+

Which way the aircraft's right wing lies across the screen. Adjust until + rolling the aircraft rolls the horizon the correct way. Re-run SET LEVEL after changing it.

+
+ +
+

Attitude source

+
+ + +
+
+ Mode demo
+ Secure context
+ In iframe
+ Orientation events 0
+ Permission
+ GNSS +
+

Device orientation needs a secure top-level context. Inside an iframe it never + fires and the page falls back to the demo circuit.

+
+ +
+

Display

+
+ + + + +
+

Engine strip, indicated airspeed, pressure altitude and vertical speed are + simulated — those are node channels and no node exists yet. Attitude, heading, track, + ground speed, GPS altitude and position are real. Not an instrument. Not airworthy.

+
+ +
+

Storage and diagnostics

+
+ + + + +
+
+ +
+ +

PULL AREA is what makes the map work with no signal, and it is also what + quietly fills a phone. Purge charts removes cached tiles and nothing else — your + aircraft, checklists and logbook are untouched, which is not true of clearing the app's data + from Android settings.

+

Junco has no crash reporter and sends nothing anywhere. If something breaks, + Copy diagnostics puts the state on your clipboard so you can paste it into an issue. + Read it before you send it: it contains your aircraft and your last known position.

+
+ +
+
+
+ + + + + + + + + diff --git a/app/mockup/manifest.webmanifest b/app/junco/manifest.webmanifest similarity index 100% rename from app/mockup/manifest.webmanifest rename to app/junco/manifest.webmanifest diff --git a/app/junco/net.js b/app/junco/net.js new file mode 100644 index 0000000..fdda000 --- /dev/null +++ b/app/junco/net.js @@ -0,0 +1,167 @@ +/* Junco mockup — outside data: traffic and weather. + * + * Two sources, two very different characters, and the difference matters more + * than the code does. + * + * api.airplanes.live sends access-control-allow-origin: *, so it works from + * a plain browser. Community ADS-B aggregator. + * aviationweather.gov sends no CORS header at all, so a browser cannot touch + * it. In the APK we go through a native bridge instead. + * + * READ THIS BEFORE TRUSTING THE TRAFFIC DISPLAY. + * + * Internet traffic is not a collision avoidance tool and this file will not + * pretend otherwise: + * + * - Coverage is crowdsourced ground receivers, line of sight. Below roughly + * 1000 to 2000 ft AGL in rural areas there is nothing at all. A powered + * parachute lives there. + * - Latency runs 5 to 15 seconds. At closing speeds that matter, a target is + * displaced by a third of a mile from where it is drawn. + * - Part 103 aircraft are not required to carry ADS-B Out and mostly do not, + * so the traffic most likely to conflict with you is the traffic least + * likely to appear. + * + * PRD design rule 9: advisory-only data never raises an alert. Nothing here + * may drive audio, the annunciator, or any advisory. It is a map layer. + * + * SPDX-License-Identifier: MPL-2.0 + */ +(function (global) { + "use strict"; + + /* ---------------- transport ---------------- + The Android wrapper injects JuncoNative.fetch for hosts that refuse CORS. + Everything falls back to a direct fetch so the PWA still does what it can. */ + function get(url) { + if (global.JuncoNative && typeof global.JuncoNative.httpGet === "function") { + return new Promise(function (res, rej) { + try { + var out = global.JuncoNative.httpGet(url); + if (out === null || out === undefined || out === "") { rej(new Error("empty")); } + else { res(out); } + } catch (e) { rej(e); } + }); + } + return fetch(url, { cache: "no-store" }).then(function (r) { + if (!r.ok) { throw new Error("HTTP " + r.status); } + return r.text(); + }); + } + function hasNative() { return !!(global.JuncoNative && global.JuncoNative.httpGet); } + + /* ---------------- geometry ---------------- */ + var R_NM = 3440.065; + function toRad(d) { return d * Math.PI / 180; } + function distNM(la1, lo1, la2, lo2) { + var p1 = toRad(la1), p2 = toRad(la2), dp = toRad(la2 - la1), dl = toRad(lo2 - lo1); + var a = Math.sin(dp/2)*Math.sin(dp/2) + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)*Math.sin(dl/2); + return R_NM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + } + function bearing(la1, lo1, la2, lo2) { + var p1 = toRad(la1), p2 = toRad(la2), dl = toRad(lo2 - lo1); + var y = Math.sin(dl)*Math.cos(p2); + var x = Math.cos(p1)*Math.sin(p2) - Math.sin(p1)*Math.cos(p2)*Math.cos(dl); + return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; + } + + /* ---------------- traffic ---------------- */ + var traffic = { list: [], at: 0, err: null, loading: false }; + + function fetchTraffic(lat, lon, radiusNM, myAltFt) { + if (lat === null || lon === null || traffic.loading) { return Promise.resolve(traffic); } + traffic.loading = true; + var r = Math.max(5, Math.min(250, Math.round(radiusNM || 40))); + var url = "https://api.airplanes.live/v2/point/" + lat.toFixed(4) + "/" + lon.toFixed(4) + "/" + r; + return get(url).then(function (txt) { + var d = JSON.parse(txt), ac = (d && d.ac) || []; + traffic.list = ac.map(function (a) { + if (typeof a.lat !== "number" || typeof a.lon !== "number") { return null; } + var altFt = (a.alt_baro === "ground") ? 0 : (typeof a.alt_baro === "number" ? a.alt_baro + : (typeof a.alt_geom === "number" ? a.alt_geom : null)); + return { + id: (a.flight || a.r || a.hex || "").trim() || "?", + type: a.t || "", + lat: a.lat, lon: a.lon, + alt: altFt, + onGround: a.alt_baro === "ground", + gs: typeof a.gs === "number" ? a.gs : null, + trk: typeof a.track === "number" ? a.track : null, + dist: distNM(lat, lon, a.lat, a.lon), + brg: bearing(lat, lon, a.lat, a.lon), + rel: (myAltFt === null || altFt === null) ? null : altFt - myAltFt + }; + }).filter(Boolean).sort(function (x, y) { return x.dist - y.dist; }); + traffic.at = Date.now(); traffic.err = null; traffic.loading = false; + return traffic; + }).catch(function (e) { + traffic.err = e.message || "unavailable"; traffic.loading = false; + return traffic; + }); + } + + /* ---------------- weather ---------------- + What can actually be fetched is METAR and TAF. ATIS proper is a recorded + broadcast that also carries the active runway and approach in use, and + there is no free public feed for it. METAR carries the same weather; it + does not carry the runway. Saying "ATIS" for this would be a lie the pilot + might act on, so the label stays METAR. */ + var wx = { metar: null, taf: null, station: null, at: 0, err: null, loading: false }; + + function fetchWx(lat, lon) { + if (lat === null || lon === null || wx.loading) { return Promise.resolve(wx); } + if (!hasNative()) { + wx.err = "aviationweather.gov sends no CORS header — needs the app, not the browser"; + return Promise.resolve(wx); + } + wx.loading = true; + var box = [ (lat - 1.2).toFixed(2), (lon - 1.6).toFixed(2), (lat + 1.2).toFixed(2), (lon + 1.6).toFixed(2) ].join(","); + var url = "https://aviationweather.gov/api/data/metar?format=json&hours=2&bbox=" + box; + return get(url).then(function (txt) { + var arr = JSON.parse(txt); + if (!arr || !arr.length) { throw new Error("no station in range"); } + arr.forEach(function (m) { + m._d = (typeof m.lat === "number" && typeof m.lon === "number") + ? distNM(lat, lon, m.lat, m.lon) : 9999; + }); + arr.sort(function (a, b) { return a._d - b._d; }); + var m = arr[0]; + wx.station = m.icaoId; wx.metar = m; wx.at = Date.now(); wx.err = null; wx.loading = false; + return fetchTaf(m.icaoId).then(function () { return wx; }); + }).catch(function (e) { + wx.err = e.message || "unavailable"; wx.loading = false; + return wx; + }); + } + + function fetchTaf(id) { + if (!hasNative() || !id) { return Promise.resolve(); } + return get("https://aviationweather.gov/api/data/taf?format=json&ids=" + encodeURIComponent(id)) + .then(function (txt) { var a = JSON.parse(txt); wx.taf = (a && a[0]) || null; }) + .catch(function () { wx.taf = null; }); + } + + // Density altitude from the station, which beats guessing from a profile limit. + function densityAltFt(m) { + if (!m || typeof m.temp !== "number" || typeof m.altim !== "number") { return null; } + var elevFt = typeof m.elev === "number" ? m.elev * 3.28084 : 0; + var altimInHg = m.altim > 200 ? m.altim / 33.8639 : m.altim; // hPa or inHg + var pa = elevFt + (29.92 - altimInHg) * 1000; + var isa = 15 - (elevFt / 1000) * 1.98; + return pa + 120 * (m.temp - isa); + } + + function fmtAge(ms) { + if (!ms) { return "—"; } + var s = Math.round((Date.now() - ms) / 1000); + return s < 60 ? s + "s" : Math.round(s / 60) + "m"; + } + + global.JuncoNet = { + hasNative: hasNative, + traffic: traffic, fetchTraffic: fetchTraffic, + wx: wx, fetchWx: fetchWx, + densityAltFt: densityAltFt, + distNM: distNM, bearing: bearing, fmtAge: fmtAge + }; +})(this); diff --git a/app/junco/ops.js b/app/junco/ops.js new file mode 100644 index 0000000..97ff8ce --- /dev/null +++ b/app/junco/ops.js @@ -0,0 +1,307 @@ +/* Junco mockup — navigation, checklists, logbook. + * + * What every EFB has that matters to an aircraft with no panel, minus the parts + * that need chart data we cannot ship: + * + * NAV distance and bearing to a destination, and to where you took + * off from. No database needed, which is why it works everywhere + * and offline. "How do I get back" is the question an ultralight + * pilot actually asks, and it is pure spherical trigonometry. + * + * MAP a plot with no basemap. Breadcrumb, home, destination, traffic, + * range rings, track-up or north-up. Deliberately tile-free: tiles + * mean a network at 800 feet over a field, which is exactly where + * there is not one. A track and a bearing beat a blank grey square. + * + * CHECKLIST per aircraft, editable, five phases. Stored against the profile + * id so switching aircraft switches checklists. + * + * LOGBOOK takeoff and landing detected from GNSS, flights recorded and + * exportable. PRD section 13: Junco proposes entries, it never + * files them, and the pilot confirms. 14 CFR 61.51 puts accuracy + * on the pilot, so nothing here is authoritative. + * + * SPDX-License-Identifier: MPL-2.0 + */ +(function (global) { + "use strict"; + + var N = global.JuncoNet; + var LS = { + get: function (k, d) { try { var v = localStorage.getItem(k); return v ? JSON.parse(v) : d; } catch (e) { return d; } }, + set: function (k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} } + }; + + /* ================= navigation ================= */ + var nav = { + home: null, // {lat, lon, at} where the wheels left the ground + dest: null, // {lat, lon, name} + active: "dest" // which one the PFD is pointing at + }; + (function () { var s = LS.get("junco.nav", null); if (s) { nav.home = s.home; nav.dest = s.dest; nav.active = s.active || "dest"; } })(); + function saveNav() { LS.set("junco.nav", nav); } + + function target() { return nav.active === "home" ? nav.home : nav.dest; } + + /** Distance, bearing, relative bearing and time to run. Null when unknown. */ + function solution(lat, lon, trackDeg, gsMs) { + var t = target(); + if (t === null || lat === null || lon === null) { return null; } + var d = N.distNM(lat, lon, t.lat, t.lon); + var b = N.bearing(lat, lon, t.lat, t.lon); + var rel = trackDeg === null ? null : (((b - trackDeg + 540) % 360) - 180); + var gsKt = gsMs === null ? null : gsMs * 1.9438445; + // Closure along the course, not raw ground speed: crabbing 40 degrees off + // does not get you there at ground speed and the ETE should not pretend so. + var closure = (gsKt === null || rel === null) ? gsKt : gsKt * Math.cos(rel * Math.PI / 180); + var ete = (closure && closure > 1) ? d / closure * 3600 : null; + return { dist: d, brg: b, rel: rel, ete: ete, name: t.name || (nav.active === "home" ? "HOME" : "DEST") }; + } + + /* ================= fuel on board ================= + A number the pilot types, not a measurement. There is no fuel sender yet + and this must never be mistaken for one, so it carries the time it was + entered and every reader is expected to show that age. Once a node exists + this becomes the fallback for when the sender is invalid, which is exactly + the arrangement design rule 8 asks for: the display says which one it got. + + Fuel exhaustion is a top cause of engine-out in light aircraft and it is + almost always arithmetic, not a leak. Endurance from a dipstick reading + and a known burn is worth more than nothing, which is what we had. */ + var fuel = LS.get("junco.fuel", null); // {litres, at} + function setFuel(litres) { + fuel = { litres: Math.max(0, litres), at: Date.now() }; + LS.set("junco.fuel", fuel); + return fuel; + } + function getFuel() { return fuel; } + function clearFuel() { fuel = null; LS.set("junco.fuel", null); } + + /** Endurance and fuel at the destination. burnLph and usable in litres. + Returns null members rather than guesses when an input is missing. */ + function fuelPlan(profile, sol) { + if (!fuel || !profile || profile.fuel.burnCruise <= 0) { return null; } + var burn = profile.fuel.burnCruise; // litres per hour + var reserveL = burn * (profile.fuel.reserveHr || 0); + var hours = fuel.litres / burn; + var toReserve = Math.max(0, fuel.litres - reserveL) / burn; + var atDest = null, dryBeforeDest = false; + if (sol && sol.ete !== null) { + atDest = fuel.litres - burn * (sol.ete / 3600); + dryBeforeDest = atDest < reserveL; + } + return { + litres: fuel.litres, at: fuel.at, burn: burn, reserveL: reserveL, + enduranceHr: hours, toReserveHr: toReserve, + atDestL: atDest, belowReserve: dryBeforeDest, + ageMin: (Date.now() - fuel.at) / 60000 + }; + } + + function setHome(lat, lon) { nav.home = { lat: lat, lon: lon, at: Date.now() }; saveNav(); } + function setDest(lat, lon, name) { nav.dest = { lat: lat, lon: lon, name: name || "DEST" }; saveNav(); } + function setActive(which) { nav.active = which; saveNav(); } + + function fmtLatLon(s) { + var m = String(s).trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/); + if (!m) { return null; } + var la = parseFloat(m[1]), lo = parseFloat(m[2]); + if (isNaN(la) || isNaN(lo) || Math.abs(la) > 90 || Math.abs(lo) > 180) { return null; } + return { lat: la, lon: lo }; + } + + /* ================= breadcrumb ================= */ + var trail = []; // {lat, lon, alt, t} + var TRAIL_MAX = 4000; + function addTrail(lat, lon, alt) { + if (lat === null || lon === null) { return; } + var last = trail[trail.length - 1]; + if (last && N.distNM(last.lat, last.lon, lat, lon) < 0.005) { return; } + trail.push({ lat: lat, lon: lon, alt: alt, t: Date.now() }); + if (trail.length > TRAIL_MAX) { trail.shift(); } + } + function clearTrail() { trail.length = 0; } + + /* ================= route ================= */ + var route = LS.get("junco.route", []); // [{lat, lon, name}] + function saveRoute() { LS.set("junco.route", route); } + function addWpt(lat, lon, name) { + route.push({ lat: lat, lon: lon, name: name || String.fromCharCode(65 + (route.length % 26)) }); + saveRoute(); return route; + } + function delWpt(i) { route.splice(i, 1); saveRoute(); return route; } + function clearRoute() { route.length = 0; saveRoute(); } + /** Leg distances and bearings, plus the total. */ + function legs() { + var out = [], tot = 0; + for (var i = 1; i < route.length; i++) { + var a = route[i - 1], b = route[i]; + var d = N.distNM(a.lat, a.lon, b.lat, b.lon); + tot += d; + out.push({ from: a.name, to: b.name, dist: d, brg: N.bearing(a.lat, a.lon, b.lat, b.lon) }); + } + return { legs: out, total: tot }; + } + + /* ================= checklists ================= */ + var PHASES = [ + { id: "preflight", name: "Preflight" }, + { id: "pretakeoff", name: "Before takeoff" }, + { id: "inflight", name: "In flight" }, + { id: "prelanding", name: "Before landing" }, + { id: "postflight", name: "After landing" } + ]; + + // Starting point, not gospel. A checklist that is not the pilot's own gets + // ignored, so these exist to be edited rather than followed. + function defaults() { + return { + preflight: ["Aircraft documents aboard", "Walk-around complete", "Fuel quantity and quality checked", + "Oil / fuel mix correct", "Prop and spinner condition", "Control surfaces free and correct", + "Wing / sail and lines inspected", "Tyres and gear condition", "Weight and balance within limits"], + pretakeoff: ["Harness fastened and snug", "Helmet and radio on", "Fuel valve on", "Engine warm-up complete", + "Both engines run up, temps green", "Controls free and correct", "Density altitude checked", + "Wind and traffic checked", "Departure path clear"], + inflight: ["Engine temps in range", "Fuel state and endurance", "Position and home bearing known", + "Traffic scan", "Landing site within reach"], + prelanding: ["Fuel sufficient for go-around", "Wind and sock checked", "Approach path clear", + "Traffic clear", "Harness secure"], + postflight: ["Engine cool-down complete", "Fuel valve off", "Ignition off", "Aircraft secured / tied down", + "Logbook entry reviewed", "Discrepancies noted"] + }; + } + function key(profileId) { return "junco.checklist." + profileId; } + function pkey(profileId) { return "junco.phases." + profileId; } + + /** Standard phases plus whatever this owner added for this aircraft. */ + function getPhases(profileId) { + var extra = LS.get(pkey(profileId), []); + return PHASES.concat(extra); + } + function addPhase(profileId, name) { + var extra = LS.get(pkey(profileId), []); + var id = "custom_" + name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 20) + "_" + extra.length; + extra.push({ id: id, name: name, custom: true }); + LS.set(pkey(profileId), extra); + return id; + } + function removePhase(profileId, id) { + LS.set(pkey(profileId), LS.get(pkey(profileId), []).filter(function (p) { return p.id !== id; })); + var c = LS.get(key(profileId), {}); delete c[id]; LS.set(key(profileId), c); + } + function getChecklist(profileId) { + var c = LS.get(key(profileId), null); + if (!c) { c = defaults(); LS.set(key(profileId), c); } + getPhases(profileId).forEach(function (p) { if (!c[p.id]) { c[p.id] = []; } }); + return c; + } + function saveChecklist(profileId, c) { LS.set(key(profileId), c); } + function resetChecklist(profileId) { var d = defaults(); LS.set(key(profileId), d); return d; } + + // tick state is per session, not persisted: a checklist that remembers being + // complete from last week is worse than no checklist. + var ticks = {}; + function tick(phase, i, on) { ticks[phase + ":" + i] = on; } + function ticked(phase, i) { return !!ticks[phase + ":" + i]; } + function clearTicks(phase) { + Object.keys(ticks).forEach(function (k) { if (!phase || k.indexOf(phase + ":") === 0) { delete ticks[k]; } }); + } + + /* ================= logbook ================= */ + /* Airborne detection from GNSS alone. Ground speed crossing a threshold + derived from the aircraft's own stall speed, held for a few samples so a + GPS glitch does not log a flight. */ + var book = LS.get("junco.logbook", []); + var fl = null; // flight in progress + var airborne = false, hyst = 0; + + function detect(P, lat, lon, gsMs, altM, nowMs) { + if (gsMs === null || lat === null) { return null; } + var thr = Math.max(3, (P.speeds.vs0 || 10) * 0.6); + var want = gsMs > thr; + if (want !== airborne) { hyst++; } else { hyst = 0; } + if (hyst < 4) { return null; } + hyst = 0; airborne = want; + + if (airborne) { + fl = { aircraft: P.name, id: P.id, reg: P.registration, + offAt: nowMs, offLat: lat, offLon: lon, + maxAltM: altM || 0, distNM: 0, landings: 0, points: [] }; + setHome(lat, lon); // where you left is where you may need to return + return { event: "takeoff" }; + } + if (fl) { + fl.onAt = nowMs; fl.onLat = lat; fl.onLon = lon; fl.landings++; + fl.minutes = Math.max(1, Math.round((fl.onAt - fl.offAt) / 60000)); + book.push(fl); LS.set("junco.logbook", book); + var done = fl; fl = null; + return { event: "landing", flight: done }; + } + return null; + } + function update(lat, lon, altM) { + if (!fl) { return; } + if (altM && altM > fl.maxAltM) { fl.maxAltM = altM; } + var last = fl.points[fl.points.length - 1]; + if (!last || N.distNM(last[0], last[1], lat, lon) > 0.01) { + fl.points.push([lat, lon, altM || 0, Date.now()]); + if (last) { fl.distNM += N.distNM(last[0], last[1], lat, lon); } + } + } + function flights() { return book.slice().reverse(); } + function current() { return fl; } + function removeFlight(i) { var idx = book.length - 1 - i; if (idx >= 0) { book.splice(idx, 1); LS.set("junco.logbook", book); } } + + function iso(ms) { return new Date(ms).toISOString(); } + function dateOnly(ms) { return iso(ms).slice(0, 10); } + function hhmm(ms) { return iso(ms).slice(11, 16) + "Z"; } + + /* Export. PRD section 13: draft, never file. These are proposals for the + pilot to review, which is why the header says so in the file itself. */ + function toCSV() { + var L = ['"Junco proposed logbook entries. Review before filing. 14 CFR 61.51 places accuracy on the pilot."']; + L.push("Date,AircraftID,Make,From,To,TotalTime,Landings,MaxAltFt,DistanceNM,OffUTC,OnUTC"); + flights().forEach(function (f) { + L.push([dateOnly(f.offAt), csv(f.reg || f.id), csv(f.aircraft), + f.offLat.toFixed(4) + " " + f.offLon.toFixed(4), + (f.onLat === undefined ? "" : f.onLat.toFixed(4) + " " + f.onLon.toFixed(4)), + (f.minutes / 60).toFixed(1), f.landings, + Math.round((f.maxAltM || 0) * 3.28084), f.distNM.toFixed(1), + hhmm(f.offAt), f.onAt ? hhmm(f.onAt) : ""].join(",")); + }); + return L.join("\n"); + } + function csv(s) { s = String(s === undefined ? "" : s); return /[",]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } + + function toGPX(i) { + var f = flights()[i]; if (!f) { return ""; } + var L = ['', + '', + "" + esc(f.aircraft) + " " + dateOnly(f.offAt) + ""]; + (f.points || []).forEach(function (p) { + L.push('' + + (p[2] || 0).toFixed(1) + ""); + }); + L.push(""); + return L.join("\n"); + } + function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } + + function totals() { + var min = 0, ld = 0; + book.forEach(function (f) { min += f.minutes || 0; ld += f.landings || 0; }); + return { hours: min / 60, landings: ld, flights: book.length }; + } + + global.JuncoOps = { + nav: nav, solution: solution, setHome: setHome, setDest: setDest, setActive: setActive, + parseLatLon: fmtLatLon, + setFuel: setFuel, getFuel: getFuel, clearFuel: clearFuel, fuelPlan: fuelPlan, + trail: trail, addTrail: addTrail, clearTrail: clearTrail, + route: route, addWpt: addWpt, delWpt: delWpt, clearRoute: clearRoute, legs: legs, + PHASES: PHASES, getPhases: getPhases, addPhase: addPhase, removePhase: removePhase, getChecklist: getChecklist, saveChecklist: saveChecklist, + resetChecklist: resetChecklist, tick: tick, ticked: ticked, clearTicks: clearTicks, + detect: detect, update: update, flights: flights, current: current, removeFlight: removeFlight, + toCSV: toCSV, toGPX: toGPX, totals: totals, dateOnly: dateOnly, hhmm: hhmm + }; +})(this); diff --git a/app/junco/profile.js b/app/junco/profile.js new file mode 100644 index 0000000..04a1d5d --- /dev/null +++ b/app/junco/profile.js @@ -0,0 +1,445 @@ +/* Junco aircraft profile — library, TOML serialisation, unit conversion. + * + * The profile describes the aircraft completely, per PRD section 11 and + * spec/aircraft-profile.md. Two things follow from that and are easy to miss: + * + * 1. The display is generated FROM the profile. A twin gets two RPM rows and a + * sync bar, a single gets one and none. Nobody edits a layout; they edit an + * aircraft. That is design rule 5, "config not code", applied to the panel. + * + * 2. Units are a display concern only. Every value moves in SI and is + * converted at render, which is the same rule the BLE protocol enforces on + * the wire. Gallons never exist in the data, only on the glass. + * + * TOML is the interchange format because a builder has to be able to open it in + * a text editor and understand it. This is a deliberately small subset: tables, + * strings, numbers, booleans. Enough for a profile, and no more. + * + * SPDX-License-Identifier: MPL-2.0 + */ +(function (global) { + "use strict"; + + var SCHEMA = 2; // 2 added [weight_balance] and [station.N] + + /* ---------------- unit conversion ---------------- */ + // Base units are SI: m/s, m, degC, litres. + var U = { + speed: { + mph: { to: function (v) { return v * 2.2369363; }, from: function (v) { return v / 2.2369363; }, label: "MPH" }, + kt: { to: function (v) { return v * 1.9438445; }, from: function (v) { return v / 1.9438445; }, label: "KT" }, + kmh: { to: function (v) { return v * 3.6; }, from: function (v) { return v / 3.6; }, label: "KM/H" } + }, + alt: { + ft: { to: function (v) { return v * 3.2808399; }, from: function (v) { return v / 3.2808399; }, label: "FT" }, + m: { to: function (v) { return v; }, from: function (v) { return v; }, label: "M" } + }, + temp: { + F: { to: function (v) { return v * 9 / 5 + 32; }, from: function (v) { return (v - 32) * 5 / 9; }, label: "°F" }, + C: { to: function (v) { return v; }, from: function (v) { return v; }, label: "°C" } + }, + fuel: { + gal: { to: function (v) { return v * 0.26417205; }, from: function (v) { return v / 0.26417205; }, label: "GAL" }, + L: { to: function (v) { return v; }, from: function (v) { return v; }, label: "L" } + }, + vs: { + fpm: { to: function (v) { return v * 196.85039; }, from: function (v) { return v / 196.85039; }, label: "FPM" }, + ms: { to: function (v) { return v; }, from: function (v) { return v; }, label: "M/S" } + }, + weight: { + lb: { to: function (v) { return v * 2.2046226; }, from: function (v) { return v / 2.2046226; }, label: "LB" }, + kg: { to: function (v) { return v; }, from: function (v) { return v; }, label: "KG" } + }, + // Arm is not chosen separately. Anyone weighing in pounds measures arms in + // inches and anyone weighing in kilograms measures them in centimetres; + // offering the four combinations would only create ways to mix them up. + arm: { + lb: { to: function (v) { return v * 39.370079; }, from: function (v) { return v / 39.370079; }, label: "IN" }, + kg: { to: function (v) { return v * 100; }, from: function (v) { return v / 100; }, label: "CM" } + } + }; + + function conv(kind, unit) { return U[kind][unit] || U[kind][Object.keys(U[kind])[0]]; } + + /* ---------------- weight and balance ---------------- + Presets ship this OFF and empty on purpose. Every other field in a preset + is a plausible starting point a builder can correct later; a weight and + balance number is not. There is no plausible empty weight for an aircraft + nobody has weighed, and a made-up one that happens to close the envelope is + more dangerous than a blank page. The page refuses to compute until the + numbers come off a real weighing. */ + function emptyWB() { + return { + enabled: false, + emptyWeight: 0, // kg + emptyArm: 0, // m aft of datum + maxGross: 0, // kg + cgFwd: 0, // m aft of datum + cgAft: 0, // m aft of datum + fuelArm: 0, // m aft of datum + fuelDensity: 0.72, // kg per litre; 0.72 mogas, 0.72 100LL, 0.80 Jet A + datumNote: "" + }; + } + + /* Solve one loading. Everything in and out is SI. + loads is { stationIndex: kg, ... } plus fuelLitres. */ + function wbSolve(p, loads, fuelLitres) { + var wb = p.wb || emptyWB(); + var rows = [], wTot = 0, mTot = 0; + function add(name, w, arm) { + if (!w) { return; } + rows.push({ name: name, weight: w, arm: arm, moment: w * arm }); + wTot += w; mTot += w * arm; + } + add("Empty", wb.emptyWeight, wb.emptyArm); + (p.stations || []).forEach(function (s, i) { + add(s.name || ("Station " + (i + 1)), (loads && loads[i]) || 0, s.arm); + }); + var fuelKg = (fuelLitres || 0) * (wb.fuelDensity || 0.72); + add("Fuel", fuelKg, wb.fuelArm); + + var cg = wTot > 0 ? mTot / wTot : null; + var errs = []; + if (wb.maxGross > 0 && wTot > wb.maxGross) { + errs.push("Over gross by " + (wTot - wb.maxGross).toFixed(1) + " kg"); + } + if (cg !== null && wb.cgFwd > 0 && cg < wb.cgFwd) { errs.push("CG forward of limit"); } + if (cg !== null && wb.cgAft > 0 && cg > wb.cgAft) { errs.push("CG aft of limit"); } + // An envelope of zero width is not "in limits", it is unset. + var envelope = wb.cgFwd > 0 && wb.cgAft > wb.cgFwd; + return { + rows: rows, weight: wTot, moment: mTot, cg: cg, fuelKg: fuelKg, + envelope: envelope, grossSet: wb.maxGross > 0, + errors: errs, ok: errs.length === 0 + }; + } + + /* 14 CFR 103.1, checked against what the profile already knows. + Deliberately reports what it cannot check rather than passing it silently. */ + function part103(p) { + var out = []; + var lb = function (kg) { return kg * 2.2046226; }; + var gal = function (l) { return l * 0.26417205; }; + var kt = function (ms) { return ms * 1.9438445; }; + var ew = p.wb ? p.wb.emptyWeight : 0; + if (!ew) { + out.push({ ok: null, text: "Empty weight under 254 lb — no weighing entered" }); + } else { + out.push({ ok: lb(ew) < 254, text: "Empty weight " + lb(ew).toFixed(1) + " lb, limit 254 lb" }); + } + out.push({ ok: gal(p.fuel.capacity) <= 5.0, + text: "Fuel capacity " + gal(p.fuel.capacity).toFixed(1) + " US gal, limit 5.0" }); + out.push({ ok: kt(p.speeds.vs1) <= 24.0, + text: "Power-off stall " + kt(p.speeds.vs1).toFixed(1) + " kt CAS, limit 24" }); + out.push({ ok: null, text: "Full-power level flight under 55 kt CAS — not measured by this app" }); + out.push({ ok: null, text: "Single occupant, no airworthiness certificate, day VFR only" }); + return out; + } + + /* ---------------- presets ---------------- */ + // Stored in SI. Written this way so the conversion path is exercised even + // when the display units happen to match what the numbers were quoted in. + function preset(id) { + var p = { + "pm2": { + id: "pm2", name: "ParaPlane PM-2", registration: "Part 103", + make: "ParaPlane", model: "PM-2", buildClass: "self-built", + engines: { count: 2, cht: 1, egt: 1, pulsesPerRev: 1 }, + limits: { rpmMax: 6800, chtMax: 224, egtMax: 677, densAltAdvisory: 2438 }, + speeds: { vs0: 9.8, vs1: 10.7, vno: 15.2, vne: 17.0, cruise: 12.5 }, + fuel: { capacity: 37.9, usable: 36.0, burnCruise: 13.6, reserveHr: 0.75 }, + wb: emptyWB(), stations: [], + units: { speed: "mph", alt: "ft", temp: "F", fuel: "gal", vs: "fpm", weight: "lb" } + }, + "single": { + id: "single", name: "Single, two-stroke", registration: "Part 103", + make: "", model: "", buildClass: "self-built", + engines: { count: 1, cht: 1, egt: 1, pulsesPerRev: 1 }, + limits: { rpmMax: 6500, chtMax: 232, egtMax: 649, densAltAdvisory: 2438 }, + speeds: { vs0: 11.2, vs1: 12.1, vno: 22.4, vne: 26.8, cruise: 19.7 }, + fuel: { capacity: 18.9, usable: 18.0, burnCruise: 9.5, reserveHr: 0.5 }, + wb: emptyWB(), stations: [], + units: { speed: "mph", alt: "ft", temp: "F", fuel: "gal", vs: "fpm", weight: "lb" } + }, + "fourcyl": { + id: "fourcyl", name: "Four-cylinder experimental", registration: "N-number", + make: "", model: "", buildClass: "kit-built", + engines: { count: 1, cht: 4, egt: 4, pulsesPerRev: 1 }, + limits: { rpmMax: 2700, chtMax: 260, egtMax: 816, densAltAdvisory: 2438 }, + speeds: { vs0: 22.4, vs1: 24.6, vno: 55.9, vne: 71.5, cruise: 49.2 }, + fuel: { capacity: 136, usable: 132, burnCruise: 30.3, reserveHr: 0.75 }, + wb: emptyWB(), stations: [], + units: { speed: "kt", alt: "ft", temp: "F", fuel: "gal", vs: "fpm", weight: "lb" } + } + }[id]; + return p ? JSON.parse(JSON.stringify(p)) : null; + } + + /* ---------------- TOML, small subset ---------------- */ + function esc(s) { return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } + function num(n) { return (Math.round(n * 1000) / 1000).toString(); } + + function toTOML(p) { + var L = []; + L.push("# Junco aircraft profile"); + L.push("# All values are SI: m/s, metres, degrees C, litres."); + L.push("# Units below affect display only, never the data."); + L.push("# See spec/aircraft-profile.md"); + L.push(""); + L.push("schema_version = " + SCHEMA); + L.push(""); + L.push("[identity]"); + L.push('id = "' + esc(p.id) + '"'); + L.push('name = "' + esc(p.name) + '"'); + L.push('registration = "' + esc(p.registration) + '"'); + L.push('make = "' + esc(p.make || "") + '"'); + L.push('model = "' + esc(p.model || "") + '"'); + L.push('build_class = "' + esc(p.buildClass) + '"'); + L.push(""); + L.push("[engines]"); + L.push("count = " + p.engines.count); + L.push("cht_per_engine = " + p.engines.cht); + L.push("egt_per_engine = " + p.engines.egt); + L.push("pulses_per_rev = " + p.engines.pulsesPerRev); + L.push(""); + L.push("[limits]"); + L.push("rpm_max = " + num(p.limits.rpmMax)); + L.push("cht_max_c = " + num(p.limits.chtMax)); + L.push("egt_max_c = " + num(p.limits.egtMax)); + L.push("density_alt_advisory_m = " + num(p.limits.densAltAdvisory)); + L.push(""); + L.push("[speeds] # m/s"); + L.push("vs0 = " + num(p.speeds.vs0)); + L.push("vs1 = " + num(p.speeds.vs1)); + L.push("vno = " + num(p.speeds.vno)); + L.push("vne = " + num(p.speeds.vne)); + L.push("cruise = " + num(p.speeds.cruise)); + L.push(""); + L.push("[fuel] # litres, litres per hour"); + L.push("capacity = " + num(p.fuel.capacity)); + L.push("usable = " + num(p.fuel.usable)); + L.push("burn_cruise = " + num(p.fuel.burnCruise)); + L.push("reserve_hr = " + num(p.fuel.reserveHr)); + L.push(""); + var wb = p.wb || emptyWB(); + L.push("[weight_balance] # kilograms, metres aft of datum"); + L.push("# Enter these from an actual weighing. Junco ships them blank because"); + L.push("# a guessed empty weight that happens to close the envelope is worse"); + L.push("# than no envelope at all."); + L.push("enabled = " + (wb.enabled ? "true" : "false")); + L.push("empty_weight = " + num(wb.emptyWeight)); + L.push("empty_arm = " + num(wb.emptyArm)); + L.push("max_gross = " + num(wb.maxGross)); + L.push("cg_fwd = " + num(wb.cgFwd)); + L.push("cg_aft = " + num(wb.cgAft)); + L.push("fuel_arm = " + num(wb.fuelArm)); + L.push("fuel_density = " + num(wb.fuelDensity)); + L.push('datum_note = "' + esc(wb.datumNote || "") + '"'); + L.push(""); + (p.stations || []).forEach(function (s, i) { + L.push("[station." + (i + 1) + "]"); + L.push('name = "' + esc(s.name || "") + '"'); + L.push("arm = " + num(s.arm || 0)); + L.push("max = " + num(s.max || 0)); + L.push(""); + }); + L.push("[units] # display only"); + L.push('speed = "' + p.units.speed + '"'); + L.push('alt = "' + p.units.alt + '"'); + L.push('temp = "' + p.units.temp + '"'); + L.push('fuel = "' + p.units.fuel + '"'); + L.push('vertical_speed = "' + p.units.vs + '"'); + L.push('weight = "' + (p.units.weight || "lb") + '" # arm follows: in with lb, cm with kg'); + L.push(""); + return L.join("\n"); + } + + function fromTOML(text) { + var out = {}, table = null; + text.split(/\r?\n/).forEach(function (raw) { + var line = raw.replace(/(^|\s)#.*$/, "").trim(); + if (!line) { return; } + var t = line.match(/^\[([A-Za-z0-9_.]+)\]$/); + if (t) { table = t[1]; out[table] = out[table] || {}; return; } + var kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/); + if (!kv) { return; } + var k = kv[1], v = kv[2].trim(), val; + if (/^".*"$/.test(v)) { val = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\"); } + else if (/^(true|false)$/.test(v)) { val = v === "true"; } + else if (/^-?\d+(\.\d+)?$/.test(v)) { val = parseFloat(v); } + else { val = v; } + if (table) { out[table][k] = val; } else { out[k] = val; } + }); + + if (out.schema_version && out.schema_version > SCHEMA) { + throw new Error("profile schema " + out.schema_version + " is newer than this build understands (" + SCHEMA + ")"); + } + // A document with none of the tables a profile is made of is not an empty + // profile, it is not a profile. Without this, pasting an empty clipboard + // into "Load from text" silently produced an aircraft made entirely of + // defaults, named "Imported aircraft", with someone's idea of a stall + // speed in it. Refusing is the only honest answer. + // The test is for a TABLE of that name, not any value of that name. A file + // with its section headers stripped leaves `fuel = "gal"` from [units] at + // the top level, and a bare string passed the first version of this check + // because Object.keys("gal") is three characters long. + var known = ["identity", "engines", "limits", "speeds", "fuel", "units", "weight_balance"]; + var isTable = function (v) { + return v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length > 0; + }; + if (!known.some(function (t) { return isTable(out[t]); })) { + throw new Error("this does not look like a Junco aircraft profile"); + } + var id = (out.identity || {}); + var en = (out.engines || {}); + var li = (out.limits || {}); + var sp = (out.speeds || {}); + var fu = (out.fuel || {}); + var un = (out.units || {}); + var wbT = (out.weight_balance || {}); + var stations = []; + Object.keys(out).forEach(function (k) { + var m = k.match(/^station\.(\d+)$/); + if (m) { stations[parseInt(m[1], 10) - 1] = out[k]; } + }); + stations = stations.filter(Boolean).map(function (s) { + return { name: String(s.name || ""), arm: numOr(s.arm, 0), max: numOr(s.max, 0) }; + }); + var p = { + id: id.id || ("imported-" + Math.abs(hash(text)).toString(36).slice(0, 6)), + name: id.name || "Imported aircraft", + registration: id.registration || "", + make: id.make || "", model: id.model || "", + buildClass: id.build_class || "self-built", + engines: { + count: clampInt(en.count, 1, 4, 1), + cht: clampInt(en.cht_per_engine, 0, 6, 1), + egt: clampInt(en.egt_per_engine, 0, 6, 1), + pulsesPerRev: clampInt(en.pulses_per_rev, 1, 6, 1) + }, + limits: { + rpmMax: numOr(li.rpm_max, 6800), chtMax: numOr(li.cht_max_c, 224), + egtMax: numOr(li.egt_max_c, 677), densAltAdvisory: numOr(li.density_alt_advisory_m, 2438) + }, + speeds: { + vs0: numOr(sp.vs0, 9.8), vs1: numOr(sp.vs1, 10.7), vno: numOr(sp.vno, 15.2), + vne: numOr(sp.vne, 17.0), cruise: numOr(sp.cruise, 12.5) + }, + fuel: { + capacity: numOr(fu.capacity, 37.9), usable: numOr(fu.usable, 36), + burnCruise: numOr(fu.burn_cruise, 13.6), reserveHr: numOr(fu.reserve_hr, 0.75) + }, + wb: { + enabled: wbT.enabled === true, + emptyWeight: numOr(wbT.empty_weight, 0), emptyArm: numOr(wbT.empty_arm, 0), + maxGross: numOr(wbT.max_gross, 0), + cgFwd: numOr(wbT.cg_fwd, 0), cgAft: numOr(wbT.cg_aft, 0), + fuelArm: numOr(wbT.fuel_arm, 0), fuelDensity: numOr(wbT.fuel_density, 0.72), + datumNote: String(wbT.datum_note || "") + }, + stations: stations, + units: { + speed: pick(un.speed, ["mph", "kt", "kmh"], "mph"), + alt: pick(un.alt, ["ft", "m"], "ft"), + temp: pick(un.temp, ["F", "C"], "F"), + fuel: pick(un.fuel, ["gal", "L"], "gal"), + vs: pick(un.vertical_speed, ["fpm", "ms"], "fpm"), + weight: pick(un.weight, ["lb", "kg"], "lb") + } + }; + var bad = validate(p); + if (bad.length) { throw new Error(bad[0]); } + return p; + } + + function clampInt(v, lo, hi, d) { v = parseInt(v, 10); return isNaN(v) ? d : Math.max(lo, Math.min(hi, v)); } + function numOr(v, d) { v = parseFloat(v); return isNaN(v) ? d : v; } + function pick(v, allowed, d) { return allowed.indexOf(v) >= 0 ? v : d; } + function hash(s) { var h = 0, i; for (i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; } return h; } + + /* ---------------- validation ---------------- + The node refuses to arm a channel that fails, so the tool has to reject the + same things. Better to be told on the ground. */ + function validate(p) { + var e = []; + if (!p.name) { e.push("aircraft needs a name"); } + if (p.engines.count < 1) { e.push("engine count must be at least 1"); } + if (p.engines.cht === 0 && p.engines.egt === 0) { e.push("an engine with no CHT and no EGT has nothing to monitor"); } + if (p.fuel.usable > p.fuel.capacity) { e.push("usable fuel exceeds capacity"); } + if (p.engines.pulsesPerRev < 1) { e.push("pulses per revolution must be at least 1"); } + if (p.speeds.vs0 >= p.speeds.vne) { e.push("stall speed is at or above never-exceed"); } + if (p.limits.chtMax <= 0) { e.push("CHT limit must be positive"); } + if (p.fuel.burnCruise <= 0) { e.push("cruise burn must be positive"); } + var wb = p.wb; + if (wb && wb.enabled) { + if (wb.emptyWeight <= 0) { e.push("weight and balance is on but empty weight is not set"); } + if (wb.maxGross > 0 && wb.emptyWeight >= wb.maxGross) { e.push("empty weight is at or above max gross"); } + if (wb.cgAft > 0 && wb.cgFwd > 0 && wb.cgAft <= wb.cgFwd) { e.push("aft CG limit must be behind the forward limit"); } + if (wb.fuelDensity <= 0) { e.push("fuel density must be positive"); } + } + return e; + } + + /* ---------------- library ---------------- */ + var KEY = "junco.profiles"; + var SEL = "junco.profile.active"; + + function all() { + var lib; + try { lib = JSON.parse(localStorage.getItem(KEY) || "null"); } catch (err) { lib = null; } + if (!lib || !lib.length) { + lib = [preset("pm2"), preset("single"), preset("fourcyl")]; + save(lib); + return lib; + } + // Forward-migrate profiles saved by an older build. A missing block is + // filled with a blank one, never a guess, for the reason emptyWB explains. + var changed = false; + lib.forEach(function (p) { + if (!p.wb) { p.wb = emptyWB(); changed = true; } + if (!p.stations) { p.stations = []; changed = true; } + if (p.units && !p.units.weight) { p.units.weight = "lb"; changed = true; } + }); + if (changed) { save(lib); } + return lib; + } + function save(lib) { try { localStorage.setItem(KEY, JSON.stringify(lib)); } catch (err) {} } + function active() { + var lib = all(), id; + try { id = localStorage.getItem(SEL); } catch (err) { id = null; } + var found = null; + lib.forEach(function (p) { if (p.id === id) { found = p; } }); + return found || lib[0]; + } + function setActive(id) { try { localStorage.setItem(SEL, id); } catch (err) {} } + function put(p) { + var lib = all(), replaced = false; + lib = lib.map(function (x) { if (x.id === p.id) { replaced = true; return p; } return x; }); + if (!replaced) { lib.push(p); } + save(lib); + return p; + } + function remove(id) { + var lib = all().filter(function (p) { return p.id !== id; }); + if (!lib.length) { lib = [preset("pm2")]; } + save(lib); + return lib; + } + function uniqueId(base) { + var lib = all(), id = base, n = 2; + var taken = function (x) { var t = false; lib.forEach(function (p) { if (p.id === x) { t = true; } }); return t; }; + while (taken(id)) { id = base + "-" + n; n++; } + return id; + } + + global.JuncoProfile = { + SCHEMA: SCHEMA, + conv: conv, units: U, + preset: preset, presets: ["pm2", "single", "fourcyl"], + toTOML: toTOML, fromTOML: fromTOML, validate: validate, + emptyWB: emptyWB, wbSolve: wbSolve, part103: part103, + all: all, save: save, active: active, setActive: setActive, + put: put, remove: remove, uniqueId: uniqueId + }; +})(this); diff --git a/app/mockup/sw.js b/app/junco/sw.js similarity index 60% rename from app/mockup/sw.js rename to app/junco/sw.js index 73df329..73e4b7b 100644 --- a/app/mockup/sw.js +++ b/app/junco/sw.js @@ -5,14 +5,21 @@ * signal drops routinely. Cache the shell on install, serve it from cache * first, and never depend on the network in the air. * - * SPDX-License-Identifier: GPL-2.0-or-later + * SPDX-License-Identifier: MPL-2.0 */ -var CACHE = "junco-pfd-v1"; +var CACHE = "junco-v9"; +var TILES = "junco-osm-tiles-v1"; var SHELL = [ "./", "./index.html", + "./profile.js", + "./net.js", + "./ops.js", + "./tiles.js", + "./airports.js", + "./data/airports.json", "./manifest.webmanifest", "./icon-192.png", "./icon-512.png" @@ -32,7 +39,7 @@ self.addEventListener("activate", function (e) { e.waitUntil( caches.keys().then(function (keys) { return Promise.all(keys.map(function (k) { - return k === CACHE ? null : caches.delete(k); + return (k === CACHE || k === TILES) ? null : caches.delete(k); })); }).then(function () { return self.clients.claim(); @@ -42,6 +49,25 @@ self.addEventListener("activate", function (e) { self.addEventListener("fetch", function (e) { if (e.request.method !== "GET") { return; } + + // Basemap tiles live in their own cache and are served cache-first for good. + // A tile pulled on the ground is the whole reason the map works in the air, + // so it is never evicted by a shell update. + if (e.request.url.indexOf("tile.openstreetmap.org") >= 0 || + e.request.url.indexOf("tiles.arcgis.com") >= 0) { + e.respondWith( + caches.open(TILES).then(function (c) { + return c.match(e.request).then(function (hit) { + if (hit) { return hit; } + return fetch(e.request).then(function (res) { + if (res && (res.status === 200 || res.type === "opaque")) { c.put(e.request, res.clone()); } + return res; + }); + }); + }) + ); + return; + } e.respondWith( caches.match(e.request).then(function (hit) { if (hit) { return hit; } diff --git a/app/junco/tiles.js b/app/junco/tiles.js new file mode 100644 index 0000000..0e40842 --- /dev/null +++ b/app/junco/tiles.js @@ -0,0 +1,278 @@ +/* Junco mockup — raster basemap and aeronautical charts. + * + * Three layers: OpenStreetMap for ground reference, and the FAA's own VFR + * Sectional and Terminal Area charts for airspace. All three are 256 px Web + * Mercator, so one drawing path serves them and one cache holds them. + * + * The earlier version of this app had no basemap on the argument that tiles need + * a network you will not have at 800 feet over a field. That argument was wrong, + * or rather it solved the wrong half of the problem: tiles need a network *when + * they are fetched*, not when they are drawn. Fetch them on the ground and the + * objection disappears. Ground reference is worth a great deal to a pilot who is + * low, slow, and navigating by looking outside. + * + * Low zoom is not a compromise here, it is the point. At zoom 11 one tile is + * about 12 nm across and 13 kB, so a 7x7 block covers roughly 80 nm for under a + * megabyte. Field boundaries, roads, rivers, towns and coastline are all legible, + * and that is the entire content of a ground reference. + * + * CHARTS GO STALE AND THIS ONE CANNOT TELL YOU HOW STALE. Sectionals run on a + * 56-day cycle. A tile cached before a cycle boundary looks exactly like a + * current one, so the map annunciates when the layer was last pulled. Treat it + * as a reference for situational awareness and not as a current chart. The FAA + * attribution says "not for navigation" because that is the truth. + * + * Why this matters more than roads: 14 CFR 103.17 forbids operating an + * ultralight in Class A, B, C, D, or the surface area of Class E designated for + * an airport without prior ATC authorisation. For a Part 103 pilot the airspace + * boundary is a legal line, not a convenience. + * + * OSM TILE USAGE POLICY. tile.openstreetmap.org is donated infrastructure with a + * published policy, and this file tries to be a good guest: + * + * - Identify. The WebView sets a User-Agent naming the app and its repository. + * - No bulk downloading. Pre-caching is user-initiated, bounded to the visible + * area, hard-capped, and never automatic. It is a pre-flight action, not a + * crawler. + * - Attribution is required by ODbL and is drawn on the map, not buried in an + * about box. + * + * If this ever sees real use, the right answer is a self-hosted basemap rather + * than a heavier lean on someone else's donated servers. Protomaps pmtiles gives + * a whole region as one file and would suit an offline instrument better than + * any tile server does. + * + * SPDX-License-Identifier: MPL-2.0 + */ +(function (global) { + "use strict"; + + /* Layers. The FAA publishes its own charts as Web Mercator tiles at 256 px, + which is the same scheme OSM uses, so one drawing path serves both. Note the + axis order differs: ArcGIS is {z}/{row}/{col}, OSM is {z}/{x}/{y}. + + Sectionals matter more than roads to a Part 103 pilot. 14 CFR 103.17 forbids + operating in Class A, B, C, D, or the surface area of Class E designated for + an airport without prior ATC authorisation, so knowing where those boundaries + are is a legal question and not only a convenience. */ + var LAYERS = { + osm: { + name: "OSM", + url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + attr: "© OpenStreetMap contributors", + dim: 0.42, max: 17 + }, + sectional: { + name: "SECTIONAL", + url: "https://tiles.arcgis.com/tiles/ssFJjBXIUyZDrSYZ/arcgis/rest/services/VFR_Sectional/MapServer/tile/{z}/{y}/{x}", + attr: "FAA Aeronautical Information Services — not for navigation", + dim: 0.12, max: 12 + }, + terminal: { + name: "TAC", + url: "https://tiles.arcgis.com/tiles/ssFJjBXIUyZDrSYZ/arcgis/rest/services/VFR_Terminal/MapServer/tile/{z}/{y}/{x}", + attr: "FAA Aeronautical Information Services — not for navigation", + dim: 0.12, max: 14 + } + }; + var ORDER = ["osm", "sectional", "terminal"]; + var layer = "sectional"; + var TILE = 256; + var MAX_CACHE = 600; // in-memory images + var PRECACHE_CAP = 240; // hard ceiling on a user-initiated area pull + + function L() { return LAYERS[layer]; } + function tileURL(z, x, y) { + return L().url.replace("{z}", z).replace("{x}", x).replace("{y}", y); + } + + var mem = new Map(); // "z/x/y" -> Image | "fail" + var pending = 0; + var enabled = true; + var onArrive = null; // redraw hook + + function lon2x(lon, z) { return (lon + 180) / 360 * Math.pow(2, z); } + function lat2y(lat, z) { + var r = lat * Math.PI / 180; + return (1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * Math.pow(2, z); + } + function mpp(z, lat) { return 156543.03392 * Math.cos(lat * Math.PI / 180) / Math.pow(2, z); } + + /** Integer zoom whose native resolution is closest to what the range needs. */ + function zoomFor(rangeNM, radiusPx, lat) { + var want = (rangeNM * 1852) / Math.max(1, radiusPx); // metres per pixel + var z = Math.log2(156543.03392 * Math.cos(lat * Math.PI / 180) / want); + return Math.max(3, Math.min(L().max, Math.round(z))); + } + + function key(z, x, y) { return layer + "/" + z + "/" + x + "/" + y; } + + function get(z, x, y) { + var k = key(z, x, y), hit = mem.get(k); + if (hit) { return hit === "fail" ? null : hit; } + if (pending > 6) { return null; } // be gentle + var img = new Image(); + img.decoding = "async"; + mem.set(k, img); + pending++; + img.onload = function () { pending--; if (onArrive) { onArrive(); } }; + img.onerror = function () { pending--; mem.set(k, "fail"); }; + img.src = tileURL(z, x, y); + if (mem.size > MAX_CACHE) { + var it = mem.keys(); + for (var i = 0; i < 80; i++) { var n = it.next(); if (n.done) { break; } mem.delete(n.value); } + } + return null; + } + + /** + * Draw the basemap under everything else. + * ctx is already at CSS pixel scale; cx,cy is own position; rot is the + * track-up rotation in degrees. + */ + function draw(ctx, W, H, lat, lon, radiusPx, rangeNM, rotDeg) { + if (!enabled || lat === null || lon === null) { return null; } + var z = zoomFor(rangeNM, radiusPx, lat); + var scale = TILE * mpp(z, lat) / ((rangeNM * 1852) / radiusPx); + var fx = lon2x(lon, z), fy = lat2y(lat, z); + var n = Math.pow(2, z); + + // enough tiles to cover the rotated viewport corner to corner + var reach = Math.ceil(Math.hypot(W, H) / 2 / scale) + 1; + var cx = W / 2, cy = H * 0.60; + + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-rotDeg * Math.PI / 180); + ctx.imageSmoothingEnabled = true; + for (var dx = -reach; dx <= reach; dx++) { + for (var dy = -reach; dy <= reach; dy++) { + var tx = Math.floor(fx) + dx, ty = Math.floor(fy) + dy; + if (ty < 0 || ty >= n) { continue; } + var wx = ((tx % n) + n) % n; // wrap longitude + var img = get(z, wx, ty); + if (!img || !img.complete || !img.naturalWidth) { continue; } + ctx.drawImage(img, (tx - fx) * scale, (ty - fy) * scale, scale + 1, scale + 1); + } + } + ctx.restore(); + + // Knock the basemap back so instrument symbology stays dominant. A basemap + // that competes with the traffic and the course line is a hazard, not a help. + ctx.fillStyle = "rgba(5,7,10," + L().dim + ")"; + ctx.fillRect(0, 0, W, H); + return z; + } + + /** Required by ODbL. Not optional and not hidden. */ + function attribution(ctx, W, H) { + if (!enabled) { return; } + var t = L().attr; + ctx.font = "11px ui-monospace, monospace"; + var w = ctx.measureText(t).width + 10; + ctx.fillStyle = "rgba(0,0,0,0.55)"; + ctx.fillRect(W - w - 6, H - 78, w, 17); + ctx.fillStyle = "#B8C2C8"; + ctx.fillText(t, W - w - 1, H - 66); + } + + /** + * Pre-flight area download. Bounded, user-initiated, capped. This is the + * feature that makes a basemap usable in an aircraft, and also the one that + * could abuse donated infrastructure, so it refuses to be large. + */ + function precache(lat, lon, rangeNM, radiusPx, progress) { + var base = zoomFor(rangeNM, radiusPx, lat); + var list = []; + [base, base + 1].forEach(function (z) { + if (z < 3 || z > L().max) { return; } + var fx = lon2x(lon, z), fy = lat2y(lat, z), n = Math.pow(2, z); + var span = z === base ? 3 : 4; + for (var dx = -span; dx <= span; dx++) { + for (var dy = -span; dy <= span; dy++) { + var tx = Math.floor(fx) + dx, ty = Math.floor(fy) + dy; + if (ty < 0 || ty >= n) { continue; } + list.push([z, ((tx % n) + n) % n, ty]); + } + } + }); + list = list.slice(0, PRECACHE_CAP); + + var done = 0, ok = 0; + return new Promise(function (resolve) { + var i = 0, active = 0; + function next() { + if (i >= list.length && active === 0) { stamp(); resolve({ total: list.length, ok: ok }); return; } + while (active < 4 && i < list.length) { + var t = list[i++]; active++; + fetch(tileURL(t[0], t[1], t[2]), { mode: "cors", cache: "force-cache" }) + .then(function () { ok++; }) + .catch(function () {}) + .then(function () { + active--; done++; + if (progress) { progress(done, list.length); } + next(); + }); + } + } + next(); + }); + } + + /* Charts go stale. The sectional cycle is 56 days and a cached tile carries no + expiry the pilot can see, so the map annunciates when the layer was last + fetched rather than letting an old chart look current. */ + var fetchedAt = {}; + function stamp() { fetchedAt[layer] = Date.now(); } + function age() { return fetchedAt[layer] || 0; } + + /* ---------------- cache accounting ---------------- + Pulling an area is the feature that makes the map work with no signal, and + it is also the feature that quietly eats a phone. A pilot who pulled twelve + areas over a summer has no way to find that out and no way to undo it + without clearing the app's data, which would also take their aircraft + profiles and their logbook. So: show the number, and give them a button + that removes only the tiles. */ + var TILE_CACHE = "junco-osm-tiles-v1"; + + function cacheStats() { + var out = { tiles: null, bytes: null, quota: null }; + var jobs = []; + if (global.caches && global.caches.open) { + jobs.push(global.caches.open(TILE_CACHE) + .then(function (c) { return c.keys(); }) + .then(function (k) { out.tiles = k.length; }) + .catch(function () {})); + } + if (global.navigator && navigator.storage && navigator.storage.estimate) { + jobs.push(navigator.storage.estimate().then(function (e) { + out.bytes = e.usage === undefined ? null : e.usage; + out.quota = e.quota === undefined ? null : e.quota; + }).catch(function () {})); + } + return Promise.all(jobs).then(function () { return out; }); + } + + function purge() { + mem.clear(); + if (!(global.caches && global.caches.delete)) { return Promise.resolve(false); } + return global.caches.delete(TILE_CACHE).catch(function () { return false; }); + } + + global.JuncoTiles = { + draw: draw, attribution: attribution, precache: precache, zoomFor: zoomFor, + layers: ORDER, layerName: function () { return L().name; }, + setLayer: function (l) { if (LAYERS[l]) { layer = l; } }, + nextLayer: function () { + var i = ORDER.indexOf(layer); + layer = ORDER[(i + 1) % ORDER.length]; + return L().name; + }, + stamp: stamp, age: age, + setRedraw: function (f) { onArrive = f; }, + setEnabled: function (v) { enabled = !!v; }, + isEnabled: function () { return enabled; }, + cached: function () { return mem.size; }, + cacheStats: cacheStats, purge: purge + }; +})(this); diff --git a/app/mockup/README.md b/app/mockup/README.md deleted file mode 100644 index a4f0b13..0000000 --- a/app/mockup/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# PFD layout mockup - -A primary flight display laid out the way pilots already read one: attitude in -the centre, airspeed tape left, altitude tape right, VSI, HSI below, engine -strip down the edge. - -**This is not the client and is not on the path to being it.** It exists to -answer layout questions before anyone writes an APK, because a tape range or a -band order is cheap to change here and expensive once there is an app. - -## What it is for - -Three questions it was built to answer: - -1. **Does design rule 8 survive a real layout?** Twenty small source tags read as - noise. The answer this mockup proposes is the one real avionics already use: - annunciate the source on the instrument. `PHONE AHRS · ADVISORY` on the - attitude indicator, cyan for node-plumbed channels, magenta for GPS-derived. -2. **Is phone attitude worth showing at all?** PRD section 15 lists this as open. - The attitude here is low-pass filtered because raw phone fusion is too jittery - to look at sitting still on a desk, which is an early data point and not an - encouraging one. -3. **Does an HSI earn its space** on an aircraft that mostly flies straight? - -## Running it - -It works with no sensors at all. On load it flies a scripted circuit, and -dragging anywhere on the display flies it by hand. That is enough to judge -layout, and arguably better than real sensors, because it holds sustained bank -angles you would not produce waving a phone around. - -For **real** attitude the page has to be a top-level HTTPS document. Device -orientation is gated by Permissions Policy, so in a cross-origin iframe it never -fires, and a page cannot grant itself the permission. - -| Where | What you get | -|---|---| -| Opened inside any iframe | Demo flight only. The diagnostics panel says why | -| Desktop Chrome, top level | DevTools → More tools → Sensors drives it | -| Phone over HTTPS, top level | Real attitude, heading, track, ground speed, position | -| Installed to the home screen | The above, plus fullscreen, landscape lock, screen wake lock, and offline | - -The simplest way to get the last row: publish this directory with GitHub Pages -and open it on the phone, then use the browser's *Add to Home Screen*. The -service worker caches the shell on first load, so it runs afterwards with no -signal. - -## As an Android app - -`android/` wraps the same files in an APK. It exists for one reason: device -orientation and geolocation are only available to a **secure context**, so a -WebView loading `file://` would silently kill exactly the sensors the app is -for. The wrapper serves the bundled assets over an `https://` origin and -intercepts those requests locally. Nothing leaves the device and no network is -used. - -Build it with `android/build.sh`, which needs `ANDROID_HOME` and build-tools 35 -or newer. It uses the raw SDK tools rather than Gradle, because one activity -with no dependencies does not need a plugin and a version matrix. - -**The APK is not committed.** `.gitignore` excludes `*.apk` deliberately, so the -`Mockup APK` workflow builds it instead: every run uploads it as an artifact, -and pushing a `mockup-v*` tag attaches it to a release. - -It is debug-signed, so installing it means allowing unknown sources. That is -appropriate for a mockup and would not be appropriate for anything else. - -## What is real and what is invented - -**Real, from the device:** attitude, heading, track, ground speed, GPS altitude, -position. - -**Simulated:** the entire engine strip, indicated airspeed, pressure altitude, -and vertical speed. Those are node channels and no node exists. Values are -plausible PM-2 cruise numbers. - -**Impossible:** pressure altitude and vertical speed can never come from the -phone, because there is no web API for ambient pressure. PRD section 6 reaches -the same conclusion from a different direction, and this is one more reason the -plumbed plenum is not optional. - -## Why this is a web page when the client will be native - -A PWA is the right shape for a prototype and the wrong shape for the instrument. -It installs in seconds, needs no store account, no signing key, and no build -toolchain, so a layout question gets answered the same afternoon it is asked. - -What it cannot do is hold a BLE link for a two hour flight. Web Bluetooth exists -on Android and would genuinely reach the node, but it needs a user gesture per -connection, has no background operation, and drops when the page is suspended. -It does not exist on iOS at all. See PRD section 19. - -Nothing in this directory should end up in the shipping client. Only the layout -decisions should. diff --git a/app/mockup/index.html b/app/mockup/index.html deleted file mode 100644 index 313f6cb..0000000 --- a/app/mockup/index.html +++ /dev/null @@ -1,986 +0,0 @@ - - - - - - - - - - - - -Junco — Primary Flight Display - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - PHONE AHRS · ADVISORY - - - - - - - - - - - - - - 28 - MPH IAS - NODE PITOT - - - - - - - - - 1180 - FT - NODE PLENUM - - - - - - - 0 - - FPM - - - - - - - - - - - - - - - PHONE GNSS TRACK - - - - ENGINE - - - - - - - - - - POSITION - - ALERTS - - - - - - - -
- -
- - - - - - - -
- -
-

Attitude source diagnostics

-
Modedemo flight
-
Secure context
-
Running in iframe
-
DeviceOrientationEvent
-
Permission request
-
Orientation events seen0
-
Generic Sensor API
-
Geolocation
-
Screen angle
-
- -
-

It flies itself out of the box. A scripted circuit drives the attitude so the whole - layout is judgeable without any sensor at all. Drag anywhere on the display to fly it - by hand instead. The annunciator on the attitude indicator always names which source is driving it.

- -

If “Enable phone sensors” does nothing

-

That is expected here, and the diagnostics panel above says why. This page runs inside an iframe, - and deviceorientation is gated by Permissions Policy: in a cross-origin frame it silently - never fires unless the parent sets allow="gyroscope; accelerometer; magnetometer", which a - page cannot grant itself. Desktop Chrome has no motion sensors to begin with.

-

Three ways to get real attitude:

-

Desktop Chrome: DevTools → ⋮ → More tools → Sensors, then set - a custom orientation. It drives deviceorientation and this page will pick it up and flip - the annunciator to PHONE AHRS.
- — Your phone, properly: save this page as an .html file and serve it - over HTTPS or from localhost as a top-level page, not framed.
- — Or just don't. For judging layout, the demo flight is better than real sensors - anyway, because it exercises bank angles and climb rates you would not get waving a phone around.

- -

What is real when sensors do work

-

Attitude, heading, track, ground speed, GPS altitude and position come from the device. - The attitude is low-pass filtered, because raw phone fusion is too jittery to look at even sitting - still on a desk — which is an early and not especially encouraging data point for a question the - PRD lists as open.

- -

What's simulated

-

Everything on the engine strip, plus indicated airspeed, pressure altitude and vertical speed. - Those are node channels and no node exists yet. Values are plausible PM-2 cruise numbers.

- -

Where design rule 8 went

-

Not twenty small tags. Real avionics annunciate a degraded or alternate source on the instrument - itself, so that's what this does: PHONE - AHRS · ADVISORY sits on the attitude indicator, - cyan labels mark node-plumbed channels, and - magenta marks GPS-derived data, which is already - the convention every pilot reads without thinking. Same rule, native idiom.

- -

Worth saying plainly

-

A full PFD makes phone attitude look authoritative, and it isn't. PRD section 19 rejects phone - attitude for a control loop because OS fusion is tuned for handheld use and drifts in sustained turns, - and section 15 still lists “phone attitude is stable enough on a vibrating airframe to be - worth showing” as an open question. That question is exactly what this layout is for — - put it on the aircraft, run the engine, and see whether the horizon is steady enough to keep. - Design rule 2 stands either way: the mechanical gauges stay installed.

- -

Layout mockup only. Not an instrument, not airworthy, not the Android client.

-
- - - - - - - diff --git a/docs/prd.md b/docs/prd.md index 6c62e70..24d2c8d 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -2,7 +2,7 @@ **Open engine and air data node for Part 103 and experimental aircraft** -Status: Draft, revision 5 +Status: Draft, revision 8 Target aircraft: ParaPlane PM-2 (twin engine powered parachute) Target publish: AirVenture 2027 @@ -12,17 +12,27 @@ Revision 3 adds traffic as a pluggable app-side channel supplied by hardware the Revision 4 relicenses to copyleft, drops the commercial roadmap, and positions Junco as the engine and air data front end for the MakerPlane stack rather than a parallel instrument system. -Revision 5 closes the open specification questions, adds design rule 9, and moves the remaining ones into `docs/open-questions.md`. Section 25 records what changed and why. +Revision 5 closes the open specification questions, adds design rule 9, and moves the remaining ones into `docs/open-questions.md`. + +Revision 6 recognises that the app became a product before the node did. Junco is now two things that ship on different schedules: an Android electronic flight bag that is useful today with no hardware at all, and the sensor node that was the original project. + +Revision 7 relicenses the code to MPL-2.0 so an iOS client stays possible. + +Revision 8 adds weight and balance with a Part 103 check, fuel endurance, and the fixes an end-to-end audit of every control turned up. Section 26 records what changed and why. --- ## 1. Summary -Junco is a low cost sensor node that instruments the parts of an ultralight or experimental aircraft a phone cannot reach, and streams that data over Bluetooth Low Energy to an app on the pilot's phone or tablet. The phone supplies position, attitude, and time. The node supplies engine, fuel, and pitot-static air data. Between them they produce a full instrument picture, an SD card flight log, and draft logbook entries. +Junco is **two products** that share a data model and ship on different schedules. + +**The app** is an Android electronic flight bag for ultralight and experimental aircraft, and it is useful today with no hardware at all. It gives an aircraft with no panel a primary flight display driven by the phone's own sensors, FAA sectional and terminal charts, direct-to navigation and a get-me-home function, 25,000 bundled US airports with their frequencies, editable per-aircraft checklists, an automatic logbook, weather, and advisory traffic. It works offline by design, because at 800 feet over a field there is no signal. -v1 is a single breadboard-built node for the PM-2, documented well enough that another builder can reproduce it. +**The node** is the original project: a low cost sensor node that instruments what a phone physically cannot reach, and streams engine, fuel, and pitot-static air data over Bluetooth Low Energy to that app. Nothing has been built yet. -**v1 is a stage, not the end state.** The destination is a small calibrated box that bolts to the frame, environmentally qualified and commercially manufactured by a production partner, with the open-source self-build path preserved alongside it. Sections 20 and 21 describe that path. Every architectural decision here is made so the later stages do not require a redesign. +**The order inverted, and the document should say so.** The app began as a layout study for hardware that did not exist. It turned out that the half of the instrument picture a phone can supply on its own is worth shipping by itself, to a pilot flying an aircraft with no panel and no budget for one. So the app goes to Google Play as a free application, and the node follows when it is built and adds the channels only it can measure. + +**What that does not change.** Every design rule in section 2 applies to both. The app is advisory, the mechanical gauges stay installed, the logbook drafts and never files, and every channel still declares its source. The node, when it arrives, is still publish-only. --- @@ -56,9 +66,9 @@ These constrain the design. They are not disclaimers. - Distributed multi-node CAN network - **A Junco-built ADS-B receiver.** Junco does not demodulate ADS-B and is not planned to. Displaying traffic from a receiver the pilot already owns is in scope as an optional channel. See section 22 - GDL90 output. Demoted to phase 2, see section 14 -- iOS native application. The protocol supports it, the app does not exist yet -- Angle of attack -- Anything sold to anyone +- iOS. Web Bluetooth does not exist there, so an iOS client is a separate native project +- Angle of attack from a vane or probe. The app estimates it from attitude minus flight path angle and labels it an estimate +- **Anything sold to anyone.** The app is free on Google Play with no advertising, no analytics, no accounts, and no in-app purchases - **Autopilot, servos, or any actuation.** Deferred, not abandoned. See section 20. When it arrives it is a separate node subscribing to Junco, not a mode of the sensor node --- @@ -423,14 +433,14 @@ Phase 0 gains thermal instrumentation as well. If the Pi-class build is going to | Artifact | License | |---|---| -| Firmware | GPL-2.0-or-later | -| Android app | GPL-2.0-or-later | +| Android app | MPL-2.0 | +| Firmware | MPL-2.0 | | Board files, STLs | CERN-OHL-S-2.0 | | Documentation and specs | CC-BY-4.0 | -Code and hardware are copyleft. Specifications are not, deliberately: they are meant to be implemented by anyone in anything, and a protocol that cannot be adopted freely does not outlive its implementation. +Code is **weak copyleft per file**, hardware is strongly reciprocal, specifications are permissive. -GPL v2 **or later** matches MakerPlane, so code moves in both directions between Junco and FIX-Gateway or pyEFIS without relicensing. See section 24. +MPL-2.0 rather than GPL, decided in revision 7. GPL cannot ship on Apple's App Store: Apple's DRM imposes exactly the further restrictions GPL section 6 forbids, and VLC was pulled over it. MPL-2.0 is what Firefox for iOS ships under. It is also explicitly GPL-compatible through its Secondary License clause, so the two-way flow with FIX-Gateway and pyEFIS that motivated the GPL choice in revision 4 survives intact. See sections 24 and 25. Stewardship requirements, driven by the goal of outliving the maintainer: @@ -466,9 +476,11 @@ Successors break what they do not understand the reason for. Every rejection bel | Build v1 on ArduPilot AP_Periph | AP_Periph defeats half of the objection above, being a genuinely publish-only DroneCAN sensor node, and it is a real candidate for the v2 bus stage. It fails v1 on four other grounds: no BLE at all, STM32 only so neither of our compute paths qualifies, no thermocouple or ignition-pulse tach support, and its EFI backends talk to an ECU over serial, which a two-stroke on CDI does not have | | Rebuilding what MakerPlane already has | FIX-Gateway already brokers avionics data from arbitrary sources, pyEFIS already displays it, and plugins already exist for ADS-B, recording, annunciation, and multi-source voting. Junco writes the two-stroke engine front end nobody has and plugs into the rest. See section 24 | | Absorbing Junco into MakerPlane entirely | The engine and air data work needs its own hardware, specs, and test program, and a Part 103 powered parachute is a narrow enough target that it would be a poor fit for a general E-AB project's roadmap. Stay separate, contribute the plugin upstream | +| Inventing a seventh checklist format | Six exist and efis-editor already reads and writes all of them under Apache 2.0. What does not exist is a human-editable text format, a structured free repository rather than PDFs, and any coverage of Part 103 at all. `spec/checklist.md` fills those three and exports through efis-editor rather than reimplementing six parsers | +| A checklist web service | Same answer as profile sync in section 24. A git tree gives history, attribution, diffs, review, forking, and full function with the origin gone. A web form gives none of that and needs paying for | | CAN-FIX as a v1 requirement | v1 has no bus and no second node. CAN-FIX becomes the leading v2 candidate over DroneCAN, because its consumers are experimental aircraft panels rather than autopilots, and its specification is Creative Commons so implementing it costs nothing legally | | PWA as the primary client | Narrower than it first looked, and still correct. Web Bluetooth reaches the node on Android and would genuinely work there, so the transport objection now applies to iOS only. What decides it is duration: a PWA needs a user gesture per connection, has no background operation, and drops the link when the page is suspended. Holding a link for a two hour flight is what native satisfies and a web page does not. The socket binding objection now scopes to the on-demand Wi-Fi AP alone, since revision 2 moved the in-flight link to BLE | -| A PWA as a prototyping vehicle | **Not rejected. Adopted for exactly that**, in `app/mockup`. It installs in seconds with no store account, signing key, or toolchain, which is what makes a layout question answerable the same afternoon it is asked. Nothing in it is on the path to the shipping client except the layout decisions it settles | +| A PWA as a prototyping vehicle | **Not rejected. Adopted for exactly that**, in `app/junco`. It installs in seconds with no store account, signing key, or toolchain, which is what makes a layout question answerable the same afternoon it is asked. Nothing in it is on the path to the shipping client except the layout decisions it settles | | Bluetooth Classic SPP | iOS blocks it for third-party apps without MFi. Locks the protocol to Android, not just the app | | Wi-Fi as the in-flight link | Joining the node's AP costs the phone its cellular data, and the node cannot usefully run AP and BLE together in flight | | Phone barometer as the vario source | Its port vents into the phone case in the slipstream, so vertical speed would report throttle position | @@ -491,17 +503,31 @@ Successors break what they do not understand the reason for. Every rejection bel --- -## 20. Roadmap beyond v1 +## 20. Roadmap **This project is not building a product line.** Its goal is to put a working, documented, reproducible engine and air data node into the world under a license that keeps it there. Revenue is not an objective and no stage below is a business plan. -| Stage | Form | Who | +**The app ships first and on its own schedule.** + +| App stage | Form | State | +|---|---|---| +| a1 | PFD, charts, navigation, airports and frequencies, checklists, logbook, weather, traffic | **Built.** Preparing for Google Play | +| a2 | BLE client for the node, engine strip driven by real data | Blocked on the node existing | +| a3 | Track log export, weight and balance, chart tile management | Wanted, not started | + +**The node follows.** + +| Node stage | Form | Who | |---|---|---| | v1 | Breadboard node, phone as hub over BLE, published files, kits at cost | This project | | v2 | Custom carrier board, CAN bus, FIX-Gateway plugin upstreamed, separate annunciator node | This project. The intended end point | | v3 | Boxed product. Assembled, calibrated, warrantied, harness included | Anyone who wants it. Not pursued here | | v4 | Autopilot node subscribing to the Junco bus | Anyone with a test program. Not pursued here | +**Publishing an app is not the same as selling one.** The app is free, has no advertising, no analytics, no account, and collects nothing: position, logs, profiles and checklists never leave the device. Distribution through Play is a way to reach pilots who will not sideload an APK, not a business. Section 4's "anything sold to anyone" non-goal is intact. + +**What publishing does change** is that strangers will fly with it. That is the reason for the acknowledgement shown at every launch, for annunciating the age of a cached chart, and for design rule 9. A layout study can be wrong quietly; a published instrument cannot. + **Why the project stops at v2.** A boxed product depends on manufacturing and support capacity. An actuating product depends on a test program and product liability insurance. Neither is something this project intends to acquire, and pretending otherwise is how a volunteer project takes on obligations it cannot meet. **Why v3 and v4 are still described.** The architecture should not foreclose them, and someone will eventually want them, so recording what they require is more useful than pretending they do not exist. Copyleft means anyone who takes those stages passes the result on under the same terms, which is the outcome this project wants from them anyway. @@ -734,7 +760,104 @@ But the boundary becomes a software boundary rather than a physical one, and sof --- -## 25. Revision history +## 25. The app as a product + +The app is the part of Junco that reaches a pilot this year. It runs on Android, is free, and is published on Google Play as `com.keylinkit.junco`. + +### What it does with no hardware at all + +| Capability | Source | +|---|---| +| Primary flight display, attitude and heading | Phone sensors, with a full three-axis mounting calibration | +| Altitude, ground speed, track, position | Phone GNSS | +| FAA VFR Sectional and Terminal charts, OpenStreetMap | Cached tiles, pulled before flight | +| Direct-to navigation, and HOME captured at takeoff | Computed | +| 25,101 US airports and 13,012 frequencies | Bundled, works with no signal | +| Per-aircraft checklists, editable, custom phases | Local | +| Logbook with takeoff and landing detected from GNSS | Local, exports CSV and GPX | +| METAR and TAF, density altitude | aviationweather.gov | +| Advisory traffic | airplanes.live | +| Aircraft profiles, shared as TOML files | Local, per section 11 | +| Weight and balance, with a 14 CFR 103.1 check | Local, from the owner's weighing report | +| Endurance and fuel remaining at the destination | Arithmetic on a figure the pilot typed | + +### Weight and balance + +Added in revision 8. It is the one electronic flight bag feature that matters **more** in this category than in a certified aircraft, because Part 103's 254 lb empty weight is not guidance. Over it, the aircraft is not an ultralight, and the pilot is flying an unregistered aircraft with no certificate and no licence. Everything else about 103 flows from that number. + +Three decisions inside it are load bearing. + +**Presets ship with weight and balance off and empty.** Every other field in a preset is a plausible starting point a builder corrects later. A weight is not. There is no plausible empty weight for an aircraft nobody has weighed, and a fabricated one that happens to close the envelope is worse than a blank page, because it looks like an answer. The page will not compute until the numbers come off a real weighing. + +**Fuel on board is declared as typed, not measured.** There is no fuel sender in this build, so endurance and fuel-at-arrival are arithmetic on a dipstick reading. Both readouts carry how long ago the figure was entered. When a node exists, the typed figure becomes the fallback for an invalid sender, which is exactly the arrangement design rule 8 describes. + +**The 103 check reports what it cannot check.** Two of the four numeric limits in 103.1 are calibrated airspeeds no phone can measure. The check lists them as unverified rather than omitting them, and it says in the page that the 254 lb limit excludes floats and safety devices intended for deployment in a potentially catastrophic situation, because a ballistic parachute changes the arithmetic and the app cannot know what was fitted. It is an arithmetic aid, not a finding of compliance. + +### Rules the app inherits and how they show up + +**Design rule 2, mechanical gauges stay installed.** Stated in the acknowledgement at every launch. + +**Design rule 3, draft never file.** Logbook entries are proposals and the export file says so in its own header. + +**Design rule 4, the owner owns the data.** Nothing leaves the device. No account, no analytics, no advertising, no telemetry. Profiles and logbooks are exported as files the owner controls, which is also the entire sync story. + +**Design rule 8, every channel declares its source.** Not twenty small tags, which read as noise on an instrument. Real avionics annunciate the source, so the app does too: `PHONE AHRS · ADVISORY` on the attitude indicator, cyan for node-plumbed channels, magenta for GNSS-derived, and a red annunciation when the mounting calibration has not been done. + +**Design rule 9, advisory-only data never alerts.** Internet traffic is a map layer and nothing else. + +### Two limits that must never be quietly designed away + +**Phone attitude is not an AHRS.** Section 15 asks whether it is stable enough on a vibrating airframe, and the answer so far is discouraging: it needed low-pass filtering to be watchable on a desk. It is displayed with a permanent advisory annunciation. If that annunciation ever gets removed for looking untidy, the display starts lying. + +**A cached chart carries no expiry the app can see.** Sectionals run a 56-day cycle. The map shows when the layer was last pulled and warns past 27 days, which is the most an offline app can honestly do. + +### The acknowledgement at every launch + +Every marine and aviation GPS does this, and the reason is that an acknowledgement is only worth something at the start of the flight it applies to. A box ticked eight months ago says nothing about whether this pilot, today, understands what they are looking at. + +--- + +## 26. Revision history + +### Revision 8, August 2026: weight and balance, and what an end-to-end audit found + +**What changed.** Weight and balance with a 14 CFR 103.1 check, fuel endurance and fuel remaining at the destination, tap an airport on the chart to identify it or go direct, chart cache accounting with a purge that leaves the owner's data alone, and a diagnostics dump that goes to the clipboard rather than to a server. + +**Why these five.** They came out of driving every control in the app through a browser automation harness and asking what a pilot would reach for and not find. Weight and balance was the largest gap and the only one with a legal edge to it. The rest are each a small thing the app already had the data for and did not surface. + +**What the audit found.** Eighty-eight scripted interactions across two passes. Five real defects, of which four were invisible from the code alone: + +- A dead handler bound to a removed button threw during setup and silently killed every handler defined after it, taking out the checklist page, the nearest page and destination entry. `$()` now returns an inert stub and warns, so one stale reference costs one control rather than the rest of the app. +- **The first tap anywhere on the screen threw away the GPS fix and erased the breadcrumb trail.** The gesture that re-asks for orientation permission on iOS was re-running the whole live-mode setup, which resets position state. It only bit on devices where the orientation sensor is slow or absent, which is precisely the population that needs the GPS most. Asking for a permission must not have side effects on unrelated state. +- A padding shorthand overrode the tab-bar clearance on four pages, leaving each one's Close button underneath the tab bar and untappable. +- The settings button sat below the overlay pages in z-order, so settings was unreachable from the map, the charts, the checklist or the logbook. +- `setPointerCapture` was called unguarded at the top of the map's pointerdown handler; when it throws, no gesture state is recorded and the map stops responding to touch entirely. + +**Why this is in the revision history at all.** Four of those five were reachable only by pretending to be a pilot with a finger, not by reading the code. That is worth recording as a method, not just as a set of fixes. + +### Revision 7, August 2026: MPL-2.0, so iOS stays possible + +**What changed.** Code moved from GPL-2.0-or-later to MPL-2.0. Hardware stays CERN-OHL-S-2.0 and specifications stay CC-BY-4.0. + +**Why.** GPL cannot go on Apple's App Store. Apple wraps every app, free ones included, in DRM that limits copying and ties it to an account, and GPL section 6 forbids exactly those further restrictions. VLC was pulled from the App Store in 2011 over this. An iOS client was already a stated future, and GPL forecloses it. + +**Why now rather than later.** Only a copyright holder can raise that objection, and today there is one. The first outside contribution under GPL would make relicensing require that person's permission, which is precisely how VLC became stuck. Relicensing was free this week and would not have been next month. + +**What it costs.** A fork may now be taken closed, which strong copyleft prevented. That was judged acceptable: the goal recorded in revision 4 was that the work live on rather than that it be protected from anyone, and MPL still requires that changes to Junco's own files be published. + +**What it does not cost.** MPL-2.0 is GPL-compatible through its Secondary License clause, so code still moves both ways with MakerPlane. That compatibility was the whole reason for choosing GPL v2 **or later** in revision 4, and it survives the change. + +### Revision 6, August 2026: the app became the product + +**What changed.** Junco is now described as two products on two schedules rather than one product with a display attached. The app ships first, free, on Google Play, and is useful with no hardware at all. The node is still the original project and still unbuilt. + +**Why.** The app began as a layout study for hardware that did not exist. It turned out the half of the instrument picture a phone can supply on its own is worth shipping by itself to a pilot flying with no panel. Charts, navigation, airports and frequencies, checklists and a logbook need no node. Continuing to call it a mockup was becoming false, and a document that describes something other than what exists is worse than no document. + +**New section 25** describes the app as a product, including how each design rule shows up in it and the two limits that must never be quietly designed away: phone attitude is not an AHRS, and a cached chart carries no expiry the app can see. + +**Section 4's "anything sold to anyone" is intact.** Free, no advertising, no analytics, no accounts, nothing collected. Publishing is a way to reach pilots who will not sideload an APK, not a business. + +**What publishing changes** is that strangers will fly with it, which is why the acknowledgement now appears at every launch rather than once, matching the convention on every marine and aviation GPS. A layout study can be wrong quietly. A published instrument cannot. ### Revision 5, August 2026: closing the open questions diff --git a/spec/README.md b/spec/README.md index 8fa14d1..6b4246d 100644 --- a/spec/README.md +++ b/spec/README.md @@ -10,6 +10,7 @@ the firmware and are the artifacts most worth getting right. | `aircraft-profile.md` | Draft. Required for v1 | | `dronecan-engine-extension.md` | Draft. Not implemented in v1, and its choice of bus is reopened. See PRD section 24 | | `ble-telemetry.md` | Draft. Required for v1. The gating document | +| `checklist.md` | Draft. Not required for v1. An interchange format and a repository convention, proposed because six vendor formats exist and none is human-readable | `ble-telemetry.md` gates firmware and app work. It carries every value the pilot sees, both reference clients are written against it, and PRD success criterion 4 diff --git a/spec/aircraft-profile.md b/spec/aircraft-profile.md index 1c30b0e..ea8e469 100644 --- a/spec/aircraft-profile.md +++ b/spec/aircraft-profile.md @@ -28,6 +28,19 @@ curve. **Fuel.** Capacity, usable quantity, burn rate against RPM. +**Weight and balance.** Empty weight and arm, maximum gross, forward and aft CG +limits, fuel arm and density, and a named station list with an arm and an +optional maximum for each. All masses in kilograms, all arms in metres aft of +the datum, and a free-text note recording which datum was used. + +**This block is present but disabled until it has been filled in from a +weighing, and a disabled block is not the same as a block full of zeros.** A +profile that carries a fabricated empty weight is more dangerous than one that +carries none, because a consumer cannot tell the difference between a number +that was measured and a number that was invented. An implementation must not +supply a default empty weight, must not compute an envelope from a disabled +block, and must distinguish "outside the envelope" from "no envelope entered". + **Units.** Every unit independently selectable. Feet or meters, knots or mph or km/h, Fahrenheit or Celsius, gallons or liters, inHg or hPa. @@ -91,6 +104,10 @@ than substituting a default. Minimum rejections: - Two channels claiming the same pin or address - A limit outside the range its sensor can represent - A fuel backend named in the channel map with no corresponding calibration +- Weight and balance enabled with no empty weight +- An empty weight at or above maximum gross +- An aft CG limit at or forward of the forward limit +- A fuel density of zero or less ## Profile hash @@ -115,6 +132,13 @@ current example: which receiver or feed a pilot uses is a property of what is in their flight bag, it changes without the aircraft changing, and the node is not in that data path at all. See PRD section 22. +## Reference implementation + +`app/junco/profile.js` writes and reads `schema_version = 2`, which added +`[weight_balance]` and `[station.N]`. Its key names are provisional until this +document specifies them, but the round trip is exercised by the app's audit and +the semantics above are already enforced there. + ## Not yet specified - The TOML key names and the file's section structure diff --git a/spec/checklist.md b/spec/checklist.md new file mode 100644 index 0000000..9cbc4f3 --- /dev/null +++ b/spec/checklist.md @@ -0,0 +1,176 @@ +# Junco checklist format + +**Status:** draft. Not required for v1. Proposed as an interchange format and a +repository convention, not as another vendor format. + +## Why this exists + +Six checklist formats already exist and are in daily use: + +| Format | System | +|---|---| +| `.ace` | Garmin G3X, G3X Touch, GTN | +| `.gplt` | Garmin Pilot | +| `.fmd` | Jeppesen ForeFlight | +| AFS | Advanced Flight Systems | +| Dynon | Dynon SkyView | +| GRT | Grand Rapids | + +[efis-editor](https://github.com/rdamazio/efis-editor) already reads and writes +all six under Apache 2.0, which is a substantial piece of work and the reason +this document does not contain six parsers. The rule from +`dronecan-engine-extension.md` applies unchanged: **standard types wherever one +exists, and a new definition only for the genuine gap.** + +Three gaps are genuine. + +**Every one of those formats is a vendor artifact.** They are binary or +semi-binary, tied to a panel, and at least one Garmin Pilot variant is encrypted +and cannot be read at all. None is something a builder opens in a text editor, +diffs, or reviews a change to. A format nobody can read by eye is a format +nobody can check, and a checklist is a safety document. + +**The free repositories that exist are PDFs.** They are useful to a human with a +printer and useless to software. Nothing can load one into a panel, diff two +revisions, or tell you the one you are holding is three years old. + +**None of it covers this aircraft class.** Every format above targets a +certified or experimental panel. A Part 103 ultralight with no electrical system +is not in scope for any of them, and it is the aircraft most likely to be flown +with no checklist at all. + +## What this is + +A **human-editable text format**, and a **repository convention** for sharing +files written in it. Not a service. The same position `aircraft-profile.md` +takes, for the same reasons: a file can be mailed, diffed, printed, reviewed, +and kept working after everyone involved has lost interest. + +TOML, matching the aircraft profile, so a builder learns one syntax. + +## Format + +```toml +schema_version = 1 + +[aircraft] +make = "ParaPlane" +model = "PM-2" +# Free text, matched loosely. A checklist for a PM-2 is a reasonable starting +# point for a PM-1 and the format should not pretend otherwise. +variant = "" +category = "part103" # part103 | lsa | experimental | certified | glider + +[provenance] +# Load-bearing, not bookkeeping. A checklist off the internet is a starting +# point for the pilot's own, and the pilot has to be able to see where it came +# from before trusting a line of it. +author = "Allen McGhan" +source = "Transcribed from the ParaPlane owner's manual, 1985 printing" +revised = "2026-08-10" +verified_on_aircraft = false +notes = "Twin-engine start sequence differs from the single-engine manual." + +[[phase]] +id = "preflight" +name = "Preflight" + +[[phase.item]] +challenge = "Fuel quantity" +response = "CHECKED, both tanks" + +[[phase.item]] +challenge = "Control surfaces" +response = "FREE AND CORRECT" + +[[phase.item]] +type = "caution" +text = "Do not run the engines with the sail unstowed." + +[[phase]] +id = "pretakeoff" +name = "Before takeoff" + +[[phase.item]] +challenge = "Engine temps" +response = "GREEN" +``` + +### Item types + +| `type` | Meaning | +|---|---| +| `challenge` (default) | A challenge and response pair. `response` may be omitted for a plain action | +| `note` | Information, no action, not ticked | +| `caution` | Something that damages the aircraft if ignored | +| `warning` | Something that hurts somebody if ignored | + +`caution` and `warning` are separate from `note` because they must render +differently and must not be tickable. A warning is not a task. + +### Phases + +`id` is from a fixed set so software can map a phase to a moment in the flight: +`preflight`, `before_start`, `start`, `pretakeoff`, `takeoff`, `climb`, +`cruise`, `descent`, `prelanding`, `landing`, `postflight`, `securing`, +`emergency`, `abnormal`. + +`name` is free text and is what the pilot sees. A phase with an unrecognised +`id` is displayed but not mapped, rather than rejected. + +## Rules + +1. **A checklist is never authoritative.** It is a proposal, exactly as a + logbook entry is under PRD section 13. The aircraft's own manual wins, and + the pilot owns the result. Any software rendering this format must show + `provenance` where the pilot can see it before use. +2. **`verified_on_aircraft = false` is the honest default.** A transcription + nobody has flown behind is worth having and worth marking. +3. **Never silently merge two checklists.** Two files for the same aircraft are + two opinions, not one better checklist. +4. **Emergency phases are never auto-advanced or auto-ticked** by anything. + +## Repository convention + +A directory tree in a git repository. That is the whole design. + +``` +checklists/ + part103/ + paraplane/pm-2.toml + quicksilver/mx-sprint.toml + experimental/ + vans/rv-12.toml +``` + +Git supplies what a checklist repository actually needs and a web form does not: +history, attribution, diffs, review before merge, forking a file you disagree +with, and full function with the origin server gone. + +**Licensing.** Files in the repository should be CC-BY-4.0 or more permissive, +matching this specification, so they can be used in any application including +closed ones. A checklist nobody may adopt does not get adopted. + +**A transcription of a manufacturer's checklist is a derivative of their +document.** Contributors are responsible for the right to publish what they +submit. Transcribing a Part 103 owner's manual whose maker closed in 1987 is a +different question from copying a current POH, and this document does not +pretend they are the same. + +## Interoperating rather than competing + +Export to the six vendor formats should go through efis-editor rather than be +reimplemented. It is Apache 2.0, it works, and rewriting six parsers to avoid a +dependency would be vanity. + +**One licensing note.** Apache 2.0 is incompatible with GPL-2.0-only, but Junco +is GPL-2.0-**or-later**, so a combined work can be distributed under GPLv3. This +is the first place that "or later" has paid for itself, and it is worth +recording that it was not decorative. + +## Not yet specified + +- Whether an item may carry a condition, such as "if the engine is fuel injected" +- How a repository declares that one file supersedes another +- Whether phases may nest, which every vendor format handles differently +- A signing or checksum convention, so a printed copy can be matched to a file diff --git a/tools/build-airports.py b/tools/build-airports.py new file mode 100755 index 0000000..6681608 --- /dev/null +++ b/tools/build-airports.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Regenerate app/mockup/data/airports.json from OurAirports. + +OurAirports is public domain and community maintained. Run this when the data +gets stale; there is no need to run it to build the app, because the output is +committed. Bundling rather than fetching is deliberate: the point of "nearest +airport" is that it works when there is no signal. + +SPDX-License-Identifier: MPL-2.0 +""" +import csv, io, json, os, sys, urllib.request + +BASE = "https://davidmegginson.github.io/ourairports-data/" +KEEP = {"small_airport", "medium_airport", "large_airport", + "seaplane_base", "heliport", "balloonport"} +TCODE = {"small_airport": "S", "medium_airport": "M", "large_airport": "L", + "seaplane_base": "W", "heliport": "H", "balloonport": "B"} +COUNTRY = os.environ.get("JUNCO_COUNTRY", "US") +OUT = os.path.join(os.path.dirname(__file__), "..", "app", "mockup", "data", "airports.json") + + +def fetch(name): + with urllib.request.urlopen(BASE + name, timeout=120) as r: + return csv.DictReader(io.StringIO(r.read().decode("utf-8"))) + + +def main(): + aps = {} + for r in fetch("airports.csv"): + if r["iso_country"] != COUNTRY or r["type"] not in KEEP: + continue + try: + lat, lon = round(float(r["latitude_deg"]), 5), round(float(r["longitude_deg"]), 5) + except ValueError: + continue + aps[r["ident"]] = [r["local_code"] or r["ident"], r["name"][:34], lat, lon, + int(float(r["elevation_ft"] or 0)), TCODE[r["type"]], []] + + freqs = 0 + for r in fetch("airport-frequencies.csv"): + a = aps.get(r["airport_ident"]) + if not a: + continue + try: + a[6].append([(r["type"] or "")[:7], round(float(r["frequency_mhz"]), 3)]) + freqs += 1 + except ValueError: + pass + + rows = sorted(aps.values(), key=lambda a: a[2]) # latitude order: the app bisects on it + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, "w", encoding="utf-8") as f: + json.dump(rows, f, separators=(",", ":")) + print(f"{len(rows)} airports, {freqs} frequencies -> {OUT}") + print(f"{os.path.getsize(OUT)/1024:.0f} kB uncompressed") + + +if __name__ == "__main__": + sys.exit(main())