diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6b156fe
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,675 @@
+GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU 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 .
+
+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
+.
+
+ 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
+.
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9ce61a1..daa19d0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d674e5d..b959d0d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,12 @@
+
+
+
+
+
+ *
+ * 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 .
+ */
+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
+
+ /** Indicates whether a GATT connection is active. For broadcast-only devices this is always `false`. */
+ val isConnected: StateFlow
+
+ /** 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
+
+ /** Deliver feedback for a previously requested user interaction. */
+ suspend fun processUserInteractionFeedback(
+ interactionType: BluetoothEvent.UserInteractionType,
+ appUserId: Int,
+ feedbackData: Any
+ )
+}
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt
new file mode 100644
index 0000000..8919ce4
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt
@@ -0,0 +1,172 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ *
+ * 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 = 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 = 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 readSettingBlocking(flow: Flow): 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 {
+ for (handler in modernKotlinHandlers) {
+ val support = handler.supportFor(deviceInfo)
+ if (support != null) return true to support.displayName
+ }
+
+ return false to null
+ }
+}
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt b/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt
new file mode 100644
index 0000000..cc5d962
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt
@@ -0,0 +1,75 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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
+ }
+}
+
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleUser.kt b/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleUser.kt
new file mode 100644
index 0000000..03668da
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleUser.kt
@@ -0,0 +1,65 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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
+ }
+}
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/scales/BroadcastScaleAdapter.kt b/app/src/main/java/com/health/openscale/core/bluetooth/scales/BroadcastScaleAdapter.kt
new file mode 100644
index 0000000..27549c4
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/scales/BroadcastScaleAdapter.kt
@@ -0,0 +1,249 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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(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
+ }
+}
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/scales/DebugGattHandler.kt b/app/src/main/java/com/health/openscale/core/bluetooth/scales/DebugGattHandler.kt
new file mode 100644
index 0000000..90c1d86
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/scales/DebugGattHandler.kt
@@ -0,0 +1,193 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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()
+ 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("|")
+ }
+}
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExingtechY1Handler.kt b/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExingtechY1Handler.kt
new file mode 100644
index 0000000..c9aa881
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExingtechY1Handler.kt
@@ -0,0 +1,138 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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}"
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt b/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt
new file mode 100644
index 0000000..859a5b5
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt
@@ -0,0 +1,428 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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 Unit>(Channel.UNLIMITED)
+
+ private data class PendingOp(
+ val id: Long,
+ val deferred: CompletableDeferred
+ )
+
+ private val deferredMap = ConcurrentHashMap()
+ 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()
+ 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()
+ 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()
+ 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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt
new file mode 100644
index 0000000..6ead74f
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt
@@ -0,0 +1,552 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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()
+
+ 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(replay = 1, extraBufferCapacity = 8)
+ override fun getEventsFlow(): SharedFlow = _events.asSharedFlow()
+
+ protected val _isConnecting = MutableStateFlow(false)
+ override val isConnecting: StateFlow = _isConnecting.asStateFlow()
+
+ protected val _isConnected = MutableStateFlow(false)
+ override val isConnected: StateFlow = _isConnected.asStateFlow()
+
+ // ---- app snapshots for handler.DataProvider -------------------------------------------------
+ @Volatile protected var selectedUserSnapshot: ScaleUser? = null
+ @Volatile protected var usersSnapshot: List = emptyList()
+ @Volatile protected var lastSnapshot: Map = 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(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 = 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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt
new file mode 100644
index 0000000..7387a50
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt
@@ -0,0 +1,410 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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,
+ val implemented: Set,
+ 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 = 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
+
+ /** 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()
+ }
+}
diff --git a/app/src/main/java/com/health/openscale/core/bluetooth/scales/SppScaleAdapter.kt b/app/src/main/java/com/health/openscale/core/bluetooth/scales/SppScaleAdapter.kt
new file mode 100644
index 0000000..ff4d17c
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/bluetooth/scales/SppScaleAdapter.kt
@@ -0,0 +1,324 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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" }
+}
diff --git a/app/src/main/java/com/health/openscale/core/data/Enums.kt b/app/src/main/java/com/health/openscale/core/data/Enums.kt
new file mode 100644
index 0000000..c759260
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/data/Enums.kt
@@ -0,0 +1,180 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ *
+ * 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
+ }
+ }
+}
diff --git a/app/src/main/java/com/health/openscale/core/data/User.kt b/app/src/main/java/com/health/openscale/core/data/User.kt
new file mode 100644
index 0000000..548b595
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/data/User.kt
@@ -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?,
+)
diff --git a/app/src/main/java/com/health/openscale/core/facade/Facades.kt b/app/src/main/java/com/health/openscale/core/facade/Facades.kt
new file mode 100644
index 0000000..8a03a6a
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/facade/Facades.kt
@@ -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 =
+ 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 =
+ context.driverDataStore.data.map { it[stringPreferencesKey("developer_mode")] == "true" }
+
+ fun observeSetting(key: String, default: Int): Flow =
+ context.driverDataStore.data.map { it[intPreferencesKey(key)] ?: default }
+
+ fun observeSetting(key: String, default: String): Flow =
+ 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(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 = selectedUser
+
+ fun observeAllUsers(): Flow> = selectedUser.map { listOfNotNull(it) }
+
+ fun getAllGoalsForUser(userId: Int): Flow> = 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> = flowOf(emptyList())
+
+ fun getAllMeasurementTypes(): Flow> = flowOf(
+ listOf(MeasurementType(MeasurementTypeKey.WEIGHT.id, MeasurementTypeKey.WEIGHT, UnitType.KG))
+ )
+}
diff --git a/app/src/main/java/com/health/openscale/core/model/MeasurementWithValues.kt b/app/src/main/java/com/health/openscale/core/model/MeasurementWithValues.kt
new file mode 100644
index 0000000..bfdda7b
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/model/MeasurementWithValues.kt
@@ -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,
+)
diff --git a/app/src/main/java/com/health/openscale/core/service/BleScanner.kt b/app/src/main/java/com/health/openscale/core/service/BleScanner.kt
new file mode 100644
index 0000000..42d91f6
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/service/BleScanner.kt
@@ -0,0 +1,357 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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,
+ val manufacturerData: SparseArray?,
+ val serviceData: Map = 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>(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> = _scannedDevices.asStateFlow()
+
+ private val _isScanning = MutableStateFlow(false)
+ /**
+ * Emits `true` if a Bluetooth LE scan is currently active, `false` otherwise.
+ */
+ val isScanning: StateFlow = _isScanning.asStateFlow()
+
+ private val _scanError = MutableStateFlow(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 = _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()
+
+ /**
+ * 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 = scanResult.scanRecord?.serviceUuids?.mapNotNull { it?.uuid } ?: emptyList()
+ val manufacturerData: SparseArray? = scanResult.scanRecord?.manufacturerSpecificData
+ val serviceData: Map = 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 { 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?.contentEquals(other: SparseArray?): 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.contentEquals(other: Map): 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
+}
diff --git a/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt b/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt
new file mode 100644
index 0000000..8e9d01e
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt
@@ -0,0 +1,266 @@
+/*
+ * openScale
+ * Copyright (C) 2025 olie.xdev
+ *
+ * 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 .
+ */
+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 {
+ 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)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/health/openscale/core/utils/LogManager.kt b/app/src/main/java/com/health/openscale/core/utils/LogManager.kt
new file mode 100644
index 0000000..c5089f7
--- /dev/null
+++ b/app/src/main/java/com/health/openscale/core/utils/LogManager.kt
@@ -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) }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fa785bf..c8a1f95 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,30 @@
FitnessDroid
+
+
+ Kunde inte leverera användarsvar: %1$s
+ Fel
+ Drivrutinen misslyckades vid anslutning: %1$s
+ Drivrutinen kunde inte tolka %1$s: %2$s
+ Ingen Bluetooth-adapter hittades på enheten.
+ Vågen hittades inte. Kontrollera att den är på och inom räckhåll.
+ Ingen användare vald
+ Återansluter… (försök %1$d/%2$d)
+ Ställ dig barfota på vågen
+ Väntar på mätning…
+ Kunde inte aktivera notifieringar för %1$s.
+ Skrivning till %1$s misslyckades: %2$s
+ Batteri
+ Kroppssammansättning
+ Historikläsning
+ Livevikt
+ Tidssynk
+ Enhetsval
+ Användarsynk
+ Ingen extra konfiguration finns för den här vågen.
+ Aggressiv
+ Balanserad
+ Försiktig
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a00639d..ed05d4c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 22cd9b0..06f509f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -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") }
+ }
}
}