openScale-port: GPL-3.0-relicens + BT-vågsdrivrutiner (grund + Exingtech Y1/VScale)
- LICENSE → GPL-3.0 (krav för att bädda in openScales kod) - Vendrat com.health.openscale.core.bluetooth: ScaleDeviceHandler, Gatt/Broadcast/Spp-adaptrar, ScaleFactory (utan Hilt), ScaleCommunicator, BleScanner, ConverterUtils, ScaleMeasurement/ScaleUser — copyright-headers kvar - ExingtechY1Handler: Biltema 84-1002 (PT-727), annonserar som "VScale" - Shims för openScales interna beroenden: facades (DataStore-backade), slimmade enums/User/MeasurementWithValues, LogManager → logcat - blessed-kotlin 3.0.12 via JitPack; Kotlin 2.0.21→2.2.0, KSP2, Room 2.7.2 - BLE-permissions (BLUETOOTH_SCAN neverForLocation + CONNECT) i manifestet - Svenska strängresurser för drivrutinernas meddelanden Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
675
LICENSE
Normal file
675
LICENSE
Normal file
@@ -0,0 +1,675 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
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.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
{project} Copyright (C) {year} {fullname}
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
|
||||
@@ -80,6 +80,7 @@ dependencies {
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.blessed.kotlin)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- BT-våg (openScale-drivrutiner). minSdk 31 → bara de nya BLE-permissionerna behövs. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".FitnessDroidApplication"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Domain events emitted by a [ScaleCommunicator].
|
||||
*
|
||||
* Notes for broadcast-only devices (advertisement parsing, no GATT):
|
||||
* - The adapter emits [Listening] when scanning starts for the target MAC.
|
||||
* - When a final (stabilized) measurement was published and scanning stops, it emits [BroadcastComplete].
|
||||
* - For such devices, [Connected] is typically never emitted.
|
||||
*/
|
||||
sealed class BluetoothEvent {
|
||||
enum class UserInteractionType {
|
||||
CHOOSE_USER,
|
||||
ENTER_CONSENT
|
||||
}
|
||||
|
||||
/** Emitted when scanning starts for a broadcast-only device. */
|
||||
data class Listening(val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted after a broadcast-only flow has completed (e.g., stabilized measurement parsed). */
|
||||
data class BroadcastComplete(val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a GATT connection has been established. */
|
||||
data class Connected(val deviceName: String, val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when an existing GATT connection has been disconnected. */
|
||||
data class Disconnected(val deviceAddress: String, val reason: String? = null) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a connection attempt to a device failed. */
|
||||
data class ConnectionFailed(val deviceAddress: String, val error: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a parsed measurement is available. */
|
||||
data class MeasurementReceived(
|
||||
val measurement: ScaleMeasurement,
|
||||
val deviceAddress: String
|
||||
) : BluetoothEvent()
|
||||
|
||||
/** Emitted for generic device-related errors. */
|
||||
data class Error(val deviceAddress: String, val error: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted for miscellaneous device/user-visible messages. */
|
||||
data class DeviceMessage(val message: String, val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when user interaction is required (e.g., pick user, enter consent code). */
|
||||
data class UserInteractionRequired(
|
||||
val deviceIdentifier: String,
|
||||
val data: Any?,
|
||||
val interactionType: UserInteractionType,
|
||||
) : BluetoothEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic interface for communicating with Bluetooth scales.
|
||||
* Implementations may be GATT-based or broadcast-only (advertisement parsing).
|
||||
*/
|
||||
interface ScaleCommunicator {
|
||||
|
||||
/** Indicates whether a connection attempt (or scan for broadcast devices) is in progress. */
|
||||
val isConnecting: StateFlow<Boolean>
|
||||
|
||||
/** Indicates whether a GATT connection is active. For broadcast-only devices this is always `false`. */
|
||||
val isConnected: StateFlow<Boolean>
|
||||
|
||||
/** Start communicating with a device identified by [address]. Binds the session to [scaleUser]. */
|
||||
fun connect(address: String, scaleUser: ScaleUser?)
|
||||
|
||||
/** Terminate the current session (disconnect or stop scanning). */
|
||||
fun disconnect()
|
||||
|
||||
/** Request a measurement (if supported; some devices only push asynchronously). */
|
||||
fun requestMeasurement()
|
||||
|
||||
/**
|
||||
* Renders the device-specific configuration UI.
|
||||
* This allows the device handler to inject custom settings fields
|
||||
* (like bind keys or user slots) into the settings screen.
|
||||
*/
|
||||
@Composable
|
||||
fun DeviceConfigurationUi()
|
||||
|
||||
/** Stream of [BluetoothEvent] emitted by the communicator. */
|
||||
fun getEventsFlow(): SharedFlow<BluetoothEvent>
|
||||
|
||||
/** Deliver feedback for a previously requested user interaction. */
|
||||
suspend fun processUserInteractionFeedback(
|
||||
interactionType: BluetoothEvent.UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* FitnessDroid-anpassning: Hilt-DI borttagen (manuell DI), och handler-listan
|
||||
* innehåller än så länge bara de drivrutiner som portats. Fler portas från
|
||||
* openScale allteftersom — behåll upstream-ordningen när listan växer
|
||||
* (första matchande handler vinner).
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth
|
||||
|
||||
import android.content.Context
|
||||
import com.health.openscale.core.bluetooth.scales.DebugGattHandler
|
||||
import com.health.openscale.core.bluetooth.scales.DeviceSupport
|
||||
import com.health.openscale.core.bluetooth.scales.ExingtechY1Handler
|
||||
import com.health.openscale.core.bluetooth.scales.GattScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.BroadcastScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.LinkMode
|
||||
import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler
|
||||
import com.health.openscale.core.bluetooth.scales.SppScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.TuningProfile
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Factory class responsible for creating appropriate [ScaleCommunicator] instances
|
||||
* for different Bluetooth scale devices.
|
||||
*/
|
||||
class ScaleFactory(
|
||||
private val applicationContext: Context,
|
||||
private val settingsFacade: SettingsFacade,
|
||||
private val measurementFacade: MeasurementFacade,
|
||||
private val userFacade: UserFacade,
|
||||
) {
|
||||
private val TAG = "ScaleHandlerFactory"
|
||||
|
||||
private val modernKotlinHandlers: List<ScaleDeviceHandler> = createHandlers()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Builds the list of modern Kotlin-based device handlers.
|
||||
*
|
||||
* Order matters: [createCommunicator] returns the FIRST handler whose
|
||||
* [ScaleDeviceHandler.supportFor] is non-null.
|
||||
*/
|
||||
internal fun createHandlers(): List<ScaleDeviceHandler> = listOf(
|
||||
// Portade från openScale hittills:
|
||||
ExingtechY1Handler(), // Biltema 84-1002 (PT-727) annonserar som "VScale"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current value of a settings [Flow] from a non-suspending context.
|
||||
*/
|
||||
private fun <T> readSettingBlocking(flow: Flow<T>): T? = runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
withTimeout(250.milliseconds) { flow.firstOrNull() }
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun createModernCommunicator(
|
||||
handler: ScaleDeviceHandler,
|
||||
support: DeviceSupport
|
||||
): ScaleCommunicator? {
|
||||
val effectiveTuning: TuningProfile = run {
|
||||
val saved: String? = readSettingBlocking(settingsFacade.savedBluetoothTuneProfile)
|
||||
|
||||
saved?.let { runCatching { TuningProfile.valueOf(it) }.getOrNull() }
|
||||
?: support.tuningProfile
|
||||
}
|
||||
|
||||
return when (support.linkMode) {
|
||||
LinkMode.CONNECT_GATT ->
|
||||
GattScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
|
||||
LinkMode.BROADCAST_ONLY ->
|
||||
BroadcastScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
|
||||
LinkMode.CLASSIC_SPP ->
|
||||
SppScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the most suitable [ScaleCommunicator] for the given scanned device.
|
||||
*/
|
||||
fun createCommunicator(deviceInfo: ScannedDeviceInfo): ScaleCommunicator? {
|
||||
val primaryIdentifier = deviceInfo.name
|
||||
LogManager.d(TAG, "createCommunicator: Searching for communicator for '${primaryIdentifier}' (${deviceInfo.address}). Handler hint: '${deviceInfo.determinedHandlerDisplayName}'")
|
||||
|
||||
if (readSettingBlocking(settingsFacade.developerModeEnabled) == true) {
|
||||
LogManager.i(TAG, "Developer mode active → routing '$primaryIdentifier' to DebugGattHandler. No measurement will be stored.")
|
||||
return createModernCommunicator(DebugGattHandler(), DebugGattHandler.SUPPORT)
|
||||
}
|
||||
|
||||
for (handler in modernKotlinHandlers) {
|
||||
val support = handler.supportFor(deviceInfo)
|
||||
if (support != null) {
|
||||
LogManager.i(TAG, "Modern handler '${support.displayName}' supports '$primaryIdentifier'.")
|
||||
val modern = createModernCommunicator(handler, support)
|
||||
if (modern != null) {
|
||||
LogManager.i(TAG, "Modern communicator '${modern.javaClass.simpleName}' created for '$primaryIdentifier' with linkMode=${support.linkMode}.")
|
||||
return modern
|
||||
}
|
||||
LogManager.w(TAG, "Modern handler '${support.displayName}' supports '$primaryIdentifier', but no communicator is available.")
|
||||
}
|
||||
}
|
||||
|
||||
LogManager.w(TAG, "No suitable communicator found for device (name: '${deviceInfo.name}', address: '${deviceInfo.address}', handler hint: '${deviceInfo.determinedHandlerDisplayName}').")
|
||||
return null
|
||||
}
|
||||
|
||||
fun getDeviceSupportFor(name: String, address: String): DeviceSupport? {
|
||||
val info = ScannedDeviceInfo(name, address, 0, emptyList(), null)
|
||||
return modernKotlinHandlers.firstNotNullOfOrNull { it.supportFor(info) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any known handler can theoretically support the given device.
|
||||
*/
|
||||
fun getSupportingHandlerInfo(deviceInfo: ScannedDeviceInfo): Pair<Boolean, String?> {
|
||||
for (handler in modernKotlinHandlers) {
|
||||
val support = handler.supportFor(deviceInfo)
|
||||
if (support != null) return true to support.displayName
|
||||
}
|
||||
|
||||
return false to null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.data
|
||||
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Represents a single measurement record from a scale, potentially combined from multiple BLE packets.
|
||||
*/
|
||||
data class ScaleMeasurement(
|
||||
var userId: Int = 0xFF, // openScale's internal app user ID
|
||||
var dateTime: Date? = null,
|
||||
var weight: Float = 0.0f, // must be in kg
|
||||
var fat: Float = 0.0f, // must be in percentage
|
||||
var water: Float = 0.0f, // must be in percentage
|
||||
var muscle: Float = 0.0f, // must be in percentage
|
||||
var visceralFat: Float = 0.0f, // must be in percentage
|
||||
var bone: Float = 0.0f, // must be in kg
|
||||
var lbm : Float = 0.0f, // must be in kg
|
||||
var bmr: Float = 0.0f, // Basal Metabolic Rate in kcal
|
||||
var heartRate: Int = 0, // must be bpm
|
||||
var impedance: Double = 0.0, // Ohms — high-frequency band when the scale is dual-band
|
||||
var impedanceLow: Double = 0.0, // Ohms — low-frequency band; 0 when not reported
|
||||
var ecw: Float = 0.0f, // Extracellular water, % of body weight
|
||||
var icw: Float = 0.0f, // Intracellular water, % of body weight
|
||||
var protein: Float = 0.0f, // Protein, % of body weight
|
||||
var bcm: Float = 0.0f, // Body cell mass, kg
|
||||
) {
|
||||
|
||||
// --- Utility methods ---
|
||||
|
||||
fun hasWeight(): Boolean = this.weight > 0f
|
||||
|
||||
fun mergeWith(other: ScaleMeasurement) = apply {
|
||||
if (other.weight > 0f && this.weight <= 0f) this.weight = other.weight
|
||||
if (other.fat > 0f && this.fat <= 0f) this.fat = other.fat
|
||||
if (other.water > 0f && this.water <= 0f) this.water = other.water
|
||||
if (other.muscle > 0f && this.muscle <= 0f) this.muscle = other.muscle
|
||||
if (other.visceralFat > 0f && this.visceralFat <= 0f) this.visceralFat = other.visceralFat
|
||||
if (other.bone > 0f && this.bone <= 0f) this.bone = other.bone
|
||||
if (other.lbm > 0f && this.lbm <= 0f) this.lbm = other.lbm
|
||||
if (other.bmr > 0f && this.bmr <= 0f) this.bmr = other.bmr
|
||||
if (other.heartRate > 0f && this.heartRate <= 0f) this.heartRate = other.heartRate
|
||||
if (other.impedance > 0.0 && this.impedance <= 0.0) this.impedance = other.impedance
|
||||
if (other.impedanceLow > 0.0 && this.impedanceLow <= 0.0) this.impedanceLow = other.impedanceLow
|
||||
if (other.ecw > 0f && this.ecw <= 0f) this.ecw = other.ecw
|
||||
if (other.icw > 0f && this.icw <= 0f) this.icw = other.icw
|
||||
if (other.protein > 0f && this.protein <= 0f) this.protein = other.protein
|
||||
if (other.bcm > 0f && this.bcm <= 0f) this.bcm = other.bcm
|
||||
|
||||
if (other.userId != 0xFF &&
|
||||
(this.userId == 0xFF || this.userId == -1)) { // -1 was common init value
|
||||
this.userId = other.userId
|
||||
}
|
||||
|
||||
if (this.dateTime == null && other.dateTime != null) this.dateTime = other.dateTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.data
|
||||
|
||||
import com.health.openscale.core.data.ActivityLevel
|
||||
import com.health.openscale.core.data.GenderType
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
|
||||
data class ScaleUser (
|
||||
var id: Int = 0,
|
||||
var userName: String = "",
|
||||
var birthday: Date = Date(),
|
||||
var bodyHeight: Float = -1f, // always in cm
|
||||
var gender: GenderType = GenderType.MALE,
|
||||
var initialWeight: Float = 0f, // always in kg
|
||||
var goalWeight: Float = 0f, // always in kg
|
||||
var scaleUnit: WeightUnit = WeightUnit.KG,
|
||||
var activityLevel: ActivityLevel = ActivityLevel.SEDENTARY
|
||||
){
|
||||
fun getAge(todayDate: Date?): Int {
|
||||
val calToday = Calendar.getInstance()
|
||||
if (todayDate != null) {
|
||||
calToday.setTime(todayDate)
|
||||
}
|
||||
|
||||
val calBirthday = Calendar.getInstance()
|
||||
calBirthday.setTime(birthday)
|
||||
|
||||
return yearsBetween(calBirthday, calToday)
|
||||
}
|
||||
|
||||
val age: Int
|
||||
get() = getAge(null)
|
||||
|
||||
private fun yearsBetween(start: Calendar, end: Calendar): Int {
|
||||
var years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR)
|
||||
|
||||
val startMonth = start.get(Calendar.MONTH)
|
||||
val endMonth = end.get(Calendar.MONTH)
|
||||
if (endMonth < startMonth
|
||||
|| (endMonth == startMonth
|
||||
&& end.get(Calendar.DAY_OF_MONTH) < start.get(Calendar.DAY_OF_MONTH))
|
||||
) {
|
||||
years -= 1
|
||||
}
|
||||
return years
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothCentralManager
|
||||
import com.welie.blessed.BluetoothCentralManagerCallback
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Broadcast adapter (no GATT)
|
||||
// - uses Blessed to scan for a specific address and forwards advertisements to handler.onAdvertisement()
|
||||
// - applies tuning: max scan window, retry/backoff, RSSI filter, packet de-dup, stabilization window
|
||||
// - attaches handler with a no-op transport immediately on start
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class BroadcastScaleAdapter(
|
||||
context: android.content.Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BleBroadcastTuning = profile.forBroadcast()
|
||||
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
private var broadcastAttached = false
|
||||
private var scanTimeoutJob: Job? = null
|
||||
private var attempt = 0
|
||||
private var isScanning = false
|
||||
|
||||
// de-duplication: contentHash -> lastSeenMs
|
||||
private val dedupSeen = LinkedHashMap<Int, Long>(64, 0.75f, true)
|
||||
private var lastForwardAtMs = 0L
|
||||
|
||||
private fun now() = SystemClock.elapsedRealtime()
|
||||
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscovered(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
// Filter: only target MAC
|
||||
if (peripheral.address != targetAddress) return
|
||||
|
||||
// Filter: optional RSSI threshold
|
||||
val rssi = scanResult.rssi
|
||||
tuning.minRssiDbm?.let { if (rssi < it) return }
|
||||
|
||||
// De-dup: collapse identical packets within packetDedupWindowMs
|
||||
val bytes = scanResult.scanRecord?.bytes
|
||||
|
||||
LogManager.d(TAG,"Discovered advertisement from ${peripheral.address} RSSI=$rssi ${bytes?.toHexPreview(24)}")
|
||||
|
||||
val hash = contentHash(bytes, rssi)
|
||||
val t = now()
|
||||
val last = dedupSeen[hash]
|
||||
if (last != null && (t - last) <= tuning.packetDedupWindowMs) {
|
||||
LogManager.w(TAG, "Deduplicated packet hash=$hash from ${peripheral.address}")
|
||||
return
|
||||
}
|
||||
dedupSeen[hash] = t
|
||||
trimDedup(t)
|
||||
|
||||
// Attach handler as soon as we see the target device (if not already)
|
||||
ensureAttached(peripheral.address)
|
||||
|
||||
// Optional stabilization: avoid forwarding bursts too quickly
|
||||
if (t - lastForwardAtMs < tuning.stabilizeWindowMs) {
|
||||
LogManager.w(TAG, "Skipping forwarding to handler (stabilize window) from ${peripheral.address}")
|
||||
return
|
||||
}
|
||||
|
||||
val user = selectedUserSnapshot ?: return
|
||||
LogManager.d(TAG,"Forwarding advertisement to handler: ${peripheral.address} RSSI=$rssi ${bytes?.toHexPreview(24)}")
|
||||
val action = handler.onAdvertisement(scanResult, user)
|
||||
LogManager.d(TAG, "Handler returned $action for ${peripheral.address}")
|
||||
|
||||
when (action) {
|
||||
BroadcastAction.IGNORED -> LogManager.d(TAG, "Advertisement IGNORED for ${peripheral.address}")
|
||||
BroadcastAction.CONSUMED_KEEP_SCANNING -> {
|
||||
LogManager.d(TAG, "Advertisement CONSUMED for ${peripheral.address}")
|
||||
lastForwardAtMs = t
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
}
|
||||
BroadcastAction.CONSUMED_STOP -> {
|
||||
lastForwardAtMs = t
|
||||
LogManager.d(TAG, "Measurement stabilized → BroadcastComplete for ${peripheral.address}")
|
||||
_events.tryEmit(BluetoothEvent.BroadcastComplete(peripheral.address))
|
||||
stopScanInternal()
|
||||
cleanup()
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
private fun ensureCentral() {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureAttached(address: String) {
|
||||
if (broadcastAttached) return
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(noopTransport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
broadcastAttached = true
|
||||
_events.tryEmit(BluetoothEvent.Listening(address))
|
||||
}
|
||||
|
||||
private fun startScanAttempt(address: String) {
|
||||
// reset per-attempt state
|
||||
isScanning = true
|
||||
lastForwardAtMs = 0
|
||||
dedupSeen.clear()
|
||||
|
||||
// Blessed does not expose ScanSettings directly; we emulate tuning at our level
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(setOf(address))
|
||||
LogManager.d(TAG, "Broadcast scan started (attempt ${attempt + 1}/${tuning.common.maxRetries}) for $address")
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Failed to start broadcast scan: ${e.message}", e)
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, e.message ?: context.getString(R.string.bt_error_generic)))
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// Arm scan timeout for this attempt
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = scope.launch {
|
||||
delay(tuning.maxScanMs.milliseconds)
|
||||
if (!isScanning) return@launch
|
||||
LogManager.w(TAG, "Broadcast scan timed out for $address")
|
||||
stopScanInternal()
|
||||
|
||||
attempt++
|
||||
if (attempt <= tuning.common.maxRetries) {
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
startScanAttempt(address)
|
||||
} else {
|
||||
cleanup()
|
||||
// keep attached? we detach to be consistent with failure
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScanInternal() {
|
||||
scanTimeoutJob?.cancel(); scanTimeoutJob = null
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
isScanning = false
|
||||
lastDisconnectAtMs = now()
|
||||
}
|
||||
|
||||
private fun trimDedup(t: Long) {
|
||||
// simple time-based eviction
|
||||
val it = dedupSeen.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
val e = it.next()
|
||||
if (t - e.value > tuning.packetDedupWindowMs) it.remove()
|
||||
else break // map is access-ordered, earliest first
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentHash(bytes: ByteArray?, rssi: Int): Int {
|
||||
if (bytes == null || bytes.isEmpty()) return rssi // fallback
|
||||
// A lightweight rolling hash (faster than Arrays.hashCode in hot path)
|
||||
var h = 1125899907
|
||||
for (b in bytes) h = (h * 131) xor (b.toInt() and 0xFF)
|
||||
// mix in coarse rssi bucket to avoid treating level-only jitter as new data
|
||||
val bucket = (rssi / 3) // bucketize
|
||||
return (h shl 1) xor bucket
|
||||
}
|
||||
|
||||
private val noopTransport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {}
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {}
|
||||
override fun read(service: UUID, characteristic: UUID) {}
|
||||
override fun disconnect() { doDisconnect() }
|
||||
override fun getPeripheral(): BluetoothPeripheral? = null
|
||||
override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = false
|
||||
}
|
||||
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
ensureCentral()
|
||||
|
||||
// Attach early so UI can show “Listening…”
|
||||
ensureAttached(address)
|
||||
|
||||
_isConnecting.value = false
|
||||
_isConnected.value = false
|
||||
|
||||
attempt = 0
|
||||
startScanAttempt(address)
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
stopScanInternal()
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* ## DebugGattHandler
|
||||
*
|
||||
* A pure **inspection** handler that:
|
||||
*
|
||||
* - Only activates while developer mode is enabled in the Bluetooth settings; it is not part of
|
||||
* the handler registry and never claims a device on its own.
|
||||
* - On connect, **dumps the full GATT table** (all services and characteristics) by
|
||||
* asking the adapter/transport for the current `BluetoothPeripheral`.
|
||||
* - Optionally performs a few safe reads/subscriptions on common services to trigger
|
||||
* some traffic (helpful to verify notifications).
|
||||
* - Logs **every incoming notification** with a compact hex & ASCII preview.
|
||||
*
|
||||
* This handler **never publishes measurements** and is intended solely for diagnostics.
|
||||
*
|
||||
* ### Adapter requirement
|
||||
* The adapter/transport must expose `debugGetPeripheral(): BluetoothPeripheral?`.
|
||||
* In `GattScaleAdapter`, implement it by returning the current `BluetoothPeripheral`.
|
||||
*
|
||||
* ### Why this lives here
|
||||
* We keep all formatting, pretty-printing, and logging **inside this handler**, while
|
||||
* the adapter stays minimal and unopinionated.
|
||||
*/
|
||||
class DebugGattHandler : ScaleDeviceHandler() {
|
||||
companion object {
|
||||
/**
|
||||
* The support descriptor this handler runs with. Exposed because the handler is no longer
|
||||
* part of the registry: [com.health.openscale.core.bluetooth.ScaleFactory] instantiates it
|
||||
* directly when developer mode is on and needs the descriptor to pick the adapter.
|
||||
*/
|
||||
val SUPPORT = DeviceSupport(
|
||||
displayName = "Debug",
|
||||
capabilities = emptySet(), // no functional features
|
||||
implemented = emptySet(),
|
||||
tuningProfile = TuningProfile.Balanced,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Never claims a device by itself. Developer mode is a separate setting
|
||||
* ([com.health.openscale.core.facade.SettingsFacade.developerModeEnabled]); routing it through
|
||||
* the device name used to overwrite the saved scale's identity, which silently broke the
|
||||
* pairing (issue #1478).
|
||||
*/
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? = null
|
||||
|
||||
/**
|
||||
* On connect:
|
||||
* 1) Dump the **entire** GATT service/characteristic tree.
|
||||
* 2) Optionally poke a few common characteristics (best-effort).
|
||||
* 3) Arm light-weight subscriptions to typical measurement characteristics,
|
||||
* if present (best-effort; errors are logged).
|
||||
*/
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
logD("Connected in Debug mode. Dumping full GATT services/characteristics…")
|
||||
dumpAllGatt()
|
||||
|
||||
// --- Optional sanity probes (best-effort; they can fail silently) -----
|
||||
// Generic Access: Device Name
|
||||
readSafe(uuid16(0x1800), uuid16(0x2A00))
|
||||
// Device Information: Manufacturer / Model / FW / SW
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A29))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A24))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A26))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A28))
|
||||
// Battery Level
|
||||
readSafe(uuid16(0x180F), uuid16(0x2A19))
|
||||
|
||||
// Subscribe to common measurement characteristics if present
|
||||
setNotifySafe(uuid16(0x181D), uuid16(0x2A9D)) // Weight Scale -> Weight Measurement
|
||||
setNotifySafe(uuid16(0x181B), uuid16(0x2A9C)) // Body Comp -> Body Composition Measurement
|
||||
|
||||
logD("Debug handler armed. Incoming NOTIFY frames will be logged; no data is stored.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every incoming notification is logged in a concise form:
|
||||
* - Pretty UUID (16-bit when possible)
|
||||
* - Hex preview (up to 64 bytes)
|
||||
* - ASCII preview (non-printables as '?')
|
||||
*/
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
val hex = data.toHexPreview(64)
|
||||
val ascii = data.toAsciiPreview(64)
|
||||
logD("NOTIFY chr=${prettyUuid(characteristic)} $hex ascii=$ascii")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Dump utilities
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Dump all discovered GATT services and their characteristics (with property flags).
|
||||
* Uses the transport's `debugGetPeripheral()` hook to access the raw peripheral.
|
||||
*/
|
||||
private fun dumpAllGatt() {
|
||||
val peripheral = getPeripheral()
|
||||
if (peripheral == null) {
|
||||
logD("No peripheral available yet (transport.debugGetPeripheral() returned null).")
|
||||
return
|
||||
}
|
||||
|
||||
val services = peripheral.services
|
||||
logD("=== GATT Service Dump BEGIN ===")
|
||||
if (services.isEmpty()) {
|
||||
logD( "(no services)")
|
||||
logD( "=== GATT Service Dump END ===")
|
||||
return
|
||||
}
|
||||
|
||||
for (svc in services) {
|
||||
logD( "Service ${prettyUuid(svc.uuid)}")
|
||||
val chars = svc.characteristics ?: emptyList()
|
||||
for (ch in chars) {
|
||||
logD(" └─ Char ${prettyUuid(ch.uuid)} props=${propsToString(ch.properties)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
logD("=== GATT Service Dump END ===")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Safe wrappers (never throw; best-effort operations)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private fun setNotifySafe(service: UUID, characteristic: UUID) {
|
||||
logD("→ setNotifyOn svc=${prettyUuid(service)} chr=${prettyUuid(characteristic)}")
|
||||
runCatching { setNotifyOn(service, characteristic) }
|
||||
.onFailure { logD("setNotifyOn failed: ${it.message ?: it::class.simpleName}") }
|
||||
}
|
||||
|
||||
private fun readSafe(service: UUID, characteristic: UUID) {
|
||||
logD("→ read svc=${prettyUuid(service)} chr=${prettyUuid(characteristic)} (best effort)")
|
||||
runCatching { readFrom(service, characteristic) }
|
||||
.onFailure { logD("read failed: ${it.message ?: it::class.simpleName}") }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Pretty-print helpers
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a standard 128-bit UUID with the Bluetooth base into a compact **0xNNNN** form.
|
||||
* Leaves full UUIDs intact for vendor/custom values.
|
||||
*/
|
||||
private fun prettyUuid(u: UUID): String {
|
||||
val s = u.toString().lowercase(Locale.ROOT)
|
||||
return if (s.startsWith("0000") && s.endsWith("-0000-1000-8000-00805f9b34fb"))
|
||||
"0x" + s.substring(4, 8)
|
||||
else
|
||||
s
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn Android GATT property flags into a readable pipe-separated string.
|
||||
* Example: `READ|WRITE_NR|NOTIFY|INDICATE`
|
||||
*/
|
||||
private fun propsToString(p: Int): String {
|
||||
val flags = mutableListOf<String>()
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_READ) != 0) flags += "READ"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) flags += "WRITE"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) flags += "WRITE_NR"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) flags += "NOTIFY"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) flags += "INDICATE"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE) != 0) flags += "SIGNED"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_BROADCAST) != 0) flags += "BROADCAST"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS) != 0) flags += "EXT"
|
||||
return if (flags.isEmpty()) "0" else flags.joinToString("|")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.ConverterUtils
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Handler for Exingtech Y1 scales (often advertising as "VScale").
|
||||
*
|
||||
* Protocol:
|
||||
* - Custom Service: f433bd80-75b8-11e2-97d9-0002a5d5c51b
|
||||
* - Notify Characteristic: 1a2ea400-75b9-11e2-be05-0002a5d5c51b
|
||||
* - Write Characteristic: 29f11080-75b9-11e2-8bf6-0002a5d5c51b
|
||||
*
|
||||
* Flow:
|
||||
* 1) Enable NOTIFY on data characteristic.
|
||||
* 2) Write user block: [0x10, userId, gender(0=male/1=female), age, height(cm)].
|
||||
* 3) Wait for a 20-byte result frame; the first one may only contain weight.
|
||||
* Publish when body composition (fat) is present (data[6] != 0xFF).
|
||||
*/
|
||||
class ExingtechY1Handler : ScaleDeviceHandler() {
|
||||
|
||||
private val SERVICE: UUID =
|
||||
UUID.fromString("f433bd80-75b8-11e2-97d9-0002a5d5c51b")
|
||||
private val CHAR_NOTIFY: UUID =
|
||||
UUID.fromString("1a2ea400-75b9-11e2-be05-0002a5d5c51b")
|
||||
private val CHAR_CMD: UUID =
|
||||
UUID.fromString("29f11080-75b9-11e2-8bf6-0002a5d5c51b")
|
||||
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
|
||||
val name = device.name.lowercase(Locale.US)
|
||||
val byName = (name == "vscale")
|
||||
|
||||
val byService = device.serviceUuids.any {
|
||||
it.equals(SERVICE)
|
||||
}
|
||||
|
||||
if (!byName && !byService) return null
|
||||
|
||||
val caps = setOf(
|
||||
DeviceCapability.BODY_COMPOSITION,
|
||||
DeviceCapability.USER_SYNC
|
||||
)
|
||||
|
||||
return DeviceSupport(
|
||||
displayName = "Exingtech Y1 (VScale)",
|
||||
capabilities = caps,
|
||||
implemented = caps,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
}
|
||||
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
// Enable notifications for result frames
|
||||
setNotifyOn(SERVICE, CHAR_NOTIFY)
|
||||
|
||||
// Send user block (id is truncated to 1 byte like legacy driver)
|
||||
val userIdOneByte = (user.id and 0xFF).toByte()
|
||||
val gender = if (user.gender.isMale()) 0x00 else 0x01
|
||||
val age = (user.age and 0xFF).toByte()
|
||||
val height = (user.bodyHeight.toInt() and 0xFF).toByte()
|
||||
|
||||
val cmd = byteArrayOf(
|
||||
0x10,
|
||||
userIdOneByte,
|
||||
gender.toByte(),
|
||||
age,
|
||||
height
|
||||
)
|
||||
writeTo(SERVICE, CHAR_CMD, cmd, withResponse = true)
|
||||
|
||||
// Prompt user
|
||||
userInfo(R.string.bt_info_step_on_scale)
|
||||
}
|
||||
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
if (characteristic != CHAR_NOTIFY) return
|
||||
if (data.size != 20) return
|
||||
|
||||
// The first notify can be "weight only"; full composition follows.
|
||||
// In legacy code we waited until fat != 0xFF.
|
||||
val fatHi = data[6]
|
||||
if (fatHi.toInt() and 0xFF == 0xFF) {
|
||||
logD("VScale: weight-only frame, waiting for full composition…")
|
||||
return
|
||||
}
|
||||
|
||||
publish(parseMeasurement(data))
|
||||
}
|
||||
|
||||
// --- Parsing --------------------------------------------------------------
|
||||
|
||||
private fun parseMeasurement(frame: ByteArray): ScaleMeasurement {
|
||||
// Big-endian 16-bit fields, matching legacy ConverterUtils.fromUnsignedInt16Be
|
||||
val weight = ConverterUtils.fromUnsignedInt16Be(frame, 4) / 10.0f
|
||||
val fat = ConverterUtils.fromUnsignedInt16Be(frame, 6) / 10.0f
|
||||
val water = ConverterUtils.fromUnsignedInt16Be(frame, 8) / 10.0f
|
||||
val bone = ConverterUtils.fromUnsignedInt16Be(frame, 10) / 10.0f
|
||||
val muscle = ConverterUtils.fromUnsignedInt16Be(frame, 12) / 10.0f
|
||||
val visceralIndex = (frame[14].toInt() and 0xFF).toFloat()
|
||||
// calorie (offset 15) and BMI (offset 17) exist but are computed by app; skip.
|
||||
|
||||
return ScaleMeasurement().apply {
|
||||
dateTime = Date()
|
||||
this.weight = weight
|
||||
this.fat = fat
|
||||
this.water = water
|
||||
this.muscle = muscle
|
||||
this.bone = bone
|
||||
this.visceralFat = visceralIndex
|
||||
}.also {
|
||||
logD("VScale result kg=${it.weight} fat=${it.fat} water=${it.water} muscle=${it.muscle} bone=${it.bone} visc=${it.visceralFat}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothCentralManager
|
||||
import com.welie.blessed.BluetoothCentralManagerCallback
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import com.welie.blessed.BluetoothPeripheralCallback
|
||||
import com.welie.blessed.ConnectionPriority
|
||||
import com.welie.blessed.GattStatus
|
||||
import com.welie.blessed.HciStatus
|
||||
import com.welie.blessed.WriteType
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// GATT adapter (BLE)
|
||||
// - scans for a specific address and connects via Blessed
|
||||
// - enables notifications, handles read/write with pacing (BleTuning)
|
||||
// - forwards notifications to handler.onNotification()
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class GattScaleAdapter(
|
||||
context: Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BleGattTuning = profile.forGatt()
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
private var currentPeripheral: BluetoothPeripheral? = null
|
||||
|
||||
private val opQueue = Channel<suspend () -> Unit>(Channel.UNLIMITED)
|
||||
|
||||
private data class PendingOp(
|
||||
val id: Long,
|
||||
val deferred: CompletableDeferred<Unit>
|
||||
)
|
||||
|
||||
private val deferredMap = ConcurrentHashMap<UUID, PendingOp>()
|
||||
private var nextOpId = 0L
|
||||
|
||||
private val ioMutex = Mutex()
|
||||
|
||||
private var connectAttempts = 0
|
||||
|
||||
init {
|
||||
// Worker coroutine processes queued BLE operations sequentially
|
||||
scope.launch {
|
||||
for (op in opQueue) {
|
||||
// wait until BLE connection is established
|
||||
while (!_isConnected.value) {
|
||||
delay(10.milliseconds)
|
||||
}
|
||||
|
||||
try {
|
||||
ioMutex.lock()
|
||||
op()
|
||||
} catch (t: Throwable) {
|
||||
LogManager.e(TAG, "BLE operation failed", t)
|
||||
} finally {
|
||||
ioMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
private suspend fun ioGap(ms: Long) {
|
||||
if (ms > 0) delay(ms.milliseconds)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Bluetooth central callbacks
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscovered(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
if (peripheral.address != targetAddress) return
|
||||
LogManager.i(TAG, "Found $targetAddress → stop scan + connect")
|
||||
central.stopScan()
|
||||
scope.launch {
|
||||
if (tuning.connectAfterScanDelayMs > 0) delay(tuning.connectAfterScanDelayMs.milliseconds)
|
||||
central.connect(peripheral, peripheralCallback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnected(peripheral: BluetoothPeripheral) {
|
||||
scope.launch {
|
||||
currentPeripheral = peripheral
|
||||
_isConnected.value = true
|
||||
_isConnecting.value = false
|
||||
_events.tryEmit(BluetoothEvent.Connected(peripheral.name, peripheral.address))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.e(TAG, "Connection failed ${peripheral.address}: $status")
|
||||
if (connectAttempts < tuning.common.maxRetries) {
|
||||
val nextTry = connectAttempts + 1
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_reconnecting_try, nextTry, tuning.common.maxRetries),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
connectAttempts = nextTry
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
runCatching { central.stopScan() }
|
||||
central.scanForPeripheralsWithAddresses(setOf(peripheral.address))
|
||||
_isConnecting.value = true
|
||||
} else {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(peripheral.address, status.toString()))
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.i(TAG, "Disconnected ${peripheral.address}: $status")
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
if (peripheral.address == targetAddress) {
|
||||
_events.tryEmit(BluetoothEvent.Disconnected(peripheral.address, status.toString()))
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Peripheral callback receives all GATT events
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
private val peripheralCallback = object : BluetoothPeripheralCallback() {
|
||||
override fun onServicesDiscovered(peripheral: BluetoothPeripheral) {
|
||||
LogManager.d(TAG, "Services discovered for ${peripheral.address}")
|
||||
currentPeripheral = peripheral
|
||||
|
||||
if (tuning.requestHighConnectionPriority) runCatching { peripheral.requestConnectionPriority(ConnectionPriority.HIGH) }
|
||||
if (tuning.requestMtuBytes > 23) runCatching { peripheral.requestMtu(tuning.requestMtuBytes) }
|
||||
|
||||
val user = selectedUserSnapshot ?: run {
|
||||
central.cancelConnection(peripheral); return
|
||||
}
|
||||
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
|
||||
handler.attach(transport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
handler.handleConnected(user)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 write response chr=${characteristic.uuid} len=${value.size} status=${status} ${value.toHexPreview(24)}")
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationStateUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 notify state chr=${characteristic.uuid} status=${status}")
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 received data chr=${characteristic.uuid} len=${value.size} status=${status} ${value.toHexPreview(24)}")
|
||||
|
||||
handler.handleNotification(characteristic.uuid, value)
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Transport exposed to handler; operations are queued automatically
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
private val transport = object : ScaleDeviceHandler.Transport {
|
||||
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
LogManager.d(TAG, "→ set notify on chr=$characteristic svc=$service")
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
val started = p.startNotify(service, characteristic)
|
||||
if (!started) {
|
||||
LogManager.w(TAG, "Failed to initiate notify for $characteristic")
|
||||
// appCallbacks.onWarn(R.string.bt_warn_notify_failed, characteristic.toString())
|
||||
deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait with timeout from tuning
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
LogManager.w(TAG, "Timeout waiting for notify on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.notifySetupDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
val ch = p.getCharacteristic(service, characteristic) ?: return@trySend
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
val supportsWriteNoResponse = ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0
|
||||
val supportsWriteResponse = ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0
|
||||
|
||||
val type = when {
|
||||
withResponse && supportsWriteResponse -> WriteType.WITH_RESPONSE
|
||||
!withResponse && supportsWriteNoResponse -> WriteType.WITHOUT_RESPONSE
|
||||
supportsWriteResponse -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support WITHOUT_RESPONSE, using WITH_RESPONSE instead")
|
||||
WriteType.WITH_RESPONSE
|
||||
}
|
||||
supportsWriteNoResponse -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support WITH_RESPONSE, using WITHOUT_RESPONSE instead")
|
||||
WriteType.WITHOUT_RESPONSE
|
||||
}
|
||||
else -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support writing")
|
||||
return@trySend
|
||||
}
|
||||
}
|
||||
|
||||
ioGap(if (withResponse) tuning.writeWithResponseDelayMs else tuning.writeWithoutResponseDelayMs)
|
||||
p.writeCharacteristic(service, characteristic, payload, type)
|
||||
|
||||
LogManager.d(TAG,"\u2192 write to chr=$characteristic svc=$service len=${payload.size} withResp=$withResponse ${payload.toHexPreview(24)}")
|
||||
|
||||
try {
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
LogManager.w(TAG, "Timeout waiting for write on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.postWriteDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(service: UUID, characteristic: UUID) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
p.getCharacteristic(service, characteristic) ?: return@trySend
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
p.readCharacteristic(service, characteristic)
|
||||
|
||||
LogManager.d(TAG,"\u2192 read from chr=$characteristic svc=$service")
|
||||
|
||||
try {
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
LogManager.w(TAG, "Timeout waiting for read on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.postReadDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
currentPeripheral?.let { central.cancelConnection(it) }
|
||||
}
|
||||
|
||||
override fun getPeripheral(): BluetoothPeripheral? = currentPeripheral
|
||||
|
||||
override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean {
|
||||
val p = currentPeripheral ?: return false
|
||||
return p.getCharacteristic(service, characteristic) != null
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Connection management
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
}
|
||||
|
||||
val sinceLastDisconnect = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
val waitMs = (tuning.common.reconnectCooldownMs - sinceLastDisconnect).coerceAtLeast(0)
|
||||
|
||||
connectAttempts = 0
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = true
|
||||
|
||||
runCatching { central.stopScan() }
|
||||
|
||||
scope.launch {
|
||||
if (waitMs > 0) delay(waitMs.milliseconds)
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(setOf(address))
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Failed to start scan/connect: ${e.message}", e)
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
e.message ?: context.getString(R.string.bt_error_generic)
|
||||
)
|
||||
)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
currentPeripheral?.let { runCatching { central.cancelConnection(it) } }
|
||||
currentPeripheral = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// Stop accepting new BLE operations and release the Blessed central
|
||||
// (its broadcast receivers) before the base cancels the coroutine scope,
|
||||
// which terminates the busy-waiting op-queue worker.
|
||||
runCatching { opQueue.close() }
|
||||
runCatching { if (::central.isInitialized) central.close() }
|
||||
super.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt1Bar
|
||||
import androidx.compose.material.icons.outlined.SignalCellularAlt2Bar
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.ScaleCommunicator
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.data.MeasurementTypeKey
|
||||
import com.health.openscale.core.data.MeasurementValue
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.User
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.model.MeasurementWithValues
|
||||
import com.health.openscale.core.utils.ConverterUtils
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Shared tuning for BLE pacing & retry (used by GATT adapter).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// Common knobs every link can use
|
||||
data class CommonTuning(
|
||||
val reconnectCooldownMs: Long = 2000,
|
||||
val retryBackoffMs: Long = 1500,
|
||||
val maxRetries: Int = 3
|
||||
)
|
||||
|
||||
// GATT-specific
|
||||
data class BleGattTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val notifySetupDelayMs: Long = 120,
|
||||
val writeWithResponseDelayMs: Long = 80,
|
||||
val writeWithoutResponseDelayMs: Long = 35,
|
||||
val postWriteDelayMs: Long = 20,
|
||||
val postReadDelayMs: Long = 20,
|
||||
val connectAfterScanDelayMs: Long = 650,
|
||||
val requestHighConnectionPriority: Boolean = false,
|
||||
val requestMtuBytes: Int = 185,
|
||||
val operationTimeoutMs: Long = 1000
|
||||
)
|
||||
|
||||
// Broadcast scanner tuning
|
||||
data class BleBroadcastTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val scanMode: Int = ScanSettings.SCAN_MODE_LOW_LATENCY,
|
||||
val maxScanMs: Long = 20_000,
|
||||
val restartBackoffMs: Long = 1500,
|
||||
val packetDedupWindowMs: Long = 750,
|
||||
val stabilizeWindowMs: Long = 1200,
|
||||
val minRssiDbm: Int? = null // e.g. -90
|
||||
)
|
||||
|
||||
// Classic SPP tuning
|
||||
data class BtSppTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val connectTimeoutMs: Long = 10_000,
|
||||
val readTimeoutMs: Long = 3000,
|
||||
val writeChunkBytes: Int = 256,
|
||||
val interChunkDelayMs: Long = 10,
|
||||
val soKeepAlive: Boolean = true
|
||||
)
|
||||
|
||||
enum class TuningProfile(
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
Conservative(
|
||||
labelRes = R.string.tuning_conservative,
|
||||
icon = Icons.Filled.SignalCellularAlt1Bar
|
||||
),
|
||||
Balanced(
|
||||
labelRes = R.string.tuning_balanced,
|
||||
icon = Icons.Outlined.SignalCellularAlt2Bar
|
||||
),
|
||||
Aggressive(
|
||||
labelRes = R.string.tuning_aggressive,
|
||||
icon = Icons.Filled.SignalCellularAlt
|
||||
)
|
||||
}
|
||||
fun TuningProfile.forGatt(): BleGattTuning = when (this) {
|
||||
TuningProfile.Balanced -> BleGattTuning(
|
||||
common = CommonTuning(2200, 1500, 3),
|
||||
notifySetupDelayMs = 120,
|
||||
writeWithResponseDelayMs = 80,
|
||||
writeWithoutResponseDelayMs = 35,
|
||||
postWriteDelayMs = 20,
|
||||
postReadDelayMs = 20,
|
||||
connectAfterScanDelayMs = 650,
|
||||
requestHighConnectionPriority = false,
|
||||
requestMtuBytes = 185,
|
||||
operationTimeoutMs = 1000
|
||||
)
|
||||
TuningProfile.Conservative -> BleGattTuning(
|
||||
common = CommonTuning(2500, 1800, 3),
|
||||
notifySetupDelayMs = 160,
|
||||
writeWithResponseDelayMs = 100,
|
||||
writeWithoutResponseDelayMs = 50,
|
||||
postWriteDelayMs = 30,
|
||||
postReadDelayMs = 30,
|
||||
connectAfterScanDelayMs = 800,
|
||||
requestHighConnectionPriority = false,
|
||||
requestMtuBytes = 0,
|
||||
operationTimeoutMs = 2000
|
||||
)
|
||||
TuningProfile.Aggressive -> BleGattTuning(
|
||||
common = CommonTuning(1200, 1200, 2),
|
||||
notifySetupDelayMs = 80,
|
||||
writeWithResponseDelayMs = 60,
|
||||
writeWithoutResponseDelayMs = 25,
|
||||
postWriteDelayMs = 15,
|
||||
postReadDelayMs = 15,
|
||||
connectAfterScanDelayMs = 400,
|
||||
requestHighConnectionPriority = true,
|
||||
requestMtuBytes = 247,
|
||||
operationTimeoutMs = 500
|
||||
)
|
||||
}
|
||||
|
||||
fun TuningProfile.forBroadcast(): BleBroadcastTuning = when (this) {
|
||||
TuningProfile.Balanced -> BleBroadcastTuning(common = CommonTuning(2200,1500,3))
|
||||
TuningProfile.Conservative -> BleBroadcastTuning(
|
||||
common = CommonTuning(2500,1800,3),
|
||||
scanMode = ScanSettings.SCAN_MODE_BALANCED,
|
||||
maxScanMs = 30_000
|
||||
)
|
||||
TuningProfile.Aggressive -> BleBroadcastTuning(
|
||||
common = CommonTuning(1200,1200,2),
|
||||
scanMode = ScanSettings.SCAN_MODE_LOW_LATENCY,
|
||||
maxScanMs = 15_000,
|
||||
stabilizeWindowMs = 900
|
||||
)
|
||||
}
|
||||
|
||||
fun TuningProfile.forSpp(): BtSppTuning = when (this) {
|
||||
TuningProfile.Balanced -> BtSppTuning()
|
||||
TuningProfile.Conservative -> BtSppTuning(connectTimeoutMs = 12_000, interChunkDelayMs = 15)
|
||||
TuningProfile.Aggressive -> BtSppTuning(connectTimeoutMs = 8_000, interChunkDelayMs = 5)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Small persisted driver settings wrapper backed by SettingsFacade (shared by all adapters).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class FacadeDriverSettings(
|
||||
private val facade: SettingsFacade,
|
||||
private val scope: CoroutineScope,
|
||||
handlerNamespace: String
|
||||
) : ScaleDeviceHandler.DriverSettings {
|
||||
|
||||
private val prefix = "ble/$handlerNamespace/"
|
||||
private val mem = ConcurrentHashMap<String, String>()
|
||||
|
||||
override fun getInt(key: String, default: Int): Int {
|
||||
val k = prefix + key
|
||||
mem[k]?.toIntOrNull()?.let { return it }
|
||||
val v = runCatching {
|
||||
runBlocking(Dispatchers.IO) { withTimeout(300.milliseconds) { facade.observeSetting(k, default).first() } }
|
||||
}.getOrElse { default }
|
||||
mem[k] = v.toString()
|
||||
return v
|
||||
}
|
||||
|
||||
override fun putInt(key: String, value: Int) {
|
||||
val k = prefix + key
|
||||
mem[k] = value.toString()
|
||||
scope.launch { facade.saveSetting(k, value) }
|
||||
}
|
||||
|
||||
override fun getString(key: String, default: String?): String? {
|
||||
val k = prefix + key
|
||||
mem[k]?.let { return it }
|
||||
val raw = runCatching {
|
||||
runBlocking(Dispatchers.IO) { withTimeout(300.milliseconds) { facade.observeSetting(k, default ?: "").first() } }
|
||||
}.getOrElse { default ?: "" }
|
||||
val result = if (raw.isEmpty() && default == null) null else raw
|
||||
result?.let { mem[k] = it }
|
||||
return result
|
||||
}
|
||||
|
||||
override fun putString(key: String, value: String) {
|
||||
val k = prefix + key
|
||||
mem[k] = value
|
||||
scope.launch { facade.saveSetting(k, value) }
|
||||
}
|
||||
|
||||
override fun remove(key: String) {
|
||||
val k = prefix + key
|
||||
mem.remove(k)
|
||||
scope.launch { facade.saveSetting(k, "") }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// ModernScaleAdapter (abstract base)
|
||||
// - Owns app integration, user/measurements snapshots, event streams, handler wiring.
|
||||
// - Concrete subclasses implement link-specific connect/disconnect logic.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
abstract class ModernScaleAdapter(
|
||||
protected val context: android.content.Context,
|
||||
protected val settingsFacade: SettingsFacade,
|
||||
protected val measurementFacade: MeasurementFacade,
|
||||
protected val userFacade: UserFacade,
|
||||
protected val handler: ScaleDeviceHandler
|
||||
) : ScaleCommunicator, AutoCloseable {
|
||||
|
||||
protected val TAG = this::class.simpleName ?: "ModernScaleAdapter"
|
||||
|
||||
// ---- coroutine & lifecycle -----------------------------------------------------------------
|
||||
protected val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
protected val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// ---- session targeting ---------------------------------------------------------------------
|
||||
protected var targetAddress: String? = null
|
||||
protected var lastDisconnectAtMs: Long = 0L
|
||||
|
||||
// ---- UI streams ----------------------------------------------------------------------------
|
||||
val _events = MutableSharedFlow<BluetoothEvent>(replay = 1, extraBufferCapacity = 8)
|
||||
override fun getEventsFlow(): SharedFlow<BluetoothEvent> = _events.asSharedFlow()
|
||||
|
||||
protected val _isConnecting = MutableStateFlow(false)
|
||||
override val isConnecting: StateFlow<Boolean> = _isConnecting.asStateFlow()
|
||||
|
||||
protected val _isConnected = MutableStateFlow(false)
|
||||
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
// ---- app snapshots for handler.DataProvider -------------------------------------------------
|
||||
@Volatile protected var selectedUserSnapshot: ScaleUser? = null
|
||||
@Volatile protected var usersSnapshot: List<ScaleUser> = emptyList()
|
||||
@Volatile protected var lastSnapshot: Map<Int, ScaleMeasurement> = emptyMap()
|
||||
|
||||
init {
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler.javaClass.simpleName
|
||||
)
|
||||
handler.attachSettings(driverSettings)
|
||||
|
||||
// Keep a *live* non-blocking snapshot of the current user.
|
||||
scope.launch {
|
||||
userFacade.observeSelectedUser().collect { u ->
|
||||
selectedUserSnapshot = u?.let(::mapUser)
|
||||
}
|
||||
}
|
||||
// Keep a *fresh enough* snapshot of users & their latest measurement.
|
||||
scope.launch {
|
||||
userFacade.observeAllUsers()
|
||||
.flatMapLatest { users ->
|
||||
usersSnapshot = users.map(::mapUser)
|
||||
if (users.isEmpty()) {
|
||||
flowOf(emptyMap())
|
||||
} else {
|
||||
combine(users.map { u -> measurementFacade.getMeasurementsForUser(u.id) }) { lists ->
|
||||
val out = HashMap<Int, ScaleMeasurement>(users.size)
|
||||
users.forEachIndexed { idx, u ->
|
||||
val newest = lists[idx].maxByOrNull { it.measurement.timestamp }
|
||||
mapMeasurement(newest)?.let { out[u.id] = it }
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
.collect { latestMap -> lastSnapshot = latestMap }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ScaleCommunicator entry points ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Template method: base validates input & selected user, then calls [doConnect].
|
||||
* Many handlers expect a selected user from app state;
|
||||
*/
|
||||
final override fun connect(address: String, scaleUser: ScaleUser?) {
|
||||
targetAddress = address
|
||||
_isConnecting.value = true
|
||||
|
||||
scope.launch {
|
||||
val user: ScaleUser? =
|
||||
scaleUser
|
||||
?: selectedUserSnapshot
|
||||
?: withTimeoutOrNull(750.milliseconds) {
|
||||
userFacade.observeSelectedUser().first()
|
||||
}?.let(::mapUser)
|
||||
|
||||
if (user == null) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
context.getString(R.string.bt_error_no_user_selected)
|
||||
)
|
||||
)
|
||||
_isConnecting.value = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
doConnect(address, user)
|
||||
}.onFailure { t ->
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
t.message ?: "—"
|
||||
)
|
||||
)
|
||||
_isConnecting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Template method: calls [doDisconnect] and resets shared state.
|
||||
*/
|
||||
final override fun disconnect() {
|
||||
doDisconnect()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default UX helper for devices that only push data via NOTIFY or broadcasts.
|
||||
* Subclasses can override if they can actively trigger measurement on device.
|
||||
*/
|
||||
override fun requestMeasurement() {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
addr
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun processUserInteractionFeedback(
|
||||
interactionType: BluetoothEvent.UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any
|
||||
) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
handler.onUserInteractionFeedback(interactionType, appUserId, feedbackData)
|
||||
}.onFailure { t ->
|
||||
val addr = targetAddress ?: "unknown"
|
||||
LogManager.e(TAG, "Delivering user feedback failed: ${t.message}", t)
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_error_delivery_user_feedback, t.message ?: "—"),
|
||||
addr
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- abstract link hooks -------------------------------------------------------------------
|
||||
|
||||
protected abstract fun doConnect(address: String, selectedUser: ScaleUser)
|
||||
protected abstract fun doDisconnect()
|
||||
|
||||
// ---- callbacks & data provider for handlers ------------------------------------------------
|
||||
|
||||
protected val appCallbacks = object : ScaleDeviceHandler.Callbacks {
|
||||
override fun onPublish(measurement: ScaleMeasurement) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.MeasurementReceived(measurement, addr))
|
||||
}
|
||||
|
||||
override fun onInfo(@StringRes resId: Int, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(context.getString(resId, *args), addr))
|
||||
}
|
||||
|
||||
override fun onWarn(@StringRes resId: Int, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(context.getString(resId, *args), addr))
|
||||
}
|
||||
|
||||
override fun onError(@StringRes resId: Int, t: Throwable?, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
val msg = context.getString(resId, *args)
|
||||
LogManager.e(TAG, msg, t)
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(msg, addr))
|
||||
}
|
||||
|
||||
override fun onUserInteractionRequired(interactionType: BluetoothEvent.UserInteractionType, data: Any?) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.UserInteractionRequired(addr, data, interactionType))
|
||||
}
|
||||
|
||||
override fun resolveString(@StringRes resId: Int, vararg args: Any): String =
|
||||
context.getString(resId, *args)
|
||||
}
|
||||
|
||||
protected val dataProvider = object : ScaleDeviceHandler.DataProvider {
|
||||
override fun currentUser(): ScaleUser = selectedUserSnapshot
|
||||
?: error("No selected user snapshot available")
|
||||
override fun usersForDevice(): List<ScaleUser> = usersSnapshot
|
||||
override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = lastSnapshot[userId]
|
||||
}
|
||||
|
||||
protected fun cleanup() {
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = false
|
||||
// keep targetAddress to allow higher layer to retry if wanted
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { scope.cancel() }
|
||||
}
|
||||
|
||||
// ---- mapping helpers (core -> legacy DTOs used by handlers) --------------------------------
|
||||
|
||||
protected fun mapUser(u: User): ScaleUser =
|
||||
ScaleUser().apply {
|
||||
runCatching { id = u.id }
|
||||
runCatching { userName = u.name }
|
||||
when (val b = runCatching { u.birthDate }.getOrNull()) {
|
||||
is Long -> birthday = Date(b)
|
||||
}
|
||||
runCatching { bodyHeight = u.heightCm }
|
||||
runCatching { gender = u.gender }
|
||||
runCatching { activityLevel = u.activityLevel }
|
||||
|
||||
runCatching {
|
||||
runBlocking(scope.coroutineContext) {
|
||||
val userGoals = userFacade.getAllGoalsForUser(u.id).first()
|
||||
|
||||
val goalWeightGoal = userGoals.find { it.measurementTypeId == MeasurementTypeKey.WEIGHT.id }
|
||||
if (goalWeightGoal != null) {
|
||||
val goalType = measurementFacade.getAllMeasurementTypes().first()
|
||||
.find { it.id == goalWeightGoal.measurementTypeId }
|
||||
|
||||
if (goalType != null) {
|
||||
goalWeight = ConverterUtils.convertFloatValueUnit(
|
||||
value = goalWeightGoal.goalValue,
|
||||
fromUnit = goalType.unit,
|
||||
toUnit = UnitType.KG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val allMeasurements = measurementFacade.getMeasurementsForUser(u.id).first()
|
||||
|
||||
val oldestWeightMeasurementValue = allMeasurements
|
||||
.sortedBy { it.measurement.timestamp }
|
||||
.firstNotNullOfOrNull { measurementWithValues ->
|
||||
measurementWithValues.values.find { it.type.key == MeasurementTypeKey.WEIGHT }
|
||||
}
|
||||
|
||||
if (oldestWeightMeasurementValue != null) {
|
||||
initialWeight = ConverterUtils.convertFloatValueUnit(
|
||||
value = oldestWeightMeasurementValue.value.floatValue ?: 0f,
|
||||
fromUnit = oldestWeightMeasurementValue.type.unit,
|
||||
toUnit = UnitType.KG
|
||||
)
|
||||
}
|
||||
|
||||
val allTypes = measurementFacade.getAllMeasurementTypes().first()
|
||||
|
||||
val weightType = allTypes.find { it.key == MeasurementTypeKey.WEIGHT }
|
||||
if (weightType != null) {
|
||||
scaleUnit = weightType.unit.toWeightUnit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun mapMeasurement(mwv: MeasurementWithValues?): ScaleMeasurement? {
|
||||
if (mwv == null) return null
|
||||
val m = ScaleMeasurement()
|
||||
runCatching { m.userId = mwv.measurement.userId }
|
||||
runCatching { m.dateTime = Date(mwv.measurement.timestamp) }
|
||||
|
||||
fun valueOf(key: MeasurementTypeKey): MeasurementValue? =
|
||||
mwv.values.firstOrNull { it.type.key == key }?.value
|
||||
|
||||
valueOf(MeasurementTypeKey.WEIGHT)?.let { m.weight = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.BODY_FAT)?.let { m.fat = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.WATER)?.let { m.water = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.MUSCLE)?.let { m.muscle = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.VISCERAL_FAT)?.let { m.visceralFat = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.LBM)?.let { m.lbm = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.BONE)?.let { m.bone = it.floatValue ?: 0f }
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
/** Pretty print a few leading bytes of a payload for logs. */
|
||||
fun ByteArray.toHexPreview(limit: Int): String {
|
||||
if (limit <= 0 || isEmpty()) return "(payload ${size}b)"
|
||||
val show = min(size, limit)
|
||||
val sb = StringBuilder("payload=[")
|
||||
for (i in 0 until show) {
|
||||
if (i > 0) sb.append(' ')
|
||||
sb.append(String.format("%02X", this[i]))
|
||||
}
|
||||
if (size > limit) sb.append(" …(+").append(size - limit).append("b)")
|
||||
sb.append(']')
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoGraph
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.outlined.BatteryStd
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.util.UUID
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* What a handler declares about a device it supports.
|
||||
*
|
||||
* @property displayName Human-friendly name shown in the UI (e.g., "Yunmai Mini").
|
||||
* @property capabilities Features the device *can* support in theory.
|
||||
* @property implemented Features this handler actually implements today (may be a subset).
|
||||
* @property tuningProfile Optional link timing/retry preferences (see [TuningProfile]).
|
||||
* @property linkMode Whether the device uses GATT or broadcast-only advertisements.
|
||||
*/
|
||||
data class DeviceSupport(
|
||||
val displayName: String,
|
||||
val capabilities: Set<DeviceCapability>,
|
||||
val implemented: Set<DeviceCapability>,
|
||||
val tuningProfile: TuningProfile = TuningProfile.Balanced,
|
||||
val linkMode: LinkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
|
||||
/** High-level capabilities a scale might offer. */
|
||||
enum class DeviceCapability(
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
BODY_COMPOSITION( R.string.cap_body_composition, Icons.Filled.FitnessCenter ),
|
||||
TIME_SYNC( R.string.cap_time_sync, Icons.Filled.Schedule ),
|
||||
USER_SYNC( R.string.cap_user_sync, Icons.Filled.Group ),
|
||||
HISTORY_READ( R.string.cap_history_read, Icons.Filled.History ),
|
||||
LIVE_WEIGHT_STREAM(R.string.cap_live_weight, Icons.Filled.AutoGraph ),
|
||||
UNIT_CONFIG( R.string.cap_unit_config, Icons.Filled.Tune ),
|
||||
BATTERY_LEVEL( R.string.cap_battery, Icons.Outlined.BatteryStd )
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines whether a device communicates via a GATT connection
|
||||
* or only via broadcast advertisements.
|
||||
*/
|
||||
enum class LinkMode { CONNECT_GATT, BROADCAST_ONLY, CLASSIC_SPP }
|
||||
|
||||
/**
|
||||
* Signals how the handler consumed an advertisement.
|
||||
* - IGNORED: payload not relevant; adapter keeps scanning silently.
|
||||
* - CONSUMED_KEEP_SCANNING: payload processed, but we want to continue scanning (e.g., waiting for stability).
|
||||
* - CONSUMED_STOP: final payload processed; adapter should stop scanning and finish the session.
|
||||
*/
|
||||
enum class BroadcastAction { IGNORED, CONSUMED_KEEP_SCANNING, CONSUMED_STOP }
|
||||
|
||||
/**
|
||||
* # ScaleDeviceHandler
|
||||
*
|
||||
* Minimal base class for a **device-specific** BLE protocol handler.
|
||||
*
|
||||
* For GATT devices, the app (via `ModernScaleAdapter`) injects a BLE [Transport] and [Callbacks],
|
||||
* then calls [onConnected] and forwards notifications to [onNotification].
|
||||
*
|
||||
* For broadcast-only devices, the adapter attaches a **no-op** transport and forwards
|
||||
* advertisement frames to [onAdvertisement]. The handler can call [publish] to emit results.
|
||||
*
|
||||
* Threading: the adapter serializes and paces BLE I/O. Avoid sleeps or blocking work inside your
|
||||
* handler; just call the helpers in the order your protocol requires.
|
||||
*/
|
||||
abstract class ScaleDeviceHandler {
|
||||
val TAG = this::class.simpleName ?: "ScaleDeviceHandler"
|
||||
|
||||
companion object {
|
||||
// Pseudo UUIDs for Classic/SPP
|
||||
val CLASSIC_DATA_UUID: UUID =
|
||||
UUID.fromString("00000000-0000-0000-0000-00000000C1A5")
|
||||
}
|
||||
/**
|
||||
* Identify whether this handler supports the given scanned device.
|
||||
* Return a [DeviceSupport] description if yes, or `null` if not.
|
||||
*/
|
||||
abstract fun supportFor(device: ScannedDeviceInfo): DeviceSupport?
|
||||
|
||||
/**
|
||||
* Optional UI component for device-specific settings.
|
||||
* Override this in concrete handlers to show custom input fields.
|
||||
*/
|
||||
@Composable
|
||||
open fun DeviceConfigurationUi() {
|
||||
// Default message when no specific configuration is required
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_special_configuration_available),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lifecycle entry points called by the adapter -------------------------
|
||||
|
||||
internal fun attachSettings(settings: DriverSettings) {
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
internal fun attach(transport: Transport, callbacks: Callbacks, settings: DriverSettings, data: DataProvider, scope: CoroutineScope) {
|
||||
this.transport = transport
|
||||
this.callbacks = callbacks
|
||||
this.settings = settings
|
||||
this.data = data
|
||||
this._scope = scope
|
||||
logD("attach()")
|
||||
}
|
||||
|
||||
internal fun handleConnected(user: ScaleUser) {
|
||||
logD("handleConnected(userId=${user.id}, height=${user.bodyHeight}, age=${user.age})")
|
||||
try {
|
||||
onConnected(user)
|
||||
} catch (t: Throwable) {
|
||||
logE("onConnected failed: ${t.message}", t)
|
||||
callbacks?.onError(
|
||||
R.string.bt_error_handler_connect_failed,
|
||||
t,
|
||||
t.message ?: "—"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleNotification(characteristic: UUID, data: ByteArray) {
|
||||
val u = currentAppUser()
|
||||
try {
|
||||
onNotification(characteristic, data, u)
|
||||
} catch (t: Throwable) {
|
||||
logE("onNotification failed for $characteristic: ${t.message}", t)
|
||||
callbacks?.onError(
|
||||
R.string.bt_error_handler_parse_error,
|
||||
t,
|
||||
characteristic.toString(),
|
||||
t.message ?: "—"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleDisconnected() {
|
||||
logD("handleDisconnected()")
|
||||
try {
|
||||
onDisconnected()
|
||||
} catch (t: Throwable) {
|
||||
logW("onDisconnected threw: ${t.message}")
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun detach() {
|
||||
logD("detach()")
|
||||
transport = null
|
||||
callbacks = null
|
||||
}
|
||||
|
||||
// --- To be implemented by concrete handlers --------------------------------
|
||||
|
||||
/** Called after services are discovered and the link is ready for I/O (GATT devices only). */
|
||||
protected open fun onConnected(user: ScaleUser) = Unit
|
||||
|
||||
/** Called for each incoming notification (GATT devices only). */
|
||||
protected open fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) = Unit
|
||||
|
||||
/** Optional cleanup hook. */
|
||||
protected open fun onDisconnected() = Unit
|
||||
|
||||
/**
|
||||
* Called for each advertisement seen for the target device (broadcast-only devices).
|
||||
* Default implementation ignores the advertisement.
|
||||
*/
|
||||
open fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction = BroadcastAction.IGNORED
|
||||
|
||||
// --- Protected helper methods (use these from your handler) ----------------
|
||||
|
||||
/** Enable notifications for a characteristic. */
|
||||
protected fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
transport?.setNotifyOn(service, characteristic)
|
||||
?: logW("setNotifyOn called without transport")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a command to a characteristic.
|
||||
* @param withResponse true for `Write With Response` (default), false for `Write Without Response`.
|
||||
*/
|
||||
protected fun writeTo(
|
||||
service: UUID,
|
||||
characteristic: UUID,
|
||||
payload: ByteArray,
|
||||
withResponse: Boolean = true
|
||||
) {
|
||||
transport?.write(service, characteristic, payload, withResponse)
|
||||
?: logW("writeTo called without transport")
|
||||
}
|
||||
|
||||
/** Read a characteristic (rare for scales; most data comes via NOTIFY). */
|
||||
protected fun readFrom(service: UUID, characteristic: UUID) {
|
||||
transport?.read(service, characteristic)
|
||||
?: logW("readFrom called without transport")
|
||||
}
|
||||
|
||||
/** Publish a fully parsed measurement to the app. */
|
||||
protected fun publish(measurement: ScaleMeasurement) {
|
||||
logI("\u2190 publish measurement to app")
|
||||
callbacks?.onPublish(measurement)
|
||||
?: logW("publish called without callbacks")
|
||||
}
|
||||
|
||||
/** Ask the adapter to terminate the link. */
|
||||
protected fun requestDisconnect() {
|
||||
logD("\u2192 request BLE disconnect")
|
||||
transport?.disconnect()
|
||||
}
|
||||
|
||||
fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean {
|
||||
val hasUUID = transport?.hasCharacteristic(service, characteristic) ?: false
|
||||
if (!hasUUID)
|
||||
logD("hasCharacteristic: $service/$characteristic → false")
|
||||
return hasUUID
|
||||
}
|
||||
|
||||
protected fun getPeripheral(): BluetoothPeripheral? {
|
||||
return transport?.getPeripheral()
|
||||
}
|
||||
|
||||
/** Helper to build a 16-bit Bluetooth Base UUID (e.g., `uuid16(0xFFE4)`). */
|
||||
protected fun uuid16(short: Int): UUID =
|
||||
UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short))
|
||||
|
||||
protected fun resolveString(@StringRes resId: Int, vararg args: Any): String =
|
||||
callbacks?.resolveString(resId, *args) ?: "res:$resId"
|
||||
|
||||
protected fun settingsGetInt(key: String, default: Int = -1): Int = settings.getInt(key, default)
|
||||
protected fun settingsPutInt(key: String, value: Int) { settings.putInt(key, value) }
|
||||
|
||||
protected fun settingsGetString(key: String, default: String? = null): String? = settings.getString(key, default)
|
||||
protected fun settingsPutString(key: String, value: String) { settings.putString(key, value) }
|
||||
|
||||
protected fun currentAppUser(): ScaleUser = data.currentUser()
|
||||
protected fun usersForDevice(): List<ScaleUser> = data.usersForDevice()
|
||||
protected fun lastMeasurementFor(userId: Int): ScaleMeasurement? = data.lastMeasurementFor(userId)
|
||||
|
||||
// --- Logging shortcuts (route to LogManager under a single TAG) ------------
|
||||
|
||||
protected fun logD(msg: String) = LogManager.d(TAG, msg)
|
||||
protected fun logI(msg: String) = LogManager.i(TAG, msg)
|
||||
protected fun logW(msg: String) = LogManager.w(TAG, msg, null)
|
||||
protected fun logE(msg: String, t: Throwable? = null) = LogManager.e(TAG, msg, t)
|
||||
|
||||
// Human-readable messages for users (e.g., snackbars/toasts)
|
||||
protected fun userInfo(@StringRes resId: Int, vararg args: Any) {
|
||||
callbacks?.onInfo(resId, *args) ?: logD("userInfo dropped: res=$resId")
|
||||
}
|
||||
protected fun userWarn(@StringRes resId: Int, vararg args: Any) {
|
||||
callbacks?.onWarn(resId, *args) ?: logW("userWarn dropped: res=$resId")
|
||||
}
|
||||
protected fun userError(@StringRes resId: Int, vararg args: Any, t: Throwable? = null) {
|
||||
callbacks?.onError(resId, t, *args) ?: logE("userError dropped: res=$resId", t)
|
||||
}
|
||||
|
||||
protected fun requestUserInteraction(
|
||||
interactionType: UserInteractionType,
|
||||
data: Any?
|
||||
) {
|
||||
callbacks?.onUserInteractionRequired(interactionType, data)
|
||||
?: logW("requestUserInteraction dropped: $interactionType")
|
||||
}
|
||||
|
||||
open suspend fun onUserInteractionFeedback(
|
||||
interactionType: UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any) { /* no-op */ }
|
||||
|
||||
// --- Wiring provided by the adapter ---------------------------------------
|
||||
|
||||
private var transport: Transport? = null
|
||||
private var callbacks: Callbacks? = null
|
||||
private lateinit var settings: DriverSettings
|
||||
private lateinit var data: DataProvider
|
||||
private var _scope: CoroutineScope? = null
|
||||
|
||||
/**
|
||||
* Lifecycle-bound coroutine scope provided by the adapter (cancelled when the communicator
|
||||
* is closed). Handlers that need timeout/fallback coroutines should use this instead of
|
||||
* creating their own scope. Valid after [attach] — i.e. inside onConnected/onNotification.
|
||||
*/
|
||||
protected val scope: CoroutineScope
|
||||
get() = _scope ?: error("ScaleDeviceHandler.scope accessed before attach()")
|
||||
/**
|
||||
* BLE transport the adapter provides. No threading/queueing implied here—
|
||||
* the adapter already serializes and paces I/O.
|
||||
*/
|
||||
interface Transport {
|
||||
fun setNotifyOn(service: UUID, characteristic: UUID)
|
||||
fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean = true)
|
||||
fun read(service: UUID, characteristic: UUID)
|
||||
fun disconnect()
|
||||
fun getPeripheral(): BluetoothPeripheral? = null
|
||||
fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean
|
||||
}
|
||||
|
||||
// ----- DataProvider: live app data the handler can query -----
|
||||
interface DataProvider {
|
||||
/** Currently selected app user (may be null if none). */
|
||||
fun currentUser(): ScaleUser
|
||||
|
||||
/** Fresh snapshot of app users that are relevant for this device. */
|
||||
fun usersForDevice(): List<ScaleUser>
|
||||
|
||||
/** Latest saved measurement for the given user (or null if none). */
|
||||
fun lastMeasurementFor(userId: Int): ScaleMeasurement?
|
||||
}
|
||||
|
||||
interface DriverSettings {
|
||||
fun getInt(key: String, default: Int = -1): Int
|
||||
fun putInt(key: String, value: Int)
|
||||
|
||||
fun getString(key: String, default: String? = null): String?
|
||||
fun putString(key: String, value: String)
|
||||
|
||||
fun remove(key: String)
|
||||
}
|
||||
|
||||
/** App callbacks to emit parsed results and user-visible messages. */
|
||||
interface Callbacks {
|
||||
fun onPublish(measurement: ScaleMeasurement)
|
||||
fun onInfo(@StringRes resId: Int, vararg args: Any) { /* optional */ }
|
||||
fun onWarn(@StringRes resId: Int, vararg args: Any) { /* optional */ }
|
||||
fun onError(@StringRes resId: Int, t: Throwable? = null, vararg args: Any) { /* optional */ }
|
||||
|
||||
fun onUserInteractionRequired(interactionType: UserInteractionType, data: Any?) { /* optional */ }
|
||||
fun resolveString(@StringRes resId: Int, vararg args: Any): String
|
||||
}
|
||||
|
||||
// --- Small utils -----------------------------------------------------------
|
||||
|
||||
/** Pretty print a few leading bytes of a payload for logs. */
|
||||
fun ByteArray.toHexPreview(limit: Int): String {
|
||||
if (limit <= 0 || isEmpty()) return "(payload ${size}b)"
|
||||
val show = min(size, limit)
|
||||
val sb = StringBuilder("payload=[")
|
||||
for (i in 0 until show) {
|
||||
if (i > 0) sb.append(' ')
|
||||
sb.append(String.format("%02X", this[i]))
|
||||
}
|
||||
if (size > limit) sb.append(" …(+").append(size - limit).append("b)")
|
||||
sb.append(']')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* ASCII preview of the first `max` bytes; non-printable bytes are rendered as '?'.
|
||||
*/
|
||||
fun ByteArray.toAsciiPreview(max: Int = 64): String {
|
||||
if (isEmpty()) return ""
|
||||
val n = min(size, max)
|
||||
val sb = StringBuilder(n)
|
||||
for (i in 0 until n) {
|
||||
val ch = (this[i].toInt() and 0xFF).toChar()
|
||||
sb.append(if (ch.isISOControl()) '?' else ch)
|
||||
}
|
||||
if (size > max) sb.append("…(+").append(size - max).append("b)")
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.content.getSystemService
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* SPP (Bluetooth Classic / RFCOMM) adapter that plugs a [ScaleDeviceHandler] into a raw byte stream.
|
||||
*
|
||||
* Tuning usage:
|
||||
* - Reconnect cooldown between attempts (common.reconnectCooldownMs)
|
||||
* - Bounded retry on initial connect (common.maxRetries + common.retryBackoffMs)
|
||||
* - Connect timeout (connectTimeoutMs)
|
||||
* - Chunked writes (writeChunkBytes + interChunkDelayMs)
|
||||
* - Small settle delay after connect (derived from interChunkDelayMs)
|
||||
*/
|
||||
class SppScaleAdapter(
|
||||
context: Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BtSppTuning = profile.forSpp()
|
||||
|
||||
private var sppSocket: BluetoothSocket? = null
|
||||
private var sppReaderJob: Job? = null
|
||||
private var sppIn: InputStream? = null
|
||||
private var sppOut: OutputStream? = null
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
val btManager: BluetoothManager? = context.getSystemService()
|
||||
val adapter: BluetoothAdapter? = btManager?.adapter
|
||||
|
||||
if (adapter == null) {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, context.getString(R.string.bt_error_no_bluetooth_adapter)))
|
||||
return
|
||||
}
|
||||
|
||||
val device: BluetoothDevice = try {
|
||||
adapter.getRemoteDevice(address)
|
||||
} catch (_: Throwable) {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, context.getString(R.string.bt_error_no_device_found)))
|
||||
return
|
||||
}
|
||||
|
||||
_isConnecting.value = true
|
||||
_isConnected.value = false
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// Cooldown between attempts
|
||||
val since = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
if (since in 1 until tuning.common.reconnectCooldownMs) {
|
||||
delay((tuning.common.reconnectCooldownMs - since).milliseconds)
|
||||
}
|
||||
|
||||
safeCancelDiscovery(adapter)
|
||||
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
try {
|
||||
LogManager.i(TAG, "Attempting SPP connection (attempt ${attempt + 1})")
|
||||
val socket = device.createRfcommSocketToServiceRecord(ScaleDeviceHandler.CLASSIC_DATA_UUID)
|
||||
sppSocket = socket
|
||||
|
||||
// --- Connect with manual timeout guard ---
|
||||
var connected = false
|
||||
// Start the blocking connect() in a child job
|
||||
val connectJob = launch(Dispatchers.IO) {
|
||||
socket.connect() // blocking call
|
||||
connected = true
|
||||
LogManager.i(TAG, "SPP connect() succeeded")
|
||||
}
|
||||
// Start a guard that closes the socket if connect takes too long
|
||||
val guardJob = launch(Dispatchers.IO) {
|
||||
val to = tuning.connectTimeoutMs
|
||||
if (to > 0) {
|
||||
delay(to.milliseconds)
|
||||
if (!connected) {
|
||||
LogManager.w(TAG, "Connect timeout reached ($to ms), closing socket")
|
||||
// Force the connect() to abort by closing the socket
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until either connect finishes or guard closes the socket
|
||||
connectJob.join()
|
||||
guardJob.cancel()
|
||||
|
||||
// If connect failed, connect() would have thrown and we’d be in catch{}
|
||||
sppIn = socket.inputStream
|
||||
sppOut = socket.outputStream
|
||||
|
||||
// Small settle delay before wiring the handler
|
||||
val settleDelay = maxOf(50L, tuning.interChunkDelayMs * 3)
|
||||
delay(settleDelay.milliseconds)
|
||||
|
||||
_isConnecting.value = false
|
||||
_isConnected.value = true
|
||||
|
||||
val name = safeDeviceName(device)
|
||||
val addr = safeDeviceAddress(device)
|
||||
LogManager.i(TAG, "Connected to device $name [$addr]")
|
||||
_events.tryEmit(BluetoothEvent.Connected(name, addr))
|
||||
|
||||
// Attach handler
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(sppTransport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
handler.handleConnected(selectedUser)
|
||||
|
||||
// Reader loop (idle-timeout via available()+delay)
|
||||
sppReaderJob = launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(1024)
|
||||
var lastRx = SystemClock.elapsedRealtime()
|
||||
try {
|
||||
while (isActive) {
|
||||
val ins = sppIn ?: break
|
||||
val avail = runCatching { ins.available() }.getOrDefault(0)
|
||||
|
||||
if (avail > 0) {
|
||||
val n = ins.read(buf, 0, min(buf.size, avail))
|
||||
if (n <= 0) break
|
||||
lastRx = SystemClock.elapsedRealtime()
|
||||
val payload = buf.copyOf(n)
|
||||
LogManager.d(TAG, "Received $n bytes from SPP ${payload.toHexPreview(24)}")
|
||||
handler.handleNotification(ScaleDeviceHandler.CLASSIC_DATA_UUID, payload)
|
||||
} else {
|
||||
delay(50.milliseconds)
|
||||
val idle = SystemClock.elapsedRealtime() - lastRx
|
||||
if (tuning.readTimeoutMs > 0 && idle >= tuning.readTimeoutMs) {
|
||||
LogManager.w(TAG, "Read idle timeout reached, disconnecting")
|
||||
sppTransport.disconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
LogManager.w(TAG, "SPP read error: ${t.message}", t)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
val da = safeDeviceAddress(device)
|
||||
LogManager.i(TAG, "Reader loop finished, emitting disconnect")
|
||||
_events.tryEmit(BluetoothEvent.Disconnected(da, "SPP stream closed"))
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
cleanup()
|
||||
doDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// success → exit retry loop
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
attempt++
|
||||
LogManager.e(TAG, "SPP connect failed (attempt $attempt/${tuning.common.maxRetries}): ${t.message}", t)
|
||||
|
||||
if (attempt <= tuning.common.maxRetries) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_reconnecting_try, attempt, tuning.common.maxRetries),
|
||||
address
|
||||
)
|
||||
)
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
safeCancelDiscovery(adapter)
|
||||
continue
|
||||
} else {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, t.message ?: "SPP connect failed"))
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
cleanup()
|
||||
doDisconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun doDisconnect() {
|
||||
sppReaderJob?.cancel(); sppReaderJob = null
|
||||
runCatching { sppIn?.close() }; sppIn = null
|
||||
runCatching { sppOut?.close() }; sppOut = null
|
||||
runCatching { sppSocket?.close() }; sppSocket = null
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = false
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
}
|
||||
|
||||
// --- Transport exposed to the handler --------------------------------------------------------
|
||||
|
||||
private val sppTransport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
// Not applicable for SPP: stream is always "notifying"
|
||||
}
|
||||
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {
|
||||
// RFCOMM is a stream; we still pace writes and chunk large payloads
|
||||
scope.launch(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
LogManager.d(TAG, "Starting write of ${payload.size} bytes to SPP: ${payload.toHexPreview(24)}")
|
||||
val chunk = maxOf(1, tuning.writeChunkBytes)
|
||||
var i = 0
|
||||
while (i < payload.size) {
|
||||
val end = min(i + chunk, payload.size)
|
||||
sppOut?.write(payload, i, end - i)
|
||||
sppOut?.flush()
|
||||
val writtenChunk = payload.copyOfRange(i, end)
|
||||
LogManager.d(TAG, "Wrote chunk ${i / chunk + 1}: ${writtenChunk.toHexPreview(16)}")
|
||||
i = end
|
||||
if (i < payload.size && tuning.interChunkDelayMs > 0) {
|
||||
delay(tuning.interChunkDelayMs.milliseconds)
|
||||
}
|
||||
}
|
||||
LogManager.i(TAG, "Finished writing ${payload.size} bytes to SPP")
|
||||
} catch (t: Throwable) {
|
||||
LogManager.e(TAG, "SPP write failed: ${t.message}", t)
|
||||
appCallbacks.onWarn(R.string.bt_warn_write_failed_status,"SPP",t.message ?: "write failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(service: UUID, characteristic: UUID) {
|
||||
// Not applicable for SPP; reads are handled by the continuous reader loop
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
doDisconnect()
|
||||
}
|
||||
|
||||
override fun getPeripheral(): BluetoothPeripheral? = null
|
||||
|
||||
override fun hasCharacteristic(
|
||||
service: UUID,
|
||||
characteristic: UUID
|
||||
): Boolean {
|
||||
// Not applicable for SPP
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers with defensive permission handling ---------------------------------------------
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeCancelDiscovery(adapter: BluetoothAdapter) {
|
||||
try {
|
||||
if (adapter.isDiscovering) adapter.cancelDiscovery()
|
||||
} catch (se: SecurityException) {
|
||||
LogManager.w(TAG, "cancelDiscovery blocked by missing permission", se)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeDeviceName(device: BluetoothDevice): String =
|
||||
try { device.name } catch (_: SecurityException) { null } ?: "Unknown"
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeDeviceAddress(device: BluetoothDevice): String =
|
||||
try { device.address } catch (_: SecurityException) { "unknown" }
|
||||
}
|
||||
180
app/src/main/java/com/health/openscale/core/data/Enums.kt
Normal file
180
app/src/main/java/com/health/openscale/core/data/Enums.kt
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* FitnessDroid: nedbantad kopia av openScales Enums.kt — bara det som
|
||||
* bluetooth-drivrutinerna behöver (strängresurser/ikoner urplockade).
|
||||
*/
|
||||
package com.health.openscale.core.data
|
||||
|
||||
enum class GenderType {
|
||||
MALE,
|
||||
FEMALE;
|
||||
|
||||
fun isMale(): Boolean {
|
||||
return this == MALE
|
||||
}
|
||||
}
|
||||
|
||||
enum class ActivityLevel {
|
||||
SEDENTARY, MILD, MODERATE, HEAVY, EXTREME;
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
SEDENTARY -> return 0
|
||||
MILD -> return 1
|
||||
MODERATE -> return 2
|
||||
HEAVY -> return 3
|
||||
EXTREME -> return 4
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): ActivityLevel {
|
||||
when (unit) {
|
||||
0 -> return SEDENTARY
|
||||
1 -> return MILD
|
||||
2 -> return MODERATE
|
||||
3 -> return HEAVY
|
||||
4 -> return EXTREME
|
||||
}
|
||||
return SEDENTARY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class WeightUnit {
|
||||
KG, LB, ST;
|
||||
|
||||
override fun toString(): String {
|
||||
when (this) {
|
||||
LB -> return "lb"
|
||||
ST -> return "st"
|
||||
KG -> return "kg"
|
||||
}
|
||||
}
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
LB -> return 1
|
||||
ST -> return 2
|
||||
KG -> return 0
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): WeightUnit {
|
||||
when (unit) {
|
||||
1 -> return LB
|
||||
2 -> return ST
|
||||
else -> return KG
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class MeasurementTypeKey(val id: Int) {
|
||||
WEIGHT(1),
|
||||
BMI(2),
|
||||
BODY_FAT(3),
|
||||
WATER(4),
|
||||
MUSCLE(5),
|
||||
LBM(6),
|
||||
BONE(7),
|
||||
WAIST(8),
|
||||
WHR(9),
|
||||
WHTR(10),
|
||||
HIPS(11),
|
||||
VISCERAL_FAT(12),
|
||||
CHEST(13),
|
||||
THIGH(14),
|
||||
BICEPS(15),
|
||||
NECK(16),
|
||||
CALIPER_1(17),
|
||||
CALIPER_2(18),
|
||||
CALIPER_3(19),
|
||||
CALIPER(20),
|
||||
BMR(21),
|
||||
TDEE(22),
|
||||
HEART_RATE(23),
|
||||
CALORIES(24),
|
||||
DATE(25),
|
||||
TIME(26),
|
||||
COMMENT(27),
|
||||
USER(28),
|
||||
IMPEDANCE(29),
|
||||
IMPEDANCE_LOW(30),
|
||||
ECW(31),
|
||||
ICW(32),
|
||||
PROTEIN(33),
|
||||
BCM(34),
|
||||
CUSTOM(99);
|
||||
}
|
||||
|
||||
enum class MeasureUnit {
|
||||
CM, INCH;
|
||||
|
||||
override fun toString(): String {
|
||||
when (this) {
|
||||
CM -> return "cm"
|
||||
INCH -> return "in"
|
||||
}
|
||||
}
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
CM -> return 0
|
||||
INCH -> return 1
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): MeasureUnit {
|
||||
when (unit) {
|
||||
1 -> return INCH
|
||||
else -> return CM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class UnitType(val displayName: String) {
|
||||
KG("kg"),
|
||||
LB("lb"),
|
||||
ST("st"),
|
||||
PERCENT("%"),
|
||||
CM("cm"),
|
||||
INCH("in"),
|
||||
KCAL("kcal"),
|
||||
BPM("bpm"),
|
||||
OHM("Ω"),
|
||||
NONE("");
|
||||
|
||||
fun isWeightUnit(): Boolean {
|
||||
return this == KG || this == LB || this == ST
|
||||
}
|
||||
|
||||
fun toWeightUnit(): WeightUnit {
|
||||
return when (this) {
|
||||
LB -> WeightUnit.LB
|
||||
ST -> WeightUnit.ST
|
||||
else -> WeightUnit.KG
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/health/openscale/core/data/User.kt
Normal file
39
app/src/main/java/com/health/openscale/core/data/User.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales User-entitet (upstream är en Room-entitet).
|
||||
* Bara fälten som bluetooth-adaptrarnas mapUser() läser.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.health.openscale.core.data
|
||||
|
||||
data class User(
|
||||
val id: Int = 1,
|
||||
val name: String = "",
|
||||
/** Födelsedag i epoch-millis (null = okänd) */
|
||||
val birthDate: Long? = null,
|
||||
/** Längd i cm */
|
||||
val heightCm: Float = -1f,
|
||||
val gender: GenderType = GenderType.MALE,
|
||||
val activityLevel: ActivityLevel = ActivityLevel.SEDENTARY,
|
||||
)
|
||||
|
||||
/** Målvikt m.m. — används av mapUser(); FitnessDroid har inga mål ännu. */
|
||||
data class Goal(
|
||||
val measurementTypeId: Int,
|
||||
val goalValue: Float,
|
||||
)
|
||||
|
||||
/** Mättyp med enhet — används för att avgöra vågens viktenhet. */
|
||||
data class MeasurementType(
|
||||
val id: Int,
|
||||
val key: MeasurementTypeKey,
|
||||
val unit: UnitType,
|
||||
)
|
||||
|
||||
/** Ett enskilt mätvärde. */
|
||||
data class MeasurementValue(
|
||||
val floatValue: Float?,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* FitnessDroid-shims för openScales facades (upstream pratar med Room +
|
||||
* inställningssystem). Här backas de av DataStore respektive appens profil,
|
||||
* med exakt den yta som bluetooth-paketet använder:
|
||||
*
|
||||
* - SettingsFacade: drivrutinernas key/value-inställningar + tuning-profil
|
||||
* - UserFacade: aktuell användare (längd/ålder/kön) som drivrutinerna behöver
|
||||
* - MeasurementFacade: senaste mätningar (används av vissa drivrutiner för
|
||||
* igenkänning av användare) — tom tills vidare
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.health.openscale.core.facade
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.health.openscale.core.data.Goal
|
||||
import com.health.openscale.core.data.MeasurementType
|
||||
import com.health.openscale.core.data.MeasurementTypeKey
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.User
|
||||
import com.health.openscale.core.model.MeasurementWithValues
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.driverDataStore by preferencesDataStore(name = "openscale_driver")
|
||||
|
||||
class SettingsFacade(private val context: Context) {
|
||||
|
||||
/** Sparad BLE-tuning-profil ("Conservative"/"Balanced"/"Aggressive"), null = handlerns default. */
|
||||
val savedBluetoothTuneProfile: Flow<String?> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey("tuning_profile")] }
|
||||
|
||||
/** openScales utvecklarläge (dumpar GATT-trädet i loggen istället för att mäta). */
|
||||
val developerModeEnabled: Flow<Boolean> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey("developer_mode")] == "true" }
|
||||
|
||||
fun observeSetting(key: String, default: Int): Flow<Int> =
|
||||
context.driverDataStore.data.map { it[intPreferencesKey(key)] ?: default }
|
||||
|
||||
fun observeSetting(key: String, default: String): Flow<String> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey(key)] ?: default }
|
||||
|
||||
suspend fun saveSetting(key: String, value: Int) {
|
||||
context.driverDataStore.edit { it[intPreferencesKey(key)] = value }
|
||||
}
|
||||
|
||||
suspend fun saveSetting(key: String, value: String) {
|
||||
context.driverDataStore.edit { it[stringPreferencesKey(key)] = value }
|
||||
}
|
||||
}
|
||||
|
||||
class UserFacade {
|
||||
|
||||
private val selectedUser = MutableStateFlow<User?>(null)
|
||||
|
||||
/** Appen uppdaterar den aktuella användaren (längd/ålder/kön) härifrån. */
|
||||
fun setSelectedUser(user: User?) {
|
||||
selectedUser.value = user
|
||||
}
|
||||
|
||||
fun observeSelectedUser(): StateFlow<User?> = selectedUser
|
||||
|
||||
fun observeAllUsers(): Flow<List<User>> = selectedUser.map { listOfNotNull(it) }
|
||||
|
||||
fun getAllGoalsForUser(userId: Int): Flow<List<Goal>> = flowOf(emptyList())
|
||||
}
|
||||
|
||||
class MeasurementFacade {
|
||||
|
||||
/** Senaste mätningar per användare — används av vissa drivrutiner för användar-igenkänning. */
|
||||
fun getMeasurementsForUser(userId: Int): Flow<List<MeasurementWithValues>> = flowOf(emptyList())
|
||||
|
||||
fun getAllMeasurementTypes(): Flow<List<MeasurementType>> = flowOf(
|
||||
listOf(MeasurementType(MeasurementTypeKey.WEIGHT.id, MeasurementTypeKey.WEIGHT, UnitType.KG))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales MeasurementWithValues (upstream är en
|
||||
* Room-relation). Bara det som bluetooth-adaptrarnas mapMeasurement() läser.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.health.openscale.core.model
|
||||
|
||||
import com.health.openscale.core.data.MeasurementType
|
||||
import com.health.openscale.core.data.MeasurementValue
|
||||
|
||||
data class Measurement(
|
||||
val userId: Int,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
data class ValueWithType(
|
||||
val type: MeasurementType,
|
||||
val value: MeasurementValue,
|
||||
)
|
||||
|
||||
data class MeasurementWithValues(
|
||||
val measurement: Measurement,
|
||||
val values: List<ValueWithType>,
|
||||
)
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.SparseArray
|
||||
import com.health.openscale.core.bluetooth.ScaleFactory
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothCentralManager
|
||||
import com.welie.blessed.BluetoothCentralManagerCallback
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import com.welie.blessed.ScanFailure
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import androidx.core.util.isNotEmpty
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Data class to hold information about a scanned Bluetooth LE device.
|
||||
*
|
||||
* @property name The advertised name of the device. Can be null.
|
||||
* @property address The MAC address of the device.
|
||||
* @property rssi The received signal strength indicator (RSSI) in dBm.
|
||||
* @property serviceUuids A list of service UUIDs advertised by the device.
|
||||
* @property manufacturerData Manufacturer-specific data advertised by the device.
|
||||
* @property serviceData Service-data payloads advertised by the device.
|
||||
* @property isSupported Flag indicating whether openScale has a handler for this device.
|
||||
* @property determinedHandlerDisplayName The display name of the handler determined for this device, if any.
|
||||
*/
|
||||
data class ScannedDeviceInfo(
|
||||
var name: String,
|
||||
val address: String,
|
||||
val rssi: Int,
|
||||
val serviceUuids: List<UUID>,
|
||||
val manufacturerData: SparseArray<ByteArray>?,
|
||||
val serviceData: Map<UUID, ByteArray> = emptyMap(),
|
||||
var isSupported: Boolean = false,
|
||||
var determinedHandlerDisplayName: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Manages Bluetooth LE device scanning operations using the Blessed library.
|
||||
*
|
||||
* This class handles starting, stopping, and processing scan results. It exposes
|
||||
* [StateFlow]s for discovered devices, scanning status, and scan errors, allowing
|
||||
* UI components or ViewModels to observe scanning activity.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param externalScope A [CoroutineScope] (typically from a ViewModel) for launching tasks like scan timeouts.
|
||||
* @param scaleFactory An instance of [ScaleFactory] used to determine device support and handler information.
|
||||
*/
|
||||
class BluetoothScannerManager(
|
||||
private val context: Context,
|
||||
private val externalScope: CoroutineScope,
|
||||
private val scaleFactory: ScaleFactory
|
||||
) {
|
||||
private companion object {
|
||||
const val TAG = "BluetoothScannerMgr"
|
||||
}
|
||||
|
||||
// Ensures Blessed library callbacks are executed on the main thread.
|
||||
private val blessedBluetoothHandler = Handler(Looper.getMainLooper())
|
||||
private val centralManager: BluetoothCentralManager by lazy {
|
||||
BluetoothCentralManager(context, centralManagerCallback, blessedBluetoothHandler)
|
||||
}
|
||||
|
||||
private val _scannedDevices = MutableStateFlow<List<ScannedDeviceInfo>>(emptyList())
|
||||
/**
|
||||
* Emits the current list of discovered and processed [ScannedDeviceInfo] objects.
|
||||
* The list is sorted by support status (supported first), then by RSSI (strongest signal first),
|
||||
* and finally by device name.
|
||||
*/
|
||||
val scannedDevices: StateFlow<List<ScannedDeviceInfo>> = _scannedDevices.asStateFlow()
|
||||
|
||||
private val _isScanning = MutableStateFlow(false)
|
||||
/**
|
||||
* Emits `true` if a Bluetooth LE scan is currently active, `false` otherwise.
|
||||
*/
|
||||
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
|
||||
|
||||
private val _scanError = MutableStateFlow<String?>(null)
|
||||
/**
|
||||
* Emits error messages related to the scanning process.
|
||||
* Emits `null` if there is no current error or an error has been cleared.
|
||||
*/
|
||||
val scanError: StateFlow<String?> = _scanError.asStateFlow()
|
||||
|
||||
private var scanTimeoutJob: Job? = null
|
||||
// Stores unique devices found during a scan, keyed by MAC address, for efficient updates.
|
||||
private val deviceMap = mutableMapOf<String, ScannedDeviceInfo>()
|
||||
|
||||
/**
|
||||
* Starts a Bluetooth LE scan for a specified duration.
|
||||
*
|
||||
* Prerequisites (e.g., Bluetooth enabled, permissions granted) are checked.
|
||||
* If a scan is already in progress, this method returns without action.
|
||||
*
|
||||
* @param scanDurationMs The duration in milliseconds for the scan.
|
||||
* The scan automatically stops after this period if not manually stopped earlier.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // Permissions are expected to be checked by the calling ViewModel.
|
||||
fun startScan(scanDurationMs: Long) {
|
||||
if (!validateScanPrerequisites()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (_isScanning.value || centralManager.isScanning) {
|
||||
LogManager.d(TAG, "Scan is already in progress.")
|
||||
return
|
||||
}
|
||||
LogManager.i(TAG, "Starting device scan for $scanDurationMs ms.")
|
||||
|
||||
deviceMap.clear()
|
||||
_scannedDevices.value = emptyList()
|
||||
_scanError.value = null // Clear previous errors.
|
||||
_isScanning.value = true
|
||||
|
||||
try {
|
||||
centralManager.scanForPeripherals()
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Exception while starting scan: ${e.message}", e)
|
||||
_scanError.value = "Error starting scan: ${e.localizedMessage ?: "Unknown error"}"
|
||||
_isScanning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = externalScope.launch {
|
||||
delay(scanDurationMs.milliseconds)
|
||||
if (_isScanning.value) {
|
||||
LogManager.i(TAG, "Scan timeout reached after $scanDurationMs ms.")
|
||||
stopScanInternal(isTimeout = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently active Bluetooth LE scan.
|
||||
*/
|
||||
fun stopScan() {
|
||||
stopScanInternal(isTimeout = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation for stopping the scan.
|
||||
* @param isTimeout Indicates if the stop was triggered by a timeout.
|
||||
*/
|
||||
private fun stopScanInternal(isTimeout: Boolean) {
|
||||
if (!_isScanning.value && !centralManager.isScanning) {
|
||||
return // Scan not active.
|
||||
}
|
||||
LogManager.i(TAG, "Stopping device scan. Triggered by timeout: $isTimeout")
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = null
|
||||
|
||||
try {
|
||||
if (centralManager.isScanning) {
|
||||
centralManager.stopScan()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Exception while stopping scan: ${e.message}", e)
|
||||
// Optionally, an error could be set here, but it's often not critical for a stop action.
|
||||
}
|
||||
_isScanning.value = false
|
||||
|
||||
if (isTimeout && deviceMap.isEmpty()) {
|
||||
_scanError.value = "No devices found."
|
||||
}
|
||||
LogManager.d(TAG, "Scan stopped. Found devices: ${deviceMap.size}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if conditions are met to start a scan (e.g., Bluetooth enabled).
|
||||
* Note: Permission checks are the responsibility of the calling ViewModel.
|
||||
*
|
||||
* @return `true` if prerequisites are met, `false` otherwise.
|
||||
* If `false`, `_scanError` is updated with the reason.
|
||||
*/
|
||||
private fun validateScanPrerequisites(): Boolean {
|
||||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
|
||||
if (bluetoothManager?.adapter?.isEnabled != true) {
|
||||
LogManager.w(TAG, "Scan prerequisites not met: Bluetooth is disabled.")
|
||||
_scanError.value = "Bluetooth is disabled. Please enable it to scan."
|
||||
return false
|
||||
}
|
||||
|
||||
if (_isScanning.value) {
|
||||
LogManager.d(TAG, "Scan is already in progress (checked in validate).")
|
||||
return false
|
||||
}
|
||||
_scanError.value = null // Clear errors if prerequisites are met.
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any active scan error message from `scanError` StateFlow.
|
||||
*/
|
||||
fun clearScanError() {
|
||||
if (_scanError.value != null) {
|
||||
_scanError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources used by the scanner, including the Blessed [BluetoothCentralManager].
|
||||
* Call this when the scanner is no longer needed (e.g., in ViewModel's `onCleared`).
|
||||
*/
|
||||
fun close() {
|
||||
LogManager.i(TAG, "Closing BluetoothScannerManager.")
|
||||
stopScanInternal(isTimeout = false) // Ensure scan is stopped.
|
||||
try {
|
||||
// Crucial to close BluetoothCentralManager to release system resources
|
||||
// and unregister internal broadcast receivers used by the Blessed library.
|
||||
centralManager.close()
|
||||
LogManager.d(TAG, "Blessed BluetoothCentralManager closed successfully.")
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Error closing Blessed BluetoothCentralManager: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private val centralManagerCallback = object : BluetoothCentralManagerCallback() {
|
||||
@SuppressLint("MissingPermission") // Permissions are handled before scan initiation.
|
||||
override fun onDiscovered(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
val deviceName = peripheral.name
|
||||
val deviceAddress = peripheral.address
|
||||
val rssi = scanResult.rssi
|
||||
val serviceUuids: List<UUID> = scanResult.scanRecord?.serviceUuids?.mapNotNull { it?.uuid } ?: emptyList()
|
||||
val manufacturerData: SparseArray<ByteArray>? = scanResult.scanRecord?.manufacturerSpecificData
|
||||
val serviceData: Map<UUID, ByteArray> = scanResult.scanRecord?.serviceData
|
||||
?.mapKeys { it.key.uuid }
|
||||
?.mapValues { it.value.copyOf() }
|
||||
?: emptyMap()
|
||||
|
||||
val newDevice = ScannedDeviceInfo(
|
||||
name = deviceName,
|
||||
address = deviceAddress,
|
||||
rssi = rssi,
|
||||
serviceUuids = serviceUuids,
|
||||
manufacturerData = manufacturerData,
|
||||
serviceData = serviceData,
|
||||
isSupported = false, // will be determined in the next getSupportingHandlerInfo
|
||||
determinedHandlerDisplayName = null // // will be determined in the next getSupportingHandlerInfo
|
||||
)
|
||||
|
||||
val (isSupported, handlerName) = scaleFactory.getSupportingHandlerInfo(newDevice)
|
||||
|
||||
newDevice.isSupported = isSupported
|
||||
newDevice.determinedHandlerDisplayName = handlerName
|
||||
|
||||
val existingDevice = deviceMap[newDevice.address]
|
||||
var listShouldBeUpdated = false
|
||||
|
||||
if (existingDevice != null) {
|
||||
// Update criteria: if RSSI changed, or if key device info (name, support, handler, services, manufacturer data) has improved or changed.
|
||||
val nameChangedToKnown = newDevice.name.isNotEmpty() && existingDevice.name.isEmpty()
|
||||
val supportStatusImproved = !existingDevice.isSupported && newDevice.isSupported
|
||||
val handlerChanged = newDevice.determinedHandlerDisplayName != existingDevice.determinedHandlerDisplayName
|
||||
val serviceUuidsUpdated = newDevice.serviceUuids.isNotEmpty() && newDevice.serviceUuids != existingDevice.serviceUuids
|
||||
val manuDataUpdated = newDevice.manufacturerData != null && !newDevice.manufacturerData.contentEquals(existingDevice.manufacturerData)
|
||||
val serviceDataUpdated = newDevice.serviceData.isNotEmpty() && !newDevice.serviceData.contentEquals(existingDevice.serviceData)
|
||||
|
||||
if (newDevice.rssi != existingDevice.rssi || nameChangedToKnown || supportStatusImproved || handlerChanged || serviceUuidsUpdated || manuDataUpdated || serviceDataUpdated) {
|
||||
deviceMap[newDevice.address] = existingDevice.copy(
|
||||
name = newDevice.name.ifEmpty { existingDevice.name }, // Prefer new name if available.
|
||||
rssi = newDevice.rssi,
|
||||
isSupported = existingDevice.isSupported || newDevice.isSupported, // Retain 'supported' status if ever true.
|
||||
determinedHandlerDisplayName = newDevice.determinedHandlerDisplayName ?: existingDevice.determinedHandlerDisplayName,
|
||||
serviceUuids = if (newDevice.serviceUuids.isNotEmpty()) newDevice.serviceUuids else existingDevice.serviceUuids,
|
||||
manufacturerData = newDevice.manufacturerData ?: existingDevice.manufacturerData,
|
||||
serviceData = newDevice.serviceData.ifEmpty { existingDevice.serviceData }
|
||||
)
|
||||
listShouldBeUpdated = true
|
||||
}
|
||||
} else {
|
||||
// Add new device if it's supported, or has a meaningful name, or provides service/manufacturer data.
|
||||
// This avoids populating the list with devices that have no identifying information and are not supported.
|
||||
if (newDevice.isSupported ||
|
||||
newDevice.name.isNotEmpty() ||
|
||||
newDevice.serviceUuids.isNotEmpty() ||
|
||||
(newDevice.manufacturerData != null && newDevice.manufacturerData.isNotEmpty()) ||
|
||||
newDevice.serviceData.isNotEmpty()
|
||||
) {
|
||||
deviceMap[newDevice.address] = newDevice
|
||||
listShouldBeUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (listShouldBeUpdated) {
|
||||
// Filter ensures only devices that are supported or have a meaningful name (not generic "Unknown Device") are emitted.
|
||||
// Sorting provides a consistent and user-friendly order.
|
||||
_scannedDevices.value = deviceMap.values
|
||||
.filter { it.isSupported || (it.name.isNotEmpty() && it.name != "Unbekanntes Gerät" && it.name != "Unknown Device") }
|
||||
.sortedWith(compareByDescending<ScannedDeviceInfo> { it.isSupported }
|
||||
.thenByDescending { it.rssi }
|
||||
.thenBy { it.name.ifEmpty { "zzzz" }.lowercase() }) // "zzzz" ensures unnamed devices sort last.
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(scanFailure: ScanFailure) {
|
||||
LogManager.e(TAG, "Bluetooth scan failed: $scanFailure")
|
||||
externalScope.launch {
|
||||
_scanError.value = "Bluetooth Scan Failed: $scanFailure"
|
||||
_isScanning.value = false
|
||||
scanTimeoutJob?.cancel() // Stop scan timeout if scan fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun SparseArray<ByteArray>?.contentEquals(other: SparseArray<ByteArray>?): Boolean {
|
||||
if (this == null || other == null) return this == other
|
||||
if (size() != other.size()) return false
|
||||
for (i in 0 until size()) {
|
||||
val key = keyAt(i)
|
||||
val otherIndex = other.indexOfKey(key)
|
||||
if (otherIndex < 0) return false
|
||||
if (!valueAt(i).contentEquals(other.valueAt(otherIndex))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun Map<UUID, ByteArray>.contentEquals(other: Map<UUID, ByteArray>): Boolean {
|
||||
if (size != other.size) return false
|
||||
for ((key, value) in this) {
|
||||
val otherValue = other[key] ?: return false
|
||||
if (!value.contentEquals(otherValue)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.utils
|
||||
|
||||
import com.health.openscale.core.data.MeasureUnit
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
object ConverterUtils {
|
||||
private const val KG_LB: Float = 2.20462f
|
||||
private const val KG_ST: Float = 0.157473f
|
||||
private const val CM_IN: Float = 0.393701f
|
||||
|
||||
private const val LB_PER_ST_DOUBLE: Double = 14.0
|
||||
|
||||
@JvmStatic
|
||||
fun toKilogram(value: Float, unit: WeightUnit): Float {
|
||||
when (unit) {
|
||||
WeightUnit.LB -> return value / KG_LB
|
||||
WeightUnit.ST -> return value / KG_ST
|
||||
WeightUnit.KG -> return value
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun fromKilogram(kg: Float, unit: WeightUnit): Float {
|
||||
when (unit) {
|
||||
WeightUnit.LB -> return kg * KG_LB
|
||||
WeightUnit.ST -> return kg * KG_ST
|
||||
WeightUnit.KG -> return kg
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun toCentimeter(value: Float, unit: MeasureUnit): Float {
|
||||
when (unit) {
|
||||
MeasureUnit.INCH -> return value / CM_IN
|
||||
MeasureUnit.CM -> return value
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun fromCentimeter(cm: Float, unit: MeasureUnit): Float {
|
||||
when (unit) {
|
||||
MeasureUnit.INCH -> return cm * CM_IN
|
||||
MeasureUnit.CM -> return cm
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun decimalStToStLb(stDec: Double): Pair<Int, Int> {
|
||||
val totalLb = stDec * LB_PER_ST_DOUBLE
|
||||
var st = floor(totalLb / LB_PER_ST_DOUBLE).toInt()
|
||||
var lb = (totalLb - st * LB_PER_ST_DOUBLE).roundToInt()
|
||||
if (lb == 14) { st += 1; lb = 0 } // normalize carry
|
||||
return st to lb
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun stLbToStDecimal(st: Int, lb: Int): Double =
|
||||
st + (lb / LB_PER_ST_DOUBLE)
|
||||
|
||||
@JvmStatic
|
||||
fun fromSignedInt16Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 1].toInt() shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt16Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 8
|
||||
value += data[offset + 1].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt16Le(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt16Le(data, offset) and 0xFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt16Be(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt16Be(data, offset) and 0xFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Le(data: ByteArray, offset: Int, value: Int) {
|
||||
data[offset + 0] = (value and 0xFF).toByte()
|
||||
data[offset + 1] = ((value shr 8) and 0xFF).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Be(data: ByteArray, offset: Int, value: Int) {
|
||||
data[offset + 0] = ((value shr 8) and 0xFF).toByte()
|
||||
data[offset + 1] = (value and 0xFF).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Le(value: Int): ByteArray {
|
||||
val data = ByteArray(2)
|
||||
toInt16Le(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Be(value: Int): ByteArray {
|
||||
val data = ByteArray(2)
|
||||
toInt16Be(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt24Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 2].toInt() shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt24Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset + 2].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt24Le(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt24Le(data, offset) and 0xFFFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt24Be(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt24Be(data, offset) and 0xFFFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt32Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 3].toInt() shl 24
|
||||
value += (data[offset + 2].toInt() and 0xFF) shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt32Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 24
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 16
|
||||
value += (data[offset + 2].toInt() and 0xFF) shl 8
|
||||
value += data[offset + 3].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt32Le(data: ByteArray, offset: Int): Long {
|
||||
return fromSignedInt32Le(data, offset).toLong() and 0xFFFFFFFFL
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt32Be(data: ByteArray, offset: Int): Long {
|
||||
return fromSignedInt32Be(data, offset).toLong() and 0xFFFFFFFFL
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Le(data: ByteArray, offset: Int, value: Long) {
|
||||
data[offset + 3] = ((value shr 24) and 0xFFL).toByte()
|
||||
data[offset + 2] = ((value shr 16) and 0xFFL).toByte()
|
||||
data[offset + 1] = ((value shr 8) and 0xFFL).toByte()
|
||||
data[offset + 0] = (value and 0xFFL).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Be(data: ByteArray, offset: Int, value: Long) {
|
||||
data[offset + 0] = ((value shr 24) and 0xFFL).toByte()
|
||||
data[offset + 1] = ((value shr 16) and 0xFFL).toByte()
|
||||
data[offset + 2] = ((value shr 8) and 0xFFL).toByte()
|
||||
data[offset + 3] = (value and 0xFFL).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Le(value: Long): ByteArray {
|
||||
val data = ByteArray(4)
|
||||
toInt32Le(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Be(value: Long): ByteArray {
|
||||
val data = ByteArray(4)
|
||||
toInt32Be(data, 0, value)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Float value from one UnitType to another, if a conversion is defined.
|
||||
* Returns the original value if no conversion is applicable or units are the same.
|
||||
*
|
||||
* @param value The float value to convert.
|
||||
* @param fromUnit The original UnitType of the value.
|
||||
* @param toUnit The target UnitType for the value.
|
||||
* @return The converted float value, or the original value if no conversion is done.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun convertFloatValueUnit(value: Float, fromUnit: UnitType, toUnit: UnitType): Float {
|
||||
if (fromUnit == toUnit) return value
|
||||
|
||||
// KG -> Andere Gewichtseinheiten
|
||||
if (fromUnit == UnitType.KG) {
|
||||
return when (toUnit) {
|
||||
UnitType.LB -> fromKilogram(value, WeightUnit.LB)
|
||||
UnitType.ST -> fromKilogram(value, WeightUnit.ST)
|
||||
else -> value // Keine Umrechnung zu anderen Typen von KG aus
|
||||
}
|
||||
}
|
||||
// LB -> Andere Gewichtseinheiten (erst zu KG, dann zum Ziel)
|
||||
if (fromUnit == UnitType.LB) {
|
||||
val kgValue = toKilogram(value, WeightUnit.LB)
|
||||
return when (toUnit) {
|
||||
UnitType.KG -> kgValue
|
||||
UnitType.ST -> fromKilogram(kgValue, WeightUnit.ST)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
// ST -> Andere Gewichtseinheiten (erst zu KG, dann zum Ziel)
|
||||
if (fromUnit == UnitType.ST) {
|
||||
val kgValue = toKilogram(value, WeightUnit.ST)
|
||||
return when (toUnit) {
|
||||
UnitType.KG -> kgValue
|
||||
UnitType.LB -> fromKilogram(kgValue, WeightUnit.LB)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
// CM -> Andere Längeneinheiten
|
||||
if (fromUnit == UnitType.CM) {
|
||||
return when (toUnit) {
|
||||
UnitType.INCH -> fromCentimeter(value, MeasureUnit.INCH)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
if (fromUnit == UnitType.INCH) {
|
||||
val cmValue = toCentimeter(value, MeasureUnit.INCH)
|
||||
return when (toUnit) {
|
||||
UnitType.CM -> cmValue
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all non-digit characters from [input] and truncates the result to [maxLen] characters.
|
||||
*
|
||||
* @param input The raw string to sanitize.
|
||||
* @param maxLen The maximum number of digit characters to retain.
|
||||
* @return A string containing only digit characters, at most [maxLen] characters long.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun sanitizeDigits(input: String, maxLen: Int): String =
|
||||
input.filter { it.isDigit() }.take(maxLen)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales LogManager.
|
||||
* Upstream har filloggning m.m. — här räcker Androids vanliga logcat.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.health.openscale.core.utils
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object LogManager {
|
||||
fun d(tag: String, msg: String) { Log.d(tag, msg) }
|
||||
fun i(tag: String, msg: String) { Log.i(tag, msg) }
|
||||
fun w(tag: String, msg: String, t: Throwable? = null) { Log.w(tag, msg, t) }
|
||||
fun e(tag: String, msg: String, t: Throwable? = null) { Log.e(tag, msg, t) }
|
||||
}
|
||||
@@ -1,4 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">FitnessDroid</string>
|
||||
|
||||
<!-- Strängar som de vendrade openScale-drivrutinerna refererar (svenska motsvarigheter
|
||||
till upstreams engelska; formatargumenten måste matcha upstream). -->
|
||||
<string name="bt_error_delivery_user_feedback">Kunde inte leverera användarsvar: %1$s</string>
|
||||
<string name="bt_error_generic">Fel</string>
|
||||
<string name="bt_error_handler_connect_failed">Drivrutinen misslyckades vid anslutning: %1$s</string>
|
||||
<string name="bt_error_handler_parse_error">Drivrutinen kunde inte tolka %1$s: %2$s</string>
|
||||
<string name="bt_error_no_bluetooth_adapter">Ingen Bluetooth-adapter hittades på enheten.</string>
|
||||
<string name="bt_error_no_device_found">Vågen hittades inte. Kontrollera att den är på och inom räckhåll.</string>
|
||||
<string name="bt_error_no_user_selected">Ingen användare vald</string>
|
||||
<string name="bt_info_reconnecting_try">Återansluter… (försök %1$d/%2$d)</string>
|
||||
<string name="bt_info_step_on_scale">Ställ dig barfota på vågen</string>
|
||||
<string name="bt_info_waiting_for_measurement">Väntar på mätning…</string>
|
||||
<string name="bt_warn_notify_failed">Kunde inte aktivera notifieringar för %1$s.</string>
|
||||
<string name="bt_warn_write_failed_status">Skrivning till %1$s misslyckades: %2$s</string>
|
||||
<string name="cap_battery">Batteri</string>
|
||||
<string name="cap_body_composition">Kroppssammansättning</string>
|
||||
<string name="cap_history_read">Historikläsning</string>
|
||||
<string name="cap_live_weight">Livevikt</string>
|
||||
<string name="cap_time_sync">Tidssynk</string>
|
||||
<string name="cap_unit_config">Enhetsval</string>
|
||||
<string name="cap_user_sync">Användarsynk</string>
|
||||
<string name="no_special_configuration_available">Ingen extra konfiguration finns för den här vågen.</string>
|
||||
<string name="tuning_aggressive">Aggressiv</string>
|
||||
<string name="tuning_balanced">Balanserad</string>
|
||||
<string name="tuning_conservative">Försiktig</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.0.21"
|
||||
kotlin = "2.2.0"
|
||||
coreKtx = "1.15.0"
|
||||
lifecycle = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
@@ -9,8 +9,9 @@ navigationCompose = "2.8.5"
|
||||
okhttp = "4.12.0"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
datastore = "1.1.1"
|
||||
room = "2.6.1"
|
||||
ksp = "2.0.21-1.0.28"
|
||||
room = "2.7.2"
|
||||
ksp = "2.2.0-2.0.2"
|
||||
blessedKotlin = "3.0.12"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -31,6 +32,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -16,6 +16,10 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Blessed-Kotlin (BLE-biblioteket som openScale-drivrutinerna använder) finns bara på JitPack
|
||||
maven("https://jitpack.io") {
|
||||
content { includeGroup("com.github.weliem") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user