diff --git a/.forgejo/workflows/gitleaks.yml b/.forgejo/workflows/gitleaks.yml
new file mode 100644
index 0000000..48fa421
--- /dev/null
+++ b/.forgejo/workflows/gitleaks.yml
@@ -0,0 +1,30 @@
+# 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
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ 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
new file mode 100644
index 0000000..8a923e5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,122 @@
+# 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 01f4169..20dbc60 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,12 +9,28 @@ 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 = 14
- versionName = "1.7.9"
+ versionCode = 15
+ versionName = "1.8.0"
vectorDrawables {
useSupportLibrary = true
@@ -24,6 +40,7 @@ android {
buildTypes {
release {
isMinifyEnabled = false
+ signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -70,17 +87,9 @@ 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 e23ca05..9896951 100644
--- a/app/src/main/java/com/adamaps/varroa/Navigation.kt
+++ b/app/src/main/java/com/adamaps/varroa/Navigation.kt
@@ -5,6 +5,7 @@ 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
@@ -12,6 +13,7 @@ object Routes {
const val DASHBOARD = "dashboard"
const val SETTINGS = "settings"
const val DEVICE_STATUS = "device_status"
+ const val BEE_SETTINGS = "bee_settings"
}
@Composable
@@ -19,7 +21,10 @@ fun VarroaNavGraph() {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = Routes.DASHBOARD) {
composable(Routes.DASHBOARD) {
- DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) })
+ DashboardScreen(
+ onNavigateToSettings = { nav.navigate(Routes.SETTINGS) },
+ onNavigateToBeeSettings = { nav.navigate(Routes.BEE_SETTINGS) }
+ )
}
composable(Routes.SETTINGS) {
SettingsScreen(
@@ -30,5 +35,8 @@ 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 8fc65f9..39a8410 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 = "***REMOVED***"
+ private var apiKey: String = ""
) {
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.take(8)
+ val oldKeyPrefix = apiKey.length
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.take(8)}...")
+ Log.d(TAG, "Sending POST request with key: ${apiKey.length}...")
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 9c96d88..09a370a 100644
--- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt
+++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt
@@ -6,15 +6,22 @@ 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
@@ -23,6 +30,7 @@ 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(
@@ -178,6 +186,35 @@ 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.
@@ -245,7 +282,6 @@ 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) ||
@@ -304,29 +340,60 @@ class BeeApiClient(
}
}
- 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()
+ 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)
+ }
- 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)
- }
+ // 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}")
}
- } catch (e: Exception) {
- ApiResult.Error(e.message ?: "Unknown error")
+ 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
}
}
@@ -343,30 +410,14 @@ class BeeApiClient(
}
}
- 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()
+ suspend fun setSshEnabled(enabled: Boolean): ApiResult {
+ val json = gson.toJson(mapOf("enable" to enabled))
+ return postRaw("/api/1/ssh/toggle", json)
+ }
- 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")
- }
+ // SSH-based device ID retrieval (stub - not implemented)
+ suspend fun getDeviceIdViaSsh(): ApiResult {
+ return ApiResult.Error("SSH device ID lookup not implemented")
}
// ── Storage & GNSS Status ─────────────────────────────────────────────────
@@ -405,32 +456,95 @@ class BeeApiClient(
}
}
- 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()
+ // ── Upload Mode ───────────────────────────────────────────────────────────
- client.newCall(request).execute().use { resp ->
- val body = resp.body?.string() ?: ""
- if (resp.isSuccessful) {
- ApiResult.Success(body)
+ 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")
} else {
- ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
+ ApiResult.Success(trimmed)
}
+ } catch (e: Exception) {
+ ApiResult.Success(r.data.trim().trim('"'))
}
- } catch (e: Exception) {
- ApiResult.Error(e.message ?: "Unknown error")
+ is ApiResult.Error -> r
}
}
+ 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 ────────────────────────────────────────────────────────────
/**
@@ -517,7 +631,6 @@ class BeeApiClient(
}
is ApiResult.Error -> {
Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}")
- // Continue trying other endpoints
}
}
}
@@ -529,10 +642,7 @@ 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 = okhttp3.RequestBody.create(
- "application/json".toMediaType(),
- jsonBody
- )
+ val requestBody = jsonBody.toRequestBody("application/json".toMediaType())
// Try both DELETE and POST methods
for (method in listOf("DELETE", "POST")) {
@@ -540,7 +650,9 @@ class BeeApiClient(
val requestBuilder = Request.Builder()
.url("$apiUrl$endpoint")
- .addHeader("Authorization", "Bearer $apiToken")
+ if (apiToken.isNotBlank()) {
+ requestBuilder.addHeader("Authorization", "Bearer $apiToken")
+ }
val request = if (method == "DELETE") {
requestBuilder.delete(requestBody).build()
@@ -565,4 +677,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 7106429..18faff5 100644
--- a/app/src/main/java/com/adamaps/varroa/data/Models.kt
+++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt
@@ -47,21 +47,6 @@ 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(
@@ -108,15 +93,7 @@ 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("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
+ @SerializedName("ip") val ip: String? = null
)
data class StorageStatus(
@@ -127,7 +104,94 @@ 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,
@@ -138,11 +202,16 @@ data class GnssStatus(
@SerializedName("last_fix_age_sec") val lastFixAgeSec: 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
+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
)
// ── 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 51d0642..6d9adcc 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 = "***REMOVED***",
+ val adamapsApiKey: String = "",
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] ?: "***REMOVED***",
+ adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "",
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 9708f7d..af427a0 100644
--- a/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt
+++ b/app/src/main/java/com/adamaps/varroa/service/BeeCollectorService.kt
@@ -202,36 +202,17 @@ 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, "API returned unknown device ID, trying SSH fallback...")
- fetchDeviceIdViaSsh()
+ Log.w(TAG, "Device ID unknown from API, using cached or unknown")
}
}
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) {
+
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 0a5fb53..0b91b28 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,7 +39,8 @@ import org.osmdroid.views.overlay.Marker
@Composable
fun DashboardScreen(
vm: DashboardViewModel = viewModel(),
- onNavigateToSettings: () -> Unit
+ onNavigateToSettings: () -> Unit,
+ onNavigateToBeeSettings: () -> Unit = {}
) {
val deviceInfo by vm.deviceInfo.collectAsState()
val gnss by vm.gnss.collectAsState()
@@ -101,8 +102,11 @@ 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 = "Settings", tint = Amber)
+ Icon(Icons.Default.Settings, contentDescription = "App Settings", tint = Amber)
}
}
)
@@ -150,7 +154,7 @@ fun DashboardScreen(
gnss?.let { GpsMapCard(it) }
// Device status
- deviceInfo?.let { DeviceStatusCard(it) }
+ deviceInfo?.let { DeviceStatusCard(it, onNavigateToBeeSettings) }
}
}
}
@@ -582,14 +586,32 @@ private fun OsmMapView(gnss: GnssData) {
}
@Composable
-private fun DeviceStatusCard(info: BeeDeviceInfo) {
+private fun DeviceStatusCard(info: BeeDeviceInfo, onNavigateToBeeSettings: () -> Unit = {}) {
Card(
colors = CardDefaults.cardColors(containerColor = Surface),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(14.dp)) {
- SectionHeader("DEVICE STATUS")
+ 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)
+ )
+ }
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
new file mode 100644
index 0000000..1a4533e
--- /dev/null
+++ b/app/src/main/java/com/adamaps/varroa/ui/settings/BeeSettingsScreen.kt
@@ -0,0 +1,972 @@
+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 0edc86b..82da999 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,7 +10,6 @@ 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
@@ -23,7 +22,6 @@ 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.*
@@ -38,7 +36,6 @@ 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) {
@@ -59,12 +56,6 @@ fun DeviceStatusScreen(
vm.clearUploadModeResult()
}
}
- LaunchedEffect(sshResult) {
- sshResult?.let {
- snackbarHostState.showSnackbar(it)
- vm.clearSshToggleResult()
- }
- }
Scaffold(
containerColor = Background,
@@ -128,9 +119,6 @@ fun DeviceStatusScreen(
// GPS/GNSS Status
GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock)
- // SSH Status
- SshStatusCard(state.sshStatus, vm)
-
// Upload Mode
UploadModeCard(state.deviceInfo?.uploadMode, vm)
@@ -376,7 +364,7 @@ private fun StorageStatusCard(storage: StorageStatus) {
@Composable
private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) {
StatusCard("GPS / GNSS") {
- val hasGps = gnss?.hasLock == true || hasLock == true
+ val hasGps = gnss?.fix == true || hasLock == true
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
@@ -402,21 +390,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.lat != null && gnss.lon != null) {
+ if (gnss.latDeg != null && gnss.lonDeg != null) {
Spacer(Modifier.height(4.dp))
- StatusRow("Position", "%.5f, %.5f".format(gnss.lat, gnss.lon))
+ StatusRow("Position", "%.5f, %.5f".format(gnss.latDeg, gnss.lonDeg))
}
- gnss.alt?.let {
+ gnss.altM?.let {
Spacer(Modifier.height(4.dp))
StatusRow("Altitude", "%.1f m".format(it))
}
- gnss.speedKmh?.let {
+ gnss.speedMs?.let {
Spacer(Modifier.height(4.dp))
- StatusRow("Speed", "%.1f km/h".format(it))
+ StatusRow("Speed", "%.1f km/h".format(it * 3.6))
}
- gnss.lastFixAgeSec?.let {
+ gnss.unixMs?.let {
Spacer(Modifier.height(4.dp))
- StatusRow("Last Fix", "${it}s ago", if (it > 30) Color.Yellow else OnSurface)
+ val ageMs = System.currentTimeMillis() - it; val ageSec = (ageMs / 1000).toInt(); StatusRow("Last Fix", "${ageSec}s ago", if (ageSec > 30) Color.Yellow else OnSurface)
}
}
}
@@ -472,55 +460,6 @@ 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 068780d..39d2cf5 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,11 +11,8 @@ 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
@@ -27,11 +24,14 @@ 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,14 +49,6 @@ 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) }
@@ -67,6 +59,21 @@ 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) {
@@ -75,24 +82,6 @@ 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,
@@ -117,7 +106,7 @@ fun SettingsScreen(
actions = {
IconButton(onClick = {
vm.save(
- currentSettings.copy(
+ VarroaSettings(
beeApiUrl = beeApiUrl.trim(),
adamapsApiUrl = adamapsApiUrl.trim(),
adamapsApiKey = adamapsApiKey.trim(),
@@ -142,15 +131,6 @@ 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,
@@ -197,24 +177,6 @@ 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",
@@ -224,6 +186,120 @@ 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",
@@ -285,265 +361,6 @@ 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(
@@ -571,7 +388,8 @@ private fun SettingsField(
value: String,
onValueChange: (String) -> Unit,
hint: String = "",
- numeric: Boolean = false
+ numeric: Boolean = false,
+ keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text
) {
OutlinedTextField(
value = value,
@@ -579,8 +397,7 @@ 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 = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number)
- else KeyboardOptions.Default,
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
@@ -763,3 +580,5 @@ 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
new file mode 100644
index 0000000..0a33a53
--- /dev/null
+++ b/app/src/main/java/com/adamaps/varroa/viewmodel/BeeSettingsViewModel.kt
@@ -0,0 +1,223 @@
+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 ae6e7fa..09ab8be 100644
--- a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt
+++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt
@@ -10,7 +10,6 @@ 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
@@ -26,7 +25,6 @@ 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
)
@@ -49,9 +47,6 @@ 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)
@@ -59,10 +54,6 @@ 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
@@ -71,14 +62,12 @@ 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
@@ -89,9 +78,8 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
wifiConfig = wifi,
storageStatus = storage,
gnssStatus = gnss,
- sshStatus = ssh,
plugins = plugins,
- error = if (!isConnected) "Cannot connect to AdaCam device" else null
+ error = if (!isConnected) "Cannot connect to Bee device" else null
)
Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}")
@@ -141,24 +129,6 @@ 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
}
@@ -166,8 +136,4 @@ 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 5d1ef53..8e16e29 100644
--- a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt
@@ -64,6 +64,7 @@ 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 0f2972b..f777e22 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
new file mode 100644
index 0000000..29254bf
Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
deleted file mode 100644
index ea5eaeb..0000000
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..8f88f75
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png 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
new file mode 100644
index 0000000..8f88f75
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..2a38a94
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png 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
new file mode 100644
index 0000000..2a38a94
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..8b51cb4
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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
new file mode 100644
index 0000000..8b51cb4
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..7f69984
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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
new file mode 100644
index 0000000..7f69984
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..7c51289
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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
new file mode 100644
index 0000000..7c51289
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
index 81d34f7..e7f24f7 100644
--- a/app/src/main/res/xml/network_security_config.xml
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -1,7 +1,12 @@
-
- 192.168.0.10
-
+
+
+
+
+ 192.168.0.10
+
diff --git a/blackbox/air-aggregator.service b/blackbox/air-aggregator.service
new file mode 100644
index 0000000..17be223
--- /dev/null
+++ b/blackbox/air-aggregator.service
@@ -0,0 +1,21 @@
+[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
new file mode 100644
index 0000000..784c17b
--- /dev/null
+++ b/blackbox/air_aggregator.py
@@ -0,0 +1,235 @@
+#!/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
new file mode 100644
index 0000000..2f4be27
--- /dev/null
+++ b/docs/AIR-QUALITY-INTEGRATION.md
@@ -0,0 +1,369 @@
+# 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
new file mode 100644
index 0000000..a7aba96
--- /dev/null
+++ b/docs/BEE-CAMERA.md
@@ -0,0 +1,467 @@
+# 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
new file mode 100644
index 0000000..a8ff7b1
--- /dev/null
+++ b/docs/adamaps-index-preview.html
@@ -0,0 +1,545 @@
+
+
+
+
+
+ AdaMaps — Verified Sign Map
+
+
+
+
+
+
+