Compare commits

..

1 commit

Author SHA1 Message Date
Sulkta
69a52f58fd feat: adacam migration — update IP, add pairing, bearer auth, wifi/ssh config, remove tunnel and cmd
- Changed default API URL from 192.168.0.10 to 192.168.0.10
- Added bearer token authentication for POST endpoints
- Added /pair endpoint support for device pairing
- Added GET/POST /api/1/ssh/status and /api/1/ssh/toggle
- Added WiFi config via /api/1/wifi/connect
- Removed JSch dependency (no more SSH from app)
- Removed /api/1/cmd endpoint references (CVE)
- Added PairResponse, SshStatus, WifiStatus models
- Added deviceSerial, apiToken, isPaired to settings
- Added deriveApiToken() function for token derivation
- Updated SettingsViewModel with pairing/wifi/ssh methods
- Updated SettingsScreen with pairing UI, WiFi config, SSH toggle
- Updated DeviceStatusScreen with SSH toggle card
2026-03-14 11:50:00 -07:00
37 changed files with 587 additions and 4150 deletions

View file

@ -1,30 +0,0 @@
# Scan the repository for committed secrets with gitleaks on every push and
# pull request. The job fails (and can be made a required check) if a secret
# is detected, so credentials never land in history.
name: gitleaks
on:
push:
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history — gitleaks needs depth to scan a commit range.
fetch-depth: 0
- name: install gitleaks
run: |
curl -sSL -o gl.tar.gz \
https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz
tar xzf gl.tar.gz gitleaks
chmod +x gitleaks
./gitleaks version
- name: scan
run: |
./gitleaks detect --source . --no-banner --redact --verbose

661
LICENSE
View file

@ -1,661 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.

122
README.md
View file

@ -1,122 +0,0 @@
# Varroa
Varroa is an Android companion app for the **Hivemapper Bee** dashcam. It
pulls detection landmarks off the camera's on-device API, queues them in a
local database, and forwards them to an **AdaMaps** ingest endpoint once the
phone has a validated internet connection. The Bee exposes its API only over
its own WiFi 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
airquality sensor on a Linux singleboard computer (e.g. a Raspberry Pi),
fuses the readings with GPS from the Bee, and posts them to the same AdaMaps
stream.
## Features
- **Storeandforward collection**`BeeCollectorService` polls the Bee for
detection landmarks and writes them to a local Room database. Nothing is lost
if the phone is offline.
- **Validatedinternet 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 WiFi/storage/GNSS
status, and toggle device options from the inapp 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 deviceAP 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
HTTPSonly.
## 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 inapp)
- **Bee URL** — the camera's API base URL. Defaults to
`http://192.168.0.10:5000`, the factory accesspoint 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 bakedin defaults.
- **Cardano wallet** — optional. When set, the address is attached to detection
uploads for reward routing.
## blackbox (airquality 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` — reverseengineering notes on the Hivemapper Bee's
internal camera pipeline (Intel Keem Bay / Myriad X VPU, DepthAI, the
ondevice REST API). Useful background for anyone building against the camera.
- `docs/AIR-QUALITY-INTEGRATION.md` — design notes for adding an airquality
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).

View file

@ -9,28 +9,12 @@ android {
namespace = "com.adamaps.varroa" namespace = "com.adamaps.varroa"
compileSdk = 34 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 { defaultConfig {
applicationId = "com.adamaps.varroa" applicationId = "com.adamaps.varroa"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 15 versionCode = 14
versionName = "1.8.0" versionName = "1.7.9"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@ -40,7 +24,6 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@ -87,9 +70,17 @@ dependencies {
implementation(libs.osmdroid.android) implementation(libs.osmdroid.android)
implementation(libs.datastore.preferences) implementation(libs.datastore.preferences)
implementation(libs.coil.compose) implementation(libs.coil.compose)
// Room (local database)
implementation(libs.room.runtime) implementation(libs.room.runtime)
implementation(libs.room.ktx) implementation(libs.room.ktx)
ksp(libs.room.compiler) ksp(libs.room.compiler)
// WorkManager (background uploads)
implementation(libs.work.runtime.ktx) 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) debugImplementation(libs.androidx.ui.tooling)
} }

View file

@ -5,7 +5,6 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.adamaps.varroa.ui.dashboard.DashboardScreen 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.DeviceStatusScreen
import com.adamaps.varroa.ui.settings.SettingsScreen import com.adamaps.varroa.ui.settings.SettingsScreen
@ -13,7 +12,6 @@ object Routes {
const val DASHBOARD = "dashboard" const val DASHBOARD = "dashboard"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val DEVICE_STATUS = "device_status" const val DEVICE_STATUS = "device_status"
const val BEE_SETTINGS = "bee_settings"
} }
@Composable @Composable
@ -21,10 +19,7 @@ fun VarroaNavGraph() {
val nav = rememberNavController() val nav = rememberNavController()
NavHost(navController = nav, startDestination = Routes.DASHBOARD) { NavHost(navController = nav, startDestination = Routes.DASHBOARD) {
composable(Routes.DASHBOARD) { composable(Routes.DASHBOARD) {
DashboardScreen( DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) })
onNavigateToSettings = { nav.navigate(Routes.SETTINGS) },
onNavigateToBeeSettings = { nav.navigate(Routes.BEE_SETTINGS) }
)
} }
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsScreen( SettingsScreen(
@ -35,8 +30,5 @@ fun VarroaNavGraph() {
composable(Routes.DEVICE_STATUS) { composable(Routes.DEVICE_STATUS) {
DeviceStatusScreen(onBack = { nav.popBackStack() }) DeviceStatusScreen(onBack = { nav.popBackStack() })
} }
composable(Routes.BEE_SETTINGS) {
BeeSettingsScreen(onBack = { nav.popBackStack() })
}
} }
} }

View file

@ -38,7 +38,7 @@ private object AdaMapsDns : Dns {
class AdaMapsApiClient( class AdaMapsApiClient(
private var apiUrl: String = "https://api.adamaps.org", private var apiUrl: String = "https://api.adamaps.org",
private var apiKey: String = "" private var apiKey: String = "***REMOVED***"
) { ) {
companion object { companion object {
private const val TAG = "VarroaAdaAPI" private const val TAG = "VarroaAdaAPI"
@ -57,7 +57,7 @@ class AdaMapsApiClient(
fun updateConfig(url: String, key: String) { fun updateConfig(url: String, key: String) {
val oldUrl = apiUrl val oldUrl = apiUrl
val oldKeyPrefix = apiKey.length val oldKeyPrefix = apiKey.take(8)
apiUrl = url.trimEnd('/') apiUrl = url.trimEnd('/')
apiKey = key apiKey = key
Log.d(TAG, "AdaMaps config updated - URL: $oldUrl -> $apiUrl, Key: ${oldKeyPrefix}... -> ${key.take(8)}...") Log.d(TAG, "AdaMaps config updated - URL: $oldUrl -> $apiUrl, Key: ${oldKeyPrefix}... -> ${key.take(8)}...")
@ -80,7 +80,7 @@ class AdaMapsApiClient(
.post(body) .post(body)
.build() .build()
Log.d(TAG, "Sending POST request with key: ${apiKey.length}...") Log.d(TAG, "Sending POST request with key: ${apiKey.take(8)}...")
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
val respBody = resp.body?.string() ?: "" val respBody = resp.body?.string() ?: ""
Log.d(TAG, "HTTP ${resp.code} ${resp.message} - response length: ${respBody.length}") Log.d(TAG, "HTTP ${resp.code} ${resp.message} - response length: ${respBody.length}")

View file

@ -6,22 +6,15 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.util.Log import android.util.Log
import com.adamaps.varroa.data.ApiResult import com.adamaps.varroa.data.ApiResult
import com.adamaps.varroa.data.BeeConfig
import com.adamaps.varroa.data.BeeDetection import com.adamaps.varroa.data.BeeDetection
import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin 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.GnssData
import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.PairResponse import com.adamaps.varroa.data.PairResponse
import com.adamaps.varroa.data.PluginState
import com.adamaps.varroa.data.SshStatus import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus 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.WifiConfig
import com.adamaps.varroa.data.WifiNetwork
import com.adamaps.varroa.data.WifiStatus import com.adamaps.varroa.data.WifiStatus
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
@ -30,7 +23,6 @@ import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class BeeApiClient( class BeeApiClient(
@ -186,35 +178,6 @@ class BeeApiClient(
} }
} }
// ── POST helper ───────────────────────────────────────────────────────────
private suspend fun postRaw(path: String, json: String, authenticated: Boolean = true): ApiResult<String> = 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. * Check if AdaCam is reachable.
* Updates internal connection state. * Updates internal connection state.
@ -282,6 +245,7 @@ class BeeApiClient(
} }
is ApiResult.Error -> { is ApiResult.Error -> {
Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})") Log.e(TAG, "getLandmarks() failed: ${r.message} (code: ${r.code})")
// Connection failed, mark as offline
if (r.message.contains("timeout", ignoreCase = true) || if (r.message.contains("timeout", ignoreCase = true) ||
r.message.contains("connect", ignoreCase = true) || r.message.contains("connect", ignoreCase = true) ||
r.message.contains("refused", ignoreCase = true) || r.message.contains("refused", ignoreCase = true) ||
@ -340,60 +304,29 @@ class BeeApiClient(
} }
} }
suspend fun setWifiConfig(ssid: String, password: String): ApiResult<String> { suspend fun setWifiConfig(ssid: String, password: String): ApiResult<String> = withContext(Dispatchers.IO) {
val json = gson.toJson(mapOf("ssid" to ssid, "password" to password)) try {
return postRaw("/api/1/wifi/connect", json) val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password))
} val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
val request = Request.Builder()
.url("$apiUrl/api/1/wifi/connect")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody)
.build()
// WiFi Client methods (legacy API) client.newCall(request).execute().use { resp ->
suspend fun getWifiClientStatus(): ApiResult<com.adamaps.varroa.data.WifiStatus> = withContext(Dispatchers.IO) { val body = resp.body?.string() ?: ""
when (val r = getRaw("/api/1/wifiClient/status")) { if (resp.isSuccessful) {
is ApiResult.Success -> try { ApiResult.Success(body)
ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WifiStatus::class.java)) } else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error("Parse error: ${e.message}") ApiResult.Error(e.message ?: "Unknown error")
}
is ApiResult.Error -> r
}
}
suspend fun getWifiSettings(): ApiResult<WifiClientSettings> = 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<String> {
val json = gson.toJson(settings)
return postRaw("/api/1/wifiClient/settings", json)
}
suspend fun setWifiEnabled(enabled: Boolean): ApiResult<String> {
val json = """{"enabled": $enabled}"""
return postRaw("/api/1/wifiClient/enable", json)
}
suspend fun scanWifi(): ApiResult<List<WifiNetwork>> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifiClient/scan")) {
is ApiResult.Success -> try {
val type = object : TypeToken<List<WifiNetwork>>() {}.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<String> = withContext(Dispatchers.IO) {
when (val r = getRaw("/api/1/wifiClient/reset")) {
is ApiResult.Success -> r
is ApiResult.Error -> r
} }
} }
@ -410,14 +343,30 @@ class BeeApiClient(
} }
} }
suspend fun setSshEnabled(enabled: Boolean): ApiResult<String> { suspend fun setSshEnabled(enabled: Boolean): ApiResult<String> = withContext(Dispatchers.IO) {
val json = gson.toJson(mapOf("enable" to enabled)) try {
return postRaw("/api/1/ssh/toggle", json) val jsonBody = gson.toJson(mapOf("enable" to enabled))
} val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
val request = Request.Builder()
.url("$apiUrl/api/1/ssh/toggle")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody)
.build()
// SSH-based device ID retrieval (stub - not implemented) client.newCall(request).execute().use { resp ->
suspend fun getDeviceIdViaSsh(): ApiResult<String> { val body = resp.body?.string() ?: ""
return ApiResult.Error("SSH device ID lookup not implemented") if (resp.isSuccessful) {
ApiResult.Success(body)
} else {
ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error")
}
} }
// ── Storage & GNSS Status ───────────────────────────────────────────────── // ── Storage & GNSS Status ─────────────────────────────────────────────────
@ -456,93 +405,30 @@ class BeeApiClient(
} }
} }
// ── Upload Mode ─────────────────────────────────────────────────────────── suspend fun setUploadMode(mode: String): ApiResult<String> = withContext(Dispatchers.IO) {
try {
val jsonBody = gson.toJson(mapOf("mode" to mode))
val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
val request = Request.Builder()
.url("$apiUrl/api/1/config/uploadMode")
.addHeader("Authorization", "Bearer $apiToken")
.post(requestBody)
.build()
suspend fun getUploadMode(): ApiResult<String> = withContext(Dispatchers.IO) { client.newCall(request).execute().use { resp ->
when (val r = getRaw("/api/1/config/uploadMode")) { val body = resp.body?.string() ?: ""
is ApiResult.Success -> try { if (resp.isSuccessful) {
val trimmed = r.data.trim().trim('"') ApiResult.Success(body)
if (trimmed.startsWith("{")) {
val obj = gson.fromJson(r.data, UploadModeResponse::class.java)
ApiResult.Success(obj.currentMode() ?: "UNKNOWN")
} else { } else {
ApiResult.Success(trimmed) ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Success(r.data.trim().trim('"')) ApiResult.Error(e.message ?: "Unknown error")
} }
is ApiResult.Error -> r
}
}
suspend fun setUploadMode(mode: String): ApiResult<String> {
val json = """{"mode": "$mode"}"""
return postRaw("/api/1/config/uploadMode", json)
}
// ── Config ────────────────────────────────────────────────────────────────
suspend fun getConfig(): ApiResult<BeeConfig> = 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<CacheStatus> = 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<FrameKmTotal> = 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<PluginState> = withContext(Dispatchers.IO) {
val knownPlugins = listOf("beekeeper", "depth-ai", "privacy-zones", "map-ai")
knownPlugins.map { name -> getPluginState(name) }
} }
// ── Camera API ──────────────────────────────────────────────────────────── // ── Camera API ────────────────────────────────────────────────────────────
@ -631,6 +517,7 @@ class BeeApiClient(
} }
is ApiResult.Error -> { is ApiResult.Error -> {
Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}") Log.w(TAG, "Cleanup failed via $endpoint: ${result.message}")
// Continue trying other endpoints
} }
} }
} }
@ -642,7 +529,10 @@ class BeeApiClient(
private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) { private suspend fun tryCleanupEndpoint(endpoint: String, landmarkIds: List<Long>): ApiResult<String> = withContext(Dispatchers.IO) {
try { try {
val jsonBody = gson.toJson(mapOf("ids" to landmarkIds)) val jsonBody = gson.toJson(mapOf("ids" to landmarkIds))
val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) val requestBody = okhttp3.RequestBody.create(
"application/json".toMediaType(),
jsonBody
)
// Try both DELETE and POST methods // Try both DELETE and POST methods
for (method in listOf("DELETE", "POST")) { for (method in listOf("DELETE", "POST")) {
@ -650,9 +540,7 @@ class BeeApiClient(
val requestBuilder = Request.Builder() val requestBuilder = Request.Builder()
.url("$apiUrl$endpoint") .url("$apiUrl$endpoint")
if (apiToken.isNotBlank()) { .addHeader("Authorization", "Bearer $apiToken")
requestBuilder.addHeader("Authorization", "Bearer $apiToken")
}
val request = if (method == "DELETE") { val request = if (method == "DELETE") {
requestBuilder.delete(requestBody).build() requestBuilder.delete(requestBody).build()

View file

@ -47,6 +47,21 @@ data class BeeDeviceInfo(
@SerializedName("ssid") val ssid: String? = null @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 ──────────────────────────────────────────────────────────── // ── ADAMaps ingest ────────────────────────────────────────────────────────────
data class AdaMapsDetection( data class AdaMapsDetection(
@ -93,7 +108,15 @@ data class WifiConfig(
@SerializedName("ssid") val ssid: String? = null, @SerializedName("ssid") val ssid: String? = null,
@SerializedName("password") val password: String? = null, @SerializedName("password") val password: String? = null,
@SerializedName("connected") val connected: Boolean? = null, @SerializedName("connected") val connected: Boolean? = null,
@SerializedName("ip") val ip: String? = null @SerializedName("ip") val ip: String? = null,
@SerializedName("state") val state: String? = null
)
data class WifiStatus(
@SerializedName("ssid") val ssid: String? = null,
@SerializedName("ip") val ip: String? = null,
@SerializedName("state") val state: String? = null,
@SerializedName("connected") val connected: Boolean? = null
) )
data class StorageStatus( data class StorageStatus(
@ -104,94 +127,7 @@ data class StorageStatus(
@SerializedName("recording_hours_available") val recordingHoursAvailable: Double? = null @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( 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("has_lock") val hasLock: Boolean? = null,
@SerializedName("satellites") val satellites: Int? = null, @SerializedName("satellites") val satellites: Int? = null,
@SerializedName("hdop") val hdop: Double? = null, @SerializedName("hdop") val hdop: Double? = null,
@ -202,16 +138,11 @@ data class GnssStatus(
@SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = null @SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = null
) )
data class PluginState( data class BeePlugin(
val name: String, @SerializedName("name") val name: String? = null,
val enabled: Boolean?, @SerializedName("version") val version: String? = null,
val error: String? = null @SerializedName("enabled") val enabled: Boolean? = null,
) @SerializedName("running") val running: Boolean? = null
data class FrameKmTotal(
@SerializedName("total") val total: Long? = null,
@SerializedName("totalBytes") val totalBytes: Long? = null,
@SerializedName("count") val count: Int? = null
) )
// ── App state ───────────────────────────────────────────────────────────────── // ── App state ─────────────────────────────────────────────────────────────────

View file

@ -17,7 +17,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
data class VarroaSettings( data class VarroaSettings(
val beeApiUrl: String = "http://192.168.0.10:5000", val beeApiUrl: String = "http://192.168.0.10:5000",
val adamapsApiUrl: String = "https://api.adamaps.org", val adamapsApiUrl: String = "https://api.adamaps.org",
val adamapsApiKey: String = "", val adamapsApiKey: String = "***REMOVED***",
val pollIntervalSeconds: Int = 30, val pollIntervalSeconds: Int = 30,
val cameraEndpoint: String = "/api/1/camera/frame", val cameraEndpoint: String = "/api/1/camera/frame",
val cameraRefreshSeconds: Int = 30, val cameraRefreshSeconds: Int = 30,
@ -62,7 +62,7 @@ class SettingsDataStore(private val context: Context) {
VarroaSettings( VarroaSettings(
beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000", beeApiUrl = prefs[KEY_BEE_URL] ?: "http://192.168.0.10:5000",
adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org", adamapsApiUrl = prefs[KEY_ADAMAPS_URL] ?: "https://api.adamaps.org",
adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "", adamapsApiKey = prefs[KEY_ADAMAPS_KEY] ?: "***REMOVED***",
pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30, pollIntervalSeconds = prefs[KEY_POLL_INTERVAL] ?: 30,
cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame", cameraEndpoint = prefs[KEY_CAMERA_ENDPOINT] ?: "/api/1/camera/frame",
cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30, cameraRefreshSeconds = prefs[KEY_CAMERA_REFRESH] ?: 30,

View file

@ -202,17 +202,36 @@ class BeeCollectorService : LifecycleService() {
settingsStore.updateCachedDeviceId(deviceId) 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"})") 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 { } else {
Log.w(TAG, "Device ID unknown from API, using cached or unknown") Log.w(TAG, "API returned unknown device ID, trying SSH fallback...")
fetchDeviceIdViaSsh()
} }
} }
is ApiResult.Error -> { is ApiResult.Error -> {
Log.e(TAG, "Failed to get device ID via API: ${result.message}, code: ${result.code}") 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" _currentDeviceId.value = "unknown"
} }
} }
} }
private fun startPollLoop(intervalSeconds: Int) {
private fun startPollLoop(intervalSeconds: Int) {
pollJob?.cancel() pollJob?.cancel()
Log.d(TAG, "Previous poll job cancelled") Log.d(TAG, "Previous poll job cancelled")
pollJob = lifecycleScope.launch { pollJob = lifecycleScope.launch {

View file

@ -39,8 +39,7 @@ import org.osmdroid.views.overlay.Marker
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
vm: DashboardViewModel = viewModel(), vm: DashboardViewModel = viewModel(),
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit
onNavigateToBeeSettings: () -> Unit = {}
) { ) {
val deviceInfo by vm.deviceInfo.collectAsState() val deviceInfo by vm.deviceInfo.collectAsState()
val gnss by vm.gnss.collectAsState() val gnss by vm.gnss.collectAsState()
@ -102,11 +101,8 @@ fun DashboardScreen(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
IconButton(onClick = onNavigateToBeeSettings) {
Icon(Icons.Default.Router, contentDescription = "AdaCam Settings", tint = Amber)
}
IconButton(onClick = onNavigateToSettings) { IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = "App Settings", tint = Amber) Icon(Icons.Default.Settings, contentDescription = "Settings", tint = Amber)
} }
} }
) )
@ -154,7 +150,7 @@ fun DashboardScreen(
gnss?.let { GpsMapCard(it) } gnss?.let { GpsMapCard(it) }
// Device status // Device status
deviceInfo?.let { DeviceStatusCard(it, onNavigateToBeeSettings) } deviceInfo?.let { DeviceStatusCard(it) }
} }
} }
} }
@ -586,32 +582,14 @@ private fun OsmMapView(gnss: GnssData) {
} }
@Composable @Composable
private fun DeviceStatusCard(info: BeeDeviceInfo, onNavigateToBeeSettings: () -> Unit = {}) { private fun DeviceStatusCard(info: BeeDeviceInfo) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = Surface), colors = CardDefaults.cardColors(containerColor = Surface),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column(modifier = Modifier.padding(14.dp)) { Column(modifier = Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SectionHeader("DEVICE STATUS") 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)) Spacer(Modifier.height(8.dp))
val rows: List<Pair<String, String>> = listOf( val rows: List<Pair<String, String>> = listOf(
"Firmware" to (info.firmwareVersion ?: ""), "Firmware" to (info.firmwareVersion ?: ""),

View file

@ -1,972 +0,0 @@
package com.adamaps.varroa.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.*
import com.adamaps.varroa.ui.theme.*
import com.adamaps.varroa.viewmodel.BeeSettingsLoadState
import com.adamaps.varroa.viewmodel.BeeSettingsViewModel
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BeeSettingsScreen(
vm: BeeSettingsViewModel = viewModel(),
onBack: () -> Unit
) {
val state by vm.state.collectAsState()
val message by vm.message.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(message) {
message?.let {
snackbarHostState.showSnackbar(it)
vm.clearMessage()
}
}
Scaffold(
containerColor = Background,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
"BEE DEVICE",
color = Amber,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
letterSpacing = 3.sp
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Amber)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface),
actions = {
IconButton(onClick = { vm.loadAll() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = Amber)
}
}
)
}
) { padding ->
when (val loadState = state.loadState) {
is BeeSettingsLoadState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Amber)
Spacer(Modifier.height(16.dp))
Text(
"Fetching Bee device info…",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
is BeeSettingsLoadState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.CloudOff,
contentDescription = null,
tint = Error,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.height(12.dp))
Text(
"Bee Unreachable",
color = Error,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Spacer(Modifier.height(8.dp))
Text(
loadState.message,
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(Modifier.height(20.dp))
Button(
onClick = { vm.loadAll() },
colors = ButtonDefaults.buttonColors(containerColor = AmberDark)
) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("RETRY", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
}
}
}
else -> {
// Success or Idle — show content
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Device Info
state.deviceInfo?.let { DeviceInfoSection(it) }
// WiFi Client
WifiSection(
status = state.wifiStatus,
settings = state.wifiSettings,
networks = state.wifiNetworks,
scanState = state.wifiScanState,
saving = state.wifiSaving,
onSave = { ssid, pass, enabled -> vm.saveWifiSettings(ssid, pass, enabled) },
onToggleEnabled = { vm.setWifiEnabled(it) },
onScan = { vm.scanWifi() },
onReset = { vm.resetWifi() }
)
// Storage
StorageSection(
cacheStatus = state.cacheStatus,
frameKmTotal = state.frameKmTotal
)
// GNSS
GnssSection(gnss = state.gnssStatus)
// Upload Mode
UploadModeSection(
currentMode = state.uploadMode,
saving = state.uploadModeSaving,
onSetMode = { vm.setUploadMode(it) }
)
// Plugins
if (state.plugins.isNotEmpty()) {
PluginSection(
plugins = state.plugins,
config = state.config
)
}
Spacer(Modifier.height(24.dp))
}
}
}
}
}
// ── Device Info ───────────────────────────────────────────────────────────────
@Composable
private fun DeviceInfoSection(info: BeeDeviceInfo) {
BeeCard(title = "DEVICE INFO", icon = Icons.Default.Info) {
val rows = listOfNotNull(
info.deviceId?.let { "Device ID" to it },
info.serial?.let { "Serial" to it },
info.firmwareVersion?.let { "Firmware" to it },
info.apiVersion?.let { "API Version" to it },
info.model?.let { "Model" to it },
info.imei?.let { "IMEI" to it },
info.ssid?.let { "Connected SSID" to it },
info.uptime?.let { "Uptime" to formatUptime(it) },
"GPS Lock" to if (info.hasGnssLock == true) "YES" else "NO",
"Internet" to if (info.internetIsHealthy == true) "HEALTHY" else "OFFLINE"
)
rows.forEach { (k, v) ->
InfoRow(k, v, valueColor = when {
k == "GPS Lock" && v == "YES" -> Success
k == "GPS Lock" && v == "NO" -> Error
k == "Internet" && v == "HEALTHY" -> Success
k == "Internet" && v == "OFFLINE" -> Error
else -> OnSurface
})
}
}
}
// ── WiFi ──────────────────────────────────────────────────────────────────────
@Composable
private fun WifiSection(
status: WifiStatus?,
settings: WifiClientSettings?,
networks: List<WifiNetwork>,
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<PluginState>,
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()
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -22,6 +23,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.BeeDeviceInfo import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig import com.adamaps.varroa.data.WifiConfig
import com.adamaps.varroa.ui.theme.* import com.adamaps.varroa.ui.theme.*
@ -36,6 +38,7 @@ fun DeviceStatusScreen(
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
val wifiResult by vm.wifiSaveResult.collectAsState() val wifiResult by vm.wifiSaveResult.collectAsState()
val uploadResult by vm.uploadModeResult.collectAsState() val uploadResult by vm.uploadModeResult.collectAsState()
val sshResult by vm.sshToggleResult.collectAsState()
// Refresh on first load // Refresh on first load
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -56,6 +59,12 @@ fun DeviceStatusScreen(
vm.clearUploadModeResult() vm.clearUploadModeResult()
} }
} }
LaunchedEffect(sshResult) {
sshResult?.let {
snackbarHostState.showSnackbar(it)
vm.clearSshToggleResult()
}
}
Scaffold( Scaffold(
containerColor = Background, containerColor = Background,
@ -119,6 +128,9 @@ fun DeviceStatusScreen(
// GPS/GNSS Status // GPS/GNSS Status
GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock) GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock)
// SSH Status
SshStatusCard(state.sshStatus, vm)
// Upload Mode // Upload Mode
UploadModeCard(state.deviceInfo?.uploadMode, vm) UploadModeCard(state.deviceInfo?.uploadMode, vm)
@ -364,7 +376,7 @@ private fun StorageStatusCard(storage: StorageStatus) {
@Composable @Composable
private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) { private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) {
StatusCard("GPS / GNSS") { StatusCard("GPS / GNSS") {
val hasGps = gnss?.fix == true || hasLock == true val hasGps = gnss?.hasLock == true || hasLock == true
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
@ -390,21 +402,21 @@ private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
StatusRow("HDOP", "%.2f".format(it), if (it > 5) Color.Yellow else OnSurface) StatusRow("HDOP", "%.2f".format(it), if (it > 5) Color.Yellow else OnSurface)
} }
if (gnss.latDeg != null && gnss.lonDeg != null) { if (gnss.lat != null && gnss.lon != null) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
StatusRow("Position", "%.5f, %.5f".format(gnss.latDeg, gnss.lonDeg)) StatusRow("Position", "%.5f, %.5f".format(gnss.lat, gnss.lon))
} }
gnss.altM?.let { gnss.alt?.let {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
StatusRow("Altitude", "%.1f m".format(it)) StatusRow("Altitude", "%.1f m".format(it))
} }
gnss.speedMs?.let { gnss.speedKmh?.let {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
StatusRow("Speed", "%.1f km/h".format(it * 3.6)) StatusRow("Speed", "%.1f km/h".format(it))
} }
gnss.unixMs?.let { gnss.lastFixAgeSec?.let {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
val ageMs = System.currentTimeMillis() - it; val ageSec = (ageMs / 1000).toInt(); StatusRow("Last Fix", "${ageSec}s ago", if (ageSec > 30) Color.Yellow else OnSurface) StatusRow("Last Fix", "${it}s ago", if (it > 30) Color.Yellow else OnSurface)
} }
} }
} }
@ -460,6 +472,55 @@ private fun UploadModeCard(currentMode: String?, vm: DeviceStatusViewModel) {
} }
} }
@Composable
private fun SshStatusCard(ssh: SshStatus?, vm: DeviceStatusViewModel) {
val isActive = ssh?.active ?: false
StatusCard("SSH ACCESS") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Enable SSH",
color = OnSurface,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
Text(
"SSH access over home WiFi",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
Switch(
checked = isActive,
onCheckedChange = { vm.toggleSsh(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
checkedTrackColor = Amber.copy(alpha = 0.5f),
uncheckedThumbColor = Color.Gray,
uncheckedTrackColor = SurfaceVariant
)
)
}
if (isActive) {
Spacer(Modifier.height(8.dp))
Text(
"SSH enabled. Connect via:\nssh root@<device-ip>",
color = Color.Green,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
}
}
}
@Composable @Composable
private fun PluginsCard(plugins: List<BeePlugin>) { private fun PluginsCard(plugins: List<BeePlugin>) {
StatusCard("PLUGINS") { StatusCard("PLUGINS") {

View file

@ -11,8 +11,11 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Clear 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.PhoneAndroid
import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -24,14 +27,11 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 androidx.lifecycle.viewmodel.compose.viewModel
import com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.data.VarroaSettings
import com.adamaps.varroa.ui.theme.* import com.adamaps.varroa.ui.theme.*
@ -49,6 +49,14 @@ fun SettingsScreen(
) { ) {
val currentSettings by vm.settings.collectAsState() val currentSettings by vm.settings.collectAsState()
val saved by vm.saved.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 // Local edit state — initialized from current settings
var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) } var beeApiUrl by remember(currentSettings) { mutableStateOf(currentSettings.beeApiUrl) }
@ -59,21 +67,6 @@ fun SettingsScreen(
var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) } var cameraEndpoint by remember(currentSettings) { mutableStateOf(currentSettings.cameraEndpoint) }
var walletAddress by remember(currentSettings) { mutableStateOf(currentSettings.walletAddress) } 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 // Show snackbar on save
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(saved) { LaunchedEffect(saved) {
@ -82,6 +75,24 @@ fun SettingsScreen(
vm.clearSaved() 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( Scaffold(
containerColor = Background, containerColor = Background,
@ -106,7 +117,7 @@ fun SettingsScreen(
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
vm.save( vm.save(
VarroaSettings( currentSettings.copy(
beeApiUrl = beeApiUrl.trim(), beeApiUrl = beeApiUrl.trim(),
adamapsApiUrl = adamapsApiUrl.trim(), adamapsApiUrl = adamapsApiUrl.trim(),
adamapsApiKey = adamapsApiKey.trim(), adamapsApiKey = adamapsApiKey.trim(),
@ -131,6 +142,15 @@ fun SettingsScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(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 // Device Status navigation card
Card( Card(
onClick = onNavigateToDeviceStatus, onClick = onNavigateToDeviceStatus,
@ -177,6 +197,24 @@ fun SettingsScreen(
} }
} }
// WiFi Config section (only show if paired)
if (isPaired) {
WifiConfigSection(
wifiStatus = wifiStatus,
onConnect = { ssid, password -> vm.connectWifi(ssid, password) },
onRefresh = { vm.refreshWifiStatus() }
)
}
// SSH section (only show if paired)
if (isPaired) {
SshSection(
sshStatus = sshStatus,
onToggle = { enabled -> vm.toggleSsh(enabled) },
onRefresh = { vm.refreshSshStatus() }
)
}
SettingsSection("ADACAM DEVICE") { SettingsSection("ADACAM DEVICE") {
SettingsField( SettingsField(
label = "AdaCam API URL", label = "AdaCam API URL",
@ -186,120 +224,6 @@ fun SettingsScreen(
) )
} }
// ── Pairing ───────────────────────────────────────────────────────
SettingsSection("PAIRING") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.Link,
contentDescription = null,
tint = if (isPaired) Amber else Color.Gray,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(8.dp))
Text(
if (isPaired) "Paired — serial: $deviceSerial"
else "Not paired",
color = if (isPaired) Amber else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
Spacer(Modifier.height(8.dp))
pairingResult?.let {
Text(it, color = if (it.startsWith("Error")) Color.Red else Amber,
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Spacer(Modifier.height(4.dp))
}
Button(
onClick = { vm.pairDevice() },
enabled = !pairingInProgress,
colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background),
modifier = Modifier.fillMaxWidth()
) {
Text(
if (pairingInProgress) "Pairing…" else if (isPaired) "Re-pair AdaCam" else "Pair with AdaCam",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
// ── Home WiFi config ──────────────────────────────────────────────
SettingsSection("HOME WIFI") {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Wifi, contentDescription = null,
tint = if (wifiStatus?.connected == true) Amber else Color.Gray,
modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text(
wifiStatus?.let {
if (it.connected == true) "Connected: ${it.ssid} (${it.ip})"
else "Disconnected"
} ?: "Unknown",
color = if (wifiStatus?.connected == true) Amber else Color.Gray,
fontFamily = FontFamily.Monospace, fontSize = 12.sp
)
}
Spacer(Modifier.height(8.dp))
SettingsField(label = "SSID", value = homeWifiSsid,
onValueChange = { homeWifiSsid = it }, hint = "Your home network")
SettingsField(label = "Password", value = homeWifiPassword,
onValueChange = { homeWifiPassword = it }, hint = "WiFi password",
keyboardType = KeyboardType.Password)
wifiConnectResult?.let {
Text(it, color = if (it.startsWith("Error")) Color.Red else Amber,
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Spacer(Modifier.height(4.dp))
}
Button(
onClick = { vm.connectWifi(homeWifiSsid, homeWifiPassword) },
enabled = isPaired && homeWifiSsid.isNotBlank() && homeWifiPassword.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background),
modifier = Modifier.fillMaxWidth()
) {
Text("Connect AdaCam to WiFi", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
if (!isPaired) {
Text("Pair device first to configure WiFi",
color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
}
}
// ── SSH access ────────────────────────────────────────────────────
SettingsSection("SSH ACCESS") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Terminal, contentDescription = null,
tint = if (sshStatus?.active == true) Amber else Color.Gray,
modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Column {
Text("SSH over home WiFi",
color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text(if (sshStatus?.active == true) "Active — ssh root@<device-ip>" 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") { SettingsSection("ADAMAPS") {
SettingsField( SettingsField(
label = "ADAMaps API URL", label = "ADAMaps API URL",
@ -361,6 +285,265 @@ fun SettingsScreen(
} }
} }
@Composable
private fun PairingSection(
isPaired: Boolean,
deviceSerial: String,
pairingInProgress: Boolean,
onPair: () -> Unit,
onClearPairing: () -> Unit
) {
SettingsSection("DEVICE PAIRING") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (isPaired) Icons.Default.Link else Icons.Default.LinkOff,
contentDescription = null,
tint = if (isPaired) Color.Green else Color.Gray,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Column {
Text(
if (isPaired) "Paired" else "Not Paired",
color = if (isPaired) Color.Green else Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
if (isPaired && deviceSerial.isNotBlank()) {
Text(
"Serial: $deviceSerial",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
}
Spacer(Modifier.height(12.dp))
if (!isPaired) {
Text(
"Connect your phone to the AdaCam WiFi network (adacam-XXXXXX), then tap Pair.",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onPair,
enabled = !pairingInProgress,
colors = ButtonDefaults.buttonColors(containerColor = Amber),
modifier = Modifier.fillMaxWidth()
) {
if (pairingInProgress) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = Background,
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
}
Text(
if (pairingInProgress) "Pairing..." else "Pair with AdaCam",
color = Background
)
}
} else {
OutlinedButton(
onClick = onClearPairing,
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.Red),
modifier = Modifier.fillMaxWidth()
) {
Text("Clear Pairing", fontSize = 12.sp)
}
}
}
}
@Composable
private fun WifiConfigSection(
wifiStatus: com.adamaps.varroa.data.WifiStatus?,
onConnect: (String, String) -> Unit,
onRefresh: () -> Unit
) {
var ssid by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
SettingsSection("HOME WIFI NETWORK") {
// Current status
if (wifiStatus != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Wifi,
contentDescription = null,
tint = if (wifiStatus.connected == true) Color.Green else Color.Yellow,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(8.dp))
Column {
Text(
if (wifiStatus.connected == true) "Connected" else "Disconnected",
color = if (wifiStatus.connected == true) Color.Green else Color.Yellow,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
if (wifiStatus.ssid != null && wifiStatus.connected == true) {
Text(
wifiStatus.ssid,
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
if (wifiStatus.ip != null && wifiStatus.connected == true) {
Text(
"IP: ${wifiStatus.ip}",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
}
}
IconButton(onClick = onRefresh) {
Icon(
Icons.Default.ChevronRight,
contentDescription = "Refresh",
tint = Amber
)
}
}
Spacer(Modifier.height(12.dp))
Divider(color = SurfaceVariant)
Spacer(Modifier.height(12.dp))
}
Text(
"Configure home WiFi for internet access",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
label = { Text("SSID", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber,
unfocusedLabelColor = Color.Gray,
cursorColor = Amber,
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber,
unfocusedBorderColor = SurfaceVariant,
focusedLabelColor = Amber,
unfocusedLabelColor = Color.Gray,
cursorColor = Amber,
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface
)
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { onConnect(ssid, password) },
enabled = ssid.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Amber),
modifier = Modifier.fillMaxWidth()
) {
Text("Connect", color = Background)
}
}
}
@Composable
private fun SshSection(
sshStatus: com.adamaps.varroa.data.SshStatus?,
onToggle: (Boolean) -> Unit,
onRefresh: () -> Unit
) {
val isActive = sshStatus?.active ?: false
SettingsSection("SSH ACCESS") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Enable SSH",
color = OnSurface,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
Text(
"SSH over home WiFi network",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp
)
}
Switch(
checked = isActive,
onCheckedChange = { onToggle(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = Amber,
checkedTrackColor = Amber.copy(alpha = 0.5f),
uncheckedThumbColor = Color.Gray,
uncheckedTrackColor = SurfaceVariant
)
)
}
if (isActive) {
Spacer(Modifier.height(8.dp))
Text(
"SSH enabled. Connect via:\nssh root@<device-ip>",
color = Color.Green,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 14.sp
)
}
}
}
@Composable @Composable
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Card( Card(
@ -388,8 +571,7 @@ private fun SettingsField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
hint: String = "", hint: String = "",
numeric: Boolean = false, numeric: Boolean = false
keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text
) { ) {
OutlinedTextField( OutlinedTextField(
value = value, value = value,
@ -397,7 +579,8 @@ private fun SettingsField(
label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) }, placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType), keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number)
else KeyboardOptions.Default,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Amber, focusedBorderColor = Amber,
@ -580,5 +763,3 @@ private fun WalletLinkingSection(
) )
} }
} }

View file

@ -1,223 +0,0 @@
package com.adamaps.varroa.viewmodel
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.adamaps.varroa.api.BeeApiClient
import com.adamaps.varroa.data.*
import com.adamaps.varroa.network.NetworkStateMonitor
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
private const val TAG = "BeeSettingsVM"
sealed class BeeSettingsLoadState {
object Idle : BeeSettingsLoadState()
object Loading : BeeSettingsLoadState()
data class Error(val message: String) : BeeSettingsLoadState()
object Success : BeeSettingsLoadState()
}
data class BeeSettingsState(
val deviceInfo: BeeDeviceInfo? = null,
val wifiStatus: WifiStatus? = null,
val wifiSettings: WifiClientSettings? = null,
val wifiNetworks: List<WifiNetwork> = emptyList(),
val cacheStatus: CacheStatus? = null,
val frameKmTotal: FrameKmTotal? = null,
val gnssStatus: GnssStatus? = null,
val uploadMode: String? = null,
val config: BeeConfig? = null,
val plugins: List<PluginState> = 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<BeeSettingsState> = _state.asStateFlow()
// Toast/snackbar messages
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _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
}
}

View file

@ -10,6 +10,7 @@ import com.adamaps.varroa.data.BeeDeviceInfo
import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.BeePlugin
import com.adamaps.varroa.data.GnssStatus import com.adamaps.varroa.data.GnssStatus
import com.adamaps.varroa.data.SettingsDataStore import com.adamaps.varroa.data.SettingsDataStore
import com.adamaps.varroa.data.SshStatus
import com.adamaps.varroa.data.StorageStatus import com.adamaps.varroa.data.StorageStatus
import com.adamaps.varroa.data.WifiConfig import com.adamaps.varroa.data.WifiConfig
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -25,6 +26,7 @@ data class DeviceStatusState(
val wifiConfig: WifiConfig? = null, val wifiConfig: WifiConfig? = null,
val storageStatus: StorageStatus? = null, val storageStatus: StorageStatus? = null,
val gnssStatus: GnssStatus? = null, val gnssStatus: GnssStatus? = null,
val sshStatus: SshStatus? = null,
val plugins: List<BeePlugin> = emptyList(), val plugins: List<BeePlugin> = emptyList(),
val error: String? = null val error: String? = null
) )
@ -47,6 +49,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
private val _uploadModeResult = MutableStateFlow<String?>(null) private val _uploadModeResult = MutableStateFlow<String?>(null)
val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow() val uploadModeResult: StateFlow<String?> = _uploadModeResult.asStateFlow()
private val _sshToggleResult = MutableStateFlow<String?>(null)
val sshToggleResult: StateFlow<String?> = _sshToggleResult.asStateFlow()
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
@ -54,6 +59,10 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
try { try {
val settings = store.settings.first() val settings = store.settings.first()
val client = BeeApiClient(settings.beeApiUrl) val client = BeeApiClient(settings.beeApiUrl)
// Set auth token if paired
if (settings.apiToken.isNotBlank()) {
client.apiToken = settings.apiToken
}
beeClient = client beeClient = client
// Fetch all status in parallel // Fetch all status in parallel
@ -62,12 +71,14 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
val storageResult = client.getStorageStatus() val storageResult = client.getStorageStatus()
val gnssResult = client.getGnssStatus() val gnssResult = client.getGnssStatus()
val pluginsResult = client.getPlugins() val pluginsResult = client.getPlugins()
val sshResult = client.getSshStatus()
val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data
val wifi = (wifiResult as? ApiResult.Success)?.data val wifi = (wifiResult as? ApiResult.Success)?.data
val storage = (storageResult as? ApiResult.Success)?.data val storage = (storageResult as? ApiResult.Success)?.data
val gnss = (gnssResult as? ApiResult.Success)?.data val gnss = (gnssResult as? ApiResult.Success)?.data
val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList() val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList()
val ssh = (sshResult as? ApiResult.Success)?.data
val isConnected = deviceInfo != null val isConnected = deviceInfo != null
@ -78,8 +89,9 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
wifiConfig = wifi, wifiConfig = wifi,
storageStatus = storage, storageStatus = storage,
gnssStatus = gnss, gnssStatus = gnss,
sshStatus = ssh,
plugins = plugins, plugins = plugins,
error = if (!isConnected) "Cannot connect to Bee device" else null error = if (!isConnected) "Cannot connect to AdaCam device" else null
) )
Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}") Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}")
@ -129,6 +141,24 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
fun toggleSsh(enabled: Boolean) {
viewModelScope.launch {
val client = beeClient ?: return@launch
_sshToggleResult.value = null
when (val result = client.setSshEnabled(enabled)) {
is ApiResult.Success -> {
_sshToggleResult.value = if (enabled) "SSH enabled" else "SSH disabled"
_state.value = _state.value.copy(sshStatus = SshStatus(active = enabled))
}
is ApiResult.Error -> {
_sshToggleResult.value = "Failed: ${result.message}"
refresh()
}
}
}
}
fun clearWifiResult() { fun clearWifiResult() {
_wifiSaveResult.value = null _wifiSaveResult.value = null
} }
@ -136,4 +166,8 @@ class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) {
fun clearUploadModeResult() { fun clearUploadModeResult() {
_uploadModeResult.value = null _uploadModeResult.value = null
} }
fun clearSshToggleResult() {
_sshToggleResult.value = null
}
} }

View file

@ -64,7 +64,6 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val _wifiConnectResult = MutableStateFlow<String?>(null) private val _wifiConnectResult = MutableStateFlow<String?>(null)
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow() val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
init { init {
// Initialize BeeApiClient with stored settings and token // Initialize BeeApiClient with stored settings and token
viewModelScope.launch { viewModelScope.launch {

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#000000" /> <solid android:color="#0A0A0A" />
</shape> </shape>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F59E0B"
android:pathData="M54,20 C35.2,20 20,35.2 20,54 C20,72.8 35.2,88 54,88 C72.8,88 88,72.8 88,54 C88,35.2 72.8,20 54,20 Z M54,30 C67.3,30 78,40.7 78,54 C78,67.3 67.3,78 54,78 C40.7,78 30,67.3 30,54 C30,40.7 40.7,30 54,30 Z" />
<path
android:fillColor="#F59E0B"
android:pathData="M54,40 L58,50 L68,50 L60,57 L63,68 L54,61 L45,68 L48,57 L40,50 L50,50 Z" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View file

@ -1,12 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config>
<!-- HTTPS strict everywhere by default. -->
<base-config cleartextTrafficPermitted="false" />
<!-- Bee AP runs HTTP on the device-AP subnet — there's no real
alternative without breaking the Bee protocol. Scope the
cleartext exception to just that one host. -->
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">192.168.0.10</domain> <domain includeSubdomains="true">192.168.0.10</domain>
</domain-config> </domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config> </network-security-config>

View file

@ -1,21 +0,0 @@
[Unit]
Description=Blackbox Air Quality Aggregator
After=network.target
[Service]
Type=simple
User=pi
Restart=always
RestartSec=10
ExecStart=/usr/bin/python3 /home/pi/air-aggregator.py
Environment=BEE_URL=http://192.168.197.1:5000
Environment=ADAMAPS_URL=https://api.adamaps.org
Environment=ADAMAPS_KEY=
Environment=DEVICE_ID=blackbox-pi
Environment=PMS_PORT=/dev/ttyS0
Environment=SEND_INTERVAL=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View file

@ -1,235 +0,0 @@
#!/usr/bin/env python3
"""
air-aggregator.py Blackbox Air Quality Service
Reads BME680 (I2C) + PMS5003 (UART), fuses with GPS from Bee API,
ships readings to AdaMaps ingest endpoint.
Hardware:
BME680 I2C (SDA=GPIO2, SCL=GPIO3, addr=0x77)
PMS5003 UART (/dev/serial0 or /dev/ttyS0, 9600 baud)
Dependencies:
pip install bme680 requests smbus2
PMS5003 is read manually (no dep needed)
Config via env vars or edit constants below.
"""
import os, time, json, struct, logging, threading
import serial
import smbus2
import bme680
import requests
from datetime import datetime, timezone
# ── Config ────────────────────────────────────────────────────────────────────
BEE_URL = os.environ.get("BEE_URL", "http://192.168.197.1:5000")
ADAMAPS_URL = os.environ.get("ADAMAPS_URL", "https://api.adamaps.org")
ADAMAPS_KEY = os.environ.get("ADAMAPS_KEY", "<your-adamaps-ingest-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()

View file

@ -1,369 +0,0 @@
# Air quality sensor on the Bee
Can the Bee carry an air quality sensor alongside the existing Hivemapper pipeline and feed readings into AdaMaps? Short answer: yes, with the USB-C data port and a USB-to-I2C bridge. The polling overhead is negligible — the real work is on the AdaMaps side (new ingest endpoint, PostGIS table, heatmap overlay).
## what's free on the Bee
Hardware is Keem Bay (RVC2), 4× A53 @ 1.5GHz, Myriad X VPU, 3.5GB usable RAM (1.34GB of that is CMA-reserved for VPU DMA), leaving ~2.2GB for userspace.
Current service load (pre Phase-1 cleanup):
| Service | CPU | RAM | Notes |
|---------|-----|-----|-------|
| map-ai | ~32% | ~1.1GB | VPU inference |
| odc-api | ~48% | ~139MB | Phase 2 replacement target |
| depthai_gate | ~5% | ~200MB | camera |
| redis | <1% | ~50MB | |
| **total** | ~85% | ~1.5GB | |
After Phase 1 (odc-api shrink): ~50-60% CPU free (2-2.4 cores idle) and 700MB-1GB RAM free. More than enough for a 1Hz polling loop.
USB topology — the Keem Bay USB controller hosts an internal hub. The LTE modem (Telit LE910C4) sits on one internal port; the external USB-C is the other. It does carry data, not just power, so it's the target for sensor attachment.
```
Keem Bay USB controller
└── internal hub
├── Telit LE910C4 (internal)
└── USB-C data port (external) ← us
```
## sensor candidates
| Model | Maker | Measures | Iface | Notes |
|-------|-------|----------|-------|-------|
| BME680 | Bosch | VOC, temp, RH, pressure | I2C/SPI | indoor IAQ, ~$10-20 |
| BME688 | Bosch | BME680 + AI gas scanning | I2C/SPI | advanced VOC classification |
| SEN50 | Sensirion | PM1.0/PM2.5/PM4/PM10 | I2C/UART | particulates only |
| SEN54 | Sensirion | PM + VOC + temp + RH | I2C/UART | |
| SEN55 | Sensirion | SEN54 + NOx | I2C/UART | full air-quality suite |
Worth flagging: SEN5x is Sensirion, not Bosch. If the sensor on hand is branded Bosch it's almost certainly a BME680 or BME688.
**BME680/688** — VOC as IAQ index 0-500; temp -40 to +85°C ±1°C; RH 0-100% ±3%; pressure 300-1100 hPa ±1 hPa; 3.6mA active, <1µA sleep. I2C address 0x76 or 0x77. Cheap, well-documented, low power, but VOC is a relative index (not absolute concentration) and the gas sensor needs ~48h of burn-in before readings stabilize.
**SEN55** — PM1.0/PM2.5/PM4/PM10 (0-1000 µg/m³), VOC index, NOx index, temp -10 to +50°C, RH 0-100%. ~60mA. Native I2C or UART. Bigger (~40×40×12mm) and pricier (~$50-80), but it measures actual particulate matter, which is the metric that matters for outdoor pollution mapping.
For AdaMaps urban pollution work, SEN55 is the right pick — PM2.5 and NOx are the actionable numbers. For a quick "does this work at all" prototype, BME680 is fine.
## USB bridge options
For I2C sensors (BME680/688) we need a USB-to-I2C adapter:
| Adapter | Cost | Notes |
|---------|------|-------|
| Adafruit FT232H | $15 | FTDI, good support, `ftdi_sio` driver |
| MCP2221A | $5 | Microchip, HID mode, `i2c-mcp2221` |
| CP2112 | $8 | Silicon Labs, HID mode |
| CH341 | $3 | generic Chinese, works but flaky |
FT232H shows up as `/dev/i2c-X` via `ftdi_sio`; MCP2221A as `/dev/hidraw*` or `/dev/i2c-X`. Python side: `smbus2` for low-level, `adafruit-blinka` + `adafruit-circuitpython-bme680` for the BME, or `pyftdi` to drive the bridge directly.
Quick read with FT232H + BME680:
```python
import board, adafruit_bme680
i2c = board.I2C()
sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c)
print(sensor.temperature, sensor.humidity, sensor.pressure, sensor.gas)
```
For SEN5x, simplest is UART mode (jumper on the sensor) + CP2102 USB-UART (~$2). Sensor shows up as `/dev/ttyUSB0`; talk SHDLC at 115200. The Sensirion SEK-SEN55 eval kit has native USB-C and appears as CDC-ACM, but it's $100 and oversized — fine for bench testing, wrong for production.
Picking one: MCP2221A + BME680 breakout ~$15 total for basic VOC. CP2102 + SEN55 ~$55 for full particulate matter.
## bee-side integration
New service `air-sensor.service` polls the sensor at 1Hz and writes a Redis key. `bee-collector.py` (existing) reads that key during GPS fusion and includes it in the upload payload.
```
USB sensor + adapter
air-sensor.service (Python, 1Hz)
Redis: AirQuality1Hz
bee-collector.py ─ GNSSFusion30Hz ── (fuse) ─► HTTPS to AdaMaps
```
Service unit:
```ini
[Unit]
Description=Air Quality Sensor Reader
After=redis.service
[Service]
Type=simple
User=root
ExecStart=/opt/air-sensor/air_sensor.py
Restart=always
RestartSec=5
```
`/opt/air-sensor/air_sensor.py`:
```python
#!/usr/bin/env python3
"""Poll BME680/688 over USB-I2C, publish to Redis at 1Hz."""
import json, time, redis
import board, adafruit_bme680
POLL = 1.0
KEY = "AirQuality1Hz"
def iaq(gas, _humidity):
# placeholder — for real IAQ use Bosch BSEC
if gas > 300000: return 50
if gas > 200000: return 100
if gas > 100000: return 150
if gas > 50000: return 200
return 300
def main():
r = redis.Redis()
i2c = board.I2C()
sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=0x77)
sensor.sea_level_pressure = 1013.25
while True:
reading = {
"ts": int(time.time() * 1000),
"temperature_c": round(sensor.temperature, 2),
"humidity_pct": round(sensor.humidity, 2),
"pressure_hpa": round(sensor.pressure, 2),
"gas_resistance_ohms": sensor.gas,
"iaq_index": iaq(sensor.gas, sensor.humidity),
}
r.set(KEY, json.dumps(reading))
r.publish("air_quality", json.dumps(reading))
time.sleep(POLL)
if __name__ == "__main__":
main()
```
The IAQ calc above is a placeholder — for real-world readings you want the Bosch BSEC library, which is closed-source but free for non-commercial use (license check needed before shipping anything that's not personal).
Extending bee-collector to merge it in:
```python
def get_air_quality():
data = redis_client.get("AirQuality1Hz")
return json.loads(data) if data else None
def collect_frame():
gnss = json.loads(redis_client.get("GNSSFusion30Hz") or "{}")
air = get_air_quality()
return {
"timestamp": int(time.time() * 1000),
"lat": gnss.get("lat"),
"lon": gnss.get("lon"),
"speed_kmh": gnss.get("speed"),
"air_temperature_c": air and air.get("temperature_c"),
"air_humidity_pct": air and air.get("humidity_pct"),
"air_pressure_hpa": air and air.get("pressure_hpa"),
"air_iaq_index": air and air.get("iaq_index"),
"air_gas_ohms": air and air.get("gas_resistance_ohms"),
}
```
Resource impact: <0.5% CPU, ~15MB RAM, one thread, <1KB/s USB traffic. Doesn't touch the camera, VPU, or map-ai. Negligible.
## AdaMaps side
Two endpoints + one table.
### `/api/ingest/air`
```python
@app.route('/api/ingest/air', methods=['POST'])
def ingest_air_quality():
if not verify_api_key(request):
return jsonify({"error": "Unauthorized"}), 401
data = request.json
required = ['lat', 'lon', 'timestamp']
if not all(k in data for k in required):
return jsonify({"error": "Missing required fields"}), 400
conn = get_db(); cur = conn.cursor()
cur.execute("""
INSERT INTO air_quality (
device_id, timestamp,
lat, lon, geom,
temperature_c, humidity_pct, pressure_hpa,
iaq_index, gas_ohms,
pm1_0, pm2_5, pm4_0, pm10,
voc_index, nox_index
) VALUES (
%s, to_timestamp(%s / 1000.0),
%s, %s, ST_SetSRID(ST_MakePoint(%s, %s), 4326),
%s, %s, %s,
%s, %s,
%s, %s, %s, %s,
%s, %s
)
""", (
data.get('device_id'), data['timestamp'],
data['lat'], data['lon'],
data['lon'], data['lat'], # ST_MakePoint is (lon, lat)
data.get('air_temperature_c'),
data.get('air_humidity_pct'),
data.get('air_pressure_hpa'),
data.get('air_iaq_index'),
data.get('air_gas_ohms'),
data.get('pm1_0'), data.get('pm2_5'),
data.get('pm4_0'), data.get('pm10'),
data.get('voc_index'), data.get('nox_index'),
))
conn.commit(); cur.close()
return jsonify({"inserted": 1})
```
### schema
```sql
CREATE TABLE air_quality (
id SERIAL PRIMARY KEY,
device_id VARCHAR(64),
timestamp TIMESTAMPTZ NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
geom GEOMETRY(Point, 4326),
-- BME680/688
temperature_c REAL,
humidity_pct REAL,
pressure_hpa REAL,
iaq_index INTEGER, -- 0-500 (Bosch IAQ)
gas_ohms INTEGER,
-- SEN5x
pm1_0 REAL, -- µg/m³
pm2_5 REAL,
pm4_0 REAL,
pm10 REAL,
voc_index INTEGER, -- 1-500
nox_index INTEGER, -- 1-500
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_air_quality_geom ON air_quality USING GIST (geom);
CREATE INDEX idx_air_quality_timestamp ON air_quality (timestamp DESC);
CREATE INDEX idx_air_quality_device ON air_quality (device_id);
```
### `/api/air/heatmap`
```python
@app.route('/api/air/heatmap', methods=['GET'])
def air_quality_heatmap():
hours = request.args.get('hours', 24, type=int)
metric = request.args.get('metric', 'iaq_index') # or pm2_5, voc_index
# bounds param parsed but unused for now — TODO
conn = get_db(); cur = conn.cursor()
cur.execute(f"""
SELECT
ST_X(ST_Centroid(ST_Collect(geom))) AS lon,
ST_Y(ST_Centroid(ST_Collect(geom))) AS lat,
AVG({metric}) AS value,
COUNT(*) AS samples
FROM air_quality
WHERE timestamp > NOW() - INTERVAL '%s hours'
GROUP BY ROUND(lat::numeric, 3), ROUND(lon::numeric, 3)
HAVING AVG({metric}) IS NOT NULL
""", (hours,))
results = [
{"lon": r[0], "lat": r[1], "value": round(r[2], 1), "samples": r[3]}
for r in cur.fetchall()
]
cur.close()
return jsonify({"data": results, "metric": metric})
```
The `metric` arg interpolates into the SQL — fine since the value is validated against a column allowlist before reaching this point (don't skip that part).
## frontend overlay
Leaflet.heat does most of the work. Convert the heatmap response to `[lat, lon, intensity]` triples, normalize IAQ 0-500 down to 0-1:
```javascript
import L from 'leaflet';
import 'leaflet.heat';
async function loadAirQualityLayer(map) {
const r = await fetch('/api/air/heatmap?hours=24&metric=iaq_index');
const { data } = await r.json();
const heat = data.map(p => [p.lat, p.lon, Math.min(p.value / 300, 1.0)]);
return L.heatLayer(heat, {
radius: 25, blur: 15, maxZoom: 17,
gradient: {
0.0: 'green', // 0-50 Excellent
0.2: 'yellow', // 51-100 Good
0.4: 'orange', // 101-150 Moderate
0.6: 'red', // 151-200 Unhealthy
0.8: 'purple', // 201-300 Very unhealthy
1.0: 'maroon', // 301+ Hazardous
},
});
}
```
Legend markup:
```html
<div class="air-quality-legend">
<h4>Air Quality Index</h4>
<div><span class="color green"></span> 0-50 Excellent</div>
<div><span class="color yellow"></span> 51-100 Good</div>
<div><span class="color orange"></span> 101-150 Moderate</div>
<div><span class="color red"></span> 151-200 Unhealthy</div>
<div><span class="color purple"></span> 201-300 Very unhealthy</div>
<div><span class="color maroon"></span> 301+ Hazardous</div>
</div>
```
## 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.

View file

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

View file

@ -1,545 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AdaMaps — Verified Sign Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d0d0d;
color: #e0e0e0;
font-family: 'Courier New', monospace;
height: 100vh;
display: flex;
flex-direction: column;
}
#header {
background: #111;
border-bottom: 1px solid #222;
padding: 10px 18px;
display: flex;
align-items: center;
gap: 12px;
z-index: 1000;
flex-wrap: wrap;
}
#header h1 {
font-size: 1.1rem;
font-weight: bold;
color: #00e5ff;
letter-spacing: 2px;
text-transform: uppercase;
}
#status {
margin-left: auto;
font-size: 0.75rem;
color: #555;
}
#status.live { color: #00e5ff; }
#status.error { color: #ff4444; }
#map {
flex: 1;
background: #111;
}
.leaflet-tile-pane { filter: brightness(0.7) invert(1) hue-rotate(180deg) saturate(0.6); }
.leaflet-popup-content-wrapper {
background: #1a1a1a;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
}
.leaflet-popup-tip { background: #1a1a1a; }
.leaflet-popup-content { margin: 10px 14px; }
.popup-title { color: #00e5ff; font-weight: bold; margin-bottom: 4px; }
.popup-row { color: #aaa; }
.popup-row span { color: #e0e0e0; }
.popup-verified { color: #00ff88; font-weight: bold; }
.popup-unverified { color: #ffaa00; }
.popup-img {
width: 100%;
max-width: 240px;
border-radius: 3px;
margin-top: 8px;
border: 1px solid #333;
display: block;
cursor: pointer;
}
.popup-img:hover { border-color: #00e5ff; }
/* Marker styles */
.verified-marker {
width: 14px;
height: 14px;
background: #00ff88;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 6px rgba(0,255,136,0.5);
}
.unverified-marker {
width: 10px;
height: 10px;
background: #ffaa00;
border-radius: 50%;
border: 2px solid #fff;
opacity: 0.7;
}
.multi-obs-marker {
width: 16px;
height: 16px;
background: #00e5ff;
border-radius: 50%;
border: 2px solid #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: bold;
color: #000;
}
#count-badge {
background: #00e5ff;
color: #000;
font-size: 0.7rem;
font-weight: bold;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 1px;
}
#verified-badge {
background: #00ff88;
color: #000;
font-size: 0.7rem;
font-weight: bold;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 1px;
}
.toggle-btn {
background: #222;
border: 1px solid #444;
color: #e0e0e0;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
font-family: 'Courier New', monospace;
}
.toggle-btn:hover {
background: #333;
}
.toggle-btn.active {
background: #00e5ff;
color: #000;
border-color: #00e5ff;
}
.marker-cluster-small {
background-color: rgba(0, 229, 255, 0.4);
}
.marker-cluster-small div {
background-color: rgba(0, 229, 255, 0.8);
color: #000;
font-weight: bold;
}
.marker-cluster-medium {
background-color: rgba(0, 200, 255, 0.4);
}
.marker-cluster-medium div {
background-color: rgba(0, 200, 255, 0.8);
color: #000;
font-weight: bold;
}
.marker-cluster-large {
background-color: rgba(0, 150, 255, 0.4);
}
.air-legend {
background: #1a1a1a;
border: 1px solid #333;
padding: 8px 12px;
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: #ccc;
border-radius: 4px;
}
.legend-title { color: #00e5ff; font-weight: bold; margin-bottom: 4px; }
.legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-row span {
display: inline-block; width: 12px; height: 12px;
border-radius: 2px; flex-shrink: 0;
}
.marker-cluster-large div {
background-color: rgba(0, 150, 255, 0.8);
color: #000;
font-weight: bold;
}
</style>
</head>
<body>
<div id="header">
<h1>AdaMaps</h1>
<span id="count-badge">0 signs</span>
<span id="verified-badge">0 verified</span>
<button class="toggle-btn active" id="btn-signs">Signs</button>
<button class="toggle-btn" id="btn-raw">Raw</button>
<button class="toggle-btn" id="btn-verified">Verified Only</button>
<span style="color:#333;margin:0 4px">|</span>
<button class="toggle-btn" id="btn-aqi">AQI Heat</button>
<button class="toggle-btn" id="btn-pm25">PM2.5 Heat</button>
<span id="status">loading...</span>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
const map = L.map('map', {
center: [33.88, -118.0],
zoom: 10,
zoomControl: true,
attributionControl: true
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
const statusEl = document.getElementById('status');
const countBadge = document.getElementById('count-badge');
const verifiedBadge = document.getElementById('verified-badge');
const btnSigns = document.getElementById('btn-signs');
const btnRaw = document.getElementById('btn-raw');
const btnVerified = document.getElementById('btn-verified');
const btnAqi = document.getElementById('btn-aqi');
const btnPm25 = document.getElementById('btn-pm25');
const markerCluster = L.markerClusterGroup({
chunkedLoading: true,
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
disableClusteringAtZoom: 16
});
map.addLayer(markerCluster);
let currentMode = 'signs';
let verifiedOnly = false;
function createVerifiedIcon() {
return L.divIcon({
className: '',
html: '<div class="verified-marker"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7],
popupAnchor: [0, -10]
});
}
function createUnverifiedIcon() {
return L.divIcon({
className: '',
html: '<div class="unverified-marker"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
popupAnchor: [0, -8]
});
}
function createMultiObsIcon(count) {
return L.divIcon({
className: '',
html: `<div class="multi-obs-marker">${count}</div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
popupAnchor: [0, -10]
});
}
function formatTimestamp(ts) {
if (!ts) return 'unknown';
try { return new Date(ts).toLocaleString(); } catch(e) { return ts; }
}
function buildSignPopup(s) {
const verifiedClass = s.verified ? 'popup-verified' : 'popup-unverified';
const verifiedText = s.verified ? '✓ VERIFIED' : 'unverified';
return `
<div class="popup-title">${s.sign_type || 'Unknown Sign'}</div>
<div class="popup-row ${verifiedClass}">${verifiedText}</div>
<div class="popup-row">Observations: <span>${s.observations}</span></div>
<div class="popup-row">Devices: <span>${s.devices}</span></div>
<div class="popup-row">Confidence: <span>${(s.confidence * 100).toFixed(1)}%</span></div>
<div class="popup-row">Heading: <span>${s.heading || '—'}</span></div>
<div class="popup-row">First seen: <span>${formatTimestamp(s.first_seen)}</span></div>
<div class="popup-row">Last seen: <span>${formatTimestamp(s.last_seen)}</span></div>
<div class="popup-row">Coords: <span>${s.lat.toFixed(6)}, ${s.lon.toFixed(6)}</span></div>
${s.image_url ? `<img class="popup-img" src="${s.image_url}" loading="lazy" alt="detection photo" onclick="window.open('${s.image_url}','_blank')">` : ''}
`;
}
function buildDetectionPopup(d) {
return `
<div class="popup-title">${d.class_label || 'Detection'}</div>
<div class="popup-row">Device: <span>${d.device_id || '—'}</span></div>
<div class="popup-row">Time: <span>${formatTimestamp(d.ts)}</span></div>
<div class="popup-row">Confidence: <span>${d.confidence ? (d.confidence * 100).toFixed(1) + '%' : '—'}</span></div>
<div class="popup-row">Coords: <span>${d.lat?.toFixed(6)}, ${d.lon?.toFixed(6)}</span></div>
${d.image_url ? `<img class="popup-img" src="${d.image_url}" loading="lazy" alt="detection photo" onclick="window.open('${d.image_url}','_blank')">` : ''}
`;
}
async function fetchAndPlot() {
statusEl.textContent = 'fetching...';
statusEl.className = '';
try {
let url, data;
if (currentMode === 'signs') {
url = verifiedOnly
? 'https://api.adamaps.org/api/signs?verified=true&limit=5000'
: 'https://api.adamaps.org/api/signs?limit=5000';
} else {
url = 'https://api.adamaps.org/api/detections?limit=5000';
}
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) {
if (res.status === 404 && currentMode === 'signs') {
statusEl.textContent = 'Run clustering first';
statusEl.className = 'error';
return;
}
throw new Error(`HTTP ${res.status}`);
}
data = await res.json();
markerCluster.clearLayers();
const bounds = [];
let verifiedCount = 0;
if (currentMode === 'signs') {
data.forEach(s => {
if (s.lat == null || s.lon == null) return;
let icon;
if (s.verified) {
verifiedCount++;
icon = s.observations > 1 ? createMultiObsIcon(s.observations) : createVerifiedIcon();
} else {
icon = createUnverifiedIcon();
}
const marker = L.marker([s.lat, s.lon], { icon });
marker.bindPopup(buildSignPopup(s));
markerCluster.addLayer(marker);
bounds.push([s.lat, s.lon]);
});
countBadge.textContent = `${data.length.toLocaleString()} sign${data.length !== 1 ? 's' : ''}`;
verifiedBadge.textContent = `${verifiedCount} verified`;
} else {
data.forEach(d => {
const lat = d.lat ?? d.latitude;
const lng = d.lng ?? d.longitude ?? d.lon;
if (lat == null || lng == null) return;
const marker = L.marker([lat, lng], { icon: createUnverifiedIcon() });
marker.bindPopup(buildDetectionPopup(d));
markerCluster.addLayer(marker);
bounds.push([lat, lng]);
});
countBadge.textContent = `${data.length.toLocaleString()} detection${data.length !== 1 ? 's' : ''}`;
verifiedBadge.textContent = '—';
}
statusEl.textContent = `live · ${new Date().toLocaleTimeString()}`;
statusEl.className = 'live';
if (bounds.length > 0 && map.getZoom() < 8) {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
}
} catch(err) {
statusEl.textContent = `error: ${err.message}`;
statusEl.className = 'error';
console.error('AdaMaps fetch error:', err);
}
}
// Button handlers
btnSigns.addEventListener('click', () => {
currentMode = 'signs';
verifiedOnly = false;
btnSigns.classList.add('active');
btnRaw.classList.remove('active');
btnVerified.classList.remove('active');
fetchAndPlot();
});
btnRaw.addEventListener('click', () => {
currentMode = 'raw';
verifiedOnly = false;
btnRaw.classList.add('active');
btnSigns.classList.remove('active');
btnVerified.classList.remove('active');
fetchAndPlot();
});
btnVerified.addEventListener('click', () => {
currentMode = 'signs';
verifiedOnly = true;
btnVerified.classList.add('active');
btnSigns.classList.remove('active');
btnRaw.classList.remove('active');
fetchAndPlot();
});
fetchAndPlot();
setInterval(fetchAndPlot, 60000);
// AQI color gradient: green→yellow→orange→red→purple
const AQI_GRADIENT = {
0.0: '#00e400', // Good (0-50)
0.17: '#ffff00', // Moderate (51-100)
0.33: '#ff7e00', // Unhealthy for sensitive (101-150)
0.50: '#ff0000', // Unhealthy (151-200)
0.67: '#8f3f97', // Very unhealthy (201-300)
1.0: '#7e0023', // Hazardous (301+)
};
const PM25_GRADIENT = {
0.0: '#00e5ff',
0.25: '#00ff88',
0.50: '#ffff00',
0.75: '#ff7e00',
1.0: '#ff0000',
};
function aqiLabel(aqi) {
if (aqi == null) return '—';
if (aqi <= 50) return `${aqi} Good`;
if (aqi <= 100) return `${aqi} Moderate`;
if (aqi <= 150) return `${aqi} Unhealthy (Sensitive)`;
if (aqi <= 200) return `${aqi} Unhealthy`;
if (aqi <= 300) return `${aqi} Very Unhealthy`;
return `${aqi} Hazardous`;
}
function aqiColor(aqi) {
if (aqi == null) return '#888';
if (aqi <= 50) return '#00e400';
if (aqi <= 100) return '#ffff00';
if (aqi <= 150) return '#ff7e00';
if (aqi <= 200) return '#ff0000';
if (aqi <= 300) return '#8f3f97';
return '#7e0023';
}
let airHeatAqi = null;
let airHeatPm25 = null;
let airMode = null; // null | 'aqi' | 'pm25'
async function fetchAirOverlay(metric) {
try {
const res = await fetch(
`https://api.adamaps.org/api/air/heatmap?metric=${metric}&hours=24`,
{ cache: 'no-store' }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json(); // [[lat, lon, intensity], ...]
} catch (e) {
console.warn('Air overlay fetch failed:', e);
return [];
}
}
async function toggleAirOverlay(metric) {
// Clear both layers first
if (airHeatAqi) { map.removeLayer(airHeatAqi); airHeatAqi = null; }
if (airHeatPm25) { map.removeLayer(airHeatPm25); airHeatPm25 = null; }
// Toggle off if same metric clicked again
if (airMode === metric) {
airMode = null;
btnAqi.classList.remove('active');
btnPm25.classList.remove('active');
statusEl.textContent = 'live · ' + new Date().toLocaleTimeString();
if (airLegend) { map.removeControl(airLegend); airLegend = null; }
return;
}
airMode = metric;
btnAqi.classList.toggle('active', metric === 'aqi');
btnPm25.classList.toggle('active', metric === 'pm25');
statusEl.textContent = `loading ${metric.toUpperCase()} overlay...`;
const points = await fetchAirOverlay(metric);
if (!points.length) {
statusEl.textContent = 'no air quality data yet';
airMode = null;
btnAqi.classList.remove('active');
btnPm25.classList.remove('active');
return;
}
const gradient = metric === 'aqi' ? AQI_GRADIENT : PM25_GRADIENT;
const layer = L.heatLayer(points, {
radius: 25,
blur: 20,
maxZoom: 17,
gradient,
minOpacity: 0.3,
});
if (metric === 'aqi') airHeatAqi = layer;
else airHeatPm25 = layer;
layer.addTo(map);
statusEl.textContent = `${metric.toUpperCase()} overlay · ${points.length.toLocaleString()} pts`;
// Show legend when overlay is active
if (!airLegend) { airLegend = buildAirLegend(); airLegend.addTo(map); }
}
// ── Legend ────────────────────────────────────────────────────────────────────
function buildAirLegend() {
const legend = L.control({ position: 'bottomright' });
legend.onAdd = () => {
const div = L.DomUtil.create('div', 'air-legend');
div.innerHTML = `
<div class="legend-title">AQI / PM2.5</div>
<div class="legend-row"><span style="background:#00e400"></span> Good (0-50)</div>
<div class="legend-row"><span style="background:#ffff00"></span> Moderate (51-100)</div>
<div class="legend-row"><span style="background:#ff7e00"></span> Unhealthy* (101-150)</div>
<div class="legend-row"><span style="background:#ff0000"></span> Unhealthy (151-200)</div>
<div class="legend-row"><span style="background:#8f3f97"></span> Very Unhealthy (201-300)</div>
<div class="legend-row"><span style="background:#7e0023"></span> Hazardous (301+)</div>
`;
return div;
};
return legend;
}
let airLegend = null;
// Call this after map init — wires up buttons and legend
function initAirOverlay() {
btnAqi.addEventListener('click', () => toggleAirOverlay('aqi'));
btnPm25.addEventListener('click', () => toggleAirOverlay('pm25'));
}
// Init air overlay buttons + legend after map is ready
initAirOverlay();
</script>
</body>
</html>

View file

@ -1,18 +1,18 @@
[versions] [versions]
agp = "8.13.2" agp = "8.3.0"
kotlin = "2.0.0" kotlin = "2.0.0"
ksp = "2.0.0-1.0.21" ksp = "2.0.0-1.0.21"
coreKtx = "1.12.0" coreKtx = "1.12.0"
lifecycleRuntimeKtx = "2.7.0" lifecycleRuntimeKtx = "2.7.0"
activityCompose = "1.13.0" activityCompose = "1.8.2"
composeBom = "2024.02.00" composeBom = "2024.02.00"
navigationCompose = "2.7.7" navigationCompose = "2.7.7"
okhttp = "4.12.0" okhttp = "4.12.0"
gson = "2.10.1" gson = "2.10.1"
coroutines = "1.8.0" coroutines = "1.8.0"
osmdroid = "6.1.20" osmdroid = "6.1.18"
datastore = "1.0.0" datastore = "1.0.0"
coil = "2.7.0" coil = "2.6.0"
room = "2.6.1" room = "2.6.1"
work = "2.9.0" work = "2.9.0"