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") } + } } }