diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml deleted file mode 100644 index 48fa421..0000000 --- a/.forgejo/workflows/gitleaks.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Scan the repository for committed secrets with gitleaks on every push and -# pull request. The job fails (and can be made a required check) if a secret -# is detected, so credentials never land in history. - -name: gitleaks - -on: - push: - pull_request: - -jobs: - scan: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Full history — gitleaks needs depth to scan a commit range. - fetch-depth: 0 - - - name: install gitleaks - run: | - curl -sSL -o gl.tar.gz \ - https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz - tar xzf gl.tar.gz gitleaks - chmod +x gitleaks - ./gitleaks version - - - name: scan - run: | - ./gitleaks detect --source . --no-banner --redact --verbose diff --git a/LICENSE b/LICENSE deleted file mode 100644 index be3f7b2..0000000 --- a/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 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 GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - 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 -them 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. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey 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; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If 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 convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero 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 that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - 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. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -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. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - 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 -state 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 Affero General Public License as published by - the Free Software Foundation, either version 3 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 Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/README.md b/README.md deleted file mode 100644 index 8a923e5..0000000 --- a/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# Varroa - -Varroa is an Android companion app for the **Hivemapper Bee** dashcam. It -pulls detection landmarks off the camera's on-device API, queues them in a -local database, and forwards them to an **AdaMaps** ingest endpoint once the -phone has a validated internet connection. The Bee exposes its API only over -its own Wi‑Fi access point, so Varroa exists to bridge that gap: collect while -you're connected to the camera, upload when you're back on the internet. - -The repository also ships `blackbox/`, a small Python companion that reads an -air‑quality sensor on a Linux single‑board computer (e.g. a Raspberry Pi), -fuses the readings with GPS from the Bee, and posts them to the same AdaMaps -stream. - -## Features - -- **Store‑and‑forward collection** — `BeeCollectorService` polls the Bee for - detection landmarks and writes them to a local Room database. Nothing is lost - if the phone is offline. -- **Validated‑internet uploads** — `AdaMapsUploadWorker` (WorkManager) drains - the queue to the AdaMaps API only when real connectivity is available, not - when you're merely attached to the camera's captive AP. -- **Image capture** — `ImageCollectorService` pulls detection JPEGs from the - Bee for landmarks that have associated crops. -- **Device pairing & status** — pair with the camera, view Wi‑Fi/storage/GNSS - status, and toggle device options from the in‑app settings screens. -- **Optional Cardano wallet linking** — attach a wallet address to detection - uploads so contribution rewards can be routed to you. Entirely optional. - -## How it works - -``` -Hivemapper Bee ──HTTP (AP only)──► Varroa (Android) - detections ├─ Room queue (local DB) - landmark crops └─ AdaMapsUploadWorker - │ (only on validated internet) - ▼ - AdaMaps ingest API -``` - -The Bee's API is reachable only over the camera's own access point, which -serves plain HTTP on a fixed device‑AP address. Varroa scopes an Android -cleartext exception to just that one host (see -`app/src/main/res/xml/network_security_config.xml`); everything else is -HTTPS‑only. - -## Build - -Requirements: **JDK 17**, **Android SDK 34**. Minimum supported device is -Android 8.0 (API 26). - -```bash -./gradlew :app:assembleDebug -``` - -The debug APK lands in `app/build/outputs/apk/debug/`. - -### Release signing - -Release builds are signed from a keystore supplied through environment -variables (no secrets are stored in the repo): - -```bash -export VARROA_KEYSTORE_PATH=/path/to/your-release.keystore -export VARROA_KEYSTORE_PASSWORD=… -export VARROA_KEY_PASSWORD=… # defaults to the keystore password -./gradlew :app:assembleRelease -``` - -If the keystore variables are unset, Gradle falls back to an unsigned release -build. - -## Configuration (set in‑app) - -- **Bee URL** — the camera's API base URL. Defaults to - `http://192.168.0.10:5000`, the factory access‑point address; change it if - your camera uses a different AP IP. -- **AdaMaps URL + ingest key** — required before uploads will run. Both are - entered in the app and stored locally; there are no baked‑in defaults. -- **Cardano wallet** — optional. When set, the address is attached to detection - uploads for reward routing. - -## blackbox (air‑quality companion) - -`blackbox/air_aggregator.py` reads a BME680 (I²C) and a PMS5003 particulate -sensor (UART), fuses the readings with GPS pulled from the Bee, and posts them -to the AdaMaps ingest endpoint on a fixed interval, buffering to disk while -offline. - -```bash -pip install bme680 requests smbus2 pyserial -BEE_URL=http://192.168.197.1:5000 \ -ADAMAPS_URL=https://api.adamaps.org \ -ADAMAPS_KEY=your-ingest-key \ -python3 blackbox/air_aggregator.py -``` - -A sample `systemd` unit is provided at `blackbox/air-aggregator.service`. - -## Documentation - -- `docs/BEE-CAMERA.md` — reverse‑engineering notes on the Hivemapper Bee's - internal camera pipeline (Intel Keem Bay / Myriad X VPU, DepthAI, the - on‑device REST API). Useful background for anyone building against the camera. -- `docs/AIR-QUALITY-INTEGRATION.md` — design notes for adding an air‑quality - sensor to the Bee and ingesting the readings. - -## Tech stack - -Kotlin · Jetpack Compose · Room · DataStore · WorkManager · OkHttp/Gson · -osmdroid (map preview) · Coil (images). - -## Contributing - -Contributions are welcome. Please open an issue to discuss substantial changes -before sending a pull request, keep commits focused, and make sure -`./gradlew :app:assembleDebug` passes before submitting. - -## License - -Released under the GNU Affero General Public License v3.0 or later -(AGPL-3.0-or-later). See [`LICENSE`](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 20dbc60..01f4169 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,28 +9,12 @@ android { namespace = "com.adamaps.varroa" compileSdk = 34 - signingConfigs { - create("release") { - // Set VARROA_KEYSTORE_PATH / VARROA_KEYSTORE_PASSWORD / VARROA_KEY_PASSWORD - // before assembleRelease — see vault item "Varroa — release keystore". - val ksPath = System.getenv("VARROA_KEYSTORE_PATH") - val ksPass = System.getenv("VARROA_KEYSTORE_PASSWORD") - val keyPass = System.getenv("VARROA_KEY_PASSWORD") ?: ksPass - if (ksPath != null && ksPass != null) { - storeFile = file(ksPath) - storePassword = ksPass - keyAlias = "varroa-release" - keyPassword = keyPass - } - } - } - defaultConfig { applicationId = "com.adamaps.varroa" minSdk = 26 targetSdk = 34 - versionCode = 15 - versionName = "1.8.0" + versionCode = 14 + versionName = "1.7.9" vectorDrawables { useSupportLibrary = true @@ -40,7 +24,6 @@ android { buildTypes { release { isMinifyEnabled = false - signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -87,9 +70,17 @@ dependencies { implementation(libs.osmdroid.android) implementation(libs.datastore.preferences) implementation(libs.coil.compose) + // Room (local database) implementation(libs.room.runtime) implementation(libs.room.ktx) ksp(libs.room.compiler) + // WorkManager (background uploads) implementation(libs.work.runtime.ktx) + // QR Code scanning + implementation("com.google.zxing:core:3.5.2") + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + implementation("androidx.camera:camera-camera2:1.3.0") + implementation("androidx.camera:camera-lifecycle:1.3.0") + implementation("androidx.camera:camera-view:1.3.0") debugImplementation(libs.androidx.ui.tooling) } diff --git a/app/src/main/java/com/adamaps/varroa/Navigation.kt b/app/src/main/java/com/adamaps/varroa/Navigation.kt index 9896951..e23ca05 100644 --- a/app/src/main/java/com/adamaps/varroa/Navigation.kt +++ b/app/src/main/java/com/adamaps/varroa/Navigation.kt @@ -5,7 +5,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.adamaps.varroa.ui.dashboard.DashboardScreen -import com.adamaps.varroa.ui.settings.BeeSettingsScreen import com.adamaps.varroa.ui.settings.DeviceStatusScreen import com.adamaps.varroa.ui.settings.SettingsScreen @@ -13,7 +12,6 @@ object Routes { const val DASHBOARD = "dashboard" const val SETTINGS = "settings" const val DEVICE_STATUS = "device_status" - const val BEE_SETTINGS = "bee_settings" } @Composable @@ -21,10 +19,7 @@ fun VarroaNavGraph() { val nav = rememberNavController() NavHost(navController = nav, startDestination = Routes.DASHBOARD) { composable(Routes.DASHBOARD) { - DashboardScreen( - onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }, - onNavigateToBeeSettings = { nav.navigate(Routes.BEE_SETTINGS) } - ) + DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }) } composable(Routes.SETTINGS) { SettingsScreen( @@ -35,8 +30,5 @@ fun VarroaNavGraph() { composable(Routes.DEVICE_STATUS) { DeviceStatusScreen(onBack = { nav.popBackStack() }) } - composable(Routes.BEE_SETTINGS) { - BeeSettingsScreen(onBack = { nav.popBackStack() }) - } } } diff --git a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt index 39a8410..8fc65f9 100644 --- a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt @@ -38,7 +38,7 @@ private object AdaMapsDns : Dns { class AdaMapsApiClient( private var apiUrl: String = "https://api.adamaps.org", - private var apiKey: String = "" + private var apiKey: String = "***REMOVED***" ) { companion object { private const val TAG = "VarroaAdaAPI" @@ -57,7 +57,7 @@ class AdaMapsApiClient( fun updateConfig(url: String, key: String) { val oldUrl = apiUrl - val oldKeyPrefix = apiKey.length + val oldKeyPrefix = apiKey.take(8) apiUrl = url.trimEnd('/') apiKey = key Log.d(TAG, "AdaMaps config updated - URL: $oldUrl -> $apiUrl, Key: ${oldKeyPrefix}... -> ${key.take(8)}...") @@ -80,7 +80,7 @@ class AdaMapsApiClient( .post(body) .build() - Log.d(TAG, "Sending POST request with key: ${apiKey.length}...") + Log.d(TAG, "Sending POST request with key: ${apiKey.take(8)}...") client.newCall(req).execute().use { resp -> val respBody = resp.body?.string() ?: "" Log.d(TAG, "HTTP ${resp.code} ${resp.message} - response length: ${respBody.length}") diff --git a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt index 09a370a..9c96d88 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -6,22 +6,15 @@ import android.net.Network import android.net.NetworkCapabilities import android.util.Log import com.adamaps.varroa.data.ApiResult -import com.adamaps.varroa.data.BeeConfig import com.adamaps.varroa.data.BeeDetection import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeePlugin -import com.adamaps.varroa.data.CacheStatus -import com.adamaps.varroa.data.FrameKmTotal import com.adamaps.varroa.data.GnssData import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.PairResponse -import com.adamaps.varroa.data.PluginState import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.StorageStatus -import com.adamaps.varroa.data.UploadModeResponse -import com.adamaps.varroa.data.WifiClientSettings import com.adamaps.varroa.data.WifiConfig -import com.adamaps.varroa.data.WifiNetwork import com.adamaps.varroa.data.WifiStatus import com.google.gson.Gson import com.google.gson.reflect.TypeToken @@ -30,7 +23,6 @@ import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.TimeUnit class BeeApiClient( @@ -186,35 +178,6 @@ class BeeApiClient( } } - // ── POST helper ─────────────────────────────────────────────────────────── - - private suspend fun postRaw(path: String, json: String, authenticated: Boolean = true): ApiResult = withContext(Dispatchers.IO) { - val fullUrl = "$apiUrl$path" - Log.d(TAG, "HTTP POST $fullUrl body=$json") - try { - val body = json.toRequestBody("application/json".toMediaType()) - val reqBuilder = Request.Builder().url(fullUrl).post(body) - if (authenticated && apiToken.isNotBlank()) { - reqBuilder.addHeader("Authorization", "Bearer $apiToken") - } - val req = reqBuilder.build() - client.newCall(req).execute().use { resp -> - val respBody = resp.body?.string() ?: "" - if (resp.isSuccessful) { - isConnected = true - Log.d(TAG, "POST ${resp.code} OK") - ApiResult.Success(respBody) - } else { - Log.w(TAG, "POST ${resp.code} ${resp.message}") - ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) - } - } - } catch (e: Exception) { - Log.e(TAG, "POST failed to $fullUrl", e) - ApiResult.Error(e.message ?: "Unknown error") - } - } - /** * Check if AdaCam is reachable. * Updates internal connection state. @@ -282,6 +245,7 @@ class BeeApiClient( } is ApiResult.Error -> { Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})") + // Connection failed, mark as offline if (r.message.contains("timeout", ignoreCase = true) || r.message.contains("connect", ignoreCase = true) || r.message.contains("refused", ignoreCase = true) || @@ -340,60 +304,29 @@ class BeeApiClient( } } - suspend fun setWifiConfig(ssid: String, password: String): ApiResult { - val json = gson.toJson(mapOf("ssid" to ssid, "password" to password)) - return postRaw("/api/1/wifi/connect", json) - } + suspend fun setWifiConfig(ssid: String, password: String): ApiResult = withContext(Dispatchers.IO) { + try { + val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password)) + val requestBody = okhttp3.RequestBody.create( + "application/json".toMediaType(), + jsonBody + ) + val request = Request.Builder() + .url("$apiUrl/api/1/wifi/connect") + .addHeader("Authorization", "Bearer $apiToken") + .post(requestBody) + .build() - // WiFi Client methods (legacy API) - suspend fun getWifiClientStatus(): ApiResult = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/wifiClient/status")) { - is ApiResult.Success -> try { - ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WifiStatus::class.java)) - } catch (e: Exception) { - ApiResult.Error("Parse error: ${e.message}") + client.newCall(request).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (resp.isSuccessful) { + ApiResult.Success(body) + } else { + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } } - is ApiResult.Error -> r - } - } - - suspend fun getWifiSettings(): ApiResult = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/wifiClient/settings")) { - is ApiResult.Success -> try { - ApiResult.Success(gson.fromJson(r.data, WifiClientSettings::class.java)) - } catch (e: Exception) { - ApiResult.Error("Parse error: ${e.message}") - } - is ApiResult.Error -> r - } - } - - suspend fun saveWifiSettings(settings: WifiClientSettings): ApiResult { - val json = gson.toJson(settings) - return postRaw("/api/1/wifiClient/settings", json) - } - - suspend fun setWifiEnabled(enabled: Boolean): ApiResult { - val json = """{"enabled": $enabled}""" - return postRaw("/api/1/wifiClient/enable", json) - } - - suspend fun scanWifi(): ApiResult> = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/wifiClient/scan")) { - is ApiResult.Success -> try { - val type = object : TypeToken>() {}.type - ApiResult.Success(gson.fromJson(r.data, type)) - } catch (e: Exception) { - ApiResult.Error("Parse error: ${e.message}") - } - is ApiResult.Error -> r - } - } - - suspend fun resetWifi(): ApiResult = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/wifiClient/reset")) { - is ApiResult.Success -> r - is ApiResult.Error -> r + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") } } @@ -410,14 +343,30 @@ class BeeApiClient( } } - suspend fun setSshEnabled(enabled: Boolean): ApiResult { - val json = gson.toJson(mapOf("enable" to enabled)) - return postRaw("/api/1/ssh/toggle", json) - } + suspend fun setSshEnabled(enabled: Boolean): ApiResult = withContext(Dispatchers.IO) { + try { + val jsonBody = gson.toJson(mapOf("enable" to enabled)) + val requestBody = okhttp3.RequestBody.create( + "application/json".toMediaType(), + jsonBody + ) + val request = Request.Builder() + .url("$apiUrl/api/1/ssh/toggle") + .addHeader("Authorization", "Bearer $apiToken") + .post(requestBody) + .build() - // SSH-based device ID retrieval (stub - not implemented) - suspend fun getDeviceIdViaSsh(): ApiResult { - return ApiResult.Error("SSH device ID lookup not implemented") + client.newCall(request).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (resp.isSuccessful) { + ApiResult.Success(body) + } else { + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") + } } // ── Storage & GNSS Status ───────────────────────────────────────────────── @@ -456,95 +405,32 @@ class BeeApiClient( } } - // ── Upload Mode ─────────────────────────────────────────────────────────── + suspend fun setUploadMode(mode: String): ApiResult = withContext(Dispatchers.IO) { + try { + val jsonBody = gson.toJson(mapOf("mode" to mode)) + val requestBody = okhttp3.RequestBody.create( + "application/json".toMediaType(), + jsonBody + ) + val request = Request.Builder() + .url("$apiUrl/api/1/config/uploadMode") + .addHeader("Authorization", "Bearer $apiToken") + .post(requestBody) + .build() - suspend fun getUploadMode(): ApiResult = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/config/uploadMode")) { - is ApiResult.Success -> try { - val trimmed = r.data.trim().trim('"') - if (trimmed.startsWith("{")) { - val obj = gson.fromJson(r.data, UploadModeResponse::class.java) - ApiResult.Success(obj.currentMode() ?: "UNKNOWN") + client.newCall(request).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (resp.isSuccessful) { + ApiResult.Success(body) } else { - ApiResult.Success(trimmed) + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) } - } catch (e: Exception) { - ApiResult.Success(r.data.trim().trim('"')) } - is ApiResult.Error -> r + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") } } - suspend fun setUploadMode(mode: String): ApiResult { - val json = """{"mode": "$mode"}""" - return postRaw("/api/1/config/uploadMode", json) - } - - // ── Config ──────────────────────────────────────────────────────────────── - - suspend fun getConfig(): ApiResult = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/config/")) { - is ApiResult.Success -> try { - ApiResult.Success(gson.fromJson(r.data, BeeConfig::class.java)) - } catch (e: Exception) { - ApiResult.Error("Parse error: ${e.message}") - } - is ApiResult.Error -> r - } - } - - // ── Cache / Storage ─────────────────────────────────────────────────────── - - suspend fun getCacheStatus(): ApiResult = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/cache/status")) { - is ApiResult.Success -> try { - ApiResult.Success(gson.fromJson(r.data, CacheStatus::class.java)) - } catch (e: Exception) { - ApiResult.Error("Parse error: ${e.message}") - } - is ApiResult.Error -> r - } - } - - suspend fun getFrameKmTotal(): ApiResult = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/framekm/total")) { - is ApiResult.Success -> try { - ApiResult.Success(gson.fromJson(r.data, FrameKmTotal::class.java)) - } catch (e: Exception) { - ApiResult.Error("Parse error: ${e.message}") - } - is ApiResult.Error -> r - } - } - - // ── Plugin State ────────────────────────────────────────────────────────── - - suspend fun getPluginState(pluginName: String): PluginState = withContext(Dispatchers.IO) { - when (val r = getRaw("/api/1/plugin/getPluginState/$pluginName")) { - is ApiResult.Success -> try { - val trimmed = r.data.trim() - val enabled = when { - trimmed == "true" -> true - trimmed == "false" -> false - trimmed.startsWith("{") -> { - val obj = gson.fromJson(trimmed, com.google.gson.JsonObject::class.java) - obj.get("enabled")?.asBoolean ?: obj.get("state")?.asString == "enabled" - } - else -> null - } - PluginState(pluginName, enabled) - } catch (e: Exception) { - PluginState(pluginName, null, e.message) - } - is ApiResult.Error -> PluginState(pluginName, null, r.message) - } - } - - suspend fun getKnownPluginStates(): List = withContext(Dispatchers.IO) { - val knownPlugins = listOf("beekeeper", "depth-ai", "privacy-zones", "map-ai") - knownPlugins.map { name -> getPluginState(name) } - } - // ── Camera API ──────────────────────────────────────────────────────────── /** @@ -631,6 +517,7 @@ class BeeApiClient( } is ApiResult.Error -> { Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}") + // Continue trying other endpoints } } } @@ -642,7 +529,10 @@ class BeeApiClient( private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List): ApiResult = withContext(Dispatchers.IO) { try { val jsonBody = gson.toJson(mapOf("ids" to landmarkIds)) - val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) + val requestBody = okhttp3.RequestBody.create( + "application/json".toMediaType(), + jsonBody + ) // Try both DELETE and POST methods for (method in listOf("DELETE", "POST")) { @@ -650,9 +540,7 @@ class BeeApiClient( val requestBuilder = Request.Builder() .url("$apiUrl$endpoint") - if (apiToken.isNotBlank()) { - requestBuilder.addHeader("Authorization", "Bearer $apiToken") - } + .addHeader("Authorization", "Bearer $apiToken") val request = if (method == "DELETE") { requestBuilder.delete(requestBody).build() @@ -677,4 +565,4 @@ class BeeApiClient( return@withContext ApiResult.Error("Exception: ${e.message}") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/adamaps/varroa/data/Models.kt b/app/src/main/java/com/adamaps/varroa/data/Models.kt index 18faff5..7106429 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -47,6 +47,21 @@ data class BeeDeviceInfo( @SerializedName("ssid") val ssid: String? = null ) +// ── Pairing API ─────────────────────────────────────────────────────────────── + +data class PairResponse( + @SerializedName("serial") val serial: String, + @SerializedName("version") val version: String, + @SerializedName("ap_ip") val apIp: String, + @SerializedName("api_port") val apiPort: Int +) + +// ── SSH API ─────────────────────────────────────────────────────────────────── + +data class SshStatus( + @SerializedName("active") val active: Boolean +) + // ── ADAMaps ingest ──────────────────────────────────────────────────────────── data class AdaMapsDetection( @@ -93,7 +108,15 @@ data class WifiConfig( @SerializedName("ssid") val ssid: String? = null, @SerializedName("password") val password: String? = null, @SerializedName("connected") val connected: Boolean? = null, - @SerializedName("ip") val ip: String? = null + @SerializedName("ip") val ip: String? = null, + @SerializedName("state") val state: String? = null +) + +data class WifiStatus( + @SerializedName("ssid") val ssid: String? = null, + @SerializedName("ip") val ip: String? = null, + @SerializedName("state") val state: String? = null, + @SerializedName("connected") val connected: Boolean? = null ) data class StorageStatus( @@ -104,94 +127,7 @@ data class StorageStatus( @SerializedName("recording_hours_available") val recordingHoursAvailable: Double? = null ) -data class BeePlugin( - @SerializedName("name") val name: String? = null, - @SerializedName("version") val version: String? = null, - @SerializedName("enabled") val enabled: Boolean? = null, - @SerializedName("running") val running: Boolean? = null -) - -// ── Pairing API ─────────────────────────────────────────────────────────────── - -data class PairResponse( - @SerializedName("serial") val serial: String, - @SerializedName("version") val version: String, - @SerializedName("ap_ip") val apIp: String, - @SerializedName("api_port") val apiPort: Int -) - -// ── SSH API ─────────────────────────────────────────────────────────────────── - -data class SshStatus( - @SerializedName("active") val active: Boolean -) - -// ── Bee Settings API models ─────────────────────────────────────────────────── - -data class WifiClientSettings( - @SerializedName("ssid") val ssid: String? = null, - @SerializedName("password") val password: String? = null, - @SerializedName("enabled") val enabled: Boolean? = null, - @SerializedName("security") val security: String? = null, - @SerializedName("freq") val freq: Int? = null -) - -data class WifiStatus( - @SerializedName("connected") val connected: Boolean? = null, - @SerializedName("ssid") val ssid: String? = null, - @SerializedName("ip") val ip: String? = null, - @SerializedName("signal") val signal: Int? = null, - @SerializedName("state") val state: String? = null -) - -data class WifiNetwork( - @SerializedName("ssid") val ssid: String? = null, - @SerializedName("signal") val signal: Int? = null, - @SerializedName("security") val security: String? = null, - @SerializedName("freq") val freq: Int? = null -) - -data class CacheStatus( - @SerializedName("enabled") val enabled: Boolean? = null, - @SerializedName("size") val size: Long? = null, - @SerializedName("samples") val samples: Int? = null, - @SerializedName("sizeBytes") val sizeBytes: Long? = null, - @SerializedName("numSamples") val numSamples: Int? = null -) { - // Normalize field names across firmware versions - fun displaySize(): Long = size ?: sizeBytes ?: 0L - fun displaySamples(): Int = samples ?: numSamples ?: 0 -} - -data class BeeConfig( - @SerializedName("uploadMode") val uploadMode: String? = null, - @SerializedName("pluginsLocked") val pluginsLocked: Boolean? = null, - @SerializedName("pluginDevMode") val pluginDevMode: Boolean? = null, - @SerializedName("pausePluginUpdates") val pausePluginUpdates: Boolean? = null, - @SerializedName("isProcessingEnabled") val isProcessingEnabled: Boolean? = null, - @SerializedName("isUSBRecordingEnabled") val isUSBRecordingEnabled: Boolean? = null, - @SerializedName("isLowPowerModeEnabled") val isLowPowerModeEnabled: Boolean? = null -) - -data class UploadModeResponse( - @SerializedName("mode") val mode: String? = null, - @SerializedName("uploadMode") val uploadMode: String? = null -) { - fun currentMode(): String? = mode ?: uploadMode -} - data class GnssStatus( - // v7.7+ API fields - @SerializedName("lat_deg") val latDeg: Double? = null, - @SerializedName("lon_deg") val lonDeg: Double? = null, - @SerializedName("alt_m") val altM: Double? = null, - @SerializedName("unix_milliseconds") val unixMs: Long? = null, - @SerializedName("fix") val fix: Boolean? = null, - @SerializedName("fixType") val fixType: String? = null, - @SerializedName("satellites_used") val satellitesUsed: Int? = null, - @SerializedName("accuracy_m") val accuracyM: Double? = null, - @SerializedName("speed_m_s") val speedMs: Double? = null, - // Legacy/status API fields @SerializedName("has_lock") val hasLock: Boolean? = null, @SerializedName("satellites") val satellites: Int? = null, @SerializedName("hdop") val hdop: Double? = null, @@ -202,16 +138,11 @@ data class GnssStatus( @SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = null ) -data class PluginState( - val name: String, - val enabled: Boolean?, - val error: String? = null -) - -data class FrameKmTotal( - @SerializedName("total") val total: Long? = null, - @SerializedName("totalBytes") val totalBytes: Long? = null, - @SerializedName("count") val count: Int? = null +data class BeePlugin( + @SerializedName("name") val name: String? = null, + @SerializedName("version") val version: String? = null, + @SerializedName("enabled") val enabled: Boolean? = null, + @SerializedName("running") val running: Boolean? = null ) // ── App state ───────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt index 6d9adcc..51d0642 100644 --- a/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt +++ b/app/src/main/java/com/adamaps/varroa/data/SettingsDataStore.kt @@ -17,7 +17,7 @@ private val Context.dataStore: DataStore by preferencesDataStore(na data class VarroaSettings( val beeApiUrl: String = "http://192.168.0.10:5000", val adamapsApiUrl: String = "https://api.adamaps.org", - val adamapsApiKey: String = "", + val adamapsApiKey: String = "***REMOVED***", val pollIntervalSeconds: Int = 30, val cameraEndpoint: String = "/api/1/camera/frame", val cameraRefreshSeconds: Int = 30, @@ -62,7 +62,7 @@ class SettingsDataStore(private val context: Context) { VarroaSettings( beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", - adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "", + adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***", pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame", cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, diff --git a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt index af427a0..9708f7d 100644 --- a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt +++ b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt @@ -202,17 +202,36 @@ class BeeCollectorService : LifecycleService() { settingsStore.updateCachedDeviceId(deviceId) Log.i(TAG, "Device ID retrieved via API: $deviceId (from ${if (result.data.deviceId != null) "deviceId" else if (result.data.serial != null) "serial" else "fallback"})") } else { - Log.w(TAG, "Device ID unknown from API, using cached or unknown") + Log.w(TAG, "API returned unknown device ID, trying SSH fallback...") + fetchDeviceIdViaSsh() } } is ApiResult.Error -> { Log.e(TAG, "Failed to get device ID via API: ${result.message}, code: ${result.code}") + Log.i(TAG, "Trying SSH fallback...") + fetchDeviceIdViaSsh() + } + } + } + + private suspend fun fetchDeviceIdViaSsh() { + Log.d(TAG, "Attempting SSH fallback for device ID...") + when (val result = beeClient.getDeviceIdViaSsh()) { + is ApiResult.Success -> { + val deviceId = result.data + _currentDeviceId.value = deviceId + // Update persistent cache + settingsStore.updateCachedDeviceId(deviceId) + Log.i(TAG, "Device ID retrieved via SSH: $deviceId") + } + is ApiResult.Error -> { + Log.e(TAG, "Failed to get device ID via SSH: ${result.message}") _currentDeviceId.value = "unknown" } } } - private fun startPollLoop(intervalSeconds: Int) { + private fun startPollLoop(intervalSeconds: Int) { pollJob?.cancel() Log.d(TAG, "Previous poll job cancelled") pollJob = lifecycleScope.launch { diff --git a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt index 0b91b28..0a5fb53 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt @@ -39,8 +39,7 @@ import org.osmdroid.views.overlay.Marker @Composable fun DashboardScreen( vm: DashboardViewModel = viewModel(), - onNavigateToSettings: () -> Unit, - onNavigateToBeeSettings: () -> Unit = {} + onNavigateToSettings: () -> Unit ) { val deviceInfo by vm.deviceInfo.collectAsState() val gnss by vm.gnss.collectAsState() @@ -102,11 +101,8 @@ fun DashboardScreen( Spacer(Modifier.width(8.dp)) } - IconButton(onClick = onNavigateToBeeSettings) { - Icon(Icons.Default.Router, contentDescription = "AdaCam Settings", tint = Amber) - } IconButton(onClick = onNavigateToSettings) { - Icon(Icons.Default.Settings, contentDescription = "App Settings", tint = Amber) + Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber) } } ) @@ -154,7 +150,7 @@ fun DashboardScreen( gnss?.let { GpsMapCard(it) } // Device status - deviceInfo?.let { DeviceStatusCard(it, onNavigateToBeeSettings) } + deviceInfo?.let { DeviceStatusCard(it) } } } } @@ -586,32 +582,14 @@ private fun OsmMapView(gnss: GnssData) { } @Composable -private fun DeviceStatusCard(info: BeeDeviceInfo, onNavigateToBeeSettings: () -> Unit = {}) { +private fun DeviceStatusCard(info: BeeDeviceInfo) { Card( colors = CardDefaults.cardColors(containerColor = Surface), shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth() ) { Column(modifier = Modifier.padding(14.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - SectionHeader("DEVICE STATUS") - Text( - "CONFIGURE ›", - color = Amber, - fontFamily = FontFamily.Monospace, - fontSize = 9.sp, - letterSpacing = 1.sp, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .clickable { onNavigateToBeeSettings() } - .background(AmberDark.copy(alpha = 0.2f)) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) - } + SectionHeader("DEVICE STATUS") Spacer(Modifier.height(8.dp)) val rows: List> = listOf( "Firmware" to (info.firmwareVersion ?: "—"), diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt deleted file mode 100644 index 1a4533e..0000000 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt +++ /dev/null @@ -1,972 +0,0 @@ -package com.adamaps.varroa.ui.settings - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.adamaps.varroa.data.* -import com.adamaps.varroa.ui.theme.* -import com.adamaps.varroa.viewmodel.BeeSettingsLoadState -import com.adamaps.varroa.viewmodel.BeeSettingsViewModel -import java.text.SimpleDateFormat -import java.util.* - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BeeSettingsScreen( - vm: BeeSettingsViewModel = viewModel(), - onBack: () -> Unit -) { - val state by vm.state.collectAsState() - val message by vm.message.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(message) { - message?.let { - snackbarHostState.showSnackbar(it) - vm.clearMessage() - } - } - - Scaffold( - containerColor = Background, - snackbarHost = { SnackbarHost(snackbarHostState) }, - topBar = { - TopAppBar( - title = { - Text( - "BEE DEVICE", - color = Amber, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - letterSpacing = 3.sp - ) - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Amber) - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), - actions = { - IconButton(onClick = { vm.loadAll() }) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = Amber) - } - } - ) - } - ) { padding -> - when (val loadState = state.loadState) { - is BeeSettingsLoadState.Loading -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator(color = Amber) - Spacer(Modifier.height(16.dp)) - Text( - "Fetching Bee device info…", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ) - } - } - } - - is BeeSettingsLoadState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(24.dp) - ) { - Icon( - Icons.Default.CloudOff, - contentDescription = null, - tint = Error, - modifier = Modifier.size(48.dp) - ) - Spacer(Modifier.height(12.dp)) - Text( - "Bee Unreachable", - color = Error, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 16.sp - ) - Spacer(Modifier.height(8.dp)) - Text( - loadState.message, - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(Modifier.height(20.dp)) - Button( - onClick = { vm.loadAll() }, - colors = ButtonDefaults.buttonColors(containerColor = AmberDark) - ) { - Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text("RETRY", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) - } - } - } - } - - else -> { - // Success or Idle — show content - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Device Info - state.deviceInfo?.let { DeviceInfoSection(it) } - - // WiFi Client - WifiSection( - status = state.wifiStatus, - settings = state.wifiSettings, - networks = state.wifiNetworks, - scanState = state.wifiScanState, - saving = state.wifiSaving, - onSave = { ssid, pass, enabled -> vm.saveWifiSettings(ssid, pass, enabled) }, - onToggleEnabled = { vm.setWifiEnabled(it) }, - onScan = { vm.scanWifi() }, - onReset = { vm.resetWifi() } - ) - - // Storage - StorageSection( - cacheStatus = state.cacheStatus, - frameKmTotal = state.frameKmTotal - ) - - // GNSS - GnssSection(gnss = state.gnssStatus) - - // Upload Mode - UploadModeSection( - currentMode = state.uploadMode, - saving = state.uploadModeSaving, - onSetMode = { vm.setUploadMode(it) } - ) - - // Plugins - if (state.plugins.isNotEmpty()) { - PluginSection( - plugins = state.plugins, - config = state.config - ) - } - - Spacer(Modifier.height(24.dp)) - } - } - } - } -} - -// ── Device Info ─────────────────────────────────────────────────────────────── - -@Composable -private fun DeviceInfoSection(info: BeeDeviceInfo) { - BeeCard(title = "DEVICE INFO", icon = Icons.Default.Info) { - val rows = listOfNotNull( - info.deviceId?.let { "Device ID" to it }, - info.serial?.let { "Serial" to it }, - info.firmwareVersion?.let { "Firmware" to it }, - info.apiVersion?.let { "API Version" to it }, - info.model?.let { "Model" to it }, - info.imei?.let { "IMEI" to it }, - info.ssid?.let { "Connected SSID" to it }, - info.uptime?.let { "Uptime" to formatUptime(it) }, - "GPS Lock" to if (info.hasGnssLock == true) "YES" else "NO", - "Internet" to if (info.internetIsHealthy == true) "HEALTHY" else "OFFLINE" - ) - rows.forEach { (k, v) -> - InfoRow(k, v, valueColor = when { - k == "GPS Lock" && v == "YES" -> Success - k == "GPS Lock" && v == "NO" -> Error - k == "Internet" && v == "HEALTHY" -> Success - k == "Internet" && v == "OFFLINE" -> Error - else -> OnSurface - }) - } - } -} - -// ── WiFi ────────────────────────────────────────────────────────────────────── - -@Composable -private fun WifiSection( - status: WifiStatus?, - settings: WifiClientSettings?, - networks: List, - scanState: BeeSettingsLoadState, - saving: Boolean, - onSave: (String, String, Boolean) -> Unit, - onToggleEnabled: (Boolean) -> Unit, - onScan: () -> Unit, - onReset: () -> Unit -) { - var expandAddNetwork by remember { mutableStateOf(false) } - var ssidInput by remember { mutableStateOf(settings?.ssid ?: "") } - var passwordInput by remember { mutableStateOf("") } - var showPassword by remember { mutableStateOf(false) } - var enabledInput by remember(settings) { mutableStateOf(settings?.enabled ?: true) } - - // Update SSID input when settings load - LaunchedEffect(settings?.ssid) { - if (settings?.ssid != null && ssidInput.isEmpty()) { - ssidInput = settings.ssid - } - } - - BeeCard(title = "WIFI CLIENT", icon = Icons.Default.Wifi) { - // Connection status - val connected = status?.connected ?: false - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - StatusIndicator(connected) - Spacer(Modifier.width(8.dp)) - Column { - Text( - text = if (connected) "CONNECTED" else "DISCONNECTED", - color = if (connected) Success else Color.Gray, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 12.sp - ) - status?.ssid?.let { - Text(it, color = OnSurface, fontFamily = FontFamily.Monospace, fontSize = 11.sp) - } - status?.ip?.let { - Text("IP: $it", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) - } - } - } - // Enable/disable toggle - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "CLIENT", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 9.sp, - letterSpacing = 1.sp - ) - Switch( - checked = enabledInput, - onCheckedChange = { - enabledInput = it - onToggleEnabled(it) - }, - colors = SwitchDefaults.colors( - checkedThumbColor = Amber, - checkedTrackColor = AmberDark, - uncheckedThumbColor = Color.Gray, - uncheckedTrackColor = SurfaceVariant - ) - ) - } - } - - Spacer(Modifier.height(8.dp)) - Divider(color = SurfaceVariant) - Spacer(Modifier.height(8.dp)) - - // Current saved network - if (settings?.ssid != null) { - InfoRow("Saved SSID", settings.ssid) - settings.security?.let { InfoRow("Security", it) } - settings.freq?.let { InfoRow("Frequency", "${it} MHz (${if (it < 3000) "2.4GHz" else "5GHz"})") } - } - - Spacer(Modifier.height(10.dp)) - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = { expandAddNetwork = !expandAddNetwork }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Amber), - border = androidx.compose.foundation.BorderStroke(1.dp, AmberDark) - ) { - Icon( - if (expandAddNetwork) Icons.Default.ExpandLess else Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(14.dp) - ) - Spacer(Modifier.width(4.dp)) - Text( - if (expandAddNetwork) "CANCEL" else "CONFIGURE", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ) - } - - OutlinedButton( - onClick = onScan, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Amber), - border = androidx.compose.foundation.BorderStroke(1.dp, AmberDark), - enabled = scanState !is BeeSettingsLoadState.Loading - ) { - if (scanState is BeeSettingsLoadState.Loading) { - CircularProgressIndicator( - color = Amber, - modifier = Modifier.size(12.dp), - strokeWidth = 1.5.dp - ) - } else { - Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(14.dp)) - } - Spacer(Modifier.width(4.dp)) - Text("SCAN", fontFamily = FontFamily.Monospace, fontSize = 10.sp, fontWeight = FontWeight.Bold) - } - } - - // Network config form (expandable) - AnimatedVisibility(visible = expandAddNetwork) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "CONFIGURE NETWORK", - color = Amber, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - letterSpacing = 1.sp - ) - BeeTextField( - value = ssidInput, - onValueChange = { ssidInput = it }, - label = "SSID", - hint = "Network name" - ) - BeeTextField( - value = passwordInput, - onValueChange = { passwordInput = it }, - label = "Password", - hint = "Leave empty for open network", - visualTransformation = if (showPassword) VisualTransformation.None - else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, - contentDescription = null, - tint = Color.Gray, - modifier = Modifier.size(18.dp) - ) - } - } - ) - - // Scan results picker - if (networks.isNotEmpty()) { - Text( - "SCAN RESULTS", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 9.sp, - letterSpacing = 1.sp - ) - networks.forEach { net -> - if (net.ssid != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .clickable { ssidInput = net.ssid } - .background(SurfaceVariant) - .padding(horizontal = 10.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon(Icons.Default.Wifi, contentDescription = null, tint = Amber, modifier = Modifier.size(14.dp)) - Text( - net.ssid, - color = OnSurface, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - net.security?.let { - Text(it, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) - } - net.signal?.let { - Text("${it}dBm", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) - } - } - } - } - } - } - - Button( - onClick = { - onSave(ssidInput, passwordInput, enabledInput) - expandAddNetwork = false - passwordInput = "" - }, - enabled = ssidInput.isNotBlank() && !saving, - colors = ButtonDefaults.buttonColors(containerColor = AmberDark), - modifier = Modifier.fillMaxWidth() - ) { - if (saving) { - CircularProgressIndicator(color = Amber, modifier = Modifier.size(14.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(6.dp)) - } - Text( - "SAVE & CONNECT", - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold - ) - } - } - } - - // Reset button - Spacer(Modifier.height(4.dp)) - TextButton( - onClick = onReset, - modifier = Modifier.align(Alignment.End) - ) { - Icon(Icons.Default.RestartAlt, contentDescription = null, tint = Color.Gray, modifier = Modifier.size(14.dp)) - Spacer(Modifier.width(4.dp)) - Text("RESET WIFI", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) - } - } -} - -// ── Storage ─────────────────────────────────────────────────────────────────── - -@Composable -private fun StorageSection( - cacheStatus: CacheStatus?, - frameKmTotal: FrameKmTotal? -) { - BeeCard(title = "STORAGE", icon = Icons.Default.Storage) { - if (cacheStatus == null && frameKmTotal == null) { - Text( - "Storage info unavailable", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp - ) - return@BeeCard - } - - cacheStatus?.let { cache -> - Text( - "DATA CACHE", - color = Amber, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - letterSpacing = 1.sp - ) - Spacer(Modifier.height(4.dp)) - InfoRow( - "Status", - if (cache.enabled == true) "ENABLED" else "DISABLED", - valueColor = if (cache.enabled == true) Success else Color.Gray - ) - InfoRow("Samples", cache.displaySamples().toString()) - InfoRow("Cache Size", formatBytes(cache.displaySize())) - Spacer(Modifier.height(8.dp)) - - // Storage bar - val sizeBytes = cache.displaySize() - val maxBytes = 500L * 1024 * 1024 // 500MB limit from docs - val fraction = (sizeBytes.toFloat() / maxBytes).coerceIn(0f, 1f) - StorageBar(fraction, label = "Cache: ${formatBytes(sizeBytes)} / 500 MB") - } - - if (cacheStatus != null && frameKmTotal != null) { - Spacer(Modifier.height(10.dp)) - Divider(color = SurfaceVariant) - Spacer(Modifier.height(10.dp)) - } - - frameKmTotal?.let { fkm -> - Text( - "FRAMEKM STORAGE", - color = Amber, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - letterSpacing = 1.sp - ) - Spacer(Modifier.height(4.dp)) - fkm.count?.let { InfoRow("Processed Frames", it.toString()) } - val totalBytes = fkm.total ?: fkm.totalBytes - totalBytes?.let { InfoRow("Total Size", formatBytes(it)) } - } - } -} - -@Composable -private fun StorageBar(fraction: Float, label: String) { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(label, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) - Text("${(fraction * 100).toInt()}%", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp) - } - Spacer(Modifier.height(3.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(RoundedCornerShape(3.dp)) - .background(SurfaceVariant) - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction) - .fillMaxHeight() - .background( - when { - fraction > 0.85f -> Error - fraction > 0.65f -> Amber - else -> Success - } - ) - ) - } - } -} - -// ── GNSS ────────────────────────────────────────────────────────────────────── - -@Composable -private fun GnssSection(gnss: GnssStatus?) { - BeeCard(title = "GPS / GNSS", icon = Icons.Default.GpsFixed) { - if (gnss == null) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.GpsOff, contentDescription = null, tint = Error, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text("No GNSS data available", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) - } - return@BeeCard - } - - val hasFix = gnss.fix ?: (gnss.latDeg != null && gnss.latDeg != 0.0 && gnss.lonDeg != null && gnss.lonDeg != 0.0) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (hasFix) Icons.Default.GpsFixed else Icons.Default.GpsNotFixed, - contentDescription = null, - tint = if (hasFix) Success else Amber, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(6.dp)) - Text( - if (hasFix) "FIX ACQUIRED" else "NO FIX", - color = if (hasFix) Success else Amber, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 12.sp - ) - } - gnss.fixType?.let { - Text(it, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) - } - } - - Spacer(Modifier.height(8.dp)) - - val rows = listOfNotNull( - gnss.latDeg?.let { "Latitude" to "%.6f°".format(it) }, - gnss.lonDeg?.let { "Longitude" to "%.6f°".format(it) }, - gnss.altM?.let { "Altitude" to "%.1f m".format(it) }, - (gnss.satellitesUsed ?: gnss.satellites)?.let { "Satellites" to it.toString() }, - gnss.accuracyM?.let { "Accuracy" to "%.1f m".format(it) }, - gnss.hdop?.let { "HDOP" to "%.2f".format(it) }, - gnss.speedMs?.let { "Speed" to "%.1f m/s".format(it) }, - gnss.unixMs?.let { "Last Fix" to formatTimestamp(it) } - ) - rows.forEach { (k, v) -> InfoRow(k, v) } - } -} - -// ── Upload Mode ─────────────────────────────────────────────────────────────── - -@Composable -private fun UploadModeSection( - currentMode: String?, - saving: Boolean, - onSetMode: (String) -> Unit -) { - val modes = listOf("LTE", "WIFI", "APP") - BeeCard(title = "UPLOAD MODE", icon = Icons.Default.CloudUpload) { - Text( - "Select how the Bee uploads data.", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - Spacer(Modifier.height(10.dp)) - - if (saving) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(color = Amber, modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(8.dp)) - Text("Saving…", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - modes.forEach { mode -> - val selected = currentMode?.uppercase() == mode - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(6.dp)) - .background(if (selected) AmberDark else SurfaceVariant) - .border( - 1.dp, - if (selected) Amber else Color.Transparent, - RoundedCornerShape(6.dp) - ) - .clickable { onSetMode(mode) } - .padding(vertical = 10.dp), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - when (mode) { - "LTE" -> Icons.Default.NetworkCell - "WIFI" -> Icons.Default.Wifi - else -> Icons.Default.PhoneAndroid - }, - contentDescription = null, - tint = if (selected) Amber else Color.Gray, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.height(4.dp)) - Text( - mode, - color = if (selected) Amber else Color.Gray, - fontFamily = FontFamily.Monospace, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, - fontSize = 11.sp, - letterSpacing = 1.sp - ) - } - } - } - } - - Spacer(Modifier.height(8.dp)) - val modeDescription = when (currentMode?.uppercase()) { - "LTE" -> "Uploading via cellular LTE connection" - "WIFI" -> "Uploading via WiFi network" - "APP" -> "Upload controlled by companion app" - else -> "Upload mode not set" - } - Text(modeDescription, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) - } - } -} - -// ── Plugins ─────────────────────────────────────────────────────────────────── - -@Composable -private fun PluginSection( - plugins: List, - config: BeeConfig? -) { - BeeCard(title = "PLUGINS", icon = Icons.Default.Extension) { - config?.let { cfg -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (cfg.pluginsLocked == true) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Lock, contentDescription = null, tint = Amber, modifier = Modifier.size(12.dp)) - Spacer(Modifier.width(4.dp)) - Text("PLUGINS LOCKED", color = Amber, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) - } - } - if (cfg.pluginDevMode == true) { - Text("DEV MODE", color = Error, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) - } - if (cfg.pausePluginUpdates == true) { - Text("UPDATES PAUSED", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 9.sp, letterSpacing = 1.sp) - } - } - if (cfg.pluginsLocked == true || cfg.pluginDevMode == true || cfg.pausePluginUpdates == true) { - Spacer(Modifier.height(8.dp)) - Divider(color = SurfaceVariant) - Spacer(Modifier.height(8.dp)) - } - } - - plugins.forEach { plugin -> - PluginRow(plugin) - if (plugin != plugins.last()) { - Spacer(Modifier.height(4.dp)) - } - } - } -} - -@Composable -private fun PluginRow(plugin: PluginState) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Icons.Default.Extension, - contentDescription = null, - tint = when (plugin.enabled) { - true -> Success - false -> Color.Gray - null -> Color(0xFF6B7280) - }, - modifier = Modifier.size(14.dp) - ) - Text( - plugin.name, - color = OnSurface, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ) - } - when { - plugin.error != null -> Text( - "ERR", - color = Error, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - plugin.enabled == true -> Text( - "ENABLED", - color = Success, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ) - plugin.enabled == false -> Text( - "DISABLED", - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - else -> Text( - "UNKNOWN", - color = Color(0xFF6B7280), - fontFamily = FontFamily.Monospace, - fontSize = 10.sp - ) - } - } -} - -// ── Shared components ───────────────────────────────────────────────────────── - -@Composable -private fun BeeCard( - title: String, - icon: ImageVector, - content: @Composable ColumnScope.() -> Unit -) { - Card( - colors = CardDefaults.cardColors(containerColor = Surface), - shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(14.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon(icon, contentDescription = null, tint = Amber, modifier = Modifier.size(14.dp)) - Text( - title, - color = Amber, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 11.sp, - letterSpacing = 2.sp - ) - } - Spacer(Modifier.height(10.dp)) - content() - } - } -} - -@Composable -private fun InfoRow( - label: String, - value: String, - valueColor: Color = OnSurface -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - label, - color = Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - modifier = Modifier.weight(0.45f) - ) - Text( - value, - color = valueColor, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(0.55f), - textAlign = androidx.compose.ui.text.style.TextAlign.End - ) - } -} - -@Composable -private fun BeeTextField( - value: String, - onValueChange: (String) -> Unit, - label: String, - hint: String = "", - visualTransformation: VisualTransformation = VisualTransformation.None, - trailingIcon: (@Composable () -> Unit)? = null, - numeric: Boolean = false -) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, - placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, - singleLine = true, - visualTransformation = visualTransformation, - trailingIcon = trailingIcon, - keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) - else KeyboardOptions.Default, - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Amber, - unfocusedBorderColor = SurfaceVariant, - focusedLabelColor = Amber, - unfocusedLabelColor = Color.Gray, - cursorColor = Amber, - focusedTextColor = OnSurface, - unfocusedTextColor = OnSurface - ) - ) -} - -@Composable -private fun StatusIndicator(active: Boolean) { - Box( - modifier = Modifier - .size(10.dp) - .background( - color = if (active) Success else Color(0xFF6B7280), - shape = RoundedCornerShape(50) - ) - ) -} - -// ── Formatters ──────────────────────────────────────────────────────────────── - -private fun formatUptime(seconds: Long): String { - val h = seconds / 3600 - val m = (seconds % 3600) / 60 - val s = seconds % 60 - return "%02d:%02d:%02d".format(h, m, s) -} - -private fun formatBytes(bytes: Long): String { - return when { - bytes < 1024 -> "$bytes B" - bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) - bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024)) - else -> "%.2f GB".format(bytes / (1024.0 * 1024 * 1024)) - } -} - -private fun formatTimestamp(ms: Long): String { - return try { - val sdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.US) - sdf.format(Date(ms)) - } catch (e: Exception) { - ms.toString() - } -} diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt index 82da999..0edc86b 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Error import androidx.compose.material3.* +import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,6 +23,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.GnssStatus +import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.WifiConfig import com.adamaps.varroa.ui.theme.* @@ -36,6 +38,7 @@ fun DeviceStatusScreen( val state by vm.state.collectAsState() val wifiResult by vm.wifiSaveResult.collectAsState() val uploadResult by vm.uploadModeResult.collectAsState() + val sshResult by vm.sshToggleResult.collectAsState() // Refresh on first load LaunchedEffect(Unit) { @@ -56,6 +59,12 @@ fun DeviceStatusScreen( vm.clearUploadModeResult() } } + LaunchedEffect(sshResult) { + sshResult?.let { + snackbarHostState.showSnackbar(it) + vm.clearSshToggleResult() + } + } Scaffold( containerColor = Background, @@ -119,6 +128,9 @@ fun DeviceStatusScreen( // GPS/GNSS Status GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock) + // SSH Status + SshStatusCard(state.sshStatus, vm) + // Upload Mode UploadModeCard(state.deviceInfo?.uploadMode, vm) @@ -364,7 +376,7 @@ private fun StorageStatusCard(storage: StorageStatus) { @Composable private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) { StatusCard("GPS / GNSS") { - val hasGps = gnss?.fix == true || hasLock == true + val hasGps = gnss?.hasLock == true || hasLock == true Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -390,21 +402,21 @@ private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) { Spacer(Modifier.height(4.dp)) StatusRow("HDOP", "%.2f".format(it), if (it > 5) Color.Yellow else OnSurface) } - if (gnss.latDeg != null && gnss.lonDeg != null) { + if (gnss.lat != null && gnss.lon != null) { Spacer(Modifier.height(4.dp)) - StatusRow("Position", "%.5f, %.5f".format(gnss.latDeg, gnss.lonDeg)) + StatusRow("Position", "%.5f, %.5f".format(gnss.lat, gnss.lon)) } - gnss.altM?.let { + gnss.alt?.let { Spacer(Modifier.height(4.dp)) StatusRow("Altitude", "%.1f m".format(it)) } - gnss.speedMs?.let { + gnss.speedKmh?.let { Spacer(Modifier.height(4.dp)) - StatusRow("Speed", "%.1f km/h".format(it * 3.6)) + StatusRow("Speed", "%.1f km/h".format(it)) } - gnss.unixMs?.let { + gnss.lastFixAgeSec?.let { Spacer(Modifier.height(4.dp)) - val ageMs = System.currentTimeMillis() - it; val ageSec = (ageMs / 1000).toInt(); StatusRow("Last Fix", "${ageSec}s ago", if (ageSec > 30) Color.Yellow else OnSurface) + StatusRow("Last Fix", "${it}s ago", if (it > 30) Color.Yellow else OnSurface) } } } @@ -460,6 +472,55 @@ private fun UploadModeCard(currentMode: String?, vm: DeviceStatusViewModel) { } } +@Composable +private fun SshStatusCard(ssh: SshStatus?, vm: DeviceStatusViewModel) { + val isActive = ssh?.active ?: false + + StatusCard("SSH ACCESS") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + "Enable SSH", + color = OnSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + Text( + "SSH access over home WiFi", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + Switch( + checked = isActive, + onCheckedChange = { vm.toggleSsh(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = Amber, + checkedTrackColor = Amber.copy(alpha = 0.5f), + uncheckedThumbColor = Color.Gray, + uncheckedTrackColor = SurfaceVariant + ) + ) + } + + if (isActive) { + Spacer(Modifier.height(8.dp)) + Text( + "SSH enabled. Connect via:\nssh root@", + color = Color.Green, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + lineHeight = 14.sp + ) + } + } +} + @Composable private fun PluginsCard(plugins: List) { StatusCard("PLUGINS") { diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt index 39d2cf5..068780d 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt @@ -11,8 +11,11 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Wifi import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -24,14 +27,11 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.Terminal -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.lifecycle.viewmodel.compose.viewModel import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.ui.theme.* @@ -49,6 +49,14 @@ fun SettingsScreen( ) { val currentSettings by vm.settings.collectAsState() val saved by vm.saved.collectAsState() + val isPaired by vm.isPaired.collectAsState() + val deviceSerial by vm.deviceSerial.collectAsState() + val pairingInProgress by vm.pairingInProgress.collectAsState() + val pairingResult by vm.pairingResult.collectAsState() + val sshStatus by vm.sshStatus.collectAsState() + val sshToggleResult by vm.sshToggleResult.collectAsState() + val wifiStatus by vm.wifiStatus.collectAsState() + val wifiConnectResult by vm.wifiConnectResult.collectAsState() // Local edit state — initialized from current settings var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) } @@ -59,21 +67,6 @@ fun SettingsScreen( var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) } var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) } - // Pairing & device state - val isPaired by vm.isPaired.collectAsState() - val deviceSerial by vm.deviceSerial.collectAsState() - val pairingInProgress by vm.pairingInProgress.collectAsState() - val pairingResult by vm.pairingResult.collectAsState() - val sshStatus by vm.sshStatus.collectAsState() - val wifiStatus by vm.wifiStatus.collectAsState() - val wifiConnectResult by vm.wifiConnectResult.collectAsState() - - // WiGLE config input state - - // WiFi config input state - var homeWifiSsid by remember { mutableStateOf("") } - var homeWifiPassword by remember { mutableStateOf("") } - // Show snackbar on save val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(saved) { @@ -82,6 +75,24 @@ fun SettingsScreen( vm.clearSaved() } } + LaunchedEffect(pairingResult) { + pairingResult?.let { + snackbarHostState.showSnackbar(it) + vm.clearPairingResult() + } + } + LaunchedEffect(sshToggleResult) { + sshToggleResult?.let { + snackbarHostState.showSnackbar(it) + vm.clearSshResult() + } + } + LaunchedEffect(wifiConnectResult) { + wifiConnectResult?.let { + snackbarHostState.showSnackbar(it) + vm.clearWifiResult() + } + } Scaffold( containerColor = Background, @@ -106,7 +117,7 @@ fun SettingsScreen( actions = { IconButton(onClick = { vm.save( - VarroaSettings( + currentSettings.copy( beeApiUrl = beeApiUrl.trim(), adamapsApiUrl = adamapsApiUrl.trim(), adamapsApiKey = adamapsApiKey.trim(), @@ -131,6 +142,15 @@ fun SettingsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Device Pairing section + PairingSection( + isPaired = isPaired, + deviceSerial = deviceSerial, + pairingInProgress = pairingInProgress, + onPair = { vm.pairDevice() }, + onClearPairing = { vm.clearPairing() } + ) + // Device Status navigation card Card( onClick = onNavigateToDeviceStatus, @@ -177,6 +197,24 @@ fun SettingsScreen( } } + // WiFi Config section (only show if paired) + if (isPaired) { + WifiConfigSection( + wifiStatus = wifiStatus, + onConnect = { ssid, password -> vm.connectWifi(ssid, password) }, + onRefresh = { vm.refreshWifiStatus() } + ) + } + + // SSH section (only show if paired) + if (isPaired) { + SshSection( + sshStatus = sshStatus, + onToggle = { enabled -> vm.toggleSsh(enabled) }, + onRefresh = { vm.refreshSshStatus() } + ) + } + SettingsSection("ADACAM DEVICE") { SettingsField( label = "AdaCam API URL", @@ -186,120 +224,6 @@ fun SettingsScreen( ) } - // ── Pairing ─────────────────────────────────────────────────────── - SettingsSection("PAIRING") { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - Icons.Default.Link, - contentDescription = null, - tint = if (isPaired) Amber else Color.Gray, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(8.dp)) - Text( - if (isPaired) "Paired — serial: $deviceSerial" - else "Not paired", - color = if (isPaired) Amber else Color.Gray, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ) - } - Spacer(Modifier.height(8.dp)) - pairingResult?.let { - Text(it, color = if (it.startsWith("Error")) Color.Red else Amber, - fontFamily = FontFamily.Monospace, fontSize = 11.sp) - Spacer(Modifier.height(4.dp)) - } - Button( - onClick = { vm.pairDevice() }, - enabled = !pairingInProgress, - colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background), - modifier = Modifier.fillMaxWidth() - ) { - Text( - if (pairingInProgress) "Pairing…" else if (isPaired) "Re-pair AdaCam" else "Pair with AdaCam", - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold - ) - } - } - - // ── Home WiFi config ────────────────────────────────────────────── - SettingsSection("HOME WIFI") { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Wifi, contentDescription = null, - tint = if (wifiStatus?.connected == true) Amber else Color.Gray, - modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(8.dp)) - Text( - wifiStatus?.let { - if (it.connected == true) "Connected: ${it.ssid} (${it.ip})" - else "Disconnected" - } ?: "Unknown", - color = if (wifiStatus?.connected == true) Amber else Color.Gray, - fontFamily = FontFamily.Monospace, fontSize = 12.sp - ) - } - Spacer(Modifier.height(8.dp)) - SettingsField(label = "SSID", value = homeWifiSsid, - onValueChange = { homeWifiSsid = it }, hint = "Your home network") - SettingsField(label = "Password", value = homeWifiPassword, - onValueChange = { homeWifiPassword = it }, hint = "WiFi password", - keyboardType = KeyboardType.Password) - wifiConnectResult?.let { - Text(it, color = if (it.startsWith("Error")) Color.Red else Amber, - fontFamily = FontFamily.Monospace, fontSize = 11.sp) - Spacer(Modifier.height(4.dp)) - } - Button( - onClick = { vm.connectWifi(homeWifiSsid, homeWifiPassword) }, - enabled = isPaired && homeWifiSsid.isNotBlank() && homeWifiPassword.isNotBlank(), - colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background), - modifier = Modifier.fillMaxWidth() - ) { - Text("Connect AdaCam to WiFi", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) - } - if (!isPaired) { - Text("Pair device first to configure WiFi", - color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) - } - } - - // ── SSH access ──────────────────────────────────────────────────── - SettingsSection("SSH ACCESS") { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Terminal, contentDescription = null, - tint = if (sshStatus?.active == true) Amber else Color.Gray, - modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(8.dp)) - Column { - Text("SSH over home WiFi", - color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 12.sp) - Text(if (sshStatus?.active == true) "Active — ssh root@" else "Inactive", - color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) - } - } - Switch( - checked = sshStatus?.active == true, - onCheckedChange = { vm.toggleSsh(it) }, - enabled = isPaired, - colors = SwitchDefaults.colors(checkedThumbColor = Background, checkedTrackColor = Amber) - ) - } - if (!isPaired) { - Text("Pair device first to toggle SSH", - color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp) - } - } - SettingsSection("ADAMAPS") { SettingsField( label = "ADAMaps API URL", @@ -361,6 +285,265 @@ fun SettingsScreen( } } +@Composable +private fun PairingSection( + isPaired: Boolean, + deviceSerial: String, + pairingInProgress: Boolean, + onPair: () -> Unit, + onClearPairing: () -> Unit +) { + SettingsSection("DEVICE PAIRING") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (isPaired) Icons.Default.Link else Icons.Default.LinkOff, + contentDescription = null, + tint = if (isPaired) Color.Green else Color.Gray, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Column { + Text( + if (isPaired) "Paired" else "Not Paired", + color = if (isPaired) Color.Green else Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + if (isPaired && deviceSerial.isNotBlank()) { + Text( + "Serial: $deviceSerial", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + if (!isPaired) { + Text( + "Connect your phone to the AdaCam WiFi network (adacam-XXXXXX), then tap Pair.", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + lineHeight = 14.sp + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = onPair, + enabled = !pairingInProgress, + colors = ButtonDefaults.buttonColors(containerColor = Amber), + modifier = Modifier.fillMaxWidth() + ) { + if (pairingInProgress) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = Background, + strokeWidth = 2.dp + ) + Spacer(Modifier.width(8.dp)) + } + Text( + if (pairingInProgress) "Pairing..." else "Pair with AdaCam", + color = Background + ) + } + } else { + OutlinedButton( + onClick = onClearPairing, + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.Red), + modifier = Modifier.fillMaxWidth() + ) { + Text("Clear Pairing", fontSize = 12.sp) + } + } + } +} + +@Composable +private fun WifiConfigSection( + wifiStatus: com.adamaps.varroa.data.WifiStatus?, + onConnect: (String, String) -> Unit, + onRefresh: () -> Unit +) { + var ssid by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + + SettingsSection("HOME WIFI NETWORK") { + // Current status + if (wifiStatus != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Wifi, + contentDescription = null, + tint = if (wifiStatus.connected == true) Color.Green else Color.Yellow, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(8.dp)) + Column { + Text( + if (wifiStatus.connected == true) "Connected" else "Disconnected", + color = if (wifiStatus.connected == true) Color.Green else Color.Yellow, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + if (wifiStatus.ssid != null && wifiStatus.connected == true) { + Text( + wifiStatus.ssid, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + if (wifiStatus.ip != null && wifiStatus.connected == true) { + Text( + "IP: ${wifiStatus.ip}", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + } + } + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.ChevronRight, + contentDescription = "Refresh", + tint = Amber + ) + } + } + Spacer(Modifier.height(12.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(12.dp)) + } + + Text( + "Configure home WiFi for internet access", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = ssid, + onValueChange = { ssid = it }, + label = { Text("SSID", fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Amber, + unfocusedBorderColor = SurfaceVariant, + focusedLabelColor = Amber, + unfocusedLabelColor = Color.Gray, + cursorColor = Amber, + focusedTextColor = OnSurface, + unfocusedTextColor = OnSurface + ) + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password", fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Amber, + unfocusedBorderColor = SurfaceVariant, + focusedLabelColor = Amber, + unfocusedLabelColor = Color.Gray, + cursorColor = Amber, + focusedTextColor = OnSurface, + unfocusedTextColor = OnSurface + ) + ) + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { onConnect(ssid, password) }, + enabled = ssid.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = Amber), + modifier = Modifier.fillMaxWidth() + ) { + Text("Connect", color = Background) + } + } +} + +@Composable +private fun SshSection( + sshStatus: com.adamaps.varroa.data.SshStatus?, + onToggle: (Boolean) -> Unit, + onRefresh: () -> Unit +) { + val isActive = sshStatus?.active ?: false + + SettingsSection("SSH ACCESS") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + "Enable SSH", + color = OnSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + Text( + "SSH over home WiFi network", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + Switch( + checked = isActive, + onCheckedChange = { onToggle(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = Amber, + checkedTrackColor = Amber.copy(alpha = 0.5f), + uncheckedThumbColor = Color.Gray, + uncheckedTrackColor = SurfaceVariant + ) + ) + } + + if (isActive) { + Spacer(Modifier.height(8.dp)) + Text( + "SSH enabled. Connect via:\nssh root@", + color = Color.Green, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + lineHeight = 14.sp + ) + } + } +} + @Composable private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { Card( @@ -388,8 +571,7 @@ private fun SettingsField( value: String, onValueChange: (String) -> Unit, hint: String = "", - numeric: Boolean = false, - keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text + numeric: Boolean = false ) { OutlinedTextField( value = value, @@ -397,7 +579,8 @@ private fun SettingsField( label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) }, singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) + else KeyboardOptions.Default, modifier = Modifier.fillMaxWidth(), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Amber, @@ -580,5 +763,3 @@ private fun WalletLinkingSection( ) } } - - diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt deleted file mode 100644 index 0a33a53..0000000 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.adamaps.varroa.viewmodel - -import android.app.Application -import android.util.Log -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.adamaps.varroa.api.BeeApiClient -import com.adamaps.varroa.data.* -import com.adamaps.varroa.network.NetworkStateMonitor -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch - -private const val TAG = "BeeSettingsVM" - -sealed class BeeSettingsLoadState { - object Idle : BeeSettingsLoadState() - object Loading : BeeSettingsLoadState() - data class Error(val message: String) : BeeSettingsLoadState() - object Success : BeeSettingsLoadState() -} - -data class BeeSettingsState( - val deviceInfo: BeeDeviceInfo? = null, - val wifiStatus: WifiStatus? = null, - val wifiSettings: WifiClientSettings? = null, - val wifiNetworks: List = emptyList(), - val cacheStatus: CacheStatus? = null, - val frameKmTotal: FrameKmTotal? = null, - val gnssStatus: GnssStatus? = null, - val uploadMode: String? = null, - val config: BeeConfig? = null, - val plugins: List = emptyList(), - val loadState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, - val wifiScanState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, - val saveState: BeeSettingsLoadState = BeeSettingsLoadState.Idle, - val uploadModeSaving: Boolean = false, - val wifiSaving: Boolean = false -) - -class BeeSettingsViewModel(app: Application) : AndroidViewModel(app) { - - private val settingsStore = SettingsDataStore(app) - private val networkMonitor = NetworkStateMonitor.getInstance(app) - private val beeClient = BeeApiClient() - - private val _state = MutableStateFlow(BeeSettingsState()) - val state: StateFlow = _state.asStateFlow() - - // Toast/snackbar messages - private val _message = MutableStateFlow(null) - val message: StateFlow = _message.asStateFlow() - - init { - viewModelScope.launch { - val s = settingsStore.settings.first() - beeClient.updateUrl(s.beeApiUrl) - val beeNet = networkMonitor.getBeeNetworkForBinding() - if (beeNet != null) { - beeClient.bindToNetwork(beeNet) - } else { - beeClient.bindToWifiNetwork(app) - } - loadAll() - } - } - - fun loadAll() { - viewModelScope.launch { - _state.update { it.copy(loadState = BeeSettingsLoadState.Loading) } - try { - // Fetch all sections concurrently - val deviceInfoDeferred = async { beeClient.getDeviceInfo() } - val wifiStatusDeferred = async { beeClient.getWifiStatus() } - val wifiSettingsDeferred = async { beeClient.getWifiSettings() } - val cacheDeferred = async { beeClient.getCacheStatus() } - val frameKmDeferred = async { beeClient.getFrameKmTotal() } - val gnssDeferred = async { beeClient.getGnssStatus() } - val uploadModeDeferred = async { beeClient.getUploadMode() } - val configDeferred = async { beeClient.getConfig() } - val pluginsDeferred = async { beeClient.getKnownPluginStates() } - - val deviceInfo = when (val r = deviceInfoDeferred.await()) { - is ApiResult.Success -> r.data - is ApiResult.Error -> { - Log.w(TAG, "deviceInfo failed: ${r.message}") - null - } - } - - if (deviceInfo == null) { - _state.update { - it.copy(loadState = BeeSettingsLoadState.Error("Bee not reachable — check connection and URL")) - } - return@launch - } - - val wifiStatus = (wifiStatusDeferred.await() as? ApiResult.Success)?.data - val wifiSettings = (wifiSettingsDeferred.await() as? ApiResult.Success)?.data - val cacheStatus = (cacheDeferred.await() as? ApiResult.Success)?.data - val frameKmTotal = (frameKmDeferred.await() as? ApiResult.Success)?.data - val gnssStatus = (gnssDeferred.await() as? ApiResult.Success)?.data - val uploadMode = (uploadModeDeferred.await() as? ApiResult.Success)?.data - val config = (configDeferred.await() as? ApiResult.Success)?.data - val plugins = pluginsDeferred.await() - - _state.update { - it.copy( - deviceInfo = deviceInfo, - wifiStatus = wifiStatus, - wifiSettings = wifiSettings, - cacheStatus = cacheStatus, - frameKmTotal = frameKmTotal, - gnssStatus = gnssStatus, - uploadMode = uploadMode ?: deviceInfo.uploadMode, - config = config, - plugins = plugins, - loadState = BeeSettingsLoadState.Success - ) - } - Log.i(TAG, "BeeSettings loaded successfully") - - } catch (e: Exception) { - Log.e(TAG, "loadAll failed", e) - _state.update { - it.copy(loadState = BeeSettingsLoadState.Error(e.message ?: "Unknown error")) - } - } - } - } - - fun scanWifi() { - viewModelScope.launch { - _state.update { it.copy(wifiScanState = BeeSettingsLoadState.Loading) } - when (val r = beeClient.scanWifi()) { - is ApiResult.Success -> { - _state.update { - it.copy( - wifiNetworks = r.data, - wifiScanState = BeeSettingsLoadState.Success - ) - } - _message.value = "Found ${r.data.size} networks" - } - is ApiResult.Error -> { - _state.update { it.copy(wifiScanState = BeeSettingsLoadState.Error(r.message)) } - _message.value = "Scan failed: ${r.message}" - } - } - } - } - - fun saveWifiSettings(ssid: String, password: String, enabled: Boolean) { - viewModelScope.launch { - _state.update { it.copy(wifiSaving = true) } - val settings = WifiClientSettings( - ssid = ssid.trim(), - password = password, - enabled = enabled, - security = if (password.length >= 8) "WPA2" else "Open" - ) - when (val r = beeClient.saveWifiSettings(settings)) { - is ApiResult.Success -> { - _message.value = "WiFi settings saved" - // Refresh WiFi status after save - (beeClient.getWifiStatus() as? ApiResult.Success)?.data?.let { status -> - _state.update { it.copy(wifiStatus = status, wifiSettings = settings) } - } - } - is ApiResult.Error -> { - _message.value = "Save failed: ${r.message}" - } - } - _state.update { it.copy(wifiSaving = false) } - } - } - - fun setWifiEnabled(enabled: Boolean) { - viewModelScope.launch { - when (val r = beeClient.setWifiEnabled(enabled)) { - is ApiResult.Success -> { - _message.value = if (enabled) "WiFi client enabled" else "WiFi client disabled" - _state.update { s -> - s.copy(wifiSettings = s.wifiSettings?.copy(enabled = enabled)) - } - } - is ApiResult.Error -> { - _message.value = "Failed: ${r.message}" - } - } - } - } - - fun resetWifi() { - viewModelScope.launch { - when (val r = beeClient.resetWifi()) { - is ApiResult.Success -> _message.value = "WiFi reset triggered" - is ApiResult.Error -> _message.value = "Reset failed: ${r.message}" - } - } - } - - fun setUploadMode(mode: String) { - viewModelScope.launch { - _state.update { it.copy(uploadModeSaving = true) } - when (val r = beeClient.setUploadMode(mode)) { - is ApiResult.Success -> { - _state.update { it.copy(uploadMode = mode, uploadModeSaving = false) } - _message.value = "Upload mode set to $mode" - } - is ApiResult.Error -> { - _state.update { it.copy(uploadModeSaving = false) } - _message.value = "Failed: ${r.message}" - } - } - } - } - - fun clearMessage() { - _message.value = null - } -} diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt index 09ab8be..ae6e7fa 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt @@ -10,6 +10,7 @@ import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.SettingsDataStore +import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.WifiConfig import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +26,7 @@ data class DeviceStatusState( val wifiConfig: WifiConfig? = null, val storageStatus: StorageStatus? = null, val gnssStatus: GnssStatus? = null, + val sshStatus: SshStatus? = null, val plugins: List = emptyList(), val error: String? = null ) @@ -47,6 +49,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { private val _uploadModeResult = MutableStateFlow(null) val uploadModeResult: StateFlow = _uploadModeResult.asStateFlow() + private val _sshToggleResult = MutableStateFlow(null) + val sshToggleResult: StateFlow = _sshToggleResult.asStateFlow() + fun refresh() { viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, error = null) @@ -54,6 +59,10 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { try { val settings = store.settings.first() val client = BeeApiClient(settings.beeApiUrl) + // Set auth token if paired + if (settings.apiToken.isNotBlank()) { + client.apiToken = settings.apiToken + } beeClient = client // Fetch all status in parallel @@ -62,12 +71,14 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { val storageResult = client.getStorageStatus() val gnssResult = client.getGnssStatus() val pluginsResult = client.getPlugins() + val sshResult = client.getSshStatus() val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data val wifi = (wifiResult as? ApiResult.Success)?.data val storage = (storageResult as? ApiResult.Success)?.data val gnss = (gnssResult as? ApiResult.Success)?.data val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList() + val ssh = (sshResult as? ApiResult.Success)?.data val isConnected = deviceInfo != null @@ -78,8 +89,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { wifiConfig = wifi, storageStatus = storage, gnssStatus = gnss, + sshStatus = ssh, plugins = plugins, - error = if (!isConnected) "Cannot connect to Bee device" else null + error = if (!isConnected) "Cannot connect to AdaCam device" else null ) Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}") @@ -129,6 +141,24 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { } } + fun toggleSsh(enabled: Boolean) { + viewModelScope.launch { + val client = beeClient ?: return@launch + _sshToggleResult.value = null + + when (val result = client.setSshEnabled(enabled)) { + is ApiResult.Success -> { + _sshToggleResult.value = if (enabled) "SSH enabled" else "SSH disabled" + _state.value = _state.value.copy(sshStatus = SshStatus(active = enabled)) + } + is ApiResult.Error -> { + _sshToggleResult.value = "Failed: ${result.message}" + refresh() + } + } + } + } + fun clearWifiResult() { _wifiSaveResult.value = null } @@ -136,4 +166,8 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { fun clearUploadModeResult() { _uploadModeResult.value = null } + + fun clearSshToggleResult() { + _sshToggleResult.value = null + } } diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt index 8e16e29..5d1ef53 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt @@ -64,7 +64,6 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { private val _wifiConnectResult = MutableStateFlow(null) val wifiConnectResult: StateFlow = _wifiConnectResult.asStateFlow() - init { // Initialize BeeApiClient with stored settings and token viewModelScope.launch { diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index f777e22..0f2972b 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.png b/app/src/main/res/drawable/ic_launcher_foreground.png deleted file mode 100644 index 29254bf..0000000 Binary files a/app/src/main/res/drawable/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ea5eaeb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 8f88f75..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 8f88f75..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 2a38a94..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 2a38a94..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 8b51cb4..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 8b51cb4..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 7f69984..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 7f69984..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 7c51289..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 7c51289..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index e7f24f7..81d34f7 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,12 +1,7 @@ - - - - - 192.168.0.10 + 192.168.0.10 + diff --git a/blackbox/air-aggregator.service b/blackbox/air-aggregator.service deleted file mode 100644 index 17be223..0000000 --- a/blackbox/air-aggregator.service +++ /dev/null @@ -1,21 +0,0 @@ -[Unit] -Description=Blackbox Air Quality Aggregator -After=network.target - -[Service] -Type=simple -User=pi -Restart=always -RestartSec=10 -ExecStart=/usr/bin/python3 /home/pi/air-aggregator.py -Environment=BEE_URL=http://192.168.197.1:5000 -Environment=ADAMAPS_URL=https://api.adamaps.org -Environment=ADAMAPS_KEY= -Environment=DEVICE_ID=blackbox-pi -Environment=PMS_PORT=/dev/ttyS0 -Environment=SEND_INTERVAL=10 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target diff --git a/blackbox/air_aggregator.py b/blackbox/air_aggregator.py deleted file mode 100644 index 784c17b..0000000 --- a/blackbox/air_aggregator.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env python3 -""" -air-aggregator.py — Blackbox Air Quality Service -Reads BME680 (I2C) + PMS5003 (UART), fuses with GPS from Bee API, -ships readings to AdaMaps ingest endpoint. - -Hardware: - BME680 → I2C (SDA=GPIO2, SCL=GPIO3, addr=0x77) - PMS5003 → UART (/dev/serial0 or /dev/ttyS0, 9600 baud) - -Dependencies: - pip install bme680 requests smbus2 - PMS5003 is read manually (no dep needed) - -Config via env vars or edit constants below. -""" - -import os, time, json, struct, logging, threading -import serial -import smbus2 -import bme680 -import requests -from datetime import datetime, timezone - -# ── Config ──────────────────────────────────────────────────────────────────── -BEE_URL = os.environ.get("BEE_URL", "http://192.168.197.1:5000") -ADAMAPS_URL = os.environ.get("ADAMAPS_URL", "https://api.adamaps.org") -ADAMAPS_KEY = os.environ.get("ADAMAPS_KEY", "") -DEVICE_ID = os.environ.get("DEVICE_ID", "blackbox-pi") -PMS_PORT = os.environ.get("PMS_PORT", "/dev/ttyS0") -SEND_INTERVAL = int(os.environ.get("SEND_INTERVAL", "10")) # seconds -BUFFER_FILE = "/tmp/air_buffer.json" -# ────────────────────────────────────────────────────────────────────────────── - -logging.basicConfig(level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s") -log = logging.getLogger("air-aggregator") - - -# ── BME680 ──────────────────────────────────────────────────────────────────── -def init_bme680(): - try: - sensor = bme680.BME680(bme680.I2C_ADDR_PRIMARY) - sensor.set_humidity_oversample(bme680.OS_2X) - sensor.set_pressure_oversample(bme680.OS_4X) - sensor.set_temperature_oversample(bme680.OS_8X) - sensor.set_filter(bme680.FILTER_SIZE_3) - sensor.set_gas_status(bme680.ENABLE_GAS_MEAS) - sensor.set_gas_heater_temperature(320) - sensor.set_gas_heater_duration(150) - sensor.select_gas_heater_profile(0) - log.info("BME680 initialized") - return sensor - except Exception as e: - log.error("BME680 init failed: %s", e) - return None - -def read_bme680(sensor): - if not sensor: - return {} - try: - if sensor.get_sensor_data(): - return { - "temperature_c": round(sensor.data.temperature, 2), - "humidity_pct": round(sensor.data.humidity, 2), - "pressure_hpa": round(sensor.data.pressure, 2), - "gas_resistance_ohm": sensor.data.gas_resistance if sensor.data.heat_stable else None, - } - except Exception as e: - log.debug("BME680 read error: %s", e) - return {} - - -# ── PMS5003 ─────────────────────────────────────────────────────────────────── -# Protocol: 32-byte frames, start bytes 0x42 0x4d -PMS_START = b'\x42\x4d' -PMS_FRAME = 32 - -def init_pms5003(): - try: - ser = serial.Serial(PMS_PORT, baudrate=9600, timeout=2) - log.info("PMS5003 on %s", PMS_PORT) - return ser - except Exception as e: - log.error("PMS5003 init failed: %s", e) - return None - -def read_pms5003(ser): - if not ser: - return {} - try: - # Sync to frame start - while True: - b = ser.read(1) - if b == b'\x42': - b2 = ser.read(1) - if b2 == b'\x4d': - break - frame = ser.read(PMS_FRAME - 2) - if len(frame) < PMS_FRAME - 2: - return {} - # Parse: skip length (2 bytes), then 13 uint16s - vals = struct.unpack('>13H', frame[2:28]) - return { - "pm1_0_ug_m3": vals[3], # atmospheric env - "pm2_5_ug_m3": vals[4], - "pm10_ug_m3": vals[5], - "particles_0_3_per_dl": vals[6], - "particles_0_5_per_dl": vals[7], - "particles_1_0_per_dl": vals[8], - "particles_2_5_per_dl": vals[9], - } - except Exception as e: - log.debug("PMS5003 read error: %s", e) - return {} - - -# ── GPS from Bee ────────────────────────────────────────────────────────────── -_gps_cache = {} - -def fetch_gps(): - global _gps_cache - try: - r = requests.get(f"{BEE_URL}/api/1/gnssConcise/latestValid", timeout=3) - if r.ok: - d = r.json() - _gps_cache = { - "lat": d.get("lat") or d.get("latitude"), - "lon": d.get("lon") or d.get("longitude"), - "alt": d.get("alt") or d.get("altitude"), - "gps_fix": True, - } - except Exception as e: - log.debug("GPS fetch failed: %s", e) - _gps_cache["gps_fix"] = False - return _gps_cache - -def gps_poller(): - while True: - fetch_gps() - time.sleep(1) - - -# ── Buffer ──────────────────────────────────────────────────────────────────── -def buffer_reading(reading): - buf = [] - try: - with open(BUFFER_FILE) as f: - buf = json.load(f) - except: pass - buf.append(reading) - # Cap buffer at 1000 readings (~3 hours at 10s interval) - if len(buf) > 1000: - buf = buf[-1000:] - with open(BUFFER_FILE, 'w') as f: - json.dump(buf, f) - -def flush_buffer(): - try: - with open(BUFFER_FILE) as f: - buf = json.load(f) - except: return - if not buf: return - try: - r = requests.post( - f"{ADAMAPS_URL}/api/ingest/air", - json={"device_id": DEVICE_ID, "readings": buf}, - headers={"X-AdaMaps-Key": ADAMAPS_KEY}, - timeout=15 - ) - if r.ok: - log.info("Flushed %d buffered readings", len(buf)) - with open(BUFFER_FILE, 'w') as f: - json.dump([], f) - else: - log.warning("Flush failed: %s", r.status_code) - except Exception as e: - log.warning("Flush error: %s", e) - - -# ── Main loop ───────────────────────────────────────────────────────────────── -def main(): - bme = init_bme680() - pms = init_pms5003() - - # GPS in background thread - t = threading.Thread(target=gps_poller, daemon=True) - t.start() - - log.info("Air aggregator started — sending every %ds", SEND_INTERVAL) - last_send = 0 - - while True: - bme_data = read_bme680(bme) - pms_data = read_pms5003(pms) - gps = dict(_gps_cache) - - reading = { - "device_id": DEVICE_ID, - "sampled_at": datetime.now(timezone.utc).isoformat(), - "lat": gps.get("lat"), - "lon": gps.get("lon"), - "alt": gps.get("alt"), - "gps_fix": gps.get("gps_fix", False), - **bme_data, - **pms_data, - } - - now = time.time() - if now - last_send >= SEND_INTERVAL: - # Try live send first, buffer on failure - try: - flush_buffer() # drain any backlog first - r = requests.post( - f"{ADAMAPS_URL}/api/ingest/air", - json={"device_id": DEVICE_ID, "readings": [reading]}, - headers={"X-AdaMaps-Key": ADAMAPS_KEY}, - timeout=10 - ) - if r.ok: - log.info("Sent | PM2.5=%.1f lat=%s lon=%s", - reading.get("pm2_5_ug_m3", 0), - reading.get("lat"), reading.get("lon")) - else: - log.warning("Send failed %s — buffering", r.status_code) - buffer_reading(reading) - except Exception as e: - log.warning("Offline (%s) — buffering", e) - buffer_reading(reading) - last_send = now - - time.sleep(1) - -if __name__ == "__main__": - main() diff --git a/docs/AIR-QUALITY-INTEGRATION.md b/docs/AIR-QUALITY-INTEGRATION.md deleted file mode 100644 index 2f4be27..0000000 --- a/docs/AIR-QUALITY-INTEGRATION.md +++ /dev/null @@ -1,369 +0,0 @@ -# Air quality sensor on the Bee - -Can the Bee carry an air quality sensor alongside the existing Hivemapper pipeline and feed readings into AdaMaps? Short answer: yes, with the USB-C data port and a USB-to-I2C bridge. The polling overhead is negligible — the real work is on the AdaMaps side (new ingest endpoint, PostGIS table, heatmap overlay). - -## what's free on the Bee - -Hardware is Keem Bay (RVC2), 4× A53 @ 1.5GHz, Myriad X VPU, 3.5GB usable RAM (1.34GB of that is CMA-reserved for VPU DMA), leaving ~2.2GB for userspace. - -Current service load (pre Phase-1 cleanup): - -| Service | CPU | RAM | Notes | -|---------|-----|-----|-------| -| map-ai | ~32% | ~1.1GB | VPU inference | -| odc-api | ~48% | ~139MB | Phase 2 replacement target | -| depthai_gate | ~5% | ~200MB | camera | -| redis | <1% | ~50MB | | -| **total** | ~85% | ~1.5GB | | - -After Phase 1 (odc-api shrink): ~50-60% CPU free (2-2.4 cores idle) and 700MB-1GB RAM free. More than enough for a 1Hz polling loop. - -USB topology — the Keem Bay USB controller hosts an internal hub. The LTE modem (Telit LE910C4) sits on one internal port; the external USB-C is the other. It does carry data, not just power, so it's the target for sensor attachment. - -``` -Keem Bay USB controller - └── internal hub - ├── Telit LE910C4 (internal) - └── USB-C data port (external) ← us -``` - -## sensor candidates - -| Model | Maker | Measures | Iface | Notes | -|-------|-------|----------|-------|-------| -| BME680 | Bosch | VOC, temp, RH, pressure | I2C/SPI | indoor IAQ, ~$10-20 | -| BME688 | Bosch | BME680 + AI gas scanning | I2C/SPI | advanced VOC classification | -| SEN50 | Sensirion | PM1.0/PM2.5/PM4/PM10 | I2C/UART | particulates only | -| SEN54 | Sensirion | PM + VOC + temp + RH | I2C/UART | | -| SEN55 | Sensirion | SEN54 + NOx | I2C/UART | full air-quality suite | - -Worth flagging: SEN5x is Sensirion, not Bosch. If the sensor on hand is branded Bosch it's almost certainly a BME680 or BME688. - -**BME680/688** — VOC as IAQ index 0-500; temp -40 to +85°C ±1°C; RH 0-100% ±3%; pressure 300-1100 hPa ±1 hPa; 3.6mA active, <1µA sleep. I2C address 0x76 or 0x77. Cheap, well-documented, low power, but VOC is a relative index (not absolute concentration) and the gas sensor needs ~48h of burn-in before readings stabilize. - -**SEN55** — PM1.0/PM2.5/PM4/PM10 (0-1000 µg/m³), VOC index, NOx index, temp -10 to +50°C, RH 0-100%. ~60mA. Native I2C or UART. Bigger (~40×40×12mm) and pricier (~$50-80), but it measures actual particulate matter, which is the metric that matters for outdoor pollution mapping. - -For AdaMaps urban pollution work, SEN55 is the right pick — PM2.5 and NOx are the actionable numbers. For a quick "does this work at all" prototype, BME680 is fine. - -## USB bridge options - -For I2C sensors (BME680/688) we need a USB-to-I2C adapter: - -| Adapter | Cost | Notes | -|---------|------|-------| -| Adafruit FT232H | $15 | FTDI, good support, `ftdi_sio` driver | -| MCP2221A | $5 | Microchip, HID mode, `i2c-mcp2221` | -| CP2112 | $8 | Silicon Labs, HID mode | -| CH341 | $3 | generic Chinese, works but flaky | - -FT232H shows up as `/dev/i2c-X` via `ftdi_sio`; MCP2221A as `/dev/hidraw*` or `/dev/i2c-X`. Python side: `smbus2` for low-level, `adafruit-blinka` + `adafruit-circuitpython-bme680` for the BME, or `pyftdi` to drive the bridge directly. - -Quick read with FT232H + BME680: - -```python -import board, adafruit_bme680 -i2c = board.I2C() -sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c) -print(sensor.temperature, sensor.humidity, sensor.pressure, sensor.gas) -``` - -For SEN5x, simplest is UART mode (jumper on the sensor) + CP2102 USB-UART (~$2). Sensor shows up as `/dev/ttyUSB0`; talk SHDLC at 115200. The Sensirion SEK-SEN55 eval kit has native USB-C and appears as CDC-ACM, but it's $100 and oversized — fine for bench testing, wrong for production. - -Picking one: MCP2221A + BME680 breakout ~$15 total for basic VOC. CP2102 + SEN55 ~$55 for full particulate matter. - -## bee-side integration - -New service `air-sensor.service` polls the sensor at 1Hz and writes a Redis key. `bee-collector.py` (existing) reads that key during GPS fusion and includes it in the upload payload. - -``` -USB sensor + adapter - │ - ▼ -air-sensor.service (Python, 1Hz) - │ - ▼ -Redis: AirQuality1Hz - │ - ▼ -bee-collector.py ─ GNSSFusion30Hz ── (fuse) ─► HTTPS to AdaMaps -``` - -Service unit: - -```ini -[Unit] -Description=Air Quality Sensor Reader -After=redis.service - -[Service] -Type=simple -User=root -ExecStart=/opt/air-sensor/air_sensor.py -Restart=always -RestartSec=5 -``` - -`/opt/air-sensor/air_sensor.py`: - -```python -#!/usr/bin/env python3 -"""Poll BME680/688 over USB-I2C, publish to Redis at 1Hz.""" - -import json, time, redis -import board, adafruit_bme680 - -POLL = 1.0 -KEY = "AirQuality1Hz" - -def iaq(gas, _humidity): - # placeholder — for real IAQ use Bosch BSEC - if gas > 300000: return 50 - if gas > 200000: return 100 - if gas > 100000: return 150 - if gas > 50000: return 200 - return 300 - -def main(): - r = redis.Redis() - i2c = board.I2C() - sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=0x77) - sensor.sea_level_pressure = 1013.25 - - while True: - reading = { - "ts": int(time.time() * 1000), - "temperature_c": round(sensor.temperature, 2), - "humidity_pct": round(sensor.humidity, 2), - "pressure_hpa": round(sensor.pressure, 2), - "gas_resistance_ohms": sensor.gas, - "iaq_index": iaq(sensor.gas, sensor.humidity), - } - r.set(KEY, json.dumps(reading)) - r.publish("air_quality", json.dumps(reading)) - time.sleep(POLL) - -if __name__ == "__main__": - main() -``` - -The IAQ calc above is a placeholder — for real-world readings you want the Bosch BSEC library, which is closed-source but free for non-commercial use (license check needed before shipping anything that's not personal). - -Extending bee-collector to merge it in: - -```python -def get_air_quality(): - data = redis_client.get("AirQuality1Hz") - return json.loads(data) if data else None - -def collect_frame(): - gnss = json.loads(redis_client.get("GNSSFusion30Hz") or "{}") - air = get_air_quality() - return { - "timestamp": int(time.time() * 1000), - "lat": gnss.get("lat"), - "lon": gnss.get("lon"), - "speed_kmh": gnss.get("speed"), - "air_temperature_c": air and air.get("temperature_c"), - "air_humidity_pct": air and air.get("humidity_pct"), - "air_pressure_hpa": air and air.get("pressure_hpa"), - "air_iaq_index": air and air.get("iaq_index"), - "air_gas_ohms": air and air.get("gas_resistance_ohms"), - } -``` - -Resource impact: <0.5% CPU, ~15MB RAM, one thread, <1KB/s USB traffic. Doesn't touch the camera, VPU, or map-ai. Negligible. - -## AdaMaps side - -Two endpoints + one table. - -### `/api/ingest/air` - -```python -@app.route('/api/ingest/air', methods=['POST']) -def ingest_air_quality(): - if not verify_api_key(request): - return jsonify({"error": "Unauthorized"}), 401 - data = request.json - required = ['lat', 'lon', 'timestamp'] - if not all(k in data for k in required): - return jsonify({"error": "Missing required fields"}), 400 - - conn = get_db(); cur = conn.cursor() - cur.execute(""" - INSERT INTO air_quality ( - device_id, timestamp, - lat, lon, geom, - temperature_c, humidity_pct, pressure_hpa, - iaq_index, gas_ohms, - pm1_0, pm2_5, pm4_0, pm10, - voc_index, nox_index - ) VALUES ( - %s, to_timestamp(%s / 1000.0), - %s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326), - %s, %s, %s, - %s, %s, - %s, %s, %s, %s, - %s, %s - ) - """, ( - data.get('device_id'), data['timestamp'], - data['lat'], data['lon'], - data['lon'], data['lat'], # ST_MakePoint is (lon, lat) - data.get('air_temperature_c'), - data.get('air_humidity_pct'), - data.get('air_pressure_hpa'), - data.get('air_iaq_index'), - data.get('air_gas_ohms'), - data.get('pm1_0'), data.get('pm2_5'), - data.get('pm4_0'), data.get('pm10'), - data.get('voc_index'), data.get('nox_index'), - )) - conn.commit(); cur.close() - return jsonify({"inserted": 1}) -``` - -### schema - -```sql -CREATE TABLE air_quality ( - id SERIAL PRIMARY KEY, - device_id VARCHAR(64), - timestamp TIMESTAMPTZ NOT NULL, - - lat DOUBLE PRECISION NOT NULL, - lon DOUBLE PRECISION NOT NULL, - geom GEOMETRY(Point, 4326), - - -- BME680/688 - temperature_c REAL, - humidity_pct REAL, - pressure_hpa REAL, - iaq_index INTEGER, -- 0-500 (Bosch IAQ) - gas_ohms INTEGER, - - -- SEN5x - pm1_0 REAL, -- µg/m³ - pm2_5 REAL, - pm4_0 REAL, - pm10 REAL, - voc_index INTEGER, -- 1-500 - nox_index INTEGER, -- 1-500 - - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX idx_air_quality_geom ON air_quality USING GIST (geom); -CREATE INDEX idx_air_quality_timestamp ON air_quality (timestamp DESC); -CREATE INDEX idx_air_quality_device ON air_quality (device_id); -``` - -### `/api/air/heatmap` - -```python -@app.route('/api/air/heatmap', methods=['GET']) -def air_quality_heatmap(): - hours = request.args.get('hours', 24, type=int) - metric = request.args.get('metric', 'iaq_index') # or pm2_5, voc_index - # bounds param parsed but unused for now — TODO - - conn = get_db(); cur = conn.cursor() - cur.execute(f""" - SELECT - ST_X(ST_Centroid(ST_Collect(geom))) AS lon, - ST_Y(ST_Centroid(ST_Collect(geom))) AS lat, - AVG({metric}) AS value, - COUNT(*) AS samples - FROM air_quality - WHERE timestamp > NOW() - INTERVAL '%s hours' - GROUP BY ROUND(lat::numeric, 3), ROUND(lon::numeric, 3) - HAVING AVG({metric}) IS NOT NULL - """, (hours,)) - - results = [ - {"lon": r[0], "lat": r[1], "value": round(r[2], 1), "samples": r[3]} - for r in cur.fetchall() - ] - cur.close() - return jsonify({"data": results, "metric": metric}) -``` - -The `metric` arg interpolates into the SQL — fine since the value is validated against a column allowlist before reaching this point (don't skip that part). - -## frontend overlay - -Leaflet.heat does most of the work. Convert the heatmap response to `[lat, lon, intensity]` triples, normalize IAQ 0-500 down to 0-1: - -```javascript -import L from 'leaflet'; -import 'leaflet.heat'; - -async function loadAirQualityLayer(map) { - const r = await fetch('/api/air/heatmap?hours=24&metric=iaq_index'); - const { data } = await r.json(); - - const heat = data.map(p => [p.lat, p.lon, Math.min(p.value / 300, 1.0)]); - - return L.heatLayer(heat, { - radius: 25, blur: 15, maxZoom: 17, - gradient: { - 0.0: 'green', // 0-50 Excellent - 0.2: 'yellow', // 51-100 Good - 0.4: 'orange', // 101-150 Moderate - 0.6: 'red', // 151-200 Unhealthy - 0.8: 'purple', // 201-300 Very unhealthy - 1.0: 'maroon', // 301+ Hazardous - }, - }); -} -``` - -Legend markup: - -```html -
-

Air Quality Index

-
0-50 Excellent
-
51-100 Good
-
101-150 Moderate
-
151-200 Unhealthy
-
201-300 Very unhealthy
-
301+ Hazardous
-
-``` - -## BOM - -Option A — BME680, basic VOC/IAQ: - -``` -BME680 breakout $15 Adafruit/SparkFun -MCP2221A USB-I2C $7 Adafruit -Qwiic/STEMMA cables $3 SparkFun - ----- - $25 -``` - -Option B — SEN55, full air quality: - -``` -SEN55 sensor $45 DigiKey/Mouser -breakout PCB $5 JLCPCB/OSHPark -CP2102 USB-UART $3 Amazon - ----- - $53 -``` - -Both, if we want everything (VOC index from BME688 cross-checked against SEN55's separate VOC/NOx readings): ~$80. - -## things still to confirm - -- exact sensor model on hand (BME680? 688? something else?) — needs a look -- Bee USB-C port host mode — plug something in and see if it enumerates -- can we `pip install` on the Bee, or is the Yocto rootfs read-only? need a wheel-bundle plan if so -- Bosch BSEC licensing for the real IAQ calculation — non-commercial vs. commercial terms differ -- 1Hz is the default polling rate; bump up or down once we see what the data looks like - -## rollout order - -Sensor on the bench first (laptop or Pi) to confirm it actually reads. Then onto the Bee — service deploys, Redis key check, fusion in bee-collector, upload spot-check. AdaMaps side (table + endpoints) can land in parallel; curl-test before pointing the Bee at it. Frontend last, drive a route, eyeball the heatmap. diff --git a/docs/BEE-CAMERA.md b/docs/BEE-CAMERA.md deleted file mode 100644 index a7aba96..0000000 --- a/docs/BEE-CAMERA.md +++ /dev/null @@ -1,467 +0,0 @@ -# Bee camera system - -Notes from poking at the Hivemapper Bee dashcam. The camera path doesn't use V4L2 at all — frames live in `/tmp/recording/pics/` and `/data/recording/cached_observations/`, written by a DepthAI pipeline running on the on-die Myriad X VPU. All access goes through XLink, not `/dev/video*`. - -## hardware - -SoC is Intel Keem Bay (RVC2 — Robotics Vision Core 2): 4× Cortex-A53 @ 1.5GHz, integrated Myriad X VPU with 16 SHAVE cores and a Neural Compute Engine, 10nm. 4GB LPDDR4 on the board, ~3.5GB usable. CMA reserves ~1.34GB for VPU/camera DMA, swap is 2GB. - -``` -MemTotal: 3,584,000 kB -SwapTotal: 2,097,148 kB -CmaTotal: 1,408,000 kB # VPU/camera DMA reservation -``` - -The camera is a Luxonis OAK-1-compatible module — Sony IMX378 (or equivalent 12MP), 4056×3040 native, downscaled to 2028×1024 by the pipeline. ~30 FPS. MIPI CSI-2 into the VPU's ISP; the ARM side never touches it directly. - -Bus layout: - -``` -┌─────────────────────────────────────────────────────┐ -│ Intel Keem Bay SoC │ -│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ -│ │ A53 ×4 │ │ Myriad X │ │ Neural Compute │ │ -│ └────┬─────┘ └────┬─────┘ └────────┬───────┘ │ -│ └────────┬────┴─────────────────┘ │ -│ Internal AXI/NoC │ -│ ┌─────┬─────┼──────┬───────────┐ │ -│ PCIe USB SDIO MIPI CSI │ │ -└─────┼─────┼─────┼──────┼───────────┘ │ - │ │ │ │ - Marvell Telit eMMC IMX378 - 88W8997 LE910C4 - WiFi/BT LTE -``` - -## kernel + V4L2 - -Custom Yocto build with Intel VPU drivers. `kmb_cam` / `kmb_imx412` may be loaded for the sensor itself, plus the usual `videodev` + `v4l2_fwnode`. None of it is reachable via `/dev/video*` during normal operation — the VPU owns the camera hardware exclusively and the host talks to it over XLink (PCIe transport on Keem Bay; USB on desktop OAK devices). - -VPU is controlled via sysfs at `/sys/class/vpu/`. Firmware is loaded by writing the filename to the `fwname` attribute. Two firmwares are present: - -- `luxonis_vpu.bin` — DepthAI firmware (what we want) -- `vpu_nvr_b0.bin` — Intel HDDL firmware (conflicts, see below) - -If you want frames without going through the existing stack you have three options: use the depthai_gate / odc-api stack as-is, stop depthai_gate and run your own DepthAI pipeline, or reverse-engineer XLink and roll custom VPU firmware. The first is by far the easiest. - -## depthai_gate - -Python+Flask, listens on localhost:11492, ~158 threads, ~200MB RSS. Lives at `/opt/depthai_gate/` (estimated — not confirmed on-device yet). Service unit looks like: - -```ini -[Unit] -Description=DepthAI Camera Gate -After=network.target - -[Service] -Type=simple -User=root -ExecStart=/opt/depthai_gate/run.py -Restart=always -``` - -What it does: loads `luxonis_vpu.bin` into the VPU, opens the XLink connection, configures the DepthAI pipeline (ColorCamera → ImageManip → XLinkOut, plus optional NeuralNetwork node), captures frames, and drops them into `/tmp/recording/pics/`. Pipeline config probably lives at `/opt/depthai_gate/pipeline.json` or `/data/camera_config.json`, possibly hardcoded. - -XLink status values seen in logs: - -``` -xlink_device_status=2 # connected, healthy - =1 # connecting / error - =0 # disconnected -``` - -### the VPU conflict - -`deviceservice.service` (Intel HDDL / OpenVINO) ships enabled and races depthai_gate for the VPU: - -1. HDDL starts at boot, loads `vpu_nvr_b0.bin` -2. depthai_gate starts, overwrites with `luxonis_vpu.bin` -3. HDDL retries XLink every 2s forever, can't talk to the now-Luxonis firmware -4. If depthai_gate restarts after HDDL is already running, HDDL grabs the VPU first and the camera goes dead -5. `secure-wdtclient` watchdog crash-loops on the dead VPU → memory pressure → OOM - -Fix: - -```bash -systemctl disable --now deviceservice -systemctl mask deviceservice # survives OTA -``` - -## map-ai - -Reads frames from depthai_gate, runs detection on the VPU's NCE, blurs faces and plates, writes results to SQLite (`/data/recording/odc-api.db`) and blurred frames to disk. - -```ini -[Unit] -Description=Map AI Processing -After=depthai_gate.service - -[Service] -Type=simple -User=root -ExecStart=/opt/map-ai/run.py -Restart=always -``` - -Pipeline: - -``` -frame from depthai_gate - │ - ▼ - ML inference (on VPU NCE) - - road sign classifier - - face detector - - license plate detector - │ - ▼ - privacy blur - - Gaussian on faces/plates - - cv2.imwrite blurred copy - │ - ├──► Redis (status keys, not detections) - └──► SQLite (observations, landmarks, frames) -``` - -Models: - -| Model | Path | Purpose | -|-------|------|---------| -| Road signs | `/opt/object-detection/model.blob` or `/data/models/` | classification | -| Privacy | `/opt/odc-api/python/` or `/data/models/` | face/plate detection | -| PVC | `/data/recording/models/pvc.onnx` | unknown — 227 bytes, probably an index file | - -Privacy model hash gets baked into FrameKm metadata for verification. - -Redis only carries readiness flags: - -``` -GET MAP_AI_READY → "True" -GET EXTERNAL_MODEL_CLASSIFIER_READY → "True" -``` - -Detections go to SQLite, not Redis ZSETs. - -## frame storage - -| Path | FS | Purpose | Persists? | -|------|----|---------|-----------| -| `/tmp/recording/pics/` | tmpfs | live frames | no | -| `/tmp/recording/preview/` | tmpfs | preview mode | no | -| `/data/recording/cached_observations/` | ext4 | landmark observations | yes | -| `/data/recording/framekm/` | ext4 | upload bundles | yes | -| `/tmp/rgb/` | tmpfs | frame list files | no | - -Frames are JPEG at 2028×1024, ~85% quality, ~150-200KB each. - -Naming: - -``` -# live -/tmp/recording/pics/{system_time_ms}_{frame_id}_{sequence}.jpg -e.g. 1709920000123_0001_0042.jpg - -# cached observations -/data/recording/cached_observations/{timestamp}_{subsecond}_{frame_number}.jpg -e.g. 1746377552_043000_2945056.jpg -``` - -`folder_purger` keeps disk under control — when `/tmp/recording/pics/` crosses 400MB, oldest frames go: - -``` -folder-purger /tmp/recording/pic 400000000 /mnt/data/gps 2000000000 ... -``` - -SQLite schemas (simplified): - -```sql -CREATE TABLE frames ( - system_time INTEGER PRIMARY KEY, - image_name TEXT -); - -CREATE TABLE observations ( - id INTEGER PRIMARY KEY, - landmark_id INTEGER, - image_name TEXT, - x1 REAL, y1 REAL, x2 REAL, y2 REAL, - ts INTEGER -); -``` - -DB lives at `/data/recording/odc-api.db` (also seen as `data-logger.v2.0.0.db`). - -## video-processor + FrameKm - -`video-processor` isn't documented in the firmware I've looked at. Based on naming it bundles frames+metadata into FrameKm tarballs, handles any H.264/H.265 encoding for preview, and orders frames for upload. It doesn't produce raw frames — it only packages already-blurred ones — so it's not useful for camera-access work. - -A FrameKm is ~1km of driving data: - -``` -framekm-2024-03-08-12-34-56-abc123.tar -├── manifest.json -├── frame_0001.jpg -├── frame_0002.jpg -├── ... -├── gnss_auth_buffer.bin -└── gnss_auth_signature.bin -``` - -Manifest: - -```json -{ - "name": "framekm-2024-03-08-12-34-56-abc123", - "numFrames": 150, - "deviceId": "fvhL2I-iCT", - "firmwareVersion": "0.0.1", - "privacyModelHash": "sha256:abc123...", - "gnssAuthBuffer": "base64...", - "gnssAuthSignature": "base64...", - "gnssAuthPublicKey": "base64...", - "createdAt": 1709920000000 -} -``` - -## odc-api - -REST API at `http://192.168.0.10:5000/api/1/`. Binds to the AP interface (`wlp1s0f0`) only — not reachable from home LAN without going through the Bee's AP. - -Preview endpoints: - -| Endpoint | Method | Notes | -|----------|--------|-------| -| `/preview/start` | GET | 120s timeout, then auto-stop | -| `/preview/stop` | GET | | -| `/preview/status` | GET | | -| `/preview/metadata` | GET | latest frame metadata | - -Preview works by writing a new config and bouncing camera-bridge: - -```typescript -export const startPreview = async () => { - await execSync('mkdir /tmp/recording/preview'); - writeFileSync(IMAGER_CONFIG_PATH, JSON.stringify(getPreviewConfig())); - await execSync(CMD.STOP_CAMERA); - await sleep(1000); - await execSync(CMD.START_CAMERA); -}; -``` - -The 120s auto-stop is there to protect 4K recording quality. - -Landmark endpoints (where the cached observation images come out): - -| Endpoint | Method | Notes | -|----------|--------|-------| -| `/landmarks/images/:id` | GET | image paths for a landmark | -| `/landmarks/:id/chips` | GET | list of chip endpoints | -| `/landmarks/:id/chips/:chip_id` | GET | cropped observation JPEG | -| `/landmarks/boundingBox/:id` | GET | bbox coords | -| `/landmarks/upload` | PUT | upload landmark image | - -Image retrieval flow: - -``` -GET /landmarks/images/123 - → ["/data/recording/cached_observations/1746377552_043000_2945056.jpg"] - -GET /landmarks/123/chips/456 - → cropped JPEG of the bbox region -``` - -Camera bridge config: `/opt/camera-bridge/config.json`. Control commands from `bee.ts`: - -```typescript -export const CMD = { - RESTART_CAMERA: 'systemctl restart camera-bridge', - START_CAMERA: 'systemctl start camera-bridge', - STOP_CAMERA: 'systemctl stop camera-bridge', - START_PREVIEW: 'systemctl start camera-preview', - STOP_PREVIEW: 'systemctl stop camera-preview', -}; -``` - -There is no `/camera/frame` endpoint. To get a frame you either use preview mode and read `/tmp/recording/preview/`, walk the landmarks API for chips, or SSH in and read `/tmp/recording/pics/` directly. - -## full data flow - -``` -IMX378 → MIPI CSI-2 → VPU ISP → DepthAI pipeline - │ - depthai_gate (:11492) - writes /tmp/recording/pics/ - │ - ┌───────────────────┴───────────────────┐ - ▼ ▼ - /tmp/recording/pics/ map-ai (VPU NCE) - (raw, purged >400MB) - sign classifier - - privacy blur - │ - ┌────────────────────────────────────┤ - ▼ ▼ - /data/recording/cached_observations/ odc-api.db (SQLite) - (blurred, persistent) landmarks/observations/frames - │ │ - └────────────────┬───────────────────┘ - ▼ - odc-api (:5000) - /preview/*, /landmarks/* - │ - ▼ - hivemapper-data-logger - → FrameKm bundles in /data/recording/framekm/ - │ - ▼ - odc-api → mitmdump :8888 → Cloudflare Workers → HERE OLP -``` - -Single detection trace: - -``` -1. IMX378 → MIPI → VPU ISP → depthai_gate -2. /tmp/recording/pics/1709920000123_0001_0042.jpg -3. map-ai picks it up, runs road sign classifier on the NCE -4. hit (e.g. speed limit 35), faces/plates blurred -5. observations row written + image saved to cached_observations/ -6. landmarks row created/updated (class_label, lat, lon, confidence) -7. /landmarks/last/5 surfaces it; /landmarks/{id}/chips/{chip_id} returns the crop -``` - -## replacement notes - -### getting a frame without odc-api - -Direct file read (simplest, race-prone, no metadata): - -```bash -ssh -p 2222 root@localhost # via an SSH tunnel -ls -lt /tmp/recording/pics/ | head -10 - -# poor man's stream -while true; do - cp $(ls -t /tmp/recording/pics/*.jpg | head -1) /tmp/current.jpg - sleep 0.033 -done -``` - -Redis pub/sub would be cleaner — `r.pubsub().subscribe('frame_ready')` — but I haven't confirmed depthai_gate publishes anything like that. Worth a `redis-cli MONITOR` while recording is live. - -### getting a frame without depthai_gate - -Don't, unless you really mean it. You'd be replacing the whole VPU pipeline: - -```python -import depthai as dai - -pipeline = dai.Pipeline() -cam = pipeline.create(dai.node.ColorCamera) -cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_4_K) -cam.setIspScale(1, 2) # → 2028×1024 - -xout = pipeline.create(dai.node.XLinkOut) -xout.setStreamName("video") -cam.video.link(xout.input) - -with dai.Device(pipeline) as device: - q = device.getOutputQueue("video") - while True: - cv2.imwrite("/tmp/frame.jpg", q.get().getCvFrame()) -``` - -Breaks everything Hivemapper depends on — map-ai, landmarks, FrameKm. Only useful if the goal is full liberation, not augmentation. - -### fastest frame grabs - -With the stack running: - -```bash -# direct -ssh -p 2222 root@localhost 'ls -t /tmp/recording/pics/*.jpg | head -1 | xargs cat' > frame.jpg - -# via API (needs preview mode) -curl http://192.168.0.10:5000/api/1/preview/start -sleep 2 -ssh -p 2222 root@localhost 'ls -t /tmp/recording/preview/*.jpg | head -1 | xargs cat' > frame.jpg -curl http://192.168.0.10:5000/api/1/preview/stop -``` - -### proposed odc-api extension - -Two new routes — single frame + MJPEG stream: - -```typescript -router.get('/frame', async (req, res) => { - const frames = readdirSync('/tmp/recording/pics') - .filter(f => f.endsWith('.jpg')) - .sort().reverse(); - if (!frames.length) return res.status(404).send('No frames available'); - res.sendFile(join('/tmp/recording/pics', frames[0])); -}); - -router.get('/stream', async (req, res) => { - res.writeHead(200, { - 'Content-Type': 'multipart/x-mixed-replace; boundary=frame', - 'Cache-Control': 'no-cache', - }); - const interval = setInterval(() => { - const frames = readdirSync('/tmp/recording/pics') - .filter(f => f.endsWith('.jpg')).sort().reverse(); - if (frames.length) { - const data = readFileSync(join('/tmp/recording/pics', frames[0])); - res.write('--frame\r\n'); - res.write('Content-Type: image/jpeg\r\n'); - res.write(`Content-Length: ${data.length}\r\n\r\n`); - res.write(data); - res.write('\r\n'); - } - }, 33); // ~30 FPS - req.on('close', () => clearInterval(interval)); -}); -``` - -Long-term replacement shape — leave depthai_gate alone, add a separate watcher service that inotify-tails `/tmp/recording/pics/` and serves frames over HTTP. Doesn't fight the VPU, doesn't break the upload chain. - -## things still to confirm - -- exact depthai_gate pipeline config (find files under `/opt/`) -- does depthai_gate publish frame events to Redis at all? (`redis-cli MONITOR`) -- camera-bridge vs depthai_gate — what's the actual dependency? (systemd deps, strace) -- preview config format — read `getPreviewConfig()` -- ML model exact locations (`find /opt /data -name '*.blob' -o -name '*.onnx'`) -- frame timestamp accuracy vs GNSS time - -## appendix — file paths - -``` -/tmp/recording/pics/ live frames -/tmp/recording/preview/ preview frames -/data/recording/cached_observations/ landmark images -/data/recording/framekm/ upload bundles -/data/recording/odc-api.db SQLite DB -/opt/camera-bridge/config.json camera config -/opt/depthai_gate/ DepthAI service (estimated) -/opt/odc-api/ Node API service -/sys/class/vpu/ VPU sysfs -``` - -## appendix — boot order - -``` -multi-user.target - ├── redis.service t+2s - ├── depthai_gate.service t+8s loads luxonis_vpu.bin - ├── map-ai.service t+12s needs depthai_gate - ├── hivemapper-data-logger.service t+15s - └── odc-api.service t+18s -``` - -## appendix — ports - -``` -22 sshd TCP AP-only (socket) -5000 odc-api HTTP AP iface -6379 redis TCP localhost -8888 mitmdump HTTP localhost -11492 depthai_gate HTTP/Flask localhost -``` diff --git a/docs/adamaps-index-preview.html b/docs/adamaps-index-preview.html deleted file mode 100644 index a8ff7b1..0000000 --- a/docs/adamaps-index-preview.html +++ /dev/null @@ -1,545 +0,0 @@ - - - - - - AdaMaps — Verified Sign Map - - - - - - - -
- - - - - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7d34a5..b391d4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,18 @@ [versions] -agp = "8.13.2" +agp = "8.3.0" kotlin = "2.0.0" ksp = "2.0.0-1.0.21" coreKtx = "1.12.0" lifecycleRuntimeKtx = "2.7.0" -activityCompose = "1.13.0" +activityCompose = "1.8.2" composeBom = "2024.02.00" navigationCompose = "2.7.7" okhttp = "4.12.0" gson = "2.10.1" coroutines = "1.8.0" -osmdroid = "6.1.20" +osmdroid = "6.1.18" datastore = "1.0.0" -coil = "2.7.0" +coil = "2.6.0" room = "2.6.1" work = "2.9.0"