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
-
-
-
-
-
-
-