From 4c911ff9d258113e1d7c2343974ada1318e38226 Mon Sep 17 00:00:00 2001 From: OliE Date: Sat, 18 Jul 2026 08:49:37 +0200 Subject: [PATCH 01/72] Weblate update (#1425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Japanese) Currently translated at 97.2% (779 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/ja/ * Translated using Weblate (Spanish) Currently translated at 100.0% (801 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/es/ * Translated using Weblate (Russian) Currently translated at 92.1% (738 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/ru/ * Translated using Weblate (German) Currently translated at 100.0% (801 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/de/ * Added translation using Weblate (Catalan) * Translated using Weblate (Swedish) Currently translated at 88.7% (711 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/sv/ * Translated using Weblate (Catalan) Currently translated at 8.8% (71 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/ca/ * Translated using Weblate (Swedish) Currently translated at 89.1% (714 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/sv/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (801 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/pt_BR/ * Translated using Weblate (Bulgarian) Currently translated at 100.0% (801 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/bg/ * Translated using Weblate (French) Currently translated at 87.6% (702 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/fr/ * Translated using Weblate (Bulgarian) Currently translated at 100.0% (801 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/bg/ * Translated using Weblate (Swedish) Currently translated at 93.0% (745 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/sv/ * Translated using Weblate (Swedish) Currently translated at 96.0% (769 of 801 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/sv/ --------- Co-authored-by: apemay Co-authored-by: Isaac Clark Co-authored-by: Holger Eisold Co-authored-by: Eduard Ereza Martínez Co-authored-by: Joel A Co-authored-by: Luan M. D. Lima Co-authored-by: Viktoria PETROVA Co-authored-by: François Kabiry --- .../app/src/main/res/values-bg/strings.xml | 180 +++++++++++++----- .../app/src/main/res/values-ca/strings.xml | 45 +++++ .../app/src/main/res/values-de/strings.xml | 71 +++++-- .../app/src/main/res/values-es/strings.xml | 2 + .../app/src/main/res/values-fr/strings.xml | 31 ++- .../app/src/main/res/values-ja/strings.xml | 4 +- .../src/main/res/values-pt-rBR/strings.xml | 2 + .../app/src/main/res/values-ru/strings.xml | 52 ++++- .../app/src/main/res/values-sv/strings.xml | 69 ++++++- 9 files changed, 367 insertions(+), 89 deletions(-) create mode 100644 android_app/app/src/main/res/values-ca/strings.xml diff --git a/android_app/app/src/main/res/values-bg/strings.xml b/android_app/app/src/main/res/values-bg/strings.xml index 019539cdd..f394d7fd4 100644 --- a/android_app/app/src/main/res/values-bg/strings.xml +++ b/android_app/app/src/main/res/values-bg/strings.xml @@ -2,28 +2,28 @@ Лого на приложението Празно - Не налично + Неналично Светла Тъмна Приложи Отмяна Изчисти Потвърди - ОК + Ок Запазено Включи Изключи Отвори връзка - Символ за предупреждение - Символ за възстановяване + Икона за предупреждение + Възстанови икона Информация Отвори меню Назад Избрано Изчисляване на получените стойности … - Стойностите са успешно изчислени. - Получи се грешка. - Не се заредиха данните. + Стойностите са успешно обновени. + Възникна грешка. + Данните не успяха да се заредят. Опитайте отново. Няма налични данни. Няма налични измервания. @@ -31,9 +31,9 @@ Обзор Диаграми Таблица - Статистика + Статистики Настройки - Детайли + Подробности Ново измерване Редактиране на измерването Относно @@ -43,18 +43,18 @@ Добавяне на потребител Потребители Управление на потребителите - Смени потребителя. Текущия е: %1$s + Смяна на потребителя. Текущият е: %1$s Добави потребител Неуспешно избиране на потребител. Не е избран потребител. - Моля първо изберете потребител. + Моля, първо изберете потребител. Няма активен потребител - Тип на измерванията Индикатор за избрания потребител Моля направете нов потребител или изберете съществуващ, за да видите и управлявате измерванията. Потребител %1$s беше изтрит успешно. - Грешка при триене на потребител %1$s. + Грешка при изтриване на потребител %1$s. Потребител %1$s беше успешно редактиран. Грешка при обновяване на потребител %1$s. Име @@ -75,24 +75,24 @@ Десен крак Ниво на активност Дата на раждане - Моля въведете валидна дата + Моля въведете валидни данни %.1f см Възраст: %1$d, Пол: %2$s Редактиране на потребител Изтриване на потребител Добави нов потребител Промяна на мерната единица - Измерването е успешно обновено - Измерването е успешно запазено - Грешка при запазване на измерване - Измерването %1$s беше успешно изтрито - Грешка при триенето на измерването + Измерването е обновено (%1$s) + Измерването е запазено (%1$s) + Неуспешно запазване на измерването. Моля, опитайте отново. + Измерването %1$s беше изтрито + Неуспешно изтриване на измерването. Моля, опитайте отново. Запази измерването Добави измерване Добави ново измерване Редактиране на измерването от %1$s Изтрий измерването от %1$s - Този момент съвпада твоето последно измерване. Моля изберете друг момент. + Това време съвпада с вашето последно измерване. Моля изберете друг момент. Все още няма измервания Добави твоите първи измервания за проследяването на успеха. В този запис няма активни стойности. @@ -111,9 +111,9 @@ Минута Дата Време - Повишение %1$s - Намаление %1$s - Редактиране %1$s + Повиши %1$s + Намали %1$s + Редактирай %1$s Покажи по-малко Покажи повече Стойността е увеличена @@ -123,7 +123,7 @@ Икона за неизвестен тип на измерване: %1$s Неизвестен тип: %1$s Тегло - ИМТ + ИТМ Телесни мазнини Вода Мускули @@ -155,7 +155,7 @@ Изтриване Редактиране на цел: %1$s Поставяне на цел: %1$s - Няма типове налични за поставяне на цели. + Няма налични типове за поставяне на цели. Избор на тип Целева стойност за %1$s Целевата стойност не може да е празна за нова цел. @@ -208,20 +208,20 @@ Еталонен модел от 2000 за не азиатско население. Полезна основа; точността варира в зависимост от типа телосложение. Gallagher (Азиатски) Съпътстващ модел, адаптиран за азиатско население. Използвайте при по-близко съответствие до потребителя. - Оценка на съдържанието на течности в тялото чрез Behnke + Оценка на съдържанието на течности в тялото чрез Бенке Класическа оценка на общо водата в тялото. Прост подход; не е индивидуализиран за атлети или специфични случаи. - Delwaide и Crenier + Делуейд и Крениер Формула за обем на водата в тялото, акцентираща на телесната маса. Разумна обща оценка; точността варира спрямо телосложението. - Формула за обем на водата в тялото на Hume–Weyers + Формула за обем на водата в тялото на Хум-Уеърс Общ модел за маса на водата в тялото спрямо височина и тегло с коефициент за пол. - Формула на Lee–Song–Kim + Формула на Лий-Сонг-Ким Късна оценка на маса на водата в тялото за корейско население. Подходящо при съвпадаща популация. - Boer LBM (клиническа) + Боер LBM (клиническа) Оценка на мускулна маса в клинични условия. Регресия от височина и тегло спрямо пола. Hume формула за смятане на не мазнината част от тялото Тегло − Телесни мазнини - U.S. Navy метод - Смята процента мазнини базирано на талия, врат и (за жени) бедра спрямо стандартите на U.S. Navy. + U.S Navy метод + Изчислява процента на телесните мазнини въз основа на обиколката на талията, врата и (при жените) бедрата, съгласно стандарта на U.S. Navy. %1$d: %2$s Потвърждаване на %1$s? Искате ли да %2$s%1$d елементи? @@ -236,7 +236,7 @@ Преместено към дясната ос Преместено към лявата ос Неуспешно връзване към кантара, моля проверете дали е включен. - запазен кантар + запазена везна Bluetooth обработване Настройка на Bluetooth везна Изключено от %1$s @@ -265,9 +265,9 @@ %1$s не е поддържан в момента. Не са намерени устройства. Стартиране на ново сканиране. Асистирано претегляне - Изберете референтно лице за %1$s + Изберете отговорно лице за %1$s Няма подходящи лица за справка. Моля, създайте потребител, който не използва асистирано претегляне. - За асистирано претегляне трябва да бъде избрано референтно лице. + За асистирано претегляне трябва да бъде избрано отговорно лице. На дясната Y ос Бутон за търсене на везна Индикатор за запазена везна @@ -277,10 +277,10 @@ Не се поддържа %1$d dBm Запази като устройство по подразбиране - Запази в режим на разработчик + Запази в режим на програмист Запазеното устройство е премахнато. Подбедрица - Изберете ампутирани крайници и укажете нивото на ампутираност. + Изберете засегнатите крайници и посочете нивото на ампутация за всяки от тях. КС Съотношение талия-ханш Отношението на талията към височината @@ -338,13 +338,13 @@ Общи настройки Изтриване на цялата база данни Активиране на автоматични резервни копия - Режимът за разработчици е активиран. Моля, активирайте лог файла: Настройки → Общи → Активиране на лог файла. + Режимът за програмисти е активиран. Моля, активирайте лог файла: Настройки → Общи → Активиране на лог файла. Експортиране на измервания (CSV) Опасна зона Избрана директория - Режимът за разработчици е активиран за това устройство. Моля, активирайте лог файла: Настройки → Общи → Активиране на лог файла. + Режимът за програмисти е активиран за това устройство. Моля, активирайте лог файла: Настройки → Общи → Активиране на лог файла. Грешка при изтриването на елементи. - Режимът за разработчици е активен — няма да се запазват никакви измервания. + Режимът за програмисти е активен — няма да се запазват никакви измервания. Моля, активирайте лог файла: Настройки → Общи → Активиране на лог файла. Състав на тялото Синхронизиране на времето @@ -362,10 +362,10 @@ Bluetooth измерване Умно разпределение на потребителя Толеранс - Игнорирайте несигурните измервания + Игнорирай несигурните измервания Ако дадено измерване е извън допустимото отклонение за всички потребители, то няма да бъде запазено. Действия на устройство - Режим за разработчици + Режим за програмисти Премахване на запазено устройство Профил за Bluetooth настройка Балансиран @@ -403,7 +403,7 @@ Bluetooth е изключен Моля, активирайте Bluetooth за търсене на везни. Включване на Bluetooth - Error icon + Икона за грешка Важно предупреждение openScale е проект с отворен код и не притежавам всяка везна. Моля, обърнете внимание, че съвместимостта не може да бъде гарантирана във всички случаи, тъй като Bluetooth протоколите може да се променят или да не са документирани.\n\nКато проект с отворен код, pull request и приноси за подобряване на поддръжката на везните са добре дошли! Потвърди и запази @@ -467,15 +467,15 @@ Сигурни ли сте, че искате да изтриете избраните %1$d елемента? Това действие не може да бъде отменено. Изтриване на тип? Искате да изтриете персонализирания тип „%1$s“? Всички свързани измервания също ще бъдат изтрити за постоянно. Това действие не може да бъде отменено. - %1$d елемента са изтрити успешно. - Изберете потребител за присвояване + %1$d елемента са изтрити + Изберете потребител за задачата Няма други потребители за смяна. - Потребителят е променен успешно за %1$d елемента. + %1$d измервания са преместени Грешка при промяната на потребителя за някои елементи. Твоя %1$s анализ базирано на %1$d измервания - Краткосрочна тенденция (30 дни) - Дългосрочна тенденция + краткосрочна тенденция + дългосрочна тенденция Нужни са поне %1$d измервания за анализ на промените в тялото с течение на времето. Необходими са поне %1$d измервания на ден за откриване на седмични навици. Необходими са поне %1$d години данни, за да се открият сезонни модели. @@ -494,7 +494,7 @@ Седмични навици Има тенденция за високи измервания %1$s и ниски %2$s Предварителен модел — измервайте по-редовно за надеждни резултати - Преглед на годината + Вашата година накратко Вашите стойности са склонни да достигат пик в %1$s и да спадат в %2$s Само една година данни — продължете да измервате за разкриване на сезонни модели Необичайни измервания @@ -518,7 +518,7 @@ Без изглаждане Експоненциално изглаждане Показване на проекция - Базис за проекция (дни) + Основа за проекция (дни) Проекция за (дни) Модел на проекцията Линеен @@ -688,11 +688,11 @@ Няма периферно устройство за четене (%1$s) Записването в %1$s не бе успешно: %2$s Състоянието на известяване е неуспешно за %1$s: %2$s - Избор на дни + Дни по избор По подразбиране: Папка за приложението Избор на интервал на резервното копие Създаване на нов потребител в кантара (слот, зададен от UDS) - Прозрения + Анализи Все още няма достатъчно данни за изчисляване на прозрения. Продължете да измервате! Филтриране на данните за диаграми Няма налични типове диаграми за начертаване. @@ -701,4 +701,82 @@ Икона за времеви диапазон Лог във файл Икона за лог във файлове + Последната промяна не беше синхронизирана. Моля отворете openScale-sync и направете ръчна синхронизация. + Отвори + Потребител %1$s е добавен успешно. + Не е запазено - измерването вече съществува в (%1$s). + Отказ + Плъзнете, за да промените размера на диаграмата или докоснете, за да я свиете + Докоснете, за да видите диаграмата + Отхвърляне на промените? + Промените не са запазени и ще бъдат загубени. + Отхвърли + Продължаване на редакцията + Импеданс (висок) + Импеданс (нисък) + Извънклетъчна вода + Вътрешно клетъчна вода + Протеин + Телесна клетъчна маса + Костна структура + Регресия, базирана на импеданс (Наследствена) + Хеймсфийлд (антропометричен) + Формула за базален метаболизъм (BMR) + Кънингам 1991 + Кънингам 1980 + Настройка на Bluetooth: %1$s + Грешка: Получен е невалиден товар от данни за взаимодействие с потребителя. + Не можаха да бъдат изтрити %1$d от %2$d измервания. + Не можа да се премести %1$d от %2$d измервания (вече съществуват за този потребител). + Вашият %1$s анализ + Рекопозиция + Покачване на маса + Смесена загуба + Покачване на мазнина + Необходими са поне %1$d измервания, за да се анализира как тялото ви се е променило с течение на времето. + Скорост/месец + Няма съществена промяна от %1$d дни — тялото ви може би се адаптира. + Скорошна низходяща тенденция — положителна промяна в сравнение с дългосрочната ви посока. + Скорошна възходяща тенденция — следете дългосрочната посока. + Открити са високи колебания — това е често срещано явление и може да отразява естествени промени в тялото, като например задържане на вода. + Вашите стойности са много последователни — основата е на надеждни данни. + Вашият телесен състав + За изчисляването на тази информация са необходими поне %1$d измервания по всичките четири показателя (тегло, телесни мазнини, мускулна маса, вода) през последните %2$d дни. + Теглото и мазнините намаляват, докато мускулната маса се запазва или дори леко нараства — губите предимно мазнинна маса. + Тялото ви се преобразува ефективно: губите мазнини и едновременно с това натрупвате значителна мускулна маса. + Натрупвате мускулна маса, докато телесните мазнини остават на същото ниво или намаляват — положителна тенденция в състава на тялото. + Предупреждение: Отслабвате, но мускулната ви маса също намалява. + Мазнинната маса се увеличава. Ако това не е целенасочено натрупване на мускулна маса, обмислете да преразгледате нивото на физическата си активност. + И мазнините, и мускулите нарастват — изглежда, че сте във фаза на натрупване на маса. Ако целта ви е да натрупате мускулна маса, преценете дали увеличението на мазнините е в рамките на целевия ви диапазон. + Всички показатели са стабилни — намирате се във фаза на поддръжка. + Все още не е установена ясна тенденция — необходими са допълнителни измервания, включително на теглото, телесните мазнини, мускулната маса и водното съдържание. + Противоречиви данни в %1$s — тенденциите при теглото, мазнините и мускулната маса все още не следват ясна закономерност. + Мазнините намаляват, а мускулната маса се увеличава — най-добрият възможен резултат. + Увеличават се както мазнините, така и мускулите. + Намаляват както мазнините, така и мускулите. Теглото спада, но се губи и мускулна маса. + Мазнините се увеличават, докато мускулната маса намалява или остава непроменена — най-неблагоприятният резултат. + Начало + Сега + Скорост/месец + средно + стабилно + умерено + лесно изпаряваш се + плато %1$d дни + Показване на точките с данни + Размер на прозореца + Средно движение + Поддържано от + Поддържано от икона + Симулация: Потребителят избрира папка + Изтрий икона + Възникна грешка при свързването с хендлъра: %1$s + Грешка при анализа на обработващия модул за %1$s: %2$s + Слот P%1$d: %2$s (Текущ потребител на приложението) + Слот P%1$d: Използва се от %2$s (Да изберете ли да го поемете?) + Слот P%1$d: Зает (некартографиран) + P%1$d: %2$s (Настоящ) + P%1$d: Използва се от %2$s (Да го поема?) + P%1$d: Зает (без връзка) + P%1$d: Създайте „%2$s“ тук diff --git a/android_app/app/src/main/res/values-ca/strings.xml b/android_app/app/src/main/res/values-ca/strings.xml new file mode 100644 index 000000000..55574b833 --- /dev/null +++ b/android_app/app/src/main/res/values-ca/strings.xml @@ -0,0 +1,45 @@ + + + El darrer canvi no s’ha sincronitzat. Obriu l’openScale-sync i feu una sincronització manual. + Obre + Logo de l’aplicació + Cap + No disponible + - + Clar + Fosc + Activa + Cancel·la + Esborra + Confirma + D’acord + S’ha desat + Activat + Desactivat + Obre l’enllaç + Icona d’advertència + Icona de restauració + Informació + Obre el menú + Endarrere + Seleccionat + S’estan calculant els valors derivats… + Els valors derivats s’han actualitzat correctament. + S’ha produït un error. + No s’han pogut carregar les dades. + Torneu-ho a provar. + No hi ha dades disponibles. + No s’ha trobat cap mesura. + No hi ha cap element disponible. + Visió general + Gràfiques + Taula + Estadístiques + Anàlisis + Configuració + Detalls + Set + Nova mesura + Edita la mesura + Quant a + diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index 4a686f763..d03dba20b 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -96,11 +96,11 @@ Neuen Benutzer hinzufügen Einheit ändern - Messung erfolgreich aktualisiert - Messung erfolgreich gespeichert - Fehler beim Speichern der Messung - Messung %1$s erfolgreich gelöscht - Fehler beim Löschen der Messung + Messung aktualisiert (%1$s) + Messung gespeichert (%1$s) + Konnte Messung nicht speichern. Bitte erneut versuchen. + Messung gelöscht (%1$s) + Konnte Messung nicht löschen. Bitte erneut versuchen. Messung speichern Messung hinzufügen Neue Messung hinzufügen @@ -234,7 +234,7 @@ Diese Messgröße wird nicht automatisch berechnet. Werte können manuell eingegeben werden. Klassische Regression (Erw.) Weit verbreitete Bevölkerungsformel von 1991. Für die Allgemeinbevölkerung gedacht; bei sehr schlanken oder sehr muskulösen Personen ggf. ungenauer. - Angepasst für Jugend & Erw. + Angepasst für Jugend & Erwachsene Variante mit Anpassung für jüngere Nutzer. Allgemeine Schätzung; sportliche oder atypische Körperbauten können abweichen. Frühes, geschlechtsspez. Modell Ältere Regression mit getrennten Koeffizienten für Männer und Frauen. Einfach, aber nicht für Spezialpopulationen optimiert. @@ -452,18 +452,18 @@ Möchten Sie die ausgewählten %1$d Elemente wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. Typ löschen? Möchtest du den benutzerdefinierten Typ „%1$s“ wirklich löschen? Alle zugehörigen Messungen werden ebenfalls endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. - %1$d Elemente erfolgreich gelöscht. + %1$d Messungen gelöscht. Nutzer für Zuweisung auswählen Keine anderen Nutzer vorhanden, zu denen gewechselt werden kann. - Nutzer für %1$d Elemente erfolgreich geändert. + %1$d Messungen verschoben Begrenzte Datenbasis Rekomposition Aufbauphase Gemischter Verlust Fettzunahme - Kurzfristiger Trend (30T) - Langfristiger Trend + Kurzfristig + Langfristig Mindestens %1$d Messungen pro Wochentag sind erforderlich, um wöchentliche Gewohnheiten zu erkennen. Mindestens %1$d Jahre an Daten sind erforderlich, um saisonale Muster zu erkennen. Mindestens %1$d Messungen sind erforderlich, um ungewöhnliche Werte zu erkennen. @@ -483,7 +483,7 @@ Vorläufiges Muster — miss regelmäßiger für verlässliche Ergebnisse. Dein Jahr im Überblick Deine Werte erreichen tendenziell ihren Höchststand im %1$s und ihren Tiefststand im %2$s. - Nur ein Jahr an Daten — miss weiter, um saisonale Muster zu erkennen. + Nur ein Jahr an Daten — miss weiter, um saisonale Muster zu erkennen Ungewöhnliche Messungen Keine ungewöhnlichen Messungen erkannt. %1$s vom erwarteten Wert %2$s @@ -712,10 +712,10 @@ Diese Waage wurde nicht gekoppelt!\n\nHalten Sie die Taste an der Unterseite der Waage gedrückt, um sie in den Kopplungsmodus zu versetzen, und verbinden Sie sich dann erneut, um das Gerätepasswort abzurufen. Kopplung erfolgreich!\n\nVerbinden Sie sich erneut, um Messdaten abzurufen. Neuen Benutzer auf der Waage erstellen. - Niedriger Batteriestand (%d%%), bitte Batterien der Waage aufladen oder ersetzen. - Bitte stellen Sie sich barfuß auf die Waage für Referenzmessungen. + Niedriger Batteriestand (%d%%), bitte Batterien der Waage aufladen oder ersetzen + Bitte stelle Dich barfuß auf die Waage für Referenzmessungen Gewichtsmessung: %.2f - Maximale Anzahl gleichzeitiger Waagenbenutzer erreicht. + Maximale Anzahl gleichzeitiger Waagenbenutzer erreicht Auslesen der openScale Daten, inkl. Benutzerinformationen und aller gespeicherten Messungen Lese- und Schreibzugriff auf openScale Daten Benutzer auf Waage auswählen @@ -784,8 +784,49 @@ P%1$d: Verwendet von %2$s (Übernehmen?) P%1$d: Belegt (Nicht verknüpft) P%1$d: \'%2$s\' hier erstellen - Die letzte Änderung wurde nicht synchronisiert. Bitte öffne openScale-sync und führe eine manuelle Synchronisierung durch. + Die letzte Änderung wurde nicht synchronisiert. Bitte öffne OpenScale-Synchronisation und führe eine manuelle Synchronisation aus. Öffnen Gespeichert Benutzer %1$s erfolgreich hinzugefügt. + Auswertungen + Nicht gespeichert - eine Messung existiert bereits am %1$s. + Rückgängig + Ziehen um das Diagramm zu vergrößern, oder Tippen um es zu verkleinern + Tippen zum Anzeigen des Diagramms + Änderungen verwerfen? + Deine Änderungen wurden nicht gesichert und werden verloren gehen. + Verwerfen + Weiter bearbeiten + Puls + Impedanz (hoch) + Impedanz (niedrig) + Extrazelluläres Wasser + Intrazelluläres Wasser + Eiweiß + Körperzellmasse + Knochenformel + Impedanz-basierte Regression (Veraltet) + Heymsfield (anthropometrisch) + BMR Formel + Cunningham 1991 + Cunningham 1980 + Waage Konfiguration + Keine zusätzliche spezielle Konfiguration für dieses Gerät verfügbar. + BLE Verbindungsschlüssel + 32-Zeichen hex Schlüssel + Die Xiaomi S400-Waage benötigt zur Entschlüsselung einen BLE-Bindungsschlüssel. Extrahiere diesen Schlüssel mithilfe des Tools „Xiaomi Cloud Tokens Extractor“ aus der Xiaomi Cloud. + Der Bind-Schlüssel muss genau 32 Hexadezimalzeichen lang sein + S400: Der Bindungsschlüssel ist nicht konfiguriert. Rufen Sie die Bluetooth-Einstellungen auf, um Ihren BLE-Schlüssel einzugeben. + Konnte %1$d von %2$d Messungen nicht löschen. + Konnte %1$d von %2$d Messungen nicht verschieben (existieren bereits für diesen Benutzer). + Deine %1$s Analyse + Mindestens %1$d Messungen sind erforderlich, um Körperveränderungen im Zeitverlauf zu analysieren. + Start + Jetzt + Rate / Monat + im Durchschnitt + stabil + moderat + unbeständig + Plateau %1$d Tage diff --git a/android_app/app/src/main/res/values-es/strings.xml b/android_app/app/src/main/res/values-es/strings.xml index 1355a9a27..75b5675db 100644 --- a/android_app/app/src/main/res/values-es/strings.xml +++ b/android_app/app/src/main/res/values-es/strings.xml @@ -774,4 +774,6 @@ Seguir editando No se pudieron eliminar %1$d de %2$d mediciones. No se pudieron mover %1$d de %2$d mediciones (ya existen para ese usuario). + El último cambio no se sincronizó. Abre openScale‑sync y ejecuta una sincronización manual. + Abrir diff --git a/android_app/app/src/main/res/values-fr/strings.xml b/android_app/app/src/main/res/values-fr/strings.xml index 7079f3174..411fbec83 100644 --- a/android_app/app/src/main/res/values-fr/strings.xml +++ b/android_app/app/src/main/res/values-fr/strings.xml @@ -85,11 +85,11 @@ Supprimer le profil Ajouter un novueau profil Changer d\'unité - Mesure mise à jour avec succès - Mesure sauvegardée avec succès - Erreur lors de la sauvegarde de la mesure - Mesure %1$s supprimée avec succès - Erreur lors de la suppression de la mesure + Mesure mise à jour (%1$s) + Mesure sauvegardée (%1$s) + La mesure n\'a pas pu être sauvegarder. Veuillez réessayer. + Mesure supprimée (%1$s) + La mesure n\'a pas pu être supprimer. Veuillez réessayer. Sauvegarder la mesure Ajouter une mesure Ajouter une nouvelle mesure @@ -444,7 +444,7 @@ Taille fenêtre Écart max. de lissage (jours) Pas de lissage - Mouvement moyen + Moyenne Glissante Lissage exponentiel Montrer la projection Bases pour la projection (jours) @@ -478,7 +478,7 @@ Pas d\'application adaptée trouvée pour exporter le fichier. Exporter le fichier de journalisation Export des mesures (CSV) - Import de mesures (CSV) + Import des mesures (CSV) Sauvegarde de la base de données Restaurer la base de données Supprimer toutes les données de mesure @@ -660,4 +660,21 @@ P%1$d : Utilisé par %2$s (Remplacer ?) P%1$d : Occupé (Pas lié) P%1$d : Créer \'%2$s\' ici + Couleurs dynamiques + Apparence + Contraste élevé + La dernière modification n\'a pas été synchronisée. Veuillez ouvrir openScale-sync et lancer une synchronisation manuelle. + Ouvrir + Effacer + Enregistré + Insights + Détails + Sem. + Profil %1$s ajouté avec succès. + Non enregistrée — une mesure existe déjà à %1$s. + Annuler + Faites glisser pour redimensionner le graphique, ou appuyez dessus pour le réduire + Appuyez pour afficher le graphique + Annuler les modifications ? + Vos modifications n\'ont pas été sauvegardées et seront perdues. diff --git a/android_app/app/src/main/res/values-ja/strings.xml b/android_app/app/src/main/res/values-ja/strings.xml index 36dfcbf11..03c049b0d 100644 --- a/android_app/app/src/main/res/values-ja/strings.xml +++ b/android_app/app/src/main/res/values-ja/strings.xml @@ -204,7 +204,7 @@ ギャラガー­­­ (2000) – 非アジア人 ギャラガー (2000) – アジア人 ベンケ そして他の人々 (1963) - + デルワイド&クレニエ (1973) ヒューム&ウェイヤーズ (1971) リー、ソン&キム (2001) ボーア (1984) @@ -221,7 +221,7 @@ アジア系人口向けに調整されたコンパニオンモデル。ユーザーの背景により適している場合に使用します。 ベンケ総体液量推定 典型的な体内総体液量の推定。シンプルなアプローチで、アスリートや特殊なケース向けに個別化されていません。 - + デルワイド&クレニエ Hume–Weyers 総体液量 性別別の係数を持つ身長と体重を使用した一般的な総体液量モデル。 リー・ソン・キム diff --git a/android_app/app/src/main/res/values-pt-rBR/strings.xml b/android_app/app/src/main/res/values-pt-rBR/strings.xml index d497cd04c..6d017584b 100644 --- a/android_app/app/src/main/res/values-pt-rBR/strings.xml +++ b/android_app/app/src/main/res/values-pt-rBR/strings.xml @@ -775,4 +775,6 @@ Continuar editando Não foi possível excluir %1$d de %2$d medições. Não foi possível mover %1$d de %2$d medições (já existem para esse usuário). + A última mudança não foi sincronizada. Por favor abra openScale-sync e execute a sincronização manual + Abrir diff --git a/android_app/app/src/main/res/values-ru/strings.xml b/android_app/app/src/main/res/values-ru/strings.xml index 82cf64fa7..d4d57072b 100644 --- a/android_app/app/src/main/res/values-ru/strings.xml +++ b/android_app/app/src/main/res/values-ru/strings.xml @@ -85,11 +85,11 @@ Удалить пользователя Добавить Изменить единицу измерения - Измерение успешно обновлено - Измерение успешно сохранено - Не удалось сохранить измерение - Измерение %1$s успешно удалено - Не удалось удалить измерение + Измерение успешно обновлено (%1$s) + Измерение успешно сохранено (%1$s) + Не удалось сохранить измерение. Повторите попытку. + Измерение успешно удалено (%1$s) + Не удалось удалить измерение. Повторите попытку. Сохранить измерение Добавить измерение Добавить новое измерение @@ -387,11 +387,11 @@ Вы уверены, что хотите удалить %1$d выбранных элементов? Это действие необратимо. Удалить тип? Вы уверены, что хотите удалить пользовательский тип данных \"%1$s\"? Все сопутствующие измерения так же будут безвозвратно удалены. Это действие необратимо. - %1$d элементов успешно удалены. + %1$d элементов успешно удалены Не удалось удалить элементы. Для присвоения выберите пользователя Нет пользователей, на которых можно было бы перейти. - Пользователь успешно изменил %1$d элементов. + %1$d измерений перемещены Не удалось изменить пользователя для некоторых элементов. Соответствующие типы измерений для статистики недоступны или не настроены. Мин @@ -673,4 +673,42 @@ Для расшифровки весов Xiaomi S400 требуется ключ привязки BLE. Извлеките этот ключ из Xiaomi Cloud с помощью инструмента Xiaomi Cloud Tokens Extractor. Ключ привязки должен содержать ровно 32 шестнадцатеричных символа S400: Ключ привязки не настроен. Перейдите в настройки Bluetooth, чтобы ввести свой ключ BLE. + Последнее изменение не было синхронизировано. Пожалуйста, откройте OpenScale-sync и запустите синхронизацию вручную. + Открыть + База знаний + Подробнее + Календарная неделя + Пользователь %1$s успешно добавлен. + Не сохранено. Измерение уже существует в %1$s. + Откатить + Перетаскивайте, чтобы изменить размер диаграммы, или нажмите, чтобы свернуть ее + Нажмите, чтобы отобразить график + Отказаться от изменений? + Внесенные вами изменения не были сохранены и будут потеряны. + Отказаться + Продолжить редактирование + Импеданс (высокий) + Импеданс (низкий) + Внеклеточная вода + Внутриклеточная вода + Белок + Клеточная масса тела + Формула костной ткани + Регрессия на основе импеданса (устаревшая версия) + Формула Стивена Хеймсфилда (антропометрический) + Формула ИМТ + Формула Каннингема, 1991 + Формула Каннингема, 1980 + Автоматическое соединение при запуске + Временной диапазон + Объединение + %1$s выбрано + Не объединять + Дни + Недели + Месяца + Годы + Не удалось удалить %1$d из %2$d измерений. + Не удалось переместить измерения %1$d из %2$d (уже существующие для этого пользователя). + Ограниченные данные diff --git a/android_app/app/src/main/res/values-sv/strings.xml b/android_app/app/src/main/res/values-sv/strings.xml index 7bea797e7..ad44623fd 100644 --- a/android_app/app/src/main/res/values-sv/strings.xml +++ b/android_app/app/src/main/res/values-sv/strings.xml @@ -67,17 +67,17 @@ Underben Vänligen ange giltig data Ålder: %1$d, Kön: %2$s - Uppdatering av mätvärde lyckades - Ett fel uppstod medan mätvärdet sparades - Mätvärdet %1$s raderades framgångsrikt + Mätvärdet uppdaterat (%1$s) + Kunde inte spara mätvärdet. Vänligen försök igen. + Mätvärdet raderades (%1$s) Lägg till mätvärde Lägg till nytt mätvärde Assisterad vägning Välj referensperson för %1$s Inga lämpliga referenspersoner är tillgängliga. Vänligen skapa en användare som inte använder assisterad vägning. För assisterad vägning måste en referensperson väljas. - Mätvärdet sparades framgångsrikt - Fel under radering av mätvärde + Mätvärdet sparat (%1$s) + Kunde inte radera mätvärde. Vänligen försök igen. Spara mätvärde Redigera mätvärde från %1$s Radera mätvärde från %1$s @@ -400,11 +400,11 @@ Är du säker på att du vill ta bort de valda %1$d objekten? Denna åtgärd kan inte ångras. Radera typ? Är du säker på att du vill ta bort den anpassade typen ”%1$s”? Alla tillhörande mätningar kommer också att raderas permanent. Denna åtgärd kan inte ångras. - %1$d objekt har tagits bort. + %1$d mätvärden borttagna Fel vid borttagning av objekt. Välj användare för tilldelning Inga andra användare tillgängliga att byta till. - Användaren har ändrats för %1$d objekt. + %1$d mätvärden flyttade Fel vid byte av användare för vissa objekt. Inga relevanta mätningstyper tillgängliga eller konfigurerade för statistik. Min @@ -687,4 +687,59 @@ Utseende Dynamiska färger automatisk anslutning vid uppstart + Öppna + Insikter + Användaren %1$s lyckades läggas till. + Senaste ändringen synkroniserades inte. Vänligen öppna openScale-sync och kör en manuell synkning. + Ångra + Dina ändringar har inte sparats och kommer att gå förlorade. + Fortsätt redigera + Protein + Kunde inte radera %1$d av %2$d mätvärden. + Stabil + Hög + %1$d dagar + Endast ett år av data — fortsätt mäta för att hitta årstidsbundna mönster + Ovanliga mätvärden + Preliminärt mönster — mät mer regelbundet för att få tillförlitliga resultat + Inga ovanliga mätvärden upptäcktes. + Varning: Du går ner i vikt, men din muskelmassa minskar också. + Fettmängden minskar medan muskelmassan ökar — det bästa möjliga utfallet. + Både fettmängden och muskelmassan ökar. + Minst %1$d mätvärden behövs för att analysera hur din kropp har förändrats över tid. + Minst %1$d mätvärden per veckodag behövs för att upptäcka veckovisa vanor. + Ej sparad — ett mätvärde finns redan vid %1$s. + Tryck för att visa diagrammet + Kasta bort ändringar? + Kasta bort + Impedans (hög) + Impedans (låg) + Cunningham 1991 + Cunningham 1980 + Begränsade uppgifter + Din %1$s-analys + Bästa månaden + Stigande + Sjunkande + Stabilt + Stabil period + Dina veckovisa vanor + En återblick på ditt år + %1$s från förväntade %2$s + %1$s + Du ökar i muskelmassa medan kroppsfettet förblir oförändrat eller minskar — en positive trend för din kroppssammansättning. + Inget tydligt mönster har upptäckts ännu — fler mätningar, bland annat av vikt, kroppsfett, muskelmassa och vattenhalt, behövs. + Både fettmängd och muskelmassa minskar. Vikten sjunker, men muskelmassa går också förlorad. + Fettmängden ökar medan muskelmassan minskar eller förblir oförändrad — det minst önskvärda utfallet. + Nu + Vid starten + i genomsnitt + Din kroppssammansättning + kortsiktig + långsiktig + Extracellulärt vatten + Intracellulärt vatten + Kunde inte flytta %1$d av %2$d mätvärden (de finns redan för den användaren). + Det krävs minst %1$d års data för att kunna upptäcka årstidsbundna mönster. + Det krävs minst %1$d mätningar för att upptäcka avvikande värden. From 01e4f9b81521d34335a2d1142853786922e3b595 Mon Sep 17 00:00:00 2001 From: Anvi Karande Date: Sat, 18 Jul 2026 12:21:55 +0530 Subject: [PATCH 02/72] Added complete Hindi translation (#1413) Added values-hi/strings.xml to provide full Hindi localization support. --- .../app/src/main/res/values-hi/strings.xml | 907 ++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 android_app/app/src/main/res/values-hi/strings.xml diff --git a/android_app/app/src/main/res/values-hi/strings.xml b/android_app/app/src/main/res/values-hi/strings.xml new file mode 100644 index 000000000..ccf2be1b1 --- /dev/null +++ b/android_app/app/src/main/res/values-hi/strings.xml @@ -0,0 +1,907 @@ + + + + openScale + पिछला बदलाव सिंक नहीं हुआ था। कृपया openScale-sync खोलें और मैन्युअल सिंक चलाएं। + खोलें + ऐप लोगो + कोई नहीं + N/A + - + हल्का + गहरा + + + सक्षम करें + रद्द करें + साफ़ करें + पुष्टि करें + ठीक है + सुरक्षित किया गया + चालू + बंद + लिंक खोलें + चेतावनी आइकन + रीस्टोर आइकन + जानकारी + मेन्यू खोलें + पीछे + चयनित + गणना की जा रही है … + मूल्य सफलतापूर्वक अपडेट किए गए। + + + त्रुटि हुई है। + डेटा लोड करने में विफल। + कृपया पुनः प्रयास करें। + + + कोई डेटा उपलब्ध नहीं है। + कोई माप नहीं मिला। + कोई प्रविष्टि उपलब्ध नहीं है। + + + सिंहावलोकन + चार्ट + तालिका + सांख्यिकी + इनसाइट्स + सेटिंग्स + विवरण + कैं. स. + नया माप + माप संपादित करें + ऐप के बारे में + प्रकार संपादित करें + प्रकार जोड़ें + माप के प्रकार + उपयोगकर्ता संपादित करें + उपयोगकर्ता जोड़ें + उपयोगकर्ता + + + उपयोगकर्ता प्रबंधन + उपयोगकर्ता बदलें। चयनित: %1$s + चयनित उपयोगकर्ता सूचक + उपयोगकर्ता जोड़ें + उपयोगकर्ता का चयन करने में विफल। + कोई उपयोगकर्ता चयनित नहीं है। + कृपया पहले एक उपयोगकर्ता का चयन करें। + कोई उपयोगकर्ता सक्रिय नहीं है + अपने मापों को देखने और प्रबंधित करने के लिए कृपया एक नया उपयोगकर्ता बनाएं या किसी मौजूदा उपयोगकर्ता का चयन करें। + उपयोगकर्ता %1$s को सफलतापूर्वक हटा दिया गया है। + उपयोगकर्ता %1$s को हटाने में त्रुटि हुई। + उपयोगकर्ता %1$s को सफलतापूर्वक अपडेट कर दिया गया है। + उपयोगकर्ता %1$s को सफलतापूर्वक जोड़ दिया गया है। + उपयोगकर्ता %1$s को अपडेट करने में त्रुटि हुई। + नाम + लंबाई + लिंग + पुरुष + महिला + अंगच्छेदन (एम्पुटेशन) सुधार + कोई नहीं + हाथ + अग्रबाहु (फोरआर्म) + पूरा हाथ + पैर (फुट) + निचला पैर + पूरा पैर + प्रभावित अंगों का चयन करें और प्रत्येक के लिए अंगच्छेदन का स्तर निर्दिष्ट करें। + बायां हाथ + दायां हाथ + बायां पैर + दायां पैर + सक्रियता का स्तर + सहायता प्राप्त वजन (असिस्टेड वेइंग) + %1$s के लिए संदर्भ व्यक्ति (रेफरेंस पर्सन) का चयन करें + कोई उपयुक्त संदर्भ व्यक्ति उपलब्ध नहीं है। कृपया ऐसा उपयोगकर्ता बनाएं जो सहायता प्राप्त वजन का उपयोग न करता हो। + सहायता प्राप्त वजन के लिए एक संदर्भ व्यक्ति का चयन किया जाना आवश्यक है। + जन्म तिथि + कृपया मान्य डेटा दर्ज करें + %.1f सेमी + आयु: %1$d, लिंग: %2$s + उपयोगकर्ता संपादित करें + उपयोगकर्ता हटाएं + नया उपयोगकर्ता जोड़ें + इकाई (यूनिट) बदलें + + + माप अपडेट किया गया (%1$s) + माप सुरक्षित किया गया (%1$s) + माप सुरक्षित नहीं किया जा सका। कृपया पुनः प्रयास करें। + सुरक्षित नहीं किया गया — %1$s पर एक माप पहले से मौजूद है। + माप हटा दिया गया (%1$s) + माप हटाया नहीं जा सका। कृपया पुनः प्रयास करें। + पूर्ववत करें (अंडू) + चार्ट का आकार बदलने के लिए ड्रैग करें, या इसे छोटा करने के लिए टैप करें + चार्ट देखने के लिए टैप करें + बदलावों को खारिज करें? + आपके बदलाव सुरक्षित नहीं किए गए हैं और वे नष्ट हो जाएंगे। + खारिज करें + संपादन जारी रखें + माप सुरक्षित करें + माप जोड़ें + नया माप जोड़ें + %1$s से माप संपादित करें + %1$s से माप हटाएं + यह समय-मुहर (टाइमस्टैम्प) आपके पिछले माप से मेल खाती है। कृपया दूसरा समय चुनें। + अभी तक कोई माप नहीं है + अपनी प्रगति को ट्रैक करना शुरू करने के लिए अपना पहला माप जोड़ें। + इस प्रविष्टि के लिए कोई सक्रिय माप मूल्य नहीं है। + + + %1$s के लिए अमान्य संख्या: \'%2$s\' + %1$s के लिए अमान्य पूर्णांक (इंटीजर): \'%2$s\' + %1$s के लिए अमान्य संख्या + %1$s के लिए अमान्य पूर्णांक (इंटीजर) + कृपया मान्य डेटा दर्ज करें। + + + %1$s संपादित करें + %1$s बदलें + रंग चुनें + आइकन चुनें + इनपुट मूल्य + इनपुट टेक्स्ट + घंटा + मिनट + + दिनांक + समय + + + %1$s बढ़ाएं + %1$s घटाएं + %1$s संपादित करें + कम दिखाएं + अधिक दिखाएं + मूल्य में वृद्धि हुई + मूल्य में कमी हुई + मूल्य स्थिर है या कोई बदलाव नहीं + + + %1$s का आइकन + अज्ञात माप प्रकार का आइकन: %1$s + अज्ञात प्रकार: %1$s + वजन + BMI + शरीर की वसा (बॉडी फैट) + पानी + मांसपेशी (मसल) + LBM + हड्डी + कमर + WHR + WHtR + नितंब (हिप्स) + विसरल फैट + छाती + जांघ + बायसेप्स + गर्दन + कैलीपर 1 + कैलीपर 2 + कैलीपर 3 + फैट कैलीपर + BMR + हृदय गति (हार्ट रेट) + प्रतिबाधा (हाई इम्पीडेंस) + प्रतिबाधा (लो इम्पीडेंस) + कोशिकाबाह्य जल (ECW) + अंतःकोशकीय जल (ICW) + प्रोटीन + बॉडी सेल मास (BCM) + बोन फॉर्मूला + इम्पीडेंस-आधारित रीग्रेशन (लेगेसी) + हेम्सफील्ड (एंथ्रोपोमेट्रिक) + BMR फॉर्मूला + कनिंघम 1991 + कनिंघम 1980 + TDEE + कैलोरी + टिप्पणी + दिनांक + समय + उपयोगकर्ता + कस्टम प्रकार + + + सक्षम (इनेबल्ड) + नाम + रंग + आइकन + इकाई (यूनिट) + लक्ष्य (गोल) + लक्ष्य की अंतिम तिथि + दिनांक चुनें + मेरे लक्ष्य + %d लक्ष्य + कोई लक्ष्य नहीं + हटाएं + लक्ष्य संपादित करें: %1$s + लक्ष्य निर्धारित करें: %1$s + लक्ष्य निर्धारित करने के लिए कोई प्रकार उपलब्ध नहीं है। + प्रकार चुनें + %1$s के लिए लक्ष्य मूल्य + नए लक्ष्य के लिए मूल्य खाली नहीं हो सकता। + कृपया पहले एक माप प्रकार का चयन करें। + लक्ष्य हटा दिया गया + इनपुट का प्रकार + पिन किया गया + दाहिने Y-अक्ष (Y-axis) पर + चयनित आइकन का पूर्वावलोकन + क्रमबद्ध करें (सॉर्ट) + संपादित करें + हटाएं + माप प्रकार \'%1$s\' सफलतापूर्वक जोड़ दिया गया है। + माप प्रकार \'%1$s\' जोड़ने में त्रुटि हुई। + माप प्रकार \'%1$s\' सफलतापूर्वक हटा दिया गया है। + माप प्रकार \'%1$s\' हटाने में त्रुटि हुई। + माप प्रकार \'%1$s\' अपडेट करने में त्रुटि हुई। + माप प्रकार \'%1$s\' सफलतापूर्वक अपडेट कर दिया गया है। + प्रकार \'%1$s\' अपडेट किया गया और %2$s मूल्य सफलतापूर्वक परिवर्तित किए गए। + प्रकार \'%1$s\' के लिए मूल्यों को परिवर्तित करने में त्रुटि। प्रकार की परिभाषा को अपडेट नहीं किया गया था। + प्रकार \'%1$s\' अपडेट किया गया। इकाई बदली गई, लेकिन किसी भी मौजूदा मूल्य को परिवर्तित करने की आवश्यकता नहीं पड़ी। + इकाई परिवर्तन की पुष्टि करें + \'%1$s\' के लिए इकाई को %2$s से %3$s में बदलने से सभी मौजूदा डेटा पॉइंट परिवर्तित हो जाएंगे। इस प्रक्रिया में कुछ समय लग सकता है और इसे आसानी से वापस नहीं बदला जा सकता। क्या आप आगे बढ़ना चाहते हैं? + + बॉडी फैट फॉर्मूला + बॉडी वॉटर फॉर्मूला + लीन बॉडी मास फॉर्मूला + फॉर्मूला गणना + यह मीट्रिक केवल पढ़ने योग्य (रीड-ओनली) हो जाती है और स्वचालित रूप से इसकी गणना की जाती है। मूल मूल्यों में बदलाव होने पर यह अपडेट हो जाती है। यह नियम केवल नए या संपादित मापों पर लागू होता है; मौजूदा माप अपरिवर्तित रहेंगे। + बंद + Deurenberg (1991) + Deurenberg (1992) + Eddy et al. (1976) + Gallagher (2000) – गैर-एशियाई (Non-Asian) + Gallagher (2000) – एशियाई (Asian) + Behnke et al. (1963) + Delwaide & Crenier (1973) + Hume & Weyers (1971) + Lee, Song & Kim (2001) + Boer (1984) + Hume (1966) + वजन − बॉडी फैट + U.S. Navy + + कोई स्वचालित गणना नहीं + यह मीट्रिक स्वचालित रूप से परिकलित नहीं होती है। आप मान (वैल्यू) मैन्युअल रूप से दर्ज कर सकते हैं। + वयस्कों के लिए क्लासिक रीग्रेशन + 1991 का एक व्यापक रूप से उपयोग किया जाने वाला जनसंख्या फॉर्मूला। सामान्य वयस्क आबादी के लिए उपयुक्त; बहुत दुबले या अत्यधिक गठीले (मस्कुलर) व्यक्तियों के लिए यह कम सटीक हो सकता है। + किशोरी और वयस्कों के लिए अनुकूलित + यह संस्करण कम उम्र के उपयोगकर्ताओं के लिए अनुकूल है। यह एक सामान्य अनुमान है; एथलेटिक या असामान्य शारीरिक संरचना वाले व्यक्तियों के परिणाम भिन्न हो सकते हैं। + शुरुआती लिंग-विशिष्ट मॉडल + पुरुषों और महिलाओं के लिए अलग-अलग गुणांकों (कोएफिशिएंट्स) वाला पुराना रीग्रेशन मॉडल। यह सरल और तेज़ है लेकिन विशेष आबादी के लिए अनुकूलित नहीं है। + Gallagher (गैर-एशियाई) + गैर-एशियाई आबादी के लिए 2000 का संदर्भ मॉडल। एक उपयोगी आधार रेखा (बेसलाइन); शरीर के विभिन्न प्रकारों के आधार पर इसकी सटीकता भिन्न हो सकती हैं। + Gallagher (एशियाई) + एशियाई आबादी के लिए विशेष रूप से तैयार किया गया सह-मॉडल (कंपैनियन मॉडल)। इसका उपयोग तब करें जब यह उपयोगकर्ता की पृष्ठभूमि से बेहतर मेल खाता हो। + Behnke TBW अनुमान + शरीर के कुल पानी (टोटल बॉडी वॉटर - TBW) का क्लासिक अनुमान। एक सरल दृष्टिकोण; यह एथलीटों या विशेष मामलों के लिए व्यक्तिगत रूप से अनुकूलित नहीं है। + Delwaide & Crenier + शरीर के द्रव्यमान (बॉडी मास) को प्राथमिकता देने वाला TBW फॉर्मूला। एक उचित सामान्य अनुमान; शारीरिक संरचना के आधार पर सटीकता बदल सकती है। + Hume–Weyers TBW + लिंग-विशिष्ट गुणांकों (कोएफिशिएंट्स) के साथ लंबाई और वजन का उपयोग करने वाला एक सामान्य TBW मॉडल। + Lee–Song–Kim + कोरियाई समूहों पर कैलिब्रेट किया गया बाद का TBW अनुमान। यदि यह आबादी मेल खाती है, तो इस पर विचार करें। + Boer LBM (क्लिनिकल) + क्लिनिकल सेटिंग्स में लोकप्रिय लीन बॉडी मास (LBM) अनुमान। लंबाई और वजन के आधार पर लिंग-विशिष्ट रीग्रेशन। + Hume LBM + एक शुरुआती LBM मॉडल। सरल और व्यापक रूप से उपयोग किया जाने वाला; अत्यधिक एथलेटिक शरीर के लिए विशेष रूप से तैयार नहीं किया गया। + वजन − बॉडी फैट + यह चुने गए बॉडी-फैट फॉर्मूले से प्राप्त वसा द्रव्यमान (फैट मास) को कुल वजन में से घटाकर LBM की गणना करता है। इसकी सटीकता बॉडी फैट (BF) के अनुमान पर निर्भर करती है। + U.S. Navy विधि + U.S. Navy मानक के अनुसार कमर, गर्दन और (महिलाओं के लिए) कूल्हों की परिधि के आधार पर शरीर की वसा प्रतिशत (बॉडी फैट परसेंटेज) की गणना करता है। + + %1$d: %2$s + %1$s की पुष्टि करें? + क्या आप निश्चित रूप से %2$s को %1$d आइटम पर लागू करना चाहते हैं? + पिन करें + अनपिन करें + सक्षम करें + अक्षम करें + बाएं अक्ष (लेफ्ट एक्सिस) पर ले जाएं + दाएं अक्ष (राइट एक्सिस) पर ले जाएं + पिन किया गया + अनपिन किया गया + सक्षम किया गया + अक्षम किया गया + दाएं अक्ष पर स्थानांतरित किया गया + बाएं अक्ष पर स्थानांतरित किया गया + + + स्केल से कनेक्ट नहीं किया जा सका, कृपया सुनिश्चित करें कि यह चालू है। + सुरक्षित किया गया स्केल + ब्लूटूथ प्रोसेसिंग चल रही है + ब्लूटूथ स्केल सेटअप करें + %1$s से डिस्कनेक्ट करें + %1$s से पुनः कनेक्शन का प्रयास करें + ब्लूटूथ त्रुटि, सेटिंग्स की जांच करें + %1$s से कनेक्ट करें + ब्लूटूथ सेटिंग्स की जांच करें + %1$s से कनेक्ट किया जा रहा है… + %1$s से डिस्कनेक्ट किया जा रहा है… + %1$s के साथ प्रोसेसिंग जारी है… + कोई ब्लूटूथ स्केल सुरक्षित नहीं है। कृपया सेटिंग्स में एक स्केल जोड़ें। + %1$s से दोबारा कनेक्ट करने का प्रयास किया जा रहा है… + ब्लूटूथ त्रुटि। कृपया सेटिंग्स की जांच करें। + %1$s से कनेक्ट करने का प्रयास किया जा रहा है… + कृपया ब्लूटूथ सेटिंग्स की जांच करें। + स्कैनिंग शुरू करने के लिए कृपया ब्लूटूथ सक्षम करें। + स्कैन करने के लिए ब्लूटूथ अनुमतियों (परमिशन) की आवश्यकता है। + ब्लूटूथ सक्षम है, लेकिन अनुमतियां अभी भी गायब हैं। + स्केल खोजने के लिए ब्लूटूथ सक्षम होना आवश्यक है। + सुरक्षित किया गया स्केल: + अज्ञात + स्कैन रोकें + स्केल खोजें + स्केल खोजने का बटन + मिले हुए डिवाइस: + \'%1$s\' को पसंदीदा स्केल के रूप में सुरक्षित किया गया। + %1$s वर्तमान में समर्थित नहीं है। + कोई डिवाइस नहीं मिला। एक नया स्कैन शुरू करें। + सुरक्षित किए गए स्केल का सूचक + समर्थित डिवाइस + असमर्थित डिवाइस + समर्थित + समर्थित नहीं है + %1$d dBm + पसंदीदा के रूप में सुरक्षित करें + डेवलपर मोड के रूप में सुरक्षित करें + सुरक्षित किया गया डिवाइस हटा दिया गया। + डेवलपर मोड सक्षम किया गया। कृपया लॉग फ़ाइल सक्षम करें: सेटिंग्स → सामान्य → लॉग फ़ाइल सक्षम करें। + इस डिवाइस के लिए डेवलपर मोड सक्षम किया गया। कृपया लॉग फ़ाइल सक्षम करें: सेटिंग्स → सामान्य → लॉग फ़ाइल सक्षम करें। + डेवलपर मोड सक्रिय है — कोई भी माप सुरक्षित नहीं किया जाएगा। + कृपया लॉग फ़ाइल सक्षम करें: सेटिंग्स → सामान्य → लॉग फ़ाइल सक्षम करें। + क्षमताएं (कैपेबिलिटीज) + शरीर की संरचना (बॉडी कंपोजिशन) + समय सिंक (टाइम सिंक) + उपयोगकर्ता सिंक (यूजर सिंक) + इतिहास पढ़ना (हिस्ट्री रीड) + लाइव वजन + यूनिट कॉन्फ़िगरेशन + बैटरी + लागू किया गया (इम्प्लीमेंटेड): %1$s + समर्थित (लागू नहीं): %1$s + अधिक विकल्प + + + + "%1$s सेटिंग्स" + ब्लूटूथ ट्यूनिंग + ट्यूनिंग प्रोफ़ाइल + ब्लूटूथ माप + स्मार्ट उपयोगकर्ता असाइनमेंट + सहनशीलता (टॉलरेंस) + Ignore uncertain measurements + यदि कोई माप सभी उपयोगकर्ताओं के लिए निर्धारित सहनशीलता (टॉलरेंस) से बाहर है, तो उसे सुरक्षित नहीं किया जाएगा। + डिवाइस क्रियाएं (एक्शन्स) + डेवलपर मोड + सुरक्षित किया गया डिवाइस हटाएं + ब्लूटूथ ट्यूनिंग प्रोफ़ाइल + ब्लूटूथ ट्यूनिंग: %1$s + संतुलित (बैलेंस्ड) + रूढ़िवादी (कंजर्वेटिव) + आक्रामक (अग्रेसिव) + उपयोगकर्ता को स्वचालित रूप से असाइन करने के लिए अधिकतम वजन विचलन (डेविएशन)। + डेवलपर + खतरे का क्षेत्र (डेंजर ज़ोन) + स्टार्टअप पर ऑटो-कनेक्ट करें + + + स्केल कॉन्फ़िगरेशन + इस डिवाइस के लिए कोई अतिरिक्त विशेष कॉन्फ़िगरेशन उपलब्ध नहीं है। + + + BLE बाइंड की (Bind Key) + 32-अक्षर की हेक्स की (Hex key) + Xiaomi S400 स्केल को डिक्रिप्शन के लिए एक BLE बाइंड की की आवश्यकता होती है। Xiaomi Cloud Tokens Extractor टूल का उपयोग करके इस की (key) को Xiaomi Cloud से निकालें। + बाइंड की (key) ठीक 32 हेक्स अक्षरों की होनी चाहिए + S400: बाइंड की (key) कॉन्फ़िगर नहीं है। अपनी BLE की दर्ज करने के लिए ब्लूटूथ सेटिंग्स में जाएं। + + + %1$s से कनेक्टेड + %1$s से कनेक्शन विफल रहा: %2$s + %1$s के साथ त्रुटि: %2$s + %1$s समर्थित नहीं है। + %1$s के लिए ड्राइवर नहीं मिला या आंतरिक त्रुटि हुई। + %1$s से प्राप्त माप को किसी उपयोगकर्ता को असाइन नहीं किया जा सकता है। + त्रुटि: माप के प्रकार लोड नहीं हुए हैं। + %1$s से कोई मान्य माप मूल्य प्राप्त नहीं हुआ। + %2$s से प्राप्त माप (%1$.1f किलोग्राम) सुरक्षित किया गया। + %1$s से प्राप्त माप को सुरक्षित करने में त्रुटि। + %1$s: %2$s + %1$s की प्रतीक्षा की जा रही है (लिसनिंग)… + %1$s से माप प्राप्त हुआ। + + + अनुमतियाँ आवश्यक आइकन + अनुमतियाँ आवश्यक हैं + स्केल खोजने के लिए, हमें ब्लूटूथ एक्सेस की आवश्यकता है। + अनुमतियाँ दें + ब्लूटूथ अक्षम आइकन + ब्लूटूथ अक्षम है + स्केल खोजने के लिए कृपया ब्लूटूथ सक्षम करें। + ब्लूटूथ सक्षम करें + त्रुटि आइकन + महत्वपूर्ण सूचना + openScale एक ओपन-सोर्स प्रोजेक्ट है, और हर एक स्केल मेरे पास उपलब्ध नहीं है। कृपया ध्यान दें कि सभी मामलों में अनुकूलता (कंपैटिबिलिटी) की गारंटी नहीं दी जा सकती है, क्योंकि ब्लूटूथ प्रोटोकॉल बदल सकते हैं या वे अनडॉक्यूमेंटेड हो सकते हैं।\n\nएक ओपन-सोर्स प्रोजेक्ट के रूप में, स्केल सपोर्ट को बेहतर बनाने के लिए पुल रिक्वेस्ट (PR) और योगदान का दिल से स्वागत है! + स्वीकार करें और सुरक्षित करें + प्रोजेक्ट वेबसाइट पर जाएं + + + प्रारंभिकरण प्रक्रिया (इनिशियलाइजेशन)... + कनेक्शन टूट गया + कनेक्शन डिस्कनेक्ट हो गया + डिवाइस नहीं मिला। %s + अप्रत्याशित त्रुटि: %s + अप्रत्याशित डेटा प्रकार प्राप्त हुआ: %s + स्केल से संदेश (ID: %1$d, मूल्य: %2$s) + उपयोगकर्ता चयन आवश्यक है + उपयोगकर्ता #%1$d (आयु: %2$d, लंबाई: %3$d) + उपयोगकर्ता %1$d (इंडेक्स %2$d) के लिए सहमति आवश्यक है। + ड्राइवर से अज्ञात स्थिति या संदेश: %s + एडेप्टर पहले से ही किसी अन्य डिवाइस (%s) के साथ व्यस्त है। + %1$s से कनेक्शन प्रक्रिया शुरू करने में त्रुटि: %2$s + माप अनुरोध के लिए कनेक्शन स्थापित नहीं है। + माप स्वचालित रूप से शुरू होना चाहिए (लेगेसी ड्राइवर)। + त्रुटि: चयन के लिए उपयोगकर्ता डेटा अनुपलब्ध है। + त्रुटि: सहमति के लिए अमान्य डेटा प्राप्त हुआ। + त्रुटि: सहमति के लिए आवश्यक डेटा अनुपलब्ध है। + त्रुटि: उपयोगकर्ता सहभागिता के लिए अमान्य डेटा पेलोड प्राप्त हुआ। + + + समय सीमा (टाइम रेंज) आइकन + चार्ट डेटा फ़िल्टर करें + %s चयनित माप फ़िल्टर दिखाई दे रहा है + माप फ़िल्टर छिपा हुआ है + %1$s (%2$d प्रविष्टियां) सभी दिन + पिछले 7 दिन + पिछले 30 दिन + पिछले 365 दिन + कस्टम दिन + %1$s से %2$s तक + समय सीमा (टाइम रेंज) + एकत्रीकरण (एग्रीगेशन) + %1$s चयनित + कोई एकत्रीकरण नहीं + दिन + सप्ताह + महीने + वर्ष + प्लॉट करने योग्य कोई चार्ट प्रकार उपलब्ध नहीं है। + प्रदर्शित करने के लिए कोई डेटा नहीं है। + चयनित सीमा में %1$s के लिए कोई डेटा नहीं है। + यह प्रकार + कृपया चार्ट के लिए माप प्रकारों का चयन करें। + प्रदर्शित करने के लिए कोई डेटा नहीं है या चुनने के लिए प्रकार उपलब्ध नहीं हैं। + चयनित प्रकारों के लिए कोई डेटा उपलब्ध नहीं है। + माप फ़िल्टर दिखाएं/छिपाएं + + + + दिनांक + कोई कॉलम नहीं चुना गया या कोई माप उपलब्ध नहीं है। + दिखाने के लिए कोई कॉलम नहीं चुना गया। + चुने गए कॉलम के लिए कोई डेटा उपलब्ध नहीं है। + ऊपर की ओर रुझान + नीचे की ओर रुझान + आइटम चुनें + %1$d चुने गए + चयन रद्द करें + चुने गए आइटम के लिए उपयोगकर्ता बदलें + चुने गए आइटम एक्सपोर्ट करें + चुने गए आइटम हटाएं + उपयोगकर्ता %1$s को हटाएं? + इस उपयोगकर्ता का सारा डेटा स्थायी रूप से हटा दिया जाएगा। इस कार्रवाई को पूर्ववत नहीं किया जा सकता। + %1$s के लिए लक्ष्य हटाएं? + इससे लक्ष्य हटा दिया जाएगा। आपके मापों का इतिहास प्रभावित नहीं होगा। + माप हटाएं? + क्या आप निश्चित रूप से %1$s (%2$s) का माप हटाना चाहते हैं? इस कार्रवाई को पूर्ववत नहीं किया जा सकता। + आइटम हटाएं? + क्या आप निश्चित रूप से चुने गए आइटम को हटाना चाहते हैं? इस कार्रवाई को पूर्ववत नहीं किया जा सकता। + क्या आप निश्चित रूप से चुने गए %1$d आइटम हटाना चाहते हैं? इस कार्रवाई को पूर्ववत नहीं किया जा सकता। + प्रकार (Type) हटाएं? + क्या आप निश्चित रूप से कस्टम प्रकार “%1$s” को हटाना चाहते हैं? इससे जुड़े सभी माप भी स्थायी रूप से हटा दिए जाएंगे। इस कार्रवाई को पूर्ववत नहीं किया जा सकता। + %1$d माप हटा दिए गए + %2$d में से %1$d माप नहीं हटाए जा सके। + आवंटन (Assignment) के लिए उपयोगकर्ता चुनें + बदलने के लिए कोई अन्य उपयोगकर्ता उपलब्ध नहीं है। + %1$d माप स्थानांतरित (move) किए गए + %2$d में से %1$d माप स्थानांतरित नहीं किए जा सके (उस उपयोगकर्ता के लिए पहले से मौजूद हैं)। + + + सीमित डेटा + आपका %1$s विश्लेषण + पुनर्गठन (Recomposition) + बल्किंग (Bulking) + मिश्रित कमी (Mixed Loss) + फैट बढ़ना + समय के साथ आपके शरीर में हुए बदलावों का विश्लेषण करने के लिए कम से कम %1$d मापों की आवश्यकता है। + साप्ताहिक आदतों का पता लगाने के लिए प्रति सप्ताह कम से कम %1$d मापों की आवश्यकता है। + मौसमी पैटर्न का पता लगाने के लिए कम से कम %1$d वर्षों के डेटा की आवश्यकता है। + असामान्य मानों (values) का पता लगाने के लिए कम से कम %1$d मापों की आवश्यकता है। + सर्वश्रेष्ठ महीना + बढ़ रहा है + घट रहा है + स्थिर + दर / महीना + उतार-चढ़ाव (Volatility) + स्थिर + मध्यम + अधिक + स्थिर अवधि (Platea) + %1$d दिन + आपकी साप्ताहिक आदतें + आपका माप आमतौर पर %1$s को सबसे अधिक और %2$s को सबसे कम होता है + प्रारंभिक पैटर्न — विश्वसनीय परिणामों के लिए अधिक नियमित रूप से मापें + आपका वार्षिक अवलोकन (Year in Review) + आपके मान (values) आमतौर पर %1$s में उच्चतम स्तर पर और %2$s में न्यूनतम स्तर पर होते हैं + केवल एक वर्ष का डेटा — मौसमी पैटर्न देखने के लिए मापना जारी रखें + असामान्य माप + कोई असामान्य माप नहीं पाया गया। + अपेक्षित %2$s से %1$s का अंतर + "%1$s" + %1$d दिनों से कोई महत्वपूर्ण बदलाव नहीं — आपका शरीर अनुकूलित (adapt) हो रहा है। + हालिया गिरावट का रुझान — आपकी दीर्घकालिक (long-term) दिशा की तुलना में सकारात्मक बदलाव। + हालिया बढ़त का रुझान — अपनी दीर्घकालिक (long-term) दिशा पर नज़र रखें। + अत्यधिक उतार-चढ़ाव पाया गया — यह सामान्य है और शरीर में होने वाले प्राकृतिक बदलावों जैसे कि 'वॉटर रिटेंशन' (पानी जमा होना) को दर्शाता है। + आपके मान (values) बहुत सुसंगत हैं — यह एक विश्वसनीय डेटा आधार है। + आपके शरीर की संरचना (Body Composition) + इस विश्लेषण की गणना करने के लिए पिछले %2$d दिनों के भीतर चारों मापों (वजन, बॉडी फैट, मांसपेशियां, पानी) के साथ कम से कम %1$d माप होने आवश्यक हैं। + वजन और फैट कम हो रहे हैं जबकि मांसपेशियों (muscle mass) सुरक्षित हैं या थोड़ा बढ़ रही हैं — आप मुख्य रूप से फैट कम कर रहे हैं। + आपका शरीर प्रभावी रूप से पुनर्गठित (recompose) हो रहा है: आप फैट कम कर रहे हैं और साथ ही महत्वपूर्ण मांसपेशियों का निर्माण कर रहे हैं। + आपका बॉडी फैट स्थिर या कम रहने के साथ-साथ आपकी मांसपेशियां बढ़ रही हैं — यह शरीर की संरचना का एक सकारात्मक रुझान है। + चेतावनी: आपका वजन तो कम हो रहा है, लेकिन आपकी मांसपेशियां (muscle mass) भी घट रही हैं। + फैट बढ़ रहा है। यदि आप जानबूझकर बल्किंग (bulk) नहीं कर रहे हैं, तो अपनी शारीरिक गतिविधियों की समीक्षा करने पर विचार करें। + फैट और मांसपेशियां दोनों बढ़ रही हैं — यह बल्किंग फेज़ (bulk phase) जैसा दिखता है। यदि मांसपेशियां बढ़ाना आपका लक्ष्य है, तो देखें कि क्या फैट में वृद्धि आपकी तय सीमा के भीतर है। + सभी माप स्थिर हैं — आप मेंटेनेंस फेज़ (maintenance phase) में हैं। + अभी तक कोई स्पष्ट पैटर्न नहीं मिला है — वजन, बॉडी फैट, मांसपेशियां और पानी सहित अधिक मापों की आवश्यकता है। + %1$s में मिश्रित संकेत मिल रहे हैं — वजन, फैट और मांसपेशियों के रुझान अभी किसी स्पष्ट पैटर्न से मेल नहीं खा रहे हैं। + फैट कम हो रहा है जबकि मांसपेशियां बढ़ रही हैं — यह सबसे अच्छा परिणाम है। + फैट और मांसपेशियां दोनों बढ़ रही हैं। + फैट और मांसपेशियां दोनों कम हो रही हैं। वजन घट रहा है, लेकिन मांसपेशियों का नुकसान भी हो रहा है। + फैट बढ़ रहा है जबकि मांसपेशियां कम या स्थिर हैं — यह सबसे कम अनुकूल परिणाम है। + शुरुआत + अब + दर / महीना + औसतन + स्थिर + मध्यम + परिवर्तनशील (volatile) + प्लेटो %1$d दिन + अल्पकालिक + दीर्घकालिक + + + सांख्यिकी (statistics) के लिए कोई प्रासंगिक माप प्रकार उपलब्ध या कॉन्फ़िगर नहीं किया गया है। + न्यूनतम + अधिकतम + औसत + बढ़ोतरी + कमी + कोई बदलाव नहीं + + + सामान्य + उपयोगकर्ता + माप के प्रकार + ब्लूटूथ + चार्ट (Chart) + डेटा प्रबंधन + ऐप के बारे में + + + सामान्य सेटिंग्स + भाषा + रिमाइंडर (Reminder) + रिमाइंडर फ़ंक्शन + रिमाइंडर टेक्स्ट + यहाँ रिमाइंडर टेक्स्ट दर्ज करें + समय + रिमाइंडर चालू किया गया + रिमाइंडर बंद किया गया + वजन मापने का समय हो गया है + रिमाइंडर + openScale + दिन + नोटिफिकेशन की अनुमति अस्वीकार कर दी गई + रंग-रूप (Appearance) + डायनेमिक रंग (Dynamic colors) + हाई कॉन्ट्रास्ट + + सोम + मंगल + बुध + गुरु + शुक्र + शनि + रवि + सभी + + फीडबैक और रिस्पॉन्स + नए माप पर वाइब्रेशन + माप पर वाइब्रेशन चालू किया गया + माप पर वाइब्रेशन बंद किया गया + + + डेटा पॉइंट्स दिखाएं + लक्ष्य रेखाएं दिखाएं + स्मूथिंग एल्गोरिदम + अल्फा + विंडो साइज + स्मूथिंग के लिए अधिकतम अंतर + कोई स्मूथिंग नहीं + मूविंग एवरेज + एक्सपोनेंशियल स्मूथिंग + प्रोजेक्शन दिखाएं + प्रोजेक्शन का आधार + कितने दिनों का प्रोजेक्शन देखना है + प्रोजेक्शन मॉडल + रैखिक + द्विघात + त्रिघात + + + वर्शन: %1$s (%2$s) + परियोजना की जानकारी (Project Info) + मेंटेनर (मुरम्मतकर्ता) + मेंटेनर आइकन + GitHub पर प्रोजेक्ट + आधिकारिक प्रोजेक्ट पेज + होमपेज आइकन + सॉफ़्टवेयर लाइसेंस का विवरण + लाइसेंस आइकन + डायग्नोस्टिक्स (निदान) + फ़ाइल लॉगिंग आइकन + फ़ाइल लॉगिंग (File Logging) + फ़ाइल लॉगिंग चालू करें? + चालू होने पर, ऐप आपके डिवाइस पर स्थानीय रूप से विस्तृत लॉग (logs) सहेजेगा। ये समस्याओं को हल करने (troubleshooting) के लिए उपयोगी होते हैं। इन फ़ाइलों में संवेदनशील जानकारी हो सकती है। इन्हें केवल विश्वसनीय व्यक्तियों के साथ ही साझा करें। + लॉगिंग चालू करते ही तुरंत एक नया लॉग सेशन शुरू हो जाएगा। एक नई लॉग फ़ाइल बनाई जाएगी और पुरानी फ़ाइल हटा दी जाएगी। + फ़ाइल लॉगिंग चालू की गई + फ़ाइल लॉगिंग बंद की गई + लॉग फ़ाइल सफलतापूर्वक एक्सपोर्ट हो गई। + लॉग फ़ाइल एक्सपोर्ट करने में त्रुटि (error) हुई। + एक्सपोर्ट करने के लिए कोई लॉग फ़ाइल नहीं मिली। + एक्सपोर्ट करने के लिए कोई लॉग फ़ाइल उपलब्ध नहीं है। + एक्सपोर्ट रद्द कर दिया गया। + फ़ाइल एक्सपोर्ट करने के लिए कोई उपयुक्त ऐप नहीं मिला। + लॉग फ़ाइल एक्सपोर्ट करें + + + डेवलपर टूल्स (Developer Tools) + परिदृश्य (Scenario) + समय सीमा (Time Range) + मौजूदा डेटा पूरी तरह मिटाएं + डेमो डेटा जनरेट करें + पिछले 30 दिन + पिछले 6 महीने + पिछले 2 वर्ष + पिछले 4 वर्ष + अपरिवर्तनीय कार्रवाई + इससे वर्तमान उपयोगकर्ता के सभी माप डिलीट हो जाएंगे और उनकी जगह डेमो डेटा ले लेगा। इस कार्रवाई को पूर्ववत नहीं किया जा सकता! + इससे वर्तमान उपयोगकर्ता प्रोफ़ाइल में कई मॉक (काल्पनिक) माप जुड़ जाएंगे। + पुष्टि करें और जनरेट करें + %2$s के लिए %1$d माप जनरेट किए जा रहे हैं… + %2$s के लिए %1$d माप सफलतापूर्वक जनरेट किए गए + पुनर्गठन (Recomposition) + स्थिर (प्लेटो/Plateau) + रुझान प्रगति (Trend Progress) + मसल गेन (फैट स्थिर) + बल्क (फैट + मसल बढ़ना) + फैट लॉस (मसल स्थिर) + फैट गेन (मसल स्थिर) + मिश्रित हानि (फैट + मसल कम होना) + विसंगतियों के साथ (z-score स्पाइक्स) + मौसमी पैटर्न (2+ वर्ष) + साप्ताहिक आदतें (दैनिक क्रम) + सीमित मेट्रिक्स (केवल वजन) + अंतरालों के साथ (30 दिन+ का ब्रेक) + + + माप एक्सपोर्ट करें (CSV) + माप इम्पोर्ट करें (CSV) + डेटाबेस का बैकअप लें + डेटाबेस रीस्टोर करें + सभी माप डेटा हटाएं + पूरा डेटाबेस हटाएं + डेंजर ज़ोन + अज्ञात त्रुटि + स्वचालित बैकअप + स्वचालित बैकअप चालू करें + स्वचालित बैकअप टॉगल करें + पिछले बैकअप की स्थिति + बैकअप स्थिति की जानकारी + बैकअप का स्थान + बैकअप स्थान की सेटिंग + बैकअप स्थान खोलें + बैकअप स्थान बदलें + बैकअप का अंतराल + बैकअप अंतराल की सेटिंग + बैकअप अंतराल बदलें + बैकअप का तरीका + बैकअप तरीके की सेटिंग + पिछला बैकअप: %1$s + स्वचालित बैकअप बंद है + हमेशा एक नई बैकअप फ़ाइल बनाएं + मौजूदा बैकअप फ़ाइल को ओवरराइट करें + डिफ़ॉल्ट: ऐप-विशिष्ट फ़ोल्डर + सिम्युलेटेड: उपयोगकर्ता द्वारा चुना गया फ़ोल्डर + बैकअप अंतराल चुनें + बैकअप स्थान कॉन्फ़िगर नहीं है + स्थान कॉन्फ़िगर नहीं है। स्वचालित बैकअप रोक दिया गया है। + बैकअप स्थान तक पहुँचने में त्रुटि + हटाने का आइकन + बैकअप डायरेक्टरी चुनें + बैकअप स्थान चुनें + चुना गया फ़ोल्डर + फ़ोल्डर खोलने के लिए कोई उपयुक्त एप्लिकेशन नहीं मिला। + बैकअप स्थान नहीं खोला जा सका। + बैकअप स्थान यहाँ सेट किया गया: %1$s + फ़ोल्डर का चयन रद्द कर दिया गया। स्वचालित बैकअप चालू नहीं हुआ। + पिछला बैकअप: %1$s + पिछला बैकअप: कभी नहीं + दैनिक (Daily) + साप्ताहिक (Weekly) + मासिक (Monthly) + + + एक्सपोर्ट: उपयोगकर्ता चुनें + इम्पोर्ट: उपयोगकर्ता चुनें + हटाएं: उपयोगकर्ता चुनें + स्थायी रूप से हटाएं? + ध्यान दें! पूरा डेटाबेस हटाएं? + डेटाबेस रीस्टोर करें? + डेटाबेस हटाने की पुष्टि करें + + + क्या आप निश्चित रूप से उपयोगकर्ता \"%1$s\" का सभी माप डेटा स्थायी रूप से हटाना चाहते हैं? इस कार्रवाई को पूर्ववत नहीं किया जा सकता। + हाँ, सभी हटाएं + इससे इस ऐप का सारा डेटा स्थायी रूप से डिलीट हो जाएगा, जिसमें सभी उपयोगकर्ता, सेटिंग्स और माप डेटा शामिल हैं। इस कार्रवाई को पूर्ववत नहीं किया जा सकता। क्या आप पूरी तरह आश्वस्त हैं?\n\nइस कार्रवाई के बाद ऐप को रीस्टार्ट करना आवश्यक होगा। + क्या आप निश्चित रूप से बैकअप फ़ाइल से वर्तमान डेटाबेस को बदलना (overwrite) चाहते हैं? वर्तमान का सारा असुरक्षित (unsaved) डेटा नष्ट हो जाएगा। रीस्टोर के बाद ऐप को रीस्टार्ट करने की आवश्यकता हो सकती है। + हाँ, रीस्टोर करें + क्या आप निश्चित रूप से पूरा डेटाबेस हटाना चाहते हैं? इससे सभी उपयोगकर्ता और सारा डेटा हटा दिया जाएगा और इसे वापस नहीं लाया जा सकता। + + + डेटा सफलतापूर्वक एक्सपोर्ट किया गया। + एक्सपोर्ट संभव नहीं है: एक्सपोर्ट के लिए कोई विशिष्ट डेटा फ़ील्ड परिभाषित नहीं है। + इस उपयोगकर्ता के लिए एक्सपोर्ट करने हेतु कोई माप उपलब्ध नहीं है। + इस उपयोगकर्ता के लिए कोई एक्सपोर्ट करने योग्य माप मान (values) नहीं मिले। + एक्सपोर्ट विफल रहा: फ़ाइल नहीं बनाई जा सकी। + एक्सपोर्ट के दौरान त्रुटि हुई: %1$s + एक्सपोर्ट करने के लिए कोई उपयोगकर्ता उपलब्ध नहीं है। + + + "%1$d माप रिकॉर्ड सफलतापूर्वक इम्पोर्ट किए गए।" + %1$d रिकॉर्ड सफलतापूर्वक इम्पोर्ट किए गए %2$s। + "%1$d माप रिकॉर्ड सफलतापूर्वक इम्पोर्ट किए गए।" + "%1$d पंक्तियाँ अनदेखी की गईं (डुप्लिकेट टाइमस्टैम्प के कारण)।" + "तारीख न होने के कारण %1$d पंक्तियाँ छोड़ दी गईं।" + "तारीख के प्रारूप में त्रुटि के कारण %1$d पंक्तियाँ छोड़ दी गईं।" + "%1$d व्यक्तिगत मानों का विश्लेषण नहीं किया जा सका।" + CSV फ़ाइल में कोई वैध डेटा नहीं मिला, या सभी पंक्तियों में त्रुटियाँ थीं। + इम्पोर्ट विफल रहा: CSV हेडर में अनिवार्य 'तारीख' कॉलम गायब है। + इम्पोर्ट विफल रहा: फ़ाइल को पढ़ा नहीं जा सका। + इम्पोर्ट के दौरान त्रुटि हुई: %1$s + इम्पोर्ट करने के लिए कोई उपयोगकर्ता उपलब्ध नहीं है। + + + हटाने के लिए कोई उपयोगकर्ता डेटा उपलब्ध नहीं है। + %1$s का सारा मापन डेटा हटा दिया गया है। + %1$s के लिए कोई मापन डेटा नहीं मिला। + %1$s का डेटा हटाने में त्रुटि हुई। + डेटा हटाने की प्रक्रिया शुरू नहीं की जा सकी: कोई उपयोगकर्ता नहीं चुना गया। + + + त्रुटि: डेटाबेस का नाम प्राप्त नहीं किया जा सका। + त्रुटि: मुख्य डेटाबेस फ़ाइल \'%1$s\' नहीं मिली। + बैकअप विफल: चयनित स्थान पर बैकअप फ़ाइल नहीं बनाई जा सकी। + डेटाबेस बैकअप के दौरान त्रुटि: %1$s + डेटाबेस का बैकअप सफलतापूर्वक ले लिया गया है। + + + रिस्टोर विफल: बैकअप फ़ाइल को पढ़ा नहीं जा सका। + रिस्टोर विफल: बैकअप फ़ाइल वैध ZIP आर्काइव नहीं है या दूषित (corrupted) है। + रिस्टोर विफल: बैकअप में आवश्यक डेटाबेस फ़ाइलें नहीं मिलीं। + डेटाबेस रिस्टोर के दौरान त्रुटि: %1$s + डेटाबेस सफलतापूर्वक रिस्टोर हो गया है। कृपया ऐप को रीस्टार्ट करें। + + + संपूर्ण डेटाबेस हटा दिया गया है। + संपूर्ण डेटाबेस हटाने में त्रुटि हुई। + + + मूल्यांकन संभव नहीं: मापन के समय का कोई मिलान खाता आयु वर्ग नहीं मिला। + असामान्य मान: संभावित सीमा (%1$.0f–%2$.0f%%) से बाहर है। + + + यह वेइंग स्केल पेयर नहीं है!\n\nपेयरिंग मोड में जाने के लिए स्केल के नीचे दिए गए बटन को दबाकर रखें, और फिर डिवाइस का पासवर्ड प्राप्त करने के लिए दोबारा कनेक्ट करें। + पेयरिंग सफल रही!\n\nमापन डेटा प्राप्त करने के लिए दोबारा कनेक्ट करें। + स्केल पर नया उपयोगकर्ता बनाएं। + बैटरी कम है (%d%%), कृपया स्केल की बैटरी बदलें या चार्ज करें + संदर्भ मापन (reference measurements) के लिए कृपया स्केल पर नंगे पैर खड़े हों + वजन मापा जा रहा है: %.2f + एक साथ स्केल का उपयोग करने वाले उपयोगकर्ताओं की अधिकतम संख्या पूरी हो चुकी है + + openScale डेटा को पढ़ना/लिखना, जिसमें उपयोगकर्ता की जानकारी और सभी सहेजे गए मापन शामिल हैं + openScale डेटा को पढ़ें और लिखें + + स्केल पर उपयोगकर्ता चुनें + सूची में से अपना उपयोगकर्ता चुनें या एक नया बनाएं। + उपयोगकर्ता चयन + पुष्टि कोड दर्ज करें + कृपया अपने स्केल पर प्रदर्शित कोड दर्ज करें। + कोड प्रविष्टि + पुष्टि कोड + उपयोगकर्ता सूची खाली है या दूषित है। + त्रुटि: उपयोगकर्ता डेटा ठीक से लोड नहीं किया जा सका। + कृपया एक उपयोगकर्ता चुनें। + कृपया एक वैध कोड दर्ज करें। + + %1$d मापन सहेजे गए + + + कृपया स्केल पर नंगे पैर खड़े हों + + + मौजूदा मैपिंग का उपयोग किया जा रहा है: ऐप उपयोगकर्ता %1$d ↔ स्केल स्लॉट %2$d। + कृपया स्लॉट %1$d के लिए सहमति कोड (consent code) दर्ज करें। + कोई सहमति कोड उपलब्ध नहीं है। मापन को पढ़ा जा सकता है, लेकिन उपयोगकर्ता सेटिंग्स को बदला नहीं जा सकता। + पूर्ण कार्यक्षमता के लिए, स्केल पर एक नया उपयोगकर्ता बनाएं। + स्केल पर नया उपयोगकर्ता बनाया जा रहा है… + कोई उपयोगकर्ता मैपिंग नहीं मिली; स्केल पर एक नया उपयोगकर्ता बनाया जा रहा है… + नोटिफिकेशन के माध्यम से मापन की प्रतीक्षा की जा रही है। + ऐप उपयोगकर्ता %1$d को स्केल स्लॉट %2$d से लिंक कर दिया गया है। + + + स्केल की बैटरी कम है (%1$d%%)। + स्केल पर उपयोगकर्ता बनाने में विफलता (कोड %1$d)। + स्केल पर कोई खाली उपयोगकर्ता स्लॉट नहीं है। कृपया डिवाइस पर किसी उपयोगकर्ता को हटाएं और पुन: प्रयास करें। + %1$s के लिए नोटिफिकेशन सक्षम करने में विफलता। + %1$s पर लिखने में विफलता। + %1$s के लिए नोटिफिकेशन स्थिति बदलने में विफलता। + सहमति कोड स्वीकार नहीं किया गया। कृपया जांचें और पुन: प्रयास करें। + + + सहमति कोड स्वीकार नहीं किया गया। कृपया जांचें और पुन: प्रयास करें। + उपयोगकर्ता फीडबैक भेजने में त्रुटि: %1$s + पुनः कनेक्ट किया जा रहा है… (प्रयास %1$d/%2$d) + नोटिफिकेशन के माध्यम से मापन की प्रतीक्षा की जा रही है। + + + setNotifyOn(%1$s) के लिए कोई पेरिफेरल (peripheral) नहीं मिला + विशेषता %1$s नहीं मिली + write(%1$s) के लिए कोई पेरिफेरल नहीं मिला + read(%1$s) के लिए कोई पेरिफेरल नहीं मिला + %1$s पर लिखना विफल रहा: %2$s + %1$s के लिए नोटिफिकेशन स्थिति विफल रही: %2$s + + + कोई उपयोगकर्ता नहीं चुना गया + त्रुटि + + कनेक्ट करने पर हैंडलर विफल रहा: %1$s + %1$s के लिए हैंडलर पार्स त्रुटि: %2$s + इस डिवाइस पर कोई ब्लूटूथ एडाप्टर नहीं मिला। + डिवाइस नहीं मिला। सुनिश्चित करें कि यह चालू है और सीमा के भीतर है। + उपयोगकर्ता %1$s के लिए स्केल स्लॉट %2$s पर मौजूदा मैपिंग का उपयोग किया जा रहा है। ऑटो-सहमति (auto-consent) का प्रयास किया जा रहा है। + अमान्य पिन (PIN) प्रारूप। कृपया एक वैध नंबर दर्ज करें। + स्केल पर उपयोगकर्ता को पंजीकृत करने में विफलता। त्रुटि: %1$s + स्केल पर उपयोगकर्ता की पुष्टि करने में विफलता। त्रुटि: %1$s + स्केल से उपयोगकर्ता सूची प्राप्त करने में विफलता। त्रुटि: %1$s + उपयोगकर्ता सेटअप विफल। स्केल से उपयोगकर्ता सूची प्राप्त नहीं की जा सकी। + + स्केल पर नया उपयोगकर्ता बनाएं (UDS असाइन किया गया स्लॉट) + स्लॉट P%1$d: %2$s (वर्तमान ऐप उपयोगकर्ता) + स्लॉट P%1$d: %2$s द्वारा उपयोग में (कब्जा लेने के लिए चुनें?) + स्लॉट P%1$d: व्यस्त/भरा हुआ (अनमैप किया गया) + + + + P%1$d: %2$s (वर्तमान) + P%1$d: %2$s द्वारा उपयोग में (कब्जा लें?) + P%1$d: व्यस्त/भरा हुआ (लिंक नहीं है) + P%1$d: यहाँ \'%2$s\' बनाएं + From c89905e0fead7c037c82feebf6d2f95d12479c79 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 18 Jul 2026 09:07:50 +0200 Subject: [PATCH 03/72] Add Bulgarian, Catalan, Hindi, Portuguese, and Persian to language list and update Hindi strings --- .../java/com/health/openscale/core/data/Enums.kt | 6 +++++- android_app/app/src/main/res/values-hi/strings.xml | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index a4d621b10..ccc894e25 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -91,7 +91,8 @@ enum class SupportedLanguage(val code: String, val nativeDisplayName: String) { //ARABIC("ar", "Arabic (العربية)"), //BENGALI("bn-BD", "Bengali (বাংলা)"), BASQUE("eu", "Basque (Euskara)"), - //CATALAN("ca", "Catalan (català)"), + BULGARIAN("bg", "Bulgarian (български език)"), + CATALAN("ca", "Catalan (català)"), CHINESE_SIMPLIFIED("zh-CN", "Chinese (simplified; 中文 (汉语))"), CHINESE_TRADITIONAL("zh-TW", "Chinese (traditional; 中文 (繁體))"), //CROATIAN("hr", "Croatian (hrvatski jezik)"), @@ -106,6 +107,7 @@ enum class SupportedLanguage(val code: String, val nativeDisplayName: String) { GERMAN("de", "German (Deutsch)"), GREEK("el", "Greek (ελληνικά)"), HEBREW("iw", "Hebrew (עברית)"), + HINDI("hi", "Hindi (हिन्दी)"), //HUNGARIAN("hu", "Hungarian (magyar)"), //INDONESIAN("id", "Indonesian (Bahasa Indonesia)"), ITALIAN("it", "Italian (Italiano)"), @@ -114,7 +116,9 @@ enum class SupportedLanguage(val code: String, val nativeDisplayName: String) { //LITHUANIAN("lt", "Lithuanian (lietuvių kalba)"), //NORWEGIAN_BOKMAL("nb", "Norwegian Bokmål (Norsk)"), POLISH("pl", "Polish (język polski)"), + PORTUGUESE("pt", "Portuguese (Português)"), PORTUGUESE_BRAZIL("pt-BR", "Portuguese (Brazil; Português)"), + PERSIAN("fa", "Persian (فارسی)"), ROMANIAN("ro", "Romanian (Română)"), RUSSIAN("ru", "Russian (русский)"), //SLOVAK("sk", "Slovak (Slovenčina)"), diff --git a/android_app/app/src/main/res/values-hi/strings.xml b/android_app/app/src/main/res/values-hi/strings.xml index ccf2be1b1..88270ca5b 100644 --- a/android_app/app/src/main/res/values-hi/strings.xml +++ b/android_app/app/src/main/res/values-hi/strings.xml @@ -432,7 +432,7 @@ प्रोजेक्ट वेबसाइट पर जाएं - प्रारंभिकरण प्रक्रिया (इनिशियलाइजेशन)... + प्रारंभिकरण प्रक्रिया (इनिशियलाइजेशन)… कनेक्शन टूट गया कनेक्शन डिस्कनेक्ट हो गया डिवाइस नहीं मिला। %s @@ -455,9 +455,11 @@ समय सीमा (टाइम रेंज) आइकन चार्ट डेटा फ़िल्टर करें - %s चयनित माप फ़िल्टर दिखाई दे रहा है + %s चयनित + माप फ़िल्टर दिखाई दे रहा है माप फ़िल्टर छिपा हुआ है - %1$s (%2$d प्रविष्टियां) सभी दिन + %1$s (%2$d प्रविष्टियां) + सभी दिन पिछले 7 दिन पिछले 30 दिन पिछले 365 दिन @@ -547,7 +549,7 @@ %1$d दिनों से कोई महत्वपूर्ण बदलाव नहीं — आपका शरीर अनुकूलित (adapt) हो रहा है। हालिया गिरावट का रुझान — आपकी दीर्घकालिक (long-term) दिशा की तुलना में सकारात्मक बदलाव। हालिया बढ़त का रुझान — अपनी दीर्घकालिक (long-term) दिशा पर नज़र रखें। - अत्यधिक उतार-चढ़ाव पाया गया — यह सामान्य है और शरीर में होने वाले प्राकृतिक बदलावों जैसे कि 'वॉटर रिटेंशन' (पानी जमा होना) को दर्शाता है। + अत्यधिक उतार-चढ़ाव पाया गया — यह सामान्य है और शरीर में होने वाले प्राकृतिक बदलावों जैसे कि \'वॉटर रिटेंशन\' (पानी जमा होना) को दर्शाता है। आपके मान (values) बहुत सुसंगत हैं — यह एक विश्वसनीय डेटा आधार है। आपके शरीर की संरचना (Body Composition) इस विश्लेषण की गणना करने के लिए पिछले %2$d दिनों के भीतर चारों मापों (वजन, बॉडी फैट, मांसपेशियां, पानी) के साथ कम से कम %1$d माप होने आवश्यक हैं। @@ -782,7 +784,7 @@ "तारीख के प्रारूप में त्रुटि के कारण %1$d पंक्तियाँ छोड़ दी गईं।" "%1$d व्यक्तिगत मानों का विश्लेषण नहीं किया जा सका।" CSV फ़ाइल में कोई वैध डेटा नहीं मिला, या सभी पंक्तियों में त्रुटियाँ थीं। - इम्पोर्ट विफल रहा: CSV हेडर में अनिवार्य 'तारीख' कॉलम गायब है। + इम्पोर्ट विफल रहा: CSV हेडर में अनिवार्य \'तारीख\' कॉलम गायब है। इम्पोर्ट विफल रहा: फ़ाइल को पढ़ा नहीं जा सका। इम्पोर्ट के दौरान त्रुटि हुई: %1$s इम्पोर्ट करने के लिए कोई उपयोगकर्ता उपलब्ध नहीं है। From bc23d5aff4e6c4b8195830ed357e220abce6c2bf Mon Sep 17 00:00:00 2001 From: 3125589 <157371567@qq.com> Date: Sat, 18 Jul 2026 15:12:28 +0800 Subject: [PATCH 04/72] Add YUNMAI-Scale-3 to YunmaiHandler device whitelist (#1416) --- .../com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt index d364ee19b..1448830ae 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt @@ -66,7 +66,7 @@ class YunmaiHandler( override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { val name = device.name val matches = when { - isMini -> name.startsWith("YUNMAI-SIGNAL") || name.startsWith("YUNMAI-ISM") || name.startsWith("YUNMAI-ISC2P") + isMini -> name.startsWith("YUNMAI-SIGNAL") || name.startsWith("YUNMAI-ISM") || name.startsWith("YUNMAI-ISC2P") || name == "YUNMAI-Scale-3" else -> name.startsWith("YUNMAI-ISSE") } if (!matches) return null From 09f2df05ec6ed68f835f3cc02f0036b1351c080a Mon Sep 17 00:00:00 2001 From: pagarsky <36376725+pagarsky@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:28:10 +0300 Subject: [PATCH 05/72] Add support for Yunmai X (YMBS-M268) broadcast-only scale (#1418) * Add support for Yunmai X (YMBS-M268) broadcast-only scale The Yunmai X (YMBS-M268) advertises measurements as manufacturer-specific data without a local name and needs no GATT connection (the vendor app calls this device family "YUNMAI-ADV"). Frames echo the device MAC byte-reversed, carry a 0B 74 17 signature, a state byte (live/stable/ final), weight and impedance as u16 BE, and an XOR checksum. Identification uses the frame structure + MAC echo during scans and the advertised 16-bit service UUID 0x1320 for saved-device snapshots (the scale advertises no name to match on). Body composition is derived from weight + impedance via YunmaiLib, like the Yunmai SE/Mini handler. Protocol reverse-engineered from an advertisement capture and confirmed end-to-end against a live measurement driven through this handler (weight and full body composition derived and stored). Unit-test fixtures are synthetic frames built to the observed layout. Co-Authored-By: Claude Fable 5 * Show handler name for saved scales that advertise no name Broadcast-only scales such as the Yunmai X advertise no local name, so the saved-scale panel rendered a blank line above the MAC. Fall back to the resolved handler display name when the advertised name is blank. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- README.md | 2 +- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../core/bluetooth/scales/YunmaiXHandler.kt | 207 ++++++++++++++++++ .../ui/screen/settings/BluetoothScreen.kt | 5 +- .../bluetooth/scales/YunmaiXHandlerTest.kt | 186 ++++++++++++++++ 5 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandlerTest.kt diff --git a/README.md b/README.md index fde2c858b..2409117f0 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ openScale has built-in support for a number of Bluetooth (BLE or "smart") scales - Silvercrest SBF75, SBF77 - Vigorun - Xiaomi Mi scale v1 and v2 -- Yunmai Mini and SE +- Yunmai Mini, SE and X - iHealth HS3 - Easy Home 64050 - and many more diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index c46c33d71..b15f16965 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -72,6 +72,7 @@ import com.health.openscale.core.bluetooth.scales.StandardBeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.TrisaBodyAnalyzeHandler import com.health.openscale.core.bluetooth.scales.TuningProfile import com.health.openscale.core.bluetooth.scales.YunmaiHandler +import com.health.openscale.core.bluetooth.scales.YunmaiXHandler import com.health.openscale.core.facade.MeasurementFacade import com.health.openscale.core.facade.SettingsFacade import com.health.openscale.core.facade.UserFacade @@ -111,6 +112,7 @@ class ScaleFactory @Inject constructor( RealmeSmartScaleHandler(), YunmaiHandler(isMini = false), YunmaiHandler(isMini = true), + YunmaiXHandler(), TrisaBodyAnalyzeHandler(), SanitasSbf72Handler(), StandardBeurerSanitasHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt new file mode 100644 index 000000000..7d56a5e3a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt @@ -0,0 +1,207 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.YunmaiLib +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date + +/** + * Advertisement parser for the Yunmai X (YMBS-M268) broadcast protocol. + * + * The scale never needs a GATT connection for measurements: it broadcasts them as + * manufacturer-specific data (the vendor app labels this device family "YUNMAI-ADV"). + * It advertises WITHOUT a local name, so identification relies on the advertisement + * structure itself plus the advertised 16-bit service UUID 0x1320. + * + * Full manufacturer AD structure (type 0xFF, 16 bytes; reverse-engineered from a + * YMBS-M268 advertisement capture and confirmed against a live measurement): + * + * [0..5] device MAC, byte-reversed + * [6..8] constant signature 0B 74 17 + * [9] per-session byte, constant within a weigh-in but varying between them + * (observed 0x04, 0x0F, 0x10); purpose unknown, so it is ignored + * [10] state: 01 = measuring (live weight), 02 = weight stable, + * 03 = final result incl. impedance + * [11..12] weight, u16 BE, unit 0.01 kg + * [13..14] impedance, u16 BE, Ω (zero until state 03) + * [15] XOR checksum over bytes [9..14] + * + * Android peels the first two bytes off as the manufacturer id, so the payload seen here is + * the last 14 bytes (offsets above shift by -2). That id is just part of the reversed MAC, + * so matching relies on the MAC echo, the 0B 74 17 signature and the checksum. + */ +internal object YunmaiXAdv { + const val STATE_LIVE = 0x01 + const val STATE_STABLE = 0x02 + const val STATE_FINAL = 0x03 + + /** 16-bit service UUID present in the scale's advertisements. */ + const val ADV_SERVICE_UUID16 = 0x1320 + + data class Frame(val state: Int, val weightKg: Float, val impedanceOhm: Int) + + /** Parse one Android-split manufacturer-data entry; [address] enables the MAC-echo check (skipped if null). */ + fun parse(manufacturerId: Int, payload: ByteArray?, address: String?): Frame? { + if (payload == null || payload.size != 14) return null + + // Constant signature + if (payload[4] != 0x0B.toByte() || payload[5] != 0x74.toByte() || payload[6] != 0x17.toByte()) { + return null + } + + // XOR checksum over the six bytes preceding it + var checksum = 0 + for (i in 7..12) checksum = checksum xor (payload[i].toInt() and 0xFF) + if (checksum != (payload[13].toInt() and 0xFF)) return null + + // The advertisement echoes the device MAC byte-reversed across the + // manufacturer id and the first four payload bytes. + val octets = address?.split(":")?.mapNotNull { it.toIntOrNull(16) } + if (octets != null && octets.size == 6) { + val macMatches = manufacturerId == ((octets[4] shl 8) or octets[5]) && + (payload[0].toInt() and 0xFF) == octets[3] && + (payload[1].toInt() and 0xFF) == octets[2] && + (payload[2].toInt() and 0xFF) == octets[1] && + (payload[3].toInt() and 0xFF) == octets[0] + if (!macMatches) return null + } + + val state = payload[8].toInt() and 0xFF + val weightRaw = ((payload[9].toInt() and 0xFF) shl 8) or (payload[10].toInt() and 0xFF) + val impedance = ((payload[11].toInt() and 0xFF) shl 8) or (payload[12].toInt() and 0xFF) + return Frame(state, weightRaw / 100.0f, impedance) + } +} + +/** + * Yunmai X (YMBS-M268) – broadcast-only handler, see [YunmaiXAdv] for the protocol. + * + * Body composition is derived from weight + impedance via [YunmaiLib], like the + * connected Yunmai SE/Mini handler. The scale repeats the final frame for many + * seconds, so the handler publishes once and re-arms only when a new measurement + * session (live/stable frames) is observed. + */ +class YunmaiXHandler : ScaleDeviceHandler() { + + private val deviceSupport = DeviceSupport( + displayName = "Yunmai X", + capabilities = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.LIVE_WEIGHT_STREAM + ), + implemented = setOf( + DeviceCapability.BODY_COMPOSITION + ), + linkMode = LinkMode.BROADCAST_ONLY + ) + + /** Publish the next final frame; re-armed by live/stable frames of a new session. */ + private var armed = true + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val manufacturerData = device.manufacturerData + if (manufacturerData != null) { + for (i in 0 until manufacturerData.size()) { + if (YunmaiXAdv.parse(manufacturerData.keyAt(i), manufacturerData.valueAt(i), device.address) != null) { + return deviceSupport + } + } + } + + // Saved-device snapshots carry no manufacturer data, and the scale advertises no + // name — fall back to the advertised 16-bit service UUID. + if (device.serviceUuids.any { it == uuid16(YunmaiXAdv.ADV_SERVICE_UUID16) }) { + return deviceSupport + } + + return null + } + + override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction { + val manufacturerData = result.scanRecord?.manufacturerSpecificData ?: return BroadcastAction.IGNORED + + var frame: YunmaiXAdv.Frame? = null + for (i in 0 until manufacturerData.size()) { + frame = YunmaiXAdv.parse( + manufacturerData.keyAt(i), + manufacturerData.valueAt(i), + result.device?.address + ) + if (frame != null) break + } + val f = frame ?: return BroadcastAction.IGNORED + + return when (f.state) { + YunmaiXAdv.STATE_LIVE, YunmaiXAdv.STATE_STABLE -> { + armed = true + BroadcastAction.CONSUMED_KEEP_SCANNING + } + + YunmaiXAdv.STATE_FINAL -> { + if (!armed) return BroadcastAction.CONSUMED_KEEP_SCANNING + if (f.weightKg <= 0f || !f.weightKg.isFinite()) return BroadcastAction.IGNORED + + publish(buildMeasurement(f, user)) + armed = false + logI("Measurement published: weight=${f.weightKg} kg, R=${f.impedanceOhm} Ω") + BroadcastAction.CONSUMED_STOP + } + + else -> BroadcastAction.IGNORED + } + } + + override fun onConnected(user: ScaleUser) { + logI("Yunmai X handler – broadcast-only, waiting for advertisements") + } + + private fun buildMeasurement(f: YunmaiXAdv.Frame, user: ScaleUser): ScaleMeasurement { + val m = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + weight = f.weightKg + } + + if (f.impedanceOhm > 0) { + m.impedance = f.impedanceOhm.toDouble() + + val sexInt = if (user.gender.isMale()) 1 else 0 + val yunmai = YunmaiLib(sexInt, user.bodyHeight, user.activityLevel) + val fatPct = yunmai.getFat(user.age, f.weightKg, f.impedanceOhm) + + if (fatPct > 0f && fatPct.isFinite()) { + m.fat = fatPct + m.muscle = yunmai.getMuscle(fatPct) + m.water = yunmai.getWater(fatPct) + m.bone = yunmai.getBoneMass(m.muscle, f.weightKg) + m.lbm = yunmai.getLeanBodyMass(f.weightKg, fatPct) + m.visceralFat = yunmai.getVisceralFat(fatPct, user.age) + } else { + logW("Body fat is zero/invalid (R=${f.impedanceOhm})") + } + } else { + logW("Final frame without impedance – weight-only measurement") + } + + return m + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt index c9447ba30..fc7663d39 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt @@ -248,7 +248,10 @@ fun BluetoothScreen( color = MaterialTheme.colorScheme.primary ) Text( - text = savedDevice?.name ?: stringResource(R.string.unknown_device), + // Some scales advertise no name; fall back to the handler's display name. + text = savedDevice?.name?.takeIf { it.isNotBlank() } + ?: savedDevice?.determinedHandlerDisplayName + ?: stringResource(R.string.unknown_device), style = MaterialTheme.typography.titleSmall ) Text( diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandlerTest.kt new file mode 100644 index 000000000..18dc8d580 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandlerTest.kt @@ -0,0 +1,186 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.util.SparseArray +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.service.ScannedDeviceInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +/** + * Tests for [YunmaiXAdv] frame parsing and [YunmaiXHandler.supportFor] matching. + * + * Fixtures are synthetic frames built to the reverse-engineered YMBS-M268 layout via + * [frame] (matching the shape observed on real hardware), not personal captures. The + * frames use a placeholder device address; Android parses the first two AD bytes as the + * manufacturer id, so [frame] returns the remaining 14-byte payload. + * + * Robolectric is required for android.util.SparseArray in ScannedDeviceInfo. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class YunmaiXHandlerTest { + + // Placeholder device: the advertisement echoes the MAC byte-reversed, and the last two + // bytes become Android's manufacturer id. + private val ADDRESS = "C0:FF:EE:12:34:56" + private val MFR_ID = 0x3456 // (0x34 << 8) or 0x56 + + /** + * Build a 14-byte manufacturer payload (as split by Android) for the placeholder device. + * + * @param state 0x01 live, 0x02 stable, 0x03 final + * @param weightRaw weight in units of 0.01 kg (u16 BE) + * @param impedance impedance in Ω (u16 BE); 0 until the final frame + * @param sessionByte the per-weigh-in byte at offset [7]; varies between sessions + */ + private fun frame(state: Int, weightRaw: Int, impedance: Int = 0, sessionByte: Int = 0x04): ByteArray { + val body = byteArrayOf( + 0x12.toByte(), 0xEE.toByte(), 0xFF.toByte(), 0xC0.toByte(), // MAC bytes 2..5, reversed + 0x0B, 0x74, 0x17, // signature + sessionByte.toByte(), + state.toByte(), + (weightRaw shr 8).toByte(), weightRaw.toByte(), + (impedance shr 8).toByte(), impedance.toByte() + ) + var xor = 0 + for (i in 7..12) xor = xor xor (body[i].toInt() and 0xFF) + return body + xor.toByte() + } + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun device( + payload: ByteArray? = null, + mfrId: Int = MFR_ID, + address: String = ADDRESS, + vararg services: Int + ) = ScannedDeviceInfo( + name = "", + address = address, + rssi = -50, + serviceUuids = services.map { uuid16(it) }, + manufacturerData = payload?.let { + SparseArray().apply { put(mfrId, it) } + }, + ) + + // --- Frame parsing -------------------------------------------------------- + + @Test + fun `parses live frame`() { + val f = YunmaiXAdv.parse(MFR_ID, frame(0x01, weightRaw = 7955), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_LIVE) + assertThat(f.weightKg).isWithin(1e-3f).of(79.55f) + assertThat(f.impedanceOhm).isEqualTo(0) + } + + @Test + fun `parses stable frame`() { + val f = YunmaiXAdv.parse(MFR_ID, frame(0x02, weightRaw = 8000), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_STABLE) + assertThat(f.weightKg).isWithin(1e-3f).of(80.00f) + } + + @Test + fun `parses final frame with impedance`() { + val f = YunmaiXAdv.parse(MFR_ID, frame(0x03, weightRaw = 8000, impedance = 500), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_FINAL) + assertThat(f.weightKg).isWithin(1e-3f).of(80.00f) + assertThat(f.impedanceOhm).isEqualTo(500) + } + + @Test + fun `parses final frame regardless of the per-session byte`() { + // byte[7] varies between weigh-ins and must not be treated as a constant. + val f = YunmaiXAdv.parse(MFR_ID, frame(0x03, weightRaw = 8000, impedance = 500, sessionByte = 0x10), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_FINAL) + assertThat(f.weightKg).isWithin(1e-3f).of(80.00f) + assertThat(f.impedanceOhm).isEqualTo(500) + } + + @Test + fun `parses idle frame as zero weight`() { + val f = YunmaiXAdv.parse(MFR_ID, frame(0x01, weightRaw = 0), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_LIVE) + assertThat(f.weightKg).isEqualTo(0f) + } + + @Test + fun `rejects corrupted checksum`() { + val corrupted = frame(0x03, weightRaw = 8000, impedance = 500).also { it[9] = 0x1C } // weight changed, checksum stale + assertThat(YunmaiXAdv.parse(MFR_ID, corrupted, ADDRESS)).isNull() + } + + @Test + fun `rejects wrong signature`() { + val wrongSig = frame(0x03, weightRaw = 8000, impedance = 500).also { it[5] = 0x75 } + assertThat(YunmaiXAdv.parse(MFR_ID, wrongSig, ADDRESS)).isNull() + } + + @Test + fun `rejects frame whose MAC echo does not match the device address`() { + assertThat(YunmaiXAdv.parse(MFR_ID, frame(0x03, weightRaw = 8000, impedance = 500), "AA:BB:CC:DD:EE:FF")).isNull() + } + + @Test + fun `rejects wrong payload size`() { + assertThat(YunmaiXAdv.parse(MFR_ID, frame(0x03, weightRaw = 8000).copyOf(13), ADDRESS)).isNull() + } + + // --- Device matching -------------------------------------------------------- + + @Test + fun `claims nameless device advertising a valid measurement frame`() { + assertThat(YunmaiXHandler().supportFor(device(frame(0x03, weightRaw = 8000, impedance = 500)))).isNotNull() + } + + @Test + fun `claims nameless device advertising an idle frame`() { + assertThat(YunmaiXHandler().supportFor(device(frame(0x01, weightRaw = 0)))).isNotNull() + } + + @Test + fun `claims saved-device snapshot via service uuid 0x1320`() { + // Snapshots carry no manufacturer data, only the advertised service UUIDs. + assertThat(YunmaiXHandler().supportFor(device(null, MFR_ID, ADDRESS, 0x1320))).isNotNull() + } + + @Test + fun `does not claim device with foreign manufacturer data`() { + // Same length, invalid signature/checksum (e.g. a wearable's frame) + val foreign = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) + assertThat(YunmaiXHandler().supportFor(device(foreign))).isNull() + } + + @Test + fun `does not claim device without manufacturer data or 0x1320 service`() { + assertThat(YunmaiXHandler().supportFor(device(null, MFR_ID, ADDRESS, 0x180D, 0x180F))).isNull() + } + + @Test + fun `does not claim valid frame relocated to another device address`() { + // Replayed/echoed frame on a different MAC must not match. + assertThat(YunmaiXHandler().supportFor(device(frame(0x03, weightRaw = 8000, impedance = 500), address = "AA:BB:CC:DD:EE:FF"))).isNull() + } +} From 9d165a17eeffb8d8c4354c63f04c84e6384d0467 Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 18 Jul 2026 12:32:09 +0500 Subject: [PATCH 06/72] feat: add Scale Up broadcast scale protocol (#1419) --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../core/bluetooth/scales/ScaleupHandler.kt | 152 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleupHandler.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index b15f16965..7f0f4b801 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -21,6 +21,7 @@ import android.content.Context import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler import com.health.openscale.core.bluetooth.scales.AAAxHandler +import com.health.openscale.core.bluetooth.scales.ScaleupHandler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler import com.health.openscale.core.bluetooth.scales.CultSmartScaleProHandler import com.health.openscale.core.bluetooth.scales.BeurerSanitasHandler @@ -151,6 +152,7 @@ class ScaleFactory @Inject constructor( CustomOpenScaleHandler(), BeurerSanitasHandler(), AAAxHandler(), + ScaleupHandler(), ActiveEraBF06Handler(), DrTrustSSW532Handler(), BodyConnectHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleupHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleupHandler.kt new file mode 100644 index 000000000..1e70aca7b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleupHandler.kt @@ -0,0 +1,152 @@ +/* + * 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 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 java.util.Date +import java.util.UUID + +/** + * ScaleupHandler + * + * Broadcast-only handler for Scaleup-protocol smart scales that advertise weight + * and impedance in non-connectable BLE Manufacturer Specific Data. + * + * Raw AD layout (after type byte 0xFF, 11 bytes): + * [flag:1][weight:2 BE][impedance:2 BE][MAC:6] + * + * Android ScanRecord consumes the first two bytes as a little-endian company ID: + * key = (weightMSB << 8) | flag + * data = [weightLSB, impedanceMSB, impedanceLSB, MAC×6] (9 bytes) + * + * Flag values: + * 0xD0 – measuring (weight visible, impedance zero) + * 0xE0 – stabilized (final weight, impedance present) + * + * Weight: big-endian uint16, unit 0.01 kg (10 g) + * Impedance: big-endian uint16, unit unknown + * + * Operated by [BroadcastScaleAdapter]; GATT hooks are intentional no-ops. + */ +class ScaleupHandler : ScaleDeviceHandler() { + + companion object { + private const val FLAG_MEASURING = 0xD0 + private const val FLAG_STABLE = 0xE0 + private const val MIN_DATA_LEN = 9 + private const val WEIGHT_MIN_KG = 0.5f + private const val WEIGHT_MAX_KG = 300f + } + + private var hasPublished = false + + // ── Device identification ───────────────────────────────────────────────── + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.trim() + if (name.equals("da", ignoreCase = true)) { + return deviceSupport + } + + val m = device.manufacturerData ?: return null + for (i in 0 until m.size()) { + val key = m.keyAt(i) + val flag = key and 0xFF + if (flag == FLAG_MEASURING || flag == FLAG_STABLE) { + return deviceSupport + } + } + return null + } + + // ── GATT hooks (intentional no-ops) ────────────────────────────────────── + + override fun onConnected(user: ScaleUser) = Unit + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) = Unit + + override fun onDisconnected() { + hasPublished = false + } + + // ── Broadcast reception ─────────────────────────────────────────────────── + + override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction { + val msd = result.scanRecord?.manufacturerSpecificData + ?: return BroadcastAction.IGNORED + + for (i in 0 until msd.size()) { + val key = msd.keyAt(i) + val data = msd.valueAt(i) ?: continue + if (data.size < MIN_DATA_LEN) continue + + val flag = key and 0xFF + if (flag != FLAG_MEASURING && flag != FLAG_STABLE) continue + + // New measurement cycle (0xD0) resets the session guard + if (flag == FLAG_MEASURING) { + if (hasPublished) LogManager.d(TAG, "new measurement cycle, resetting session guard") + hasPublished = false + } + if (hasPublished) { + LogManager.d(TAG, "already published this session → CONSUMED_STOP") + return BroadcastAction.CONSUMED_STOP + } + + val weightRaw = ((key shr 8) shl 8) or (data[0].toInt() and 0xFF) + val weightKg = weightRaw / 100.0f + + if (weightKg < WEIGHT_MIN_KG || weightKg > WEIGHT_MAX_KG) { + LogManager.d(TAG, "weight ${"%.2f".format(weightKg)} kg out of range; discarded") + continue + } + + if (flag == FLAG_MEASURING) { + LogManager.d(TAG, "measuring ${"%.2f".format(weightKg)} kg") + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + val impedanceRaw = ((data[1].toInt() and 0xFF) shl 8) or + (data[2].toInt() and 0xFF) + + LogManager.i(TAG, "stable ${"%.2f".format(weightKg)} kg, impedance=$impedanceRaw → publish") + + publish(ScaleMeasurement().apply { + userId = user.id + weight = weightKg + dateTime = Date() + }) + + hasPublished = true + return BroadcastAction.CONSUMED_STOP + } + + return BroadcastAction.IGNORED + } + + private val deviceSupport = DeviceSupport( + displayName = "Scale Up", + capabilities = setOf(DeviceCapability.LIVE_WEIGHT_STREAM), + implemented = setOf(DeviceCapability.LIVE_WEIGHT_STREAM), + linkMode = LinkMode.BROADCAST_ONLY + ) +} From c7d1134ddf9d95118e16ffe9b55cd8d2f4e4a701 Mon Sep 17 00:00:00 2001 From: Tanner Meade <46303912+tannermeade@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:38:39 -0600 Subject: [PATCH 07/72] Added scale support for EEBBL scale. (#1420) * Added scale support for EEBBL scale. * Remove "DEBUG" from supported device names in EEBBLHandler --------- Co-authored-by: oliexdev --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../core/bluetooth/scales/EEBBLHandler.kt | 258 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 7f0f4b801..a1479d716 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -69,6 +69,7 @@ import com.health.openscale.core.bluetooth.scales.SoehnleHandler import com.health.openscale.core.bluetooth.scales.SppScaleAdapter import com.health.openscale.core.bluetooth.scales.TaylorBIAHandler import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler +import com.health.openscale.core.bluetooth.scales.EEBBLHandler import com.health.openscale.core.bluetooth.scales.StandardBeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.TrisaBodyAnalyzeHandler import com.health.openscale.core.bluetooth.scales.TuningProfile @@ -131,6 +132,7 @@ class ScaleFactory @Inject constructor( MiScaleHandler(), RunstarR5Handler(), RobiS9Handler(), + EEBBLHandler(), MGBHandler(), MedisanaBs44xHandler(), InlifeHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt new file mode 100644 index 000000000..e9231136b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt @@ -0,0 +1,258 @@ +package com.health.openscale.core.bluetooth.scales + +import com.health.openscale.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 java.util.Calendar +import java.util.UUID + +/** + * Handler for EEBBL Body Fat Scale (P1) - 8-electrode with handle. + */ +class EEBBLHandler : ScaleDeviceHandler() { + + private val SERVICE_UUID = uuid16(0xFFB0) + private val CHAR_NOTIFY = uuid16(0xFFB2) + private val CHAR_WRITE = uuid16(0xFFB1) + + private var lastRawWeight: Int? = null + private var stableCount = 0 + private val REQUIRED_STABLE_COUNT = 8 + + private var weightPublished = false + private var publishedWeightKg: Float = 0f + private var extraFinalPackets = 0 + + // We publish weight *immediately* on stable detection. + // Then we give the scale a short grace period to send body-comp data (if any), + // and then we proactively disconnect. This keeps the scale in a clean idle state + // and allows reliable user switching / assisted weighing. + private val BODY_COMP_GRACE_PACKETS = 12 // short window after publishing weight to catch body-comp + private val KEEP_ALIVE_FINAL_PACKETS = 80 + private val STEPPED_OFF_THRESHOLD_PACKETS = 5 // trigger disconnect sooner after weight drop + + private var pendingMeasurement: ScaleMeasurement? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.uppercase() + if (name.contains("EEBBL") || name.contains("P1")) { + return DeviceSupport( + displayName = "EEBBL Body Fat Scale (P1) - 8-electrode", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG, + DeviceCapability.BODY_COMPOSITION // partial: weight reliable; full metrics need parse impl + good measurement log + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + return null + } + + override fun onConnected(user: ScaleUser) { + logI("=== EEBBL connected (custom handler) ===") + resetState() + + setNotifyOn(SERVICE_UUID, CHAR_NOTIFY) + + // Init sequence (sent quickly; will interleave with incoming measurement stream) + writeInit(0xF7, 0, 0, 0) + writeInit(0xFA, 0, 0, 0) + + val sex = if (user.gender.isMale()) 1 else 2 + val age = user.age + val heightCm = user.bodyHeight.toInt().coerceIn(100, 220) + writeInit(0xFB, sex, age, heightCm) + + val now = Calendar.getInstance() + val yy = (now.get(Calendar.YEAR) - 2000).coerceIn(0, 99) + val mm = now.get(Calendar.MONTH) + 1 + val dd = now.get(Calendar.DAY_OF_MONTH) + writeInit(0xFD, yy, mm, dd) + + val HH = now.get(Calendar.HOUR_OF_DAY) + val MM = now.get(Calendar.MINUTE) + val SS = now.get(Calendar.SECOND) + writeInit(0xFC, HH, MM, SS) + + writeInit(0xFE, 6, user.scaleUnit.toInt(), 0) + + userInfo(R.string.bt_info_step_on_scale) + } + + private fun resetState() { + lastRawWeight = null + stableCount = 0 + weightPublished = false + publishedWeightKg = 0f + extraFinalPackets = 0 + pendingMeasurement = null + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (characteristic != CHAR_NOTIFY) return + if (data.size < 20) return + + // Primary path: A2 measurement packets (all observed packets match xx 07 00 A2 ... ) + if (data[1] == 0x07.toByte() && data[2] == 0x00.toByte() && data[3] == 0xA2.toByte()) { + processA2Packet(data) + return + } + + // Fallback / alternative rich indication path (for possible future/official-app formats with embedded 1F 01 type markers) + val weightIdx = findWeightPattern(data) + if (weightIdx >= 0) { + processRichIndicationPacket(data, weightIdx) + return + } + + // Unknown packet - log for debugging new firmware variants + logD("Unknown notify packet on FFB2: ${data.joinToString(" ") { "%02X".format(it) }}") + } + + private fun findWeightPattern(data: ByteArray): Int { + for (i in 0 until data.size - 4) { + if (data[i] == 0x1F.toByte() && data[i + 1] == 0x01.toByte() && + (data[i + 2].toInt() and 0xFF) in 0x01..0x03) return i + } + return -1 + } + + private fun processA2Packet(data: ByteArray) { + val flag = data[4].toInt() and 0xFF + val raw = ((data[6].toInt() and 0xFF) shl 16) or + ((data[7].toInt() and 0xFF) shl 8) or + (data[8].toInt() and 0xFF) + val weight = raw / 1000.0f + + if (flag == 0x03) { + // Final / stable measurement phase + if (lastRawWeight == raw) { + stableCount++ + } else { + lastRawWeight = raw + stableCount = 1 + } + + if (!weightPublished && stableCount >= REQUIRED_STABLE_COUNT && weight in 30f..250f) { + pendingMeasurement = ScaleMeasurement().apply { this.weight = weight } + logI("*** STABLE WEIGHT DETECTED: $weight kg (raw=$raw) — publishing + disconnecting immediately so measurement is tied to the correct user (fixes assisted weighing / baby mode) ***") + publish(pendingMeasurement!!) + weightPublished = true + publishedWeightKg = weight + pendingMeasurement = null + requestDisconnect() // Clean session end — next person presses Connect with the right user selected + } + + if (weightPublished && pendingMeasurement != null) { + extraFinalPackets++ + + val extraBytes = data.sliceArray(9..18) + val hasExtra = extraBytes.any { (it.toInt() and 0xFF) != 0 } + + // Always log the critical final packets so user can see if/when body comp data arrives + if (hasExtra || extraFinalPackets <= 5 || extraFinalPackets % 5 == 0) { + logD("A2 FINAL #$extraFinalPackets w=${weight}kg hasExtra=$hasExtra extra=[${extraBytes.joinToString(" ") { "%02X".format(it) }}] last=${data.getOrNull(19)?.toInt()?.and(0xFF) ?: 0}") + } + + if (hasExtra) { + publish(pendingMeasurement!!) + pendingMeasurement = null + weightPublished = false + requestDisconnect() + return + } + + // Short grace period after we already published the weight. + // If we haven't received body-comp data by now, we disconnect anyway. + if (extraFinalPackets >= BODY_COMP_GRACE_PACKETS) { + logI("=== Grace period ended after publishing weight — disconnecting (body comp data not received or already processed) ===") + if (pendingMeasurement != null) { + // In case body-comp arrived in the last packets + publish(pendingMeasurement!!) + pendingMeasurement = null + } + weightPublished = false + requestDisconnect() + return + } + + val steppedOff = raw < (publishedWeightKg * 1000 * 0.25).toInt() && extraFinalPackets > STEPPED_OFF_THRESHOLD_PACKETS + if (extraFinalPackets >= KEEP_ALIVE_FINAL_PACKETS || steppedOff) { + if (pendingMeasurement != null) { + logI("Publishing on keepalive/steppedOff and disconnecting") + publish(pendingMeasurement!!) + pendingMeasurement = null + } + weightPublished = false + requestDisconnect() + } + } + } else { + // Live / in-progress measurement (flag 0x01 or other). + // Allow a new measurement cycle if the user stepped off (weight dropped significantly). + val significantDrop = weightPublished && publishedWeightKg > 0f && + weight < (publishedWeightKg * 0.5f) // stepped off or very light + if (significantDrop || !weightPublished) { + stableCount = 0 + lastRawWeight = null + if (significantDrop) { + weightPublished = false + publishedWeightKg = 0f + pendingMeasurement = null + extraFinalPackets = 0 + logD("Significant weight drop detected — resetting for new measurement") + } + } + // Optional: log interesting live packets occasionally + if (raw > 10000 && (stableCount % 20 == 0)) { + logD("Live A2 packet flag=$flag raw=$raw (${weight}kg) bytes6-8=${data[6].toInt() and 0xFF} ${data[7].toInt() and 0xFF} ${data[8].toInt() and 0xFF}") + } + } + } + + private fun processRichIndicationPacket(data: ByteArray, weightStartIdx: Int) { + val raw = ((data[weightStartIdx + 2].toInt() and 0xFF) shl 16) or + ((data[weightStartIdx + 3].toInt() and 0xFF) shl 8) or + (data[weightStartIdx + 4].toInt() and 0xFF) + val weight = raw / 1000.0f + + logI("*** RICH BODY-COMP INDICATION (alternative path) weight=$weight kg at idx=$weightStartIdx ***") + + if (pendingMeasurement == null) { + pendingMeasurement = ScaleMeasurement().apply { this.weight = weight } + } else { + pendingMeasurement!!.weight = weight + } + + publish(pendingMeasurement!!) + // Do not null pending here in case more data comes; main path handles most cases + } + + + private fun writeInit(cmd: Int, b3: Int, b4: Int, b5: Int) { + val buf = ByteArray(8) + buf[0] = 0xAC.toByte() + buf[1] = 0x02.toByte() + buf[2] = (cmd and 0xFF).toByte() + buf[3] = (b3 and 0xFF).toByte() + buf[4] = (b4 and 0xFF).toByte() + buf[5] = (b5 and 0xFF).toByte() + buf[6] = 0xCC.toByte() + val sum = ((buf[2].toInt() and 0xFF) + (buf[3].toInt() and 0xFF) + + (buf[4].toInt() and 0xFF) + (buf[5].toInt() and 0xFF) + + (buf[6].toInt() and 0xFF)) and 0xFF + buf[7] = sum.toByte() + writeTo(SERVICE_UUID, CHAR_WRITE, buf, withResponse = true) + } +} From 7dbcaff58584e6a4749133b1710ff503755b67c4 Mon Sep 17 00:00:00 2001 From: Gustav Dahl <382210+gustav-dahl@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:42:42 +0200 Subject: [PATCH 08/72] Add support for Vitafit VT701 body-fat scale (#1423) The Vitafit "Body Fat" scale (VT701, Vitafit/Fitdays app) speaks a custom GATT protocol on service 0xFFF0 that no existing handler covers. The protocol was reverse-engineered from an HCI snoop of a Vitafit-app weigh-in and confirmed against the scale's displayed readings (85.00 kg / 393 Ohm, 84.95 kg / 389 Ohm). Frames are [hdr][len][0x26][type][data][chk][0xAA] with an XOR checksum. The handler streams live weight (type 0x10, stable at flag 0x02, u16 BE in 0.01 kg), acks the stable weight to trigger the impedance measurement, reads the whole-body impedance (type 0x11, u16 BE Ohm), and derives body composition via the shared StandardImpedanceLib. A 6-second fallback publishes weight-only if no impedance frame arrives. Co-authored-by: Claude Opus 4.8 Co-authored-by: OliE --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../bluetooth/scales/VitafitVT701Handler.kt | 229 ++++++++++++++++++ .../scales/VitafitVT701HandlerTest.kt | 87 +++++++ 3 files changed, 318 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/VitafitVT701Handler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/VitafitVT701HandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index a1479d716..332698f74 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -73,6 +73,7 @@ import com.health.openscale.core.bluetooth.scales.EEBBLHandler import com.health.openscale.core.bluetooth.scales.StandardBeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.TrisaBodyAnalyzeHandler import com.health.openscale.core.bluetooth.scales.TuningProfile +import com.health.openscale.core.bluetooth.scales.VitafitVT701Handler import com.health.openscale.core.bluetooth.scales.YunmaiHandler import com.health.openscale.core.bluetooth.scales.YunmaiXHandler import com.health.openscale.core.facade.MeasurementFacade @@ -132,6 +133,7 @@ class ScaleFactory @Inject constructor( MiScaleHandler(), RunstarR5Handler(), RobiS9Handler(), + VitafitVT701Handler(), EEBBLHandler(), MGBHandler(), MedisanaBs44xHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/VitafitVT701Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/VitafitVT701Handler.kt new file mode 100644 index 000000000..fae97153a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/VitafitVT701Handler.kt @@ -0,0 +1,229 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Date +import java.util.UUID + +/** + * Vitafit "Body Fat" smart scale (VT701), sold with the Vitafit / Fitdays app. + * + * Protocol reverse-engineered from an HCI snoop of two Vitafit-app weigh-ins and confirmed + * against the scale's displayed readings (85.00 kg and 84.95 kg): + * - Service 0xFFF0, notify characteristic 0xFFF1, write characteristic 0xFFF2. + * - Both directions use the frame `[hdr][len][0x26][type][data…][chk][0xAA]`, where + * `hdr` = 0x5A for scale→phone and 0xA5 for phone→scale, `len` counts every byte after + * itself (0x26 through 0xAA), and `chk` = XOR of `len` through the last data byte. + * - Type 0x10 frames stream live weight; byte 4 is a stability flag that settles to 0x02, and + * the weight is a 16-bit big-endian value in 0.01 kg at bytes 8..9. + * - After the phone acks the stable weight (`a5 05 26 10 02 …`) the scale reports the raw + * whole-body impedance in a type 0x11 frame (16-bit big-endian Ω at bytes 9..10, ~390 Ω). + * + * The scale itself sends only weight + impedance; body composition (fat/water/muscle) is left + * to openScale's estimator, which needs the raw impedance published here. + */ +class VitafitVT701Handler : ScaleDeviceHandler() { + + private val service = uuid16(0xFFF0) + private val notifyCharacteristic = uuid16(0xFFF1) + private val writeCharacteristic = uuid16(0xFFF2) + + private var savedWeightKg = -1f + private var published = false + private var fallbackJob: Job? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + if (!device.name.startsWith("Vitafit", ignoreCase = true)) return null + + val caps = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION + ) + return DeviceSupport( + displayName = "Vitafit VT701", + capabilities = caps, + implemented = caps, + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + savedWeightKg = -1f + published = false + fallbackJob?.cancel() + fallbackJob = null + + setNotifyOn(service, notifyCharacteristic) + + // Replay the app's static hello sequence. These frames carry no timestamp/user data, + // so they are safe to send verbatim; the scale then streams type 0x10 weight frames. + writeTo(service, writeCharacteristic, buildCommand(0x33, byteArrayOf(0x00))) + writeTo(service, writeCharacteristic, buildCommand(0x44, byteArrayOf())) + writeTo(service, writeCharacteristic, buildCommand(0x17, byteArrayOf(0x01))) + + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (characteristic != notifyCharacteristic) return + if (published) return + + parseStableWeightRaw(data)?.let { raw -> + if (savedWeightKg >= 0f) return // already latched; wait for impedance + savedWeightKg = raw / 100.0f + logD("Vitafit stable weight=$savedWeightKg kg") + + // Ack the stable weight; this prompts the scale to run the impedance measurement. + writeTo(service, writeCharacteristic, buildCommand(TYPE_WEIGHT, byteArrayOf(STABLE_FLAG.toByte()))) + + // Safety net: if the impedance frame never arrives (e.g. user steps off), publish + // weight-only so the reading is not lost. + fallbackJob = scope.launch { + delay(6000) + if (!published && savedWeightKg >= 0f) { + logD("No impedance within 6s; publishing weight-only") + publishMeasurement(savedWeightKg, 0, user) + } + } + return + } + + parseImpedanceOhm(data)?.let { ohm -> + if (savedWeightKg < 0f) return // impedance without a weight — ignore + // Ack the impedance frame like the app does. + writeTo(service, writeCharacteristic, buildCommand(TYPE_IMPEDANCE, byteArrayOf(0x00))) + publishMeasurement(savedWeightKg, ohm, user) + } + } + + private fun publishMeasurement(weightKg: Float, impedanceOhm: Int, user: ScaleUser) { + if (published) return + published = true + fallbackJob?.cancel() + fallbackJob = null + + val measurement = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + weight = weightKg + } + + // The scale reports only weight + raw impedance; derive body composition the same way the + // other impedance scales in this package do. The guard mirrors StandardImpedanceLib's note + // that its formulas only hold for whole-body impedance in a sane range. + if (impedanceOhm in 1 until 1500) { + measurement.impedance = impedanceOhm.toDouble() + val lib = StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = weightKg.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = impedanceOhm.toDouble(), + ) + measurement.fat = lib.totalFatPercentage.toFloat() + measurement.water = lib.totalBodyWaterPercentage.toFloat() + measurement.muscle = lib.skeletalMusclePercentage.toFloat() + measurement.bone = lib.boneMassKg.toFloat() + measurement.bmr = lib.basalMetabolicRate.toFloat() + } + + publish(measurement) + logI("Vitafit publish weight=$weightKg kg impedance=$impedanceOhm Ω") + requestDisconnect() + } + + companion object { + private const val HEADER_SCALE = 0x5A + private const val HEADER_PHONE = 0xA5 + private const val PRODUCT_ID = 0x26 + private const val TRAILER = 0xAA + private const val TYPE_WEIGHT = 0x10 + private const val TYPE_IMPEDANCE = 0x11 + private const val STABLE_FLAG = 0x02 + + /** + * Weight in raw 0.01 kg units from a valid, *stable* type 0x10 frame, or `null` if the + * frame is malformed, not a weight frame, or still settling. + * + * Layout: `5a 0a 26 10 [flag] 00 00 [w_hi_extra] [w_hi] [w_lo] [chk] aa`; the weight is + * the big-endian pair at bytes 8..9 (e.g. `21 34` = 8500 = 85.00 kg). + */ + fun parseStableWeightRaw(frame: ByteArray): Int? { + if (!isValidScaleFrame(frame)) return null + if ((frame[3].toInt() and 0xFF) != TYPE_WEIGHT) return null + if (frame.size < 12) return null + if ((frame[4].toInt() and 0xFF) != STABLE_FLAG) return null + return ((frame[8].toInt() and 0xFF) shl 8) or (frame[9].toInt() and 0xFF) + } + + /** + * Whole-body impedance in ohms from a valid type 0x11 frame, or `null` if the frame is + * malformed, not an impedance frame, or reports zero. + * + * Layout: `5a 0b 26 11 00 00 00 00 00 [z_hi] [z_lo] [chk] aa`; impedance is the big-endian + * pair at bytes 9..10 (e.g. `01 89` = 393 Ω). + */ + fun parseImpedanceOhm(frame: ByteArray): Int? { + if (!isValidScaleFrame(frame)) return null + if ((frame[3].toInt() and 0xFF) != TYPE_IMPEDANCE) return null + if (frame.size < 13) return null + val ohm = ((frame[9].toInt() and 0xFF) shl 8) or (frame[10].toInt() and 0xFF) + return if (ohm > 0) ohm else null + } + + /** True if [frame] is a well-formed scale frame with a matching XOR checksum. */ + fun isValidScaleFrame(frame: ByteArray): Boolean { + if (frame.size < 6) return false + if ((frame[0].toInt() and 0xFF) != HEADER_SCALE) return false + if ((frame[2].toInt() and 0xFF) != PRODUCT_ID) return false + if ((frame[frame.size - 1].toInt() and 0xFF) != TRAILER) return false + // len counts every byte after itself, i.e. index 2 through the trailer. + if ((frame[1].toInt() and 0xFF) != frame.size - 2) return false + return checksum(frame, 1, frame.size - 2) == (frame[frame.size - 2].toInt() and 0xFF) + } + + /** Build a phone→scale command frame with the correct length and checksum. */ + fun buildCommand(type: Int, data: ByteArray): ByteArray { + val len = data.size + 4 // 0x26, type, chk, trailer + val frame = ByteArray(data.size + 6) + frame[0] = HEADER_PHONE.toByte() + frame[1] = len.toByte() + frame[2] = PRODUCT_ID.toByte() + frame[3] = type.toByte() + data.copyInto(frame, 4) + frame[frame.size - 2] = checksum(frame, 1, frame.size - 2).toByte() + frame[frame.size - 1] = TRAILER.toByte() + return frame + } + + /** XOR of bytes in `[from, toExclusive)`. */ + private fun checksum(frame: ByteArray, from: Int, toExclusive: Int): Int { + var acc = 0 + for (i in from until toExclusive) acc = acc xor (frame[i].toInt() and 0xFF) + return acc and 0xFF + } + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/VitafitVT701HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/VitafitVT701HandlerTest.kt new file mode 100644 index 000000000..41d673e99 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/VitafitVT701HandlerTest.kt @@ -0,0 +1,87 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [VitafitVT701Handler] frame parsing and command building. + * + * Vectors are taken verbatim from the HCI snoop of a Vitafit-app weigh-in; the stable weight + * frame was confirmed against the scale's displayed 85.00 kg reading. + */ +class VitafitVT701HandlerTest { + + private fun hex(s: String): ByteArray = + s.filterNot { it.isWhitespace() }.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + @Test + fun `extracts 85_00 kg from the real stable frame`() { + val raw = VitafitVT701Handler.parseStableWeightRaw(hex("5a 0a 26 10 02 00 00 21 21 34 0a aa")) + assertThat(raw).isEqualTo(8500) + assertThat(raw!! / 100.0f).isWithin(1e-3f).of(85.00f) + } + + @Test + fun `ignores settling frames whose stability flag is not 0x02`() { + // flag 0x01, still measuring (85.15 kg preview). + assertThat(VitafitVT701Handler.parseStableWeightRaw(hex("5a 0a 26 10 01 00 00 21 21 43 7e aa"))) + .isNull() + } + + @Test + fun `extracts impedance from the type 0x11 frames of both weigh-ins`() { + // 85.00 kg weigh-in: 01 89 = 393 Ohm. + assertThat(VitafitVT701Handler.parseImpedanceOhm(hex("5a 0b 26 11 00 00 00 00 00 01 89 b4 aa"))) + .isEqualTo(393) + // 84.95 kg weigh-in: 01 85 = 389 Ohm. + assertThat(VitafitVT701Handler.parseImpedanceOhm(hex("5a 0b 26 11 00 00 00 00 00 01 85 b8 aa"))) + .isEqualTo(389) + } + + @Test + fun `does not confuse weight and impedance frame types`() { + // A weight frame is not an impedance frame and vice versa. + assertThat(VitafitVT701Handler.parseImpedanceOhm(hex("5a 0a 26 10 02 00 00 21 21 34 0a aa"))) + .isNull() + assertThat(VitafitVT701Handler.parseStableWeightRaw(hex("5a 0b 26 11 00 00 00 00 00 01 89 b4 aa"))) + .isNull() + } + + @Test + fun `rejects malformed and bad-checksum frames`() { + // corrupted checksum byte. + assertThat(VitafitVT701Handler.parseStableWeightRaw(hex("5a 0a 26 10 02 00 00 21 21 34 ff aa"))) + .isNull() + assertThat(VitafitVT701Handler.parseImpedanceOhm(hex("5a 0b 26 11 00 00 00 00 00 01 89 ff aa"))) + .isNull() + // truncated. + assertThat(VitafitVT701Handler.parseStableWeightRaw(ByteArray(5))).isNull() + } + + @Test + fun `builds the observed hello commands with correct length and checksum`() { + assertThat(VitafitVT701Handler.buildCommand(0x33, byteArrayOf(0x00))) + .isEqualTo(hex("a5 05 26 33 00 10 aa")) + assertThat(VitafitVT701Handler.buildCommand(0x44, byteArrayOf())) + .isEqualTo(hex("a5 04 26 44 66 aa")) + assertThat(VitafitVT701Handler.buildCommand(0x17, byteArrayOf(0x01))) + .isEqualTo(hex("a5 05 26 17 01 35 aa")) + } +} From cf1f27efe8294a4309cf591b9a2faf1fbc7b5d4d Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 18 Jul 2026 12:45:55 +0200 Subject: [PATCH 09/72] Normalize comma decimal separators in NumberInputDialog and UserDetailScreen --- .../openscale/ui/screen/dialog/NumberInputDialog.kt | 6 +++++- .../openscale/ui/screen/settings/UserDetailScreen.kt | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt index f79a599bc..be0867e3e 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/dialog/NumberInputDialog.kt @@ -134,7 +134,11 @@ fun NumberInputField( onValueChange(String.format(Locale.US, "%.3f", stDec).trimEnd('0').trimEnd('.')) } } else { - onValueChange(textValue.text) + // Normalize a comma decimal separator to a dot at the source, so every downstream + // consumer (measurement save loop, dialog onConfirm, …) can parse with the + // locale-independent toFloatOrNull() without repeating the replace at each call site. + // Comma-locale keyboards (e.g. Spanish) emit "80,5" and the input filter permits it. + onValueChange(textValue.text.replace(',', '.')) } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt index a0e880f56..44582eca3 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/UserDetailScreen.kt @@ -322,13 +322,17 @@ fun UserDetailScreen( OutlinedTextField( value = heightValueString, onValueChange = { newValue -> - val filteredValue = newValue.filter { it.isDigit() || it == '.' } + // Accept both '.' and ',' as the decimal separator (comma-locale keyboards such as + // Spanish emit a comma) and normalize to a dot immediately, so heightValueString is + // always dot-form for the toFloatOrNull() parses on save and unit conversion. + val filteredValue = newValue.filter { it.isDigit() || it == '.' || it == ',' } + .replace(',', '.') if (filteredValue.count { it == '.' } <= 1) { heightValueString = filteredValue } }, label = { Text(stringResource(R.string.user_detail_label_height)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.fillMaxWidth(), singleLine = true, trailingIcon = { From 61d36369c157e87bff4b3a1d6950031438f1dd8c Mon Sep 17 00:00:00 2001 From: DanyPM <94061157+DanyPM@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:48:05 +0200 Subject: [PATCH 10/72] Fix crash when importing pre-v2.5 database (issue #1410) (#1421) --- .../openscale/core/database/AppDatabase.kt | 172 +++++++++++++++++- .../openscale/core/database/MigrationTest.kt | 35 ++++ .../openscale/testutil/RoomTestSupport.kt | 75 ++++++++ 3 files changed, 281 insertions(+), 1 deletion(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt index 869e98a9b..5eec264ee 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt @@ -47,7 +47,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase = Room.databaseBuilder(ctx, AppDatabase::class.java, AppDatabase.Companion.DATABASE_NAME) - .addMigrations(MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15) .build() @Provides @@ -91,6 +91,176 @@ abstract class AppDatabase : RoomDatabase() { } } +// ----------------------------------------------------------------------------- +// Legacy v2.x migrations (schema versions 1..6). +// +// These were shipped by the old Java app (see +// v2.5.4:.../core/database/AppDatabase.java) and were dropped during the Kotlin +// rewrite, which registered only 6..15. A database exported from an older v2.x +// build carries a `user_version` below 6 (e.g. v2.3.1 == 5), so restoring it and +// launching v3.x left Room without a migration path and it crashed on launch +// (issue #1410). Restoring these brings a v1..v5 database up to version 6, where +// MIGRATION_6_7 (the legacy schema converter) takes over. The SQL is a verbatim +// port of the original, production-tested migrations; Room already wraps each +// migration in a transaction, so the old explicit begin/end calls are omitted. +// ----------------------------------------------------------------------------- + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + // Drop old index on datetime only + db.execSQL("DROP INDEX index_scaleMeasurements_datetime") + + // Rename old table + db.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld") + + // Create new table with foreign key + db.execSQL( + "CREATE TABLE scaleMeasurements" + + " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + " userId INTEGER NOT NULL, enabled INTEGER NOT NULL," + + " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL," + + " water REAL NOT NULL, muscle REAL NOT NULL, lbw REAL NOT NULL," + + " waist REAL NOT NULL, hip REAL NOT NULL, bone REAL NOT NULL," + + " comment TEXT, FOREIGN KEY(userId) REFERENCES scaleUsers(id)" + + " ON UPDATE NO ACTION ON DELETE CASCADE)" + ) + + // Create new index on datetime + userId + db.execSQL( + "CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime" + + " ON scaleMeasurements (userId, datetime)" + ) + + // Copy data from the old table, ignoring those with invalid userId (if any) + db.execSQL( + "INSERT INTO scaleMeasurements" + + " SELECT * FROM scaleMeasurementsOld" + + " WHERE userId IN (SELECT id from scaleUsers)" + ) + + // Delete old table + db.execSQL("DROP TABLE scaleMeasurementsOld") + } +} + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // Drop old index + db.execSQL("DROP INDEX index_scaleMeasurements_userId_datetime") + + // Rename old tables + db.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld") + db.execSQL("ALTER TABLE scaleUsers RENAME TO scaleUsersOld") + + // Create new table with foreign key + db.execSQL( + "CREATE TABLE scaleMeasurements" + + " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + " userId INTEGER NOT NULL, enabled INTEGER NOT NULL," + + " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL," + + " water REAL NOT NULL, muscle REAL NOT NULL, visceralFat REAL NOT NULL," + + " lbm REAL NOT NULL, waist REAL NOT NULL, hip REAL NOT NULL," + + " bone REAL NOT NULL, chest REAL NOT NULL, thigh REAL NOT NULL," + + " biceps REAL NOT NULL, neck REAL NOT NULL, caliper1 REAL NOT NULL," + + " caliper2 REAL NOT NULL, caliper3 REAL NOT NULL, comment TEXT," + + " FOREIGN KEY(userId) REFERENCES scaleUsers(id)" + + " ON UPDATE NO ACTION ON DELETE CASCADE)" + ) + + db.execSQL( + "CREATE TABLE scaleUsers " + + "(id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "username TEXT NOT NULL, birthday INTEGER NOT NULL, bodyHeight REAL NOT NULL, " + + "scaleUnit INTEGER NOT NULL, gender INTEGER NOT NULL, initialWeight REAL NOT NULL, " + + "goalWeight REAL NOT NULL, goalDate INTEGER, measureUnit INTEGER NOT NULL, activityLevel INTEGER NOT NULL)" + ) + + // Create new index on datetime + userId + db.execSQL( + "CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime" + + " ON scaleMeasurements (userId, datetime)" + ) + + // Copy data from the old tables + db.execSQL( + "INSERT INTO scaleMeasurements" + + " SELECT id, userId, enabled, datetime, weight, fat, water, muscle," + + " 0 AS visceralFat, lbw AS lbm, waist, hip, bone, 0 AS chest," + + " 0 as thigh, 0 as biceps, 0 as neck, 0 as caliper1," + + " 0 as caliper2, 0 as caliper3, comment FROM scaleMeasurementsOld" + ) + + db.execSQL( + "INSERT INTO scaleUsers" + + " SELECT id, username, birthday, bodyHeight, scaleUnit, gender, initialWeight, goalWeight," + + " goalDate, 0 AS measureUnit, 0 AS activityLevel FROM scaleUsersOld" + ) + + // Delete old tables + db.execSQL("DROP TABLE scaleMeasurementsOld") + db.execSQL("DROP TABLE scaleUsersOld") + } +} + +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + // Drop old index + db.execSQL("DROP INDEX index_scaleMeasurements_userId_datetime") + + // Rename old table + db.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld") + + // Create new table with foreign key + db.execSQL( + "CREATE TABLE scaleMeasurements" + + " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + " userId INTEGER NOT NULL, enabled INTEGER NOT NULL," + + " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL," + + " water REAL NOT NULL, muscle REAL NOT NULL, visceralFat REAL NOT NULL," + + " lbm REAL NOT NULL, waist REAL NOT NULL, hip REAL NOT NULL," + + " bone REAL NOT NULL, chest REAL NOT NULL, thigh REAL NOT NULL," + + " biceps REAL NOT NULL, neck REAL NOT NULL, caliper1 REAL NOT NULL," + + " caliper2 REAL NOT NULL, caliper3 REAL NOT NULL, calories REAL NOT NULL, comment TEXT," + + " FOREIGN KEY(userId) REFERENCES scaleUsers(id)" + + " ON UPDATE NO ACTION ON DELETE CASCADE)" + ) + + // Create new index on datetime + userId + db.execSQL( + "CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime" + + " ON scaleMeasurements (userId, datetime)" + ) + + // Copy data from the old table + db.execSQL( + "INSERT INTO scaleMeasurements" + + " SELECT id, userId, enabled, datetime, weight, fat, water, muscle," + + " visceralFat, lbm, waist, hip, bone, chest," + + " thigh, biceps, neck, caliper1," + + " caliper2, caliper3, 0 as calories, comment FROM scaleMeasurementsOld" + ) + + // Delete old table + db.execSQL("DROP TABLE scaleMeasurementsOld") + } +} + +val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add assisted weighing and left/right amputation level to table + db.execSQL("ALTER TABLE scaleUsers ADD assistedWeighing INTEGER NOT NULL default 0") + db.execSQL("ALTER TABLE scaleUsers ADD leftAmputationLevel INTEGER NOT NULL default 0") + db.execSQL("ALTER TABLE scaleUsers ADD rightAmputationLevel INTEGER NOT NULL default 0") + } +} + +val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add goal enabled to scale user table + db.execSQL("ALTER TABLE scaleUsers ADD goalEnabled INTEGER NOT NULL default 0") + } +} + val MIGRATION_6_7 = object : Migration(6, 7) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("PRAGMA foreign_keys=OFF") diff --git a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt index 2d6cb3ba9..b62f31d50 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt @@ -81,4 +81,39 @@ class MigrationTest { // The rewrite seeds the default measurement types. assertThat(repo.getAllMeasurementTypes().first()).isNotEmpty() } + + /** + * Regression for issue #1410: a backup from an old v2.x build carries a legacy user_version + * below 6 (e.g. v2.3.1 == 5). Before the fix the app registered migrations only from 6, so + * Room had no path and crashed on the first launch after restore. Starting from version 1 + * exercises the restored MIGRATION_1_2 .. MIGRATION_5_6 and then the 6_7.. rewrite chain. + */ + @Test + fun legacyV1_migratesToCurrent_preservingData() = runBlocking { + val dbFile = context.getDatabasePath(AppDatabase.DATABASE_NAME) + dbFile.parentFile?.mkdirs() + if (dbFile.exists()) dbFile.delete() + + RoomTestSupport.writeLegacyV1Database(dbFile) + + // Opening with the full migration chain runs MIGRATION_1_2 .. MIGRATION_14_15 in order. + val opened = RoomTestSupport.onDisk(context).also { db = it } + val repo = RoomTestSupport.repositoryFor(opened) + + val users = repo.getAllUsers().first() + assertThat(users).hasSize(1) + val user = users.single() + assertThat(user.name).isEqualTo("legacy-user") + assertThat(user.gender).isEqualTo(GenderType.FEMALE) // legacy gender=1 -> FEMALE + assertThat(user.heightCm).isWithin(0.1f).of(170f) + + val measurements = repo.getMeasurementsWithValuesForUser(user.id).first() + assertThat(measurements).hasSize(1) + val weight = measurements.single().values + .mapNotNull { it.value.floatValue } + .firstOrNull { abs(it - 80.5f) < 0.1f } + assertThat(weight).isNotNull() + + assertThat(repo.getAllMeasurementTypes().first()).isNotEmpty() + } } diff --git a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt index 76625436a..bce056eb5 100644 --- a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt +++ b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt @@ -31,6 +31,11 @@ import com.health.openscale.core.database.MIGRATION_11_12 import com.health.openscale.core.database.MIGRATION_12_13 import com.health.openscale.core.database.MIGRATION_13_14 import com.health.openscale.core.database.MIGRATION_14_15 +import com.health.openscale.core.database.MIGRATION_1_2 +import com.health.openscale.core.database.MIGRATION_2_3 +import com.health.openscale.core.database.MIGRATION_3_4 +import com.health.openscale.core.database.MIGRATION_4_5 +import com.health.openscale.core.database.MIGRATION_5_6 import com.health.openscale.core.database.MIGRATION_6_7 import com.health.openscale.core.database.MIGRATION_7_8 import com.health.openscale.core.database.MIGRATION_8_9 @@ -74,6 +79,7 @@ object RoomTestSupport { /** The full, ordered migration chain — must mirror DatabaseModule.provideDatabase. */ val ALL_MIGRATIONS: Array = arrayOf( + MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, ) @@ -173,6 +179,75 @@ object RoomTestSupport { } } + /** + * Writes the oldest legacy openScale schema (version 1) with one user and one measurement. + * Opening it with the full migration chain exercises MIGRATION_1_2 .. MIGRATION_5_6 (the + * migrations restored for issue #1410) before the MIGRATION_6_7 rewrite — i.e. the exact path + * a pre-v2.5 backup (e.g. v2.3.1, user_version=5) needs. The column set and order of + * `scaleMeasurements` must match MIGRATION_1_2's `INSERT ... SELECT *` target. + * User: gender=1 (FEMALE), height 170cm. Measurement: weight 80.5. + */ + fun writeLegacyV1Database(file: File) { + val database = SQLiteDatabase.openOrCreateDatabase(file, null) + try { + database.execSQL( + """ + CREATE TABLE scaleUsers ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + username TEXT NOT NULL, + birthday INTEGER NOT NULL, + bodyHeight REAL NOT NULL, + scaleUnit INTEGER NOT NULL, + gender INTEGER NOT NULL, + initialWeight REAL NOT NULL, + goalWeight REAL NOT NULL, + goalDate INTEGER + ) + """.trimIndent() + ) + // v1 scaleMeasurements column order must line up with MIGRATION_1_2's SELECT *. + database.execSQL( + """ + CREATE TABLE scaleMeasurements ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + userId INTEGER NOT NULL, + enabled INTEGER NOT NULL, + datetime INTEGER, + weight REAL NOT NULL, + fat REAL NOT NULL, + water REAL NOT NULL, + muscle REAL NOT NULL, + lbw REAL NOT NULL, + waist REAL NOT NULL, + hip REAL NOT NULL, + bone REAL NOT NULL, + comment TEXT + ) + """.trimIndent() + ) + database.execSQL( + "CREATE INDEX index_scaleMeasurements_datetime ON scaleMeasurements (datetime)" + ) + database.execSQL( + """ + INSERT INTO scaleUsers + (id, username, birthday, bodyHeight, scaleUnit, gender, initialWeight, goalWeight, goalDate) + VALUES (1, 'legacy-user', 946684800000, 170.0, 0, 1, 80.0, 75.0, NULL) + """.trimIndent() + ) + database.execSQL( + """ + INSERT INTO scaleMeasurements + (id, userId, enabled, datetime, weight, fat, water, muscle, lbw, waist, hip, bone, comment) + VALUES (1, 1, 1, 1712325600000, 80.5, 0, 0, 0, 0, 0, 0, 0, 'legacy measurement') + """.trimIndent() + ) + database.execSQL("PRAGMA user_version = 1") + } finally { + database.close() + } + } + // --- Facade builders (wire the real production object graph against in-memory Room) --- /** Real [SettingsFacadeImpl] backed by an isolated test DataStore file (avoids process-singleton cross-talk). */ From c01f7ea076e3a68ba92e7372a9fddcd6e59d3f1e Mon Sep 17 00:00:00 2001 From: shisoratsu Date: Sat, 18 Jul 2026 22:04:42 +0800 Subject: [PATCH 11/72] Add minimal Huawei Hagrid WSP support (#1408) * Add minimal Huawei Hagrid WSP support * moved HuaweiHagridSecretProvider into HuaweiHagridWspLib.kt --------- Co-authored-by: shisoratsu <277485761+shisoratsu@users.noreply.github.com> Co-authored-by: oliexdev --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../core/bluetooth/libs/HuaweiHagridWspLib.kt | 1084 +++++++++++++++++ .../scales/HuaweiHagridWspHandler.kt | 984 +++++++++++++++ .../openscale/core/facade/SettingsFacade.kt | 25 +- .../openscale/core/service/BleScanner.kt | 37 +- .../bluetooth/libs/HuaweiHagridWspLibTest.kt | 154 +++ .../scales/HuaweiHagridWspHandlerTest.kt | 549 +++++++++ 7 files changed, 2831 insertions(+), 4 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 332698f74..43b547f7c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -42,6 +42,7 @@ import com.health.openscale.core.bluetooth.scales.HesleyHandler import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler import com.health.openscale.core.bluetooth.scales.HuaweiAhCh100Handler import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler +import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler import com.health.openscale.core.bluetooth.scales.InlifeHandler import com.health.openscale.core.bluetooth.scales.LinkMode @@ -141,6 +142,7 @@ class ScaleFactory @Inject constructor( IHealthHS3Handler(), HuaweiAhCh100Handler(), HuaweiCH100SHandler(), + HuaweiHagridWspHandler(), HoffenBbs8107Handler(), HesleyHandler(), ExingtechY1Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt new file mode 100644 index 000000000..178197f46 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt @@ -0,0 +1,1084 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import java.security.MessageDigest +import java.util.Calendar +import java.util.Date +import java.util.Locale +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.math.ceil +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Huawei Hagrid / WSP framing, authentication and payload helpers. + * + * The CAK/C1/C2 values are supplied by the caller. This library intentionally + * does not contain any Huawei Health key-extraction logic. + */ +object HuaweiHagridWspLib { + const val FRAME_WRITE_PLAIN = 0xDB + const val FRAME_WRITE_ENCRYPTED = 0xDC + const val FRAME_NOTIFY_PLAIN = 0xBD + const val FRAME_NOTIFY_ENCRYPTED = 0xCD + const val CAPABILITY_BIT_HIGH_FREQUENCY_IMPEDANCE = 18 + + private const val MAX_SIMPLE_PAYLOAD_BYTES = 240 + private const val SIMPLE_CHUNK_BYTES = 15 + private const val USER_INFO_PAYLOAD_BYTES = 69 + private const val MANAGER_HUID_BYTES = 30 + private const val MANAGER_DEVICE_ID_BYTES = 40 + private val AUTH_TOKEN_SUFFIX_TO_SCALE = byteArrayOf(0x31, 0x31, 0x32, 0x33) // "1123" + private val AUTH_TOKEN_SUFFIX_FROM_SCALE = byteArrayOf(0x39, 0x38, 0x35, 0x36) // "9856" + + private val crcTable = intArrayOf( + 0, 4129, 8258, 12387, 16516, 20645, 24774, 28903, 33032, 37161, 41290, 45419, 49548, 53677, 57806, 61935, + 4657, 528, 12915, 8786, 21173, 17044, 29431, 25302, 37689, 33560, 45947, 41818, 54205, 50076, 62463, 58334, + 9314, 13379, 1056, 5121, 25830, 29895, 17572, 21637, 42346, 46411, 34088, 38153, 58862, 62927, 50604, 54669, + 13907, 9842, 5649, 1584, 30423, 26358, 22165, 18100, 46939, 42874, 38681, 34616, 63455, 59390, 55197, 51132, + 18628, 22757, 26758, 30887, 2112, 6241, 10242, 14371, 51660, 55789, 59790, 63919, 35144, 39273, 43274, 47403, + 23285, 19156, 31415, 27286, 6769, 2640, 14899, 10770, 56317, 52188, 64447, 60318, 39801, 35672, 47931, 43802, + 27814, 31879, 19684, 23749, 11298, 15363, 3168, 7233, 60846, 64911, 52716, 56781, 44330, 48395, 36200, 40265, + 32407, 28342, 24277, 20212, 15891, 11826, 7761, 3696, 65439, 61374, 57309, 53244, 48923, 44858, 40793, 36728, + 37256, 33193, 45514, 41451, 53516, 49453, 61774, 57711, 4224, 97, 12482, 8419, 20484, 16421, 28742, 24679, + 33721, 37784, 41979, 46042, 49981, 54044, 58239, 62302, 689, 4752, 8947, 13010, 16949, 21012, 25207, 29270, + 46570, 42443, 38312, 34185, 62830, 58703, 54572, 50445, 13538, 9411, 5280, 1153, 29798, 25671, 21540, 17413, + 42971, 47098, 34713, 38840, 59231, 63358, 50973, 55100, 9939, 14066, 1681, 5808, 26199, 30326, 17941, 22068, + 55628, 51565, 63758, 59695, 39368, 35305, 47498, 43435, 22596, 18533, 30726, 26663, 6336, 2273, 14466, 10403, + 52093, 56156, 60223, 64286, 35833, 39896, 43963, 48026, 19061, 23124, 27191, 31254, 2801, 6864, 10931, 14994, + 64814, 60687, 56684, 52557, 48554, 44427, 40424, 36297, 31782, 27655, 23652, 19525, 15522, 11395, 7392, 3265, + 61215, 65342, 53085, 57212, 44955, 49082, 36825, 40952, 28183, 32310, 20053, 24180, 11923, 16050, 3793, 7920 + ) + + data class WspFrame( + val encrypted: Boolean, + val totalFrames: Int, + val frameIndex: Int, + val payload: ByteArray, + val crcValid: Boolean + ) { + override fun equals(other: Any?): Boolean = + other is WspFrame && + encrypted == other.encrypted && + totalFrames == other.totalFrames && + frameIndex == other.frameIndex && + crcValid == other.crcValid && + payload.contentEquals(other.payload) + + override fun hashCode(): Int { + var result = encrypted.hashCode() + result = 31 * result + totalFrames + result = 31 * result + frameIndex + result = 31 * result + crcValid.hashCode() + result = 31 * result + payload.contentHashCode() + return result + } + } + + data class HagridWeightMeasurement( + val rawLength: Int, + val weightKg: Float, + val fatPercent: Float, + val timestamp: Date?, + val lowFrequencyImpedance: List, + val highFrequencyImpedance: List, + val heartRateBpm: Int?, + val trailingBytesHex: String?, + val historySuspectedFlag: Int? = null, + val historyCompleteFlag: Int? = null + ) { + val representativeLowOhm: Double? + get() = lowFrequencyImpedance.firstNotNullOfOrNull { hagridImpedanceOhm(it) } + + val representativeHighOhm: Double? + get() = highFrequencyImpedance.firstNotNullOfOrNull { hagridImpedanceOhm(it) } + + val lowFrequencyImpedanceOhm: List + get() = lowFrequencyImpedance.map { hagridImpedanceOhm(it) } + + val highFrequencyImpedanceOhm: List + get() = highFrequencyImpedance.map { hagridImpedanceOhm(it) } + + val historyDedupeKey: String? + get() { + val millis = timestamp?.time ?: return null + return listOf( + millis.toString(), + (weightKg * 100.0f).roundToInt().toString(), + (fatPercent * 10.0f).roundToInt().toString(), + heartRateBpm?.toString().orEmpty(), + lowFrequencyImpedance.joinToString(separator = ","), + highFrequencyImpedance.joinToString(separator = ",") + ).joinToString(separator = "|") + } + + val isHistoryComplete: Boolean? + get() = historyCompleteFlag?.let { it == 0 } + + private fun hagridImpedanceOhm(raw: Int): Double? = + when (raw) { + in 1..3999 -> raw.toDouble() + in 4000..39999 -> raw / 10.0 + else -> null + } + } + + data class HagridScaleVersion( + val version: String, + val serialOrDataInfo: String, + val rawLength: Int + ) + + data class HagridWeightUnit( + val raw: Int, + val parsed: Int, + val label: String + ) + + data class HagridProductInfo( + val rawText: String, + val smartProductId: String, + val variant: String, + val rawLength: Int + ) + + enum class HagridProductFamily { + SCALE_2_PRO, + SCALE_3_PRO, + SCALE_3, + HONOR_SCALE_2, + DOBBY, + UNKNOWN + } + + data class HagridProductProfile( + val family: HagridProductFamily, + val displayName: String, + val smartProductId: String?, + val expectedHighFrequencyImpedance: Boolean? + ) + + data class HagridCapabilityBits( + val rawHex: String, + val byteCount: Int, + val supportedBits: Set + ) { + val supportsHighFrequencyImpedance: Boolean + get() = supports(CAPABILITY_BIT_HIGH_FREQUENCY_IMPEDANCE) + + fun supports(bit: Int): Boolean = + bit >= 0 && supportedBits.contains(bit) + } + + data class StandardMeasurement( + val weightKg: Float? = null, + val fatPercent: Float? = null, + val musclePercent: Float? = null, + val bodyWaterMassKg: Float? = null, + val impedanceOhm: Double? = null, + val scaleUserIndex: Int? = null, + val timestamp: Date? = null + ) + + data class HagridUserInfo( + val huid: String, + val uid: String, + val gender: Int, + val ageYears: Int, + val heightCm: Int, + val weightKg: Float, + val userType: Int = 0 + ) + + data class HagridManagerInfo( + val huid: String, + val deviceId: String, + val accountInfo: String?, + val rawLength: Int + ) + + data class HagridSecrets( + val cak: ByteArray, + val c1: ByteArray, + val c2: ByteArray + ) { + init { + require(cak.size == 16) { "CAK must be 16 bytes" } + require(c1.size == 16) { "C1 must be 16 bytes" } + require(c2.size == 16) { "C2 must be 16 bytes" } + } + + override fun equals(other: Any?): Boolean = + other is HagridSecrets && + cak.contentEquals(other.cak) && + c1.contentEquals(other.c1) && + c2.contentEquals(other.c2) + + override fun hashCode(): Int { + var result = cak.contentHashCode() + result = 31 * result + c1.contentHashCode() + result = 31 * result + c2.contentHashCode() + return result + } + } + + data class HagridSession( + val randA: ByteArray, + val randB: ByteArray, + val rootKey: ByteArray, + val workKey: ByteArray + ) { + init { + require(randA.size == 16) { "randA must be 16 bytes" } + require(randB.size == 16) { "randB must be 16 bytes" } + require(rootKey.size == 16) { "rootKey must be 16 bytes" } + require(workKey.size == 16) { "workKey must be 16 bytes" } + } + + override fun equals(other: Any?): Boolean = + other is HagridSession && + randA.contentEquals(other.randA) && + randB.contentEquals(other.randB) && + rootKey.contentEquals(other.rootKey) && + workKey.contentEquals(other.workKey) + + override fun hashCode(): Int { + var result = randA.contentHashCode() + result = 31 * result + randB.contentHashCode() + result = 31 * result + rootKey.contentHashCode() + result = 31 * result + workKey.contentHashCode() + return result + } + } + + class FrameAccumulator(private val requireValidCrc: Boolean = true) { + private var expectedFrames: Int = 0 + private var encrypted: Boolean = false + private var allFramesCrcValid: Boolean = true + private val chunks = mutableMapOf() + + var lastCompletedEncrypted: Boolean = false + private set + + fun ingest(rawFrame: ByteArray): ByteArray? { + val frame = parseNotificationFrame(rawFrame, requireValidCrc) ?: return null + if (frame.totalFrames <= 1) { + lastCompletedEncrypted = frame.encrypted + reset() + return frame.payload + } + + if (expectedFrames != frame.totalFrames || encrypted != frame.encrypted) { + reset() + expectedFrames = frame.totalFrames + encrypted = frame.encrypted + } + + allFramesCrcValid = allFramesCrcValid && frame.crcValid + chunks[frame.frameIndex] = frame.payload + if (chunks.size < expectedFrames) return null + + val out = ByteArray(chunks.values.sumOf { it.size }) + var pos = 0 + for (i in 0 until expectedFrames) { + val chunk = chunks[i] ?: run { + reset() + return null + } + chunk.copyInto(out, pos) + pos += chunk.size + } + lastCompletedEncrypted = encrypted + reset() + return out + } + + fun reset() { + expectedFrames = 0 + encrypted = false + allFramesCrcValid = true + chunks.clear() + } + } + + fun buildWriteFrames(payload: ByteArray, encrypted: Boolean = false): List { + require(payload.size <= MAX_SIMPLE_PAYLOAD_BYTES) { + "WSP long payloads > $MAX_SIMPLE_PAYLOAD_BYTES bytes are not implemented" + } + + val total = maxOf(1, ceil(payload.size / SIMPLE_CHUNK_BYTES.toDouble()).toInt()) + val frames = ArrayList(total) + var offset = 0 + for (index in 0 until total) { + val chunkLen = min(SIMPLE_CHUNK_BYTES, payload.size - offset).coerceAtLeast(0) + val frame = ByteArray(chunkLen + 5) + frame[0] = (if (encrypted) FRAME_WRITE_ENCRYPTED else FRAME_WRITE_PLAIN).toByte() + frame[1] = (chunkLen + 3).toByte() + frame[2] = ((((total - 1) and 0x0F) shl 4) or (index and 0x0F)).toByte() + if (chunkLen > 0) { + payload.copyInto(frame, destinationOffset = 3, startIndex = offset, endIndex = offset + chunkLen) + offset += chunkLen + } + putCrc(frame) + frames += frame + } + return frames + } + + fun parseNotificationFrame(frame: ByteArray, requireValidCrc: Boolean = true): WspFrame? { + if (frame.size < 5) return null + val prefix = frame[0].toInt() and 0xFF + if (prefix != FRAME_NOTIFY_PLAIN && prefix != FRAME_NOTIFY_ENCRYPTED) return null + val crcValid = hasValidNotificationCrc(frame) + if (requireValidCrc && !crcValid) return null + + val payloadLen = (frame[1].toInt() and 0xFF) - 3 + if (payloadLen < 0 || frame.size < payloadLen + 5) return null + + val totalFrames = ((frame[2].toInt() ushr 4) and 0x0F) + 1 + val frameIndex = frame[2].toInt() and 0x0F + if (frameIndex >= totalFrames) return null + + return WspFrame( + encrypted = prefix == FRAME_NOTIFY_ENCRYPTED, + totalFrames = totalFrames, + frameIndex = frameIndex, + payload = frame.copyOfRange(3, 3 + payloadLen), + crcValid = crcValid + ) + } + + fun hasNotificationFramePrefix(frame: ByteArray): Boolean { + val prefix = frame.firstOrNull()?.toInt()?.and(0xFF) ?: return false + return prefix == FRAME_NOTIFY_PLAIN || prefix == FRAME_NOTIFY_ENCRYPTED + } + + fun hasValidNotificationCrc(frame: ByteArray): Boolean { + if (frame.size < 5) return false + val expected = u16le(frame, frame.size - 2) + return crc16Modbus(frame.copyOfRange(0, frame.size - 2)) == expected + } + + fun crc16(data: ByteArray): Int { + var crc = 0 + for (byte in data) { + crc = ((crc shl 8) xor crcTable[((byte.toInt() and 0xFF) xor (crc ushr 8)) and 0xFF]) and 0xFFFF + } + return crc + } + + fun crc16Modbus(data: ByteArray): Int { + var crc = 0xFFFF + for (byte in data) { + crc = crc xor (byte.toInt() and 0xFF) + repeat(8) { + crc = if ((crc and 0x0001) != 0) { + (crc ushr 1) xor 0xA001 + } else { + crc ushr 1 + } + } + crc = crc and 0xFFFF + } + return crc + } + + fun parseRealtimeMeasurement(payload: ByteArray): HagridWeightMeasurement? = + parseHagridWeightMeasurement( + payload = payload, + allowedLengths = setOf(26, 38), + timestampPresent = false + ) + + fun parseHistoryMeasurement(payload: ByteArray): HagridWeightMeasurement? = + parseHagridWeightMeasurement( + payload = payload, + allowedLengths = setOf(27, 28, 39, 40), + timestampPresent = true + ) + + fun parseScaleVersion(payload: ByteArray): HagridScaleVersion? { + if (payload.size < 5 || payload.size > 20) return null + + val versionBits = bitArrayMsb(payload[1]) + bitArrayMsb(payload[2]) + bitArrayMsb(payload[3]) + val major = versionBits.subList(0, 4).bitsToInt() + val minor = versionBits.subList(4, 8).bitsToInt() + val patch = versionBits.subList(8, 12).bitsToInt() + val build = versionBits.subList(12, 22).bitsToInt() + val serial = decodeFixedAscii(payload.copyOfRange(4, payload.size)) + + return HagridScaleVersion( + version = "$major.$minor.$patch.$build", + serialOrDataInfo = serial, + rawLength = payload.size + ) + } + + fun parseWeightUnit(payload: ByteArray): HagridWeightUnit? { + val raw = payload.firstOrNull()?.toInt()?.and(0xFF) ?: return null + val parsed = raw.toString(16).toIntOrNull() ?: raw + val label = when (parsed) { + 1 -> "kg" + 2 -> "lb" + 3 -> "g" + else -> "unknown" + } + return HagridWeightUnit(raw = raw, parsed = parsed, label = label) + } + + fun parseProductInfo(payload: ByteArray): HagridProductInfo? { + if (payload.isEmpty()) return null + val text = decodeFixedAscii(payload) + if (text.isBlank() || text.startsWith("hex:")) return null + + return HagridProductInfo( + rawText = text, + smartProductId = text.take(4), + variant = text.drop(4), + rawLength = payload.size + ) + } + + fun productProfileForMarker(marker: String?): HagridProductProfile { + val normalized = marker + ?.trim() + ?.uppercase(Locale.US) + .orEmpty() + return when (normalized) { + "007B", "HAG-B19", "HAGRID-B19", "HUAWEI SCALE 2 PRO" -> + HagridProductProfile( + family = HagridProductFamily.SCALE_2_PRO, + displayName = "HUAWEI Scale 2 Pro", + smartProductId = "007B", + expectedHighFrequencyImpedance = false + ) + + "M00F", "HAGRID-B29", "HAGRID2021-B19", "HUAWEI SCALE 3 PRO" -> + HagridProductProfile( + family = HagridProductFamily.SCALE_3_PRO, + displayName = "HUAWEI Scale 3 Pro", + smartProductId = "M00F", + expectedHighFrequencyImpedance = true + ) + + "M00D", "HEM-B19", "HERM-B19", "HUAWEI SCALE 3" -> + HagridProductProfile( + family = HagridProductFamily.SCALE_3, + displayName = "HUAWEI Scale 3", + smartProductId = "M00D", + expectedHighFrequencyImpedance = false + ) + + "N001", "LUP-B19", "LUPIN-B19HN" -> + HagridProductProfile( + family = HagridProductFamily.HONOR_SCALE_2, + displayName = "HONOR Scale 2", + smartProductId = "N001", + expectedHighFrequencyImpedance = false + ) + + "M0CJ", "DOBBY-B19" -> + HagridProductProfile( + family = HagridProductFamily.DOBBY, + displayName = "Huawei/Honor WSP Scale", + smartProductId = "M0CJ", + expectedHighFrequencyImpedance = null + ) + + else -> + HagridProductProfile( + family = HagridProductFamily.UNKNOWN, + displayName = "Huawei/Honor WSP Scale", + smartProductId = normalized.ifBlank { null }, + expectedHighFrequencyImpedance = null + ) + } + } + + fun parseCapabilityBits(capacityHex: String?): HagridCapabilityBits? { + val hex = capacityHex + ?.filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + ?.uppercase(Locale.US) + .orEmpty() + if (hex.isEmpty() || hex.length % 2 != 0) return null + + val bytes = runCatching { + ByteArray(hex.length / 2) { index -> + hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + }.getOrNull() ?: return null + + val supportedBits = mutableSetOf() + bytes.forEachIndexed { byteIndex, byte -> + val value = byte.toInt() and 0xFF + for (bitInByte in 0 until 8) { + val mask = 1 shl bitInByte + if ((value and mask) == mask) { + supportedBits += byteIndex * 8 + bitInByte + } + } + } + + return HagridCapabilityBits( + rawHex = hex, + byteCount = bytes.size, + supportedBits = supportedBits + ) + } + + fun buildAuthTokenPayload(randA: ByteArray, randB: ByteArray, cak: ByteArray): ByteArray { + require(randA.size == 16) { "randA must be 16 bytes" } + require(randB.size == 16) { "randB must be 16 bytes" } + require(cak.size == 16) { "CAK must be 16 bytes" } + + return randB + hagridAuthToken(randA, randB, cak, AUTH_TOKEN_SUFFIX_TO_SCALE) + } + + fun expectedAuthResponsePayload(randA: ByteArray, randB: ByteArray, cak: ByteArray): ByteArray { + require(randA.size == 16) { "randA must be 16 bytes" } + require(randB.size == 16) { "randB must be 16 bytes" } + require(cak.size == 16) { "CAK must be 16 bytes" } + + return hagridAuthToken(randA, randB, cak, AUTH_TOKEN_SUFFIX_FROM_SCALE) + } + + fun isValidAuthResponsePayload( + response: ByteArray, + randA: ByteArray, + randB: ByteArray, + cak: ByteArray + ): Boolean = + response.contentEquals(expectedAuthResponsePayload(randA, randB, cak)) + + fun hagridC3FromBluetoothAddress(address: String): ByteArray { + val compact = address.filter { it != ':' && it != '-' }.uppercase(Locale.US) + require(compact.length == 12) { "Bluetooth address must contain 12 hex digits" } + return (compact + "0000").encodeToByteArray() + } + + fun deriveHagridRootKey(c1: ByteArray, c2: ByteArray, c3: ByteArray): ByteArray { + require(c1.size == c2.size) { "C1 and C2 must have the same length" } + require(c1.isNotEmpty()) { "C1 and C2 must not be empty" } + require(c3.size == c1.size) { "C3 must be the same length as C1/C2" } + + val mixed = ByteArray(c1.size) { index -> + ((((c1[index].toInt() and 0xFF) shl 4) xor (c2[index].toInt() and 0xFF)) and 0xFF).toByte() + } + val keyData = sha256First16(mixed) + val folded = ByteArray(keyData.size) { index -> + (((keyData[index].toInt() and 0xFF) ushr 6) xor (c3[index].toInt() and 0xFF)).toByte() + } + return sha256First16(folded) + } + + fun encryptPayload(plaintext: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + require(key.size == 16) { "AES-CTR key must be 16 bytes" } + require(iv.size == 16) { "AES-CTR IV must be 16 bytes" } + return aesCtr(plaintext, key, iv) + } + + fun encryptedPayloadWithIv(plaintext: ByteArray, key: ByteArray, iv: ByteArray): ByteArray = + iv + encryptPayload(plaintext, key, iv) + + fun decryptPayload(ciphertextWithIv: ByteArray, key: ByteArray): ByteArray { + require(ciphertextWithIv.size >= 16) { "Encrypted payload must include a 16-byte IV" } + val iv = ciphertextWithIv.copyOfRange(0, 16) + val ciphertext = ciphertextWithIv.copyOfRange(16, ciphertextWithIv.size) + return encryptPayload(ciphertext, key, iv) + } + + fun buildEncryptedWriteFrames(plaintext: ByteArray, key: ByteArray, iv: ByteArray): List = + buildWriteFrames(encryptedPayloadWithIv(plaintext, key, iv), encrypted = true) + + fun buildSendWorkKeyFrames(workKey: ByteArray, rootKey: ByteArray, iv: ByteArray): List { + require(workKey.size == 16) { "workKey must be 16 bytes" } + return buildEncryptedWriteFrames(workKey, rootKey, iv) + } + + fun buildUserInfoPayload(info: HagridUserInfo): ByteArray { + val payload = ByteArray(USER_INFO_PAYLOAD_BYTES) + copyUtf8Fixed(info.huid, payload, offset = 0, length = 30) + copyUtf8Fixed(info.uid, payload, offset = 30, length = 32) + payload[62] = info.gender.coerceIn(0, 255).toByte() + payload[63] = info.ageYears.coerceIn(0, 255).toByte() + putU16Le(payload, 64, info.heightCm.coerceIn(0, 0xFFFF)) + + val weightCentiKg = if (info.weightKg.isFinite() && info.weightKg > 0f) { + (info.weightKg * 100f).roundToInt().coerceIn(0, 0xFFFF) + } else { + 0 + } + putU16Le(payload, 66, weightCentiKg) + payload[68] = info.userType.coerceIn(0, 255).toByte() + return payload + } + + fun buildManagerInfoPayload(huid: String, deviceId: String, accountInfo: String? = null): ByteArray { + val accountBytes = accountInfo?.encodeToByteArray()?.let { bytes -> + bytes.copyOfRange(0, min(bytes.size, 255)) + } + val payload = if (accountBytes == null) { + ByteArray(MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES) + } else { + ByteArray(MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + 1 + accountBytes.size) + } + copyUtf8Fixed(huid, payload, offset = 0, length = MANAGER_HUID_BYTES) + copyUtf8Fixed(deviceId, payload, offset = MANAGER_HUID_BYTES, length = MANAGER_DEVICE_ID_BYTES) + if (accountBytes != null) { + val accountLengthOffset = MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + payload[accountLengthOffset] = accountBytes.size.toByte() + accountBytes.copyInto(payload, destinationOffset = accountLengthOffset + 1) + } + return payload + } + + fun parseManagerInfo(payload: ByteArray): HagridManagerInfo? { + if (payload.isEmpty()) { + return HagridManagerInfo(huid = "", deviceId = "", accountInfo = null, rawLength = 0) + } + if (payload.size <= MANAGER_HUID_BYTES) return null + + val huid = decodeFixedText(payload.copyOfRange(0, MANAGER_HUID_BYTES)) + val extended = payload.size > MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + val deviceIdEnd = if (extended) { + MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + } else { + payload.size + } + val deviceId = decodeFixedText(payload.copyOfRange(MANAGER_HUID_BYTES, deviceIdEnd)) + + val accountLengthOffset = MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + val accountInfo = if (extended) { + val declaredLength = u8(payload, accountLengthOffset) + val accountEnd = min(payload.size, accountLengthOffset + 1 + declaredLength) + if (accountEnd > accountLengthOffset + 1) { + decodeFixedText(payload.copyOfRange(accountLengthOffset + 1, accountEnd)) + .takeIf { it.isNotBlank() } + } else { + null + } + } else { + null + } + + return HagridManagerInfo( + huid = huid, + deviceId = deviceId, + accountInfo = accountInfo, + rawLength = payload.size + ) + } + + fun parseStandardWeightMeasurement(payload: ByteArray): StandardMeasurement? { + if (payload.size < 3) return null + var offset = 0 + val flags = u8(payload, offset) + offset += 1 + + val imperial = (flags and 0x01) != 0 + val timestampPresent = (flags and 0x02) != 0 + val userPresent = (flags and 0x04) != 0 + val bmiAndHeightPresent = (flags and 0x08) != 0 + + val weightRaw = u16leOrNull(payload, offset) ?: return null + offset += 2 + val weightKg = massToKg(weightRaw, imperial) + + val timestamp = if (timestampPresent) { + readTimestamp(payload, offset) ?: return null + } else { + null + } + if (timestampPresent) offset += 7 + + val scaleUserIndex = if (userPresent) { + u8OrNull(payload, offset) ?: return null + } else { + null + } + if (userPresent) offset += 1 + + if (bmiAndHeightPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + u16leOrNull(payload, offset) ?: return null + } + + return StandardMeasurement( + weightKg = weightKg, + scaleUserIndex = scaleUserIndex, + timestamp = timestamp + ) + } + + fun parseStandardBodyCompositionMeasurement(payload: ByteArray): StandardMeasurement? { + if (payload.size < 4) return null + var offset = 0 + val flags = u16le(payload, offset) + offset += 2 + + val imperial = (flags and 0x0001) != 0 + val timestampPresent = (flags and 0x0002) != 0 + val userPresent = (flags and 0x0004) != 0 + val basalMetabolismPresent = (flags and 0x0008) != 0 + val musclePercentPresent = (flags and 0x0010) != 0 + val muscleMassPresent = (flags and 0x0020) != 0 + val fatFreeMassPresent = (flags and 0x0040) != 0 + val softLeanMassPresent = (flags and 0x0080) != 0 + val bodyWaterMassPresent = (flags and 0x0100) != 0 + val impedancePresent = (flags and 0x0200) != 0 + val weightPresent = (flags and 0x0400) != 0 + val heightPresent = (flags and 0x0800) != 0 + + val bodyFatRaw = u16leOrNull(payload, offset) ?: return null + offset += 2 + val fatPercent = bodyFatRaw * 0.1f + + val timestamp = if (timestampPresent) { + readTimestamp(payload, offset) ?: return null + } else { + null + } + if (timestampPresent) offset += 7 + + val scaleUserIndex = if (userPresent) { + u8OrNull(payload, offset) ?: return null + } else { + null + } + if (userPresent) offset += 1 + + if (basalMetabolismPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + } + + val musclePercent = if (musclePercentPresent) { + val raw = u16leOrNull(payload, offset) ?: return null + offset += 2 + raw * 0.1f + } else { + null + } + + if (muscleMassPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + } + if (fatFreeMassPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + } + if (softLeanMassPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + } + + val bodyWaterMassKg = if (bodyWaterMassPresent) { + val raw = u16leOrNull(payload, offset) ?: return null + offset += 2 + massToKg(raw, imperial) + } else { + null + } + + val impedanceOhm = if (impedancePresent) { + val raw = u16leOrNull(payload, offset) ?: return null + offset += 2 + (raw * 0.1f).toDouble() + } else { + null + } + + val weightKg = if (weightPresent) { + val raw = u16leOrNull(payload, offset) ?: return null + offset += 2 + massToKg(raw, imperial) + } else { + null + } + + if (heightPresent) { + u16leOrNull(payload, offset) ?: return null + } + + return StandardMeasurement( + weightKg = weightKg, + fatPercent = fatPercent, + musclePercent = musclePercent, + bodyWaterMassKg = bodyWaterMassKg, + impedanceOhm = impedanceOhm, + scaleUserIndex = scaleUserIndex, + timestamp = timestamp + ) + } + + private fun parseHagridWeightMeasurement( + payload: ByteArray, + allowedLengths: Set, + timestampPresent: Boolean + ): HagridWeightMeasurement? { + if (payload.size !in allowedLengths) return null + + val highResistancePresent = payload.size >= 38 + val baseLength = if (highResistancePresent) 38 else 26 + val weightKg = u16le(payload, 0) / 100.0f + val fatPercent = u16le(payload, 2) / 10.0f + val timestamp = if (timestampPresent) { + readTimestamp(payload, 4) ?: return null + } else { + null + } + val low = (0 until 6).map { u16le(payload, 12 + it * 2) } + val high = if (highResistancePresent) { + (0 until 6).map { u16le(payload, 26 + it * 2) } + } else { + emptyList() + } + val heartRateRaw = u16le(payload, 24) + val heartRate = heartRateRaw.takeIf { it in 1..240 } + val trailing = if (payload.size > baseLength) { + payload.copyOfRange(baseLength, payload.size).toHexString() + } else { + null + } + val historySuspectedFlag = if (timestampPresent && payload.size > baseLength) { + u8(payload, baseLength) + } else { + null + } + val historyCompleteFlag = if (timestampPresent && payload.size > baseLength + 1) { + u8(payload, baseLength + 1) + } else { + null + } + + return HagridWeightMeasurement( + rawLength = payload.size, + weightKg = weightKg, + fatPercent = fatPercent, + timestamp = timestamp, + lowFrequencyImpedance = low, + highFrequencyImpedance = high, + heartRateBpm = heartRate, + trailingBytesHex = trailing, + historySuspectedFlag = historySuspectedFlag, + historyCompleteFlag = historyCompleteFlag + ) + } + + fun currentTimePayload(calendar: Calendar = Calendar.getInstance()): ByteArray { + val year = calendar.get(Calendar.YEAR) + val dayOfWeek = (((calendar.get(Calendar.DAY_OF_WEEK) + 5) % 7) + 1) + return byteArrayOf( + (year and 0xFF).toByte(), + ((year ushr 8) and 0xFF).toByte(), + (calendar.get(Calendar.MONTH) + 1).toByte(), + calendar.get(Calendar.DAY_OF_MONTH).toByte(), + calendar.get(Calendar.HOUR_OF_DAY).toByte(), + calendar.get(Calendar.MINUTE).toByte(), + calendar.get(Calendar.SECOND).toByte(), + dayOfWeek.toByte() + ) + } + + private fun putCrc(frame: ByteArray) { + frame[frame.size - 2] = 0 + frame[frame.size - 1] = 0 + val crc = crc16(frame) + frame[frame.size - 2] = (crc and 0xFF).toByte() + frame[frame.size - 1] = ((crc ushr 8) and 0xFF).toByte() + } + + private fun hagridAuthToken(randA: ByteArray, randB: ByteArray, cak: ByteArray, suffix: ByteArray): ByteArray { + val rand = randA + randB + val innerKey = hmacSha256(rand, cak + suffix) + return hmacSha256(rand, innerKey) + } + + private fun hmacSha256(message: ByteArray, key: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(message) + } + + private fun sha256First16(data: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256").digest(data) + return digest.copyOfRange(0, 16) + } + + private fun aesCtr(input: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(input) + } + + private fun u16le(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) + + private fun putU16Le(data: ByteArray, offset: Int, value: Int) { + data[offset] = (value and 0xFF).toByte() + data[offset + 1] = ((value ushr 8) and 0xFF).toByte() + } + + private fun copyUtf8Fixed(value: String, target: ByteArray, offset: Int, length: Int) { + val bytes = value.encodeToByteArray() + bytes.copyInto(target, destinationOffset = offset, startIndex = 0, endIndex = min(bytes.size, length)) + } + + private fun decodeFixedText(bytes: ByteArray): String { + val nul = bytes.indexOf(0) + val meaningful = if (nul >= 0) bytes.copyOfRange(0, nul) else bytes.copyOf() + val trimmed = meaningful + .dropLastWhile { byte -> byte == 0.toByte() || byte == 0x20.toByte() } + .toByteArray() + if (trimmed.isEmpty()) return "" + + val printableAscii = trimmed.all { byte -> + val c = byte.toInt() and 0xFF + c in 0x20..0x7E + } + return if (printableAscii) trimmed.decodeToString() else "hex:${trimmed.toHexString()}" + } + + private fun decodeFixedAscii(bytes: ByteArray): String = + decodeFixedText(bytes) + + private fun u16leOrNull(data: ByteArray, offset: Int): Int? = + if (offset + 1 < data.size) u16le(data, offset) else null + + private fun u8(data: ByteArray, offset: Int): Int = + data[offset].toInt() and 0xFF + + private fun u8OrNull(data: ByteArray, offset: Int): Int? = + if (offset < data.size) u8(data, offset) else null + + private fun readTimestamp(data: ByteArray, offset: Int): Date? { + if (offset + 6 >= data.size) return null + val year = u16le(data, offset) + val month = u8(data, offset + 2) + val day = u8(data, offset + 3) + val hour = u8(data, offset + 4) + val minute = u8(data, offset + 5) + val second = u8(data, offset + 6) + if (year < 2000 || month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) { + return null + } + val cal = Calendar.getInstance() + cal.set(year, (month - 1).coerceIn(0, 11), day, hour, minute, second) + cal.set(Calendar.MILLISECOND, 0) + return cal.time + } + + private fun massToKg(raw: Int, imperial: Boolean): Float { + val value = if (imperial) raw * 0.01f else raw * 0.005f + return if (imperial) value * 0.45359237f else value + } + + private fun bitArrayMsb(byte: Byte): List { + var value = byte.toInt() + val bits = MutableList(8) { 0 } + for (i in 7 downTo 0) { + bits[i] = value and 1 + value = value shr 1 + } + return bits + } + + private fun List.bitsToInt(): Int { + var value = 0 + forEach { bit -> + value = (value shl 1) or (bit and 1) + } + return value + } + + private fun ByteArray.toHexString(): String = + joinToString(separator = "") { "%02X".format(it.toInt() and 0xFF) } +} + + +/** + * Replaceable source for Huawei Hagrid CAK/C1/C2 key material. + * + * Implementations must not log raw secret values. The upstream app only uses + * explicitly configured settings here; private Huawei Health extraction tools + * are outside this boundary. + */ +interface HuaweiHagridSecretProvider { + val id: String + + fun load( + getString: (String) -> String?, + warn: (String) -> Unit + ): HuaweiHagridWspLib.HagridSecrets? + + class DriverSettings( + private val cakKey: String, + private val c1Key: String, + private val c2Key: String + ) : HuaweiHagridSecretProvider { + override val id: String = "driver-settings" + + override fun load( + getString: (String) -> String?, + warn: (String) -> Unit + ): HuaweiHagridWspLib.HagridSecrets? { + val cak = settingsHexOrNull(getString, warn, cakKey, 16) + val c1 = settingsHexOrNull(getString, warn, c1Key, 16) + val c2 = settingsHexOrNull(getString, warn, c2Key, 16) + if (cak == null && c1 == null && c2 == null) return null + if (cak == null || c1 == null || c2 == null) { + warn("Incomplete Huawei Hagrid secrets; require CAK, C1, and C2 hex settings") + return null + } + return runCatching { HuaweiHagridWspLib.HagridSecrets(cak, c1, c2) } + .getOrElse { error -> + warn("Invalid Huawei Hagrid secrets: ${error.message}") + null + } + } + + private fun settingsHexOrNull( + getString: (String) -> String?, + warn: (String) -> Unit, + key: String, + expectedBytes: Int + ): ByteArray? { + val raw = getString(key)?.trim().orEmpty() + if (raw.isEmpty()) return null + val hex = raw.filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + if (hex.length != expectedBytes * 2) { + warn("Ignoring Huawei Hagrid setting $key: expected ${expectedBytes * 2} hex digits, got ${hex.length}") + return null + } + return runCatching { + ByteArray(expectedBytes) { index -> + hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + }.getOrElse { error -> + warn("Ignoring Huawei Hagrid setting $key: ${error.message}") + null + } + } + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt new file mode 100644 index 000000000..8f6d0231b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt @@ -0,0 +1,984 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.util.SparseArray +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.HuaweiHagridSecretProvider +import com.health.openscale.core.bluetooth.libs.HuaweiHagridWspLib +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.security.SecureRandom +import java.util.Date +import java.util.Locale +import java.util.UUID +import kotlin.math.roundToInt + +/** + * Huawei/Honor Hagrid WSP scale handler. + * + * Authentication uses only CAK/C1/C2 values explicitly configured in driver + * settings. This handler intentionally does not contain Huawei Health key + * extraction, Frida, root, APK, or native white-box logic. + */ +class HuaweiHagridWspHandler( + private val secretProvider: HuaweiHagridSecretProvider = + HuaweiHagridSecretProvider.DriverSettings( + cakKey = SETTINGS_KEY_CAK_HEX, + c1Key = SETTINGS_KEY_C1_HEX, + c2Key = SETTINGS_KEY_C2_HEX + ), + private val randomBytes: (Int) -> ByteArray = { size -> + ByteArray(size).also { SecureRandom().nextBytes(it) } + } +) : ScaleDeviceHandler() { + + private val svcUserData = uuid16(0x181C) + private val svcCurrentTime = uuid16(0x1805) + private val svcBodyComposition = uuid16(0x181B) + private val svcWeightScale = uuid16(0x181D) + + private val chrRequestAuth = UUID.fromString("02b2a08e-f8b0-4047-b1fd-f4e0efeee679") + private val chrAuthToken = UUID.fromString("32330a04-15d9-421a-91c5-2a2d5c7525c9") + private val chrSendWorkKey = UUID.fromString("a3d330f8-b84f-4f48-a78c-f8d1e33b597a") + private val chrBindRequest = UUID.fromString("42596cbe-d291-4da3-8ca6-d1ae5d1c9174") + private val chrSetUserInfo = UUID.fromString("8cc61d7d-66c0-4802-89c3-38c5a163592e") + private val chrGetManagerInfo = UUID.fromString("4338c65e-ed8e-4085-bbea-a25e33ca6b54") + private val chrCurrentTime = uuid16(0x2A2B) + private val chrProductInfo = UUID.fromString("75143e79-f878-4a00-a628-edc40509de7e") + private val chrScaleVersion = UUID.fromString("1f5d3d5c-496d-4290-af03-c7a8d5419741") + private val chrGetWeightUnit = UUID.fromString("7e6dbc73-42e7-45b9-a6ec-6aa2d7834695") + private val chrMeasurementStatusPoll = UUID.fromString("bfc36f6e-4150-4a4b-9052-3d359e52962e") + private val chrMeasurementStatusResult = UUID.fromString("ba216311-1787-472b-bef6-3eb29e62293e") + private val chrWeightMeasurement = uuid16(0x2A9D) + private val chrBodyCompositionMeasurement = uuid16(0x2A9C) + private val chrRealtimeWeight = UUID.fromString("46797c17-d639-488d-9476-4789e8472878") + private val chrHistoryWeight = UUID.fromString("0212f42a-5f19-4bc1-ba52-d7ec7ccb71a4") + + private val accumulators = mutableMapOf() + private var pendingDeviceAddress: String? = null + private var pendingProductProfile = HuaweiHagridWspLib.productProfileForMarker(null) + private var pendingCapabilityBits: HuaweiHagridWspLib.HagridCapabilityBits? = null + private var configuredSecrets: HuaweiHagridWspLib.HagridSecrets? = null + private var pendingRandA: ByteArray? = null + private var pendingRandB: ByteArray? = null + private var session: HuaweiHagridWspLib.HagridSession? = null + private var handshakeState = HandshakeState.IDLE + private var currentUserInfoSync: ScaleUser? = null + private var userInfoSyncGeneration = 0 + private var lastManagerInfo: HuaweiHagridWspLib.HagridManagerInfo? = null + private var pendingProfileSyncAfterManagerInfo: ScaleUser? = null + private var managerInfoReadGeneration = 0 + private var historyReadActive = false + private var historyReadCount = 0 + private val publishedHistoryKeys = mutableSetOf() + private var measurementStatusPollingActive = false + private var measurementStatusPollCount = 0 + private var measurementStatusPollJob: Job? = null + private var measurementStatusReady = false + private var postStatusMeasurementReadsStarted = false + private var realtimeReadRequestCount = 0 + + @Composable + override fun DeviceConfigurationUi() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Huawei Hagrid WSP secrets", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "Configure CAK, C1, and C2 as 32 hex characters each. Values stay in per-driver app settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SecretHexField("CAK", SETTINGS_KEY_CAK_HEX) + SecretHexField("C1", SETTINGS_KEY_C1_HEX) + SecretHexField("C2", SETTINGS_KEY_C2_HEX) + } + } + + @Composable + private fun SecretHexField(label: String, key: String) { + val persistedValue = settingsGetString(key) ?: "" + var inputValue by remember(persistedValue) { mutableStateOf(persistedValue) } + var lastSavedValue by remember(persistedValue) { mutableStateOf(persistedValue) } + val isValid = inputValue.length == 32 + val showError = inputValue.isNotEmpty() && !isValid + val isSaved = inputValue.isNotEmpty() && isValid && inputValue == lastSavedValue + + LaunchedEffect(inputValue) { + when { + inputValue.isEmpty() && lastSavedValue.isNotEmpty() -> { + settingsPutString(key, "") + lastSavedValue = "" + } + isValid && inputValue != lastSavedValue -> { + settingsPutString(key, inputValue) + lastSavedValue = inputValue + } + } + } + + OutlinedTextField( + value = inputValue, + onValueChange = { newValue -> + val filtered = newValue + .filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + .uppercase(Locale.US) + if (filtered.length <= 32) { + inputValue = filtered + } + }, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) }, + singleLine = true, + isError = showError, + visualTransformation = PasswordVisualTransformation(), + supportingText = { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = when { + showError -> "Expected 32 hex characters" + isSaved -> "Saved" + else -> "" + }, + color = if (showError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + Spacer(modifier = Modifier.weight(1f)) + Text("${inputValue.length}/32") + } + } + ) + } + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val marker = findProductMarker(device) ?: return null + pendingDeviceAddress = device.address + pendingProductProfile = HuaweiHagridWspLib.productProfileForMarker(marker) + pendingCapabilityBits = findCapabilityBits(device) + + val capabilities = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.HISTORY_READ, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.UNIT_CONFIG + ) + + return DeviceSupport( + displayName = pendingProductProfile.displayName, + capabilities = capabilities, + implemented = setOf( + DeviceCapability.TIME_SYNC, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.HISTORY_READ, + ), + tuningProfile = TuningProfile.Conservative, + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + accumulators.clear() + pendingRandA = null + pendingRandB = null + session = null + stopMeasurementStatusPolling() + configuredSecrets = loadConfiguredSecrets() + handshakeState = if (configuredSecrets == null) { + HandshakeState.PROBE_ONLY + } else { + HandshakeState.WAITING_AUTH_CHALLENGE + } + currentUserInfoSync = null + userInfoSyncGeneration++ + lastManagerInfo = null + pendingProfileSyncAfterManagerInfo = null + managerInfoReadGeneration++ + historyReadActive = false + historyReadCount = 0 + publishedHistoryKeys.clear() + postStatusMeasurementReadsStarted = false + realtimeReadRequestCount = 0 + measurementStatusReady = false + + setNotifyIfPresent(svcUserData, chrRequestAuth) + setNotifyIfPresent(svcUserData, chrAuthToken) + setNotifyIfPresent(svcUserData, chrSendWorkKey) + setNotifyIfPresent(svcUserData, chrBindRequest) + setNotifyIfPresent(svcUserData, chrSetUserInfo) + setNotifyIfPresent(svcUserData, chrGetManagerInfo) + setNotifyIfPresent(svcCurrentTime, chrCurrentTime) + setNotifyIfPresent(svcCurrentTime, chrProductInfo) + setNotifyIfPresent(svcCurrentTime, chrScaleVersion) + setNotifyIfPresent(svcCurrentTime, chrGetWeightUnit) + setNotifyIfPresent(svcCurrentTime, chrMeasurementStatusPoll) + setNotifyIfPresent(svcCurrentTime, chrMeasurementStatusResult) + setNotifyIfPresent(svcBodyComposition, chrBodyCompositionMeasurement) + setNotifyIfPresent(svcWeightScale, chrWeightMeasurement) + setNotifyIfPresent(svcBodyComposition, chrRealtimeWeight) + setNotifyIfPresent(svcBodyComposition, chrHistoryWeight) + + userInfo(R.string.bt_info_step_on_scale) + logI( + if (configuredSecrets == null) { + "Huawei Hagrid WSP secrets missing; using probe-only mode" + } else { + "Huawei Hagrid WSP secrets configured; starting authentication" + } + ) + logDeviceProfile("scan") + + writeWspPlainIfPresent(svcUserData, chrRequestAuth, ByteArray(0)) + if (configuredSecrets == null) { + writeWspPlainIfPresent(svcCurrentTime, chrCurrentTime, HuaweiHagridWspLib.currentTimePayload()) + } + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (HuaweiHagridWspLib.hasNotificationFramePrefix(data)) { + val accumulator = accumulators.getOrPut(characteristic) { + HuaweiHagridWspLib.FrameAccumulator() + } + val payload = accumulator.ingest(data) ?: return + handleWspNotification(characteristic, payload, accumulator.lastCompletedEncrypted, user) + return + } + + handleRawNotification(characteristic, data, user) + } + + override fun onDisconnected() { + stopMeasurementStatusPolling() + accumulators.clear() + pendingRandA = null + pendingRandB = null + session = null + configuredSecrets = null + currentUserInfoSync = null + userInfoSyncGeneration++ + lastManagerInfo = null + pendingProfileSyncAfterManagerInfo = null + managerInfoReadGeneration++ + historyReadActive = false + historyReadCount = 0 + publishedHistoryKeys.clear() + postStatusMeasurementReadsStarted = false + realtimeReadRequestCount = 0 + measurementStatusReady = false + handshakeState = HandshakeState.IDLE + } + + private fun handleWspNotification( + characteristic: UUID, + rawPayload: ByteArray, + encrypted: Boolean, + user: ScaleUser + ) { + val payload = if (encrypted) { + val activeSession = session + if (activeSession == null) { + logW("Encrypted Hagrid notification before work key: chr=${characteristicLabel(characteristic)} len=${rawPayload.size}") + return + } + runCatching { HuaweiHagridWspLib.decryptPayload(rawPayload, activeSession.workKey) } + .getOrElse { error -> + logW("Failed to decrypt Hagrid notification chr=${characteristicLabel(characteristic)}: ${error.message}") + return + } + } else { + rawPayload + } + + logD( + "Huawei Hagrid WSP notification chr=${characteristicLabel(characteristic)} " + + "encrypted=$encrypted rawLen=${rawPayload.size} plainLen=${payload.size}" + ) + + when (characteristic) { + chrRealtimeWeight, + chrBindRequest, + chrBodyCompositionMeasurement, + chrWeightMeasurement -> handleRealtimePayload(payload, user) + + chrRequestAuth -> handleAuthChallenge(payload) + chrAuthToken -> handleAuthResponse(payload) + chrSendWorkKey -> handleWorkKeyAck(payload, user) + chrSetUserInfo -> handleUserInfoAck(payload) + chrGetManagerInfo -> handleManagerInfoPayload(payload) + chrProductInfo -> handleProductInfoPayload(payload) + chrScaleVersion -> handleScaleVersionPayload(payload) + chrGetWeightUnit -> handleWeightUnitPayload(payload) + chrMeasurementStatusPoll, + chrMeasurementStatusResult -> handleMeasurementStatusPayload(payload) + chrHistoryWeight -> handleHistoryWeightPayload(payload, user) + chrCurrentTime -> logD("Huawei Hagrid time-sync ack len=${payload.size}") + else -> logD("Unhandled Hagrid WSP notification chr=${characteristicLabel(characteristic)} len=${payload.size}") + } + } + + private fun handleRawNotification(characteristic: UUID, payload: ByteArray, user: ScaleUser) { + when (characteristic) { + chrWeightMeasurement -> handleStandardWeightPayload(payload, user) + chrBodyCompositionMeasurement -> handleStandardBodyCompositionPayload(payload, user) + chrRealtimeWeight, + chrBindRequest -> handleRealtimePayload(payload, user) + chrSetUserInfo -> handleUserInfoAck(payload) + chrGetManagerInfo -> handleManagerInfoPayload(payload) + chrProductInfo -> handleProductInfoPayload(payload) + chrScaleVersion -> handleScaleVersionPayload(payload) + chrGetWeightUnit -> handleWeightUnitPayload(payload) + chrMeasurementStatusPoll, + chrMeasurementStatusResult -> handleMeasurementStatusPayload(payload) + chrHistoryWeight -> handleHistoryWeightPayload(payload, user) + else -> logD("Unhandled Hagrid raw notification chr=${characteristicLabel(characteristic)} len=${payload.size}") + } + } + + private fun handleAuthChallenge(payload: ByteArray) { + if (payload.size != 16) { + logW("Ignoring invalid Hagrid auth challenge length=${payload.size}") + return + } + + val secrets = configuredSecrets + if (secrets == null) { + logD("Hagrid auth challenge received without configured secrets; staying probe-only") + writeWspPlainIfPresent(svcBodyComposition, chrRealtimeWeight, ByteArray(0)) + return + } + + val randB = randomBytes(16) + require(randB.size == 16) { "randomBytes(16) returned ${randB.size} bytes" } + pendingRandA = payload.copyOf() + pendingRandB = randB + handshakeState = HandshakeState.AUTH_TOKEN_SENT + + val authPayload = HuaweiHagridWspLib.buildAuthTokenPayload(payload, randB, secrets.cak) + logI("Sending Huawei Hagrid auth token (${authPayload.size} bytes)") + writeWspPlainIfPresent(svcUserData, chrAuthToken, authPayload) + } + + private fun handleAuthResponse(payload: ByteArray) { + val secrets = configuredSecrets + val randA = pendingRandA + val randB = pendingRandB + if (secrets == null || randA == null || randB == null) { + logW("Hagrid auth response received without pending auth state") + return + } + + if (!HuaweiHagridWspLib.isValidAuthResponsePayload(payload, randA, randB, secrets.cak)) { + handshakeState = HandshakeState.AUTH_FAILED + logW("Huawei Hagrid auth response verification failed (${payload.size} bytes)") + return + } + + val address = getPeripheral()?.address ?: pendingDeviceAddress + if (address.isNullOrBlank()) { + handshakeState = HandshakeState.AUTH_FAILED + logW("Cannot derive Huawei Hagrid root key without Bluetooth address") + return + } + + val rootKey = runCatching { + HuaweiHagridWspLib.deriveHagridRootKey( + secrets.c1, + secrets.c2, + HuaweiHagridWspLib.hagridC3FromBluetoothAddress(address) + ) + }.getOrElse { error -> + handshakeState = HandshakeState.AUTH_FAILED + logW("Cannot derive Huawei Hagrid root key: ${error.message}") + return + } + + val workKey = randomBytes(16) + val iv = randomBytes(16) + require(workKey.size == 16) { "randomBytes(16) returned ${workKey.size} bytes for workKey" } + require(iv.size == 16) { "randomBytes(16) returned ${iv.size} bytes for work-key IV" } + session = HuaweiHagridWspLib.HagridSession(randA.copyOf(), randB.copyOf(), rootKey, workKey.copyOf()) + handshakeState = HandshakeState.WORK_KEY_SENT + + logI("Huawei Hagrid auth verified; sending encrypted work key") + writeWspFramesIfPresent( + svcUserData, + chrSendWorkKey, + HuaweiHagridWspLib.buildSendWorkKeyFrames(workKey, rootKey, iv) + ) + } + + private fun handleWorkKeyAck(payload: ByteArray, user: ScaleUser) { + val status = payload.firstOrNull()?.toInt()?.and(0xFF) + if (status != 0x00) { + handshakeState = HandshakeState.AUTH_FAILED + logW("Huawei Hagrid work key rejected: status=${status ?: -1}") + return + } + + logI("Huawei Hagrid work key accepted; syncing time and selected profile") + writeWspPlainIfPresent(svcCurrentTime, chrCurrentTime, HuaweiHagridWspLib.currentTimePayload()) + readManagerInfoBeforeProfileSync(user) + } + + private fun readManagerInfoBeforeProfileSync(user: ScaleUser) { + if (!hasCharacteristic(svcUserData, chrGetManagerInfo)) { + beginUserProfileSync(user) + return + } + + pendingProfileSyncAfterManagerInfo = user + val generation = ++managerInfoReadGeneration + writeWspPlainIfPresent(svcUserData, chrGetManagerInfo, ByteArray(0)) + scope.launch { + delay(MANAGER_INFO_TIMEOUT_MS) + if (pendingProfileSyncAfterManagerInfo?.id == user.id && managerInfoReadGeneration == generation) { + logW("Huawei Hagrid manager-info read timed out; using local HUID") + pendingProfileSyncAfterManagerInfo = null + beginUserProfileSync(user) + } + } + } + + private fun beginUserProfileSync(selectedUser: ScaleUser) { + if (!sendEncryptedUserInfo(selectedUser)) { + handshakeState = HandshakeState.READY + sendPostUserReads() + return + } + + currentUserInfoSync = selectedUser + handshakeState = HandshakeState.USER_INFO_SENT + val generation = ++userInfoSyncGeneration + scope.launch { + delay(USER_INFO_ACK_TIMEOUT_MS) + if ( + handshakeState == HandshakeState.USER_INFO_SENT && + userInfoSyncGeneration == generation && + currentUserInfoSync?.id == selectedUser.id + ) { + logW("Huawei Hagrid set-user-info ack timed out; continuing with post-auth reads") + currentUserInfoSync = null + handshakeState = HandshakeState.READY + sendPostUserReads() + } + } + } + + private fun sendEncryptedUserInfo(user: ScaleUser): Boolean { + val activeSession = session + if (activeSession == null) { + logW("Cannot send Huawei Hagrid user-info without an active work key") + return false + } + if (!hasCharacteristic(svcUserData, chrSetUserInfo)) { + logD("Characteristic missing, skip encrypted Huawei Hagrid user-info write") + return false + } + + val info = buildUserInfo(user) + val plaintext = HuaweiHagridWspLib.buildUserInfoPayload(info) + val iv = randomBytes(16) + require(iv.size == 16) { "randomBytes(16) returned ${iv.size} bytes for user-info IV" } + + logD( + "Sending encrypted Huawei Hagrid user-info: userId=${user.id} " + + "payloadLen=${plaintext.size} huidPresent=${info.huid.isNotBlank()}" + ) + HuaweiHagridWspLib.buildEncryptedWriteFrames(plaintext, activeSession.workKey, iv) + .forEach { frame -> writeTo(svcUserData, chrSetUserInfo, frame, withResponse = true) } + return true + } + + private fun handleUserInfoAck(payload: ByteArray) { + val status = payload.firstOrNull()?.toInt()?.and(0xFF) + val accepted = status == 0x00 + if (accepted) { + logI("Huawei Hagrid set-user-info accepted") + } else { + logW("Huawei Hagrid set-user-info returned status=${status ?: -1}") + } + + if (handshakeState == HandshakeState.USER_INFO_SENT) { + currentUserInfoSync = null + handshakeState = HandshakeState.READY + sendPostUserReads() + } + } + + private fun sendPostUserReads() { + writeWspPlainIfPresent(svcCurrentTime, chrProductInfo, ByteArray(0)) + writeWspPlainIfPresent(svcUserData, chrGetManagerInfo, ByteArray(0)) + writeWspPlainIfPresent(svcCurrentTime, chrScaleVersion, ByteArray(0)) + writeWspPlainIfPresent(svcCurrentTime, chrGetWeightUnit, ByteArray(0)) + postStatusMeasurementReadsStarted = false + realtimeReadRequestCount = 0 + startMeasurementStatusPolling() + } + + private fun buildUserInfo(user: ScaleUser): HuaweiHagridWspLib.HagridUserInfo { + val lastWeight = lastMeasurementFor(user.id)?.weight + val configuredWeight = when { + lastWeight != null && lastWeight.isFinite() && lastWeight > 0f -> lastWeight + user.initialWeight.isFinite() && user.initialWeight > 0f -> user.initialWeight + else -> 0f + } + val heightCm = if (user.bodyHeight.isFinite() && user.bodyHeight > 0f) { + user.bodyHeight.roundToInt().coerceIn(0, 0xFFFF) + } else { + 0 + } + + return HuaweiHagridWspLib.HagridUserInfo( + huid = lastManagerInfo?.huid?.takeIf { it.isNotBlank() } ?: localSyntheticHuid(), + uid = "u:%08X".format(Locale.US, user.id), + gender = if (user.gender == GenderType.MALE) 0 else 1, + ageYears = user.age.coerceIn(0, 255), + heightCm = heightCm, + weightKg = configuredWeight, + ) + } + + private fun localSyntheticHuid(): String { + val compactAddress = (getPeripheral()?.address ?: pendingDeviceAddress) + ?.filter { it != ':' && it != '-' } + ?.uppercase(Locale.US) + ?.takeIf { it.length == 12 } + ?: "000000000000" + return "openscale:$compactAddress" + } + + private fun handleRealtimePayload(payload: ByteArray, user: ScaleUser) { + val parsed = HuaweiHagridWspLib.parseRealtimeMeasurement(payload) + if (parsed == null || parsed.weightKg <= 0f) { + logD("Ignoring unsupported Hagrid realtime payload len=${payload.size}") + return + } + + userInfo(R.string.bluetooth_scale_info_measuring_weight, parsed.weightKg) + logD( + "Hagrid realtime measurement parsed len=${parsed.rawLength} " + + "lowCount=${parsed.lowFrequencyImpedance.size} highCount=${parsed.highFrequencyImpedance.size} saved=false" + ) + } + + private fun handleHistoryWeightPayload(payload: ByteArray, user: ScaleUser) { + val parsed = HuaweiHagridWspLib.parseHistoryMeasurement(payload) + if (parsed == null || parsed.weightKg <= 0f) { + historyReadActive = false + logD("Hagrid history read finished or unsupported len=${payload.size}") + return + } + + historyReadActive = true + historyReadCount += 1 + val key = parsed.historyDedupeKey + val shouldPublish = key == null || publishedHistoryKeys.add(key) + if (shouldPublish) { + publishHagridMeasurement(parsed, user) + } else { + logD("Skipped duplicate Hagrid history measurement") + } + + val complete = parsed.isHistoryComplete == true + if (!complete && historyReadCount < MAX_HISTORY_RECORDS) { + writeWspPlainIfPresent(svcBodyComposition, chrHistoryWeight, byteArrayOf(0x00)) + } else { + historyReadActive = false + logD( + "Hagrid history read stopped: complete=$complete count=$historyReadCount " + + "saved=${publishedHistoryKeys.size}" + ) + } + } + + private fun publishHagridMeasurement( + parsed: HuaweiHagridWspLib.HagridWeightMeasurement, + user: ScaleUser + ) { + val low = parsed.representativeLowOhm ?: 0.0 + val high = parsed.representativeHighOhm ?: low + val measurement = ScaleMeasurement( + userId = user.id, + dateTime = parsed.timestamp ?: Date(), + weight = parsed.weightKg, + fat = parsed.fatPercent.takeIf { it > 0f && it < 80f } ?: 0f, + heartRate = parsed.heartRateBpm ?: 0, + impedance = high, + impedanceLow = low, + ) + logI( + "Publishing Huawei Hagrid measurement: lowCount=${parsed.lowFrequencyImpedance.size} " + + "highCount=${parsed.highFrequencyImpedance.size}" + ) + publish(measurement) + } + + private fun handleStandardWeightPayload(payload: ByteArray, user: ScaleUser) { + val parsed = HuaweiHagridWspLib.parseStandardWeightMeasurement(payload) + val weight = parsed?.weightKg + if (weight == null || weight <= 0f) { + logD("Ignoring unsupported standard weight payload len=${payload.size}") + return + } + + publish( + ScaleMeasurement( + userId = user.id, + dateTime = parsed.timestamp ?: Date(), + weight = weight, + ) + ) + } + + private fun handleStandardBodyCompositionPayload(payload: ByteArray, user: ScaleUser) { + val parsed = HuaweiHagridWspLib.parseStandardBodyCompositionMeasurement(payload) + if (parsed == null) { + logD("Ignoring unsupported standard body-composition payload len=${payload.size}") + return + } + if (parsed.weightKg == null && parsed.fatPercent == null && parsed.impedanceOhm == null) { + return + } + + publish( + ScaleMeasurement( + userId = user.id, + dateTime = parsed.timestamp ?: Date(), + weight = parsed.weightKg ?: 0f, + fat = parsed.fatPercent?.takeIf { it > 0f && it < 80f } ?: 0f, + muscle = parsed.musclePercent?.takeIf { it > 0f && it < 100f } ?: 0f, + impedance = parsed.impedanceOhm ?: 0.0, + ) + ) + } + + private fun handleManagerInfoPayload(payload: ByteArray) { + val parsed = HuaweiHagridWspLib.parseManagerInfo(payload) + if (parsed == null) { + logD("Ignoring unsupported Hagrid manager-info payload len=${payload.size}") + return + } + lastManagerInfo = parsed + logD("Huawei Hagrid manager-info read len=${parsed.rawLength} huidPresent=${parsed.huid.isNotBlank()}") + + pendingProfileSyncAfterManagerInfo?.let { user -> + pendingProfileSyncAfterManagerInfo = null + beginUserProfileSync(user) + } + } + + private fun handleProductInfoPayload(payload: ByteArray) { + val parsed = HuaweiHagridWspLib.parseProductInfo(payload) + if (parsed == null) { + logD("Ignoring unsupported Hagrid product-info payload len=${payload.size}") + return + } + pendingProductProfile = HuaweiHagridWspLib.productProfileForMarker(parsed.smartProductId) + logDeviceProfile("product-info") + } + + private fun handleScaleVersionPayload(payload: ByteArray) { + val parsed = HuaweiHagridWspLib.parseScaleVersion(payload) + if (parsed == null) { + logD("Ignoring unsupported Hagrid scale-version payload len=${payload.size}") + return + } + logD("Huawei Hagrid scale version=${parsed.version} len=${parsed.rawLength}") + } + + private fun handleWeightUnitPayload(payload: ByteArray) { + val parsed = HuaweiHagridWspLib.parseWeightUnit(payload) + if (parsed == null) { + logD("Ignoring unsupported Hagrid weight-unit payload len=${payload.size}") + return + } + logD("Huawei Hagrid weight unit=${parsed.label} raw=${parsed.raw}") + } + + private fun handleMeasurementStatusPayload(payload: ByteArray) { + val status = payload.firstOrNull()?.toInt()?.and(0xFF) + logD("Huawei Hagrid measurement-status len=${payload.size} status=${status ?: -1}") + if (status == 0x00) { + measurementStatusReady = true + sendMeasurementDataRequests() + } + } + + private fun startMeasurementStatusPolling() { + if (!hasCharacteristic(svcCurrentTime, chrMeasurementStatusPoll)) { + sendMeasurementDataRequests() + return + } + + measurementStatusPollingActive = true + measurementStatusReady = false + measurementStatusPollCount = 0 + sendMeasurementStatusPoll() + + measurementStatusPollJob?.cancel() + measurementStatusPollJob = scope.launch { + while ( + measurementStatusPollingActive && + !measurementStatusReady && + measurementStatusPollCount < MAX_STATUS_POLLS + ) { + delay(MEASUREMENT_STATUS_POLL_INTERVAL_MS) + if (measurementStatusPollingActive && !measurementStatusReady) { + sendMeasurementStatusPoll() + } + } + if (measurementStatusPollingActive && !measurementStatusReady) { + logW("Huawei Hagrid measurement status timed out; requesting measurements directly") + sendMeasurementDataRequests() + } + } + } + + private fun stopMeasurementStatusPolling() { + measurementStatusPollingActive = false + measurementStatusPollJob?.cancel() + measurementStatusPollJob = null + measurementStatusPollCount = 0 + } + + private fun sendMeasurementStatusPoll() { + measurementStatusPollCount += 1 + writeWspPlainIfPresent( + svcCurrentTime, + chrMeasurementStatusPoll, + ByteArray(0), + withResponse = false + ) + } + + private fun sendMeasurementDataRequests() { + stopMeasurementStatusPolling() + if (postStatusMeasurementReadsStarted) { + sendRealtimeMeasurementRequest() + return + } + + postStatusMeasurementReadsStarted = true + if (hasCharacteristic(svcBodyComposition, chrHistoryWeight)) { + historyReadActive = true + historyReadCount = 0 + writeWspPlainIfPresent(svcBodyComposition, chrHistoryWeight, ByteArray(0)) + } + sendRealtimeMeasurementRequest() + } + + private fun sendRealtimeMeasurementRequest() { + realtimeReadRequestCount += 1 + writeWspPlainIfPresent(svcBodyComposition, chrRealtimeWeight, ByteArray(0)) + } + + private fun setNotifyIfPresent(service: UUID, characteristic: UUID) { + if (hasCharacteristic(service, characteristic)) { + setNotifyOn(service, characteristic) + } + } + + private fun writeWspPlainIfPresent( + service: UUID, + characteristic: UUID, + payload: ByteArray, + withResponse: Boolean = true + ) { + if (!hasCharacteristic(service, characteristic)) return + HuaweiHagridWspLib.buildWriteFrames(payload, encrypted = false) + .forEach { frame -> writeTo(service, characteristic, frame, withResponse) } + } + + private fun writeWspFramesIfPresent( + service: UUID, + characteristic: UUID, + frames: List + ) { + if (!hasCharacteristic(service, characteristic)) return + frames.forEach { frame -> writeTo(service, characteristic, frame, withResponse = true) } + } + + private fun loadConfiguredSecrets(): HuaweiHagridWspLib.HagridSecrets? = + runCatching { + secretProvider.load( + getString = { key -> settingsGetString(key) }, + warn = { message -> logW(message) } + ) + } + .onFailure { logW("Invalid Huawei Hagrid secret settings: ${it.message}") } + .getOrNull() + + private fun logDeviceProfile(source: String) { + val highFrequency = pendingCapabilityBits?.supportsHighFrequencyImpedance + ?: pendingProductProfile.expectedHighFrequencyImpedance + logD( + "Huawei Hagrid profile source=$source family=${pendingProductProfile.family} " + + "display=${pendingProductProfile.displayName} highFrequency=$highFrequency" + ) + } + + private fun findProductMarker(device: ScannedDeviceInfo): String? { + extractProductMarker(device.name)?.let { return it } + asciiFromManufacturerData(device.manufacturerData).forEach { text -> + extractProductMarker(text)?.let { return it } + } + asciiFromServiceData(device.serviceData).forEach { text -> + extractProductMarker(text)?.let { return it } + } + return null + } + + private fun findCapabilityBits(device: ScannedDeviceInfo): HuaweiHagridWspLib.HagridCapabilityBits? { + val texts = buildList { + add(device.name) + addAll(asciiFromManufacturerData(device.manufacturerData)) + addAll(asciiFromServiceData(device.serviceData)) + } + + for (text in texts) { + val match = CAPABILITY_PATTERN.find(text) ?: continue + HuaweiHagridWspLib.parseCapabilityBits(match.groupValues[1])?.let { return it } + } + return null + } + + private fun extractProductMarker(raw: String?): String? { + val text = raw?.uppercase(Locale.US) ?: return null + return PRODUCT_MARKERS.firstOrNull { marker -> text.contains(marker) } + } + + private fun asciiFromManufacturerData(data: SparseArray?): List { + if (data == null) return emptyList() + return buildList { + for (index in 0 until data.size()) { + val bytes = data.valueAt(index) ?: continue + add(bytes.toAsciiString()) + } + } + } + + private fun asciiFromServiceData(data: Map): List = + data.values.map { it.toAsciiString() } + + private fun ByteArray.toAsciiString(): String = + map { byte -> + val value = byte.toInt() and 0xFF + if (value in 0x20..0x7E) value.toChar() else ' ' + }.joinToString(separator = "") + + private fun characteristicLabel(characteristic: UUID): String = + when (characteristic) { + chrRequestAuth -> "request-auth" + chrAuthToken -> "auth-token" + chrSendWorkKey -> "send-work-key" + chrBindRequest -> "bind-request" + chrSetUserInfo -> "set-user-info" + chrGetManagerInfo -> "get-manager-info" + chrCurrentTime -> "current-time" + chrProductInfo -> "product-info" + chrScaleVersion -> "scale-version" + chrGetWeightUnit -> "weight-unit" + chrMeasurementStatusPoll -> "measurement-status-poll" + chrMeasurementStatusResult -> "measurement-status-result" + chrWeightMeasurement -> "weight-measurement" + chrBodyCompositionMeasurement -> "body-composition-measurement" + chrRealtimeWeight -> "realtime-weight" + chrHistoryWeight -> "history-weight" + else -> characteristic.toString() + } + + private enum class HandshakeState { + IDLE, + PROBE_ONLY, + WAITING_AUTH_CHALLENGE, + AUTH_TOKEN_SENT, + WORK_KEY_SENT, + USER_INFO_SENT, + READY, + AUTH_FAILED + } + + companion object { + const val SETTINGS_KEY_CAK_HEX = "ble/huawei_hagrid/cak_hex" + const val SETTINGS_KEY_C1_HEX = "ble/huawei_hagrid/c1_hex" + const val SETTINGS_KEY_C2_HEX = "ble/huawei_hagrid/c2_hex" + + private const val MANAGER_INFO_TIMEOUT_MS = 2500L + private const val USER_INFO_ACK_TIMEOUT_MS = 3000L + private const val MEASUREMENT_STATUS_POLL_INTERVAL_MS = 3000L + private const val MAX_STATUS_POLLS = 30 + private const val MAX_HISTORY_RECORDS = 64 + + private val PRODUCT_MARKERS = listOf( + "HUAWEI SCALE 2 PRO", + "HAG-B19", + "HAGRID-B19", + "HUAWEI SCALE 3 PRO", + "HAGRID-B29", + "HAGRID2021-B19", + "HUAWEI SCALE 3", + "HEM-B19", + "HERM-B19", + "HONOR SCALE 2", + "LUP-B19", + "LUPIN-B19HN", + "DOBBY-B19", + "M00F", + "M00D", + "M0CJ", + "N001", + "007B", + ) + + private val CAPABILITY_PATTERN = + Regex("""(?i)(?:capacity|capability|caps?)[:=]([0-9a-f]+)""") + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt index 1dc0fe7f8..61789a824 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt @@ -85,6 +85,7 @@ object SettingsPreferenceKeys { val SAVED_BLUETOOTH_DEVICE_SERVICE_UUIDS = stringSetPreferencesKey("saved_bluetooth_device_service_uuids") val SAVED_BLUETOOTH_DEVICE_HANDLER_HINT = stringPreferencesKey("saved_bluetooth_device_handler_hint") val SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA = stringPreferencesKey("saved_bluetooth_device_manufacturer_data") + val SAVED_BLUETOOTH_DEVICE_SERVICE_DATA = stringPreferencesKey("saved_bluetooth_device_service_data") val SAVED_BLUETOOTH_TUNE_PROFILE = stringPreferencesKey("saved_bluetooth_tune_profile") val SAVED_BLUETOOTH_SMART_ASSIGNMENT_ENABLED = booleanPreferencesKey("saved_bluetooth_smart_assignment_enabled") val SAVED_BLUETOOTH_TOLERANCE_PERCENT = intPreferencesKey("saved_bluetooth_tolerance_percent") @@ -461,14 +462,16 @@ class SettingsFacadeImpl @Inject constructor( val uuidsF = observeSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_UUIDS.name, emptySet()) val hintF = observeSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_HANDLER_HINT.name, null as String?) val manDataF = observeSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA.name, null as String?) + val serviceDataF = observeSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_DATA.name, null as String?) - return combine(addrF, nameF, rssiF, uuidsF, hintF, manDataF) { array -> + return combine(addrF, nameF, rssiF, uuidsF, hintF, manDataF, serviceDataF) { array -> val addr = array[0] as String? val name = array[1] as String? val rssi = array[2] as Int val uuidStrSet = (array[3] as? Set<*>)?.filterIsInstance() ?: emptySet() val hint = array[4] as String? val manDataJson = array[5] as String? + val serviceDataJson = array[6] as String? if (addr.isNullOrBlank()) { null @@ -490,12 +493,25 @@ class SettingsFacadeImpl @Inject constructor( } } + val serviceData = mutableMapOf() + serviceDataJson?.let { jsonStr -> + runCatching { + val jsonObj = JSONObject(jsonStr) + jsonObj.keys().forEach { key -> + val uuid = UUID.fromString(key) + val value = Base64.decode(jsonObj.getString(key), Base64.NO_WRAP) + serviceData[uuid] = value + } + } + } + ScannedDeviceInfo( name = name ?: "", address = addr, rssi = rssi, serviceUuids = uuids, manufacturerData = if (manData.isNotEmpty()) manData else null, + serviceData = serviceData.toMap(), isSupported = false, determinedHandlerDisplayName = hint ) @@ -528,6 +544,12 @@ class SettingsFacadeImpl @Inject constructor( } } prefs[SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA] = manData.toString() + + val serviceData = JSONObject() + device.serviceData.forEach { (uuid, bytes) -> + serviceData.put(uuid.toString(), Base64.encodeToString(bytes, Base64.NO_WRAP)) + } + prefs[SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_DATA] = serviceData.toString() } } @@ -540,6 +562,7 @@ class SettingsFacadeImpl @Inject constructor( prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_UUIDS) prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_HANDLER_HINT) prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA) + prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_DATA) prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_AUTO_CONNECT) } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/BleScanner.kt b/android_app/app/src/main/java/com/health/openscale/core/service/BleScanner.kt index a30f42698..42d91f6a7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/BleScanner.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/BleScanner.kt @@ -49,6 +49,7 @@ import kotlin.time.Duration.Companion.milliseconds * @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. */ @@ -58,6 +59,7 @@ data class ScannedDeviceInfo( val rssi: Int, val serviceUuids: List, val manufacturerData: SparseArray?, + val serviceData: Map = emptyMap(), var isSupported: Boolean = false, var determinedHandlerDisplayName: String? = null ) @@ -250,6 +252,10 @@ class BluetoothScannerManager( 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, @@ -257,6 +263,7 @@ class BluetoothScannerManager( 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 ) @@ -276,15 +283,17 @@ class BluetoothScannerManager( 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) { + 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 + manufacturerData = newDevice.manufacturerData ?: existingDevice.manufacturerData, + serviceData = newDevice.serviceData.ifEmpty { existingDevice.serviceData } ) listShouldBeUpdated = true } @@ -294,7 +303,8 @@ class BluetoothScannerManager( if (newDevice.isSupported || newDevice.name.isNotEmpty() || newDevice.serviceUuids.isNotEmpty() || - (newDevice.manufacturerData != null && newDevice.manufacturerData.isNotEmpty()) + (newDevice.manufacturerData != null && newDevice.manufacturerData.isNotEmpty()) || + newDevice.serviceData.isNotEmpty() ) { deviceMap[newDevice.address] = newDevice listShouldBeUpdated = true @@ -324,3 +334,24 @@ class BluetoothScannerManager( } } + +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/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt new file mode 100644 index 000000000..807029317 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt @@ -0,0 +1,154 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Calendar +import java.util.TimeZone + +class HuaweiHagridWspLibTest { + + @Test + fun `write frames reassemble from notification frames`() { + val payload = ByteArray(40) { it.toByte() } + val frames = HuaweiHagridWspLib.buildWriteFrames(payload) + val accumulator = HuaweiHagridWspLib.FrameAccumulator() + + assertThat(frames).hasSize(3) + val first = accumulator.ingest(toNotifyFrame(frames[0])) + val second = accumulator.ingest(toNotifyFrame(frames[1])) + val third = accumulator.ingest(toNotifyFrame(frames[2])) + + assertThat(first).isNull() + assertThat(second).isNull() + assertThat(third).isEqualTo(payload) + } + + @Test + fun `auth token validates expected scale response`() { + val randA = ByteArray(16) { it.toByte() } + val randB = ByteArray(16) { (it + 16).toByte() } + val cak = ByteArray(16) { (it + 32).toByte() } + + val token = HuaweiHagridWspLib.buildAuthTokenPayload(randA, randB, cak) + val expectedResponse = HuaweiHagridWspLib.expectedAuthResponsePayload(randA, randB, cak) + + assertThat(token.copyOfRange(0, 16)).isEqualTo(randB) + assertThat(token).hasLength(48) + assertThat(expectedResponse).hasLength(32) + assertThat(HuaweiHagridWspLib.isValidAuthResponsePayload(expectedResponse, randA, randB, cak)) + .isTrue() + } + + @Test + fun `encrypted payload round trips`() { + val plaintext = "hagrid-user-profile".encodeToByteArray() + val key = ByteArray(16) { (it + 4).toByte() } + val iv = ByteArray(16) { (it + 80).toByte() } + + val encrypted = HuaweiHagridWspLib.encryptedPayloadWithIv(plaintext, key, iv) + + assertThat(encrypted.copyOfRange(0, 16)).isEqualTo(iv) + assertThat(HuaweiHagridWspLib.decryptPayload(encrypted, key)).isEqualTo(plaintext) + } + + @Test + fun `realtime payload parses low and high impedance bands`() { + val parsed = HuaweiHagridWspLib.parseRealtimeMeasurement(realtimePayload()) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.weightKg).isWithin(0.0001f).of(77.32f) + assertThat(parsed.fatPercent).isWithin(0.0001f).of(18.5f) + assertThat(parsed.heartRateBpm).isEqualTo(72) + assertThat(parsed.lowFrequencyImpedanceOhm).containsExactly(500.0, 501.0, 502.0, 503.0, 504.0, 505.0) + assertThat(parsed.highFrequencyImpedanceOhm).containsExactly(600.0, 601.0, 602.0, 603.0, 604.0, 605.0) + } + + @Test + fun `history payload parses timestamp and terminal flag`() { + val parsed = HuaweiHagridWspLib.parseHistoryMeasurement(historyPayload(completeFlag = 0x00)) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.timestamp).isNotNull() + assertThat(parsed.isHistoryComplete).isTrue() + assertThat(parsed.historyDedupeKey).isNotEmpty() + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.time = parsed.timestamp!! + assertThat(calendar.get(Calendar.YEAR)).isEqualTo(2026) + assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.JUNE) + assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(22) + } + + @Test + fun `standard weight measurement parses kilograms`() { + val parsed = HuaweiHagridWspLib.parseStandardWeightMeasurement( + byteArrayOf(0x00, 0x8C.toByte(), 0x3C) + ) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.weightKg).isWithin(0.0001f).of(77.5f) + } + + @Test + fun `user info payload has fixed Huawei layout`() { + val payload = HuaweiHagridWspLib.buildUserInfoPayload( + HuaweiHagridWspLib.HagridUserInfo( + huid = "openscale:001122334455", + uid = "u:00000007", + gender = 1, + ageYears = 26, + heightCm = 171, + weightKg = 83.2f, + ) + ) + + assertThat(payload).hasLength(69) + assertThat(payload[62].toInt() and 0xFF).isEqualTo(1) + assertThat(payload[63].toInt() and 0xFF).isEqualTo(26) + assertThat(payload[64].toInt() and 0xFF).isEqualTo(171) + assertThat(payload[65].toInt() and 0xFF).isEqualTo(0) + assertThat(payload[66].toInt() and 0xFF).isEqualTo(0x80) + assertThat(payload[67].toInt() and 0xFF).isEqualTo(0x20) + } + + private fun toNotifyFrame(writeFrame: ByteArray): ByteArray { + val notifyFrame = writeFrame.copyOf() + notifyFrame[0] = HuaweiHagridWspLib.FRAME_NOTIFY_PLAIN.toByte() + val crc = HuaweiHagridWspLib.crc16Modbus(notifyFrame.copyOfRange(0, notifyFrame.size - 2)) + notifyFrame[notifyFrame.size - 2] = (crc and 0xFF).toByte() + notifyFrame[notifyFrame.size - 1] = ((crc ushr 8) and 0xFF).toByte() + return notifyFrame + } + + private fun realtimePayload(): ByteArray = + byteArrayOf( + 0x34, 0x1E, + 0xB9.toByte(), 0x00, + 0xEA.toByte(), 0x07, 0x06, 0x16, 0x0F, 0x0E, 0x2A, 0x00, + 0xF4.toByte(), 0x01, 0xF5.toByte(), 0x01, 0xF6.toByte(), 0x01, + 0xF7.toByte(), 0x01, 0xF8.toByte(), 0x01, 0xF9.toByte(), 0x01, + 0x48, 0x00, + 0x58, 0x02, 0x59, 0x02, 0x5A, 0x02, + 0x5B, 0x02, 0x5C, 0x02, 0x5D, 0x02, + ) + + private fun historyPayload(completeFlag: Int): ByteArray = + realtimePayload() + byteArrayOf(0x01, completeFlag.toByte()) +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt new file mode 100644 index 000000000..8b5624c82 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt @@ -0,0 +1,549 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.util.SparseArray +import com.google.common.truth.Truth.assertThat +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.HuaweiHagridWspLib +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.ArrayDeque +import java.util.Date +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HuaweiHagridWspHandlerTest { + + @Test + fun `claims known Huawei Hagrid devices without claiming generic BIA`() { + val scale2 = HuaweiHagridWspHandler().supportFor(device("HUAWEI Scale 2 Pro")) + val scale3Pro = HuaweiHagridWspHandler().supportFor(device("Hagrid-B29")) + val generic = HuaweiHagridWspHandler().supportFor( + device("Generic BIA", services = intArrayOf(0x1805, 0x181B, 0x181C, 0x181D)) + ) + + assertThat(scale2).isNotNull() + assertThat(scale2!!.displayName).isEqualTo("HUAWEI Scale 2 Pro") + assertThat(scale3Pro).isNotNull() + assertThat(scale3Pro!!.displayName).isEqualTo("HUAWEI Scale 3 Pro") + assertThat(scale2.implemented).doesNotContain(DeviceCapability.USER_SYNC) + assertThat(scale2.implemented).doesNotContain(DeviceCapability.UNIT_CONFIG) + assertThat(generic).isNull() + } + + @Test + fun `claims product id from manufacturer or service data`() { + val manufacturerData = SparseArray().apply { + put(0x027D, "model=007B".encodeToByteArray()) + } + val manufacturerSupport = HuaweiHagridWspHandler().supportFor( + device(name = "Unknown Scale", manufacturerData = manufacturerData) + ) + val serviceSupport = HuaweiHagridWspHandler().supportFor( + device( + name = "Unknown Scale", + serviceData = mapOf(uuid16(0xFD00) to "product=007B".encodeToByteArray()) + ) + ) + + assertThat(manufacturerSupport).isNotNull() + assertThat(serviceSupport).isNotNull() + assertThat(manufacturerSupport!!.displayName).isEqualTo("HUAWEI Scale 2 Pro") + assertThat(serviceSupport!!.displayName).isEqualTo("HUAWEI Scale 2 Pro") + } + + @Test + fun `raw standard weight notification publishes standard measurement`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + handler.handleNotification( + characteristic = CHR_WEIGHT_MEASUREMENT, + data = byteArrayOf(0x00, 0x8C.toByte(), 0x3C), + ) + + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single().userId).isEqualTo(7) + assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.5f) + } + + @Test + fun `does not send auth token when Hagrid secrets are not configured`() { + val handler = HuaweiHagridWspHandler() + val transport = CapturingTransport.allHagrid() + handler.supportFor(device("HUAWEI Scale 2 Pro", address = HAGRID_ADDRESS)) + handler.attach( + transport = transport, + callbacks = CapturingCallbacks(), + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + handler.handleConnected(ScaleUser(id = 7)) + sendWspNotification(handler, CHR_REQUEST_AUTH, ByteArray(16) { it.toByte() }) + + assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_AUTH_TOKEN) + assertThat(transport.writes.map { it.characteristic }).contains(CHR_REALTIME_WEIGHT) + } + + @Test + fun `plain WSP realtime notification reports progress without publishing`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + + assertThat(callbacks.published).isEmpty() + assertThat(callbacks.infos.map { it.resId }) + .contains(R.string.bluetooth_scale_info_measuring_weight) + assertThat(callbacks.infos.single().args.single() as Float) + .isWithin(0.0001f).of(77.32f) + } + + @Test + fun `Hagrid history notification publishes non-terminal record and acknowledges next read`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + val transport = CapturingTransport(setOf(SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT)) + handler.attach( + transport = transport, + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_HISTORY_WEIGHT, historyPayload(completeFlag = 0x01)) + + assertThat(callbacks.published).hasSize(1) + val measurement = callbacks.published.single() + assertThat(measurement.weight).isWithin(0.0001f).of(77.32f) + assertThat(measurement.fat).isWithin(0.0001f).of(18.5f) + assertThat(measurement.heartRate).isEqualTo(72) + assertThat(measurement.impedanceLow).isWithin(0.0001).of(500.0) + assertThat(measurement.impedance).isWithin(0.0001).of(600.0) + assertThat(transport.reassembleWriteMessagesTo(CHR_HISTORY_WEIGHT).last()) + .isEqualTo(byteArrayOf(0x00)) + } + + @Test + fun `Hagrid history duplicate is acknowledged without duplicate publish`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + val transport = CapturingTransport(setOf(SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT)) + handler.attach( + transport = transport, + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_HISTORY_WEIGHT, historyPayload(completeFlag = 0x01)) + sendWspNotification(handler, CHR_HISTORY_WEIGHT, historyPayload(completeFlag = 0x01)) + + assertThat(callbacks.published).hasSize(1) + assertThat(transport.reassembleWriteMessagesTo(CHR_HISTORY_WEIGHT)).hasSize(2) + } + + @Test + fun `Hagrid history terminal notification publishes without next-read ack`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + val transport = CapturingTransport(setOf(SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT)) + handler.attach( + transport = transport, + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_HISTORY_WEIGHT, historyPayload(completeFlag = 0x00)) + + assertThat(callbacks.published).hasSize(1) + assertThat(transport.reassembleWriteMessagesTo(CHR_HISTORY_WEIGHT)).isEmpty() + } + + @Test + fun `configured fake Hagrid secrets authenticate and start measurement reads after status ready`() { + val randA = ByteArray(16) { it.toByte() } + val randB = ByteArray(16) { (it + 16).toByte() } + val workKey = ByteArray(16) { (it + 32).toByte() } + val workKeyIv = ByteArray(16) { (it + 48).toByte() } + val cak = ByteArray(16) { (it + 64).toByte() } + val c1 = ByteArray(16) { (it + 80).toByte() } + val c2 = ByteArray(16) { (it + 96).toByte() } + val userInfoIv = ByteArray(16) { (it + 112).toByte() } + val user = ScaleUser( + id = 7, + birthday = Date(946684800000L), + bodyHeight = 171f, + gender = GenderType.FEMALE, + initialWeight = 83.2f, + ) + val randomValues = ArrayDeque(listOf(randB, workKey, workKeyIv, userInfoIv)) + val handler = HuaweiHagridWspHandler(randomBytes = { size -> + assertThat(size).isEqualTo(16) + randomValues.removeFirst() + }) + val settings = InMemorySettings().apply { + putString(HuaweiHagridWspHandler.SETTINGS_KEY_CAK_HEX, cak.toHex()) + putString(HuaweiHagridWspHandler.SETTINGS_KEY_C1_HEX, c1.toHex()) + putString(HuaweiHagridWspHandler.SETTINGS_KEY_C2_HEX, c2.toHex()) + } + val transport = CapturingTransport.allHagrid() + handler.supportFor(device("HUAWEI Scale 2 Pro", address = HAGRID_ADDRESS)) + handler.attach( + transport = transport, + callbacks = CapturingCallbacks(), + settings = settings, + data = FixedDataProvider(user), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + handler.handleConnected(user) + sendWspNotification(handler, CHR_REQUEST_AUTH, randA) + + val authTokenPayload = transport.reassembleWritesTo(CHR_AUTH_TOKEN) + assertThat(authTokenPayload) + .isEqualTo(HuaweiHagridWspLib.buildAuthTokenPayload(randA, randB, cak)) + + sendWspNotification( + handler, + CHR_AUTH_TOKEN, + HuaweiHagridWspLib.expectedAuthResponsePayload(randA, randB, cak) + ) + + val rootKey = HuaweiHagridWspLib.deriveHagridRootKey( + c1, + c2, + HuaweiHagridWspLib.hagridC3FromBluetoothAddress(HAGRID_ADDRESS) + ) + val workKeyPayload = transport.reassembleWritesTo(CHR_SEND_WORK_KEY) + assertThat(workKeyPayload).hasLength(32) + assertThat(HuaweiHagridWspLib.decryptPayload(workKeyPayload, rootKey)).isEqualTo(workKey) + + sendWspNotification(handler, CHR_SEND_WORK_KEY, byteArrayOf(0x00)) + + assertThat(transport.writes.map { it.characteristic }).contains(CHR_GET_MANAGER_INFO) + assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_SET_USER_INFO) + + sendWspNotification(handler, CHR_GET_MANAGER_INFO, managerInfoPayload()) + + val userInfoPayload = transport.reassembleWritesTo(CHR_SET_USER_INFO) + assertThat(userInfoPayload).hasLength(85) + assertThat(HuaweiHagridWspLib.decryptPayload(userInfoPayload, workKey)) + .isEqualTo( + HuaweiHagridWspLib.buildUserInfoPayload( + HuaweiHagridWspLib.HagridUserInfo( + huid = HAGRID_MANAGER_HUID, + uid = "u:00000007", + gender = 1, + ageYears = user.age, + heightCm = 171, + weightKg = 83.2f, + ) + ) + ) + + val userInfoAck = HuaweiHagridWspLib.encryptedPayloadWithIv( + byteArrayOf(0x00), + workKey, + ByteArray(16) { (it + 128).toByte() } + ) + sendWspNotification(handler, CHR_SET_USER_INFO, userInfoAck, encrypted = true) + + assertThat(transport.writes.map { it.characteristic }) + .containsAtLeast( + CHR_CURRENT_TIME, + CHR_SET_USER_INFO, + CHR_PRODUCT_INFO, + CHR_GET_MANAGER_INFO, + CHR_SCALE_VERSION, + CHR_GET_WEIGHT_UNIT, + CHR_MEASUREMENT_STATUS_POLL, + ) + assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_HISTORY_WEIGHT) + assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_REALTIME_WEIGHT) + + sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) + + assertThat(transport.writes.map { it.characteristic }) + .containsAtLeast(CHR_HISTORY_WEIGHT, CHR_REALTIME_WEIGHT) + } + + private fun device( + name: String, + address: String = "00:11:22:33:44:55", + services: IntArray = intArrayOf(), + manufacturerData: SparseArray? = null, + serviceData: Map = emptyMap() + ) = ScannedDeviceInfo( + name = name, + address = address, + rssi = -50, + serviceUuids = services.map { uuid16(it) }, + manufacturerData = manufacturerData, + serviceData = serviceData, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun sendWspNotification( + handler: HuaweiHagridWspHandler, + characteristic: UUID, + payload: ByteArray, + encrypted: Boolean = false + ) { + HuaweiHagridWspLib.buildWriteFrames(payload, encrypted).forEach { writeFrame -> + val notifyFrame = writeFrame.copyOf() + notifyFrame[0] = ( + if (encrypted) { + HuaweiHagridWspLib.FRAME_NOTIFY_ENCRYPTED + } else { + HuaweiHagridWspLib.FRAME_NOTIFY_PLAIN + } + ).toByte() + val crc = HuaweiHagridWspLib.crc16Modbus(notifyFrame.copyOfRange(0, notifyFrame.size - 2)) + notifyFrame[notifyFrame.size - 2] = (crc and 0xFF).toByte() + notifyFrame[notifyFrame.size - 1] = ((crc ushr 8) and 0xFF).toByte() + handler.handleNotification(characteristic, notifyFrame) + } + } + + private fun ByteArray.toHex(): String = + joinToString(separator = "") { "%02X".format(it.toInt() and 0xFF) } + + private fun managerInfoPayload(): ByteArray = + HuaweiHagridWspLib.buildManagerInfoPayload( + huid = HAGRID_MANAGER_HUID, + deviceId = "c55f8f03-1111-4222-8333-aabbccddee49" + ) + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + val infos = mutableListOf() + + override fun onPublish(measurement: ScaleMeasurement) { + published += measurement + } + + override fun onInfo(resId: Int, vararg args: Any) { + infos += InfoRecord(resId, args.toList()) + } + + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private data class InfoRecord( + val resId: Int, + val args: List + ) + + private class NoopTransport : ScaleDeviceHandler.Transport { + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) = Unit + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = false + } + + private data class WriteRecord( + val service: UUID, + val characteristic: UUID, + val payload: ByteArray, + val withResponse: Boolean + ) + + private class CapturingTransport( + private val available: Set> + ) : ScaleDeviceHandler.Transport { + val writes = mutableListOf() + val notifications = mutableListOf>() + + override fun setNotifyOn(service: UUID, characteristic: UUID) { + notifications += service to characteristic + } + + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) { + writes += WriteRecord(service, characteristic, payload, withResponse) + } + + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = + available.contains(service to characteristic) + + fun reassembleWritesTo(characteristic: UUID): ByteArray { + val chunks = writes + .filter { it.characteristic == characteristic } + .sortedBy { it.payload[2].toInt() and 0x0F } + .flatMap { record -> + val len = (record.payload[1].toInt() and 0xFF) - 3 + record.payload.copyOfRange(3, 3 + len).toList() + } + return chunks.toByteArray() + } + + fun reassembleWriteMessagesTo(characteristic: UUID): List { + val messages = mutableListOf() + val current = mutableListOf() + var expectedFrames = 0 + + writes.filter { it.characteristic == characteristic }.forEach { record -> + val frame = record.payload + val totalFrames = ((frame[2].toInt() ushr 4) and 0x0F) + 1 + if (current.isEmpty()) { + expectedFrames = totalFrames + } + current += record + if (current.size == expectedFrames) { + val chunks = current + .sortedBy { it.payload[2].toInt() and 0x0F } + .flatMap { + val len = (it.payload[1].toInt() and 0xFF) - 3 + it.payload.copyOfRange(3, 3 + len).toList() + } + messages += chunks.toByteArray() + current.clear() + expectedFrames = 0 + } + } + return messages + } + + companion object { + fun allHagrid(): CapturingTransport = + CapturingTransport( + setOf( + SVC_USER_DATA to CHR_REQUEST_AUTH, + SVC_USER_DATA to CHR_AUTH_TOKEN, + SVC_USER_DATA to CHR_SEND_WORK_KEY, + SVC_USER_DATA to CHR_BIND_REQUEST, + SVC_USER_DATA to CHR_SET_USER_INFO, + SVC_USER_DATA to CHR_GET_MANAGER_INFO, + SVC_CURRENT_TIME to CHR_CURRENT_TIME, + SVC_CURRENT_TIME to CHR_PRODUCT_INFO, + SVC_CURRENT_TIME to CHR_SCALE_VERSION, + SVC_CURRENT_TIME to CHR_GET_WEIGHT_UNIT, + SVC_CURRENT_TIME to CHR_MEASUREMENT_STATUS_POLL, + SVC_CURRENT_TIME to CHR_MEASUREMENT_STATUS_RESULT, + SVC_BODY_COMPOSITION to CHR_REALTIME_WEIGHT, + SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT, + SVC_WEIGHT_SCALE to CHR_WEIGHT_MEASUREMENT, + ) + ) + } + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + private val values = mutableMapOf() + + override fun getInt(key: String, default: Int): Int = values[key]?.toIntOrNull() ?: default + override fun putInt(key: String, value: Int) { + values[key] = value.toString() + } + + override fun getString(key: String, default: String?): String? = values[key] ?: default + override fun putString(key: String, value: String) { + values[key] = value + } + + override fun remove(key: String) { + values.remove(key) + } + } + + private class FixedDataProvider( + private val user: ScaleUser, + private val users: List = listOf(user) + ) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = users + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = null + } + + companion object { + private const val HAGRID_ADDRESS = "0C:95:41:6E:9E:50" + private const val HAGRID_MANAGER_HUID = "420086000106881907" + private val SVC_USER_DATA = uuid16Static(0x181C) + private val SVC_CURRENT_TIME = uuid16Static(0x1805) + private val SVC_BODY_COMPOSITION = uuid16Static(0x181B) + private val SVC_WEIGHT_SCALE = uuid16Static(0x181D) + private val CHR_REQUEST_AUTH = UUID.fromString("02b2a08e-f8b0-4047-b1fd-f4e0efeee679") + private val CHR_AUTH_TOKEN = UUID.fromString("32330a04-15d9-421a-91c5-2a2d5c7525c9") + private val CHR_SEND_WORK_KEY = UUID.fromString("a3d330f8-b84f-4f48-a78c-f8d1e33b597a") + private val CHR_BIND_REQUEST = UUID.fromString("42596cbe-d291-4da3-8ca6-d1ae5d1c9174") + private val CHR_SET_USER_INFO = UUID.fromString("8cc61d7d-66c0-4802-89c3-38c5a163592e") + private val CHR_GET_MANAGER_INFO = UUID.fromString("4338c65e-ed8e-4085-bbea-a25e33ca6b54") + private val CHR_CURRENT_TIME = uuid16Static(0x2A2B) + private val CHR_PRODUCT_INFO = UUID.fromString("75143e79-f878-4a00-a628-edc40509de7e") + private val CHR_SCALE_VERSION = UUID.fromString("1f5d3d5c-496d-4290-af03-c7a8d5419741") + private val CHR_GET_WEIGHT_UNIT = UUID.fromString("7e6dbc73-42e7-45b9-a6ec-6aa2d7834695") + private val CHR_MEASUREMENT_STATUS_POLL = UUID.fromString("bfc36f6e-4150-4a4b-9052-3d359e52962e") + private val CHR_MEASUREMENT_STATUS_RESULT = UUID.fromString("ba216311-1787-472b-bef6-3eb29e62293e") + private val CHR_WEIGHT_MEASUREMENT = uuid16Static(0x2A9D) + private val CHR_REALTIME_WEIGHT = UUID.fromString("46797c17-d639-488d-9476-4789e8472878") + private val CHR_HISTORY_WEIGHT = UUID.fromString("0212f42a-5f19-4bc1-ba52-d7ec7ccb71a4") + + private fun uuid16Static(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun realtimePayload(): ByteArray = + byteArrayOf( + 0x34, 0x1E, + 0xB9.toByte(), 0x00, + 0xEA.toByte(), 0x07, 0x06, 0x16, 0x0F, 0x0E, 0x2A, 0x00, + 0xF4.toByte(), 0x01, 0xF5.toByte(), 0x01, 0xF6.toByte(), 0x01, + 0xF7.toByte(), 0x01, 0xF8.toByte(), 0x01, 0xF9.toByte(), 0x01, + 0x48, 0x00, + 0x58, 0x02, 0x59, 0x02, 0x5A, 0x02, + 0x5B, 0x02, 0x5C, 0x02, 0x5D, 0x02, + ) + + private fun historyPayload(completeFlag: Int): ByteArray = + realtimePayload() + byteArrayOf(0x01, completeFlag.toByte()) + } +} From 0320339084c162f9ec2de655c9fb4fba4b5b381e Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 19 Jul 2026 07:14:13 +0200 Subject: [PATCH 12/72] Add support for `values_json` in `DatabaseProvider` update path - Allow external apps to enrich existing measurement entries with any metric (including custom types) via the `values_json` payload. - Skip types already handled by explicit columns to prevent redundant updates. - Add `GenericValueJsonTest` to verify round-trip parsing of numeric values across predefined and custom types. --- .../core/database/DatabaseProvider.kt | 35 ++++++ .../core/usecase/GenericValueJsonTest.kt | 116 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt index f821e2737..670d9ece9 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt @@ -470,6 +470,41 @@ class DatabaseProvider : ContentProvider() { processValueUpdate(waterFromProviderPercent, MeasurementTypeKey.WATER, MeasurementColumns.WATER) processValueUpdate(muscleFromProviderPercent, MeasurementTypeKey.MUSCLE, MeasurementColumns.MUSCLE) + // Generic inbound values (all types incl. custom) supplied as a + // "values_json" payload — mirrors the insert path. Lets an external app + // (e.g. a circumference tape) enrich an existing entry with any metric. + // Values arrive canonical and are converted to the user's unit by parse(). + val valuesJson = values.getAsString(MeasurementColumns.VALUES_JSON) + if (valuesJson != null) { + val typesByKey = allMeasurementTypes.associateBy { it.key.name } + val typesById = allMeasurementTypes.associateBy { it.id } + // Skip types already handled by the explicit weight/fat/water/muscle + // columns above, so a duplicate in the JSON can't overwrite them. + val handledTypeIds = buildSet { + if (values.containsKey(MeasurementColumns.WEIGHT)) typeMap[MeasurementTypeKey.WEIGHT]?.id?.let(::add) + if (values.containsKey(MeasurementColumns.BODY_FAT)) typeMap[MeasurementTypeKey.BODY_FAT]?.id?.let(::add) + if (values.containsKey(MeasurementColumns.WATER)) typeMap[MeasurementTypeKey.WATER]?.id?.let(::add) + if (values.containsKey(MeasurementColumns.MUSCLE)) typeMap[MeasurementTypeKey.MUSCLE]?.id?.let(::add) + } + GenericValueJson.parse(valuesJson, typesByKey, typesById).forEach { (typeId, userValue) -> + if (typeId in handledTypeIds) return@forEach + val existingValue = existingMeasurementWithValues.values.find { it.type.id == typeId } + if (existingValue != null) { + if (existingValue.value.floatValue != userValue) { + databaseRepository.updateMeasurementValue(existingValue.value.copy(floatValue = userValue)) + anyChangeMade = true + LogManager.d(TAG, "Updated typeId=$typeId for measurement ${measurementToUpdate.id} to $userValue (values_json)") + } + } else { + databaseRepository.insertMeasurementValue( + MeasurementValue(measurementId = measurementToUpdate.id, typeId = typeId, floatValue = userValue) + ) + anyChangeMade = true + LogManager.d(TAG, "Inserted typeId=$typeId for measurement ${measurementToUpdate.id} = $userValue (values_json)") + } + } + } + if (anyChangeMade) { LogManager.d(TAG, "Measurement values changed for user ${measurementToUpdate.userId}. Derived values will be recalculated by repository calls.") return@runBlocking 1 // Return 1 row affected diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt new file mode 100644 index 000000000..2abf7afff --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt @@ -0,0 +1,116 @@ +/* + * openScale + * Copyright (C) 2026 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.usecase + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementValue +import com.health.openscale.core.data.UnitType +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Round-trip coverage for [GenericValueJson], the self-describing value payload shared by the + * sync Intent and the ContentProvider. The provider's insert AND update paths rely on + * [GenericValueJson.parse] returning every numeric type (incl. custom) in the user's unit, so a + * build -> parse round-trip must preserve the values regardless of unit or predefined/custom type. + * + * Runs under Robolectric because [GenericValueJson] uses android's org.json implementation. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GenericValueJsonTest { + + private companion object { + const val EPS = 1e-3f + + // Predefined types kept in their canonical unit (no conversion on the wire). + val weightKg = MeasurementType(id = 1, key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG) + val fatPercent = MeasurementType(id = 2, key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT) + // Predefined type in a non-canonical unit -> exercises inch<->cm conversion on the wire. + val waistInch = MeasurementType(id = 3, key = MeasurementTypeKey.WAIST, unit = UnitType.INCH) + // Custom types are matched by typeId (key == "CUSTOM") on parse. + val chestCustomCm = MeasurementType(id = 50, key = MeasurementTypeKey.CUSTOM, name = "Chest tape", unit = UnitType.CM) + val noteCustomText = MeasurementType( + id = 60, key = MeasurementTypeKey.CUSTOM, name = "Note", unit = UnitType.NONE, inputType = InputFieldType.TEXT + ) + + val allTypes = listOf(weightKg, fatPercent, waistInch, chestCustomCm, noteCustomText) + val typesById = allTypes.associateBy { it.id } + val typesByKey = allTypes.associateBy { it.key.name } + } + + private fun value(typeId: Int, float: Float? = null, text: String? = null) = + MeasurementValue(measurementId = 1, typeId = typeId, floatValue = float, textValue = text) + + private fun roundTrip(values: List): Map = + GenericValueJson.parse(GenericValueJson.build(values, typesById), typesByKey, typesById) + .toMap() + + @Test + fun roundTrip_preservesNumericValuesAcrossPredefinedCustomAndUnits() { + val parsed = roundTrip( + listOf( + value(weightKg.id, 80.5f), // canonical unit, exact + value(fatPercent.id, 18.4f), // percent, no conversion + value(waistInch.id, 36.0f), // inch -> cm -> inch conversion + value(chestCustomCm.id, 42.3f), // custom type matched by typeId + ) + ) + + assertThat(parsed[weightKg.id]).isWithin(EPS).of(80.5f) + assertThat(parsed[fatPercent.id]).isWithin(EPS).of(18.4f) + assertThat(parsed[waistInch.id]).isWithin(EPS).of(36.0f) + assertThat(parsed[chestCustomCm.id]).isWithin(EPS).of(42.3f) + } + + @Test + fun roundTrip_skipsNonNumericValues() { + // A TEXT value carries no "value" field, so it must not appear in the parsed numeric set. + val parsed = roundTrip( + listOf( + value(weightKg.id, 80.5f), + value(noteCustomText.id, text = "morning weigh-in"), + ) + ) + + assertThat(parsed).containsKey(weightKg.id) + assertThat(parsed).doesNotContainKey(noteCustomText.id) + } + + @Test + fun parse_ignoresTypesUnknownToTheReceiver() { + // Built with the full type set, but parsed by a receiver that doesn't know the waist type + // (e.g. it was removed) -> that entry is dropped instead of failing the whole payload. + val json = GenericValueJson.build( + listOf(value(weightKg.id, 70f), value(waistInch.id, 34f)), + typesById, + ) + val reducedById = typesById.filterKeys { it != waistInch.id } + val reducedByKey = typesByKey.filterKeys { it != waistInch.key.name } + + val parsed = GenericValueJson.parse(json, reducedByKey, reducedById).toMap() + + assertThat(parsed).containsKey(weightKg.id) + assertThat(parsed).doesNotContainKey(waistInch.id) + } +} From fe77bcbe31af660a9651dfa2c7c9dfa1a48efd88 Mon Sep 17 00:00:00 2001 From: Joost van den Broek Date: Wed, 22 Jul 2026 07:55:25 +0200 Subject: [PATCH 13/72] Revert MiScaleHandler time sync to device-local timezone (#1427) Commit cd4d5e16 ("Fix: Use UTC for MiScale time synchronization") switched both the write of the BLE Current Time characteristic and the parsing of time read back from the scale from the device's default (local) timezone to hardcoded UTC. The scale itself has no timezone concept: it only ever receives and echoes back plain wall-clock digits (year/month/day/hour/minute/second), with no UTC/local distinction. It expects those digits to represent local wall-clock time. Sending it UTC digits instead - while still interpreting whatever it reports back as UTC - produces a timestamp permanently offset from the true measurement time by the device's local UTC offset (e.g. +2h during CEST, +1h during CET), since the round-trip no longer matches the convention the firmware itself assumes. This was found by cross-referencing years of MIBFS (Xiaomi Mi Body Composition Scale) measurements recorded by openScale against an independent BLE reader of the same physical scale: every measurement from before cd4d5e16 matches ground truth exactly; every measurement since is offset by exactly the local UTC delta at the time of measurement. No linked issue or repro was found for cd4d5e16's stated rationale ("previously the device's local timezone was used, which could lead to incorrect timestamps"), and it is contradicted by this data. This reverts the two timezone-related lines to their pre-cd4d5e16 behavior (local timezone for both write and parse), while keeping the unrelated day-of-week/adjust-reason payload byte correction from the same commit, which is a separate, valid protocol fix. Co-authored-by: Claude Sonnet 5 --- .../health/openscale/core/bluetooth/scales/MiScaleHandler.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt index bba807cc7..bf7d96022 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt @@ -202,7 +202,7 @@ class MiScaleHandler : ScaleDeviceHandler() { /** Prefer writing Current Time to the primary service; fallback to alternate if needed. */ private fun writeCurrentTimePreferPrimary(primarySvc: UUID, alternateSvc: UUID) { - val c = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + val c = Calendar.getInstance() val year = c.get(Calendar.YEAR) val payload = byteArrayOf( (year and 0xFF).toByte(), ((year shr 8) and 0xFF).toByte(), @@ -455,7 +455,7 @@ class MiScaleHandler : ScaleDeviceHandler() { private fun parseMinuteDate(y: Int, m: Int, d: Int, h: Int, min: Int): Date? = runCatching { val sdf = SimpleDateFormat("yyyy/MM/dd/HH/mm", Locale.US) - sdf.timeZone = TimeZone.getTimeZone("UTC") + sdf.timeZone = TimeZone.getDefault() sdf.parse("$y/$m/$d/$h/$min") }.getOrNull() From c261d87d20ec3a92544bf16d939a0e4017464297 Mon Sep 17 00:00:00 2001 From: Richard LT Date: Fri, 24 Jul 2026 13:20:02 +0200 Subject: [PATCH 14/72] Add support for the FitTrack Dara 2.0 scale (#1430) eLink/Icomon "MGB"-family scale on service 0xFFB0. Reads weight plus body fat, water, lean mass, bone, protein and visceral fat from the scale's data dump, with time/user/unit sync. Registered ahead of MGBHandler so its FitTrack name match wins the shared 0xFFB0 service. Includes unit tests for the frame decode. --- .../openscale/core/bluetooth/ScaleFactory.kt | 6 +- .../bluetooth/scales/FitTrackDaraHandler.kt | 316 ++++++++++++++++++ .../scales/FitTrackDaraHandlerTest.kt | 76 +++++ 3 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 43b547f7c..07da8eb32 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -21,6 +21,7 @@ import android.content.Context import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler import com.health.openscale.core.bluetooth.scales.AAAxHandler +import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler import com.health.openscale.core.bluetooth.scales.ScaleupHandler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler import com.health.openscale.core.bluetooth.scales.CultSmartScaleProHandler @@ -106,8 +107,8 @@ class ScaleFactory @Inject constructor( // List of modern Kotlin-based device handlers. // Order matters: createCommunicator() returns the FIRST handler whose supportFor() is non-null. - // TaylorBIAHandler must stay ahead of MGBHandler — both live on service 0xFFB0, and MGBHandler - // also matches that service, so a later position would let MGB wrongly claim the Taylor scale. + // TaylorBIAHandler and FitTrackDaraHandler must stay ahead of MGBHandler — all live on service + // 0xFFB0, which MGBHandler also matches, so a later position would let MGB wrongly claim them. private val modernKotlinHandlers: List = listOf( BeurerBF450Handler(), TaylorBIAHandler(), @@ -136,6 +137,7 @@ class ScaleFactory @Inject constructor( RobiS9Handler(), VitafitVT701Handler(), EEBBLHandler(), + FitTrackDaraHandler(), MGBHandler(), MedisanaBs44xHandler(), InlifeHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandler.kt new file mode 100644 index 000000000..63684274f --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandler.kt @@ -0,0 +1,316 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.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 kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.UUID + +/** + * FitTrack Dara 2.0 body-composition scale. + * + * The FitTrack Dara (vendor app `com.elink.fittrackhealth.pro`, an eLink/Icomon platform) is part of + * the same "MGB" family as [MGBHandler]/[TaylorBIAHandler]: it exposes service 0xFFB0 and uses the + * `AC 02 … CC`/checksum framing. It advertises as "FitTrack" (not swan/icomon/yg), so it needs its + * own name match; register this handler *before* [MGBHandler] in ScaleFactory so it wins (MGBHandler + * also claims service 0xFFB0). + * + * ## GATT layout + * Service 0xFFB0: + * 0xFFB1 – config/command write (App → Scale, Write-No-Response) + * 0xFFB2 – data NOTIFY (Scale → App) + * (A second custom service d618d000-…-000000000000 is present but unused by this protocol.) + * + * ## Framing (reverse-engineered from a btsnoop_hci.log capture) + * Short control/telemetry frames are 8 bytes: + * AC 02 + * - cksum = (b2 + b3 + b4 + b5 + chan) & 0xFF (see [checksum8]) + * - (byte 6) selects the logical stream: + * 0xCC = command / handshake (App → Scale, and echoes) + * 0xCE = live (unstable) weight (Scale → App) + * 0xCA = stabilised weight (Scale → App) + * 0xCB = final body-composition data dump (Scale → App) + * + * Weight frames (chan 0xCE/0xCA): b2/b3 hold the weight big-endian in 0.1 display-unit steps (see + * [weightDeciUnits]). The scale transmits in whatever unit we set via `FE 06 `, so we convert + * to kg with [ConverterUtils.toKilogram] (WeightUnit.toInt(): KG=0, LB=1, ST=2). + * + * ## Final body-composition dump (chan 0xCB) + * When the reading locks, the scale sends the computed metrics as index/value pairs + * `AC 02 FE CB `, then repeats them packed into two 20-byte notifications + * (`AC 02 FF …` + `01 00 …`) mirroring the [MGBHandler] layout. The individual index frames are the + * authoritative source; the packed frames are used only as a completion signal. + * + * Index → metric (each index has its own scale factor; most are ÷10, but visceral fat, metabolic + * age and BMR are integers): + * 0 weight · 1 BMI · 2 fat % · 3 subcutaneous fat % · 4 visceral index · 5 lean mass kg · + * 6 BMR kcal · 7 bone kg · 8 water % · 9 metabolic age · 10 protein % · 252 (0xFC) end marker. + * [applyDumpIndex] stores the fields [ScaleMeasurement] supports; BMI/BMR are derived by openScale, + * and subcutaneous fat / metabolic age have no field. + */ +class FitTrackDaraHandler : ScaleDeviceHandler() { + + companion object { + /** Consecutive identical live readings that mark a weight as final (~1 s at the scale's rate). */ + private const val STABLE_FRAMES = 4 + + /** + * Grace period after a settled weight before publishing without a dump. The body-composition + * dump (chan 0xCB) follows a few seconds after the weight settles; if it never arrives (e.g. + * the scale requires the vendor's AE/AD pairing, which we don't replay), we still record weight. + */ + private const val DUMP_GRACE_MS = 10_000L + + // Frame constants + private const val HDR = 0xAC + private const val CHAN_CMD = 0xCC + private const val CHAN_WEIGHT_LIVE = 0xCE + private const val CHAN_WEIGHT_STABLE = 0xCA + private const val CHAN_DUMP = 0xCB + + /** 8-byte control-frame checksum: (bytes[2] + bytes[3] + bytes[4] + bytes[5] + bytes[6]) & 0xFF. */ + fun checksum8(b: ByteArray): Int { + var s = 0 + for (i in 2..6) s += b[i].toInt() and 0xFF + return s and 0xFF + } + + /** Weight as tenths of a display unit (kg/lb/st) from the two big-endian value bytes. */ + fun weightDeciUnits(hi: Byte, lo: Byte): Int = + ((hi.toInt() and 0xFF) shl 8) or (lo.toInt() and 0xFF) + } + + private val SERVICE: UUID = uuid16(0xFFB0) + private val CHAR_CFG: UUID = uuid16(0xFFB1) // config/command writes (App → Scale) + private val CHAR_DATA: UUID = uuid16(0xFFB2) // measurement notifications (Scale → App) + + /** Latest live weight (kg) seen this session; published once it settles / on fallback. */ + private var pendingWeightKg: Float = 0f + /** Run length of identical live readings; publishing weight-only triggers at [STABLE_FRAMES]. */ + private var stableCount = 0 + /** Deci-unit value of the previous live reading (for the identical-run counter). */ + private var lastDeci = -1 + /** True once we've published this session (publishing is single-shot). */ + private var published = false + /** Grace timer that publishes if the body-comp dump never arrives. */ + private var dumpJob: Job? = null + /** Measurement accumulated across the dump's index frames, published when the dump completes. */ + private var pending = ScaleMeasurement() + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + // Match on the advertised name so we don't collide with the other 0xFFB0 handlers. + val name = device.name.uppercase(Locale.ROOT) + if (!name.startsWith("FITTRACK")) return null + + return DeviceSupport( + displayName = "FitTrack Dara 2.0", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.USER_SYNC, + DeviceCapability.TIME_SYNC, + DeviceCapability.UNIT_CONFIG, + ), + // Weight + fat/water/bone/visceral are decoded from real hardware (see [applyDumpIndex]). + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + // Handlers are long-lived singletons reused across connections, so reset per-session state. + pendingWeightKg = 0f + stableCount = 0 + lastDeci = -1 + published = false + dumpJob?.cancel() + dumpJob = null + pending = ScaleMeasurement() + + // 1) Subscribe to measurement notifications on FFB2. + setNotifyOn(SERVICE, CHAR_DATA) + + // 2) MGB-family init sequence on FFB1. Mirrors what the FitTrack app sent in the capture + // (F7/FA magic, FB user profile, FE 06 unit). We intentionally do NOT replay the vendor's + // AE 03 … / AD 01 … challenge-response: it is dynamic (the AD payload looks encrypted) so it + // cannot be reproduced from a capture, and weight streaming worked without it. + writeCfg(0xF7, 0, 0, 0) // magic init #1 + writeCfg(0xFA, 0, 0, 0) // magic init #2 + + // User profile: sex (1=male, 2=female), age in years, height in cm. + val sexByte = if (user.gender.isMale()) 1 else 2 + val heightCm = user.bodyHeight.toInt().coerceIn(0, 255) + writeCfg(0xFB, sexByte, user.age.coerceIn(0, 255), heightCm) + + // Date (year since 2000, month 1-12, day) and time (HH, MM, SS) from the phone clock. + val now = Calendar.getInstance() + val yy = (now.get(Calendar.YEAR) - 2000).coerceIn(0, 99) + writeCfg(0xFD, yy, now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH)) + writeCfg(0xFC, now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND)) + + // Display unit the scale should transmit in: WeightUnit.toInt() → KG=0, LB=1, ST=2. + writeCfg(0xFE, 6, user.scaleUnit.toInt(), 0) + + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onDisconnected() { + dumpJob?.cancel() + dumpJob = null + // Last-chance publish if data was seen but nothing was finalised. + if (!published) publishFinal() + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (characteristic != CHAR_DATA) return + + // Dump second fragment ("01 00 …"): the per-index values already arrived as FE frames, so + // this only signals the dump is complete → publish. + if (data.size >= 20 && (data[0].toInt() and 0xFF) == 0x01 && (data[1].toInt() and 0xFF) == 0x00) { + publishFinal() + return + } + + if (data.size < 8 || (data[0].toInt() and 0xFF) != HDR) return + + val b2 = data[2].toInt() and 0xFF + val chan = data[6].toInt() and 0xFF + + // Dump first fragment ("AC 02 FF 00 02 21 "): packed weight/BMI/ + // fat/subcut, redundant with the FE frames below but re-applied idempotently if those are missed. + if (data.size >= 20 && b2 == 0xFF) { + for ((i, off) in listOf(0 to 12, 1 to 14, 2 to 16, 3 to 18)) { + applyDumpIndex(i, weightDeciUnits(data[off], data[off + 1])) + } + armDumpGrace() + return + } + + // 8-byte control/telemetry frame — validate the checksum before trusting it. + if (data.size == 8 && checksum8(data) != (data[7].toInt() and 0xFF)) { + logD("bad checksum, dropping frame ${data.toHexPreview(8)}") + return + } + + when (chan) { + CHAN_WEIGHT_LIVE -> handleWeightFrame(data, stable = false, user = user) + CHAN_WEIGHT_STABLE -> handleWeightFrame(data, stable = true, user = user) + // FE index/value pairs (AC 02 FE CB); other 0xCB frames (FA/FB) ignored. + CHAN_DUMP -> if (b2 == 0xFE) applyDumpIndex(data[3].toInt() and 0xFF, weightDeciUnits(data[4], data[5])) + CHAN_CMD -> { /* command echo / handshake; nothing to do */ } + else -> logD("unknown chan 0x%02X frame %s".format(chan, data.toHexPreview(8))) + } + } + + // --- Weight (live + stable) ------------------------------------------------ + + private fun handleWeightFrame(data: ByteArray, stable: Boolean, user: ScaleUser) { + val deci = weightDeciUnits(data[2], data[3]) + // Ignore the 0-weight idle frames and obviously-out-of-range noise (< 600.0 display units). + if (deci <= 0 || deci >= 6000) return + + val weightKg = ConverterUtils.toKilogram(deci / 10.0f, user.scaleUnit) + pendingWeightKg = weightKg + + stableCount = if (deci == lastDeci) stableCount + 1 else 1 + lastDeci = deci + + // Don't publish on a settled weight yet — arm a grace timer and let the body-comp dump win; + // the timer only fires if the dump never arrives. + if (stable || stableCount >= STABLE_FRAMES) armDumpGrace() + } + + private fun armDumpGrace() { + if (dumpJob != null || published) return + dumpJob = scope.launch { + delay(DUMP_GRACE_MS) + if (!published && pendingWeightKg > 0f) { + logD("body-composition dump not received within $DUMP_GRACE_MS ms; publishing what we have") + publishFinal() + } + } + } + + /** Store the dump indices that map to a [ScaleMeasurement] field (see class docs for the full set). */ + private fun applyDumpIndex(idx: Int, value: Int) { + when (idx) { + 0 -> pending.weight = ConverterUtils.toKilogram(value / 10.0f, currentAppUser().scaleUnit) + 2 -> pending.fat = value / 10.0f // % + 4 -> pending.visceralFat = value.toFloat() // index (no ÷10) + 5 -> pending.lbm = value / 10.0f // kg + 7 -> pending.bone = value / 10.0f // kg + 8 -> pending.water = value / 10.0f // % + 10 -> pending.protein = value / 10.0f // % + } + } + + // --- Publish --------------------------------------------------------------- + + /** + * Publish the accumulated measurement exactly once. Weight comes from the dump (idx 0) when + * present, otherwise falls back to the latest live-stream weight so a reading is still recorded + * even if the body-composition dump never arrives. Any body-comp fields set by [applyDumpIndex] + * ride along. + */ + private fun publishFinal() { + if (published) return + val weightKg = if (pending.weight > 0f) pending.weight else pendingWeightKg + if (weightKg <= 0f) return + published = true + dumpJob?.cancel() + dumpJob = null + pending.weight = weightKg + pending.dateTime = Date() + logD("publishing weight=${pending.weight} fat=${pending.fat} water=${pending.water} " + + "lbm=${pending.lbm} bone=${pending.bone} visceral=${pending.visceralFat} protein=${pending.protein}") + publish(pending) + requestDisconnect() + } + + // --- Command writer -------------------------------------------------------- + + /** + * Writes an 8-byte config packet to 0xFFB1 (MGB-family framing): + * `[AC, 02, b2, b3, b4, b5, CC, checksum]`, checksum = (b2 + b3 + b4 + b5 + CC) & 0xFF. + * The scale uses Write-No-Response on this characteristic. + */ + private fun writeCfg(b2: Int, b3: Int, b4: Int, b5: Int) { + val buf = ByteArray(8) + buf[0] = HDR.toByte() + buf[1] = 0x02 + buf[2] = (b2 and 0xFF).toByte() + buf[3] = (b3 and 0xFF).toByte() + buf[4] = (b4 and 0xFF).toByte() + buf[5] = (b5 and 0xFF).toByte() + buf[6] = CHAN_CMD.toByte() + buf[7] = checksum8(buf).toByte() + writeTo(SERVICE, CHAR_CFG, buf, withResponse = false) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandlerTest.kt new file mode 100644 index 000000000..27323e2fd --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandlerTest.kt @@ -0,0 +1,76 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.WeightUnit +import com.health.openscale.core.utils.ConverterUtils +import org.junit.Test + +/** + * Unit tests for the FitTrack Dara 2.0 frame decode ([FitTrackDaraHandler.checksum8] / + * [FitTrackDaraHandler.weightDeciUnits]), reverse-engineered from a btsnoop_hci.log capture. + * + * Frame (8 bytes): AC 02 + * cksum = (b2 + b3 + b4 + b5 + chan) & 0xFF + * weight frames (chan 0xCE/0xCA): weight_display = ((b2<<8)|b3) / 10.0 + * + * Ground truth from the capture: the stable weight frame AC 02 05 5F 00 00 CA .. + * decodes 0x055F = 1375 → 137.5 lb → 62.37 kg. + */ +class FitTrackDaraHandlerTest { + + private fun bytes(vararg v: Int): ByteArray = ByteArray(v.size) { v[it].toByte() } + + @Test + fun `checksum matches captured command frame`() { + // AC 02 FE 00 00 00 CC CA (FE+00+00+00+CC = 0x1CA → 0xCA) + val frame = bytes(0xAC, 0x02, 0xFE, 0x00, 0x00, 0x00, 0xCC, 0xCA) + assertThat(FitTrackDaraHandler.checksum8(frame)).isEqualTo(0xCA) + } + + @Test + fun `checksum matches captured live-weight frame`() { + // AC 02 05 51 00 00 CE 24 (05+51+00+00+CE = 0x124 → 0x24) + val frame = bytes(0xAC, 0x02, 0x05, 0x51, 0x00, 0x00, 0xCE, 0x24) + assertThat(FitTrackDaraHandler.checksum8(frame)).isEqualTo(0x24) + } + + @Test + fun `weight decodes to recorded 137_5 lb`() { + // Stable frame value bytes 05 5F. + val deci = FitTrackDaraHandler.weightDeciUnits(0x05.toByte(), 0x5F.toByte()) + assertThat(deci).isEqualTo(1375) + + val displayLb = deci / 10.0f + assertThat(displayLb).isWithin(1e-4f).of(137.5f) + + // openScale stores kg: 137.5 lb ≈ 62.37 kg. + val kg = ConverterUtils.toKilogram(displayLb, WeightUnit.LB) + assertThat(kg).isWithin(0.05f).of(62.37f) + } + + @Test + fun `weight in kg passes through unchanged`() { + // If the user's unit is kg, the same raw would be 62.4 kg (0x0270 = 624 → 62.4). + val deci = FitTrackDaraHandler.weightDeciUnits(0x02.toByte(), 0x70.toByte()) + assertThat(deci).isEqualTo(624) + assertThat(ConverterUtils.toKilogram(deci / 10.0f, WeightUnit.KG)) + .isWithin(1e-4f).of(62.4f) + } +} From bf0b91e74fce4dbe0dff084618fd7878ff6b9ac8 Mon Sep 17 00:00:00 2001 From: Daniel Kukula <904179+dkuku@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:23:35 +0200 Subject: [PATCH 15/72] Add selectable Scientific body-composition algorithm to Mi Scale (#1429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add selectable Scientific body-composition algorithm to Mi Scale Mi Scale v1/v2 previously derived body composition only from the reverse-engineered Mi Fit algorithm (MiScaleLib). This adds a per-scale setting letting users pick between that "Xiaomi (original app)" path and a new "Scientific" estimator. - BodyMiScaleLib: mono-frequency estimator ported from the bodymiscale Home Assistant integration (GPL-3.0). SCIENCE mode chains a hardware-calibrated LBM with peer-reviewed formulas (Siri, Pace, Wang, Schofield) and additionally reports protein and BMR. Locked to byte-for-byte parity with the reference project via a regression test. - MiScaleHandler: radio picker to choose the algorithm; new measurements use the selected path. - ScaleDeviceHandler: extract a reusable SettingRadioGroup composable (settings-backed radio group) for device configuration UIs. - CREDITS: add bodymiscale attribution. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Daniel Kukula * Simplify BodyMiScaleLib to a scientific-only estimator The Mode enum threaded through every method was a vestigial artifact of the upstream bodymiscale port. openScale already selects the algorithm at the handler (MiScaleLib vs BodyMiScaleLib), so BodyMiScaleLib.Mode.XIAOMI was never used in production — only by a test comparing it to MiScaleLib. Drop Mode and its XIAOMI branches so the class is purely the scientific path. Method signatures shrink accordingly (getProtein no longer needs muscle/water, which existed only for the Xiaomi formula). Output is unchanged; the regression fixture still matches byte-for-byte. Co-Authored-By: Claude Opus 4.8 (1M context) * Update CREDITS --------- Signed-off-by: Daniel Kukula Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: OliE --- .../core/bluetooth/libs/BodyMiScaleLib.kt | 141 ++++++++++++++++++ .../core/bluetooth/scales/MiScaleHandler.kt | 81 +++++++++- .../bluetooth/scales/ScaleDeviceHandler.kt | 56 +++++++ .../app/src/main/res/values/strings.xml | 6 + .../core/bluetooth/libs/BodyMiScaleLibTest.kt | 67 +++++++++ 5 files changed, 343 insertions(+), 8 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt new file mode 100644 index 000000000..d21dfcc01 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt @@ -0,0 +1,141 @@ +/* + * openScale + * Copyright (C) 2026 olie.xdev + * + * Portions derived from bodymiscale (C) dckiller51 and contributors, GPL-3.0 + * (https://github.com/dckiller51/bodymiscale). + * + * 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.libs + +import com.health.openscale.core.data.GenderType + +/** + * Scientific mono-frequency (standard impedance) body-composition estimator, ported from the + * Home Assistant integration **bodymiscale** by dckiller51 (GPL-3.0): + * https://github.com/dckiller51/bodymiscale + * custom_components/bodymiscale/metrics/{impedance,weight}.py + util.py + * + * A hardware-calibrated LBM (the same baseline Xiaomi uses) is combined with peer-reviewed + * downstream formulas — body fat via the Siri (1956) 2-compartment model, water via the + * Pace & Rathbun (1945) constant, protein via Wang (1999), and BMR via the Schofield (WHO) + * equation. + * + * All metrics are chained and internally consistent: fat is derived from LBM, water and + * protein from fat/LBM, and muscle from fat and bone. Compute [getLbm] first and feed its + * result into the other methods (as the callers in bodymiscale do) to reproduce its output + * exactly. + * + * The reverse-engineered Zepp Life / Mi Fit algorithm is not reproduced here — openScale + * selects [MiScaleLib] for that path. The S400 dual-frequency mode of bodymiscale is likewise + * out of scope; openScale has its own dual-frequency path in [S400BodyComposition]. + */ +class BodyMiScaleLib( + private val gender: GenderType, + private val age: Int, + private val heightCm: Float, +) { + private val isMale = gender == GenderType.MALE + + /** + * Lean / fat-free body mass in kg — the Xiaomi hardware-calibrated formula, shared by + * both modes and capped at 98% of body weight. Everything downstream depends on this. + */ + fun getLbm(weightKg: Float, impedance: Float): Float { + val lbm = (heightCm * 9.058f / 100f) * (heightCm / 100f) + + weightKg * 0.32f + 12.226f - impedance * 0.0068f - age * 0.0542f + return minOf(lbm, weightKg * 0.98f) + } + + /** Body fat percentage via the Siri (1956) 2-compartment model. Pass the [getLbm] result as [lbm]. */ + fun getFat(weightKg: Float, lbm: Float): Float { + val fat = (weightKg - lbm) / weightKg * 100f + return fat.coerceIn(5f, 75f) + } + + /** Water percentage of body weight, via the Pace & Rathbun (1945) 0.73 constant. */ + fun getWater(fatPercent: Float): Float = + ((100f - fatPercent) * 0.73f).coerceIn(35f, 73f) + + /** Protein percentage via Wang (1999): protein ≈ 19.5% of LBM. */ + fun getProtein(weightKg: Float, lbm: Float): Float = + (lbm * 0.195f / weightKg * 100f).coerceIn(5f, 32f) + + /** Bone mass in kg — empirical formula shared by all modes, driven by [getLbm]. */ + fun getBoneMass(lbm: Float): Float { + val base = if (isMale) 0.18016894f else 0.245691014f + var bone = (base - lbm * 0.05158f) * -1f + bone = if (bone > 2.2f) bone + 0.1f else bone - 0.1f + if ((isMale && bone > 5.2f) || (!isMale && bone > 5.1f)) bone = 8.0f + return bone.coerceIn(0.5f, 8f) + } + + /** + * Total muscle mass in kg: weight − fat mass − bone mass. Matches bodymiscale's + * "muscle_mass" sensor (the "Mięśnie" / Masa mięśniowa value), not skeletal muscle. + */ + fun getMuscleMass(weightKg: Float, fatPercent: Float, boneMassKg: Float): Float { + val muscle = weightKg - (fatPercent * 0.01f * weightKg) - boneMassKg + return muscle.coerceIn(10f, 120f) + } + + /** Basal metabolic rate in kcal/day via the Schofield (WHO) equation. */ + fun getBmr(weightKg: Float): Float = + schofieldBmr(weightKg).coerceIn(500f, 5000f) + + /** Schofield BMR by age bracket (WHO standard). */ + private fun schofieldBmr(weightKg: Float): Float { + val coeffs = if (isMale) MALE_SCHOFIELD else FEMALE_SCHOFIELD + val (slope, constant) = when { + age < 3 -> coeffs[0] + age < 10 -> coeffs[1] + age < 18 -> coeffs[2] + age < 30 -> coeffs[3] + age < 60 -> coeffs[4] + else -> coeffs[5] + } + return slope * weightKg + constant + } + + /** Visceral fat rating (Zepp Life formula, shared by all modes). */ + fun getVisceralFat(weightKg: Float): Float { + val h = heightCm + val w = weightKg + val vfal = if (isMale) { + if (h < w * 1.6f + 63.0f) + age * 0.15f + ((w * 305.0f) / ((h * 0.0826f * h - h * 0.4f) + 48.0f) - 2.9f) + else + age * 0.15f + (w * (h * -0.0015f + 0.765f) - h * 0.143f) - 5.0f + } else { + if (w <= h * 0.5f - 13.0f) + age * 0.07f + (w * (h * -0.0024f + 0.691f) - h * 0.027f) - 10.5f + else + age * 0.07f + ((w * 500.0f) / ((h * 1.45f + h * 0.1158f * h) - 120.0f) - 6.0f) + } + return vfal.coerceIn(1f, 50f) + } + + private companion object { + // Schofield (slope, constant) by bracket: 0-3, 3-10, 10-18, 18-30, 30-60, 60+ + val MALE_SCHOFIELD = arrayOf( + 59.512f to -30.4f, 22.706f to 504.3f, 17.686f to 658.2f, + 15.057f to 692.2f, 11.472f to 873.1f, 11.711f to 587.7f, + ) + val FEMALE_SCHOFIELD = arrayOf( + 58.317f to -31.1f, 20.315f to 485.9f, 13.384f to 692.6f, + 14.818f to 486.6f, 8.126f to 845.6f, 9.082f to 658.5f, + ) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt index bf7d96022..65ef7ab40 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt @@ -17,9 +17,11 @@ */ package com.health.openscale.core.bluetooth.scales +import androidx.compose.runtime.Composable import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.BodyMiScaleLib import com.health.openscale.core.bluetooth.libs.MiScaleLib import com.health.openscale.core.data.GenderType import com.health.openscale.core.service.ScannedDeviceInfo @@ -81,6 +83,36 @@ class MiScaleHandler : ScaleDeviceHandler() { // Timers private var historyFallbackJob: Job? = null + // ----- Body-composition algorithm selection (per-scale setting) ----- + + private val SETTINGS_KEY_ALGORITHM = "body_comp_algorithm" + + /** + * Which library derives body composition from this scale's mono-frequency impedance. + * [XIAOMI] is openScale's reverse-engineered Mi Fit port ([MiScaleLib]); [BODYMISCALE_SCIENCE] + * is the peer-reviewed estimator ported from the bodymiscale Home Assistant integration + * ([BodyMiScaleLib]). + */ + private enum class BodyCompAlgorithm { XIAOMI, BODYMISCALE_SCIENCE } + + private fun readBodyCompAlgorithm(): BodyCompAlgorithm = + runCatching { BodyCompAlgorithm.valueOf(settingsGetString(SETTINGS_KEY_ALGORITHM) ?: "") } + .getOrDefault(BodyCompAlgorithm.XIAOMI) + + @Composable + override fun DeviceConfigurationUi() { + SettingRadioGroup( + titleRes = R.string.mi_body_comp_algorithm_label, + key = SETTINGS_KEY_ALGORITHM, + options = listOf( + BodyCompAlgorithm.XIAOMI.name to R.string.mi_algorithm_xiaomi, + BodyCompAlgorithm.BODYMISCALE_SCIENCE.name to R.string.mi_algorithm_bodymiscale_science, + ), + defaultValue = BodyCompAlgorithm.XIAOMI.name, + descriptionRes = R.string.mi_algorithm_description, + ) + } + // ----- Capability & detection ----- override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { @@ -350,14 +382,10 @@ class MiScaleHandler : ScaleDeviceHandler() { if (imp > 0) { // Store the raw impedance so body composition can be recomputed later. m.impedance = imp.toDouble() - val sex = if (user.gender == GenderType.MALE) 1 else 0 - val lib = MiScaleLib(sex, user.age, user.bodyHeight) - m.water = lib.getWater(m.weight, imp.toFloat()) - m.visceralFat = lib.getVisceralFat(m.weight) - m.fat = lib.getBodyFat(m.weight, imp.toFloat()) - m.muscle = lib.getMuscle(m.weight, imp.toFloat()) - m.lbm = lib.getLBM(m.weight, imp.toFloat()) - m.bone = lib.getBoneMass(m.weight, imp.toFloat()) + when (readBodyCompAlgorithm()) { + BodyCompAlgorithm.XIAOMI -> applyXiaomiComposition(m, imp.toFloat(), user) + BodyCompAlgorithm.BODYMISCALE_SCIENCE -> applyBodyMiScaleComposition(m, imp.toFloat(), user) + } } } @@ -367,6 +395,43 @@ class MiScaleHandler : ScaleDeviceHandler() { return true } + /** openScale's reverse-engineered Mi Fit algorithm (the original-app parity path). */ + private fun applyXiaomiComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { + val sex = if (user.gender == GenderType.MALE) 1 else 0 + val lib = MiScaleLib(sex, user.age, user.bodyHeight) + m.water = lib.getWater(m.weight, impedance) + m.visceralFat = lib.getVisceralFat(m.weight) + m.fat = lib.getBodyFat(m.weight, impedance) + m.muscle = lib.getMuscle(m.weight, impedance) + m.lbm = lib.getLBM(m.weight, impedance) + m.bone = lib.getBoneMass(m.weight, impedance) + } + + /** + * Scientific estimator; see [BodyMiScaleLib] for attribution and formula sources. + * + * Fields follow the same units as [applyXiaomiComposition]: fat/water/muscle/protein are + * percentages of body weight, bone/lbm are kg, bmr is kcal/day. Muscle mass (kg) is + * converted to a percentage to match the schema and the Xiaomi path. + */ + private fun applyBodyMiScaleComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { + val lib = BodyMiScaleLib(user.gender, user.age, user.bodyHeight) + + val lbmKg = lib.getLbm(m.weight, impedance) + val fatPct = lib.getFat(m.weight, lbmKg) + val boneKg = lib.getBoneMass(lbmKg) + val muscleKg = lib.getMuscleMass(m.weight, fatPct, boneKg) + + m.fat = fatPct + m.water = lib.getWater(fatPct) + m.muscle = if (m.weight > 0f) muscleKg / m.weight * 100f else 0f + m.lbm = lbmKg + m.bone = boneKg + m.protein = lib.getProtein(m.weight, lbmKg) + m.bmr = lib.getBmr(m.weight) + m.visceralFat = lib.getVisceralFat(m.weight) + } + /** History record (10 bytes): [status][weightLE(2)][yearLE(2)][mon][day][h][m][s] */ private fun parseHistory10(d: ByteArray, user: ScaleUser): Boolean { if (d.size != 10) return false diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 7c8c755bf..6a17f6854 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -19,8 +19,12 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoGraph import androidx.compose.material.icons.filled.FitnessCenter @@ -30,12 +34,18 @@ 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.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.bluetooth.data.ScaleMeasurement @@ -139,6 +149,52 @@ abstract class ScaleDeviceHandler { } } + /** + * A titled radio-button group backed by a string setting. Reads the persisted value for + * [key] (falling back to [defaultValue]) and writes the selection back on each change. + * Each option is a stored value paired with its label string resource. + */ + @Composable + protected fun SettingRadioGroup( + @StringRes titleRes: Int, + key: String, + options: List>, + defaultValue: String, + @StringRes descriptionRes: Int? = null, + ) { + val persisted = settingsGetString(key) ?: defaultValue + var selected by remember(persisted) { mutableStateOf(persisted) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleSmall, + ) + options.forEach { (value, labelRes) -> + val onSelect = { + selected = value + settingsPutString(key, value) + } + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected == value, onClick = onSelect), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected == value, onClick = onSelect) + Text(stringResource(labelRes)) + } + } + if (descriptionRes != null) { + Text( + text = stringResource(descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + // --- Lifecycle entry points called by the adapter ------------------------- internal fun attachSettings(settings: DriverSettings) { diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 1fe8a5c50..7adea8155 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -394,6 +394,12 @@ Scale Configuration No additional special configuration available for this device. + + Body composition algorithm + Xiaomi (original app) + Scientific + Choose how body composition is derived from impedance. \"Xiaomi\" reproduces the original Mi Fit / Zepp Life app. \"Scientific\" uses peer-reviewed formulas (Siri, Pace, Wang, Schofield) and also reports protein and BMR. Applies to new measurements from this scale. + BLE Bind Key 32-character hex key diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt new file mode 100644 index 000000000..fe5f8b4ef --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt @@ -0,0 +1,67 @@ +/* + * openScale + * Copyright (C) 2026 olie.xdev + * + * Portions derived from bodymiscale (C) dckiller51 and contributors, GPL-3.0 + * (https://github.com/dckiller51/bodymiscale). + * + * 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.libs + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.GenderType +import org.junit.Test + +/** + * Unit tests for [BodyMiScaleLib]. + * + * The regression fixture is a real measurement captured from the upstream bodymiscale + * Home Assistant integration (male, 46y, 168cm, 86.10kg, impedance 421), so this test + * locks openScale's port to byte-for-byte parity with the reference project. + */ +class BodyMiScaleLibTest { + private val EPS = 0.05f + + private val lib = BodyMiScaleLib(GenderType.MALE, age = 46, heightCm = 168f) + private val weight = 86.10f + private val impedance = 421f + + @Test + fun matches_bodymiscale_reference() { + val lbm = lib.getLbm(weight, impedance) + val fat = lib.getFat(weight, lbm) + val water = lib.getWater(fat) + val bone = lib.getBoneMass(lbm) + val muscle = lib.getMuscleMass(weight, fat, bone) + val protein = lib.getProtein(weight, lbm) + + assertThat(lbm).isWithin(EPS).of(60.0f) + assertThat(fat).isWithin(EPS).of(30.33f) + assertThat(water).isWithin(EPS).of(50.86f) + assertThat(protein).isWithin(EPS).of(13.59f) + assertThat(bone).isWithin(EPS).of(3.01f) + assertThat(muscle).isWithin(EPS).of(56.97f) + assertThat(lib.getBmr(weight)).isWithin(1f).of(1861f) + assertThat(lib.getVisceralFat(weight)).isWithin(EPS).of(15.36f) + } + + @Test + fun fat_reacts_to_impedance_only_weakly() { + // Xiaomi-calibrated LBM barely moves with impedance; verify direction is sane. + val fatLow = lib.getFat(weight, lib.getLbm(weight, 400f)) + val fatHigh = lib.getFat(weight, lib.getLbm(weight, 600f)) + assertThat(fatHigh).isGreaterThan(fatLow) + } +} From c4ebad3487eedd3ff0a59d82895ea22f5e631dfc Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 24 Jul 2026 13:27:21 +0200 Subject: [PATCH 16/72] Move SettingRadioGroup from ScaleDeviceHandler to MiScaleHandler --- .../core/bluetooth/scales/MiScaleHandler.kt | 63 +++++++++++++++++++ .../bluetooth/scales/ScaleDeviceHandler.kt | 56 ----------------- 2 files changed, 63 insertions(+), 56 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt index 65ef7ab40..f65b65d73 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt @@ -17,7 +17,24 @@ */ package com.health.openscale.core.bluetooth.scales +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser @@ -99,6 +116,52 @@ class MiScaleHandler : ScaleDeviceHandler() { runCatching { BodyCompAlgorithm.valueOf(settingsGetString(SETTINGS_KEY_ALGORITHM) ?: "") } .getOrDefault(BodyCompAlgorithm.XIAOMI) + /** + * A titled radio-button group backed by a string setting. Reads the persisted value for + * [key] (falling back to [defaultValue]) and writes the selection back on each change. + * Each option is a stored value paired with its label string resource. + */ + @Composable + protected fun SettingRadioGroup( + @StringRes titleRes: Int, + key: String, + options: List>, + defaultValue: String, + @StringRes descriptionRes: Int? = null, + ) { + val persisted = settingsGetString(key) ?: defaultValue + var selected by remember(persisted) { mutableStateOf(persisted) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleSmall, + ) + options.forEach { (value, labelRes) -> + val onSelect = { + selected = value + settingsPutString(key, value) + } + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected == value, onClick = onSelect), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected == value, onClick = onSelect) + Text(stringResource(labelRes)) + } + } + if (descriptionRes != null) { + Text( + text = stringResource(descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + @Composable override fun DeviceConfigurationUi() { SettingRadioGroup( diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 6a17f6854..7c8c755bf 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -19,12 +19,8 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoGraph import androidx.compose.material.icons.filled.FitnessCenter @@ -34,18 +30,12 @@ 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.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.bluetooth.data.ScaleMeasurement @@ -149,52 +139,6 @@ abstract class ScaleDeviceHandler { } } - /** - * A titled radio-button group backed by a string setting. Reads the persisted value for - * [key] (falling back to [defaultValue]) and writes the selection back on each change. - * Each option is a stored value paired with its label string resource. - */ - @Composable - protected fun SettingRadioGroup( - @StringRes titleRes: Int, - key: String, - options: List>, - defaultValue: String, - @StringRes descriptionRes: Int? = null, - ) { - val persisted = settingsGetString(key) ?: defaultValue - var selected by remember(persisted) { mutableStateOf(persisted) } - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(titleRes), - style = MaterialTheme.typography.titleSmall, - ) - options.forEach { (value, labelRes) -> - val onSelect = { - selected = value - settingsPutString(key, value) - } - Row( - modifier = Modifier - .fillMaxWidth() - .selectable(selected = selected == value, onClick = onSelect), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton(selected = selected == value, onClick = onSelect) - Text(stringResource(labelRes)) - } - } - if (descriptionRes != null) { - Text( - text = stringResource(descriptionRes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - // --- Lifecycle entry points called by the adapter ------------------------- internal fun attachSettings(settings: DriverSettings) { From 1ee3510078296d911f85a79b3f2e208c7d71ef0a Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 29 Jul 2026 10:30:47 +0200 Subject: [PATCH 17/72] Add Runstar R6 scale support (#1432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Runstar R6 scale support Adds RunstarR6Handler for the Runstar R6, whose FFB0 protocol is distinct from R5 despite the similar name. Publishes live weight, heart rate, impedance, and syncs offline-stored history entries. RunstarR5Handler now excludes "RUNSTAR-R6" explicitly so the two can't collide; R6 is registered ahead of R5 in ScaleFactory. Also corrects two stale comments in RobiS9Handler now that the shared FFB0 checksum and impedance/heart-rate offsets are known. * Tighten Runstar R5 name matching instead of excluding R6 R5 matched any "RUNSTAR-" prefix, which also caught R6 devices. Restrict it to the known "RUNSTAR-R5"/"RUNSTAR-RX" names instead of adding an R6-specific exclusion — removes the need for R6 to be registered ahead of R5, and the collision no longer needs explaining in RunstarR6Handler's doc. Also trims two RobiS9Handler comments down to the new finding (checksum formula, impedance/heart-rate offsets) without pointing at RunstarR6Handler for the backstory. --------- Co-authored-by: Leon Dönch --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../core/bluetooth/scales/RobiS9Handler.kt | 11 +- .../core/bluetooth/scales/RunstarR5Handler.kt | 6 +- .../core/bluetooth/scales/RunstarR6Handler.kt | 258 ++++++++++++++++++ 4 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR6Handler.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 07da8eb32..a5c446ea7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -63,6 +63,7 @@ import com.health.openscale.core.bluetooth.scales.RenphoES26BBHandler import com.health.openscale.core.bluetooth.scales.RenphoHandler import com.health.openscale.core.bluetooth.scales.RobiS9Handler import com.health.openscale.core.bluetooth.scales.RunstarR5Handler +import com.health.openscale.core.bluetooth.scales.RunstarR6Handler import com.health.openscale.core.bluetooth.scales.RyFitHandler import com.health.openscale.core.bluetooth.scales.SanitasSbf72Handler import com.health.openscale.core.bluetooth.scales.SenssunHandler @@ -133,6 +134,7 @@ class ScaleFactory @Inject constructor( MiScaleS400Handler(), XiaomiS800Handler(), MiScaleHandler(), + RunstarR6Handler(), RunstarR5Handler(), RobiS9Handler(), VitafitVT701Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RobiS9Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RobiS9Handler.kt index 0519cd900..2af6e0d3d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RobiS9Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RobiS9Handler.kt @@ -45,9 +45,10 @@ import java.util.UUID * [MGBHandler] in [com.health.openscale.core.bluetooth.ScaleFactory] so a "Robi …" device * is claimed here by name (the MGB handler claims by service and would otherwise grab it). * - * The handshake is replayed verbatim: its 20-byte frames carry a trailer checksum whose - * algorithm is not cracked plus a stale unix-timestamp + token, so regenerating them is - * unsafe; the scale accepts the replayed frames for a weigh-in. + * The handshake is replayed verbatim: its 20-byte frames carry a trailer checksum, now + * known to be `sum(bytes[3..18]) & 0x1F` (verified with zero mismatches across all 11 + * frames of [HANDSHAKE]) — plus a stale unix-timestamp + token, so regenerating them is + * still unsafe; the scale accepts the replayed frames for a weigh-in. */ class RobiS9Handler : ScaleDeviceHandler() { @@ -94,8 +95,8 @@ class RobiS9Handler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { dateTime = Date() weight = weightKg - // Impedance offset is not decoded yet (the captured A3 frame is all-zero after the - // weight), so no body composition is published — only weight. + // Byte 8 is heart rate (bpm), bytes 9..10 are impedance (u16 BE, Ohm); this + // capture's A3 frame had zeros there, so body composition still isn't published. } publish(m) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR5Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR5Handler.kt index 53122f895..2c772584b 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR5Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR5Handler.kt @@ -57,9 +57,9 @@ class RunstarR5Handler : ScaleDeviceHandler() { override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { val name = device.name.uppercase(Locale.US) - val matchesKnownAdvertisingName = - name == "RUNSTAR-R5" || name == "RUNSTAR-RX" || name.startsWith("RUNSTAR-") - if (!matchesKnownAdvertisingName) return null + // Match known advertising names only — not a "RUNSTAR-" prefix wildcard, which + // would also swallow the incompatible R6 (see RunstarR6Handler). + if (name != "RUNSTAR-R5" && name != "RUNSTAR-RX") return null return DeviceSupport( displayName = "Runstar R5", diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR6Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR6Handler.kt new file mode 100644 index 000000000..d68561836 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR6Handler.kt @@ -0,0 +1,258 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.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 java.util.Date +import java.util.Locale +import java.util.UUID +import kotlin.math.abs + +/** + * Runstar R6 smart scale. + * + * Same FFB0 frame family as [RobiS9Handler] — 20-byte frames + * `[seq][len][00][type][payload…][chk]`, trailer checksum `sum(bytes[3..18]) & 0x1F` + * (verified across 38 frames, zero mismatches): + * - Service 0xFFB0 + * - 0xFFB1 write — app -> scale + * - 0xFFB2 notify — live weight stream (0xA2) + * - 0xFFB3 indicate — device info (0xA1), ack (0xA0), offline history (0xA4), final + * result (0xA3) + * + * The scale queues weigh-ins taken while disconnected and replays them as 0xA4 on the + * next connect, before the live 0xA3. Acking (0xB0) retires the entry on the scale — + * unacked entries keep reappearing on later connects, so every 0xA4 must be acked. 0xA4 + * only carries a timestamp + weight, no impedance/heart rate. + * + * Despite the name this is a different, incompatible protocol from [RunstarR5Handler]. + */ +class RunstarR6Handler : ScaleDeviceHandler() { + + private val SERVICE: UUID = uuid16(0xFFB0) + private val CHAR_WRITE: UUID = uuid16(0xFFB1) // write (ack / future handshake) + private val CHAR_LIVE: UUID = uuid16(0xFFB2) // notify (live A2 frames) + private val CHAR_RESULT: UUID = uuid16(0xFFB3) // indicate (A1 info / A0 ack / A3 result / A4 history) + + private var lastPreviewWeightKg = -1f + private var lastLockedWeightKg: Float? = null + private var lastPublishedWeightRaw: Int? = null + private var outgoingSeq = 0 + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.lowercase(Locale.ROOT) + // Claim strictly by advertised name — openScale only sees the name (not the + // characteristic list) before connecting. + if (name != "runstar-r6" && !name.startsWith("runstar-r6")) return null + + return DeviceSupport( + displayName = "Runstar R6", + // Publishes raw impedance/heart rate but derives no fat/water/muscle, so + // BODY_COMPOSITION is advertised but not in `implemented`. + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.HISTORY_READ + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.HISTORY_READ + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + lastPreviewWeightKg = -1f + lastLockedWeightKg = null + lastPublishedWeightRaw = null + outgoingSeq = 0 + + // Subscribe live first, then result — mirrors RobiS9Handler's subscription order + // for the same FFB0 family. + setNotifyOn(SERVICE, CHAR_LIVE) + setNotifyOn(SERVICE, CHAR_RESULT) + + // No BA/BB handshake: confirmed unnecessary across multiple field tests, the scale + // streams A2 and delivers the A3 result without one. + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (data.size != 20) return + if ((data[2].toInt() and 0xFF) != 0x00) return + if (!isChecksumValid(data)) { + logD("Runstar R6 checksum mismatch ${data.toHexPreview(20)}") + return + } + + val seq = data[0].toInt() and 0xFF + when (data[3].toInt() and 0xFF) { + TYPE_LIVE_WEIGHT -> handleLiveWeight(data) + TYPE_DEVICE_INFO -> { + logD("Runstar R6 device info ${data.toHexPreview(20)}") + sendAck(seq) + } + TYPE_ACK -> logD("Runstar R6 ack ${data.toHexPreview(20)}") + TYPE_FINAL_RESULT -> handleFinalResult(data, seq) + TYPE_HISTORY_ENTRY -> handleHistoryEntry(data, seq) + else -> logD( + "Runstar R6 unhandled frame type=0x${String.format("%02X", data[3])} " + + data.toHexPreview(20) + ) + } + } + + /** + * 0xA2 live weight frame: byte4 = state (0x01 measuring, 0x02 settling, 0x04 locked), + * bytes 6..8 = weight, u24 BE grams. Never publishes — only the 0xA3 result is + * authoritative — but surfaces a throttled progress message while measuring/settling. + */ + private fun handleLiveWeight(data: ByteArray) { + val state = data[4].toInt() and 0xFF + val weightKg = u24be(data, 6) / 1000.0f + + when (state) { + STATE_MEASURING, STATE_SETTLING -> { + if (abs(weightKg - lastPreviewWeightKg) >= 0.05f) { + userInfo(R.string.bluetooth_scale_info_measuring_weight, weightKg) + lastPreviewWeightKg = weightKg + } + } + STATE_LOCKED -> lastLockedWeightKg = weightKg + } + } + + /** + * 0xA3 final result frame: byte4 = status (0x00 = OK), bytes 5..7 = weight (u24 BE + * grams), byte8 = heart rate (bpm), bytes 9..10 = impedance (u16 BE Ohm). + */ + private fun handleFinalResult(data: ByteArray, seq: Int) { + logI("Runstar R6 final result ${data.toHexPreview(20)}") + + val grams = u24be(data, 5) + if (lastPublishedWeightRaw == grams) { + logD("Runstar R6 duplicate final result raw=$grams, skipping publish") + return + } + + val heartRateRaw = data[8].toInt() and 0xFF + val impedanceRaw = u16be(data, 9) + + val measurement = ScaleMeasurement().apply { + dateTime = Date() + weight = grams / 1000.0f + if (heartRateRaw != 0) heartRate = heartRateRaw + if (impedanceRaw != 0) impedance = impedanceRaw.toDouble() + } + publish(measurement) + lastPublishedWeightRaw = grams + sendAck(seq) + + // No requestDisconnect(): the scale hangs up on its own a few seconds after the + // result (every capture: HCI reason 19, remote-initiated). Forcing it here would + // risk cutting off 0xA4 history entries still pending on this connection. + } + + /** + * 0xA4 history entry: bytes 4..7 = Unix timestamp (u32 BE, seconds), bytes 9..11 = + * weight (u24 BE grams). No dedup — acking retires the entry on the scale (see class + * doc), so a resend only happens if the ack itself is lost. + */ + private fun handleHistoryEntry(data: ByteArray, seq: Int) { + val epochSeconds = u32be(data, 4) + val grams = u24be(data, 9) + val entryDate = Date(epochSeconds * 1000L) + logI("Runstar R6 history entry seq=$seq date=$entryDate weight=${grams / 1000.0f}kg") + + publish(ScaleMeasurement().apply { + dateTime = entryDate + weight = grams / 1000.0f + }) + sendAck(seq) + } + + /** Ack a scale indication: 0xB0, payload = [seq being acked][0x00]. */ + private fun sendAck(seq: Int) { + val frame = buildFrame(TYPE_ACK_OUT, byteArrayOf((seq and 0xFF).toByte(), 0x00)) + writeTo(SERVICE, CHAR_WRITE, frame, withResponse = true) + } + + /** + * Build a 20-byte outgoing frame: `[seq][len][00][type][payload…][chk]`, where `len` + * covers TYPE + payload (i.e. `payload.size + 1`) and `chk` is [computeChecksum]. Uses + * and advances [outgoingSeq]. Kept general so a future handshake can reuse it. + */ + private fun buildFrame(type: Int, payload: ByteArray): ByteArray { + require(payload.size <= 15) { "payload too large for a 20-byte frame" } + val frame = ByteArray(20) + frame[0] = (outgoingSeq and 0xFF).toByte() + outgoingSeq = (outgoingSeq + 1) and 0xFF + frame[1] = ((payload.size + 1) and 0xFF).toByte() + frame[2] = 0x00 + frame[3] = (type and 0xFF).toByte() + for (i in payload.indices) { + frame[4 + i] = payload[i] + } + frame[19] = computeChecksum(frame).toByte() + return frame + } + + /** `sum(bytes[3..18]) & 0x1F` — see class doc for how this was verified. */ + private fun computeChecksum(frame: ByteArray): Int { + var sum = 0 + for (i in 3..18) { + sum += frame[i].toInt() and 0xFF + } + return sum and 0x1F + } + + private fun isChecksumValid(frame: ByteArray): Boolean = + (frame[19].toInt() and 0xFF) == computeChecksum(frame) + + private fun u24be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 16) or + ((data[offset + 1].toInt() and 0xFF) shl 8) or + (data[offset + 2].toInt() and 0xFF) + + private fun u16be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or + (data[offset + 1].toInt() and 0xFF) + + private fun u32be(data: ByteArray, offset: Int): Long = + ((data[offset].toLong() and 0xFF) shl 24) or + ((data[offset + 1].toLong() and 0xFF) shl 16) or + ((data[offset + 2].toLong() and 0xFF) shl 8) or + (data[offset + 3].toLong() and 0xFF) + + companion object { + private const val TYPE_DEVICE_INFO = 0xA1 + private const val TYPE_ACK = 0xA0 + private const val TYPE_LIVE_WEIGHT = 0xA2 + private const val TYPE_FINAL_RESULT = 0xA3 + private const val TYPE_HISTORY_ENTRY = 0xA4 + private const val TYPE_ACK_OUT = 0xB0 + + private const val STATE_MEASURING = 0x01 + private const val STATE_SETTLING = 0x02 + private const val STATE_LOCKED = 0x04 + } +} From cfec324fd0734a170dc333a4cd074d1f54507dbb Mon Sep 17 00:00:00 2001 From: leko Date: Fri, 7 Aug 2026 13:07:57 +0800 Subject: [PATCH 18/72] Add Keep S3 scale support (#1438) * Add Keep S3 scale support * Revert addition of extended measurement metrics and database version 16 This change removes support for several recently added measurement types and reverts the database schema to version 15. Key changes include: * **Measurement Types:** Removed `PHASE_ANGLE`, `SKELETAL_MUSCLE`, `LEAN_SOFT_TISSUE`, `SUBCUTANEOUS_FAT`, `BODY_AGE`, `BMI_22_REFERENCE_WEIGHT`, and `DEVICE_IMPEDANCE` from `MeasurementTypeKey` and `ScaleMeasurement`. * **Database:** Reverted `AppDatabase` version to 15, deleted `MIGRATION_15_16`, and removed the version 16 JSON schema. * **Units & Strings:** Removed `UnitType.DEGREE` and associated string resources and localization logic. * **Logic Cleanup:** * Removed generalized percentage-to-mass unit conversion logic in `ConverterUtils` and `MeasurementTypeCrudUseCases`. * Updated `KeepS3Handler` to stop publishing extended metrics. * Removed `awaitPendingOperations` from the BLE transport interface. * Add muscle percentage mapping in KeepS3Handler * Fix Keep S3 runtime behavior --------- Co-authored-by: oliexdev --- .../openscale/core/bluetooth/ScaleFactory.kt | 3 + .../bluetooth/libs/KeepS3BodyComposition.kt | 251 ++++++ .../core/bluetooth/scales/KeepS3Handler.kt | 808 +++++++++++++++++ .../libs/KeepS3BodyCompositionTest.kt | 209 +++++ .../bluetooth/scales/KeepS3HandlerTest.kt | 848 ++++++++++++++++++ 5 files changed, 2119 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyComposition.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyCompositionTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index a5c446ea7..80ad9daed 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -46,6 +46,7 @@ import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler import com.health.openscale.core.bluetooth.scales.InlifeHandler +import com.health.openscale.core.bluetooth.scales.KeepS3Handler import com.health.openscale.core.bluetooth.scales.LinkMode import com.health.openscale.core.bluetooth.scales.MGBHandler import com.health.openscale.core.bluetooth.scales.MedisanaBs44xHandler @@ -111,6 +112,8 @@ class ScaleFactory @Inject constructor( // TaylorBIAHandler and FitTrackDaraHandler must stay ahead of MGBHandler — all live on service // 0xFFB0, which MGBHandler also matches, so a later position would let MGB wrongly claim them. private val modernKotlinHandlers: List = listOf( + // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). + KeepS3Handler(), BeurerBF450Handler(), TaylorBIAHandler(), RyFitHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyComposition.kt new file mode 100644 index 000000000..0622e9dad --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyComposition.kt @@ -0,0 +1,251 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import com.health.openscale.core.data.GenderType + +/** + * Offline compatibility implementation of BestHealth's `BHKeep_2023-03-30` two-leg, + * dual-frequency body-composition routine bundled in Keep 9.0.80. + * + * The APK does not call this library from its application code; captured Keep reports appear to + * receive composition values from Keep's server and differ slightly from this routine. These + * results are therefore local BIA estimates, not Keep cloud values or direct scale measurements. + * The implementation was recovered for interoperability and does not contain or load vendor code. + * + * Inputs and fixed-point output units match the bundled SDK. Keeping the intermediate arithmetic + * as [Float] and tenths is intentional: the ARM64 routine uses IEEE-754 single precision and + * truncation toward zero. + */ +internal object KeepS3BodyComposition { + data class Input( + val gender: GenderType, + val age: Int, + val heightCm: Int, + val weightKg: Float, + val impedance50Ohm: Int, + val impedance100Ohm: Int, + val athlete: Boolean = false, + ) + + data class Result( + val bodyFatKg: Float, + val bodyFatPercent: Float, + val fatFreeMassKg: Float, + val waterPercent: Float, + val boneKg: Float, + val muscleKg: Float, + val musclePercent: Float, + val skeletalMuscleKg: Float, + val skeletalMusclePercent: Float, + val subcutaneousFatKg: Float, + val subcutaneousFatPercent: Float, + val proteinPercent: Float, + val visceralFatLevel: Int, + val basalMetabolicRateKcal: Int, + val bodyAge: Int, + val bmi22ReferenceWeightKg: Float, + ) + + fun calculate(input: Input): Result? { + if (input.age !in 6..99 || input.heightCm !in 90..220) return null + if (!input.weightKg.isFinite() || input.weightKg !in 10f..200f) return null + if (input.impedance50Ohm !in 200..1200 || input.impedance100Ohm !in 200..1200) { + return null + } + + val male = input.gender == GenderType.MALE + val weightRaw = (input.weightKg * 10f).toInt().coerceIn(100, 2000) + val height = input.heightCm + val age = input.age + val averageImpedance = (input.impedance50Ohm + input.impedance100Ohm) ushr 1 + val heightSquared = height * height + val bmiRaw = weightRaw * 10_000 / heightSquared + val bmi22ReferenceWeightRaw = (heightSquared.toFloat() * 0.022f).toInt() + + val rawFatFreeMass = heightSquared.toFloat() * 9.058f / 10_000f + + 12.226f + weightRaw.toFloat() * 0.032f - + averageImpedance.toFloat() * 0.0068f - age.toFloat() * 0.0542f + + var adjustedFatFreeMass = rawFatFreeMass - when { + male -> 0.8f + age < 50 -> 9.25f + else -> 7.25f + } + + if (male) { + adjustedFatFreeMass *= 1.05f + if (averageImpedance.toFloat() / height.toFloat() < 2.6f) { + adjustedFatFreeMass *= 1.03f + } else { + adjustedFatFreeMass *= 0.96f + if (weightRaw < 610) adjustedFatFreeMass *= 0.97f + if (height > 170) adjustedFatFreeMass *= 0.98f + } + } else { + adjustedFatFreeMass *= 1.02f + if (weightRaw < 500) adjustedFatFreeMass *= 1.02f + if (weightRaw > 600) adjustedFatFreeMass *= 0.96f + if (height > 160) adjustedFatFreeMass *= 1.03f + } + + var fatKg = weightRaw.toFloat() / 10f - adjustedFatFreeMass + if (input.athlete) { + fatKg = if (male) fatKg * 0.778f - 0.93f else fatKg * 0.992f - 1.5f + } + val fatRateRaw = (fatKg * 10_000f / weightRaw.toFloat()).toInt().coerceIn(50, 750) + val fatKgRaw = fatRateRaw * weightRaw / 1000 + val fatFreeMassRaw = weightRaw - fatKgRaw + + var waterRateRaw = ((1000 - fatRateRaw) * 7 / 10).let { base -> + base * if (base > 500) 98 else 102 + } / 100 + if (input.athlete) { + waterRateRaw = (waterRateRaw.toFloat() * (if (male) 0.996f else 0.985f) + + (if (male) 4f else 9f)).toInt() + } + waterRateRaw = waterRateRaw.coerceAtLeast(350) + + var boneRaw = (rawFatFreeMass * 0.5158f - if (male) 1.802f else 2.4569f).toInt() + boneRaw += if (boneRaw > 22) 1 else -1 + if (input.athlete) { + boneRaw += when { + boneRaw < 20 -> 1 + boneRaw < 30 -> 2 + else -> 3 + } + } + + val bodyAge = calculateBodyAge(age, bmiRaw) + var bmrRaw = if (male) { + weightRaw.toFloat() * 1.4916f + 877.8f - height.toFloat() * 0.726f - + age.toFloat() * 8.976f + } else { + weightRaw.toFloat() * 1.0204f + 864.6f - height.toFloat() * 0.3934f - + age.toFloat() * 6.204f + }.toInt() + if (input.athlete) bmrRaw = (bmrRaw.toFloat() * 1.16f - 149f).toInt() + bmrRaw = bmrRaw.coerceAtLeast(500) + + val visceralFat = calculateVisceralFat( + male = male, + athlete = input.athlete, + age = age, + height = height, + weightRaw = weightRaw, + heightSquared = heightSquared, + ) + + val muscleKgRaw = (fatFreeMassRaw - boneRaw).coerceAtLeast(0) + val muscleRateRaw = (muscleKgRaw.toFloat() * 1000f / weightRaw.toFloat()).toInt() + val waterKgRaw = waterRateRaw * weightRaw / 1000 + val skeletalMuscleKgRaw = (waterKgRaw.toFloat() * 0.832f - 27.354f) + .toInt().coerceAtLeast(0) + val skeletalMuscleRate = if (weightRaw > 0) { + skeletalMuscleKgRaw.toFloat() * 100f / weightRaw.toFloat() + } else { + 0f + } + + val boneRateRaw = boneRaw.toFloat() * 1000f / weightRaw.toFloat() + val proteinRateRaw = ((1000 - fatRateRaw).toFloat() - + waterRateRaw.toFloat() * 1.08f - boneRateRaw).toInt().coerceIn(20, 300) + + var subcutaneousFatKgRaw = averageImpedance.toFloat() * 0.031f + + bmiRaw.toFloat() * 0.94f + age.toFloat() * 1.049f - 210.772f + subcutaneousFatKgRaw = subcutaneousFatKgRaw.coerceIn(10f, 300f) * -9.4f / 34f + + fatKgRaw.toFloat() + if (input.athlete) subcutaneousFatKgRaw *= 0.85f + val subcutaneousFatRateRaw = (subcutaneousFatKgRaw * 1000f / weightRaw.toFloat()) + .toInt().coerceIn(10, 600) + val storedSubcutaneousFatKgRaw = subcutaneousFatRateRaw * weightRaw / 1000 + + return Result( + bodyFatKg = fatKgRaw / 10f, + bodyFatPercent = fatRateRaw / 10f, + fatFreeMassKg = fatFreeMassRaw / 10f, + waterPercent = waterRateRaw / 10f, + boneKg = boneRaw / 10f, + muscleKg = muscleKgRaw / 10f, + musclePercent = muscleRateRaw / 10f, + skeletalMuscleKg = skeletalMuscleKgRaw / 10f, + skeletalMusclePercent = skeletalMuscleRate, + subcutaneousFatKg = storedSubcutaneousFatKgRaw / 10f, + subcutaneousFatPercent = subcutaneousFatRateRaw / 10f, + proteinPercent = proteinRateRaw / 10f, + visceralFatLevel = visceralFat, + basalMetabolicRateKcal = bmrRaw, + bodyAge = bodyAge, + bmi22ReferenceWeightKg = bmi22ReferenceWeightRaw / 10f, + ) + } + + private fun calculateBodyAge(age: Int, bmiRaw: Int): Int { + val first = (age.toFloat() + 28.428f - bmiRaw.toFloat() * 0.1428f) + .toInt().coerceIn(age - 5, age + 5) + val second = (bmiRaw.toFloat() * 0.1724f + age.toFloat() - 34.931f) + .toInt().coerceIn(age - 8, age + 8) + val result = if (bmiRaw < 30) { + first.toFloat() * 0.6f + second.toFloat() * 0.4f + } else { + first.toFloat() * 0.4f + second.toFloat() * 0.6f + } + return result.toInt().coerceIn(6, 99) + } + + private fun calculateVisceralFat( + male: Boolean, + athlete: Boolean, + age: Int, + height: Int, + weightRaw: Int, + heightSquared: Int, + ): Int { + val weight = weightRaw.toFloat() + val stature = height.toFloat() + var visceral = if (male) { + if (weight * 0.16f + 63f > stature) { + weight * 30.5f / + (heightSquared.toFloat() * 0.0826f - stature * 0.4f + 48f) - + 2.9f + age.toFloat() * 0.15f + } else { + (stature * -0.0015f + 0.765f) * weight / 10f - + stature * 0.143f + age.toFloat() * 0.15f - 5f + } + } else { + if (stature * 5f - 130f < weight) { + weight * 50f / + (heightSquared.toFloat() * 0.1158f + stature * 1.45f - 144f) - + 6f + age.toFloat() * 0.07f + } else { + (stature * -0.0024f + 0.691f) * weight / 10f - + stature * 0.027f + age.toFloat() * 0.07f - 10.5f + } + } + + if (athlete) { + visceral = when { + visceral < 2f -> 1f + visceral < 10f -> visceral - 2f + visceral < 20f -> visceral * 0.8f + else -> visceral * 0.85f + } + } + return visceral.toInt().coerceIn(1, 50) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt new file mode 100644 index 000000000..a647218b7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -0,0 +1,808 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.KeepS3BodyComposition +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.security.SecureRandom +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.UUID +import kotlin.math.roundToInt + +/** Pure codec for the capture-verified Keep S3 application protocol. */ +internal object KeepS3Protocol { + const val FRAME_REQUEST = 0x01 + const val FRAME_RESPONSE = 0x02 + const val FRAME_EVENT = 0x03 + const val FRAME_ACK = 0x04 + const val MAGIC = 0x53 + const val STATUS_OK = 0x80 + + const val OP_SET_TIME = 0x01 + const val OP_BATTERY = 0x03 + const val OP_SET_UNIT = 0x05 + const val OP_READ_TIME = 0x0A + const val OP_UNKNOWN_20 = 0x20 + const val OP_PROFILE = 0x32 + const val OP_MEASUREMENT_CONTROL = 0x36 + const val OP_NEGOTIATE = 0x38 + const val OP_MEASUREMENT_EVENT = 0x57 + const val OP_FINAL_RECORD = 0x58 + const val OP_DEVICE_INFO = 0xE7 + const val OP_UNKNOWN_F5 = 0xF5 + + const val STAGE_FINAL = 0x29 + + private const val PROFILE_PAYLOAD_SIZE = 63 + private const val CONTROL_PAYLOAD_SIZE = 50 + private const val TOKEN_SIZE = 24 + private const val TOKEN_PAIR_SIZE = TOKEN_SIZE * 2 + + data class FrameHeader(val type: Int, val opcode: Int, val payloadLength: Int) + data class Response(val opcode: Int, val data: ByteArray) + data class MeasurementEvent( + val stage: Int, + val weightKg: Float, + val impedanceOhm: Int, + val heartRateBpm: Int, + ) + + data class FinalRecord( + val weightKg: Float, + val encodedImpedance50: Int, + val encodedImpedance100: Int, + val impedance50Ohm: Int?, + val impedance100Ohm: Int?, + val phaseAngle50Raw: Int, + val phaseAngle100Raw: Int, + val phaseAngle50Degrees: Float?, + val phaseAngle100Degrees: Float?, + val heartRateBpm: Int, + ) + + data class PreviousRecord( + val weightKg: Float = 0f, + val timestampSeconds: Long = 0L, + val impedanceOhm: Double = 0.0, + ) + + data class PersistedDeviceImpedance( + val timestampSeconds: Long, + val weightRaw: Int, + val impedanceOhm: Int, + ) + + /** Read only the non-sensitive frame identity. Payload validity is checked separately. */ + fun peekFrameHeader(frame: ByteArray): FrameHeader? { + if (frame.size < 5) return null + if (u8(frame[1]) != MAGIC || u8(frame[3]) != 0) return null + val type = u8(frame[0]) + if (type !in FRAME_REQUEST..FRAME_ACK) return null + return FrameHeader(type, u8(frame[2]), u8(frame[4])) + } + + /** Validate the complete frame, including the type-specific declared payload length. */ + fun parseFrameHeader(frame: ByteArray): FrameHeader? { + val header = peekFrameHeader(frame) ?: return null + val expectedSize = when (header.type) { + FRAME_RESPONSE -> 6 + header.payloadLength // status byte is not included in data length + FRAME_ACK -> 6 + header.payloadLength + else -> 5 + header.payloadLength + } + return header.takeIf { frame.size == expectedSize } + } + + fun parseResponse(frame: ByteArray): Response? { + val header = parseFrameHeader(frame) ?: return null + if (header.type != FRAME_RESPONSE || u8(frame[5]) != STATUS_OK) return null + return Response(header.opcode, frame.copyOfRange(6, frame.size)) + } + + fun parseMeasurementEvent(frame: ByteArray): MeasurementEvent? { + val header = parseFrameHeader(frame) ?: return null + if (header.type != FRAME_EVENT || header.opcode != OP_MEASUREMENT_EVENT) return null + if (header.payloadLength != 8 || frame.size != 13) return null + + return MeasurementEvent( + stage = u8(frame[5]), + weightKg = decodeU16BE(frame, 6) / 200.0f, + impedanceOhm = decodeU16BE(frame, 10), + heartRateBpm = u8(frame[12]), + ) + } + + fun parseFinalRecord(frame: ByteArray): FinalRecord? { + val header = parseFrameHeader(frame) ?: return null + if (header.type != FRAME_EVENT || header.opcode != OP_FINAL_RECORD) return null + if (header.payloadLength != 61 || frame.size != 66) return null + + val encodedImpedance50 = decodeU24BE(frame, 55) + val encodedImpedance100 = decodeU24BE(frame, 58) + val phaseAngle50Raw = decodeI16BE(frame, 61) + val phaseAngle100Raw = decodeI16BE(frame, 63) + return FinalRecord( + weightKg = decodeU16BE(frame, 53) / 200.0f, + encodedImpedance50 = encodedImpedance50, + encodedImpedance100 = encodedImpedance100, + impedance50Ohm = decodeEncodedImpedance(encodedImpedance50), + impedance100Ohm = decodeEncodedImpedance(encodedImpedance100), + phaseAngle50Raw = phaseAngle50Raw, + phaseAngle100Raw = phaseAngle100Raw, + phaseAngle50Degrees = decodePhaseAngleDegrees(phaseAngle50Raw), + phaseAngle100Degrees = decodePhaseAngleDegrees(phaseAngle100Raw), + heartRateBpm = u8(frame[65]), + ) + } + + fun buildRequest(opcode: Int, payload: ByteArray = byteArrayOf()): ByteArray { + require(opcode in 0..0xFF) { "opcode must fit in one byte" } + require(payload.size <= 0xFF) { "payload is too large" } + return byteArrayOf( + FRAME_REQUEST.toByte(), + MAGIC.toByte(), + opcode.toByte(), + 0x00, + payload.size.toByte(), + ) + payload + } + + fun buildAck(opcode: Int): ByteArray { + require(opcode in 0..0xFF) { "opcode must fit in one byte" } + return byteArrayOf( + FRAME_ACK.toByte(), + MAGIC.toByte(), + opcode.toByte(), + 0x00, + 0x00, + STATUS_OK.toByte(), + ) + } + + fun buildProfilePayload( + token: String, + previous: PreviousRecord?, + heightCm: Int, + birthYear: Int, + birthMonth: Int, + birthDay: Int, + ): ByteArray { + require(validateToken(token)) { "invalid Keep S3 ID24 token" } + val payload = ByteArray(PROFILE_PAYLOAD_SIZE) + repeatedTokenBytes(token).copyInto(payload) + + val record = previous ?: PreviousRecord() + encodeU16BE(payload, 48, encodeWeightRaw(record.weightKg)) + encodeU32BE(payload, 50, record.timestampSeconds.coerceIn(0L, 0xFFFF_FFFFL)) + encodeU16BE(payload, 54, encodeImpedanceRaw(record.impedanceOhm)) + encodeU16BE(payload, 56, 0) // Capture-verified reserved field; semantic is unknown. + payload[58] = heightCm.coerceIn(0, 0xFF).toByte() + encodeU16BE(payload, 59, birthYear.coerceIn(0, 0xFFFF)) + payload[61] = birthMonth.coerceIn(1, 12).toByte() + payload[62] = birthDay.coerceIn(1, 31).toByte() + return payload + } + + fun buildMeasurementControl(token: String, start: Boolean): ByteArray { + require(validateToken(token)) { "invalid Keep S3 ID24 token" } + val payload = repeatedTokenBytes(token) + byteArrayOf(0x00, if (start) 0x01 else 0x00) + check(payload.size == CONTROL_PAYLOAD_SIZE) + return payload + } + + fun repeatedTokenBytes(token: String): ByteArray { + require(validateToken(token)) { "invalid Keep S3 ID24 token" } + val bytes = (token + token).encodeToByteArray() + check(bytes.size == TOKEN_PAIR_SIZE) + return bytes + } + + fun validateToken(token: String): Boolean = + token.length == TOKEN_SIZE && token.all { it in '0'..'9' || it in 'a'..'f' } + + /** Generate an opaque token from exactly 12 random bytes; the vendor algorithm is unknown. */ + fun generateToken(randomBytes: ByteArray): String { + require(randomBytes.size == 12) { "Keep S3 ID24 requires 12 random bytes" } + return randomBytes.joinToString(separator = "") { + String.format(Locale.ROOT, "%02x", u8(it)) + } + } + + fun tokenSettingKey(userId: Int): String = "id24-user-$userId" + + fun deviceImpedanceSettingKey(userId: Int): String = "previous-device-impedance-user-$userId" + + fun serializeDeviceImpedance( + timestampSeconds: Long, + weightKg: Float, + impedanceOhm: Int, + ): String? { + if (timestampSeconds !in 1L..0xFFFF_FFFFL || impedanceOhm !in 1..0xFFFF) return null + val weightRaw = encodeWeightRaw(weightKg).takeIf { it > 0 } ?: return null + return "$timestampSeconds:$weightRaw:$impedanceOhm" + } + + fun parseDeviceImpedance(value: String?): PersistedDeviceImpedance? { + val fields = value?.split(':') ?: return null + if (fields.size != 3) return null + val timestampSeconds = fields[0].toLongOrNull()?.takeIf { it in 1L..0xFFFF_FFFFL } + ?: return null + val weightRaw = fields[1].toIntOrNull()?.takeIf { it in 1..0xFFFF } ?: return null + val impedanceOhm = fields[2].toIntOrNull()?.takeIf { it in 1..0xFFFF } ?: return null + return PersistedDeviceImpedance(timestampSeconds, weightRaw, impedanceOhm) + } + + fun deviceImpedanceMatches( + stored: PersistedDeviceImpedance, + timestampSeconds: Long, + weightKg: Float, + ): Boolean = stored.timestampSeconds == timestampSeconds && + stored.weightRaw == encodeWeightRaw(weightKg) + + fun encodeU16BE(target: ByteArray, offset: Int, value: Int) { + require(offset >= 0 && offset + 2 <= target.size) + val clamped = value.coerceIn(0, 0xFFFF) + target[offset] = (clamped ushr 8).toByte() + target[offset + 1] = clamped.toByte() + } + + fun encodeU32BE(target: ByteArray, offset: Int, value: Long) { + require(offset >= 0 && offset + 4 <= target.size) + val clamped = value.coerceIn(0L, 0xFFFF_FFFFL) + target[offset] = (clamped ushr 24).toByte() + target[offset + 1] = (clamped ushr 16).toByte() + target[offset + 2] = (clamped ushr 8).toByte() + target[offset + 3] = clamped.toByte() + } + + fun decodeU16BE(data: ByteArray, offset: Int): Int { + require(offset >= 0 && offset + 2 <= data.size) + return (u8(data[offset]) shl 8) or u8(data[offset + 1]) + } + + fun decodeU32BE(data: ByteArray, offset: Int): Long { + require(offset >= 0 && offset + 4 <= data.size) + return (u8(data[offset]).toLong() shl 24) or + (u8(data[offset + 1]).toLong() shl 16) or + (u8(data[offset + 2]).toLong() shl 8) or + u8(data[offset + 3]).toLong() + } + + fun decodeU24BE(data: ByteArray, offset: Int): Int { + require(offset >= 0 && offset + 3 <= data.size) + return (u8(data[offset]) shl 16) or + (u8(data[offset + 1]) shl 8) or + u8(data[offset + 2]) + } + + fun decodeI16BE(data: ByteArray, offset: Int): Int { + val unsigned = decodeU16BE(data, offset) + return if (unsigned and 0x8000 != 0) unsigned - 0x1_0000 else unsigned + } + + /** Decoder recovered from the APK's BhGetBodyComposition_TwoLegs240 input path. */ + fun decodeEncodedImpedance(encoded: Int): Int? { + if (encoded !in 0..0xFF_FFFF || encoded == 0xFF_FFFF) return null + val upper = (encoded and 0x0F00) or ((encoded ushr 16) and 0xFF) + val lower = ((encoded and 0xFF) shl 2) + ((encoded ushr 12) and 0x0F) + val decoded = (upper - lower) / 2 // Kotlin division truncates toward zero, like the SDK. + return decoded.takeIf { it in 200..1200 } + } + + /** Both captures and the APK's native call path encode phase angle as negative tenths. */ + fun decodePhaseAngleDegrees(raw: Int): Float? = + if (raw in Short.MIN_VALUE until 0) -raw / 10.0f else null + + private fun encodeWeightRaw(weightKg: Float): Int { + if (!weightKg.isFinite() || weightKg <= 0f) return 0 + return (weightKg.toDouble() * 200.0).roundToInt().coerceIn(0, 0xFFFF) + } + + private fun encodeImpedanceRaw(impedanceOhm: Double): Int { + if (!impedanceOhm.isFinite() || impedanceOhm <= 0.0) return 0 + return impedanceOhm.roundToInt().coerceIn(0, 0xFFFF) + } + + private fun u8(value: Byte): Int = value.toInt() and 0xFF +} + +internal enum class KeepS3InitStep(val opcode: Int) { + NEGOTIATE(KeepS3Protocol.OP_NEGOTIATE), + READ_TIME(KeepS3Protocol.OP_READ_TIME), + SET_TIME(KeepS3Protocol.OP_SET_TIME), + SET_UNIT(KeepS3Protocol.OP_SET_UNIT), + DEVICE_INFO(KeepS3Protocol.OP_DEVICE_INFO), + UNKNOWN_F5_FIRST(KeepS3Protocol.OP_UNKNOWN_F5), + UNKNOWN_F5_SECOND(KeepS3Protocol.OP_UNKNOWN_F5), + BATTERY(KeepS3Protocol.OP_BATTERY), + UNKNOWN_20(KeepS3Protocol.OP_UNKNOWN_20), + PROFILE(KeepS3Protocol.OP_PROFILE), + START(KeepS3Protocol.OP_MEASUREMENT_CONTROL), +} + +/** Response-driven initialization state. No transition occurs for a foreign opcode. */ +internal class KeepS3InitStateMachine { + private var index = 0 + + val expectedStep: KeepS3InitStep? + get() = KeepS3InitStep.entries.getOrNull(index) + + fun reset(): KeepS3InitStep { + index = 0 + return KeepS3InitStep.entries[index] + } + + fun acceptSuccessfulResponse(opcode: Int): KeepS3InitStep? { + val expected = expectedStep ?: return null + if (expected.opcode != opcode) return null + index++ + return expectedStep + } +} + +/** + * Keep S3 handler for the capture-verified 0x00FF / FF01 / FF02 protocol. + * + * The device also exposes 0xFFF0, but both captured official-app sessions exclusively used + * FF01 notifications and FF02 Write With Response. Body-composition values are calculated offline + * by the SDK-compatible routine bundled in Keep 9.0.80; Keep's cloud report differs slightly. + */ +class KeepS3Handler : ScaleDeviceHandler() { + private val service: UUID = uuid16(0x00FF) + private val notifyCharacteristic: UUID = uuid16(0xFF01) + private val writeCharacteristic: UUID = uuid16(0xFF02) + + private val initState = KeepS3InitStateMachine() + private var currentUser: ScaleUser? = null + private var token = "" + private var published = false + private var finishing = false + private var latestImpedanceOhm = 0 + private var pendingFinalEvent: KeepS3Protocol.MeasurementEvent? = null + private var finalPublishJob: Job? = null + private var finishJob: Job? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + if (!device.name.equals(DEVICE_NAME, ignoreCase = true)) return null + + if (device.serviceUuids.contains(service)) { + logD("Keep S3 matched by exact name with advertised 0x00FF service") + } else { + // Saved/incomplete scan results do not always carry advertised service UUIDs. + logD("Keep S3 matched by exact name; advertised 0x00FF service was not present") + } + + return DEVICE_SUPPORT + } + + override fun onConnected(user: ScaleUser) { + resetSession() + currentUser = user + token = loadOrCreateToken(user.id) + + // GattScaleAdapter queues these operations, so the first request follows notify setup. + setNotifyOn(service, notifyCharacteristic) + sendStep(initState.reset(), responseData = byteArrayOf()) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (characteristic != notifyCharacteristic) return + + val identity = KeepS3Protocol.peekFrameHeader(data) + if (identity == null) { + logMalformedFrame(data) + return + } + + when (identity.type) { + KeepS3Protocol.FRAME_RESPONSE -> handleResponse(data, user) + KeepS3Protocol.FRAME_EVENT -> handleEvent(identity, data, user) + else -> logW("Ignoring unexpected frame type=${identity.type} opcode=0x${identity.opcode.toString(16)} len=${data.size}") + } + } + + override fun onDisconnected() { + publishPendingFinalWithoutRecord() + resetSession() + } + + private fun handleResponse(frame: ByteArray, user: ScaleUser) { + val response = KeepS3Protocol.parseResponse(frame) + if (response == null) { + logMalformedFrame(frame) + return + } + + val completedStep = initState.expectedStep + if (completedStep == null) { + logD("Ignoring response after initialization opcode=0x${response.opcode.toString(16)}") + return + } + if (completedStep.opcode != response.opcode) { + logW( + "Ignoring out-of-order response opcode=0x${response.opcode.toString(16)} " + + "expected=0x${completedStep.opcode.toString(16)}", + ) + return + } + + val nextStep = initState.acceptSuccessfulResponse(response.opcode) + if (completedStep == KeepS3InitStep.BATTERY && response.data.size == 1) { + logI("Keep S3 battery level=${response.data[0].toInt() and 0xFF}% (no battery callback API)") + } + + if (nextStep == null) { + logI("Keep S3 initialization completed") + return + } + sendStep(nextStep, response.data, user) + } + + private fun sendStep( + step: KeepS3InitStep, + responseData: ByteArray, + user: ScaleUser? = currentUser, + ) { + val activeUser = user ?: return + val payload = when (step) { + KeepS3InitStep.SET_TIME -> { + if (responseData.size == 4) { + responseData + } else { + logW("Scale time response had no four-byte timestamp; using current Unix time") + unixTimeBytes() + } + } + + KeepS3InitStep.SET_UNIT -> byteArrayOf(0x00) // Capture strongly supports 0x00 = kg. + KeepS3InitStep.PROFILE -> buildProfilePayload(activeUser) + KeepS3InitStep.START -> KeepS3Protocol.buildMeasurementControl(token, start = true) + else -> byteArrayOf() + } + + writeTo( + service, + writeCharacteristic, + KeepS3Protocol.buildRequest(step.opcode, payload), + withResponse = true, + ) + + if (step == KeepS3InitStep.START) { + userInfo(R.string.bt_info_step_on_scale) + } + } + + private fun handleEvent(header: KeepS3Protocol.FrameHeader, frame: ByteArray, user: ScaleUser) { + when (header.opcode) { + KeepS3Protocol.OP_MEASUREMENT_EVENT -> { + // ACK from the verified envelope before attempting to parse the event payload. + sendAck(KeepS3Protocol.OP_MEASUREMENT_EVENT) + val event = KeepS3Protocol.parseMeasurementEvent(frame) + if (event == null) { + logMalformedFrame(frame) + return + } + handleMeasurementEvent(event, user) + } + + KeepS3Protocol.OP_FINAL_RECORD -> { + sendAck(KeepS3Protocol.OP_FINAL_RECORD) + val record = KeepS3Protocol.parseFinalRecord(frame) + if (record == null) { + logMalformedFrame(frame) + return + } + finalPublishJob?.cancel() + finalPublishJob = null + val finalEvent = pendingFinalEvent + logD( + "Keep S3 final record impedance50=${record.impedance50Ohm ?: "invalid"}Ω " + + "impedance100=${record.impedance100Ohm ?: "invalid"}Ω " + + "phase50=${record.phaseAngle50Degrees ?: "invalid"}° " + + "phase100=${record.phaseAngle100Degrees ?: "invalid"}°", + ) + publishOnce( + user = user, + weightKg = finalEvent?.weightKg ?: record.weightKg, + deviceImpedanceOhm = finalEvent?.impedanceOhm ?: latestImpedanceOhm, + heartRateBpm = finalEvent?.heartRateBpm?.takeIf { it > 0 } + ?: record.heartRateBpm, + phaseAngle50Degrees = record.phaseAngle50Degrees, + phaseAngle100Degrees = record.phaseAngle100Degrees, + compositionImpedance50Ohm = record.impedance50Ohm, + compositionImpedance100Ohm = record.impedance100Ohm, + ) + finishSession() + } + + else -> logD("Ignoring unknown event opcode=0x${header.opcode.toString(16)} len=${frame.size}") + } + } + + private fun handleMeasurementEvent(event: KeepS3Protocol.MeasurementEvent, user: ScaleUser) { + if (event.impedanceOhm > 0) latestImpedanceOhm = event.impedanceOhm + + // Only 0x29 has a capture-verified final-result meaning. Other stage labels are unknown. + if (event.stage == KeepS3Protocol.STAGE_FINAL) { + pendingFinalEvent = event + if (finalPublishJob == null) { + finalPublishJob = scope.launch { + delay(FINAL_RECORD_WAIT_MS) + publishPendingFinalWithoutRecord() + finalPublishJob = null + } + } + } else if (event.stage == 0x00 || event.stage == 0x01) { + // These two progress stages carry live weight; their formal vendor names are unknown. + if (event.weightKg > 0f) { + userInfo(R.string.bluetooth_scale_info_measuring_weight, event.weightKg) + } + } + } + + private fun publishPendingFinalWithoutRecord() { + val user = currentUser ?: return + val event = pendingFinalEvent ?: return + publishOnce(user, event.weightKg, event.impedanceOhm, event.heartRateBpm) + } + + private fun publishOnce( + user: ScaleUser, + weightKg: Float, + deviceImpedanceOhm: Int, + heartRateBpm: Int, + phaseAngle50Degrees: Float? = null, + phaseAngle100Degrees: Float? = null, + compositionImpedance50Ohm: Int? = null, + compositionImpedance100Ohm: Int? = null, + ) { + if (published || !weightKg.isFinite() || weightKg <= 0f) return + + val measurement = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + weight = weightKg + if (heartRateBpm > 0) heartRate = heartRateBpm + // openScale has no measurement type for the vendor impedance or the phase angles, + // so they are decoded and logged but not published. Re-enabling any of these needs + // a MeasurementTypeKey, a ScaleMeasurement field, a DB migration and BleConnector + // wiring. + // if (deviceImpedanceOhm > 0) deviceImpedance = deviceImpedanceOhm.toDouble() + // phaseAngle50Degrees?.takeIf { it.isFinite() && it > 0f }?.let { phaseAngle = it } + // phaseAngle100Degrees?.takeIf { it.isFinite() && it > 0f }?.let { phaseAngleHigh = it } + + val hasDualFrequencyImpedance = + compositionImpedance50Ohm != null && compositionImpedance100Ohm != null + if (hasDualFrequencyImpedance) { + // ScaleMeasurement's established dual-band convention is high frequency in + // impedance and low frequency in impedanceLow. Keep S3 reports 100/50 kHz. + impedance = compositionImpedance100Ohm.toDouble() + impedanceLow = compositionImpedance50Ohm.toDouble() + } + + val composition = if (hasDualFrequencyImpedance) { + KeepS3BodyComposition.calculate( + KeepS3BodyComposition.Input( + gender = user.gender, + age = user.age, + heightCm = if (user.bodyHeight.isFinite()) { + user.bodyHeight.roundToInt() + } else { + 0 + }, + weightKg = weightKg, + impedance50Ohm = compositionImpedance50Ohm, + impedance100Ohm = compositionImpedance100Ohm, + // openScale's activity level is a TDEE multiplier, not the Keep SDK's + // athlete body-type flag. Capture/native API evidence does not establish + // an equivalence, so keep the vendor flag off until there is an explicit, + // user-controlled Keep setting. + athlete = false, + ), + ) + } else { + null + } + if (composition == null) { + logW("Keep S3 body composition unavailable; missing or invalid dual-frequency input") + } else { + fat = composition.bodyFatPercent + water = composition.waterPercent + visceralFat = composition.visceralFatLevel.toFloat() + bone = composition.boneKg + lbm = composition.fatFreeMassKg + bmr = composition.basalMetabolicRateKcal.toFloat() + protein = composition.proteinPercent + // openScale evaluates MUSCLE as a skeletal-muscle percentage (plausible range + // 15-60%). Keep's broader "muscle" value is FFM minus bone and can exceed that + // range, so publish the separately decoded skeletal-muscle percentage here. + muscle = composition.skeletalMusclePercent + // Keep's broader composition.musclePercent remains decoded by the model but is + // not published because openScale has no lean-soft-tissue measurement type. + // Not published — openScale has no measurement type for these. + // subcutaneousFat = composition.subcutaneousFatPercent + // bodyAge = composition.bodyAge + // bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg + logI("Keep S3 body composition calculated with offline BHKeep SDK-compatible model") + } + } + publish(measurement) + rememberDeviceImpedance(user.id, measurement, deviceImpedanceOhm) + published = true + } + + private fun finishSession() { + if (finishing) return + finishing = true + + val stopPayload = KeepS3Protocol.buildMeasurementControl(token, start = false) + val stopRequest = KeepS3Protocol.buildRequest(KeepS3Protocol.OP_MEASUREMENT_CONTROL, stopPayload) + writeTo(service, writeCharacteristic, stopRequest, withResponse = true) + writeTo(service, writeCharacteristic, stopRequest, withResponse = true) + + finishJob = scope.launch { + // The scale can emit more than 140 measurement events per session and leave a + // double-digit ACK backlog when the final record arrives. Allow that backlog, the + // 0x58 ACK and both stop commands to drain before disconnecting. The stop command + // is sent twice so a dropped one is not fatal. + delay(DISCONNECT_DELAY_MS) + requestDisconnect() + } + } + + /** + * Isolates the first-measurement all-zero policy from the wire codec. A Keep S3 accepted + * this fallback during hardware validation; the vendor-defined semantics remain unknown. + */ + private fun previousRecordFor(user: ScaleUser): KeepS3Protocol.PreviousRecord? { + val previous = lastMeasurementFor(user.id) + if (previous == null) { + logI("No previous measurement for user ${user.id}; using all-zero previous record") + return null + } + val impedanceOhm = previousDeviceImpedance(user.id, previous) + if (impedanceOhm == null) { + // Reusing impedance/impedanceLow would send a decoded 100/50 kHz band, while the + // captured official-app profile uses the distinct impedance from the 0x57 event. + logW("No matching Keep S3 protocol impedance; using all-zero previous record") + return null + } + logI("Keep S3 previous record uses matched protocol impedance=${impedanceOhm.roundToInt()}Ω") + return KeepS3Protocol.PreviousRecord( + weightKg = previous.weight, + timestampSeconds = (previous.dateTime?.time ?: 0L) / 1000L, + impedanceOhm = impedanceOhm, + ) + } + + private fun rememberDeviceImpedance( + userId: Int, + measurement: ScaleMeasurement, + deviceImpedanceOhm: Int, + ) { + val timestampSeconds = measurement.dateTime?.time?.div(1000L) ?: return + val encoded = KeepS3Protocol.serializeDeviceImpedance( + timestampSeconds = timestampSeconds, + weightKg = measurement.weight, + impedanceOhm = deviceImpedanceOhm, + ) ?: return + settingsPutString(KeepS3Protocol.deviceImpedanceSettingKey(userId), encoded) + } + + private fun previousDeviceImpedance(userId: Int, previous: ScaleMeasurement): Double? { + val timestampSeconds = previous.dateTime?.time?.div(1000L) ?: return null + val stored = KeepS3Protocol.parseDeviceImpedance( + settingsGetString(KeepS3Protocol.deviceImpedanceSettingKey(userId)), + ) ?: return null + return stored.impedanceOhm.toDouble().takeIf { + KeepS3Protocol.deviceImpedanceMatches(stored, timestampSeconds, previous.weight) + } + } + + private fun buildProfilePayload(user: ScaleUser): ByteArray { + val birthday = Calendar.getInstance().apply { time = user.birthday } + val heightCm = if (user.bodyHeight.isFinite()) user.bodyHeight.roundToInt() else 0 + return KeepS3Protocol.buildProfilePayload( + token = token, + previous = previousRecordFor(user), + heightCm = heightCm, + birthYear = birthday.get(Calendar.YEAR), + birthMonth = birthday.get(Calendar.MONTH) + 1, + birthDay = birthday.get(Calendar.DAY_OF_MONTH), + ) + } + + private fun loadOrCreateToken(userId: Int): String { + val key = KeepS3Protocol.tokenSettingKey(userId) + val stored = settingsGetString(key) + val normalized = stored?.lowercase(Locale.ROOT) + if (normalized != null && KeepS3Protocol.validateToken(normalized)) { + if (stored != normalized) settingsPutString(key, normalized) + return normalized + } + + val randomBytes = ByteArray(12).also(SecureRandom()::nextBytes) + return KeepS3Protocol.generateToken(randomBytes).also { settingsPutString(key, it) } + } + + private fun sendAck(opcode: Int) { + writeTo( + service, + writeCharacteristic, + KeepS3Protocol.buildAck(opcode), + withResponse = true, + ) + } + + private fun unixTimeBytes(): ByteArray = ByteArray(4).also { + KeepS3Protocol.encodeU32BE(it, 0, System.currentTimeMillis() / 1000L) + } + + private fun logMalformedFrame(frame: ByteArray) { + val type = frame.getOrNull(0)?.toInt()?.and(0xFF) + val opcode = frame.getOrNull(2)?.toInt()?.and(0xFF) + logW("Ignoring malformed Keep S3 frame len=${frame.size} type=$type opcode=$opcode") + } + + private fun resetSession() { + finalPublishJob?.cancel() + finalPublishJob = null + finishJob?.cancel() + finishJob = null + currentUser = null + token = "" + published = false + finishing = false + latestImpedanceOhm = 0 + pendingFinalEvent = null + } + + companion object { + private const val DEVICE_NAME = "Keep_S3" + private const val FINAL_RECORD_WAIT_MS = 2_000L + private const val DISCONNECT_DELAY_MS = 6_000L + + private val DEVICE_SUPPORT = DeviceSupport( + displayName = "Keep S3", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.BATTERY_LEVEL, + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG, + DeviceCapability.BODY_COMPOSITION, + ), + tuningProfile = TuningProfile.Balanced, + linkMode = LinkMode.CONNECT_GATT, + ) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyCompositionTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyCompositionTest.kt new file mode 100644 index 000000000..58e2fb0df --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyCompositionTest.kt @@ -0,0 +1,209 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.GenderType +import org.junit.Test + +class KeepS3BodyCompositionTest { + @Test + fun firstCapture_matchesBundledNativeSdk() { + val result = calculate( + weightKg = 85.1f, + impedance50 = 506, + impedance100 = 478, + ) + + assertThat(result.bodyFatKg).isEqualTo(25.1f) + assertThat(result.bodyFatPercent).isEqualTo(29.5f) + assertThat(result.fatFreeMassKg).isEqualTo(60.0f) + assertThat(result.waterPercent).isEqualTo(50.2f) + assertThat(result.boneKg).isEqualTo(3.0f) + assertThat(result.muscleKg).isEqualTo(57.0f) + assertThat(result.musclePercent).isEqualTo(66.9f) + assertThat(result.skeletalMuscleKg).isEqualTo(32.7f) + assertThat(result.skeletalMusclePercent).isWithin(0.001f).of(38.42538f) + assertThat(result.subcutaneousFatKg).isEqualTo(21.8f) + assertThat(result.subcutaneousFatPercent).isEqualTo(25.7f) + assertThat(result.proteinPercent).isEqualTo(12.7f) + assertThat(result.visceralFatLevel).isEqualTo(12) + assertThat(result.basalMetabolicRateKcal).isEqualTo(1791) + assertThat(result.bodyAge).isEqualTo(28) + assertThat(result.bmi22ReferenceWeightKg).isEqualTo(62.0f) + } + + @Test + fun secondCapture_matchesBundledNativeSdk() { + val result = calculate( + weightKg = 85.0f, + impedance50 = 502, + impedance100 = 475, + ) + + assertThat(result.bodyFatKg).isEqualTo(24.9f) + assertThat(result.bodyFatPercent).isEqualTo(29.4f) + assertThat(result.fatFreeMassKg).isEqualTo(60.1f) + assertThat(result.waterPercent).isEqualTo(50.3f) + assertThat(result.boneKg).isEqualTo(3.0f) + assertThat(result.muscleKg).isEqualTo(57.1f) + assertThat(result.musclePercent).isEqualTo(67.1f) + assertThat(result.skeletalMuscleKg).isEqualTo(32.7f) + assertThat(result.subcutaneousFatKg).isEqualTo(21.6f) + assertThat(result.subcutaneousFatPercent).isEqualTo(25.5f) + assertThat(result.proteinPercent).isEqualTo(12.7f) + assertThat(result.visceralFatLevel).isEqualTo(12) + assertThat(result.basalMetabolicRateKcal).isEqualTo(1790) + assertThat(result.bodyAge).isEqualTo(28) + } + + @Test + fun femaleAthlete_matchesBundledNativeSdkBranches() { + val result = calculate( + gender = GenderType.FEMALE, + age = 42, + athlete = true, + weightKg = 85.1f, + impedance50 = 506, + impedance100 = 478, + ) + + assertThat(result.bodyFatKg).isEqualTo(32.6f) + assertThat(result.bodyFatPercent).isEqualTo(38.4f) + assertThat(result.fatFreeMassKg).isEqualTo(52.5f) + assertThat(result.waterPercent).isEqualTo(44.1f) + assertThat(result.boneKg).isEqualTo(3.1f) + assertThat(result.muscleKg).isEqualTo(49.4f) + assertThat(result.musclePercent).isEqualTo(58.0f) + assertThat(result.skeletalMuscleKg).isEqualTo(28.4f) + assertThat(result.subcutaneousFatKg).isEqualTo(24.5f) + assertThat(result.subcutaneousFatPercent).isEqualTo(28.9f) + assertThat(result.proteinPercent).isEqualTo(10.3f) + assertThat(result.visceralFatLevel).isEqualTo(7) + assertThat(result.basalMetabolicRateKcal).isEqualTo(1481) + assertThat(result.bodyAge).isEqualTo(44) + } + + @Test + fun invalidSdkInput_isRejected() { + assertThat( + KeepS3BodyComposition.calculate( + KeepS3BodyComposition.Input( + gender = GenderType.MALE, + age = 26, + heightCm = 168, + weightKg = 85.1f, + impedance50Ohm = 199, + impedance100Ohm = 478, + ), + ), + ).isNull() + } + + @Test + fun bmi22ReferenceWeight_usesHeightSquaredTimesPoint022() { + val result = calculate( + heightCm = 190, + weightKg = 85.1f, + impedance50 = 506, + impedance100 = 478, + ) + + // The SDK truncates the fixed-point value before converting tenths of a kg. + val expectedKg = (190 * 190 * 0.022f).toInt() / 10f + assertThat(result.bmi22ReferenceWeightKg).isEqualTo(expectedKg) + assertThat(result.bmi22ReferenceWeightKg).isEqualTo(79.4f) + } + + @Test + fun crossProfileBranches_matchBundledNativeSdk() { + assertComposition( + calculate(age = 30, heightCm = 190, weightKg = 60f, impedance50 = 506, impedance100 = 478), + Expected(3.0f, 5.0f, 57.0f, 65.1f, 2.9f, 54.1f, 90.1f, 29.7f, + 2.7f, 4.5f, 19.8f, 1, 1365, 27, 79.4f), + ) + assertComposition( + calculate(age = 30, heightCm = 180, athlete = true, weightKg = 70f, + impedance50 = 506, impedance100 = 478), + Expected(8.7f, 12.5f, 61.3f, 60.0f, 3.1f, 58.2f, 83.1f, 32.2f, + 6.4f, 9.2f, 18.2f, 6, 1615, 30, 71.2f), + ) + assertComposition( + calculate(gender = GenderType.FEMALE, age = 55, heightCm = 160, weightKg = 50f, + impedance50 = 506, impedance100 = 478), + Expected(11.4f, 22.8f, 38.6f, 52.9f, 1.9f, 36.7f, 73.4f, 19.2f, + 10.1f, 20.2f, 16.2f, 4, 970, 53, 56.3f), + ) + assertComposition( + calculate(gender = GenderType.FEMALE, age = 30, heightCm = 150, weightKg = 45f, + impedance50 = 506, impedance100 = 478), + Expected(10.8f, 24.2f, 34.2f, 51.9f, 1.8f, 32.4f, 72.0f, 16.6f, + 10.1f, 22.5f, 15.7f, 2, 1078, 29, 49.5f), + ) + } + + private fun calculate( + gender: GenderType = GenderType.MALE, + age: Int = 26, + heightCm: Int = 168, + athlete: Boolean = false, + weightKg: Float, + impedance50: Int, + impedance100: Int, + ): KeepS3BodyComposition.Result = checkNotNull( + KeepS3BodyComposition.calculate( + KeepS3BodyComposition.Input( + gender = gender, + age = age, + heightCm = heightCm, + weightKg = weightKg, + impedance50Ohm = impedance50, + impedance100Ohm = impedance100, + athlete = athlete, + ), + ), + ) + + private fun assertComposition(result: KeepS3BodyComposition.Result, expected: Expected) { + assertThat(result.bodyFatKg).isEqualTo(expected.fatKg) + assertThat(result.bodyFatPercent).isEqualTo(expected.fatPercent) + assertThat(result.fatFreeMassKg).isEqualTo(expected.ffmKg) + assertThat(result.waterPercent).isEqualTo(expected.waterPercent) + assertThat(result.boneKg).isEqualTo(expected.boneKg) + assertThat(result.muscleKg).isEqualTo(expected.muscleKg) + assertThat(result.musclePercent).isEqualTo(expected.musclePercent) + assertThat(result.skeletalMuscleKg).isEqualTo(expected.skeletalKg) + assertThat(result.subcutaneousFatKg).isEqualTo(expected.subcutaneousKg) + assertThat(result.subcutaneousFatPercent).isEqualTo(expected.subcutaneousPercent) + assertThat(result.proteinPercent).isEqualTo(expected.proteinPercent) + assertThat(result.visceralFatLevel).isEqualTo(expected.visceral) + assertThat(result.basalMetabolicRateKcal).isEqualTo(expected.bmr) + assertThat(result.bodyAge).isEqualTo(expected.bodyAge) + assertThat(result.bmi22ReferenceWeightKg).isEqualTo(expected.bmi22ReferenceWeightKg) + } + + private data class Expected( + val fatKg: Float, + val fatPercent: Float, + val ffmKg: Float, + val waterPercent: Float, + val boneKg: Float, + val muscleKg: Float, + val musclePercent: Float, + val skeletalKg: Float, + val subcutaneousKg: Float, + val subcutaneousPercent: Float, + val proteinPercent: Float, + val visceral: Int, + val bmr: Int, + val bodyAge: Int, + val bmi22ReferenceWeightKg: Float, + ) +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt new file mode 100644 index 000000000..15802a249 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt @@ -0,0 +1,848 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.KeepS3BodyComposition +import com.health.openscale.core.data.ActivityLevel +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Calendar +import java.util.Date +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.math.roundToInt + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class KeepS3HandlerTest { + private val service = uuid16(0x00FF) + private val notifyCharacteristic = uuid16(0xFF01) + private val writeCharacteristic = uuid16(0xFF02) + + @Test + fun `builds empty negotiate request`() { + assertThat(KeepS3Protocol.buildRequest(0x38)) + .isEqualTo(bytes("01 53 38 00 00")) + } + + @Test + fun `builds measurement event ack`() { + assertThat(KeepS3Protocol.buildAck(0x57)) + .isEqualTo(bytes("04 53 57 00 00 80")) + } + + @Test + fun `parses first capture-verified final measurement`() { + val event = KeepS3Protocol.parseMeasurementEvent( + bytes("03 53 57 00 08 29 42 7C 00 00 01 2D 6B"), + )!! + + assertThat(event.stage).isEqualTo(0x29) + assertThat(event.weightKg).isWithin(0.0001f).of(85.10f) + assertThat(event.impedanceOhm).isEqualTo(301) + assertThat(event.heartRateBpm).isEqualTo(107) + } + + @Test + fun `parses second capture-verified final measurement`() { + val event = KeepS3Protocol.parseMeasurementEvent( + bytes("03 53 57 00 08 29 42 68 00 00 01 2C 61"), + )!! + + assertThat(event.stage).isEqualTo(0x29) + assertThat(event.weightKg).isWithin(0.0001f).of(85.00f) + assertThat(event.impedanceOhm).isEqualTo(300) + assertThat(event.heartRateBpm).isEqualTo(97) + } + + @Test + fun `parses live weight without publishing`() { + val setup = attachedHandler() + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + val live = bytes("03 53 57 00 08 00 42 5E 00 00 00 00 00") + val parsed = KeepS3Protocol.parseMeasurementEvent(live)!! + setup.handler.handleNotification(notifyCharacteristic, live) + + assertThat(parsed.stage).isEqualTo(0x00) + assertThat(parsed.weightKg).isWithin(0.0001f).of(84.95f) + assertThat(setup.callbacks.published).isEmpty() + assertThat(setup.transport.writes).hasSize(1) + assertThat(setup.transport.writes.single().payload) + .isEqualTo(bytes("04 53 57 00 00 80")) + } + + @Test + fun `rejects truncated measurement event safely and still acknowledges known envelope`() { + val setup = attachedHandler() + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + val truncated = bytes("03 53 57 00 08 29 42 7C 00 00 01 2D") + + assertThat(KeepS3Protocol.parseMeasurementEvent(truncated)).isNull() + setup.handler.handleNotification(notifyCharacteristic, truncated) + + assertThat(setup.callbacks.published).isEmpty() + assertThat(setup.transport.writes).hasSize(1) + assertThat(setup.transport.writes.single().payload) + .isEqualTo(bytes("04 53 57 00 00 80")) + } + + @Test + fun `parses first capture final record and decodes dual frequency fields`() { + val record = KeepS3Protocol.parseFinalRecord( + finalRecord( + weightRaw = 17020, + encodedImpedance50 = 0x5E255A, + encodedImpedance100 = 0x2627DA, + phaseAngle50Raw = -76, + phaseAngle100Raw = -76, + heartRate = 107, + ), + )!! + + assertThat(record.weightKg).isWithin(0.0001f).of(85.10f) + assertThat(record.encodedImpedance50).isEqualTo(0x5E255A) + assertThat(record.encodedImpedance100).isEqualTo(0x2627DA) + assertThat(record.impedance50Ohm).isEqualTo(506) + assertThat(record.impedance100Ohm).isEqualTo(478) + assertThat(record.phaseAngle50Raw).isEqualTo(-76) + assertThat(record.phaseAngle100Raw).isEqualTo(-76) + assertThat(record.phaseAngle50Degrees!!).isWithin(0.0001f).of(7.6f) + assertThat(record.phaseAngle100Degrees!!).isWithin(0.0001f).of(7.6f) + assertThat(record.heartRateBpm).isEqualTo(107) + } + + @Test + fun `parses second capture final record and decodes dual frequency fields`() { + val record = KeepS3Protocol.parseFinalRecord( + finalRecord( + weightRaw = 17000, + encodedImpedance50 = 0x8227E5, + encodedImpedance100 = 0x6C66AC, + phaseAngle50Raw = -77, + phaseAngle100Raw = -77, + heartRate = 97, + ), + )!! + + assertThat(record.weightKg).isWithin(0.0001f).of(85.00f) + assertThat(record.impedance50Ohm).isEqualTo(502) + assertThat(record.impedance100Ohm).isEqualTo(475) + assertThat(record.phaseAngle50Degrees!!).isWithin(0.0001f).of(7.7f) + assertThat(record.phaseAngle100Degrees!!).isWithin(0.0001f).of(7.7f) + assertThat(record.heartRateBpm).isEqualTo(97) + } + + @Test + fun `rejects malformed final record and impedance sentinel safely`() { + assertThat(KeepS3Protocol.parseFinalRecord(ByteArray(65))).isNull() + assertThat(KeepS3Protocol.decodeEncodedImpedance(0xFFFFFF)).isNull() + } + + @Test + fun `final record falls back once and finishes once while every event is acknowledged`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + // A non-final 0x57 can still provide the impedance used by the 0x58 fallback. + setup.handler.handleNotification( + notifyCharacteristic, + bytes("03 53 57 00 08 09 42 7C 00 00 01 2D 00"), + ) + val record = finalRecord( + weightRaw = 17020, + encodedImpedance50 = 0x5E255A, + encodedImpedance100 = 0x2627DA, + phaseAngle50Raw = -76, + phaseAngle100Raw = -76, + heartRate = 107, + ) + setup.handler.handleNotification(notifyCharacteristic, record) + setup.handler.handleNotification(notifyCharacteristic, record) + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(85.10f) + assertThat(setup.callbacks.published.single().impedance).isEqualTo(478.0) + assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(506.0) + assertThat(setup.callbacks.published.single().heartRate).isEqualTo(107) + assertThat(setup.callbacks.published.single().fat).isEqualTo(29.5f) + assertThat(setup.callbacks.published.single().water).isEqualTo(50.2f) + assertThat(setup.callbacks.published.single().muscle) + .isWithin(0.001f).of(38.42538f) + assertThat(setup.callbacks.published.single().visceralFat).isEqualTo(12f) + assertThat(setup.callbacks.published.single().protein).isEqualTo(12.7f) + assertThat(setup.callbacks.published.single().bone).isEqualTo(3.0f) + assertThat(setup.callbacks.published.single().lbm).isEqualTo(60.0f) + assertThat(setup.callbacks.published.single().bmr).isEqualTo(1791f) + + val storedDeviceImpedance = KeepS3Protocol.parseDeviceImpedance( + setup.settings.strings[KeepS3Protocol.deviceImpedanceSettingKey(setup.user.id)], + )!! + assertThat(storedDeviceImpedance.timestampSeconds).isEqualTo( + setup.callbacks.published.single().dateTime!!.time / 1000L, + ) + assertThat(storedDeviceImpedance.weightRaw).isEqualTo(17020) + assertThat(storedDeviceImpedance.impedanceOhm).isEqualTo(301) + + val payloads = setup.transport.writes.map { it.payload } + assertThat(payloads.count { it.contentEquals(KeepS3Protocol.buildAck(0x58)) }).isEqualTo(2) + assertThat(payloads.count { isControlRequest(it, start = false) }).isEqualTo(2) + + // The disconnect is held back for DISCONNECT_DELAY_MS so the queued ACKs and stop + // commands can drain first. + runCurrent() + assertThat(setup.transport.disconnectCount).isEqualTo(0) + + advanceTimeBy(5_999) + runCurrent() + assertThat(setup.transport.disconnectCount).isEqualTo(0) + + advanceTimeBy(1) + runCurrent() + assertThat(setup.transport.disconnectCount).isEqualTo(1) + } + + @Test + fun `final measurement waits briefly for final record and publishes enriched data once`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + val final = bytes("03 53 57 00 08 29 42 68 00 00 01 2C 61") + val record = finalRecord( + weightRaw = 17000, + encodedImpedance50 = 0x8227E5, + encodedImpedance100 = 0x6C66AC, + phaseAngle50Raw = -77, + phaseAngle100Raw = -77, + heartRate = 97, + ) + + setup.handler.handleNotification(notifyCharacteristic, final) + setup.handler.handleNotification(notifyCharacteristic, final) + assertThat(setup.callbacks.published).isEmpty() + + setup.handler.handleNotification(notifyCharacteristic, record) + setup.handler.handleNotification(notifyCharacteristic, record) + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().userId).isEqualTo(setup.user.id) + assertThat(setup.callbacks.published.single().impedance).isEqualTo(475.0) + assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(502.0) + assertThat(setup.callbacks.published.single().fat).isEqualTo(29.4f) + assertThat(setup.callbacks.published.single().water).isEqualTo(50.3f) + assertThat(setup.callbacks.published.single().muscle) + .isWithin(0.001f).of(38.47059f) + assertThat(setup.transport.writes.count { + it.payload.contentEquals(KeepS3Protocol.buildAck(0x57)) + }).isEqualTo(2) + assertThat(setup.transport.writes.count { + it.payload.contentEquals(KeepS3Protocol.buildAck(0x58)) + }).isEqualTo(2) + } + + @Test + fun `final measurement publishes once after timeout when final record is missing`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + val final = bytes("03 53 57 00 08 29 42 68 00 00 01 2C 61") + + setup.handler.handleNotification(notifyCharacteristic, final) + setup.handler.handleNotification(notifyCharacteristic, final) + + assertThat(setup.callbacks.published).isEmpty() + advanceTimeBy(1_999) + runCurrent() + assertThat(setup.callbacks.published).isEmpty() + advanceTimeBy(1) + runCurrent() + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(85.00f) + assertThat(setup.callbacks.published.single().impedance).isEqualTo(0.0) + assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(0.0) + assertThat(setup.callbacks.published.single().heartRate).isEqualTo(97) + } + + @Test + fun `invalid user profile keeps verified result and omits composition safely`() = runTest { + val user = syntheticUser().apply { bodyHeight = Float.NaN } + val setup = attachedHandler(user = user, scope = this) + setup.handler.handleConnected(user) + setup.transport.clearWrites() + + setup.handler.handleNotification( + notifyCharacteristic, + finalRecord( + weightRaw = 17020, + encodedImpedance50 = 0x5E255A, + encodedImpedance100 = 0x2627DA, + phaseAngle50Raw = -76, + phaseAngle100Raw = -76, + heartRate = 107, + ), + ) + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().weight).isEqualTo(85.1f) + assertThat(setup.callbacks.published.single().heartRate).isEqualTo(107) + assertThat(setup.callbacks.published.single().fat).isEqualTo(0f) + assertThat(setup.callbacks.published.single().water).isEqualTo(0f) + assertThat(setup.callbacks.published.single().lbm).isEqualTo(0f) + } + + @Test + fun `extreme activity level does not enable unverified Keep athlete mode`() = runTest { + val user = syntheticUser().apply { activityLevel = ActivityLevel.EXTREME } + val setup = attachedHandler(user = user, scope = this) + setup.handler.handleConnected(user) + setup.transport.clearWrites() + + setup.handler.handleNotification( + notifyCharacteristic, + finalRecord( + weightRaw = 17020, + encodedImpedance50 = 0x5E255A, + encodedImpedance100 = 0x2627DA, + phaseAngle50Raw = -76, + phaseAngle100Raw = -76, + heartRate = 107, + ), + ) + + val expected = KeepS3BodyComposition.calculate( + KeepS3BodyComposition.Input( + gender = user.gender, + age = user.age, + heightCm = user.bodyHeight.roundToInt(), + weightKg = 85.10f, + impedance50Ohm = 506, + impedance100Ohm = 478, + athlete = false, + ), + )!! + val actual = setup.callbacks.published.single() + assertThat(actual.fat).isEqualTo(expected.bodyFatPercent) + assertThat(actual.water).isEqualTo(expected.waterPercent) + assertThat(actual.muscle).isEqualTo(expected.skeletalMusclePercent) + assertThat(actual.bone).isEqualTo(expected.boneKg) + assertThat(actual.lbm).isEqualTo(expected.fatFreeMassKg) + } + + @Test + fun `builds profile payload with repeated token previous record and big endian fields`() { + val token = "0123456789abcdef01234567" + val payload = KeepS3Protocol.buildProfilePayload( + token = token, + previous = KeepS3Protocol.PreviousRecord( + weightKg = 85.10f, + timestampSeconds = 0x1234_5678L, + impedanceOhm = 301.0, + ), + heightCm = 168, + birthYear = 2000, + birthMonth = 5, + birthDay = 20, + ) + + assertThat(payload).hasLength(63) + assertThat(payload.copyOfRange(0, 24)).isEqualTo(token.encodeToByteArray()) + assertThat(payload.copyOfRange(24, 48)).isEqualTo(token.encodeToByteArray()) + assertThat(KeepS3Protocol.decodeU16BE(payload, 48)).isEqualTo(17020) + assertThat(KeepS3Protocol.decodeU32BE(payload, 50)).isEqualTo(0x1234_5678L) + assertThat(KeepS3Protocol.decodeU16BE(payload, 54)).isEqualTo(301) + assertThat(KeepS3Protocol.decodeU16BE(payload, 56)).isEqualTo(0) + assertThat(payload[58].toInt() and 0xFF).isEqualTo(168) + assertThat(KeepS3Protocol.decodeU16BE(payload, 59)).isEqualTo(2000) + assertThat(payload[61].toInt() and 0xFF).isEqualTo(5) + assertThat(payload[62].toInt() and 0xFF).isEqualTo(20) + } + + @Test + fun `missing previous measurement produces isolated all-zero record`() { + val payload = KeepS3Protocol.buildProfilePayload( + token = "0123456789abcdef01234567", + previous = null, + heightCm = 168, + birthYear = 2000, + birthMonth = 5, + birthDay = 20, + ) + + assertThat(payload.copyOfRange(48, 58)).isEqualTo(ByteArray(10)) + } + + @Test + fun `profile numeric fields clamp overflow`() { + val payload = KeepS3Protocol.buildProfilePayload( + token = "0123456789abcdef01234567", + previous = KeepS3Protocol.PreviousRecord( + weightKg = Float.MAX_VALUE, + timestampSeconds = Long.MAX_VALUE, + impedanceOhm = Double.MAX_VALUE, + ), + heightCm = Int.MAX_VALUE, + birthYear = Int.MAX_VALUE, + birthMonth = 99, + birthDay = 99, + ) + + assertThat(KeepS3Protocol.decodeU16BE(payload, 48)).isEqualTo(0xFFFF) + assertThat(KeepS3Protocol.decodeU32BE(payload, 50)).isEqualTo(0xFFFF_FFFFL) + assertThat(KeepS3Protocol.decodeU16BE(payload, 54)).isEqualTo(0xFFFF) + assertThat(payload[58].toInt() and 0xFF).isEqualTo(0xFF) + assertThat(KeepS3Protocol.decodeU16BE(payload, 59)).isEqualTo(0xFFFF) + assertThat(payload[61].toInt() and 0xFF).isEqualTo(12) + assertThat(payload[62].toInt() and 0xFF).isEqualTo(31) + } + + @Test + fun `builds start and stop control payloads`() { + val token = "0123456789abcdef01234567" + val start = KeepS3Protocol.buildMeasurementControl(token, start = true) + val stop = KeepS3Protocol.buildMeasurementControl(token, start = false) + + assertThat(start).hasLength(50) + assertThat(start.copyOfRange(0, 48)).isEqualTo((token + token).encodeToByteArray()) + assertThat(start.copyOfRange(48, 50)).isEqualTo(byteArrayOf(0x00, 0x01)) + assertThat(stop.copyOfRange(48, 50)).isEqualTo(byteArrayOf(0x00, 0x00)) + } + + @Test + fun `validates and deterministically formats token bytes`() { + val token = KeepS3Protocol.generateToken(ByteArray(12) { it.toByte() }) + + assertThat(token).isEqualTo("000102030405060708090a0b") + assertThat(KeepS3Protocol.validateToken(token)).isTrue() + assertThat(KeepS3Protocol.validateToken(token.uppercase())).isFalse() + assertThat(KeepS3Protocol.repeatedTokenBytes(token)).hasLength(48) + } + + @Test + fun `persisted device impedance is accepted only for its source measurement`() { + val encoded = KeepS3Protocol.serializeDeviceImpedance( + timestampSeconds = 0x1234_5678L, + weightKg = 85.10f, + impedanceOhm = 301, + )!! + val stored = KeepS3Protocol.parseDeviceImpedance(encoded)!! + + assertThat(stored.timestampSeconds).isEqualTo(0x1234_5678L) + assertThat(stored.weightRaw).isEqualTo(17020) + assertThat(stored.impedanceOhm).isEqualTo(301) + assertThat(KeepS3Protocol.deviceImpedanceMatches(stored, 0x1234_5678L, 85.10f)).isTrue() + assertThat(KeepS3Protocol.deviceImpedanceMatches(stored, 0x1234_5679L, 85.10f)).isFalse() + assertThat(KeepS3Protocol.deviceImpedanceMatches(stored, 0x1234_5678L, 85.00f)).isFalse() + assertThat(KeepS3Protocol.serializeDeviceImpedance(0L, 85.10f, 301)).isNull() + assertThat(KeepS3Protocol.serializeDeviceImpedance(0x1234_5678L, 0f, 301)).isNull() + assertThat(KeepS3Protocol.parseDeviceImpedance("invalid")).isNull() + } + + @Test + fun `generated token persists and reloads for the same user`() { + val settings = InMemorySettings() + val user = syntheticUser() + val first = attachedHandler(user = user, settings = settings) + first.handler.handleConnected(user) + val key = KeepS3Protocol.tokenSettingKey(user.id) + val generated = settings.strings[key]!! + + val second = attachedHandler(user = user, settings = settings) + second.handler.handleConnected(user) + + assertThat(KeepS3Protocol.validateToken(generated)).isTrue() + assertThat(settings.strings[key]).isEqualTo(generated) + } + + @Test + fun `state machine advances only on expected successful opcode and requires both F5 responses`() { + val state = KeepS3InitStateMachine() + assertThat(state.reset()).isEqualTo(KeepS3InitStep.NEGOTIATE) + assertThat(state.acceptSuccessfulResponse(0x0A)).isNull() + assertThat(state.expectedStep).isEqualTo(KeepS3InitStep.NEGOTIATE) + + assertThat(state.acceptSuccessfulResponse(0x38)).isEqualTo(KeepS3InitStep.READ_TIME) + assertThat(state.acceptSuccessfulResponse(0x0A)).isEqualTo(KeepS3InitStep.SET_TIME) + assertThat(state.acceptSuccessfulResponse(0x01)).isEqualTo(KeepS3InitStep.SET_UNIT) + assertThat(state.acceptSuccessfulResponse(0x05)).isEqualTo(KeepS3InitStep.DEVICE_INFO) + assertThat(state.acceptSuccessfulResponse(0xE7)).isEqualTo(KeepS3InitStep.UNKNOWN_F5_FIRST) + assertThat(state.acceptSuccessfulResponse(0xF5)).isEqualTo(KeepS3InitStep.UNKNOWN_F5_SECOND) + assertThat(state.expectedStep).isEqualTo(KeepS3InitStep.UNKNOWN_F5_SECOND) + assertThat(state.acceptSuccessfulResponse(0x03)).isNull() + assertThat(state.expectedStep).isEqualTo(KeepS3InitStep.UNKNOWN_F5_SECOND) + assertThat(state.acceptSuccessfulResponse(0xF5)).isEqualTo(KeepS3InitStep.BATTERY) + } + + @Test + fun `handler sends response-driven initialization exactly once`() { + val setup = attachedHandler( + settings = InMemorySettings().apply { + putString(KeepS3Protocol.tokenSettingKey(7), "0123456789abcdef01234567") + }, + ) + setup.handler.handleConnected(setup.user) + + assertThat(setup.transport.notifications) + .containsExactly(service to notifyCharacteristic) + assertThat(requestOpcodes(setup.transport)).containsExactly(0x38).inOrder() + + setup.handler.handleNotification(notifyCharacteristic, response(0x22)) + setup.handler.handleNotification(notifyCharacteristic, response(0x38)) + setup.handler.handleNotification(notifyCharacteristic, response(0x38)) // duplicate old response + setup.handler.handleNotification(notifyCharacteristic, response(0x0A, bytes("12 34 56 78"))) + setup.handler.handleNotification(notifyCharacteristic, response(0x01)) + setup.handler.handleNotification(notifyCharacteristic, response(0x05)) + setup.handler.handleNotification(notifyCharacteristic, response(0xE7)) + setup.handler.handleNotification(notifyCharacteristic, response(0xF5, byteArrayOf(0x01))) + setup.handler.handleNotification(notifyCharacteristic, response(0xF5, byteArrayOf(0x01))) + setup.handler.handleNotification(notifyCharacteristic, response(0x03, byteArrayOf(95))) + setup.handler.handleNotification(notifyCharacteristic, response(0x20)) + setup.handler.handleNotification(notifyCharacteristic, response(0x32, byteArrayOf(0x00))) + setup.handler.handleNotification(notifyCharacteristic, response(0x32, byteArrayOf(0x00))) // duplicate old response + setup.handler.handleNotification(notifyCharacteristic, response(0x36)) + setup.handler.handleNotification(notifyCharacteristic, response(0x36)) // duplicate after completion + + assertThat(requestOpcodes(setup.transport)).containsExactly( + 0x38, 0x0A, 0x01, 0x05, 0xE7, 0xF5, 0xF5, 0x03, 0x20, 0x32, 0x36, + ).inOrder() + assertThat(setup.transport.writes.all { it.withResponse }).isTrue() + assertThat(setup.transport.writes.all { + it.service == service && it.characteristic == writeCharacteristic + }).isTrue() + val timeRequest = setup.transport.writes.first { requestOpcode(it.payload) == 0x01 }.payload + assertThat(timeRequest.copyOfRange(5, 9)).isEqualTo(bytes("12 34 56 78")) + } + + @Test + fun `profile carries the persisted protocol impedance instead of either frequency band`() = runTest { + val settings = InMemorySettings() + val first = attachedHandler(previous = null, settings = settings, scope = this) + first.handler.handleConnected(first.user) + first.handler.handleNotification( + notifyCharacteristic, + bytes("03 53 57 00 08 29 42 68 00 00 01 2C 61"), + ) + first.handler.handleNotification( + notifyCharacteristic, + finalRecord( + weightRaw = 17000, + encodedImpedance50 = 0x8227E5, + encodedImpedance100 = 0x6C66AC, + phaseAngle50Raw = -77, + phaseAngle100Raw = -77, + heartRate = 97, + ), + ) + val previous = first.callbacks.published.single() + assertThat(previous.impedance).isEqualTo(475.0) + assertThat(previous.impedanceLow).isEqualTo(502.0) + + val setup = attachedHandler(previous = previous, settings = settings, scope = this) + + driveInitializationThroughProfile(setup) + + val profileRequest = setup.transport.writes.single { requestOpcode(it.payload) == 0x32 }.payload + assertThat(KeepS3Protocol.decodeU16BE(profileRequest, 5 + 48)).isEqualTo(17000) + assertThat(KeepS3Protocol.decodeU32BE(profileRequest, 5 + 50)).isEqualTo( + previous.dateTime!!.time / 1000L, + ) + assertThat(KeepS3Protocol.decodeU16BE(profileRequest, 5 + 54)).isEqualTo(300) + } + + @Test + fun `profile uses all-zero record when persisted protocol impedance does not match`() { + val previous = ScaleMeasurement( + userId = 7, + dateTime = Date(0x1234_5678L * 1000L), + weight = 85.10f, + impedance = 478.0, + impedanceLow = 506.0, + ) + val settings = InMemorySettings().apply { + putString( + KeepS3Protocol.deviceImpedanceSettingKey(previous.userId), + KeepS3Protocol.serializeDeviceImpedance( + timestampSeconds = 0x1234_5677L, + weightKg = 85.10f, + impedanceOhm = 301, + )!!, + ) + } + val setup = attachedHandler(previous = previous, settings = settings) + + driveInitializationThroughProfile(setup) + + val profileRequest = setup.transport.writes.single { requestOpcode(it.payload) == 0x32 }.payload + assertThat(profileRequest.copyOfRange(5 + 48, 5 + 58)).isEqualTo(ByteArray(10)) + } + + @Test + fun `failed response status does not advance initialization`() { + val setup = attachedHandler() + setup.handler.handleConnected(setup.user) + + setup.handler.handleNotification( + notifyCharacteristic, + bytes("02 53 38 00 00 81"), + ) + + assertThat(requestOpcodes(setup.transport)).containsExactly(0x38) + } + + @Test + fun `matches exact Keep S3 name without requiring advertised service`() { + val handler = KeepS3Handler() + val support = handler.supportFor(device("keep_s3"))!! + + assertThat(support.displayName).isEqualTo("Keep S3") + assertThat(support.implemented).containsExactly( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG, + DeviceCapability.BODY_COMPOSITION, + ) + assertThat(support.capabilities).contains(DeviceCapability.BODY_COMPOSITION) + assertThat(support.implemented).doesNotContain(DeviceCapability.BATTERY_LEVEL) + assertThat(handler.supportFor(device("Keep_S3", 0x00FF))).isNotNull() + assertThat(handler.supportFor(device("Keep_S3_extra", 0x00FF))).isNull() + assertThat(handler.supportFor(device("Other", 0x00FF, 0xFFF0))).isNull() + } + + private fun attachedHandler( + user: ScaleUser = syntheticUser(), + settings: InMemorySettings = InMemorySettings().apply { + putString(KeepS3Protocol.tokenSettingKey(user.id), "0123456789abcdef01234567") + }, + scope: CoroutineScope = CoroutineScope(EmptyCoroutineContext), + previous: ScaleMeasurement? = ScaleMeasurement( + userId = user.id, + dateTime = Date(0x1234_5678L * 1000L), + weight = 85.10f, + impedance = 301.0, + ), + ): Setup { + val handler = KeepS3Handler() + val transport = CapturingTransport() + val callbacks = CapturingCallbacks() + handler.attach( + transport = transport, + callbacks = callbacks, + settings = settings, + data = FixedDataProvider(user, previous), + scope = scope, + ) + return Setup(handler, transport, callbacks, settings, user) + } + + private fun syntheticUser(): ScaleUser { + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + val birthday = Calendar.getInstance().apply { + clear() + set(currentYear - 26, Calendar.JANUARY, 1) + }.time + return ScaleUser(id = 7, birthday = birthday, bodyHeight = 168f) + } + + private fun response(opcode: Int, data: ByteArray = byteArrayOf()): ByteArray = + byteArrayOf( + KeepS3Protocol.FRAME_RESPONSE.toByte(), + KeepS3Protocol.MAGIC.toByte(), + opcode.toByte(), + 0x00, + data.size.toByte(), + KeepS3Protocol.STATUS_OK.toByte(), + ) + data + + private fun driveInitializationThroughProfile(setup: Setup) { + setup.handler.handleConnected(setup.user) + setup.handler.handleNotification(notifyCharacteristic, response(0x38)) + setup.handler.handleNotification(notifyCharacteristic, response(0x0A, bytes("12 34 56 78"))) + setup.handler.handleNotification(notifyCharacteristic, response(0x01)) + setup.handler.handleNotification(notifyCharacteristic, response(0x05)) + setup.handler.handleNotification(notifyCharacteristic, response(0xE7)) + setup.handler.handleNotification(notifyCharacteristic, response(0xF5, byteArrayOf(0x01))) + setup.handler.handleNotification(notifyCharacteristic, response(0xF5, byteArrayOf(0x01))) + setup.handler.handleNotification(notifyCharacteristic, response(0x03, byteArrayOf(95))) + setup.handler.handleNotification(notifyCharacteristic, response(0x20)) + } + + private fun finalRecord( + weightRaw: Int, + encodedImpedance50: Int = 0, + encodedImpedance100: Int = 0, + phaseAngle50Raw: Int = 0, + phaseAngle100Raw: Int = 0, + heartRate: Int, + ): ByteArray { + val frame = ByteArray(66) + frame[0] = KeepS3Protocol.FRAME_EVENT.toByte() + frame[1] = KeepS3Protocol.MAGIC.toByte() + frame[2] = KeepS3Protocol.OP_FINAL_RECORD.toByte() + frame[3] = 0x00 + frame[4] = 61 + val tokenPair = KeepS3Protocol.repeatedTokenBytes("0123456789abcdef01234567") + tokenPair.copyInto(frame, destinationOffset = 5) + KeepS3Protocol.encodeU16BE(frame, 53, weightRaw) + encodeU24BE(frame, 55, encodedImpedance50) + encodeU24BE(frame, 58, encodedImpedance100) + KeepS3Protocol.encodeU16BE(frame, 61, phaseAngle50Raw and 0xFFFF) + KeepS3Protocol.encodeU16BE(frame, 63, phaseAngle100Raw and 0xFFFF) + frame[65] = heartRate.toByte() + return frame + } + + private fun encodeU24BE(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value ushr 16).toByte() + target[offset + 1] = (value ushr 8).toByte() + target[offset + 2] = value.toByte() + } + + private fun requestOpcodes(transport: CapturingTransport): List = + transport.writes.mapNotNull { requestOpcode(it.payload) } + + private fun requestOpcode(payload: ByteArray): Int? = + if (payload.size >= 5 && (payload[0].toInt() and 0xFF) == KeepS3Protocol.FRAME_REQUEST) { + payload[2].toInt() and 0xFF + } else { + null + } + + private fun isControlRequest(payload: ByteArray, start: Boolean): Boolean = + requestOpcode(payload) == KeepS3Protocol.OP_MEASUREMENT_CONTROL && + payload.size == 55 && + payload[53] == 0x00.toByte() && + payload[54] == (if (start) 0x01 else 0x00).toByte() + + private fun device(name: String, vararg services: Int) = ScannedDeviceInfo( + name = name, + address = "00:11:22:33:44:55", + rssi = -50, + serviceUuids = services.map(::uuid16), + manufacturerData = null, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun bytes(hex: String): ByteArray = hex + .trim() + .split(Regex("\\s+")) + .filter(String::isNotEmpty) + .map { it.toInt(16).toByte() } + .toByteArray() + + private data class Setup( + val handler: KeepS3Handler, + val transport: CapturingTransport, + val callbacks: CapturingCallbacks, + val settings: InMemorySettings, + val user: ScaleUser, + ) + + private data class Write( + val service: UUID, + val characteristic: UUID, + val payload: ByteArray, + val withResponse: Boolean, + ) + + private class CapturingTransport : ScaleDeviceHandler.Transport { + val notifications = mutableListOf>() + val writes = mutableListOf() + var disconnectCount = 0 + + override fun setNotifyOn(service: UUID, characteristic: UUID) { + notifications += service to characteristic + } + + override fun write( + service: UUID, + characteristic: UUID, + payload: ByteArray, + withResponse: Boolean, + ) { + writes += Write(service, characteristic, payload.copyOf(), withResponse) + } + + override fun read(service: UUID, characteristic: UUID) = Unit + + override fun disconnect() { + disconnectCount++ + } + + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = true + + fun clearWrites() = writes.clear() + } + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + + override fun onPublish(measurement: ScaleMeasurement) { + published += measurement.copy() + } + + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + val strings = mutableMapOf() + private val ints = mutableMapOf() + + override fun getInt(key: String, default: Int): Int = ints[key] ?: default + override fun putInt(key: String, value: Int) { + ints[key] = value + } + + override fun getString(key: String, default: String?): String? = strings[key] ?: default + override fun putString(key: String, value: String) { + strings[key] = value + } + + override fun remove(key: String) { + strings.remove(key) + ints.remove(key) + } + } + + private class FixedDataProvider( + private val user: ScaleUser, + private val previous: ScaleMeasurement?, + ) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = + previous?.takeIf { userId == user.id } + } +} From 3a154c24c3abd86d948c2fa228e380c32b34da6e Mon Sep 17 00:00:00 2001 From: Caio Figueiredo <81820496+Caiofiga@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:14:31 -0300 Subject: [PATCH 19/72] Add Relaxmedic scale support (icomon protocol 107) (#1441) Shares service 0xFFB0 and the 20-byte frame family with RobiS9Handler and RunstarR6Handler, but differs in two ways. Frames fragment: byte 2 is a fragment index, not a constant zero, and the 0xA3 result spans two frames. Every fragment contributes bytes[3..18], so the byte after the fragment index is payload rather than a header byte to skip. Both existing handlers in this family reject such frames outright. The 0xBA profile is generated rather than replayed. RobiS9Handler replays a captured handshake because its timestamp and token could not be regenerated; this one is understood field by field, so it is built from the openScale user. flags1 carries sex and age and is the only place they are sent -- getting it wrong is silent, the scale simply computes for the wrong person. The generated frame is byte-identical to a capture the scale accepts. Body composition comes from Wla25BodyComposition, ported from ICBodyFatAlgorithmWLA25::calc in the vendor library: fat mass is a 13-term linear regression over height, weight, the rounded BMI and all ten impedances, and the rest follows from fat-free mass. Verified against the vendor library over 240 randomised inputs spanning both clamp boundaries, both impedance branches and both sexes -- exact on all ten fields. Three roundings are load-bearing and each is worth a tenth of a unit: the weight is rounded before anything else, the BMI fed into the regression is rounded, and fat mass is rounded before fat-free mass is derived from it. The rounding is half-up and computed in float32; everything else runs in double, matching the library. Doing it all in Float shifts BMR by 1 kcal in roughly one case in eighty. compute() takes no sex parameter: this algorithm's body composition does not depend on it, only metabolic age does. Known limitation: bone mass can read 0.1 kg below the scale's own display. The vendor app agrees with this code, and no algorithm the vendor library ships reproduces the scale's combination of values, so the scale's firmware appears to compute bone differently. Metabolic age is implemented but not published -- ScaleMeasurement has no field for it. EtekcityLib and HesleyHandler hit the same wall, one computing it and the other reading it off the wire, both discarding it. Co-authored-by: Claude Opus 5 (1M context) --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../bluetooth/libs/Wla25BodyComposition.kt | 221 +++++++++++ .../bluetooth/scales/RelaxmedicHandler.kt | 361 ++++++++++++++++++ 3 files changed, 584 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 80ad9daed..8f756a7c2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -62,6 +62,7 @@ import com.health.openscale.core.bluetooth.scales.QNHandlerBroadcast import com.health.openscale.core.bluetooth.scales.RealmeSmartScaleHandler import com.health.openscale.core.bluetooth.scales.RenphoES26BBHandler import com.health.openscale.core.bluetooth.scales.RenphoHandler +import com.health.openscale.core.bluetooth.scales.RelaxmedicHandler import com.health.openscale.core.bluetooth.scales.RobiS9Handler import com.health.openscale.core.bluetooth.scales.RunstarR5Handler import com.health.openscale.core.bluetooth.scales.RunstarR6Handler @@ -139,6 +140,7 @@ class ScaleFactory @Inject constructor( MiScaleHandler(), RunstarR6Handler(), RunstarR5Handler(), + RelaxmedicHandler(), RobiS9Handler(), VitafitVT701Handler(), EEBBLHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt new file mode 100644 index 000000000..cb92b0102 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt @@ -0,0 +1,221 @@ +/* + * openScale + * + * 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.libs + +/** + * Body composition as computed by the Fitdays/icomon `WLA25` algorithm. + * + * Ported from `ICBodyFatAlgorithmWLA25::calc` in the vendor app's + * `libICBodyFatAlgorithms.so`. Fat mass is a 13-term linear regression over + * height, weight, the rounded BMI and all ten impedances; every other field + * follows from fat-free mass. + * + * Verified against the vendor library over 240 randomised inputs spanning both + * clamp boundaries, both impedance branches and both sexes: exact on all ten + * fields. On real hardware it reproduces the scale's display, except bone mass + * which can read 0.1 kg low (see [bone]). + * + * Three details are load-bearing and each is worth a tenth of a unit: + * - the weight is rounded to one decimal *before anything else*, + * - the BMI fed into the regression is rounded, + * - fat mass is rounded before fat-free mass is derived from it. + * + * [round1] is half-up and runs in single precision, as the library's `fmodf` + * chain does. Both matter: `round1(26.35)` is 26.4 where a half-to-even + * rounding gives 26.3, and 1.95 has a float32 fraction of exactly 0.95, so the + * half-up test fails and the answer is 1.9 rather than 2.0. + */ +object Wla25BodyComposition { + + /** Body fat is clamped to this range before anything is derived from it. */ + private const val BFR_MIN = 3.0 + private const val BFR_MAX = 60.0 + + /** Sex as the vendor library encodes it. */ + const val SEX_MALE = 1 + const val SEX_FEMALE = 2 + + data class Result( + val weightKg: Float, + val bmi: Float, + /** Body fat, % of body weight. */ + val fat: Float, + /** Total body water, % of body weight. */ + val water: Float, + /** Muscle, % of body weight. */ + val musclePercent: Float, + val muscleKg: Float, + /** + * Bone mass in kg. + * + * Known limitation: two field measurements read 0.1 kg below the scale's + * own display while the other five fields matched exactly. The vendor app + * agrees with this value, and no algorithm the vendor library ships + * reproduces the scale's combination, so the scale's firmware appears to + * compute bone slightly differently. + */ + val boneKg: Float, + /** Subcutaneous fat, % of body weight. */ + val subcutaneousFat: Float, + /** Visceral fat as a 1..20 level, not a percentage. */ + val visceralFat: Int, + /** Protein, % of body weight. Not cross-checked against the vendor app. */ + val protein: Float, + /** Skeletal muscle, % of body weight. */ + val skeletalMuscle: Float, + val bmrKcal: Int, + /** Fat-free mass in kg. */ + val lbmKg: Float + ) + + /** + * The vendor library's one-decimal rounding: half-up, computed in float32. + * + * The narrowing is deliberate and confined to here. Everything else runs in + * double, as the library does — `dVar49 = dVar39 - dVar38` and friends are + * double subtractions of values that merely *originated* as floats. Widening + * this narrowing to the whole computation shifts BMR by 1 kcal in about one + * case in eighty. + */ + fun round1(value: Double): Double { + val v = value.toFloat() + val whole = v.toInt() + val tenths = (v % 1.0f) * 10.0f + val carried = if (tenths % 1.0f > 0.5f) tenths + 1.0f else tenths + return (carried.toInt() / 10.0f + whole).toDouble() + } + + fun bmi(heightCm: Int, weightKg: Double): Double = + weightKg * 10000.0 / (heightCm * heightCm) + + /** + * The library's own validity gate. + * + * Slots 0 and 5 carry the small leading value of each measurement group + * (~15-25 ohm) and are checked against 1.0; the other eight are ~300 ohm and + * are checked against 100.0. That asymmetry is what pins the ordering of the + * ten values. When the gate fails the library zeroes its entire result + * rather than reporting an error. + */ + fun impedancesValid(imps: DoubleArray): Boolean { + if (imps.size != 10) return false + if (imps[0] < 1.0 || imps[5] < 1.0) return false + for (i in intArrayOf(1, 2, 3, 4, 6, 7, 8, 9)) { + if (imps[i] < 100.0) return false + } + return true + } + + /** Fat mass in kg. [weightKg] and the BMI must already be rounded. */ + private fun fatMass(heightCm: Int, weightKg: Double, imps: DoubleArray): Double { + val scaled0 = imps[0] * 0.826 + // The smaller of the two leading values wins, with a -3.0 offset when + // slot 0 is the smaller one. + val scaled5 = if (imps[5] <= imps[0]) imps[5] * 0.826 else scaled0 - 3.0 + + return weightKg * -0.138 + + heightCm * 0.164 + + round1(bmi(heightCm, weightKg)) * 2.657 + + imps[2] * -0.053 + + imps[1] * -0.000491 + + scaled0 * -0.03 + + imps[4] * -0.127 + + imps[3] * -0.052 + + imps[7] * 0.07 + + imps[6] * 0.019 + + scaled5 * 0.439 + + imps[9] * 0.153 + + imps[8] * 0.07 + + -88.052 + } + + /** + * Compute every field, or `null` if the impedances fail the library's gate. + * + * [rawWeightKg] is the weight straight off the wire; it is rounded here, as + * the device does. + * + * Note there is no sex parameter: this algorithm's body composition does not + * depend on it, only [bodyAge] does. Validation against the vendor library + * passed for both sexes with the formula below, which ignores it. + */ + fun compute(heightCm: Int, rawWeightKg: Double, imps: DoubleArray): Result? { + if (!impedancesValid(imps)) return null + + val weight = round1(rawWeightKg) + val fat = fatMass(heightCm, weight, imps) + val percent = (fat / weight * 100.0).coerceIn(BFR_MIN, BFR_MAX) + + // The clamp bounds the percentage, so recover the fat mass it implies. + val roundedFat = round1(percent / 100.0 * weight) + val ffm = weight - roundedFat + val waterMass = ffm * 0.733 + val musclePercent = round1((ffm * 0.733 + ffm * 0.2) / weight * 100.0) + val bfr = round1(percent) + + // Visceral fat truncates rather than rounds — it is an int cast. + val visceral = (ffm * -0.029 + roundedFat * 0.502 - 0.477).toInt() + .coerceIn(1, 20) + + return Result( + weightKg = weight.toFloat(), + bmi = round1(bmi(heightCm, weight)).toFloat(), + fat = bfr.toFloat(), + water = round1(waterMass / weight * 100.0).toFloat(), + musclePercent = musclePercent.toFloat(), + muscleKg = round1(musclePercent / 100.0 * weight).toFloat(), + boneKg = round1(ffm * 0.067).toFloat(), + subcutaneousFat = round1((bfr * -0.0002 + 0.72) * bfr).toFloat(), + visceralFat = visceral, + protein = round1(ffm * 0.2 / weight * 100.0).toFloat(), + skeletalMuscle = round1((waterMass * 0.834 - 2.627) / weight * 100.0).toFloat(), + bmrKcal = (ffm * 21.6 + 370.0).toInt(), + lbmKg = ffm.toFloat() + ) + } + + /** + * Metabolic age: the user's age nudged by a per-sex body-fat band. + * + * Currently unused — [com.health.openscale.core.bluetooth.data.ScaleMeasurement] + * has no field for it, so there is nowhere to publish it. `EtekcityLib` and + * `HesleyHandler` hit the same wall: one computes metabolic age and the + * other reads it off the wire, and both discard it. Kept here because it is + * part of the algorithm and is verified against the vendor library; wiring + * it up is a data-model change, not a driver one. + * + * The offsets skip zero — the healthy band steps straight from -1 to +1. + * The female band at [45, 46) returning +0 while >=46 gives +5 is not a + * transcription slip; the vendor library really does single it out. + */ + fun bodyAge(age: Int, fatPercent: Double, sex: Int): Int { + if (age < 10) return age + + val bands = if (sex == SEX_MALE) { + arrayOf(14.0 to -3, 19.0 to -2, 24.0 to -1, 27.0 to 1, + 30.0 to 2, 33.0 to 3, 36.0 to 4) + } else { + arrayOf(24.0 to -3, 28.0 to -2, 32.0 to -1, 35.0 to 1, + 38.0 to 2, 42.0 to 3, 45.0 to 4, 46.0 to 0) + } + + for ((upper, delta) in bands) { + if (fatPercent < upper) return age + delta + } + return age + 5 + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt new file mode 100644 index 000000000..265572dee --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt @@ -0,0 +1,361 @@ +/* + * openScale + * + * 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 com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.Wla25BodyComposition +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import kotlin.math.abs + +/** + * Relaxmedic body-composition scale (Fitdays app, icomon protocol version 107). + * + * Shares service 0xFFB0 and the 20-byte frame family with [RobiS9Handler] and + * [RunstarR6Handler], but differs from both in two ways that matter: + * + * - **Frames fragment.** Byte 2 is a fragment index, not a constant zero. The + * 0xA3 result spans two frames and every fragment contributes `bytes[3..18]` + * to the reassembled message — so the byte after the fragment index is + * payload, not a header byte to skip. + * - **The profile is generated, not replayed.** [RobiS9Handler] replays a + * captured handshake because its timestamp and token could not be + * regenerated. The 0xBA profile this scale wants is fully understood, so it + * is built here from the openScale user. + * + * The scale gates its own display on receiving a valid 0xBA profile, and + * **flags1 carries sex and age** — the only place they are sent. Getting that + * byte wrong is silent: the scale accepts the frame and computes for the wrong + * person. + * + * Body composition is computed locally by [Wla25BodyComposition]; the scale + * transmits weight and impedances but never its own derived values. + * + * Frame layout, both directions: + * ``` + * [seq][len][fragment][payload…][checksum] + * ``` + * `len` is the reassembled payload length including the command byte, and the + * trailing byte is `sum(bytes[3..18]) & 0x1F`. Writes with a wrong checksum are + * dropped silently. + */ +class RelaxmedicHandler : ScaleDeviceHandler() { + + private val SERVICE: UUID = uuid16(0xFFB0) + private val CHAR_WRITE: UUID = uuid16(0xFFB1) // write (profile, acks) + private val CHAR_LIVE: UUID = uuid16(0xFFB2) // notify (live A2 weight) + private val CHAR_RESULT: UUID = uuid16(0xFFB3) // indicate (A1 info, A3 result, A0) + + private var outgoingSeq = 0 + private var lastPreviewWeightKg = -1f + private var lastPublishedGrams: Int? = null + + /** Reassembly state for the current multi-fragment message. */ + private var stream = ByteArray(0) + private var expectedLength = 0 + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.lowercase(Locale.ROOT) + // Other 0xFFB0 families claim by their own names; never take theirs. + if (name.startsWith("swan") || name == "icomon" || name == "yg") return null + if (!name.startsWith("relaxmedic")) return null + + return DeviceSupport( + displayName = "Relaxmedic", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.USER_SYNC + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.USER_SYNC + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + outgoingSeq = 0 + lastPreviewWeightKg = -1f + lastPublishedGrams = null + stream = ByteArray(0) + expectedLength = 0 + + setNotifyOn(SERVICE, CHAR_LIVE) + setNotifyOn(SERVICE, CHAR_RESULT) + + sendUserProfile(user) + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (data.size != FRAME_SIZE) return + if (!isChecksumValid(data)) { + logD("Relaxmedic checksum mismatch, dropping frame") + return + } + + val message = reassemble(data) ?: return + val command = message[0].toInt() and 0xFF + val payload = message.copyOfRange(1, message.size) + + when (command) { + TYPE_LIVE_WEIGHT -> handleLiveWeight(payload) + TYPE_FINAL_RESULT -> handleFinalResult(payload, data[0].toInt() and 0xFF, user) + TYPE_DEVICE_INFO -> logD("Relaxmedic device info, ${payload.size} byte payload") + TYPE_ACK_IN -> sendAck(data[0].toInt() and 0xFF) + else -> logD("Relaxmedic unhandled command 0x${"%02X".format(command)}") + } + } + + /** + * Accumulate fragments until a whole message is present. + * + * Every fragment contributes `bytes[3..18]`; on fragment 0 the first of + * those is the command byte, so a complete message is `len + 1` bytes. + */ + private fun reassemble(frame: ByteArray): ByteArray? { + val length = frame[1].toInt() and 0xFF + val fragment = frame[2].toInt() and 0xFF + val chunk = frame.copyOfRange(3, 19) + + if (fragment == 0) { + stream = chunk + expectedLength = length + } else { + if (stream.isEmpty()) return null // continuation without a start + stream += chunk + } + + if (stream.size < expectedLength + 1) return null + val message = stream.copyOfRange(0, expectedLength + 1) + stream = ByteArray(0) + expectedLength = 0 + return message + } + + /** + * 0xA2 live weight. Payload: `[state][packed u32 BE][heart rate][mode]`. + * Never published — only the 0xA3 result is authoritative. + */ + private fun handleLiveWeight(payload: ByteArray) { + if (payload.size < 5) return + val weightKg = (u32be(payload, 1) and WEIGHT_MASK) / 1000.0f + if (abs(weightKg - lastPreviewWeightKg) >= 0.05f) { + userInfo(R.string.bluetooth_scale_info_measuring_weight, weightKg) + lastPreviewWeightKg = weightKg + } + } + + /** + * 0xA3 settled reading. + * + * Payload: a big-endian `u32` whose low 18 bits are grams, then two unused + * bytes, then the impedances as big-endian `u16` from offset 5, each in + * tenths of an ohm. They run to the end of the payload — ten of them on this + * scale, in two groups of five, each group beginning with a small value. + * + * The scale repeats this frame several times; only the first is published. + */ + private fun handleFinalResult(payload: ByteArray, seq: Int, user: ScaleUser) { + if (payload.size < 5) return + + val grams = u32be(payload, 0) and WEIGHT_MASK + if (lastPublishedGrams == grams) { + sendAck(seq) + return + } + + val imps = DoubleArray((payload.size - 5) / 2) { i -> + u16be(payload, 5 + 2 * i) / 10.0 + } + + val measurement = ScaleMeasurement().apply { + dateTime = Date() + weight = grams / 1000.0f + if (imps.isNotEmpty()) impedance = imps[0] + } + + val result = Wla25BodyComposition.compute( + heightCm = user.bodyHeight.toInt(), + rawWeightKg = measurement.weight.toDouble(), + imps = imps + ) + + if (result != null) { + measurement.apply { + weight = result.weightKg + fat = result.fat + water = result.water + muscle = result.musclePercent + bone = result.boneKg + visceralFat = result.visceralFat.toFloat() + protein = result.protein + bmr = result.bmrKcal.toFloat() + lbm = result.lbmKg + } + } else { + // The impedances failed the algorithm's validity gate; the weight is + // still good, so publish that rather than nothing. + logW("Relaxmedic impedances rejected (${imps.size} values), weight only") + } + + publish(measurement) + lastPublishedGrams = grams + sendAck(seq) + } + + /** + * 0xBA user profile. + * + * ``` + * u8 0xBA + * u32 unix time, big-endian + * u16 UTC offset in minutes; bit 0x8000 marks a negative offset + * u32 user id + * u8 height in cm + * u16 profile weight * 100 + * u8 flags1 = (sex shl 7) or (age and 0x7F) + * u8 flags2 feature bits + * u8 trailing + * ``` + * + * `flags2` and `trailing` are the values the vendor app sends and the scale + * accepts. Their bit assignments are only partly understood, so they are + * kept verbatim rather than derived. + */ + private fun sendUserProfile(user: ScaleUser) { + val heightCm = user.bodyHeight.toInt() + val age = user.age + val sexBit = if (user.gender.isMale()) 1 else 0 + val flags1 = (sexBit shl 7) or (age and 0x7F) + + val nowSeconds = System.currentTimeMillis() / 1000L + val offsetMinutes = TimeZone.getDefault() + .getOffset(System.currentTimeMillis()) / 60000 + val encodedOffset = (abs(offsetMinutes) and 0x7FFF) + .let { if (offsetMinutes < 0) it or 0x8000 else it } + + // The profile's stored weight, not a live reading; the scale only needs + // it to be plausible. + val profileWeight = (user.initialWeight.takeIf { it > 0f } ?: 60.0f) + val weightHundredths = (profileWeight * 100.0f).toInt() + + val payload = ByteArray(15) + payload[0] = TYPE_USER_PROFILE.toByte() + putU32be(payload, 1, nowSeconds) + putU16be(payload, 5, encodedOffset) + putU32be(payload, 7, 0L) // user id + payload[11] = (heightCm and 0xFF).toByte() + putU16be(payload, 12, weightHundredths) + payload[14] = (flags1 and 0xFF).toByte() + + // flags2 and trailing continue past the 15 bytes carried here; the frame + // builder zero-pads, so append them explicitly. + val full = payload + byteArrayOf(FLAGS2.toByte(), TRAILING.toByte()) + writeTo(SERVICE, CHAR_WRITE, buildFrame(full), withResponse = true) + logD("Relaxmedic profile sent: ${heightCm}cm, age $age, sex $sexBit") + } + + /** 0xB0 — acknowledge a packet the scale sent. */ + private fun sendAck(seq: Int) { + writeTo( + SERVICE, CHAR_WRITE, + buildFrame(byteArrayOf(TYPE_ACK_OUT.toByte(), (seq and 0xFF).toByte(), 0x00)), + withResponse = true + ) + } + + /** + * Wrap a message (command byte first) in a 20-byte frame. + * + * `len` counts the payload after the command byte. Only single-fragment + * writes are needed — everything this handler sends fits. + */ + private fun buildFrame(message: ByteArray): ByteArray { + val frame = ByteArray(FRAME_SIZE) + frame[0] = (outgoingSeq and 0xFF).toByte() + outgoingSeq = (outgoingSeq + 1) and 0xFF + frame[1] = ((message.size - 1) and 0xFF).toByte() + frame[2] = 0x00 + // A frame carries 16 payload bytes. The 0xBA profile is one byte longer, + // and the vendor app simply lets the trailing byte fall off -- the + // captured frame the scale accepts ends `... 95 2f 04`, with `len` still + // counting the untruncated length. Reproduced rather than corrected, + // because this is the form known to work on hardware. + val carried = minOf(message.size, FRAME_SIZE - 4) + message.copyInto(frame, 3, 0, carried) + frame[19] = computeChecksum(frame).toByte() + return frame + } + + /** `sum(bytes[3..18]) & 0x1F`. Writes failing it are dropped silently. */ + private fun computeChecksum(frame: ByteArray): Int { + var sum = 0 + for (i in 3..18) sum += frame[i].toInt() and 0xFF + return sum and 0x1F + } + + private fun isChecksumValid(frame: ByteArray): Boolean = + (frame[19].toInt() and 0xFF) == computeChecksum(frame) + + private fun u16be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + + private fun u32be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) + + private fun putU16be(data: ByteArray, offset: Int, value: Int) { + data[offset] = ((value shr 8) and 0xFF).toByte() + data[offset + 1] = (value and 0xFF).toByte() + } + + private fun putU32be(data: ByteArray, offset: Int, value: Long) { + data[offset] = ((value shr 24) and 0xFF).toByte() + data[offset + 1] = ((value shr 16) and 0xFF).toByte() + data[offset + 2] = ((value shr 8) and 0xFF).toByte() + data[offset + 3] = (value and 0xFF).toByte() + } + + companion object { + private const val FRAME_SIZE = 20 + + private const val TYPE_ACK_IN = 0xA0 + private const val TYPE_DEVICE_INFO = 0xA1 + private const val TYPE_LIVE_WEIGHT = 0xA2 + private const val TYPE_FINAL_RESULT = 0xA3 + private const val TYPE_ACK_OUT = 0xB0 + private const val TYPE_USER_PROFILE = 0xBA + + /** The packed weight word carries grams in its low 18 bits. */ + private const val WEIGHT_MASK = 0x3FFFF + + /** Feature bits and trailer, verbatim from a capture the scale accepted. */ + private const val FLAGS2 = 0x2F + private const val TRAILING = 0x0F + } +} From b6edeb4c3f3e08f9c178e4786cce641ccf66a80a Mon Sep 17 00:00:00 2001 From: joshferns Date: Fri, 7 Aug 2026 10:47:31 +0530 Subject: [PATCH 20/72] Add support for Dr. Trust Smart 505 Scale (India) (#1445) * Support 8-byte streaming frames in MGBHandler (Dr Trust Smart 505) MGBHandler previously assumed every notification was a 20-byte composite frame and dropped anything else, so scales like the Dr Trust Smart 505 (same SWAN name / 0xFFB0 service, but 8-byte streaming frames) connected and never produced a measurement. onNotification now dispatches on frame size: the existing 20-byte composite path is unchanged (only moved into onCompositeFrame), and a new 8-byte streaming path parses live/final weight and impedance frames via pure, unit-tested companion functions, latching both values before publishing one measurement and deriving body composition with StandardImpedanceLib (same approach as VitafitVT701Handler). * Inline onStreamingFrame into onNotification It had a single call site; folding it into the 8-byte branch matches VitafitVT701Handler's flatter onNotification style. No logic change. --------- Co-authored-by: Claude --- .../core/bluetooth/scales/MGBHandler.kt | 135 +++++++++++++++++- .../scales/MGBHandlerStreamingTest.kt | 93 ++++++++++++ 2 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/MGBHandlerStreamingTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MGBHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MGBHandler.kt index dc99569f8..a85909062 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MGBHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MGBHandler.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.utils.LogManager import java.util.Date @@ -45,10 +46,21 @@ import java.util.UUID * - 0xFE 06 00 * 3) Ask user to step on the scale. * - * Data frames (NOTIFY on 0xFFB2, 20 bytes): + * Two wire variants share this service/characteristic pair, distinguished by frame size: + * + * Composite frames (NOTIFY on 0xFFB2, 20 bytes): * - First part: header AC 02|03 FF … → contains weight, fat (and other fields we ignore) * - Second part: header 01 00 … → contains muscle, bone, water (+ misc) * We collect both parts to publish one complete ScaleMeasurement. + * + * Streaming frames (NOTIFY on 0xFFB2, 8 bytes, e.g. Dr Trust Smart 505): + * `AC 02 [b2] [b3] [b4] [b5] [b6] [chk]`, checksum = (b2+b3+b4+b5+b6) & 0xFF. Byte 6 is a + * type/state flag: 0xCE = live weight (settling, ignored), 0xCA = final weight (same payload as + * the preceding 0xCE, flag flipped), 0xCB = impedance, 0xCC = config echo/status (ignored). + * Weight is a big-endian u16 in b2:b3 (0.01 kg) with b4==0 && b5==0. Impedance is a big-endian + * u16 in b4:b5, only valid when b2==0xFD && b3==0x01 (0xFD 0x00 opens the block, 0xFD 0xFF marks + * impedance-unavailable; both are ignored). We latch weight and impedance as they stream in and + * publish once both are present. */ class MGBHandler : ScaleDeviceHandler() { @@ -57,9 +69,14 @@ class MGBHandler : ScaleDeviceHandler() { private val CHAR_CFG: UUID = uuid16(0xFFB1) // write private val CHAR_CTRL: UUID = uuid16(0xFFB2) // notify - // Pending measurement until 2nd frame arrives + // Pending measurement until 2nd composite frame arrives private var pending: ScaleMeasurement? = null + // Streaming-variant session state, latched until both are present + private var streamingWeightRaw: Int? = null + private var streamingImpedanceOhm: Int? = null + private var streamingPublished = false + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { val name = device.name.lowercase(Locale.ROOT) val nameMatch = name.startsWith("swan") || (name == "icomon") || (name == "yg") @@ -126,8 +143,37 @@ class MGBHandler : ScaleDeviceHandler() { // -- Notifications -- override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { if (characteristic != CHAR_CTRL) return - if (data.size != 20) return + when (data.size) { + 20 -> onCompositeFrame(data) + 8 -> { + if (streamingPublished) return + + parseFinalWeightRaw(data)?.let { raw -> + streamingWeightRaw = raw + logD("streaming final weight raw=$raw") + tryPublishStreaming(user) + return + } + + parseImpedanceOhm(data)?.let { ohm -> + streamingImpedanceOhm = ohm + logD("streaming impedance=$ohm Ω") + tryPublishStreaming(user) + } + } + else -> return + } + } + + override fun onDisconnected() { + streamingWeightRaw = null + streamingImpedanceOhm = null + streamingPublished = false + } + + // -- Composite (20-byte) variant -- + private fun onCompositeFrame(data: ByteArray) { val b0 = data[0].toUByte().toInt() val b1 = data[1].toUByte().toInt() val b2 = data[2].toUByte().toInt() @@ -144,6 +190,43 @@ class MGBHandler : ScaleDeviceHandler() { } } + // -- Streaming (8-byte) variant, e.g. Dr Trust Smart 505 -- + + private fun tryPublishStreaming(user: ScaleUser) { + if (streamingPublished) return + val weightRaw = streamingWeightRaw ?: return + val impedanceOhm = streamingImpedanceOhm ?: return + streamingPublished = true + + val weightKg = weightRaw / 100.0f + val m = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + weight = weightKg + } + + // The scale reports only weight + raw impedance; derive body composition the same way + // VitafitVT701Handler does for its weight+impedance scales. + if (impedanceOhm in 1 until 1500) { + m.impedance = impedanceOhm.toDouble() + val lib = StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = weightKg.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = impedanceOhm.toDouble(), + ) + m.fat = lib.totalFatPercentage.toFloat() + m.water = lib.totalBodyWaterPercentage.toFloat() + m.muscle = lib.skeletalMusclePercentage.toFloat() + m.bone = lib.boneMassKg.toFloat() + m.bmr = lib.basalMetabolicRate.toFloat() + } + + publish(m) + logI("streaming publish weight=$weightKg kg impedance=$impedanceOhm Ω") + } + // -- Helpers: config writer, parsers, LE readers -- /** @@ -242,4 +325,50 @@ class MGBHandler : ScaleDeviceHandler() { val v = (hi shl 8) or lo return v / 10.0f } + + companion object { + private const val FLAG_LIVE_WEIGHT = 0xCE + private const val FLAG_FINAL_WEIGHT = 0xCA + private const val FLAG_IMPEDANCE = 0xCB + + /** + * True if [frame] is a well-formed 8-byte streaming frame with a matching checksum. + * + * Layout: `AC 02 [b2] [b3] [b4] [b5] [b6] [chk]`, checksum = (b2+b3+b4+b5+b6) & 0xFF. + */ + fun isValidStreamingFrame(frame: ByteArray): Boolean { + if (frame.size != 8) return false + if ((frame[0].toInt() and 0xFF) != 0xAC) return false + if ((frame[1].toInt() and 0xFF) != 0x02) return false + val sum = (2..6).sumOf { frame[it].toInt() and 0xFF } and 0xFF + return sum == (frame[7].toInt() and 0xFF) + } + + private fun weightRaw(frame: ByteArray, flag: Int): Int? { + if (!isValidStreamingFrame(frame)) return null + if ((frame[6].toInt() and 0xFF) != flag) return null + if ((frame[4].toInt() and 0xFF) != 0 || (frame[5].toInt() and 0xFF) != 0) return null + return ((frame[2].toInt() and 0xFF) shl 8) or (frame[3].toInt() and 0xFF) + } + + /** Raw weight in 0.01 kg units from a *final* (0xCA) streaming frame, or `null`. */ + fun parseFinalWeightRaw(frame: ByteArray): Int? = weightRaw(frame, FLAG_FINAL_WEIGHT) + + /** Raw weight in 0.01 kg units from a *live/settling* (0xCE) streaming frame, or `null`. */ + fun parseLiveWeightRaw(frame: ByteArray): Int? = weightRaw(frame, FLAG_LIVE_WEIGHT) + + /** + * Whole-body impedance in ohms from a `FD 01 [hi] [lo] CB [chk]` streaming frame, or + * `null`. `FD 00 …` (block open) and `FD FF …` (impedance-unavailable) are not values; + * config writes also use 0xFD in b2 (e.g. the date write), so the flag byte (0xCB, not + * 0xCC) is what distinguishes an impedance frame from a config echo. + */ + fun parseImpedanceOhm(frame: ByteArray): Int? { + if (!isValidStreamingFrame(frame)) return null + if ((frame[6].toInt() and 0xFF) != FLAG_IMPEDANCE) return null + if ((frame[2].toInt() and 0xFF) != 0xFD) return null + if ((frame[3].toInt() and 0xFF) != 0x01) return null + return ((frame[4].toInt() and 0xFF) shl 8) or (frame[5].toInt() and 0xFF) + } + } } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/MGBHandlerStreamingTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/MGBHandlerStreamingTest.kt new file mode 100644 index 000000000..a418e85fe --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/MGBHandlerStreamingTest.kt @@ -0,0 +1,93 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [MGBHandler]'s 8-byte streaming frame parsers (e.g. Dr Trust Smart 505). + * + * Vectors are taken from the decoded protocol notes; checksums were hand-verified against + * `chk = (b2 + b3 + b4 + b5 + b6) & 0xFF`. + */ +class MGBHandlerStreamingTest { + + private fun hex(s: String): ByteArray = + s.filterNot { it.isWhitespace() }.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + @Test + fun `extracts 99_10 kg from the final weight frame`() { + val raw = MGBHandler.parseFinalWeightRaw(hex("AC 02 26 B6 00 00 CA A6")) + assertThat(raw).isEqualTo(9910) + assertThat(raw!! / 100.0f).isWithin(1e-3f).of(99.10f) + } + + @Test + fun `live frames are not read as final weight`() { + // Same payload as the final-weight frame, flag still 0xCE (settling). + assertThat(MGBHandler.parseFinalWeightRaw(hex("AC 02 26 B6 00 00 CE AA"))) + .isNull() + } + + @Test + fun `live frames are readable via parseLiveWeightRaw`() { + val raw = MGBHandler.parseLiveWeightRaw(hex("AC 02 26 B6 00 00 CE AA")) + assertThat(raw).isEqualTo(9910) + } + + @Test + fun `extracts 600 ohm from the impedance frame`() { + assertThat(MGBHandler.parseImpedanceOhm(hex("AC 02 FD 01 02 58 CB 23"))) + .isEqualTo(600) + } + + @Test + fun `FD 00 block-open frame is not an impedance value`() { + assertThat(MGBHandler.parseImpedanceOhm(hex("AC 02 FD 00 00 00 CB C8"))) + .isNull() + } + + @Test + fun `FD FF impedance-unavailable frame is not an impedance value`() { + assertThat(MGBHandler.parseImpedanceOhm(hex("AC 02 FD FF 00 00 CB C7"))) + .isNull() + } + + @Test + fun `date-write config echo is not confused with impedance`() { + // Same FD-in-b2 shape as impedance frames, but flag is 0xCC (config), not 0xCB. + assertThat(MGBHandler.parseImpedanceOhm(hex("AC 02 FD 1A 07 18 CC 02"))) + .isNull() + } + + @Test + fun `rejects a frame with a bad checksum`() { + assertThat(MGBHandler.isValidStreamingFrame(hex("AC 02 26 B6 00 00 CA FF"))) + .isFalse() + assertThat(MGBHandler.parseFinalWeightRaw(hex("AC 02 26 B6 00 00 CA FF"))) + .isNull() + } + + @Test + fun `does not accept a 20-byte composite frame as a streaming frame`() { + assertThat(MGBHandler.isValidStreamingFrame(ByteArray(20))).isFalse() + assertThat(MGBHandler.parseFinalWeightRaw(ByteArray(20))).isNull() + assertThat(MGBHandler.parseImpedanceOhm(ByteArray(20))).isNull() + } +} From c96840481dcafaacc05485b8417a8790a89dfe4a Mon Sep 17 00:00:00 2001 From: leko Date: Fri, 7 Aug 2026 13:59:28 +0800 Subject: [PATCH 21/72] Complete Traditional Chinese translation (#1439) * Add Keep S3 scale support * Revert addition of extended measurement metrics and database version 16 This change removes support for several recently added measurement types and reverts the database schema to version 15. Key changes include: * **Measurement Types:** Removed `PHASE_ANGLE`, `SKELETAL_MUSCLE`, `LEAN_SOFT_TISSUE`, `SUBCUTANEOUS_FAT`, `BODY_AGE`, `BMI_22_REFERENCE_WEIGHT`, and `DEVICE_IMPEDANCE` from `MeasurementTypeKey` and `ScaleMeasurement`. * **Database:** Reverted `AppDatabase` version to 15, deleted `MIGRATION_15_16`, and removed the version 16 JSON schema. * **Units & Strings:** Removed `UnitType.DEGREE` and associated string resources and localization logic. * **Logic Cleanup:** * Removed generalized percentage-to-mass unit conversion logic in `ConverterUtils` and `MeasurementTypeCrudUseCases`. * Updated `KeepS3Handler` to stop publishing extended metrics. * Removed `awaitPendingOperations` from the BLE transport interface. * Add muscle percentage mapping in KeepS3Handler * Fix Keep S3 runtime behavior * Complete Traditional Chinese translation --------- Co-authored-by: oliexdev --- .../src/main/res/values-zh-rTW/strings.xml | 834 ++++++++++++++++-- 1 file changed, 777 insertions(+), 57 deletions(-) diff --git a/android_app/app/src/main/res/values-zh-rTW/strings.xml b/android_app/app/src/main/res/values-zh-rTW/strings.xml index e0e94614e..13fa648ae 100644 --- a/android_app/app/src/main/res/values-zh-rTW/strings.xml +++ b/android_app/app/src/main/res/values-zh-rTW/strings.xml @@ -1,59 +1,779 @@ - + - 應用程式圖示 - 亮色 - 暗色 - 通用使用者介面元素和操作 - 取消 - 確認 - - - 打開連結 - 警告圖示 - 恢復圖示 - 資訊 - 打開選單 - 返回 - 已選取 - 計算導出值中… - 裝置數值已成功更新。 - 通用錯誤訊息。 - 數據加載失敗。 - 請重試。 - 無可用數據訊息。 - 未找到測量數據。 - 沒有可用的項目。 - 概述 - 圖表 - 表格 - 數據統計 - 設定 - 新測量 - 編輯測量 - 關於 - 編輯類型 - 增加類型 - 測量類型 - 編輯使用者 - 新增使用者 - 使用者 - 管理用戶 - 切換用戶。已選擇:%1$s - 已選使用者指示器 - 增加使用者 - 用戶選擇失敗。 - 未選擇使用者。 - 請先選擇使用者。 - 無活躍使用者 - 請建立新使用者或選擇現有使用者以查看和管理您的測量數據。 - 使用者 %1$s 已成功刪除。 - 刪除使用者 %1$s 時發生錯誤。 - 使用者 %1$s 已成功更新。 - 更新使用者 %1$s 時發生錯誤。 - 名稱 - 高度 - 性別 - 男性 - 女性 - + 上次變更尚未同步。請開啟 openScale-sync 並手動執行同步。 + 開啟 + 應用程式標誌 + + 不適用 + - + 淺色 + 深色 + 啟用 + 取消 + 清除 + 確認 + 確定 + 已儲存 + 開啟 + 關閉 + 開啟連結 + 警告圖示 + 還原圖示 + 資訊 + 開啟選單 + 返回 + 已選取 + 正在計算衍生值… + 已成功更新衍生值。 + 發生錯誤。 + 無法載入資料。 + 請再試一次。 + 沒有可用的資料。 + 找不到測量紀錄。 + 沒有可用的項目。 + 總覽 + 圖表 + 表格 + 統計 + 分析 + 設定 + 詳細資料 + 週次 + 新增測量紀錄 + 編輯測量紀錄 + 關於 + 編輯類型 + 新增類型 + 測量類型 + 編輯使用者 + 新增使用者 + 使用者 + 管理使用者 + 切換使用者。目前選取:%1$s + 所選使用者指示圖示 + 新增使用者 + 無法選取使用者。 + 尚未選取使用者。 + 請先選取使用者。 + 沒有使用中的使用者 + 請建立新使用者或選取現有使用者,以查看及管理測量紀錄。 + 已成功刪除使用者 %1$s。 + 刪除使用者 %1$s 時發生錯誤。 + 已成功更新使用者 %1$s。 + 已成功新增使用者 %1$s。 + 更新使用者 %1$s 時發生錯誤。 + 名稱 + 身高 + 性別 + 男性 + 女性 + 截肢校正 + + 手部 + 前臂 + 整隻手臂 + 足部 + 小腿 + 整條腿 + 請選取受影響的肢體,並指定各肢體的截肢程度。 + 左臂 + 右臂 + 左腿 + 右腿 + 活動程度 + 輔助秤重 + 選取 %1$s 的參照對象 + 沒有合適的參照對象。請建立一位未使用輔助秤重的使用者。 + 使用輔助秤重時必須選取參照對象。 + 出生日期 + 請輸入有效資料 + %.1f 公分 + 年齡:%1$d,性別:%2$s + 編輯使用者 + 刪除使用者 + 新增使用者 + 變更單位 + 已更新測量紀錄(%1$s) + 已儲存測量紀錄(%1$s) + 無法儲存測量紀錄,請再試一次。 + 未儲存——%1$s 已有一筆測量紀錄。 + 已刪除測量紀錄(%1$s) + 無法刪除測量紀錄,請再試一次。 + 復原 + 拖曳以調整圖表大小,或輕觸以收合圖表 + 輕觸以顯示圖表 + 捨棄變更? + 變更尚未儲存,捨棄後將會遺失。 + 捨棄 + 繼續編輯 + 儲存測量紀錄 + 新增測量紀錄 + 新增測量紀錄 + 編輯 %1$s 的測量紀錄 + 刪除 %1$s 的測量紀錄 + 此測量時間與上一筆測量紀錄相同,請選擇其他時間。 + 尚無測量紀錄 + 新增第一筆測量紀錄,開始追蹤進度。 + 此項目沒有啟用中的測量值。 + %1$s 的數值「%2$s」無效 + %1$s 的整數「%2$s」無效 + %1$s 的數值無效 + %1$s 的整數無效 + 請輸入有效資料。 + 編輯%1$s + 變更%1$s + 選擇顏色 + 選擇圖示 + 輸入數值 + 輸入文字 + 小時 + 分鐘 + 日期 + 時間 + 增加%1$s + 減少%1$s + 編輯%1$s + 顯示較少 + 顯示更多 + 數值上升 + 數值下降 + 數值穩定或沒有變化 + %1$s 的圖示 + 未知測量類型的圖示:%1$s + 未知類型:%1$s + 體重 + BMI + 體脂肪 + 體水分 + 肌肉量 + 除脂體重 + 骨量 + 腰圍 + 腰臀比 + 腰高比 + 臀圍 + 內臟脂肪 + 胸圍 + 大腿圍 + 上臂圍 + 頸圍 + 皮脂夾 1 + 皮脂夾 2 + 皮脂夾 3 + 皮脂夾體脂 + 基礎代謝率 + 心率 + 阻抗(高頻) + 阻抗(低頻) + 細胞外水分 + 細胞內水分 + 蛋白質 + 身體細胞質量 + 骨量公式 + 阻抗迴歸(舊版) + Heymsfield(人體測量法) + 基礎代謝率公式 + Cunningham 1991 + Cunningham 1980 + 每日總消耗熱量 + 熱量 + 備註 + 日期 + 時間 + 使用者 + 自訂類型 + 已啟用 + 名稱 + 顏色 + 圖示 + 單位 + 目標 + 目標日期 + 選擇日期 + 我的目標 + %d 個目標 + 沒有目標 + 刪除 + 編輯目標:%1$s + 設定目標:%1$s + 沒有可設定目標的類型。 + 選擇類型 + %1$s 的目標值 + 新增目標時,目標值不得留空。 + 請先選擇測量類型。 + 已刪除目標 + 輸入類型 + 已釘選 + 位於右側 Y 軸 + 已選圖示預覽 + 排序 + 編輯 + 刪除 + 已成功新增測量類型「%1$s」。 + 新增測量類型「%1$s」時發生錯誤。 + 已成功刪除測量類型「%1$s」。 + 刪除測量類型「%1$s」時發生錯誤。 + 更新測量類型「%1$s」時發生錯誤。 + 已成功更新測量類型「%1$s」。 + 已更新類型「%1$s」,並成功轉換 %2$s 個數值。 + 轉換類型「%1$s」的數值時發生錯誤,類型定義未更新。 + 已更新類型「%1$s」。單位已變更,但沒有既有數值需要轉換。 + 確認變更單位 + 將「%1$s」的單位從 %2$s 變更為 %3$s,會轉換所有既有資料點。此作業可能需要一些時間,且難以復原。要繼續嗎? + 體脂肪公式 + 體水分公式 + 除脂體重公式 + 公式計算 + 此指標將設為唯讀並自動計算,基礎數值變更時也會更新。此設定適用於新增或編輯的測量紀錄,既有紀錄不受影響。 + 關閉 + Deurenberg(1991) + Deurenberg(1992) + Eddy 等人(1976) + Gallagher(2000)— 非亞洲族群 + Gallagher(2000)— 亞洲族群 + Behnke 等人(1963) + Delwaide & Crenier(1973) + Hume & Weyers(1971) + Lee、Song & Kim(2001) + Boer(1984) + Hume(1966) + 體重 − 體脂肪量 + 美國海軍法 + 不自動計算 + 此指標不會自動計算,您可以手動輸入數值。 + 成人經典迴歸公式 + 廣泛使用的 1991 年族群公式,適合一般成人;對體脂極低或肌肉非常發達者可能較不準確。 + 針對青少年與成人調整 + 針對較年輕使用者調整的版本,適合一般用途;運動員或非典型體型可能會有偏差。 + 早期性別專用模型 + 較早期的迴歸公式,男性與女性採用不同係數。簡單快速,但未針對特殊族群調整。 + Gallagher(非亞洲族群) + 2000 年針對非亞洲族群建立的參考模型,可作為實用基準;準確度會因體型而異。 + Gallagher(亞洲族群) + 針對亞洲族群調整的配套模型。使用者背景較符合此族群時可選用。 + Behnke 總體水量估算 + 經典的總體水量估算方法。計算簡單,但未針對運動員或極端情況個別調整。 + Delwaide & Crenier + 著重體重的總體水量公式,適合作為一般估算;精確度會因體型而異。 + Hume–Weyers 總體水量 + 常見的總體水量模型,使用身高與體重,並依性別採用不同係數。 + Lee–Song–Kim + 較新的總體水量估算公式,以韓國受試族群校準。使用者背景符合該族群時可考慮使用。 + Boer 除脂體重(臨床) + 臨床常用的除脂體重估算,依身高與體重並針對性別進行迴歸計算。 + Hume 除脂體重 + 早期的除脂體重模型,簡單且廣泛使用,但不特別適合肌肉非常發達的體型。 + 體重 − 體脂肪量 + 以總體重減去所選體脂肪公式算出的脂肪量,計算除脂體重。準確度取決於體脂肪估算結果。 + 美國海軍法 + 依照美國海軍標準,根據腰圍、頸圍及女性的臀圍計算體脂率。 + %1$d:%2$s + 確認%1$s? + 確定要對 %1$d 個項目執行「%2$s」嗎? + 釘選 + 取消釘選 + 啟用 + 停用 + 移至左側座標軸 + 移至右側座標軸 + 已釘選 + 已取消釘選 + 已啟用 + 已停用 + 已移至右側座標軸 + 已移至左側座標軸 + 無法連線至體重計,請確認體重計已開啟。 + 已儲存的體重計 + 正在處理藍牙作業 + 設定藍牙體重計 + 中斷與 %1$s 的連線 + 重新嘗試連線至 %1$s + 藍牙發生錯誤,請檢查設定 + 連線至 %1$s + 檢查藍牙設定 + 正在連線至 %1$s… + 正在中斷與 %1$s 的連線… + 正在處理 %1$s… + 尚未儲存藍牙體重計,請在設定中新增體重計。 + 正在重新嘗試連線至 %1$s… + 藍牙發生錯誤,請檢查設定。 + 正在嘗試連線至 %1$s… + 請檢查藍牙設定。 + 請啟用藍牙以開始掃描。 + 掃描需要藍牙權限。 + 藍牙已啟用,但仍缺少必要權限。 + 必須啟用藍牙才能搜尋體重計。 + 已儲存的體重計: + 未知 + 停止掃描 + 搜尋體重計 + 搜尋體重計按鈕 + 找到的裝置: + 已將「%1$s」儲存為偏好的體重計。 + 目前不支援 %1$s。 + 找不到裝置,請開始新的掃描。 + 已儲存體重計指示圖示 + 支援的裝置 + 不支援的裝置 + 支援 + 不支援 + %1$d dBm + 儲存為偏好裝置 + 以開發人員模式儲存 + 已移除儲存的裝置。 + 已啟用開發人員模式。請啟用記錄檔:設定 → 一般 → 啟用記錄檔。 + 已為此裝置啟用開發人員模式。請啟用記錄檔:設定 → 一般 → 啟用記錄檔。 + 開發人員模式已啟用——不會儲存任何測量紀錄。 + 請啟用記錄檔:設定 → 一般 → 啟用記錄檔。 + 功能 + 身體組成 + 時間同步 + 使用者同步 + 讀取歷史紀錄 + 即時體重 + 單位設定 + 電池 + 已實作:%1$s + 支援但尚未實作:%1$s + 更多選項 + %1$s 設定 + 藍牙調校 + 調校設定檔 + 藍牙測量 + 智慧指派使用者 + 容許差值 + 忽略不確定的測量紀錄 + 若測量值超出所有使用者的容許範圍,便不會儲存。 + 裝置操作 + 開發人員模式 + 移除儲存的裝置 + 藍牙調校設定檔 + 藍牙調校:%1$s + 平衡 + 保守 + 積極 + 自動將測量紀錄指派給使用者時,允許的最大體重差值。 + 開發人員 + 危險區域 + 啟動時自動連線 + 體重計設定 + 此裝置沒有其他特殊設定。 + 身體組成演算法 + Xiaomi(原廠應用程式) + 科學公式 + 選擇如何根據阻抗推算身體組成。「Xiaomi」會重現原廠 Mi Fit/Zepp Life 應用程式的結果;「科學公式」使用經同儕審查的公式(Siri、Pace、Wang、Schofield),並額外提供蛋白質與基礎代謝率。此設定適用於這台體重計之後新增的測量紀錄。 + BLE 綁定金鑰 + 32 字元十六進位金鑰 + Xiaomi S400 體重計需要 BLE 綁定金鑰才能解密。請使用 Xiaomi Cloud Tokens Extractor 工具,從 Xiaomi Cloud 擷取此金鑰。 + 綁定金鑰必須正好是 32 個十六進位字元 + S400:尚未設定綁定金鑰。請前往藍牙設定輸入 BLE 金鑰。 + 已連線至 %1$s + 無法連線至 %1$s:%2$s + %1$s 發生錯誤:%2$s + 不支援 %1$s。 + 找不到 %1$s 的驅動程式,或發生內部錯誤。 + 無法將來自 %1$s 的測量紀錄指派給使用者。 + 錯誤:尚未載入測量類型。 + 未從 %1$s 收到有效的測量值。 + 已儲存來自 %2$s 的測量紀錄(%1$.1f 公斤)。 + 儲存來自 %1$s 的測量紀錄時發生錯誤。 + %1$s:%2$s + 正在等待 %1$s… + 已收到來自 %1$s 的測量紀錄。 + 需要權限圖示 + 需要權限 + 需要藍牙存取權限才能搜尋體重計。 + 授予權限 + 藍牙已停用圖示 + 藍牙已停用 + 請啟用藍牙以搜尋體重計。 + 啟用藍牙 + 錯誤圖示 + 重要通知 + openScale 是開放原始碼專案,我並未擁有每一款體重計。由於藍牙通訊協定可能變更或沒有公開文件,因此無法保證所有情況都能相容。\n\n歡迎透過 Pull Request 或其他方式貢獻,協助這個開放原始碼專案改善體重計支援! + 我已了解並儲存 + 前往專案網站 + 正在初始化… + 連線中斷 + 已中斷連線 + 找不到裝置。%s + 未預期的錯誤:%s + 收到未預期的資料類型:%s + 體重計訊息(ID:%1$d,值:%2$s) + 需要選取使用者 + 使用者 #%1$d(年齡:%2$d,身高:%3$d) + 需要使用者 %1$d(索引 %2$d)的同意。 + 驅動程式傳回未知狀態或訊息:%s + 介面卡正在處理其他裝置(%s)。 + 啟動與 %1$s 的連線程序時發生錯誤:%2$s + 尚未連線,無法要求測量。 + 測量應會自動開始(舊版驅動程式)。 + 錯誤:缺少選取使用者所需的資料。 + 錯誤:收到的同意資料無效。 + 錯誤:缺少取得同意所需的資料。 + 錯誤:收到的使用者互動資料無效。 + 時間範圍圖示 + 篩選圖表資料 + 已選取%s + 測量篩選器已顯示 + 測量篩選器已隱藏 + %1$s(%2$d 筆) + 所有日期 + 最近 7 天 + 最近 30 天 + 最近 365 天 + 自訂天數 + %1$s 至 %2$s + 時間範圍 + 彙整 + 已選取%1$s + 不彙整 + + + + + 沒有可繪製成圖表的類型。 + 沒有可顯示的資料。 + 所選範圍內沒有 %1$s 的資料。 + 此類型 + 請選擇要顯示在圖表中的測量類型。 + 沒有可顯示的資料或可選擇的類型。 + 所選類型沒有可用資料。 + 顯示/隱藏測量篩選器 + 日期 + 尚未選取欄位,或沒有可用的測量紀錄。 + 尚未選取要顯示的欄位。 + 目前選取的欄位沒有可用資料。 + 呈上升趨勢 + 呈下降趨勢 + 選取項目 + 已選取 %1$d 個項目 + 取消選取 + 變更所選項目的使用者 + 匯出所選項目 + 刪除所選項目 + 刪除使用者 %1$s? + 這位使用者的所有資料將永久刪除,且無法復原。 + 刪除 %1$s 的目標? + 這會移除目標,但不會影響您的測量歷史紀錄。 + 刪除測量紀錄? + 確定要刪除 %1$s(%2$s)的測量紀錄嗎?此操作無法復原。 + 刪除項目? + 確定要刪除所選項目嗎?此操作無法復原。 + 確定要刪除所選的 %1$d 個項目嗎?此操作無法復原。 + 刪除類型? + 確定要刪除自訂類型「%1$s」嗎?所有相關測量紀錄也會永久刪除,且無法復原。 + 已刪除 %1$d 筆測量紀錄 + %2$d 筆測量紀錄中有 %1$d 筆無法刪除。 + 選擇要指派的使用者 + 沒有其他可變更的使用者。 + 已移動 %1$d 筆測量紀錄 + %2$d 筆測量紀錄中有 %1$d 筆無法移動(該使用者已有相同紀錄)。 + 資料有限 + 您的%1$s分析 + 體態重組 + 增肌 + 混合減重 + 脂肪增加 + 至少需要 %1$d 筆測量紀錄,才能分析身體隨時間的變化。 + 每個星期幾至少需要 %1$d 筆測量紀錄,才能找出每週習慣。 + 至少需要 %1$d 年的資料,才能找出季節性模式。 + 至少需要 %1$d 筆測量紀錄,才能偵測異常數值。 + 最佳月份 + 上升 + 下降 + 穩定 + 每月變化率 + 波動程度 + 穩定 + 中等 + + 穩定期 + %1$d 天 + 您的每週習慣 + 您的測量值通常在%1$s最高,在%2$s最低 + 目前只是初步模式——請更規律地測量,以獲得可靠結果 + 您的年度回顧 + 您的數值通常在%1$s達到高峰,在%2$s降到低點 + 目前只有一年的資料——請持續測量,以找出季節性模式 + 異常測量紀錄 + 未偵測到異常測量紀錄。 + 與預期值 %2$s 相差 %1$s + 「%1$s」 + 已連續 %1$d 天沒有明顯變化——您的身體可能正在適應。 + 近期呈下降趨勢——相較於長期走向是正向變化。 + 近期呈上升趨勢——請留意長期走向。 + 偵測到高度波動——這很常見,也可能反映水分滯留等自然身體變化。 + 您的數值非常一致——是可靠的資料基礎。 + 您的身體組成 + 最近 %2$d 天內至少需要 %1$d 筆同時包含四項指標(體重、體脂肪、肌肉量、體水分)的測量紀錄,才能計算這項洞察。 + 體重與脂肪都在下降,而肌肉量維持不變甚至略有增加——您主要減少的是脂肪量。 + 您的身體正在有效重組:減少脂肪的同時,也明顯增加肌肉量。 + 肌肉量增加,而體脂肪維持穩定或下降——這是正向的身體組成趨勢。 + 提醒:您的體重正在下降,但肌肉量也同時減少。 + 脂肪量正在增加。若這不是刻意的增肌增重期,建議檢視您的活動量。 + 脂肪與肌肉都在增加——看起來正處於增肌增重期。若增加肌肉是您的目標,請確認脂肪增加幅度是否在預期範圍內。 + 所有指標都很穩定——您正處於維持期。 + 尚未偵測到明確模式——需要更多包含體重、體脂肪、肌肉量及體水分的測量紀錄。 + %1$s 期間的訊號不一致——體重、脂肪與肌肉趨勢尚未形成明確模式。 + 脂肪減少而肌肉增加——這是最理想的結果。 + 脂肪與肌肉都在增加。 + 脂肪與肌肉都在減少。體重雖然下降,但肌肉量也在流失。 + 脂肪增加,而肌肉減少或維持不變——這是最不理想的結果。 + 開始 + 現在 + 每月變化率 + 平均 + 穩定 + 中等 + 波動大 + 穩定 %1$d 天 + 短期 + 長期 + 沒有可用或已設定用於統計的相關測量類型。 + 最小 + 最大 + 平均 + 增加 + 減少 + 沒有變化 + 一般 + 使用者 + 測量類型 + 藍牙 + 圖表 + 資料管理 + 關於 + 一般設定 + 語言 + 提醒 + 提醒功能 + 提醒文字 + 在此輸入提醒文字 + 時間 + 已啟用提醒 + 已停用提醒 + 該量體重了 + 提醒 + openScale + 星期 + 通知權限遭拒 + 外觀 + 動態色彩 + 高對比 + 週一 + 週二 + 週三 + 週四 + 週五 + 週六 + 週日 + 全部 + 回饋 + 新增測量紀錄時震動 + 已啟用測量紀錄震動 + 已停用測量紀錄震動 + 顯示資料點 + 顯示目標線 + 平滑演算法 + Alpha + 視窗大小 + 平滑處理的最大間隔(天) + 不進行平滑處理 + 移動平均 + 指數平滑 + 顯示預測 + 預測依據(天) + 預測期間(天) + 預測模型 + 線性 + 二次 + 三次 + 版本:%1$s(%2$s) + 專案資訊 + 維護者 + 維護者圖示 + GitHub 上的專案 + 官方專案頁面 + 首頁圖示 + 軟體授權詳細資料 + 授權圖示 + 診斷 + 檔案記錄圖示 + 檔案記錄 + 啟用檔案記錄? + 啟用後,應用程式會將詳細記錄儲存在您的裝置上,以協助疑難排解。檔案中可能包含敏感資訊,請只分享給您信任的人。 + 啟用記錄後會立即開始新的記錄工作階段,建立新的記錄檔並刪除舊檔。 + 已啟用檔案記錄 + 已停用檔案記錄 + 已成功匯出記錄檔。 + 匯出記錄檔時發生錯誤。 + 找不到可匯出的記錄檔。 + 沒有可匯出的記錄檔。 + 已取消匯出。 + 找不到可匯出檔案的應用程式。 + 匯出記錄檔 + 匯出測量紀錄(CSV) + 匯入測量紀錄(CSV) + 備份資料庫 + 還原資料庫 + 刪除所有測量資料 + 刪除整個資料庫 + 危險區域 + 未知錯誤 + 自動備份 + 啟用自動備份 + 切換自動備份 + 上次備份狀態 + 備份狀態資訊 + 備份位置 + 備份位置設定 + 開啟備份位置 + 變更備份位置 + 備份間隔 + 備份間隔設定 + 變更備份間隔 + 備份方式 + 備份方式設定 + 上次備份:%1$s + 自動備份已停用 + 一律建立新的備份檔 + 覆寫現有備份檔 + 預設:應用程式專用資料夾 + 模擬:使用者已選擇資料夾 + 選擇備份間隔 + 尚未設定備份位置 + 尚未設定位置,已暫停自動備份。 + 存取備份位置時發生錯誤 + 刪除圖示 + 選擇備份目錄 + 選擇備份位置 + 已選擇的資料夾 + 找不到可開啟此資料夾的應用程式。 + 無法開啟備份位置。 + 備份位置已設為:%1$s + 已取消選擇資料夾,因此未啟用自動備份。 + 上次備份:%1$s + 上次備份:從未 + 每天 + 每週 + 每月 + 匯出:選擇使用者 + 匯入:選擇使用者 + 刪除:選擇使用者 + 要永久刪除嗎? + 注意!要刪除整個資料庫嗎? + 還原資料庫? + 確認刪除資料庫 + 確定要永久刪除使用者「%1$s」的所有測量資料嗎?此操作無法復原。 + 是,全部刪除 + 這將永久刪除此應用程式的所有資料,包括所有使用者、設定與測量資料。此操作無法復原。您確定要繼續嗎?\n\n完成後需要重新啟動應用程式。 + 確定要用備份覆寫目前的資料庫嗎?目前所有尚未儲存的資料都會遺失。還原後可能需要重新啟動應用程式。 + 是,還原 + 確定要刪除整個資料庫嗎?這會移除所有使用者及所有資料,且無法復原。 + 已成功匯出資料。 + 無法匯出:尚未定義要匯出的特定資料欄位。 + 這位使用者沒有可匯出的測量紀錄。 + 找不到這位使用者可匯出的測量值。 + 匯出失敗:無法建立檔案。 + 匯出時發生錯誤:%1$s + 沒有可供匯出的使用者。 + 已成功匯入 %1$d 筆測量紀錄。 + 已成功匯入 %1$d 筆紀錄%2$s。 + 已成功匯入 %1$d 筆測量紀錄。 + 已忽略 %1$d 列(時間戳記重複)。 + 已略過 %1$d 列(缺少日期)。 + 已略過 %1$d 列(日期解析錯誤)。 + 有 %1$d 個個別數值無法解析。 + CSV 檔案中找不到有效資料,或所有列都包含錯誤。 + 匯入失敗:CSV 標頭缺少必要的「date」欄位。 + 匯入失敗:無法讀取檔案。 + 匯入時發生錯誤:%1$s + 沒有可供匯入的使用者。 + 沒有可刪除的使用者資料。 + 已刪除 %1$s 的所有測量資料。 + 找不到 %1$s 的測量資料。 + 刪除 %1$s 的資料時發生錯誤。 + 無法開始刪除程序:尚未選取使用者。 + 錯誤:無法取得資料庫名稱。 + 錯誤:找不到主要資料庫檔案「%1$s」。 + 備份失敗:無法在所選位置建立備份檔。 + 備份資料庫時發生錯誤:%1$s + 已成功備份資料庫。 + 還原失敗:無法讀取備份檔。 + 還原失敗:備份檔不是有效的 ZIP 壓縮檔,或檔案已損毀。 + 還原失敗:備份中找不到必要的資料庫檔案。 + 還原資料庫時發生錯誤:%1$s + 已成功還原資料庫,請重新啟動應用程式。 + 已刪除整個資料庫。 + 刪除整個資料庫時發生錯誤。 + 無法評估:測量當時找不到相符的年齡區間。 + 數值異常:超出合理範圍(%1$.0f–%2$.0f%%)。 + 此體重計尚未配對!\n\n按住體重計底部的按鈕以切換至配對模式,然後重新連線以取得裝置密碼。 + 配對成功!\n\n請重新連線以取得測量資料。 + 在體重計上建立新使用者。 + 電量偏低(%d%%),請充電或更換體重計電池 + 請赤腳站上體重計以進行參照測量 + 正在測量體重:%.2f + 已達體重計可同時使用的使用者人數上限 + 讀取及寫入 openScale 資料,包括使用者資訊與所有已儲存的測量紀錄 + 讀寫 openScale 資料 + 選擇體重計上的使用者 + 從清單中選擇您的使用者,或建立新使用者。 + 選擇使用者 + 輸入確認碼 + 請輸入體重計上顯示的代碼。 + 輸入代碼 + 確認碼 + 使用者清單是空的或已損毀。 + 錯誤:無法正確載入使用者資料。 + 請選擇使用者。 + 請輸入有效的代碼。 + 已儲存 %1$d 筆測量紀錄 + 請赤腳站上體重計 + 使用現有對應:應用程式使用者 %1$d ↔ 體重計位置 %2$d。 + 請輸入位置 %1$d 的確認碼。 + 沒有可用的確認碼。可以讀取測量紀錄,但無法變更使用者設定。 + 若要使用完整功能,請在體重計上建立新使用者。 + 正在體重計上建立新使用者… + 找不到使用者對應,正在體重計上建立新使用者… + 正在等候透過通知傳來的測量資料。 + 已將應用程式使用者 %1$d 連結至體重計位置 %2$d。 + 體重計電量偏低(%1$d%%)。 + 無法在體重計上建立使用者(代碼 %1$d)。 + 體重計沒有可用的使用者位置。請在裝置上刪除一位使用者後再試一次。 + 無法啟用 %1$s 的通知。 + 寫入 %1$s 失敗。 + 變更 %1$s 的通知狀態失敗。 + 確認碼未獲接受,請檢查後再試一次。 + 確認碼未獲接受,請檢查後再試一次。 + 傳送使用者回應時發生錯誤:%1$s + 正在重新連線…(第 %1$d/%2$d 次) + 正在等候透過通知傳來的測量資料。 + 沒有可供 setNotifyOn(%1$s) 使用的周邊裝置 + 找不到特徵值 %1$s + 沒有可供 write(%1$s) 使用的周邊裝置 + 沒有可供 read(%1$s) 使用的周邊裝置 + 寫入 %1$s 失敗:%2$s + %1$s 的通知狀態設定失敗:%2$s + 尚未選取使用者 + 錯誤 + 處理常式連線失敗:%1$s + 處理常式解析 %1$s 時發生錯誤:%2$s + 此裝置未偵測到藍牙介面卡。 + 找不到裝置。請確認裝置已開啟且在連線範圍內。 + 正在使用使用者 %1$s 與體重計位置 %2$s 的現有對應,並嘗試自動確認。 + PIN 格式無效,請輸入有效的數字。 + 無法在體重計上註冊使用者。錯誤:%1$s + 無法在體重計上確認使用者。錯誤:%1$s + 無法從體重計取得使用者清單。錯誤:%1$s + 使用者設定失敗,無法從體重計取得使用者清單。 + 在體重計上建立新使用者(由 UDS 指派位置) + 位置 P%1$d:%2$s(目前的應用程式使用者) + 位置 P%1$d:由 %2$s 使用(選取以接管?) + 位置 P%1$d:已占用(未對應) + P%1$d:%2$s(目前) + P%1$d:由 %2$s 使用(接管?) + P%1$d:已占用(未連結) + P%1$d:在此建立「%2$s」 From 10d3a2b0c20848b05d58116884b352c32ddf1198 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 7 Aug 2026 09:32:25 +0200 Subject: [PATCH 22/72] Improved auto-backup reliability and status reporting - Switched to `ExistingPeriodicWorkPolicy.UPDATE` to prevent backup postponement on reboots or settings changes - Added tracking and display for the next scheduled backup time in settings - Added detailed error reporting and specific failure states (location inaccessible, file creation, or write failures) - Removed flex interval from `PeriodicWorkRequest` to ensure more regular scheduling - Improved folder picker behavior by launching at the currently configured location - Added unit tests for backup error state persistence --- .../com/health/openscale/core/data/Enums.kt | 22 +++++ .../core/facade/DataManagementFacade.kt | 4 + .../openscale/core/facade/SettingsFacade.kt | 49 +++++++++ .../core/usecase/AutoBackupUseCases.kt | 46 +++++++-- .../openscale/core/worker/BackupWorker.kt | 20 +++- .../settings/DataManagementSettingsScreen.kt | 99 +++++++++++++++---- .../ui/screen/settings/SettingsViewModel.kt | 13 +++ .../app/src/main/res/values/strings.xml | 8 +- .../openscale/core/worker/BackupWorkerTest.kt | 46 +++++++-- 9 files changed, 270 insertions(+), 37 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index ccc894e25..f081f5fb1 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -588,6 +588,28 @@ enum class BackupInterval { } } +/** + * Reason why the last automatic backup attempt failed. + * + * Persisted by name, so entries must not be renamed without a migration. + */ +enum class AutoBackupError { + /** The configured folder is gone, or the persisted URI permission was revoked. */ + LOCATION_INACCESSIBLE, + /** The folder is writable, but the backup file itself could not be created. */ + FILE_CREATION_FAILED, + /** Writing the database into the backup file failed. */ + WRITE_FAILED; + + fun getDisplayName(context: Context): String { + return when (this) { + LOCATION_INACCESSIBLE -> context.getString(R.string.settings_backup_error_location_inaccessible) + FILE_CREATION_FAILED -> context.getString(R.string.settings_backup_error_file_creation_failed) + WRITE_FAILED -> context.getString(R.string.settings_backup_error_write_failed) + } + } +} + enum class EvaluationState { LOW, NORMAL, diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/DataManagementFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/DataManagementFacade.kt index 7e933950e..89f3e6cd7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/DataManagementFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/DataManagementFacade.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.facade import android.content.ContentResolver import android.net.Uri +import com.health.openscale.core.data.AutoBackupError import com.health.openscale.core.data.BackupInterval import com.health.openscale.core.usecase.AutoBackupUseCases import com.health.openscale.core.usecase.BackupRestoreUseCases @@ -52,6 +53,9 @@ class DataManagementFacade @Inject constructor( val autoBackupInterval: Flow get() = autoBackup.interval val isAutoBackupNewFileMode: Flow get() = autoBackup.createNewFile val autoBackupLastSuccessTimestamp: Flow get() = autoBackup.lastSuccessfulTimestamp + val autoBackupLastError: Flow get() = autoBackup.lastError + val autoBackupLastErrorTimestamp: Flow get() = autoBackup.lastErrorTimestamp + val autoBackupNextRunAtMillis: Flow get() = autoBackup.nextBackupAtMillis // Mutations suspend fun setAutoBackupEnabled(enabled: Boolean) = diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt index 61789a824..8560b77b2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt @@ -32,6 +32,7 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.health.openscale.core.data.AutoBackupError import com.health.openscale.core.data.BackupInterval import com.health.openscale.core.data.BodyFatFormulaOption import com.health.openscale.core.data.BodyWaterFormulaOption @@ -110,6 +111,8 @@ object SettingsPreferenceKeys { val AUTO_BACKUP_INTERVAL = stringPreferencesKey("auto_backup_interval") val AUTO_BACKUP_CREATE_NEW_FILE = booleanPreferencesKey("auto_backup_create_new_file") val AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP = longPreferencesKey("auto_backup_last_successful_timestamp") + val AUTO_BACKUP_LAST_ERROR = stringPreferencesKey("auto_backup_last_error") + val AUTO_BACKUP_LAST_ERROR_TIMESTAMP = longPreferencesKey("auto_backup_last_error_timestamp") // --- Reminder Settings --- val REMINDER_ENABLED = booleanPreferencesKey("reminder_enabled") @@ -252,6 +255,11 @@ interface SettingsFacade { val autoBackupLastSuccessfulTimestamp: Flow suspend fun setAutoBackupLastSuccessfulTimestamp(timestamp: Long) + /** Reason of the most recent failed backup attempt, or null if the last attempt succeeded. */ + val autoBackupLastError: Flow + val autoBackupLastErrorTimestamp: Flow + suspend fun setAutoBackupLastError(error: AutoBackupError?) + // --- Reminder Settings --- val reminderEnabled: Flow suspend fun setReminderEnabled(enabled: Boolean) @@ -868,6 +876,47 @@ class SettingsFacadeImpl @Inject constructor( saveSetting(SettingsPreferenceKeys.AUTO_BACKUP_LAST_SUCCESSFUL_TIMESTAMP.name, timestamp) } + override val autoBackupLastError: Flow = dataStore.data + .catch { exception -> + LogManager.e(TAG, "Error reading autoBackupLastError from DataStore.", exception) + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + val errorName = preferences[SettingsPreferenceKeys.AUTO_BACKUP_LAST_ERROR] + try { + errorName?.let { AutoBackupError.valueOf(it) } + } catch (e: IllegalArgumentException) { + LogManager.w(TAG, "Invalid AutoBackupError name '$errorName' in DataStore.", e) + AutoBackupError.WRITE_FAILED + } + } + .distinctUntilChanged() + + override val autoBackupLastErrorTimestamp: Flow = observeSetting( + SettingsPreferenceKeys.AUTO_BACKUP_LAST_ERROR_TIMESTAMP.name, + 0L + ).catch { exception -> + LogManager.e(TAG, "Error observing autoBackupLastErrorTimestamp", exception) + emit(0L) + } + + override suspend fun setAutoBackupLastError(error: AutoBackupError?) { + LogManager.d(TAG, "Setting autoBackupLastError to: ${error?.name ?: "none"}") + dataStore.edit { preferences -> + if (error != null) { + preferences[SettingsPreferenceKeys.AUTO_BACKUP_LAST_ERROR] = error.name + preferences[SettingsPreferenceKeys.AUTO_BACKUP_LAST_ERROR_TIMESTAMP] = System.currentTimeMillis() + } else { + preferences.remove(SettingsPreferenceKeys.AUTO_BACKUP_LAST_ERROR) + preferences.remove(SettingsPreferenceKeys.AUTO_BACKUP_LAST_ERROR_TIMESTAMP) + } + } + } + // --- Reminder Settings --- override val reminderEnabled: Flow = observeSetting( SettingsPreferenceKeys.REMINDER_ENABLED.name, diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/AutoBackupUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/AutoBackupUseCases.kt index 6ad4a6e3b..2bc32714e 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/AutoBackupUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/AutoBackupUseCases.kt @@ -23,13 +23,16 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import com.health.openscale.core.data.AutoBackupError import com.health.openscale.core.data.BackupInterval import com.health.openscale.core.facade.SettingsFacade import com.health.openscale.core.utils.LogManager import com.health.openscale.core.worker.BackupWorker import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton import java.util.concurrent.TimeUnit @@ -57,6 +60,24 @@ class AutoBackupUseCases @Inject constructor( val interval: Flow get() = settings.autoBackupInterval val createNewFile: Flow get() = settings.autoBackupCreateNewFile val lastSuccessfulTimestamp: Flow get() = settings.autoBackupLastSuccessfulTimestamp + val lastError: Flow get() = settings.autoBackupLastError + val lastErrorTimestamp: Flow get() = settings.autoBackupLastErrorTimestamp + + /** + * When the periodic worker is due next, or null if nothing is queued — this lets the UI + * distinguish "not scheduled at all" from "scheduled, just not due yet", which otherwise + * both look like "Last backup: Never". + * + * WorkManager reports [Long.MAX_VALUE] whenever no next run is known, which also covers the + * short window while a backup is actually running. + */ + val nextBackupAtMillis: Flow + get() = WorkManager.getInstance(appContext) + .getWorkInfosForUniqueWorkFlow(BackupWorker.WORK_NAME) + .map { infos -> + infos.firstOrNull()?.nextScheduleTimeMillis?.takeIf { it != Long.MAX_VALUE } + } + .distinctUntilChanged() // --- Mutations --- @@ -101,30 +122,37 @@ class AutoBackupUseCases @Inject constructor( return } - val (repeatMillis, flexMillis) = intervalMillis(chosen) + val repeatMillis = intervalMillis(chosen) val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.NOT_REQUIRED) .setRequiresStorageNotLow(true) .build() + // No flex window on purpose: WorkManager applies flex only to the *first* run of a + // periodic worker (see WorkSpec.calculateNextRunTime), so it would merely postpone the + // very first backup by (interval - flex) without making later runs any more regular. val req = PeriodicWorkRequestBuilder( - repeatMillis, TimeUnit.MILLISECONDS, - flexMillis, TimeUnit.MILLISECONDS + repeatMillis, TimeUnit.MILLISECONDS ).setConstraints(constraints) .addTag(BackupWorker.TAG) .build() + // UPDATE instead of REPLACE: REPLACE discards the elapsed period, so every settings + // change and every reboot (BootReceiver calls us) restarted the interval from zero and + // the backup could be postponed indefinitely. UPDATE keeps lastEnqueueTime/periodCount, + // still enqueues the work if it is missing or cancelled, and lets a running backup + // finish instead of cancelling it mid-write. wm.enqueueUniquePeriodicWork( BackupWorker.WORK_NAME, - ExistingPeriodicWorkPolicy.REPLACE, + ExistingPeriodicWorkPolicy.UPDATE, req ) - LogManager.i(TAG, "Auto-backup scheduled: interval=$chosen repeat=${repeatMillis}ms flex=${flexMillis}ms") + LogManager.i(TAG, "Auto-backup scheduled: interval=$chosen repeat=${repeatMillis}ms") } - private fun intervalMillis(interval: BackupInterval): Pair = when (interval) { - BackupInterval.DAILY -> 24L * 60 * 60 * 1000L to (24L * 60 * 60 * 1000L / 8) - BackupInterval.WEEKLY -> 7L * 24 * 60 * 60 * 1000L to (7L * 24 * 60 * 60 * 1000L / 8) - BackupInterval.MONTHLY -> 30L * 24 * 60 * 60 * 1000L to (30L * 24 * 60 * 60 * 1000L / 8) + private fun intervalMillis(interval: BackupInterval): Long = when (interval) { + BackupInterval.DAILY -> 24L * 60 * 60 * 1000L + BackupInterval.WEEKLY -> 7L * 24 * 60 * 60 * 1000L + BackupInterval.MONTHLY -> 30L * 24 * 60 * 60 * 1000L } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/worker/BackupWorker.kt b/android_app/app/src/main/java/com/health/openscale/core/worker/BackupWorker.kt index 6f3831561..98089767d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/worker/BackupWorker.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/worker/BackupWorker.kt @@ -23,6 +23,7 @@ import androidx.documentfile.provider.DocumentFile import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.health.openscale.core.data.AutoBackupError import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.facade.SettingsFacade import com.health.openscale.core.utils.LogManager @@ -66,7 +67,7 @@ class BackupWorker @AssistedInject constructor( if (parentDocumentFile == null || !parentDocumentFile.canWrite()) { LogManager.e(TAG, "Cannot write to backup location: $locationUriString. Permissions might be lost or URI invalid.") - settingsFacade.setAutoBackupLastSuccessfulTimestamp(0L) + settingsFacade.setAutoBackupLastError(AutoBackupError.LOCATION_INACCESSIBLE) return Result.failure() } @@ -83,6 +84,7 @@ class BackupWorker @AssistedInject constructor( } else { if (!backupDocumentFile.delete()) { LogManager.e(TAG, "Could not delete existing file $finalFileName for overwrite.") + settingsFacade.setAutoBackupLastError(AutoBackupError.FILE_CREATION_FAILED) return Result.failure() } backupDocumentFile = null @@ -95,13 +97,18 @@ class BackupWorker @AssistedInject constructor( if (backupDocumentFile == null) { LogManager.e(TAG, "Could not create backup file: $finalFileName in $locationUriString") + settingsFacade.setAutoBackupLastError(AutoBackupError.FILE_CREATION_FAILED) return Result.failure() } applicationContext.contentResolver.openOutputStream(backupDocumentFile.uri)?.use { outputStream -> ZipOutputStream(outputStream).use { zipOutputStream -> val dbFile = applicationContext.getDatabasePath(dbName) - val dbDir = dbFile.parentFile ?: return Result.failure() + val dbDir = dbFile.parentFile ?: run { + LogManager.e(TAG, "Database directory not resolvable for $dbName") + settingsFacade.setAutoBackupLastError(AutoBackupError.WRITE_FAILED) + return Result.failure() + } val filesToBackup = listOfNotNull( dbFile, @@ -120,15 +127,20 @@ class BackupWorker @AssistedInject constructor( } } } - } ?: return Result.failure() + } ?: run { + LogManager.e(TAG, "Could not open output stream for ${backupDocumentFile.uri}") + settingsFacade.setAutoBackupLastError(AutoBackupError.WRITE_FAILED) + return Result.failure() + } LogManager.i(TAG, "Automatic backup successful to: ${backupDocumentFile.uri}") settingsFacade.setAutoBackupLastSuccessfulTimestamp(System.currentTimeMillis()) + settingsFacade.setAutoBackupLastError(null) return Result.success() } catch (e: Exception) { LogManager.e(TAG, "Error during automatic backup", e) - settingsFacade.setAutoBackupLastSuccessfulTimestamp(0L) + settingsFacade.setAutoBackupLastError(AutoBackupError.WRITE_FAILED) return Result.failure() } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt index 9653a3fa6..b82bc4ab9 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/DataManagementSettingsScreen.kt @@ -20,6 +20,7 @@ package com.health.openscale.ui.screen.settings import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri +import android.provider.DocumentsContract import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -97,6 +98,22 @@ import com.health.openscale.core.data.GenderType import com.health.openscale.core.utils.CalculationUtils +/** + * Turns the persisted SAF *tree* URI into the *document* URI that pickers and file managers + * expect. Handing them the bare tree URI makes them ignore it and fall back to their default + * folder (usually Downloads) instead of the configured backup location. + * + * Returns null if [treeUriString] is absent or not a tree URI, so callers can fall back. + */ +private fun backupLocationDocumentUri(treeUriString: String?): Uri? { + val treeUri = treeUriString?.toUri() ?: return null + return try { + DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri)) + } catch (_: IllegalArgumentException) { + null + } +} + /** * Represents items in the data management settings list. */ @@ -154,8 +171,15 @@ fun DataManagementSettingsScreen( val autoBackupInterval by settingsViewModel.autoBackupInterval.collectAsState() val autoBackupCreateNewFile by settingsViewModel.autoBackupCreateNewFile.collectAsState() val autoBackupLastSuccessfulTimestamp by settingsViewModel.autoBackupLastSuccessfulTimestamp.collectAsState() + val autoBackupLastError by settingsViewModel.autoBackupLastError.collectAsState() + val autoBackupLastErrorTimestamp by settingsViewModel.autoBackupLastErrorTimestamp.collectAsState() + val autoBackupNextRunAt by settingsViewModel.autoBackupNextRunAtMillis.collectAsState() val isAutoBackupLocationConfigured = autoBackupLocationUriString != null + // An error only counts as current as long as no later attempt succeeded. + val hasCurrentBackupError = autoBackupLastError != null && + autoBackupLastErrorTimestamp >= autoBackupLastSuccessfulTimestamp + // Effective state: global switch is on AND a location is configured. val isAutoBackupEffectivelyEnabled by remember(autoBackupGloballyEnabled, isAutoBackupLocationConfigured) { mutableStateOf(autoBackupGloballyEnabled && isAutoBackupLocationConfigured) @@ -167,22 +191,32 @@ fun DataManagementSettingsScreen( autoBackupLocationUriString, autoBackupGloballyEnabled, autoBackupLastSuccessfulTimestamp, + autoBackupLastError, + autoBackupLastErrorTimestamp, context ) { + val dateFormat = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + Locale.getDefault() + ) mutableStateOf( if (isAutoBackupEffectivelyEnabled) { - if (autoBackupLastSuccessfulTimestamp > 0L) { - val timestamp = autoBackupLastSuccessfulTimestamp - val date = Date(timestamp) - val dateFormat = DateFormat.getDateTimeInstance( - DateFormat.MEDIUM, - DateFormat.SHORT, - Locale.getDefault() - ) - val formattedTime = dateFormat.format(date) - resources.getString(R.string.settings_last_backup_status_successful, formattedTime) - } else { - resources.getString(R.string.settings_last_backup_status_never) + val error = autoBackupLastError + when { + error != null && hasCurrentBackupError -> { + val formattedTime = dateFormat.format(Date(autoBackupLastErrorTimestamp)) + resources.getString( + R.string.settings_last_backup_status_failed, + formattedTime, + error.getDisplayName(context) + ) + } + autoBackupLastSuccessfulTimestamp > 0L -> { + val formattedTime = dateFormat.format(Date(autoBackupLastSuccessfulTimestamp)) + resources.getString(R.string.settings_last_backup_status_successful, formattedTime) + } + else -> resources.getString(R.string.settings_last_backup_status_never) } } else if (autoBackupGloballyEnabled && !isAutoBackupLocationConfigured) { resources.getString(R.string.settings_backup_location_not_configured_for_auto) @@ -192,6 +226,24 @@ fun DataManagementSettingsScreen( ) } + // Null while auto-backup is off — there is no next run to report then. + val nextBackupStatusText by remember(isAutoBackupEffectivelyEnabled, autoBackupNextRunAt, context) { + val dateFormat = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + Locale.getDefault() + ) + mutableStateOf( + if (!isAutoBackupEffectivelyEnabled) { + null + } else { + autoBackupNextRunAt?.let { + resources.getString(R.string.settings_next_backup_scheduled, dateFormat.format(Date(it))) + } ?: resources.getString(R.string.settings_next_backup_not_scheduled) + } + ) + } + val selectedBackupIntervalDisplay = remember(autoBackupInterval, context) { autoBackupInterval.getDisplayName(context) } @@ -400,7 +452,8 @@ fun DataManagementSettingsScreen( supportingText = currentBackupLocationUserDisplay, onClick = { if (!isAnyOperationLoading) { - selectAutoBackupDirectoryLauncher.launch(null) // Allow changing/re-selecting + // Start the picker in the folder currently configured, not at its default. + selectAutoBackupDirectoryLauncher.launch(backupLocationDocumentUri(autoBackupLocationUriString)) } }, enabled = !isAnyOperationLoading, @@ -412,8 +465,10 @@ fun DataManagementSettingsScreen( onClick = { if (!isAnyOperationLoading) { try { + val folderUri = backupLocationDocumentUri(autoBackupLocationUriString) + ?: autoBackupLocationUriString!!.toUri() val intent = Intent(Intent.ACTION_VIEW) - intent.setDataAndType(autoBackupLocationUriString!!.toUri(), "vnd.android.document/directory") + intent.setDataAndType(folderUri, "vnd.android.document/directory") intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) context.startActivity(intent) } catch (_: ActivityNotFoundException) { @@ -431,7 +486,7 @@ fun DataManagementSettingsScreen( IconButton( onClick = { if (!isAnyOperationLoading) { - selectAutoBackupDirectoryLauncher.launch(null) + selectAutoBackupDirectoryLauncher.launch(backupLocationDocumentUri(autoBackupLocationUriString)) } }, enabled = !isAnyOperationLoading @@ -449,10 +504,20 @@ fun DataManagementSettingsScreen( item { SettingsCardItem( label = stringResource(R.string.settings_last_backup_status_label), - supportingText = lastBackupStatusText, + supportingText = listOfNotNull(lastBackupStatusText, nextBackupStatusText).joinToString("\n"), onClick = { /* Could show more details or trigger a manual sync if needed */ }, enabled = !isAnyOperationLoading, - customLeadingContent = { Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.content_desc_backup_status_icon)) } + customLeadingContent = { + if (hasCurrentBackupError) { + Icon( + Icons.Filled.WarningAmber, + contentDescription = stringResource(R.string.content_desc_backup_status_icon), + tint = MaterialTheme.colorScheme.error + ) + } else { + Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.content_desc_backup_status_icon)) + } + } ) } item { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt index b5df6cb32..9eb963e4e 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.SnackbarDuration import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.health.openscale.R +import com.health.openscale.core.data.AutoBackupError import com.health.openscale.core.data.BackupInterval import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.User @@ -212,6 +213,18 @@ class SettingsViewModel @Inject constructor( val autoBackupLastSuccessfulTimestamp = dataManagementFacade.autoBackupLastSuccessTimestamp.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L) + val autoBackupLastError: StateFlow = + dataManagementFacade.autoBackupLastError.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val autoBackupLastErrorTimestamp = + dataManagementFacade.autoBackupLastErrorTimestamp.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L) + + /** When the next automatic backup is due, or null if none is queued. */ + val autoBackupNextRunAtMillis: StateFlow = + dataManagementFacade.autoBackupNextRunAtMillis.stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5_000), null + ) + fun setAutoBackupEnabled(enabled: Boolean) { viewModelScope.launch { dataManagementFacade.setAutoBackupEnabled(enabled) } } diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 7adea8155..886d05eeb 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -718,7 +718,7 @@ Automatic Backups Enable automatic backups Toggle automatic backups - Last backup status + Backup status Backup status information Backup location Backup location setting @@ -749,6 +749,12 @@ Folder selection cancelled. Automatic backup not enabled. Last backup: %1$s Last backup: Never + Last backup failed on %1$s: %2$s + Next backup: %1$s + Next backup: not scheduled + The backup folder is no longer accessible. Please select it again. + The backup file could not be created in the selected folder. + The database could not be written to the backup file. Daily Weekly Monthly diff --git a/android_app/app/src/test/java/com/health/openscale/core/worker/BackupWorkerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/worker/BackupWorkerTest.kt index cd23a8c7d..46e59e4ce 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/worker/BackupWorkerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/worker/BackupWorkerTest.kt @@ -25,12 +25,14 @@ import androidx.work.WorkerFactory import androidx.work.WorkerParameters import androidx.work.testing.TestListenableWorkerBuilder import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.AutoBackupError import com.health.openscale.core.database.AppDatabase import com.health.openscale.core.facade.SettingsFacadeImpl import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Test @@ -58,15 +60,46 @@ class BackupWorkerTest { @Test fun doWork_returnsSuccess_whenAutoBackupDisabled() = runBlocking { - db = RoomTestSupport.inMemory(context) - val repository = RoomTestSupport.repositoryFor(db) + val settings = newSettings() // auto-backup defaults to disabled + + val result = newWorker(settings).doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + } + + /** + * A failing attempt must record why it failed and must not touch the last *successful* + * timestamp — otherwise the UI degrades to "Last backup: Never" and the real cause is lost. + */ + @Test + fun doWork_recordsError_andKeepsLastSuccess_whenLocationNotWritable() = runBlocking { + val settings = newSettings() + settings.setAutoBackupEnabledGlobally(true) + settings.setAutoBackupLocationUri( + "content://com.android.externalstorage.documents/tree/primary%3Adoes-not-exist" + ) + settings.setAutoBackupLastSuccessfulTimestamp(PREVIOUS_SUCCESS) + + val result = newWorker(settings).doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.failure()) + assertThat(settings.autoBackupLastError.first()).isEqualTo(AutoBackupError.LOCATION_INACCESSIBLE) + assertThat(settings.autoBackupLastErrorTimestamp.first()).isGreaterThan(0L) + assertThat(settings.autoBackupLastSuccessfulTimestamp.first()).isEqualTo(PREVIOUS_SUCCESS) + } + + private fun newSettings(): SettingsFacadeImpl { val dataStore = PreferenceDataStoreFactory.create( scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), produceFile = { File(context.cacheDir, "bw-${System.nanoTime()}.preferences_pb") }, ) - val settings = SettingsFacadeImpl(dataStore) // auto-backup defaults to disabled + return SettingsFacadeImpl(dataStore) + } - val worker = TestListenableWorkerBuilder(context) + private fun newWorker(settings: SettingsFacadeImpl): BackupWorker { + db = RoomTestSupport.inMemory(context) + val repository = RoomTestSupport.repositoryFor(db) + return TestListenableWorkerBuilder(context) .setWorkerFactory(object : WorkerFactory() { override fun createWorker( appContext: Context, @@ -75,8 +108,9 @@ class BackupWorkerTest { ): ListenableWorker = BackupWorker(appContext, workerParameters, settings, repository) }) .build() + } - val result = worker.doWork() - assertThat(result).isEqualTo(ListenableWorker.Result.success()) + private companion object { + const val PREVIOUS_SUCCESS = 1_700_000_000_000L } } From f49ebe3a7a4c98ee8e52291fd1e89691ccb3ba19 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 7 Aug 2026 14:13:48 +0200 Subject: [PATCH 23/72] Fix measurement type filter selection on fresh installs --- .../openscale/core/database/AppDatabase.kt | 7 +++-- .../ui/screen/components/MeasurementChart.kt | 29 ++++++++++++------- .../components/MeasurementTypeFilterRow.kt | 18 +++++++----- .../openscale/ui/screen/table/TableScreen.kt | 21 +++++++------- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt index 5eec264ee..304abe315 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt @@ -567,9 +567,10 @@ val MIGRATION_14_15 = object : Migration(14, 15) { ) // Seed the six new MeasurementTypes introduced for S400 dual-frequency - // body composition: IMPEDANCE, IMPEDANCE_LOW (raw band readings) and - // ECW, ICW, PROTEIN, BCM (derived). All disabled by default; new - // installs receive them via getDefaultMeasurementTypes(). + // body composition: IMPEDANCE, IMPEDANCE_LOW (raw band readings, kept + // internal) and ECW, ICW, PROTEIN, BCM (enabled). Flags are taken from + // getDefaultMeasurementTypes(), so upgraded installs end up with the + // same defaults as fresh ones. val newKeys = setOf( MeasurementTypeKey.IMPEDANCE, MeasurementTypeKey.IMPEDANCE_LOW, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt index 04c45ee37..dc0f87d9c 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt @@ -147,18 +147,24 @@ fun MeasurementChart( val allAvailableMeasurementTypes by sharedViewModel.measurementTypes.collectAsState() - val defaultSelectedTypesValue = remember(targetMeasurementTypeId) { + // Resolved via MeasurementTypeKey, never via MeasurementTypeKey.id: the row id of a + // MeasurementType is auto-generated on insert and does not match the enum id. + val defaultSelectedTypesValue = remember(targetMeasurementTypeId, allAvailableMeasurementTypes) { if (targetMeasurementTypeId != null) { setOf(targetMeasurementTypeId.toString()) } else { - setOf( - MeasurementTypeKey.WEIGHT.id.toString(), - MeasurementTypeKey.BMI.id.toString(), - MeasurementTypeKey.BODY_FAT.id.toString(), - MeasurementTypeKey.WATER.id.toString(), - MeasurementTypeKey.MUSCLE.id.toString(), - MeasurementTypeKey.COMMENT.id.toString(), + val defaultKeys = setOf( + MeasurementTypeKey.WEIGHT, + MeasurementTypeKey.BMI, + MeasurementTypeKey.BODY_FAT, + MeasurementTypeKey.WATER, + MeasurementTypeKey.MUSCLE, + MeasurementTypeKey.COMMENT, ) + allAvailableMeasurementTypes + .filter { it.key in defaultKeys } + .map { it.id.toString() } + .toSet() } } @@ -359,9 +365,10 @@ fun MeasurementChart( .find { it.id == targetMeasurementTypeId } ?.let { listOf(it.id) } ?: emptyList() } else { - val defaults = listOf(MeasurementTypeKey.WEIGHT.id, MeasurementTypeKey.BODY_FAT.id) - defaults - .filter { id -> selectableFilteredTypes.any { it.id == id } } + val defaultKeys = setOf(MeasurementTypeKey.WEIGHT, MeasurementTypeKey.BODY_FAT) + selectableFilteredTypes + .filter { it.key in defaultKeys } + .map { it.id } .ifEmpty { selectableFilteredTypes.firstOrNull()?.let { listOf(it.id) } ?: emptyList() } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementTypeFilterRow.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementTypeFilterRow.kt index ce9d2919d..38be20ffd 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementTypeFilterRow.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementTypeFilterRow.kt @@ -92,8 +92,14 @@ fun MeasurementTypeFilterRow( val context = LocalContext.current val scope = rememberCoroutineScope() - val selectableTypes = remember(allMeasurementTypesProvider, filterLogic) { - filterLogic(allMeasurementTypesProvider()) + // The provider is invoked during composition so that its state read is tracked here. + // Keying the remember on the lambda itself would never invalidate: the Compose compiler + // memoizes it, so it stays the same instance while the list behind it changes. The type + // list arrives asynchronously (empty on the very first frame after a fresh install), and + // an unkeyed selectableTypes would stay empty for the whole lifetime of the composition. + val allMeasurementTypes = allMeasurementTypesProvider() + val selectableTypes = remember(allMeasurementTypes, filterLogic) { + filterLogic(allMeasurementTypes) } val selectedTypeIdsFlow = remember(selectedTypeIdsFlowProvider) { selectedTypeIdsFlowProvider() } @@ -156,18 +162,14 @@ fun MeasurementTypeFilterRow( } // Effect 2: React to changes from the Flow AFTER initialization. - LaunchedEffect(isInitialized, selectedTypeIdsFlow, allMeasurementTypesProvider, filterLogic) { + LaunchedEffect(isInitialized, selectedTypeIdsFlow, selectableTypes) { if (isInitialized) { selectedTypeIdsFlow .distinctUntilChanged() .collect { newPersistedSet -> - // Recalculate selectable types in case they changed externally - val currentAllTypes = allMeasurementTypesProvider() - val currentAvailableTypesForFilter = filterLogic(currentAllTypes) - val newIdsFromFlow = newPersistedSet .mapNotNull { it.toIntOrNull() } - .filter { id -> currentAvailableTypesForFilter.any { type -> type.id == id } } + .filter { id -> selectableTypes.any { type -> type.id == id } } if (newIdsFromFlow.toSet() != displayedSelectedIds.toSet()) { displayedSelectedIds = newIdsFromFlow diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt index 94ec2a1b5..3cb0e818a 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt @@ -691,17 +691,18 @@ fun TableScreen( } }, defaultSelectionLogic = { availableFilteredTypes -> + // Matched on MeasurementTypeKey, not on MeasurementTypeKey.id: the row id of a + // MeasurementType is auto-generated on insert and does not match the enum id. + val defaultKeys = setOf( + MeasurementTypeKey.WEIGHT, + MeasurementTypeKey.BMI, + MeasurementTypeKey.BODY_FAT, + MeasurementTypeKey.WATER, + MeasurementTypeKey.MUSCLE, + MeasurementTypeKey.COMMENT, + ) availableFilteredTypes - .filter { - it.id in listOf( - MeasurementTypeKey.WEIGHT.id, - MeasurementTypeKey.BMI.id, - MeasurementTypeKey.BODY_FAT.id, - MeasurementTypeKey.WATER.id, - MeasurementTypeKey.MUSCLE.id, - MeasurementTypeKey.COMMENT.id, - ) && it.isEnabled - } + .filter { it.key in defaultKeys && it.isEnabled } .map { it.id } }, onSelectionChanged = { newSelectedIds -> From e4af22f11dce3f0533118177028d0598ad38f1f8 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 9 Aug 2026 13:21:37 +0200 Subject: [PATCH 24/72] Name the sending openScale flavor in the sync intents. * Added a "package" extra in `startSyncService()`, so all five triggers carry it and a future one cannot forget it. --- .../java/com/health/openscale/core/usecase/SyncUseCases.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt index d8d4d5cea..261080919 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt @@ -203,6 +203,13 @@ class SyncUseCases @Inject constructor( */ private fun startSyncService(pkgName: String, intent: Intent) { if (!isSyncAppInstalled(pkgName)) return + + // Name ourselves on every op. Several openScale flavors can sit on one device and they all + // address the same sync app, but a measurement id only means something within one install — + // so the sync app has to be able to drop ops from the variant it is not synced with. Added + // here rather than in each trigger, so a future one cannot forget it. + intent.putExtra("package", application.packageName) + try { ContextCompat.startForegroundService(application.applicationContext, intent) } catch (e: Exception) { From 1f42b45948ba066cb2e365b64a2768580ba25486 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 9 Aug 2026 13:31:41 +0200 Subject: [PATCH 25/72] Remove orphaned translations for deleted strings --- android_app/app/src/main/res/values-bg/strings.xml | 7 ------- android_app/app/src/main/res/values-es/strings.xml | 4 ---- android_app/app/src/main/res/values-fi/strings.xml | 2 -- android_app/app/src/main/res/values-fr/strings.xml | 2 -- android_app/app/src/main/res/values-it/strings.xml | 2 -- android_app/app/src/main/res/values-ja/strings.xml | 2 -- android_app/app/src/main/res/values-pt-rBR/strings.xml | 5 ----- android_app/app/src/main/res/values-pt/strings.xml | 2 -- android_app/app/src/main/res/values-ro/strings.xml | 2 -- android_app/app/src/main/res/values-ru/strings.xml | 2 -- android_app/app/src/main/res/values-sl/strings.xml | 7 ------- android_app/app/src/main/res/values-sv/strings.xml | 2 -- android_app/app/src/main/res/values-zh-rCN/strings.xml | 2 -- 13 files changed, 41 deletions(-) diff --git a/android_app/app/src/main/res/values-bg/strings.xml b/android_app/app/src/main/res/values-bg/strings.xml index f394d7fd4..87c0c2ccf 100644 --- a/android_app/app/src/main/res/values-bg/strings.xml +++ b/android_app/app/src/main/res/values-bg/strings.xml @@ -343,7 +343,6 @@ Опасна зона Избрана директория Режимът за програмисти е активиран за това устройство. Моля, активирайте лог файла: Настройки → Общи → Активиране на лог файла. - Грешка при изтриването на елементи. Режимът за програмисти е активен — няма да се запазват никакви измервания. Моля, активирайте лог файла: Настройки → Общи → Активиране на лог файла. Състав на тялото @@ -471,12 +470,8 @@ Изберете потребител за задачата Няма други потребители за смяна. %1$d измервания са преместени - Грешка при промяната на потребителя за някои елементи. - Твоя %1$s анализ - базирано на %1$d измервания краткосрочна тенденция дългосрочна тенденция - Нужни са поне %1$d измервания за анализ на промените в тялото с течение на времето. Необходими са поне %1$d измервания на ден за откриване на седмични навици. Необходими са поне %1$d години данни, за да се открият сезонни модели. Необходими са поне %1$d измервания за откриване на необичайни стойности. @@ -490,7 +485,6 @@ Висока Стабилен период %1$d дни - Най-добър месец: %1$s %2$d (%3$s) Седмични навици Има тенденция за високи измервания %1$s и ниски %2$s Предварителен модел — измервайте по-редовно за надеждни резултати @@ -693,7 +687,6 @@ Избор на интервал на резервното копие Създаване на нов потребител в кантара (слот, зададен от UDS) Анализи - Все още няма достатъчно данни за изчисляване на прозрения. Продължете да измервате! Филтриране на данните за диаграми Няма налични типове диаграми за начертаване. Моля, изберете видове измервания за диаграмата. diff --git a/android_app/app/src/main/res/values-es/strings.xml b/android_app/app/src/main/res/values-es/strings.xml index 75b5675db..7d818812b 100644 --- a/android_app/app/src/main/res/values-es/strings.xml +++ b/android_app/app/src/main/res/values-es/strings.xml @@ -412,11 +412,9 @@ ¿Eliminar tipo? ¿Seguro que quieres eliminar el tipo personalizado “%1$s”? Todas las mediciones asociadas también se eliminarán permanentemente. Esta acción no se puede deshacer. %1$d mediciones eliminadas - Error al eliminar los elementos. Seleccionar usuario para la asignación No hay otros usuarios disponibles para cambiar. %1$d mediciones movidas - Error al cambiar el usuario de algunos elementos. No hay tipos de medición relevantes disponibles o configurados para las estadísticas. Mín Max @@ -689,12 +687,10 @@ Alto contraste Análisis Datos insuficientes - Tu análisis de %1$s Recomposición corporal Volumen Pérdida mixta Aumento de grasa - Se necesitan al menos %1$d mediciones para analizar cómo ha cambiado tu cuerpo con el tiempo. Se necesitan al menos %1$d mediciones por día de la semana para detectar hábitos semanales. Se necesitan al menos %1$d años de datos para detectar patrones estacionales. Se necesitan al menos %1$d mediciones para detectar valores inusuales. diff --git a/android_app/app/src/main/res/values-fi/strings.xml b/android_app/app/src/main/res/values-fi/strings.xml index 99dacb605..738ae44be 100644 --- a/android_app/app/src/main/res/values-fi/strings.xml +++ b/android_app/app/src/main/res/values-fi/strings.xml @@ -376,11 +376,9 @@ Poista tyyppi? Haluatko varmasti poistaa mukautetun tyypin “%1$s”? Kaikki siihen liittyvät mittaukset poistetaan myös pysyvästi. Tätä toimintoa ei voi peruuttaa. %1$d kohteet poistettu onnistuneesti. - Virhe kohteiden poistamisessa. Valitse käyttäjä tehtävää varten Ei muita käyttäjiä, joihin voi vaihtaa. Käyttäjä on vaihdettu %1$d kohteille onnistuneesti. - Virhe käyttäjän vaihtamisessa joissakin kohteissa. Tilastoja varten ei ole käytettävissä tai määritettyjä relevantteja mittaustyyppejä. min. maks. diff --git a/android_app/app/src/main/res/values-fr/strings.xml b/android_app/app/src/main/res/values-fr/strings.xml index 411fbec83..03fbe1548 100644 --- a/android_app/app/src/main/res/values-fr/strings.xml +++ b/android_app/app/src/main/res/values-fr/strings.xml @@ -337,7 +337,6 @@ Suppression de type ? Voulez-vous vraiment supprimer le type personnalisé \"%1$s\" ? Toutes les données associées seront définitivement supprimées. Cette action ne peut pas être annulée. %1$d items supprimés avec succès. - Erreur lors de la suppression d\'items. Choisir un profil pour assignation Pas d\'autre profil disponible pour modifier. Date de l\'objectif cible @@ -396,7 +395,6 @@ Conservateur Agressif Le profil a changé %1$d items avec succès. - Erreur de changement de profil pour certains items. Aucun type de mesure pertinent disponible ou configuré pour les statistiques. Min. Max. diff --git a/android_app/app/src/main/res/values-it/strings.xml b/android_app/app/src/main/res/values-it/strings.xml index d2064f187..8e6b31bc4 100644 --- a/android_app/app/src/main/res/values-it/strings.xml +++ b/android_app/app/src/main/res/values-it/strings.xml @@ -414,11 +414,9 @@ Elimina tipo? Vuoi davvero eliminare il tipo personalizzato \"%1$s\"? Anche tutte le misurazioni associate verranno eliminate definitivamente. Questa azione non può essere annullata. %1$d elementi eliminati correttamente. - Errore durante l\'eliminazione degli elementi. Seleziona utente per l\'assegnazione Nessun altro utente disponibile per il cambio. L\'utente ha modificato con successo %1$d elementi. - Errore durante il cambio utente per alcuni elementi. Nessun tipo di misurazione rilevante disponibile o configurato per le statistiche. Minimo Massimo diff --git a/android_app/app/src/main/res/values-ja/strings.xml b/android_app/app/src/main/res/values-ja/strings.xml index 03c049b0d..b1db2ad6d 100644 --- a/android_app/app/src/main/res/values-ja/strings.xml +++ b/android_app/app/src/main/res/values-ja/strings.xml @@ -327,11 +327,9 @@ タイプを削除しますか? カスタムタイプ「%1$s」を削除してもよろしいですか?関連するすべての測定値も完全に削除されます。この操作は元に戻せません。 %1$d 個のアイテムが正常に削除されました。 - アイテムの削除中にエラーが発生しました。 割り当てるユーザーを選択 変更できる他のユーザーはいません。 %1$d 個のアイテムのユーザーが正常に変更されました。 - 一部のアイテムのユーザー変更中にエラーが発生しました。 統計に関連する測定タイプは利用できない、または設定されていません。 最低 最大 diff --git a/android_app/app/src/main/res/values-pt-rBR/strings.xml b/android_app/app/src/main/res/values-pt-rBR/strings.xml index 6d017584b..4a07f962d 100644 --- a/android_app/app/src/main/res/values-pt-rBR/strings.xml +++ b/android_app/app/src/main/res/values-pt-rBR/strings.xml @@ -454,11 +454,9 @@ Desativado Movido para o eixo direito Movido para o eixo esquerdo - Erro ao excluir itens. Selecionar Usuário para Atribuição Não há outros usuários disponíveis para troca. %1$d medições movidas - Erro ao alterar o usuário de alguns itens. Nenhum tipo de medição relevante disponível ou configurado para estatísticas. Mín Máx @@ -689,15 +687,12 @@ Análises Conectar automaticamente ao iniciar Dados insuficientes - Sua análise de %1$s Recomposição corporal Bulking Perda Mista Ganho de Gordura - Agora curto prazo longo prazo - São necessárias pelo menos %1$d medições para analisar como seu corpo mudou ao longo do tempo. São necessárias ao menos %1$d medições por dia da semana para detectar hábitos semanais. São necessários ao menos %1$d anos de dados para detectar padrões sazonais. São necessárias ao menos %1$d medições para detectar valores incomuns. diff --git a/android_app/app/src/main/res/values-pt/strings.xml b/android_app/app/src/main/res/values-pt/strings.xml index 0d13e32b9..2df43b209 100644 --- a/android_app/app/src/main/res/values-pt/strings.xml +++ b/android_app/app/src/main/res/values-pt/strings.xml @@ -437,11 +437,9 @@ Apagar Tipo? Tem certeza que deseja apagar o tipo personalizado “%1$s”? Todas as medições associadas também serão apagadas permanentemente. Esta ação não pode ser desfeita. %1$d medições excluídas - Erro ao apagar elementos. Selecionar Utilizador para Atribuição Não há outros utilizadores disponíveis para troca. %1$d medições movidas - Erro ao alterar o utilizador de alguns elementos. Dados insuficientes Análise de %1$s Recomposição corporal diff --git a/android_app/app/src/main/res/values-ro/strings.xml b/android_app/app/src/main/res/values-ro/strings.xml index 9f4e5016a..44169fb58 100644 --- a/android_app/app/src/main/res/values-ro/strings.xml +++ b/android_app/app/src/main/res/values-ro/strings.xml @@ -436,11 +436,9 @@ Șterge tip? Are you sure you want to șterge custom tip “%1$s”? All associated măsurări va also be permanently șters. Această action nu poate be undone. %1$d items șters cu succes. - Eroare deleting items. Alege Utilizatorul for Assignment Nu există other utilizatori disponibile to schimbă to. Utilizatorul changed for %1$d items cu succes. - Eroare changing utilizator for some items. Nu există relevant măsurare tipuri disponibile sau configured for statistics. Min Max diff --git a/android_app/app/src/main/res/values-ru/strings.xml b/android_app/app/src/main/res/values-ru/strings.xml index d4d57072b..defa8cb07 100644 --- a/android_app/app/src/main/res/values-ru/strings.xml +++ b/android_app/app/src/main/res/values-ru/strings.xml @@ -388,11 +388,9 @@ Удалить тип? Вы уверены, что хотите удалить пользовательский тип данных \"%1$s\"? Все сопутствующие измерения так же будут безвозвратно удалены. Это действие необратимо. %1$d элементов успешно удалены - Не удалось удалить элементы. Для присвоения выберите пользователя Нет пользователей, на которых можно было бы перейти. %1$d измерений перемещены - Не удалось изменить пользователя для некоторых элементов. Соответствующие типы измерений для статистики недоступны или не настроены. Мин Макс diff --git a/android_app/app/src/main/res/values-sl/strings.xml b/android_app/app/src/main/res/values-sl/strings.xml index 74b1cab46..8536171a9 100644 --- a/android_app/app/src/main/res/values-sl/strings.xml +++ b/android_app/app/src/main/res/values-sl/strings.xml @@ -425,11 +425,9 @@ Izbrisati tip? Ali si prepričan/a, da želiš izbrisati tip po meri »%1$s«? Vse povezane meritve bodo prav tako trajno izbrisane. Tega dejanja ni mogoče razveljaviti. %1$d element/a/i/ov je bilo uspešno izbrisanih. - Napaka pri brisanju elementov. Izberi uporabnika za dodelitev Ni drugih uporabnikov, na katere bi lahko prešli. Uporabnik je bil uspešno spremenjen za %1$d elementov. - Napaka pri spreminjanju uporabnika za nekatere elemente. Ni ustreznih vrst meritev, ki bi bile na voljo ali nastavljene za statistiko. Minimum Maksimum @@ -689,12 +687,8 @@ Visok kontrast Ugotovitve Omejeni podatki - Tvoja analiza %1$s - Za izračun ugotovitev še ni dovolj podatkov. Nadaljuj z merjenjem! - na podlagi %1$d meritev Kratkoročni trend (30 dni) Dolgoročni trend - Za analizo sprememb svojega telesa skozi čas je potrebnih vsaj %1$d meritev. Za ugotavljanje tedenskih navad je potrebnih vsaj %1$d meritev na delovni dan. Za ugotavljanje sezonskih vzorcev je potrebnih vsaj %1$d let podatkov. Za ugotavljanje neobičajnih vrednosti je potrebnih vsaj %1$d meritev. @@ -709,7 +703,6 @@ Visoka Stabilno obdobje %1$d dni - Najboljši mesec: %1$s %2$d (%3$s) Tvoje tedenske navade Najvišje vrednosti dosegaš pri %1$s, najnižje pa pri %2$s Začasni vzorec - za zanesljive rezultate meri bolj redno diff --git a/android_app/app/src/main/res/values-sv/strings.xml b/android_app/app/src/main/res/values-sv/strings.xml index ad44623fd..9dcaa5a94 100644 --- a/android_app/app/src/main/res/values-sv/strings.xml +++ b/android_app/app/src/main/res/values-sv/strings.xml @@ -401,11 +401,9 @@ Radera typ? Är du säker på att du vill ta bort den anpassade typen ”%1$s”? Alla tillhörande mätningar kommer också att raderas permanent. Denna åtgärd kan inte ångras. %1$d mätvärden borttagna - Fel vid borttagning av objekt. Välj användare för tilldelning Inga andra användare tillgängliga att byta till. %1$d mätvärden flyttade - Fel vid byte av användare för vissa objekt. Inga relevanta mätningstyper tillgängliga eller konfigurerade för statistik. Min Max diff --git a/android_app/app/src/main/res/values-zh-rCN/strings.xml b/android_app/app/src/main/res/values-zh-rCN/strings.xml index a1027eef0..ef25677a5 100644 --- a/android_app/app/src/main/res/values-zh-rCN/strings.xml +++ b/android_app/app/src/main/res/values-zh-rCN/strings.xml @@ -295,10 +295,8 @@ 删除项目? 您确定要删除所选项目吗?此操作无法撤销。 删除类型? - 删除项目时出错。 选择要分配的用户 无其他可用用户可更改。 - 更改某些项目的用户时出错。 无相关测量类型可用或为统计配置。 增加 减少 From 44e1a565ced533db8d24b1aa8819a050ce948fad Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 9 Aug 2026 14:53:32 +0200 Subject: [PATCH 26/72] Inherit numeric values from the preceding measurement Scales only report what they can weigh, so waist, hips, caliper and custom values were absent on every Bluetooth sync. applyValueInheritance fills those gaps from the last measurement *before* the incoming timestamp, so historic entries read from a scale's memory inherit the state of their own point in time, never of a later one. Only numeric (FLOAT/INT) values are inherited; empty, derived, internal and disabled types are skipped. Values the scale delivered always win. The add-measurement form now pre-fills through the same method, so a manual entry starts out exactly where a sync would. --- .../core/facade/MeasurementFacade.kt | 31 +- .../MeasurementTransformationUseCase.kt | 67 ++++ .../overview/MeasurementDetailScreen.kt | 42 +-- .../openscale/ui/shared/SharedViewModel.kt | 7 + .../MeasurementValueInheritanceTest.kt | 313 ++++++++++++++++++ 5 files changed, 432 insertions(+), 28 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementValueInheritanceTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt index fd370c041..5a4ca6483 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt @@ -223,6 +223,14 @@ class MeasurementFacade @Inject constructor( // BLE // ------------------------------------------------------------------------- + /** + * Persists a measurement received from a scale. + * + * The carry-over runs last, on the measurement whose user is already final (smart assignment may + * have reassigned it), so the gaps are filled from the history of the user the entry ends up on. + * It also runs before CRUD applies the body-composition formulas, which lets an inherited + * waist/neck/hips feed e.g. the US-Navy body fat formula. + */ suspend fun saveMeasurementFromBleDevice( measurement: Measurement, values: List, @@ -230,10 +238,18 @@ class MeasurementFacade @Inject constructor( val ref = pendingReferenceUser if (ref != null) { val finalValues = transformation.applyAssistedWeighing(measurement, values, ref) - crud.saveMeasurement(measurement, finalValues) + crud.saveMeasurement( + measurement, + transformation.applyValueInheritance(measurement, finalValues) + ) } else { val finalMeasurement = transformation.applySmartUserAssignment(measurement, values) - if (finalMeasurement != null) crud.saveMeasurement(finalMeasurement, values) + if (finalMeasurement != null) { + crud.saveMeasurement( + finalMeasurement, + transformation.applyValueInheritance(finalMeasurement, values) + ) + } } } @@ -245,6 +261,17 @@ class MeasurementFacade @Inject constructor( // CRUD delegates // ------------------------------------------------------------------------- + /** + * The values to pre-fill an empty "new measurement" form with: whatever the measurement at + * [timestamp] would inherit from its predecessor, via the very same carry-over a Bluetooth sync + * runs — so a manual entry and a scale sync start from an identical state. + */ + suspend fun prefillValuesForNewMeasurement(userId: Int, timestamp: Long): List = + transformation.applyValueInheritance( + Measurement(userId = userId, timestamp = timestamp), + emptyList(), + ) + suspend fun saveMeasurement( measurement: Measurement, values: List, diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt index 9b9e07621..0efad7632 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.usecase import com.health.openscale.core.data.BodyFatFormulaOption import com.health.openscale.core.data.BodyWaterFormulaOption import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.LbmFormulaOption import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType @@ -356,6 +357,72 @@ class MeasurementTransformationUseCase @Inject constructor( return out } + /** + * Fills the gaps in [measurement] from the chronologically preceding measurement of the same + * user — the one and only inheritance rule in the app, used both when a scale delivers a + * measurement and when the "new measurement" form is pre-filled. + * + * A scale only reports what it can weigh, so everything the user keeps by hand — waist, hips, + * caliper values, custom types — would silently be absent on every sync. Those are taken from + * the last measurement **before** [measurement]'s timestamp, so a historic entry read from the + * scale's memory inherits the state of its own point in time and never that of a later one. + * + * Inherited are only **numeric** values (FLOAT/INT) that still carry information: + * - Values already in [values] win — only missing types are filled. + * - An empty predecessor (`null` or `0`) is dropped; `0` says nothing worth repeating. + * - TEXT, DATE, TIME and USER are dropped: a comment or a date is written about *that* one + * weigh-in and would be wrong on the next. + * - Derived types (BMI, BMR, …) are dropped — they are recomputed from the raw values. + * - Internal (impedance) and disabled types are dropped: raw device input, stale the moment it + * is copied. + * + * The weight needs no special case: every scale reports one, so on the sync path it is already + * in [values] and is left alone. Only the form pre-fill passes an empty [values] and therefore + * gets the previous weight as its starting point. + * + * No DB writes here; the caller persists via CRUD. + * + * @return [values] plus the inherited ones, or [values] unchanged if there is nothing to inherit. + */ + suspend fun applyValueInheritance( + measurement: Measurement, + values: List + ): List { + // History is ordered newest → oldest, so the first strictly older entry is the predecessor. + val previous = query.getMeasurementsForUser(measurement.userId).first() + .firstOrNull { + it.measurement.id != measurement.id && + it.measurement.timestamp < measurement.timestamp + } ?: return values + + val presentTypeIds = values.map { it.typeId }.toSet() + + val carried = previous.values.mapNotNull { previousValue -> + val type = previousValue.type + if (type.id in presentTypeIds) return@mapNotNull null + if (type.isDerived || type.isInternal || !type.isEnabled) return@mapNotNull null + + when (type.inputType) { + InputFieldType.FLOAT -> previousValue.value.floatValue + ?.takeIf { it != 0f } + ?.let { MeasurementValue(measurementId = 0, typeId = type.id, floatValue = it) } + InputFieldType.INT -> previousValue.value.intValue + ?.takeIf { it != 0 } + ?.let { MeasurementValue(measurementId = 0, typeId = type.id, intValue = it) } + InputFieldType.TEXT, InputFieldType.DATE, + InputFieldType.TIME, InputFieldType.USER -> null + } + } + + if (carried.isEmpty()) return values + + LogManager.i( + "MeasurementTransformation", + "Carry-over: inherited ${carried.size} value(s) from measurement ${previous.measurement.id}." + ) + return values + carried + } + /** * Corrects the weight value in a list of measurements if the user has amputations. * diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt index 538b9201d..6d9ff6787 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt @@ -183,33 +183,23 @@ fun MeasurementDetailScreen( pendingUserId = null valuesState.clear() - // Preload values from the user's last measurement, if available and types are loaded. - if (allMeasurementTypes.isNotEmpty() && lastMeasurementToPreloadFrom != null) { - // Ensure the last measurement belongs to the current user. - if (lastMeasurementToPreloadFrom!!.measurement.userId == userId) { - lastMeasurementToPreloadFrom!!.values.forEach { mvFromLast -> - val correspondingType = allMeasurementTypes.find { it.id == mvFromLast.type.id } - if (correspondingType != null && - correspondingType.isEnabled && - correspondingType.inputType != InputFieldType.DATE && - correspondingType.inputType != InputFieldType.TIME - ) { - val valueString = when (correspondingType.inputType) { - InputFieldType.FLOAT -> mvFromLast.value.floatValue?.let { String.format(Locale.US, "%.2f", it) } ?: "" - InputFieldType.INT -> mvFromLast.value.intValue?.toString() ?: "" - InputFieldType.TEXT -> mvFromLast.value.textValue ?: "" - else -> "" - } - if (valueString.isNotEmpty()) { - valuesState[correspondingType.id] = valueString - } - } + // Pre-fill from the user's preceding measurement. Which values may be inherited is + // decided in one place for the whole app (MeasurementTransformationUseCase's + // applyValueInheritance), so a manual entry starts out exactly where a Bluetooth + // sync would — the form only formats what it gets back. + if (allMeasurementTypes.isNotEmpty()) { + sharedViewModel.prefillValuesForNewMeasurement(userId, measurementTimestampState) + .forEach { inherited -> + val correspondingType = allMeasurementTypes.find { it.id == inherited.typeId } + ?: return@forEach + val valueString = when (correspondingType.inputType) { + InputFieldType.FLOAT -> inherited.floatValue?.let { String.format(Locale.US, "%.2f", it) } + InputFieldType.INT -> inherited.intValue?.toString() + else -> null + } ?: return@forEach + + valuesState[correspondingType.id] = valueString } - } else { - // Log if preloading is skipped due to user mismatch (for debugging). - // Consider using a formal logger if this becomes a common scenario to debug. - println("DEBUG: lastMeasurementToPreloadFrom.userId (${lastMeasurementToPreloadFrom!!.measurement.userId}) != currentScreenUserId ($userId). Not preloading values.") - } } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt index c8baa7151..f1241a1c2 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt @@ -679,6 +679,13 @@ class SharedViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** + * The values to pre-fill an empty add-measurement form with — produced by the same carry-over a + * Bluetooth sync runs, so a manual entry starts out exactly where a scale sync would. + */ + suspend fun prefillValuesForNewMeasurement(userId: Int, timestamp: Long): List = + measurementFacade.prefillValuesForNewMeasurement(userId, timestamp) + // ------------------------------------------------------------------------- // CRUD // ------------------------------------------------------------------------- diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementValueInheritanceTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementValueInheritanceTest.kt new file mode 100644 index 000000000..2ec0a2f23 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementValueInheritanceTest.kt @@ -0,0 +1,313 @@ +/* + * 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.usecase + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.ActivityLevel +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.MeasurementType +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.database.AppDatabase +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.getDefaultMeasurementTypes +import com.health.openscale.testutil.RoomTestSupport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * Tests [MeasurementTransformationUseCase.applyValueInheritance] against in-memory Room + * (Robolectric): a measurement coming from a scale inherits the manually kept *numeric* values + * (waist, hips, custom types, …) of its chronological predecessor, without ever overwriting what + * the scale reported. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MeasurementValueInheritanceTest { + + private val app: Application get() = ApplicationProvider.getApplicationContext() + private lateinit var db: AppDatabase + private lateinit var repo: DatabaseRepository + private lateinit var transformation: MeasurementTransformationUseCase + private var userId = 0 + private var weightId = 0 + private var waistId = 0 + private var hipsId = 0 + private var heartRateId = 0 + private var commentId = 0 + private var bmiId = 0 + private var impedanceId = 0 + + @Before + fun setUp() = runBlocking { + db = RoomTestSupport.inMemory(app) + repo = RoomTestSupport.repositoryFor(db) + repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + + val settings = RoomTestSupport.settingsFacadeFor( + CoroutineScope(SupervisorJob() + Dispatchers.IO), + File(app.cacheDir, "inheritance-${System.nanoTime()}.preferences_pb"), + ) + val query = MeasurementQueryUseCases(repo) + val sync = SyncUseCases(app, MeasurementTypeCrudUseCases(repo)) + transformation = MeasurementTransformationUseCase( + settings, UserUseCases(repo, settings, sync), query + ) + + val types = repo.getAllMeasurementTypes().first() + weightId = types.first { it.key == MeasurementTypeKey.WEIGHT }.id + waistId = types.first { it.key == MeasurementTypeKey.WAIST }.id + hipsId = types.first { it.key == MeasurementTypeKey.HIPS }.id + heartRateId = types.first { it.key == MeasurementTypeKey.HEART_RATE }.id + commentId = types.first { it.key == MeasurementTypeKey.COMMENT }.id + bmiId = types.first { it.key == MeasurementTypeKey.BMI }.id + impedanceId = types.first { it.key == MeasurementTypeKey.IMPEDANCE }.id + + userId = repo.insertUser( + User( + name = "u", birthDate = 0L, gender = GenderType.MALE, heightCm = 175f, + activityLevel = ActivityLevel.MODERATE, useAssistedWeighing = false, + ) + ).toInt() + } + + @After + fun tearDown() = db.close() + + /** Writes a historic measurement directly through the repository. */ + private suspend fun insertHistory(timestamp: Long, vararg values: MeasurementValue): Int { + val id = repo.insertMeasurement(Measurement(userId = userId, timestamp = timestamp)).toInt() + values.forEach { repo.insertMeasurementValue(it.copy(measurementId = id)) } + return id + } + + private fun float(typeId: Int, value: Float) = + MeasurementValue(measurementId = 0, typeId = typeId, floatValue = value) + + private fun incoming(timestamp: Long) = Measurement(userId = userId, timestamp = timestamp) + + private fun List.floatOf(typeId: Int) = + firstOrNull { it.typeId == typeId }?.floatValue + + @Test + fun inherits_fillsNumericTypesTheScaleDoesNotReport() = runBlocking { + insertHistory( + 1_000L, + float(weightId, 70f), + float(waistId, 90f), + MeasurementValue(measurementId = 0, typeId = heartRateId, intValue = 60), + ) + + val result = transformation.applyValueInheritance( + incoming(2_000L), listOf(float(weightId, 71f)) + ) + + assertThat(result.floatOf(waistId)).isEqualTo(90f) + assertThat(result.first { it.typeId == heartRateId }.intValue).isEqualTo(60) + } + + @Test + fun inherits_noComment() = runBlocking { + insertHistory( + 1_000L, + float(weightId, 70f), + MeasurementValue(measurementId = 0, typeId = commentId, textValue = "after breakfast"), + ) + + val result = transformation.applyValueInheritance( + incoming(2_000L), listOf(float(weightId, 71f)) + ) + + assertThat(result.none { it.typeId == commentId }).isTrue() + } + + @Test + fun inherits_neverOverwritesValuesFromTheScale() = runBlocking { + insertHistory(1_000L, float(weightId, 70f), float(waistId, 90f)) + + val result = transformation.applyValueInheritance( + incoming(2_000L), listOf(float(weightId, 71f), float(waistId, 85f)) + ) + + assertThat(result.filter { it.typeId == waistId }).hasSize(1) + assertThat(result.floatOf(waistId)).isEqualTo(85f) + } + + @Test + fun inherits_ignoresEmptyPredecessorValues() = runBlocking { + insertHistory( + 1_000L, + float(weightId, 70f), + float(waistId, 0f), + MeasurementValue(measurementId = 0, typeId = heartRateId, intValue = 0), + ) + + val result = transformation.applyValueInheritance( + incoming(2_000L), listOf(float(weightId, 71f)) + ) + + assertThat(result.none { it.typeId == waistId }).isTrue() + assertThat(result.none { it.typeId == heartRateId }).isTrue() + } + + @Test + fun inherits_usesThePredecessorNotALaterMeasurement() = runBlocking { + insertHistory(1_000L, float(weightId, 70f), float(waistId, 90f)) + insertHistory(3_000L, float(weightId, 72f), float(waistId, 100f)) + + // A historic entry read from the scale's memory, landing between the two. + val result = transformation.applyValueInheritance( + incoming(2_000L), listOf(float(weightId, 71f)) + ) + + assertThat(result.floatOf(waistId)).isEqualTo(90f) + } + + @Test + fun inherits_returnsValuesUnchangedWhenNothingPrecedes() = runBlocking { + insertHistory(3_000L, float(weightId, 72f), float(waistId, 100f)) + + val values = listOf(float(weightId, 71f)) + val result = transformation.applyValueInheritance(incoming(2_000L), values) + + assertThat(result).isEqualTo(values) + } + + @Test + fun inherits_skipsDerivedAndInternalTypes() = runBlocking { + insertHistory( + 1_000L, + float(weightId, 70f), + float(impedanceId, 500f), + float(hipsId, 95f), + ) + // The insert triggers the derived recalculation, so BMI exists on the predecessor. + assertThat(repo.getMeasurementsWithValuesForUser(userId).first() + .single().values.any { it.type.key == MeasurementTypeKey.BMI }).isTrue() + + val result = transformation.applyValueInheritance( + incoming(2_000L), listOf(float(weightId, 71f)) + ) + + assertThat(result.none { it.typeId == bmiId }).isTrue() + assertThat(result.none { it.typeId == impedanceId }).isTrue() + // The scale's own weight stands; the old one is not pulled in alongside it. + assertThat(result.filter { it.typeId == weightId }).hasSize(1) + assertThat(result.floatOf(weightId)).isEqualTo(71f) + assertThat(result.floatOf(hipsId)).isEqualTo(95f) + } + + @Test + fun inherits_coversNumericCustomTypesButNoOthers() = runBlocking { + val customFloatId = repo.insertMeasurementType( + MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Blood pressure", + unit = UnitType.NONE, inputType = InputFieldType.FLOAT) + ).toInt() + val customIntId = repo.insertMeasurementType( + MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Steps", + unit = UnitType.NONE, inputType = InputFieldType.INT) + ).toInt() + val customTextId = repo.insertMeasurementType( + MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Mood", + unit = UnitType.NONE, inputType = InputFieldType.TEXT) + ).toInt() + val customDateId = repo.insertMeasurementType( + MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Last blood test", + unit = UnitType.NONE, inputType = InputFieldType.DATE) + ).toInt() + val disabledCustomId = repo.insertMeasurementType( + MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Retired", + unit = UnitType.NONE, inputType = InputFieldType.FLOAT, isEnabled = false) + ).toInt() + + insertHistory( + 1_000L, + float(weightId, 70f), + float(customFloatId, 120f), + MeasurementValue(measurementId = 0, typeId = customIntId, intValue = 8_000), + MeasurementValue(measurementId = 0, typeId = customTextId, textValue = "good"), + MeasurementValue(measurementId = 0, typeId = customDateId, dateValue = 987_000L), + float(disabledCustomId, 42f), + ) + + val result = transformation.applyValueInheritance( + incoming(2_000L), listOf(float(weightId, 71f)) + ) + + assertThat(result.floatOf(customFloatId)).isEqualTo(120f) + assertThat(result.first { it.typeId == customIntId }.intValue).isEqualTo(8_000) + assertThat(result.none { it.typeId == customTextId }).isTrue() + assertThat(result.none { it.typeId == customDateId }).isTrue() + assertThat(result.none { it.typeId == disabledCustomId }).isTrue() + } + + /** + * The form pre-fill path goes through the same method with an empty value list — so the weight, + * which the sync path always supplies itself, is inherited here as a starting point. + */ + @Test + fun inherits_withoutIncomingValues_prefillsIncludingWeight() = runBlocking { + insertHistory( + 1_000L, + float(weightId, 70f), + float(waistId, 90f), + MeasurementValue(measurementId = 0, typeId = commentId, textValue = "after breakfast"), + ) + + val prefill = transformation.applyValueInheritance(incoming(2_000L), emptyList()) + + assertThat(prefill.floatOf(weightId)).isEqualTo(70f) + assertThat(prefill.floatOf(waistId)).isEqualTo(90f) + assertThat(prefill.none { it.typeId == commentId }).isTrue() + assertThat(prefill.none { it.typeId == bmiId }).isTrue() + } + + @Test + fun inherits_ignoresHistoryOfAnotherUser() = runBlocking { + val otherUserId = repo.insertUser( + User( + name = "other", birthDate = 0L, gender = GenderType.FEMALE, heightCm = 165f, + activityLevel = ActivityLevel.MODERATE, useAssistedWeighing = false, + ) + ).toInt() + val otherMeasurementId = + repo.insertMeasurement(Measurement(userId = otherUserId, timestamp = 1_000L)).toInt() + repo.insertMeasurementValue(float(waistId, 75f).copy(measurementId = otherMeasurementId)) + + val values = listOf(float(weightId, 71f)) + val result = transformation.applyValueInheritance(incoming(2_000L), values) + + assertThat(result).isEqualTo(values) + } +} From 7a170e4077e9af10c107ff6dab470ea28c495b7a Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 9 Aug 2026 15:00:49 +0200 Subject: [PATCH 27/72] update openScale to 3.1.2 --- android_app/app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-GB/changelogs/76.txt | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-GB/changelogs/76.txt diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index 4012e05b1..e52df370d 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -29,8 +29,8 @@ android { applicationId = "com.health.openscale" minSdk = 31 targetSdk = 37 - versionCode = 75 - versionName = "3.1.1" + versionCode = 76 + versionName = "3.1.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders["appName"] = "openScale" diff --git a/fastlane/metadata/android/en-GB/changelogs/76.txt b/fastlane/metadata/android/en-GB/changelogs/76.txt new file mode 100644 index 000000000..ea3b04f3e --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/76.txt @@ -0,0 +1,6 @@ +* New scale support: Keep S3, Runstar R6, FitTrack Dara 2.0, Vitafit VT701, Relaxmedic, EEBBL, Taylor 5331891, 1BODY CONNECT, Robi S9, Xiaomi S800, Eufy P2, Dr.Trust 505, Scale Up and more Yunmai/Huawei models. +* New entries inherit waist, hips etc. from the previous measurement. +* Scientific body-composition algorithm for Mi Scale. +* Undo for deleted entries, safer saving, resizable charts. +* Fixed crash when importing a pre-2.5 database. +* New translations, incl. Hindi and Bulgarian. From 8ea4bbd9a2ec563ec22f294f8e8daae1205fdecc Mon Sep 17 00:00:00 2001 From: oliexdev Date: Mon, 10 Aug 2026 12:51:53 +0200 Subject: [PATCH 28/72] Fix crash when the overview list keys aggregated rows by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregated entries are synthetic and all carry Measurement.id == -1, so the LazyColumn key must not fall back to the id. It did whenever isAggregated was false while the list already held aggregated items — that flag comes from the settings flow, the items from the data flow, and the two disagree for a frame after an aggregation change or on the first composition, where the cached data flow already has the previous result. Every row then got the key -1 and Compose aborted the measure pass with "Key -1 was already used". Key by id + timestamp instead: unique for raw rows via the database id, unique for aggregated rows because their periods are disjoint. --- .../openscale/ui/screen/overview/OverviewScreen.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index 059ea6587..fb2ee66b1 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -577,11 +577,15 @@ fun OverviewScreen( ) { itemsIndexed( items = aggregatedItems, + // Must not depend on isAggregated: that flag comes from the + // settings flow while the items come from the data flow, so + // the two disagree for a frame after an aggregation change. + // Aggregated entries are synthetic and all carry id = -1, + // which then yields duplicate keys and crashes the list. + // id + timestamp is unique in both modes. key = { _, item -> - if (isAggregated) - item.enriched.measurementWithValues.measurement.timestamp - else - item.enriched.measurementWithValues.measurement.id + val m = item.enriched.measurementWithValues.measurement + "${m.id}_${m.timestamp}" }, ) { _, aggItem -> val enrichedItem = aggItem.enriched From 804a95ce75cad60247604d879c1b11ec20682c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Abdullah=20G=C3=BCltekin?= <103918821+ahmetabdullahgultekin@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:30:02 +0300 Subject: [PATCH 29/72] Complete the Turkish translation (154 -> 782 of 811 strings) (#1451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Complete the Turkish translation (154 -> 782 of 811 strings) Turkish was the least complete of the maintained locales at 19%, while German and Spanish sit at 95%. Four out of five strings a Turkish user saw were still English, which makes large parts of the app - Bluetooth setup, insights, backup and every error message - unreadable for users who do not speak English. This adds the 628 missing translatable strings. Existing translations are untouched, and their terminology is followed for consistency. Strings marked translatable=false are left out. Verified: gradlew :app:processDebugResources completes successfully; format specifiers match the source strings one for one. * Reword three Turkish strings that read awkwardly in context Found while walking the app on an emulator with the Turkish locale active - these were literal renderings that a native speaker would not say out loud. - permission prompt: 'erişimine gereksinimimiz var' -> 'erişimi gerekiyor' - generic confirm: the verb placeholder no longer has to fit a Turkish case ending, which it cannot do for an arbitrary verb - unit-change dialog: 'sürdürmek' -> 'devam etmek' * Shorten the plateau chip so it fits on screen in Turkish The insight chips sit in a horizontally scrolling Row, and my translation of the plateau chip was long enough that it was clipped at the screen edge on a 1080px-wide device: it rendered as '180 gunluk durag'. The English 'plateau %1$d days' fits, so this was the translation's problem, not the layout's. '%1$d gunluk duraganlik' -> '%1$d gun duragan', which is shorter than the English source and reads just as naturally. Verified on an Android 16 emulator with the app locale set to Turkish: the chip now renders in full as '180 gun duragan'. --- .../app/src/main/res/values-tr/strings.xml | 628 ++++++++++++++++++ 1 file changed, 628 insertions(+) diff --git a/android_app/app/src/main/res/values-tr/strings.xml b/android_app/app/src/main/res/values-tr/strings.xml index 40532f585..28240b376 100644 --- a/android_app/app/src/main/res/values-tr/strings.xml +++ b/android_app/app/src/main/res/values-tr/strings.xml @@ -154,4 +154,632 @@ Alfa Pencere boyutu Mevcut veritabanının üzerine yedek dosya yazmak istediğinizden emin misiniz? Kaydedilmemiş tüm mevcut veriler kaybolacaktır. Geri yükleme işleminden sonra uygulamanın yeniden başlatılması gerekebilir. +Son değişiklik eşitlenmedi. Lütfen openScale-sync uygulamasını açıp elle eşitleme yapın. + +Uygulama logosu +Yok +Yok +Temizle +Kaydedildi +Bağlantıyı aç +Uyarı simgesi +Geri yükleme simgesi +Bilgi +Menüyü aç +Geri +Seçili +Türetilen değerler hesaplanıyor … +Türetilen değerler güncellendi. +Bir hata oluştu. +Veriler yüklenemedi. +Lütfen yeniden deneyin. +Veri yok. +Ölçüm bulunamadı. +Kayıt yok. +Genel bakış +İçgörüler +Ayrıntılar +TH +Kullanıcı değiştir. Seçili: %1$s +Seçili kullanıcı göstergesi +Kullanıcı seçilemedi. +Kullanıcı seçilmedi. +Lütfen önce bir kullanıcı seçin. +Etkin kullanıcı yok +Ölçümlerinizi görmek ve yönetmek için yeni bir kullanıcı oluşturun ya da var olan bir kullanıcıyı seçin. +%1$s kullanıcısı silindi. +%1$s kullanıcısı silinirken hata oluştu. +%1$s kullanıcısı güncellendi. +%1$s kullanıcısı eklendi. +%1$s kullanıcısı güncellenirken hata oluştu. +Ampütasyon düzeltmesi +Etkilenen uzuvları seçin ve her biri için ampütasyon düzeyini belirtin. +Destekli tartım +%1$s için referans kişiyi seçin +Uygun referans kişi yok. Lütfen destekli tartım kullanmayan bir kullanıcı oluşturun. +Destekli tartım için bir referans kişi seçilmelidir. +Lütfen geçerli veri girin +%.1f cm +Yaş: %1$d, Cinsiyet: %2$s +Ölçüm güncellendi (%1$s) +Ölçüm kaydedildi (%1$s) +Ölçüm kaydedilemedi. Lütfen yeniden deneyin. +Kaydedilmedi — %1$s zamanında zaten bir ölçüm var. +Ölçüm silindi (%1$s) +Ölçüm silinemedi. Lütfen yeniden deneyin. +Geri al +Çizgeyi yeniden boyutlandırmak için sürükleyin, daraltmak için dokunun +Çizgeyi göstermek için dokunun +Değişiklikler atılsın mı? +Değişiklikleriniz kaydedilmedi ve kaybolacak. +At +Düzenlemeyi sürdür +Ölçümü kaydet +Ölçüm ekle +Yeni ölçüm ekle +%1$s tarihli ölçümü düzenle +%1$s tarihli ölçümü sil +Bu zaman damgası son ölçümünüzle aynı. Lütfen farklı bir zaman seçin. +Henüz ölçüm yok +İlerlemenizi izlemeye başlamak için ilk ölçümünüzü ekleyin. +Bu kayıt için etkin ölçüm değeri yok. +%1$s için geçersiz sayı: \'%2$s\' +%1$s için geçersiz tam sayı: \'%2$s\' +%1$s için geçersiz sayı +%1$s için geçersiz tam sayı +Lütfen geçerli veri girin. +%1$s düzenle +%1$s değiştir +%1$s artır +%1$s azalt +%1$s düzenle +Değer arttı +Değer azaldı +Değer sabit ya da değişim yok +%1$s simgesi +Bilinmeyen ölçüm türü simgesi: %1$s +Bilinmeyen tür: %1$s +Kilo +VKİ +Vücut yağı +Su +Kas +YVK +Kemik +Bel +BKO +BBO +Kalça +Viseral yağ +Göğüs +Uyluk +Pazı +Boyun +Kaliper 1 +Kaliper 2 +Kaliper 3 +Yağ kaliperi +BMH +Nabız +Empedans (yüksek) +Empedans (düşük) +Hücre dışı su +Hücre içi su +Protein +Vücut hücre kütlesi +Kemik formülü +Empedans tabanlı regresyon (eski) +Heymsfield (antropometrik) +BMH formülü +Cunningham 1991 +Cunningham 1980 +TGEH +Kalori +Yorum +Tarih +Saat +%d hedef +Hedefi düzenle: %1$s +Hedef belirle: %1$s +Hedef belirlenebilecek tür yok. +Tür seçin +%1$s için hedef değeri +Yeni hedef için hedef değeri boş bırakılamaz. +Lütfen önce bir ölçüm türü seçin. +Hedef silindi +Giriş türü +Sabitlenmiş +Sağ Y ekseninde +Seçili simge önizlemesi +\'%1$s\' ölçüm türü eklendi. +\'%1$s\' ölçüm türü eklenirken hata oluştu. +\'%1$s\' ölçüm türü silindi. +\'%1$s\' ölçüm türü silinirken hata oluştu. +\'%1$s\' ölçüm türü güncellenirken hata oluştu. +\'%1$s\' ölçüm türü güncellendi. +\'%1$s\' türü güncellendi ve %2$s değer dönüştürüldü. +\'%1$s\' türünün değerleri dönüştürülürken hata oluştu. Tür tanımı güncellenmedi. +\'%1$s\' türü güncellendi. Birim değişti, ancak dönüştürülmesi gereken değer yoktu. +Birim değişikliğini onaylayın +\'%1$s\' türünün birimini %2$s biriminden %3$s birimine değiştirmek, var olan tüm veri noktalarını dönüştürecek. Bu işlem biraz sürebilir ve kolayca geri alınamaz. Devam etmek istiyor musunuz? +Bu ölçüt salt okunur olur ve kendiliğinden hesaplanır. Temel değerler değiştiğinde güncellenir. Yeni ya da düzenlenen ölçümler için geçerlidir; var olan ölçümler değişmez. +Kapalı +Gallagher (2000) – Asyalı olmayan +Gallagher (2000) – Asyalı +Kilo − vücut yağı +ABD Donanması +Kendiliğinden hesaplama yok +Bu ölçüt kendiliğinden hesaplanmaz. Değerleri elle girebilirsiniz. +Yetişkinler için klasik regresyon +1991 tarihli, yaygın kullanılan bir popülasyon formülü. Genel yetişkin popülasyonlar için uygundur; çok zayıf ya da çok kaslı kişilerde doğruluğu düşebilir. +Gençler ve yetişkinler için uyarlanmış +Genç kullanıcılara uyarlanan bir türev. Genel amaçlı bir tahmindir; atletik ya da sıra dışı vücut yapılarında sapma görülebilir. +Erken dönem cinsiyete özgü model +Erkekler ve kadınlar için ayrı katsayıları olan eski bir regresyon. Yalın ve hızlıdır, ancak özel gruplara göre uyarlanmamıştır. +Gallagher (Asyalı olmayan) +Asyalı olmayan popülasyonlar için 2000 tarihli referans model. İyi bir başlangıç noktasıdır; doğruluk vücut tipine göre değişir. +Gallagher (Asyalı) +Asyalı popülasyonlar için ayarlanmış eşlenik model. Kullanıcının kökenine daha uygun olduğunda kullanın. +Behnke TVS tahmini +Klasik toplam vücut suyu tahmini. Yalın bir yaklaşımdır; sporcular ya da uç durumlar için kişiselleştirilmemiştir. +Delwaide & Crenier +Vücut kütlesini öne çıkaran TVS formülü. Makul bir genel tahmindir; kesinliği vücut yapısına göre değişir. +Hume–Weyers TVS +Boy ve kiloyu, cinsiyete özgü katsayılarla kullanan yaygın bir TVS modeli. +Lee–Song–Kim +Kore kohortlarında kalibre edilmiş daha yeni bir TVS tahmini. Bu popülasyon uyumu geçerliyse değerlendirin. +Boer YVK (klinik) +Klinik ortamlarda yaygın kullanılan yağsız vücut kütlesi tahmini. Boy ve kilodan, cinsiyete özgü regresyon. +Hume YVK +Erken dönem YVK modeli. Yalın ve yaygındır; çok atletik yapılara göre uyarlanmamıştır. +Kilo − vücut yağı +YVK değerini, toplam kilodan seçilen vücut yağı formülüyle bulunan yağ kütlesini çıkararak hesaplar. Doğruluğu, yağ tahminine bağlıdır. +ABD Donanması yöntemi +Vücut yağ oranını, ABD Donanması standardına göre bel, boyun ve (kadınlarda) kalça çevresinden hesaplar. +%1$d: %2$s +%1$s onaylansın mı? +%1$d öğe üzerinde %2$s işlemi yapılsın mı? +sol eksene taşı +sağ eksene taşı +Sağ eksene taşındı +Sol eksene taşındı +Tartıya bağlanılamadı, lütfen açık olduğundan emin olun. +kayıtlı tartı +Bluetooth işlemi sürüyor +Bluetooth tartıyı ayarla +%1$s bağlantısını kes +%1$s bağlantısını yeniden dene +Bluetooth hatası, ayarları denetleyin +%1$s aygıtına bağlan +Bluetooth ayarlarını denetleyin +%1$s aygıtına bağlanılıyor… +%1$s bağlantısı kesiliyor… +%1$s ile işleniyor… +Kayıtlı Bluetooth tartı yok. Lütfen ayarlardan bir tartı ekleyin. +%1$s bağlantısı yeniden deneniyor… +Bluetooth hatası. Lütfen ayarları denetleyin. +%1$s aygıtına bağlanmaya çalışılıyor… +Lütfen Bluetooth ayarlarını denetleyin. +Taramayı başlatmak için lütfen Bluetooth\'u açın. +Tarama için Bluetooth izinleri gerekiyor. +Bluetooth açık, ancak izinler hâlâ eksik. +Tartı aramak için Bluetooth açık olmalıdır. +Kayıtlı tartı: +Bilinmiyor +Taramayı durdur +Tartı ara +Tartı arama düğmesi +Bulunan aygıtlar: +\'%1$s\' yeğlenen tartı olarak kaydedildi. +%1$s şu anda desteklenmiyor. +Aygıt bulunamadı. Yeni bir tarama başlatın. +Kayıtlı tartı göstergesi +Desteklenen aygıt +Desteklenmeyen aygıt +Destekleniyor +Desteklenmiyor +%1$d dBm +Yeğlenen olarak kaydet +Geliştirici kipinde kaydet +Kayıtlı aygıt kaldırıldı. +Geliştirici kipi açıldı. Lütfen günlük dosyasını etkinleştirin: Ayarlar → Genel → Günlük dosyası. +Bu aygıt için geliştirici kipi açıldı. Lütfen günlük dosyasını etkinleştirin: Ayarlar → Genel → Günlük dosyası. +Geliştirici kipi etkin — hiçbir ölçüm kaydedilmeyecek. +Lütfen günlük dosyasını etkinleştirin: Ayarlar → Genel → Günlük dosyası. +Yetenekler +Vücut bileşimi +Saat eşitleme +Kullanıcı eşitleme +Geçmiş okuma +Anlık kilo +Birim ayarı +Pil +Uygulanmış: %1$s +Destekleniyor (uygulanmamış): %1$s +Diğer seçenekler +"%1$s ayarları" +Bluetooth ince ayarı +İnce ayar profili +Bluetooth ölçümü +Akıllı kullanıcı ataması +Tolerans +Belirsiz ölçümleri yok say +Bir ölçüm tüm kullanıcılar için tolerans dışındaysa kaydedilmez. +Aygıt işlemleri +Geliştirici kipi +Kayıtlı aygıtı kaldır +Bluetooth ince ayar profili +Bluetooth ince ayarı: %1$s +Dengeli +Tutucu +Agresif +Bir kullanıcıya kendiliğinden atama için en çok kilo sapması. +Geliştirici +Tehlikeli bölge +Açılışta kendiliğinden bağlan +Tartı yapılandırması +Bu aygıt için ek özel yapılandırma yok. +Vücut bileşimi algoritması +Xiaomi (özgün uygulama) +Bilimsel +Vücut bileşiminin empedanstan nasıl türetileceğini seçin. \"Xiaomi\", özgün Mi Fit / Zepp Life uygulamasını yeniden üretir. \"Bilimsel\", hakemli formülleri (Siri, Pace, Wang, Schofield) kullanır ve ayrıca protein ile BMH değerlerini verir. Bu tartıdan gelen yeni ölçümler için geçerlidir. +BLE bağlama anahtarı +32 karakterlik onaltılık anahtar +Xiaomi S400 tartısı, şifre çözme için bir BLE bağlama anahtarı gerektirir. Bu anahtarı, Xiaomi Cloud Tokens Extractor aracıyla Xiaomi bulutundan çıkarabilirsiniz. +Bağlama anahtarı tam olarak 32 onaltılık karakter olmalıdır +S400: Bağlama anahtarı yapılandırılmamış. BLE anahtarınızı girmek için Bluetooth ayarlarına gidin. +%1$s aygıtına bağlanıldı +%1$s bağlantısı başarısız: %2$s +%1$s ile ilgili hata: %2$s +%1$s desteklenmiyor. +%1$s için sürücü bulunamadı ya da iç hata oluştu. +%1$s aygıtından gelen ölçüm bir kullanıcıya atanamıyor. +Hata: Ölçüm türleri yüklenmedi. +%1$s aygıtından geçerli ölçüm değeri alınmadı. +%2$s aygıtından gelen ölçüm (%1$.1f kg) kaydedildi. +%1$s aygıtından gelen ölçüm kaydedilirken hata oluştu. +%1$s: %2$s +%1$s dinleniyor… +%1$s aygıtından ölçüm alındı. +İzin gerekli simgesi +İzin gerekli +Tartı aramak için Bluetooth erişimi gerekiyor. +İzin ver +Bluetooth kapalı simgesi +Bluetooth kapalı +Tartı aramak için lütfen Bluetooth\'u açın. +Bluetooth\'u aç +Hata simgesi +Önemli bilgilendirme +openScale açık kaynaklı bir projedir ve her tartı bende bulunmuyor. Bluetooth protokolleri değişebildiği ya da belgelenmemiş olabildiği için uyumluluğun her durumda güvence altına alınamayacağını lütfen göz önünde bulundurun.\n\nAçık kaynaklı bir proje olarak, tartı desteğini geliştiren katkılar ve çekme istekleri her zaman beklenir! +Anladım ve kaydet +Proje sitesine git +İlklendirme süreci... +Bağlantı koptu +Bağlantı kesildi +Aygıt bulunamadı. %s +Beklenmeyen hata: %s +Beklenmeyen veri türü alındı: %s +Tartıdan gelen ileti (Kimlik: %1$d, Değer: %2$s) +Kullanıcı seçimi gerekli +Kullanıcı #%1$d (Yaş: %2$d, Boy: %3$d) +%1$d numaralı kullanıcı için onay gerekli (Dizin %2$d). +Sürücüden bilinmeyen durum ya da ileti: %s +Bağdaştırıcı başka bir aygıtla (%s) meşgul. +%1$s aygıtına bağlantı süreci başlatılırken hata: %2$s +Ölçüm isteği için bağlantı yok. +Ölçüm kendiliğinden başlamalı (eski sürücü). +Hata: Seçim için kullanıcı verisi eksik. +Hata: Onay için geçersiz veri alındı. +Hata: Onay için gereken veri eksik. +Hata: Kullanıcı etkileşimi için geçersiz veri alındı. +Zaman aralığı simgesi +Çizge verilerini süz +%s seçildi +Ölçüm süzgeci görünür +Ölçüm süzgeci gizli +%1$s (%2$d kayıt) +Özel gün sayısı +%1$s – %2$s arası +Zaman aralığı +Toplulaştırma +%1$s seçildi +Toplulaştırma yok +Gün +Hafta +Ay +Yıl +Çizilebilecek çizge türü yok. +Gösterilecek veri yok. +Seçili aralıkta %1$s için veri yok. +bu tür +Lütfen çizge için ölçüm türlerini seçin. +Gösterilecek veri ya da seçilecek tür yok. +Seçili türler için veri yok. +Ölçüm süzgecini göster/gizle +Tarih +Sütun seçilmedi ya da ölçüm yok. +Gösterilecek sütun seçilmedi. +Geçerli sütun seçimi için veri yok. +Yükseliyor +Düşüyor +Öğeleri seç +%1$d seçildi +Seçimi iptal et +Seçili öğeler için kullanıcıyı değiştir +Seçili öğeleri dışa aktar +Seçili öğeleri sil +%1$s kullanıcısı silinsin mi? +Bu kullanıcıya ait tüm veriler kalıcı olarak silinecek. Bu işlem geri alınamaz. +%1$s hedefi silinsin mi? +Bu, hedefi kaldıracak. Ölçüm geçmişiniz etkilenmeyecek. +Ölçüm silinsin mi? +%1$s tarihli ölçümü (%2$s) silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. +Öğeler silinsin mi? +Seçili öğeyi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. +Seçili %1$d öğeyi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. +Tür silinsin mi? +“%1$s” özel türünü silmek istediğinizden emin misiniz? İlişkili tüm ölçümler de kalıcı olarak silinecek. Bu işlem geri alınamaz. +%1$d ölçüm silindi +%2$d ölçümün %1$d tanesi silinemedi. +Atama için kullanıcı seçin +Geçilebilecek başka kullanıcı yok. +%1$d ölçüm taşındı +%2$d ölçümün %1$d tanesi taşınamadı (o kullanıcıda zaten var). +Sınırlı veri +%1$s çözümlemeniz +Yeniden bileşim +Kütle artışı +Karışık kayıp +Yağ artışı +Vücudunuzun zaman içinde nasıl değiştiğini çözümlemek için en az %1$d ölçüm gerekiyor. +Haftalık alışkanlıkları saptamak için her hafta günü en az %1$d ölçüm gerekiyor. +Mevsimsel örüntüleri saptamak için en az %1$d yıllık veri gerekiyor. +Sıra dışı değerleri saptamak için en az %1$d ölçüm gerekiyor. +En iyi ay +Artıyor +Azalıyor +Sabit +Aylık değişim +Oynaklık +Sabit +Orta +Yüksek +Durağan dönem +%1$d gün +Haftalık alışkanlıklarınız +En yüksek ölçümleriniz %1$s, en düşük ölçümleriniz %2$s günü oluyor +Ön bulgu — güvenilir sonuç için daha düzenli ölçüm yapın +Yılınıza bakış +Değerleriniz %1$s ayında yükselip %2$s ayında düşme eğiliminde +Yalnızca bir yıllık veri var — mevsimsel örüntüler için ölçmeyi sürdürün +Sıra dışı ölçümler +Sıra dışı ölçüm saptanmadı. +Beklenen %2$s değerinden %1$s +"%1$s" +%1$d gündür belirgin bir değişim yok — vücudunuz uyum sağlıyor olabilir. +Son dönemde düşüş eğilimi — uzun vadeli yönünüze göre olumlu bir değişim. +Son dönemde yükseliş eğilimi — uzun vadeli yönü göz önünde bulundurun. +Yüksek dalgalanma saptandı — bu yaygındır ve su tutulması gibi doğal vücut değişimlerini yansıtabilir. +Değerleriniz oldukça tutarlı — güvenilir bir veri temeli. +Vücut bileşiminiz +Bu içgörüyü hesaplamak için son %2$d gün içinde, dört ölçütün de (kilo, vücut yağı, kas, su) bulunduğu en az %1$d ölçüm gerekiyor. +Kilo ve yağ azalırken kas kütlesi korunuyor, hatta biraz artıyor — ağırlıklı olarak yağ kaybediyorsunuz. +Vücudunuz verimli biçimde yeniden bileşiyor: yağ kaybederken aynı anda belirgin kas kütlesi kazanıyorsunuz. +Vücut yağınız sabit kalırken ya da azalırken kas kütlesi kazanıyorsunuz — olumlu bir vücut bileşimi eğilimi. +Uyarı: Kilo veriyorsunuz, ancak kas kütleniz de azalıyor. +Yağ kütlesi artıyor. Bu bilinçli bir kütle artışı değilse etkinlik düzeyinizi gözden geçirmeyi düşünün. +Hem yağ hem kas artıyor — bu bir kütle artışı dönemine benziyor. Amacınız kas kazanmaksa, yağ artışının hedef aralığınızda olup olmadığını değerlendirin. +Tüm ölçütler sabit — koruma dönemindesiniz. +Henüz belirgin bir örüntü saptanmadı — kilo, vücut yağı, kas ve su içeren daha çok ölçüm gerekiyor. +%1$s içinde karışık sinyaller — kilo, yağ ve kas eğilimleri henüz belirgin bir örüntüye oturmuyor. +Yağ azalırken kas artıyor — olabilecek en iyi sonuç. +Hem yağ hem kas artıyor. +Hem yağ hem kas azalıyor. Kilo düşüyor, ancak kas kütlesi de kaybediliyor. +Yağ artarken kas azalıyor ya da sabit kalıyor — en olumsuz sonuç. +Başlangıç +Şimdi +Aylık değişim +ortalama +sabit +orta +oynak +%1$d gün durağan +kısa vadeli +uzun vadeli +Sayımlamalar için uygun ya da yapılandırılmış ölçüm türü yok. +Tartılma zamanı +Görünüm +Devingen renkler +Yüksek karşıtlık +Düzleştirme algoritması +Düzleştirme için en çok boşluk (gün) +Düzleştirme yok +Hareketli ortalama +Üstel düzleştirme +İzdüşümü göster +İzdüşüm temeli (gün) +İzdüşüm süresi (gün) +İzdüşüm modeli +Doğrusal +İkinci dereceden +Üçüncü dereceden +Sürüm: %1$s (%2$s) +Proje bilgileri +Sorumlu geliştirici +Sorumlu geliştirici simgesi +GitHub\'daki proje +Resmî proje sayfası +Ana sayfa simgesi +Yazılım lisansı ayrıntıları +Lisans simgesi +Tanılama +Dosyaya günlükleme simgesi +Dosyaya günlükleme +Dosyaya günlükleme açılsın mı? +Etkinleştirildiğinde uygulama, ayrıntılı günlükleri aygıtınıza yerel olarak kaydeder. Bunlar sorun gidermede işe yarar. Dosyalar duyarlı bilgiler içerebilir; yalnızca güvendiğiniz kişilerle paylaşın. +Günlüklemeyi açmak hemen yeni bir günlük oturumu başlatır. Yeni bir günlük dosyası oluşturulur ve eskisi silinir. +Dosyaya günlükleme açıldı +Dosyaya günlükleme kapatıldı +Günlük dosyası dışa aktarıldı. +Günlük dosyası dışa aktarılırken hata oluştu. +Dışa aktarılacak günlük dosyası bulunamadı. +Dışa aktarılabilecek günlük dosyası yok. +Dışa aktarma iptal edildi. +Dosyayı dışa aktarmak için uygun bir uygulama bulunamadı. +Günlük dosyasını dışa aktar +Ölçümleri dışa aktar (CSV) +Ölçümleri içe aktar (CSV) +Veritabanını yedekle +Veritabanını geri yükle +Tüm ölçüm verilerini sil +Tüm veritabanını sil +Tehlikeli bölge +Bilinmeyen hata +Kendiliğinden yedekleme +Kendiliğinden yedeklemeyi etkinleştir +Kendiliğinden yedeklemeyi aç/kapat +Yedekleme durumu +Yedekleme durumu bilgisi +Yedek konumu +Yedekleme sıklığı +Yedekleme sıklığı ayarı +Yedekleme sıklığını değiştir +Yedekleme davranışı +Yedekleme davranışı ayarı +Son yedekleme: %1$s +Kendiliğinden yedekleme kapalı +Her zaman yeni bir yedek dosyası oluştur +Var olan yedek dosyasının üzerine yaz +Varsayılan: Uygulamaya özel klasör +Benzetim: Kullanıcı bir klasör seçti +Yedekleme sıklığını seçin +Yedek konumu yapılandırılmamış +Konum yapılandırılmamış. Kendiliğinden yedekleme duraklatıldı. +Yedek konumuna erişilirken hata oluştu +Silme simgesi +Yedek dizinini seçin +Yedek konumunu seç +Seçili klasör +Klasörü açacak bir uygulama bulunamadı. +Yedek konumu açılamadı. +Yedek konumu şuraya ayarlandı: %1$s +Klasör seçimi iptal edildi. Kendiliğinden yedekleme etkinleştirilmedi. +Son yedekleme: %1$s +Son yedekleme: Hiç +Son yedekleme %1$s tarihinde başarısız oldu: %2$s +Sonraki yedekleme: %1$s +Sonraki yedekleme: zamanlanmadı +Yedek klasörüne artık erişilemiyor. Lütfen yeniden seçin. +Seçili klasörde yedek dosyası oluşturulamadı. +Veritabanı yedek dosyasına yazılamadı. +Dışa aktarma: Kullanıcı seçin +İçe aktarma: Kullanıcı seçin +\"%1$s\" kullanıcısına ait TÜM ölçüm verilerini kalıcı olarak silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. +Tüm veritabanını silmek istediğinizden emin misiniz? Bu işlem TÜM kullanıcıları ve TÜM verileri kaldırır ve geri alınamaz. +Veriler dışa aktarıldı. +Dışa aktarma yapılamıyor: Dışa aktarım için tanımlı veri alanı yok. +Bu kullanıcı için dışa aktarılacak ölçüm yok. +Bu kullanıcı için dışa aktarılabilir ölçüm değeri bulunamadı. +Dışa aktarma başarısız: Dosya oluşturulamadı. +Dışa aktarma sırasında hata: %1$s +Dışa aktarma için kullanıcı yok. +"%1$d ölçüm kaydı içe aktarıldı." +%1$d kayıt içe aktarıldı%2$s. +"%1$d ölçüm kaydı içe aktarıldı." +"%1$d satır yok sayıldı (yinelenen zaman damgası)." +"Tarihi eksik olduğu için %1$d satır atlandı." +"Tarih çözümleme hatası nedeniyle %1$d satır atlandı." +"%1$d değer çözümlenemedi." +CSV dosyasında geçerli veri bulunamadı ya da tüm satırlarda hata var. +İçe aktarma başarısız: CSV başlığında zorunlu tarih sütunu eksik. +İçe aktarma başarısız: Dosya okunamadı. +İçe aktarma sırasında hata: %1$s +İçe aktarma için kullanıcı yok. +Silinecek kullanıcı verisi yok. +%1$s kullanıcısına ait tüm ölçüm verileri silindi. +%1$s için ölçüm verisi bulunamadı. +%1$s için veriler silinirken hata oluştu. +Silme işlemi başlatılamadı: Kullanıcı seçilmedi. +Hata: Veritabanı adı alınamadı. +Hata: Ana veritabanı dosyası \'%1$s\' bulunamadı. +Yedekleme başarısız: Seçili konumda yedek dosyası oluşturulamadı. +Veritabanı yedeklenirken hata: %1$s +Veritabanı yedeklendi. +Geri yükleme başarısız: Yedek dosyası okunamadı. +Geri yükleme başarısız: Yedek dosyası geçerli bir ZIP arşivi değil ya da bozuk. +Geri yükleme başarısız: Yedekte gerekli veritabanı dosyaları bulunamadı. +Veritabanı geri yüklenirken hata: %1$s +Veritabanı geri yüklendi. Lütfen uygulamayı yeniden başlatın. +Tüm veritabanı silindi. +Veritabanı silinirken hata oluştu. +Değerlendirme yapılamıyor: Ölçüm anında eşleşen yaş aralığı yok. +Sıra dışı değer: makul aralığın dışında (%%%1$.0f–%2$.0f). +Bu tartı eşleştirilmemiş!\n\nEşleştirme kipine geçirmek için tartının altındaki düğmeyi basılı tutun, ardından aygıt parolasını almak için yeniden bağlanın. +Eşleştirme başarılı!\n\nÖlçüm verilerini almak için yeniden bağlanın. +Tartıda yeni kullanıcı oluşturun. +Pil düzeyi düşük (%%%d), lütfen tartının pillerini şarj edin ya da değiştirin +Referans ölçümler için lütfen çıplak ayakla tartıya çıkın +Kilo ölçülüyor: %.2f +Tartıda eşzamanlı en çok kullanıcı sayısına ulaşıldı +kullanıcı bilgileri ve kayıtlı tüm ölçümler dahil olmak üzere openScale verilerini okuma/yazma +openScale verilerini okuma ve yazma +Tartıda kullanıcı seçin +Listeden kullanıcınızı seçin ya da yeni bir kullanıcı oluşturun. +Kullanıcı seçimi +Onay kodunu girin +Lütfen tartınızda görünen kodu girin. +Kod girişi +Onay kodu +Kullanıcı listesi boş ya da bozuk. +Hata: Kullanıcı verileri doğru yüklenemedi. +Lütfen bir kullanıcı seçin. +Lütfen geçerli bir kod girin. +%1$d ölçüm kaydedildi +Lütfen çıplak ayakla tartıya çıkın +Var olan eşleme kullanılıyor: uygulama kullanıcısı %1$d ↔ tartı yuvası %2$d. +Lütfen %1$d numaralı yuva için onay kodunu girin. +Onay kodu yok. Ölçümler okunabilir, ancak kullanıcı ayarları değiştirilemez. +Tüm işlevler için tartıda yeni bir kullanıcı oluşturun. +Tartıda yeni kullanıcı oluşturuluyor… +Kullanıcı eşlemesi bulunamadı; tartıda yeni kullanıcı oluşturuluyor… +Bildirimler yoluyla ölçüm bekleniyor. +Uygulama kullanıcısı %1$d, tartı yuvası %2$d ile eşlendi. +Tartının pili zayıf (%%%1$d). +Tartıda kullanıcı oluşturulamadı (kod %1$d). +Tartıda boş kullanıcı yuvası yok. Aygıttan bir kullanıcı silip yeniden deneyin. +%1$s için bildirimler etkinleştirilemedi. +%1$s aygıtına yazma başarısız. +%1$s için bildirim durumu değiştirilemedi. +Onay kodu kabul edilmedi. Lütfen denetleyip yeniden deneyin. +Onay kodu kabul edilmedi. Lütfen denetleyip yeniden deneyin. +Kullanıcı geri bildirimi iletilirken hata: %1$s +Yeniden bağlanılıyor… (deneme %1$d/%2$d) +Bildirimler yoluyla ölçüm bekleniyor. +setNotifyOn(%1$s) için çevre birim yok +%1$s özelliği bulunamadı +write(%1$s) için çevre birim yok +read(%1$s) için çevre birim yok +%1$s aygıtına yazma başarısız: %2$s +%1$s için bildirim durumu başarısız: %2$s +Kullanıcı seçilmedi +Hata +İşleyici bağlanırken başarısız oldu: %1$s +%1$s için işleyici çözümleme hatası: %2$s +Bu aygıtta Bluetooth bağdaştırıcısı bulunamadı. +Aygıt bulunamadı. Açık ve menzilde olduğundan emin olun. +%1$s kullanıcısı için tartı yuvası %2$s ile var olan eşleme kullanılıyor. Kendiliğinden onay deneniyor. +Geçersiz PIN biçimi. Lütfen geçerli bir sayı girin. +Tartıda kullanıcı kaydedilemedi. Hata: %1$s +Tartıda kullanıcı onaylanamadı. Hata: %1$s +Tartıdan kullanıcı listesi alınamadı. Hata: %1$s +Kullanıcı kurulumu başarısız. Tartıdan kullanıcı listesi alınamadı. +Tartıda yeni kullanıcı oluşturun (UDS ile atanan yuva) +Yuva P%1$d: %2$s (geçerli uygulama kullanıcısı) +Yuva P%1$d: %2$s tarafından kullanılıyor (devralmak için seçin) +Yuva P%1$d: Dolu (eşlenmemiş) +P%1$d: %2$s (geçerli) +P%1$d: %2$s tarafından kullanılıyor (devralınsın mı?) +P%1$d: Dolu (eşlenmemiş) +P%1$d: \'%2$s\' kullanıcısını burada oluştur From 1acbe48a68ca50ae0a78fd8bdcd79253fb35ac9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Abdullah=20G=C3=BCltekin?= <103918821+ahmetabdullahgultekin@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:36:15 +0300 Subject: [PATCH 30/72] Add the missing test for Wla25BodyComposition (#1456) Every other file in core/bluetooth/libs has a matching test - 17 libs, 16 tests. This adds the one that was missing. The class documents three rounding steps as load-bearing and pins the rounding mode with two exact values; both are asserted here through compute(), since round1 is private and rounds the incoming weight before anything else. The validity gate's asymmetry - slots 0 and 5 checked against 1.0, the other eight against 100.0 - is covered per slot, because that asymmetry is what fixes the ordering of the ten impedances. The remaining assertions are on invariants the source states outright (the 3-60% fat clamp, visceral fat 1-20, fat-free mass as weight minus fat, BMR from Katch-McArdle), so they hold independently of the regression constants and cannot go stale if those are ever retuned. --- .../libs/Wla25BodyCompositionTest.kt | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/Wla25BodyCompositionTest.kt diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/Wla25BodyCompositionTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/Wla25BodyCompositionTest.kt new file mode 100644 index 000000000..3a9774acf --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/Wla25BodyCompositionTest.kt @@ -0,0 +1,177 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [Wla25BodyComposition]. + * + * The class documents three rounding details as "load-bearing … each worth a tenth of a + * unit", and two exact values that pin the rounding mode down. Those are asserted here so + * the behaviour cannot drift silently: + * + * - `round1(26.35)` is **26.4**; half-to-even would give 26.3. + * - `round1(1.95)` is **1.9**; 1.95 has a float32 fraction of exactly 0.95, so the half-up + * test fails and the value rounds down. + * + * `round1` is private, but [Wla25BodyComposition.compute] rounds the incoming weight with it + * before anything else and reports the result in [Wla25BodyComposition.Result.weightKg], so + * the two cases can be asserted through the public API without relaxing its visibility. + * + * The remaining tests cover the documented validity gate and the clamps, which are stated in + * the source as invariants rather than derived from vendor output — so they hold regardless + * of the regression constants. + */ +class Wla25BodyCompositionTest { + + /** + * Impedances that satisfy the gate: slots 0 and 5 carry the small leading value of each + * group (~15-25 Ω), the other eight are the large ones (~300 Ω). + */ + private fun validImpedances() = doubleArrayOf( + 20.0, 300.0, 320.0, 310.0, 305.0, + 18.0, 300.0, 315.0, 308.0, 302.0, + ) + + // --- the validity gate --------------------------------------------------- + + @Test + fun `accepts a well-formed impedance set`() { + assertThat(Wla25BodyComposition.impedancesValid(validImpedances())).isTrue() + } + + @Test + fun `rejects an array that is not exactly ten values`() { + assertThat(Wla25BodyComposition.impedancesValid(DoubleArray(9) { 300.0 })).isFalse() + assertThat(Wla25BodyComposition.impedancesValid(DoubleArray(11) { 300.0 })).isFalse() + assertThat(Wla25BodyComposition.impedancesValid(DoubleArray(0))).isFalse() + } + + @Test + fun `leading slots are checked against one, not against a hundred`() { + // 20 Ω in slot 0 or 5 is normal and must pass; the same value in any other slot + // is below that slot's 100 Ω floor and must fail. This asymmetry is what pins the + // ordering of the ten values, so it is worth guarding explicitly. + val lowLeading = validImpedances().also { it[0] = 1.0; it[5] = 1.0 } + assertThat(Wla25BodyComposition.impedancesValid(lowLeading)).isTrue() + + for (slot in intArrayOf(1, 2, 3, 4, 6, 7, 8, 9)) { + val tooLow = validImpedances().also { it[slot] = 20.0 } + assertThat(Wla25BodyComposition.impedancesValid(tooLow)).isFalse() + } + } + + @Test + fun `rejects leading slots below one`() { + assertThat(Wla25BodyComposition.impedancesValid(validImpedances().also { it[0] = 0.5 })) + .isFalse() + assertThat(Wla25BodyComposition.impedancesValid(validImpedances().also { it[5] = 0.5 })) + .isFalse() + } + + @Test + fun `compute returns null when the gate fails`() { + assertThat(Wla25BodyComposition.compute(170, 70.0, DoubleArray(10))).isNull() + } + + // --- BMI ----------------------------------------------------------------- + + @Test + fun `bmi follows the standard formula`() { + // 70 kg at 175 cm -> 70 / 1.75^2 = 22.857… + assertThat(Wla25BodyComposition.bmi(175, 70.0)).isWithin(1e-9).of(22.857142857142858) + // At 100 cm the divisor is 1.0, so BMI equals the weight — used by the tests below. + assertThat(Wla25BodyComposition.bmi(100, 26.4)).isWithin(1e-9).of(26.4) + } + + // --- the documented rounding --------------------------------------------- + + @Test + fun `round1 is half-up, so 26_35 becomes 26_4`() { + val result = Wla25BodyComposition.compute(100, 26.35, validImpedances()) + assertThat(result).isNotNull() + // Half-to-even would give 26.3 here. + assertThat(result!!.weightKg).isEqualTo(26.4f) + } + + @Test + fun `round1 rounds 1_95 down, because float32 makes the half-up test fail`() { + val result = Wla25BodyComposition.compute(100, 1.95, validImpedances()) + assertThat(result).isNotNull() + assertThat(result!!.weightKg).isEqualTo(1.9f) + } + + @Test + fun `a weight already at one decimal is unchanged`() { + val result = Wla25BodyComposition.compute(175, 70.4, validImpedances()) + assertThat(result).isNotNull() + assertThat(result!!.weightKg).isEqualTo(70.4f) + } + + // --- clamps and internal consistency ------------------------------------- + + @Test + fun `body fat stays inside the documented clamp`() { + // The clamp is stated in the source as 3.0…60.0 percent. + for (weight in doubleArrayOf(1.9, 45.0, 70.0, 120.0, 250.0)) { + val result = Wla25BodyComposition.compute(175, weight, validImpedances()) + assertThat(result).isNotNull() + assertThat(result!!.fat).isAtLeast(3.0f) + assertThat(result.fat).isAtMost(60.0f) + } + } + + @Test + fun `visceral fat stays inside its one to twenty range`() { + for (weight in doubleArrayOf(1.9, 45.0, 70.0, 120.0, 250.0)) { + val result = Wla25BodyComposition.compute(175, weight, validImpedances()) + assertThat(result).isNotNull() + assertThat(result!!.visceralFat).isAtLeast(1) + assertThat(result.visceralFat).isAtMost(20) + } + } + + @Test + fun `fat-free mass is the rounded weight minus the fat it implies`() { + val result = Wla25BodyComposition.compute(175, 70.0, validImpedances()) + assertThat(result).isNotNull() + // lbm = weight - roundedFat, so the two must add back up to the rounded weight. + val impliedFatMass = result!!.weightKg - result.lbmKg + assertThat(impliedFatMass + result.lbmKg).isWithin(1e-3f).of(result.weightKg) + assertThat(result.lbmKg).isLessThan(result.weightKg) + } + + @Test + fun `muscle mass in kg agrees with muscle percent`() { + val result = Wla25BodyComposition.compute(175, 70.0, validImpedances()) + assertThat(result).isNotNull() + val expectedKg = result!!.musclePercent / 100f * result.weightKg + // muscleKg is round1() of that product, so allow a tenth. + assertThat(result.muscleKg).isWithin(0.05f).of(expectedKg) + } + + @Test + fun `bmr is derived from fat-free mass by the Katch-McArdle constants`() { + val result = Wla25BodyComposition.compute(175, 70.0, validImpedances()) + assertThat(result).isNotNull() + val expected = (result!!.lbmKg * 21.6 + 370.0).toInt() + assertThat(result.bmrKcal).isEqualTo(expected) + } +} From 2bae837839805b14bf1d9a7d7893e02ae267a5ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Abdullah=20G=C3=BCltekin?= <103918821+ahmetabdullahgultekin@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:47:28 +0300 Subject: [PATCH 31/72] Keep intValue populated when aggregating INT-typed measurements (#1457) Aggregation averaged the values but wrote intValue = null, while copying the type unchanged - so the synthetic entry still reported InputFieldType.INT. Every consumer switches on inputType and reads intValue, so an INT-typed measurement read as absent once any aggregation level other than NONE was selected: - MeasurementChart: yValue resolves to null, the series disappears - StatisticsScreen: min/max/avg/first/last/difference all null - MeasurementSmoothingUseCases: the type is dropped from smoothing Heart rate is InputFieldType.INT, as are INT custom types, so switching the statistics screen to weekly aggregation made those cards render Min/Max/Avg as - with an empty chart, while daily raw data rendered fine. The average is rounded rather than truncated, so 60 and 65 aggregate to 63 rather than 62. floatValue stays populated as before, so consumers reading either field agree. Verified: the two new INT tests fail on master and pass with this change; 7/7 green afterwards. --- .../usecase/MeasurementAggregationUseCase.kt | 10 ++- .../MeasurementAggregationUseCaseTest.kt | 67 +++++++++++++++++++ .../com/health/openscale/testutil/Fixtures.kt | 7 ++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementAggregationUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementAggregationUseCase.kt index ce5def8c7..a32ba44a4 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementAggregationUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementAggregationUseCase.kt @@ -32,6 +32,7 @@ import java.time.temporal.WeekFields import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.roundToInt /** * Use case that aggregates a list of [EnrichedMeasurement] into a smaller list of @@ -160,7 +161,14 @@ class MeasurementAggregationUseCase @Inject constructor() { id = -1, measurementId = -1, floatValue = avg, - intValue = null, + // The synthetic value keeps the original type, so consumers still switch on + // InputFieldType.INT and read intValue. Leaving it null made INT-typed + // measurements read as absent everywhere downstream. + intValue = if (vwt.type.inputType == InputFieldType.INT) { + avg.roundToInt() + } else { + null + }, ) vwt.copy(value = syntheticVal) } diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt index 126568755..5294d8148 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.usecase import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.AggregationLevel +import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.model.EnrichedMeasurement import com.health.openscale.testutil.Fixtures @@ -80,4 +81,70 @@ class MeasurementAggregationUseCaseTest { assertThat(out).hasSize(2) assertThat(out.map { it.aggregatedFromCount }).containsExactly(1, 1) } + + // --- INT-typed measurements ------------------------------------------------ + // The aggregated value keeps the original type, so every consumer switches on + // InputFieldType.INT and reads intValue. If only floatValue is populated, an + // INT-typed measurement reads as absent in the chart, the statistics screen and + // smoothing alike. + + private val heartRate = Fixtures.type( + id = 2, + key = MeasurementTypeKey.HEART_RATE, + inputType = InputFieldType.INT, + ) + + private fun emInt(measurementId: Int, timestamp: Long, value: Int): EnrichedMeasurement = + Fixtures.enriched( + Fixtures.mwv( + measurementId = measurementId, + timestamp = timestamp, + values = listOf(Fixtures.intValueWithType(heartRate, value, measurementId)), + ) + ) + + @Test + fun aggregate_day_populatesIntValueForIntTypedMeasurements() { + val input = listOf( + emInt(1, Fixtures.ts(2025, 4, 7, 8), 60), + emInt(2, Fixtures.ts(2025, 4, 7, 20), 70), + ) + val out = useCase.aggregate(input, AggregationLevel.DAY) + + assertThat(out).hasSize(1) + val aggregated = out[0].enriched.measurementWithValues.values + .first { it.type.id == heartRate.id }.value + + assertThat(aggregated.intValue).isEqualTo(65) + // floatValue stays populated too, so consumers reading either field agree. + assertThat(aggregated.floatValue).isNotNull() + assertThat(aggregated.floatValue!!).isWithin(1e-3f).of(65f) + } + + @Test + fun aggregate_day_roundsIntValueRatherThanTruncating() { + // 60 and 65 average to 62.5 — truncation would report 62. + val input = listOf( + emInt(1, Fixtures.ts(2025, 4, 7, 8), 60), + emInt(2, Fixtures.ts(2025, 4, 7, 20), 65), + ) + val out = useCase.aggregate(input, AggregationLevel.DAY) + + val aggregated = out[0].enriched.measurementWithValues.values + .first { it.type.id == heartRate.id }.value + assertThat(aggregated.intValue).isEqualTo(63) + } + + @Test + fun aggregate_leavesIntValueNullForFloatTypedMeasurements() { + val input = listOf( + em(1, Fixtures.ts(2025, 4, 7, 8), 80f), + em(2, Fixtures.ts(2025, 4, 7, 20), 82f), + ) + val out = useCase.aggregate(input, AggregationLevel.DAY) + + val aggregated = out[0].enriched.measurementWithValues.values + .first { it.type.id == weight.id }.value + assertThat(aggregated.intValue).isNull() + } } diff --git a/android_app/app/src/test/java/com/health/openscale/testutil/Fixtures.kt b/android_app/app/src/test/java/com/health/openscale/testutil/Fixtures.kt index 04dd0ef2f..5d17514a8 100644 --- a/android_app/app/src/test/java/com/health/openscale/testutil/Fixtures.kt +++ b/android_app/app/src/test/java/com/health/openscale/testutil/Fixtures.kt @@ -62,6 +62,13 @@ object Fixtures { type = type, ) + /** As [valueWithType], but for a type whose [MeasurementType.inputType] is INT. */ + fun intValueWithType(type: MeasurementType, value: Int, measurementId: Int = 0): MeasurementValueWithType = + MeasurementValueWithType( + value = MeasurementValue(measurementId = measurementId, typeId = type.id, intValue = value), + type = type, + ) + fun mwv( measurementId: Int, timestamp: Long, From 7161ca01d3bccb67500d80d697da3d6b066abf02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Abdullah=20G=C3=BCltekin?= <103918821+ahmetabdullahgultekin@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:01:21 +0300 Subject: [PATCH 32/72] Cult Smart Scale Pro: publish muscle as a percentage, not kilograms (#1458) ScaleMeasurement.muscle is declared as 'must be in percentage', but this handler assigned skeletalMuscleMassKg. The sibling assignments in the same block get it right - fat uses totalFatPercentage and water uses totalBodyWaterPercentage - and the three other handlers driving StandardImpedanceLib all use skeletalMusclePercentage. The log lines said muscle=...kg, so the unit was known at the call site; the field it was written into is the percentage one. Users of this scale saw a kilogram figure wherever a muscle percentage was expected, in every screen and every export. Also corrects those two log lines to %. --- .../core/bluetooth/scales/CultSmartScaleProHandler.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt index cfb94f98f..05b538aaf 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt @@ -272,7 +272,7 @@ class CultSmartScaleProHandler : ScaleDeviceHandler() { m.fat = lib.totalFatPercentage.toFloat().coerceIn(0f, 75f) m.water = lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) - m.muscle = lib.skeletalMuscleMassKg.toFloat().coerceIn(0f, 100f) + m.muscle = lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f) m.bone = lib.boneMassKg.toFloat().coerceIn(0f, 10f) m.lbm = lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f) m.bmr = lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) @@ -280,12 +280,12 @@ class CultSmartScaleProHandler : ScaleDeviceHandler() { logI( "body comp (StandardImpedanceLib, impedance=${pendingImpedance}Ω): " + - "fat=${m.fat}% water=${m.water}% muscle=${m.muscle}kg " + + "fat=${m.fat}% water=${m.water}% muscle=${m.muscle}% " + "bone=${m.bone}kg lbm=${m.lbm}kg bmr=${m.bmr}kcal" ) } - logI("publishing → weight=${m.weight}kg fat=${m.fat}% water=${m.water}% muscle=${m.muscle}kg bmr=${m.bmr}kcal impedance=${m.impedance}Ω") + logI("publishing → weight=${m.weight}kg fat=${m.fat}% water=${m.water}% muscle=${m.muscle}% bmr=${m.bmr}kcal impedance=${m.impedance}Ω") publish(m) } From 3841f905791461c0e3ae07bfa740a07cdd8fd641 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Tue, 11 Aug 2026 18:11:29 +0200 Subject: [PATCH 33/72] Store synced values in the column their input type requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ContentProvider's values_json path only ever wrote floatValue. INT values landed in the wrong column and read as absent everywhere downstream, and TEXT, DATE and TIME were dropped entirely — parse() bailed out on a missing "value" key before it ever looked at "text". GenericValueJson.parse now returns the type together with a MeasurementValue that has the right column filled, so both provider call sites just store what they get. The wire format is unchanged; build() is untouched. Existing rows keep their misfiled values — repairing those needs a migration. --- .../core/database/DatabaseProvider.kt | 30 ++++---- .../openscale/core/usecase/SyncUseCases.kt | 36 ++++++++-- .../core/usecase/GenericValueJsonTest.kt | 71 ++++++++++++++++--- 3 files changed, 107 insertions(+), 30 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt index 670d9ece9..64c5b6cd5 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt @@ -288,10 +288,10 @@ class DatabaseProvider : ContentProvider() { val typesByKey = allMeasurementTypes.associateBy { it.key.name } val typesById = allMeasurementTypes.associateBy { it.id } val existingTypeIds = measurementValuesToInsert.mapTo(HashSet()) { it.typeId } - GenericValueJson.parse(valuesJson, typesByKey, typesById).forEach { (typeId, v) -> - if (typeId !in existingTypeIds) { - measurementValuesToInsert.add(MeasurementValue(measurementId = 0, typeId = typeId, floatValue = v)) - existingTypeIds.add(typeId) + GenericValueJson.parse(valuesJson, typesByKey, typesById).forEach { (type, parsedValue) -> + if (type.id !in existingTypeIds) { + measurementValuesToInsert.add(parsedValue) + existingTypeIds.add(type.id) } } } @@ -486,21 +486,27 @@ class DatabaseProvider : ContentProvider() { if (values.containsKey(MeasurementColumns.WATER)) typeMap[MeasurementTypeKey.WATER]?.id?.let(::add) if (values.containsKey(MeasurementColumns.MUSCLE)) typeMap[MeasurementTypeKey.MUSCLE]?.id?.let(::add) } - GenericValueJson.parse(valuesJson, typesByKey, typesById).forEach { (typeId, userValue) -> - if (typeId in handledTypeIds) return@forEach - val existingValue = existingMeasurementWithValues.values.find { it.type.id == typeId } + GenericValueJson.parse(valuesJson, typesByKey, typesById).forEach { (type, parsedValue) -> + if (type.id in handledTypeIds) return@forEach + val existingValue = existingMeasurementWithValues.values.find { it.type.id == type.id } if (existingValue != null) { - if (existingValue.value.floatValue != userValue) { - databaseRepository.updateMeasurementValue(existingValue.value.copy(floatValue = userValue)) + // Compare whole rows: the payload field differs per input type, + // and the parsed value is already in its stored form. + val updated = parsedValue.copy( + id = existingValue.value.id, + measurementId = existingValue.value.measurementId, + ) + if (updated != existingValue.value) { + databaseRepository.updateMeasurementValue(updated) anyChangeMade = true - LogManager.d(TAG, "Updated typeId=$typeId for measurement ${measurementToUpdate.id} to $userValue (values_json)") + LogManager.d(TAG, "Updated typeId=${type.id} for measurement ${measurementToUpdate.id} (values_json)") } } else { databaseRepository.insertMeasurementValue( - MeasurementValue(measurementId = measurementToUpdate.id, typeId = typeId, floatValue = userValue) + parsedValue.copy(measurementId = measurementToUpdate.id) ) anyChangeMade = true - LogManager.d(TAG, "Inserted typeId=$typeId for measurement ${measurementToUpdate.id} = $userValue (values_json)") + LogManager.d(TAG, "Inserted typeId=${type.id} for measurement ${measurementToUpdate.id} (values_json)") } } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt index 261080919..01bb20822 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt @@ -36,6 +36,7 @@ import org.json.JSONObject import java.util.Date import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.roundToInt /** * Bundles sync trigger use cases for the external SyncService. @@ -285,26 +286,47 @@ object GenericValueJson { } /** - * Reverse of [build] (inbound): parse a generic value JSON into (typeId, valueInUserUnit) pairs. + * Reverse of [build] (inbound): parse a generic value JSON into (type, value) pairs, the value + * already in the user's unit and in the field the type's [MeasurementType.inputType] requires. * Predefined types are matched by [typesByKey] (enum name), custom by [typesById] (typeId). + * + * The returned [MeasurementValue]s carry `measurementId = 0` — the caller owns that. + * + * Filling the right field here rather than at the call sites is deliberate: consumers read + * only the field their input type designates, so a value in the wrong one reads as *absent*. + * Entries that carry nothing this type can hold are skipped. */ fun parse( json: String, typesByKey: Map, typesById: Map - ): List> { - val out = mutableListOf>() + ): List> { + val out = mutableListOf>() runCatching { val arr = JSONArray(json) for (i in 0 until arr.length()) { val o = arr.getJSONObject(i) - if (!o.has("value")) continue val key = o.optString("key", "") val type = if (key == "CUSTOM") typesById[o.optInt("typeId", -1)] else typesByKey[key] if (type == null) continue - val canonical = o.getDouble("value").toFloat() - val userValue = ConverterUtils.convertFloatValueUnit(canonical, canonicalUnit(type.unit), type.unit) - out.add(type.id to userValue) + val empty = MeasurementValue(measurementId = 0, typeId = type.id) + val parsed: MeasurementValue? = when (type.inputType) { + InputFieldType.FLOAT, InputFieldType.INT -> { + if (!o.has("value")) null else { + val canonical = o.getDouble("value").toFloat() + val userValue = ConverterUtils.convertFloatValueUnit(canonical, canonicalUnit(type.unit), type.unit) + if (type.inputType == InputFieldType.FLOAT) empty.copy(floatValue = userValue) + else empty.copy(intValue = userValue.roundToInt()) + } + } + InputFieldType.TEXT -> + if (!o.has("text")) null else empty.copy(textValue = o.optString("text")) + // build() sends dateValue verbatim as epoch millis in a decimal string. + InputFieldType.DATE, InputFieldType.TIME -> + o.optString("text", "").toLongOrNull()?.let { empty.copy(dateValue = it) } + InputFieldType.USER -> null + } + if (parsed != null) out.add(type to parsed) } } return out diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt index 2abf7afff..34388dcb3 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt @@ -31,8 +31,9 @@ import org.robolectric.annotation.Config /** * Round-trip coverage for [GenericValueJson], the self-describing value payload shared by the * sync Intent and the ContentProvider. The provider's insert AND update paths rely on - * [GenericValueJson.parse] returning every numeric type (incl. custom) in the user's unit, so a - * build -> parse round-trip must preserve the values regardless of unit or predefined/custom type. + * [GenericValueJson.parse] returning every type (incl. custom) in the user's unit and in the + * field its input type requires, so a build -> parse round-trip must preserve the values + * regardless of unit, input type, or predefined/custom type. * * Runs under Robolectric because [GenericValueJson] uses android's org.json implementation. */ @@ -53,8 +54,21 @@ class GenericValueJsonTest { val noteCustomText = MeasurementType( id = 60, key = MeasurementTypeKey.CUSTOM, name = "Note", unit = UnitType.NONE, inputType = InputFieldType.TEXT ) + // Non-FLOAT input types: each lives in a different column of MeasurementValue. + val heartRateBpm = MeasurementType( + id = 4, key = MeasurementTypeKey.HEART_RATE, unit = UnitType.BPM, inputType = InputFieldType.INT + ) + val dateCustom = MeasurementType( + id = 61, key = MeasurementTypeKey.CUSTOM, name = "Cycle start", unit = UnitType.NONE, inputType = InputFieldType.DATE + ) + val timeCustom = MeasurementType( + id = 62, key = MeasurementTypeKey.CUSTOM, name = "Bedtime", unit = UnitType.NONE, inputType = InputFieldType.TIME + ) - val allTypes = listOf(weightKg, fatPercent, waistInch, chestCustomCm, noteCustomText) + val allTypes = listOf( + weightKg, fatPercent, waistInch, chestCustomCm, noteCustomText, + heartRateBpm, dateCustom, timeCustom, + ) val typesById = allTypes.associateBy { it.id } val typesByKey = allTypes.associateBy { it.key.name } } @@ -62,13 +76,17 @@ class GenericValueJsonTest { private fun value(typeId: Int, float: Float? = null, text: String? = null) = MeasurementValue(measurementId = 1, typeId = typeId, floatValue = float, textValue = text) - private fun roundTrip(values: List): Map = + /** parse() returns the type itself; these assertions only care about which type it was. */ + private fun roundTrip(values: List): Map = GenericValueJson.parse(GenericValueJson.build(values, typesById), typesByKey, typesById) - .toMap() + .associate { (type, parsed) -> type.id to parsed } + + private fun roundTripFloats(values: List): Map = + roundTrip(values).mapValues { (_, parsed) -> parsed.floatValue } @Test fun roundTrip_preservesNumericValuesAcrossPredefinedCustomAndUnits() { - val parsed = roundTrip( + val parsed = roundTripFloats( listOf( value(weightKg.id, 80.5f), // canonical unit, exact value(fatPercent.id, 18.4f), // percent, no conversion @@ -84,17 +102,47 @@ class GenericValueJsonTest { } @Test - fun roundTrip_skipsNonNumericValues() { - // A TEXT value carries no "value" field, so it must not appear in the parsed numeric set. + fun roundTrip_putsEachInputTypeInItsOwnColumn() { + // The failure this guards: a value in the wrong column reads as *absent* downstream, + // because every consumer reads only the column its input type designates. val parsed = roundTrip( listOf( value(weightKg.id, 80.5f), + MeasurementValue(measurementId = 1, typeId = heartRateBpm.id, intValue = 65), value(noteCustomText.id, text = "morning weigh-in"), + MeasurementValue(measurementId = 1, typeId = dateCustom.id, dateValue = 1_754_899_200_000L), + MeasurementValue(measurementId = 1, typeId = timeCustom.id, dateValue = 81_000_000L), ) ) - assertThat(parsed).containsKey(weightKg.id) - assertThat(parsed).doesNotContainKey(noteCustomText.id) + assertThat(parsed[weightKg.id]!!.floatValue).isWithin(EPS).of(80.5f) + assertThat(parsed[weightKg.id]!!.intValue).isNull() + + assertThat(parsed[heartRateBpm.id]!!.intValue).isEqualTo(65) + assertThat(parsed[heartRateBpm.id]!!.floatValue).isNull() + + assertThat(parsed[noteCustomText.id]!!.textValue).isEqualTo("morning weigh-in") + assertThat(parsed[dateCustom.id]!!.dateValue).isEqualTo(1_754_899_200_000L) + assertThat(parsed[timeCustom.id]!!.dateValue).isEqualTo(81_000_000L) + } + + @Test + fun parse_returnsValuesWithoutAMeasurementId() { + // The caller owns the measurementId — parse() must not invent one. + val parsed = roundTrip(listOf(value(weightKg.id, 80.5f))) + + assertThat(parsed[weightKg.id]!!.measurementId).isEqualTo(0) + } + + @Test + fun parse_skipsAnEntryThatCarriesNothingItsTypeCanHold() { + // build() never emits "value" for a TEXT type, but a foreign producer might. There is no + // column for it, so the entry is dropped rather than written to floatValue. + val json = """[{"typeId":${noteCustomText.id},"key":"CUSTOM","unit":"","value":42.0}]""" + + val parsed = GenericValueJson.parse(json, typesByKey, typesById) + + assertThat(parsed).isEmpty() } @Test @@ -108,7 +156,8 @@ class GenericValueJsonTest { val reducedById = typesById.filterKeys { it != waistInch.id } val reducedByKey = typesByKey.filterKeys { it != waistInch.key.name } - val parsed = GenericValueJson.parse(json, reducedByKey, reducedById).toMap() + val parsed = GenericValueJson.parse(json, reducedByKey, reducedById) + .associate { (type, value) -> type.id to value } assertThat(parsed).containsKey(weightKg.id) assertThat(parsed).doesNotContainKey(waistInch.id) From e2069f77f657e1ea264979dfa84eccb6461f8317 Mon Sep 17 00:00:00 2001 From: psuedo-tty <306883593+psuedo-tty@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:36:32 -0400 Subject: [PATCH 34/72] Add support for the Weight Gurus 0376 scale (#1467) * Add support for the Weight Gurus 0376 scale Greater Goods / DMD Brands 0376B, a Transtek A3 device. Body composition, time sync and user sync over the vendor service 0d005750-c36b-11e3-9c1a- 0800200c9a66, plus the stored history the scale hands over on connect. Pairing needs three things the scale is strict about. It streams all eight user slots and the host must stay silent until the last one has arrived; answering earlier drops the scale into E1 instead of completing. The 0x51 profile carries height as an IEEE-11073 SFLOAT in metres, not raw centimetres, so 170 cm goes out as 0xD6A4. And 0x22 is followed by waiting for the scale to close the link itself, because tearing it down from this side interrupts the commit and is reported as E1 as well. The scale computes body composition on-device from the profile stored in whichever slot the reading is attributed to, and slot selection is a button on the scale that the host cannot influence. A slot left holding a stale height and age therefore produces wrong percentages for the right weight. Registering into that slot corrects it, so the handler asks which of the eight slots the user is, listing the names read off the scale, and writes the openScale profile there. The choice is persisted and reused after that. Deduplication matches on exact timestamp instead of tracking the newest stored measurement. The scale keeps a separate queue per slot, drains only the selected one, and delivers newest first, so readings legitimately arrive older than ones already held; a high-water mark discards them, and since the scale drops a reading once delivered, anything discarded is lost. Decoded weight matches the scale's own display, and impedance is reported for handlers downstream. * Document the Weight Gurus 0376 protocol Frame layouts, command encodings and the sync behaviour behind WeightGurusA3Handler, so the parts that are easy to get wrong are written down instead of rediscovered: the SFLOAT-in-metres height, the wait for all eight slot frames, per-slot measurement queues that drain newest first, and readings being dropped by the scale once delivered. Records what is confirmed against hardware and what is not, including the weight-frame flag 0x20 and the visceral fat and basal metabolism fields that no test weighing has exercised. * Delete docs/misc/weight_gurus_a3_protocol.md --------- Co-authored-by: OliE --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../bluetooth/scales/WeightGurusA3Handler.kt | 733 ++++++++++++++++++ .../app/src/main/res/values/strings.xml | 3 + .../scales/WeightGurusA3HandlerTest.kt | 280 +++++++ 4 files changed, 1018 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3Handler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3HandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 8f756a7c2..638bb1dd1 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -79,6 +79,7 @@ import com.health.openscale.core.bluetooth.scales.StandardBeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.TrisaBodyAnalyzeHandler import com.health.openscale.core.bluetooth.scales.TuningProfile import com.health.openscale.core.bluetooth.scales.VitafitVT701Handler +import com.health.openscale.core.bluetooth.scales.WeightGurusA3Handler import com.health.openscale.core.bluetooth.scales.YunmaiHandler import com.health.openscale.core.bluetooth.scales.YunmaiXHandler import com.health.openscale.core.facade.MeasurementFacade @@ -171,6 +172,7 @@ class ScaleFactory @Inject constructor( ActiveEraBF06Handler(), DrTrustSSW532Handler(), BodyConnectHandler(), + WeightGurusA3Handler(), ) /** diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3Handler.kt new file mode 100644 index 000000000..a885024ea --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3Handler.kt @@ -0,0 +1,733 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.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.ConverterUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Date +import java.util.TimeZone +import java.util.UUID +import kotlin.random.Random + +/** + * Weight Gurus Bluetooth Smart Scale 0376 (Greater Goods / DMD Brands, Transtek A3). + * + * Service 0d005750-c36b-11e3-9c1a-0800200c9a66: + * - 0x8A20 feature (read) + * - 0x8A22 body composition, 16-bit SFLOATs (indicate) + * - 0x8A24 weight, 32-bit FLOAT (indicate) + * - 0x8A81 commands, host to device (write) + * - 0x8A82 events, device to host (indicate) + * + * Events: 0xA0 password, 0xA1 random, 0x83 slot status, 0xC0 profile echo. + * Commands: 0x02 time, 0x03 add user, 0x20 verification, 0x21 account id, + * 0x22 enable disconnect, 0x51 profile. + * + * Pairing: 0xA0 -> 0x21; 0xA1 -> 0x20; then eight 0x83 frames, and only after the last + * -> 0x03, 0x51, 0x02, 0x22. Established session: 0xA1 -> 0x20, 0x02. + * + * Body composition is computed on the scale from the profile stored in the slot a reading is + * attributed to. Slot selection is a button on the scale and cannot be set by the host, so a + * slot holding a stale height or age yields wrong percentages for a correct weight; writing + * 0x51 to that slot corrects it. + * + * Protocol reference: `docs/misc/weight_gurus_a3_protocol.md`. + */ +class WeightGurusA3Handler : ScaleDeviceHandler() { + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + // The scale renames itself between sessions: "10376B" while it is waiting to be + // paired, "00376B" once it is. Both carry the Transtek model code 0376B. + if (!NAME_PATTERN.matches(device.name)) return null + return DeviceSupport( + displayName = "Weight Gurus Smart Scale 0376", + capabilities = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.HISTORY_READ, + DeviceCapability.BATTERY_LEVEL + ), + implemented = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + // The scale queues weigh-ins taken while disconnected and hands the whole + // backlog over on the next connection; those are stored like live readings. + DeviceCapability.HISTORY_READ + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + // --- UUIDs ---------------------------------------------------------------- + + private val SVC: UUID = UUID.fromString("0d005750-c36b-11e3-9c1a-0800200c9a66") + private val CHR_WEIGHT = uuid16(0x8A24) // device → host, weight + private val CHR_APPEND = uuid16(0x8A22) // device → host, body composition + private val CHR_DNLD = uuid16(0x8A81) // host → device, commands + private val CHR_UPLD = uuid16(0x8A82) // device → host, events + + // Device Information Service, read on connect before the pairing frames arrive. + private val DIS = uuid16(0x180A) + private val DIS_CHARS = listOf(0x2A23, 0x2A24, 0x2A25, 0x2A26, 0x2A27, 0x2A28, 0x2A29) + .map { uuid16(it) } + + // --- Upload (device → host) opcodes --------------------------------------- + + private val EVT_PASSWORD: Byte = 0xA0.toByte() + private val EVT_RANDOM: Byte = 0xA1.toByte() + private val EVT_SLOT_STATUS: Byte = 0x83.toByte() + private val EVT_PROFILE_ECHO: Byte = 0xC0.toByte() + + // --- Download (host → device) opcodes ------------------------------------- + + private val CMD_TIME: Byte = 0x02 + private val CMD_ADD_USER: Byte = 0x03 + private val CMD_VERIFICATION: Byte = 0x20 + private val CMD_ACCOUNT_ID: Byte = 0x21 + private val CMD_ENABLE_DISCONNECT: Byte = 0x22 + private val CMD_PROFILE: Byte = 0x51 + + /** Field mask the vendor app sends with the 0x51 profile command. */ + private val PROFILE_MASK: Byte = 0x17 + + /** Device timestamps are seconds since 2010-01-01 00:00:00 (0x4B3D3B00). */ + private val TS_OFFSET = 1262304000L + + // --- Session state -------------------------------------------------------- + + private var password: ByteArray? = null + + /** + * A 0x8A24 frame whose status byte announces a follow-up composition frame is held + * here until the matching 0x8A22 arrives, so both land in a single measurement. + */ + private var pendingWeight: ScaleMeasurement? = null + private var pendingTimestamp: Int? = null + + /** Guards against the same record arriving twice within a single session. */ + private val publishedAtMs = mutableSetOf() + + private var slotAcked = false + private var setupFinished = false + private var profileSent = false + + /** Slots the scale reported as empty, collected across the whole 0x83 stream. */ + private val freeSlots = mutableSetOf() + + /** Slot number to the name stored on the scale, collected across the whole 0x83 stream. */ + private val slotNames = mutableMapOf() + + /** Non-null while a slot-choice dialog is open, holding the user the answer applies to. */ + private var pendingUser: ScaleUser? = null + + /** True once the scale has handed over a password and the link is being dropped to commit it. */ + private var pairing = false + + // --- Lifecycle ------------------------------------------------------------ + + override fun onConnected(user: ScaleUser) { + setNotifyOn(SVC, CHR_UPLD) + setNotifyOn(SVC, CHR_WEIGHT) + setNotifyOn(SVC, CHR_APPEND) + + // Some firmware in this family will not finalise pairing until these have been read. + DIS_CHARS.forEach { readFrom(DIS, it) } + + password = settingsGetString(KEY_PASSWORD)?.let(::hexToBytes) + pendingWeight = null + pendingTimestamp = null + publishedAtMs.clear() + slotAcked = false + setupFinished = false + profileSent = false + pairing = false + freeSlots.clear() + slotNames.clear() + pendingUser = null + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + when (characteristic) { + CHR_UPLD -> onEvent(data, user) + CHR_WEIGHT -> onWeight(data) + CHR_APPEND -> onAppend(data) + // The Device Information reads issued in onConnected come back through here too. + in DIS_CHARS -> logD("Device info ${characteristic.shortId()}: ${data.toAsciiPreview()}") + else -> logW("Unknown characteristic: $characteristic") + } + } + + override fun onDisconnected() { + // A weight frame that never got its promised composition frame is still worth keeping. + flushPendingWeight() + } + + // --- Upload (device → host) processing ------------------------------------- + + private fun onEvent(data: ByteArray, user: ScaleUser) { + if (data.isEmpty()) return + when (data[0]) { + EVT_PASSWORD -> onPassword(data) + EVT_RANDOM -> onRandom(data, user) + EVT_SLOT_STATUS -> onSlotStatus(data, user) + EVT_PROFILE_ECHO -> onProfileEcho() + else -> logW("Unknown event opcode: ${data.toHexPreview(4)}") + } + } + + /** + * Pairing: the scale hands out a password exactly once, while it advertises as "1…". + * It has to be kept; without it no later session can pass the challenge. + */ + private fun onPassword(data: ByteArray) { + if (data.size < 5) { + logW("Password frame too short: ${data.size}b") + return + } + val pw = data.copyOfRange(1, 5) + password = pw + settingsPutString(KEY_PASSWORD, bytesToHex(pw)) + userInfo(R.string.bluetooth_scale_trisa_success_pairing) + + // Claiming an account ID is what completes pairing on the scale's side. + pairing = true + writeCommand(CMD_ACCOUNT_ID, randomAccountId()) + + // Registration, profile, clock and the closing 0x22 are all issued from the slot handler + // after the eighth 0x83 frame. No timer may race that: 0x22 sent before the stream ends + // aborts setup with E1. + // + // Nor may the link be dropped here. The scale closes it once the pairing is committed to + // flash; cutting it first interrupts the commit and is also reported as E1. Force a + // disconnect only if the scale never closes. + scope.launch { + delay(PAIRING_COMMIT_FALLBACK_MS) + if (pendingUser != null) { + logD("Still waiting for a slot to be chosen; leaving the link alone") + return@launch + } + logW("Scale did not close the link after setup; forcing disconnect") + requestDisconnect() + } + } + + private fun onRandom(data: ByteArray, user: ScaleUser) { + if (data.size < 5) { + logW("Random frame too short: ${data.size}b") + return + } + val pw = password ?: run { + userWarn(R.string.bluetooth_scale_trisa_message_not_paired_instruction) + requestDisconnect() + return + } + val random = data.copyOfRange(1, 5) + + // Answered in every session, pairing included; the scale waits for it before committing. + writeCommand(CMD_VERIFICATION, xor(pw, random)) + + // During pairing both are issued from the slot handler instead. Resending the profile + // here carries a changed height or age to the scale without re-pairing. + if (!pairing) { + writeCommand(CMD_TIME, ConverterUtils.toInt32Le(javaTimeToDevice(System.currentTimeMillis()).toLong())) + writeProfile(settingsGetInt(KEY_SLOT, DEFAULT_SLOT), user) + } + } + + /** + * The scale streams all eight of its user slots as `[0x83][slot][18-byte name]` frames. + * + * Registration is deferred until the eighth frame. Replying mid-stream, for instance on the + * first occupied slot, aborts setup with E1. + */ + private fun onSlotStatus(data: ByteArray, user: ScaleUser) { + if (data.size < 2) return + val slot = data[1].toInt() and 0xFF + + // Slot names are space padded; anything else means the slot is taken. + val occupied = (2 until data.size).any { i -> + val b = data[i].toInt() and 0xFF + b != 0x00 && b != 0x20 + } + if (!occupied) freeSlots.add(slot) + slotNames[slot] = decodeSlotName(data) + logD("Slot $slot ${if (occupied) "occupied" else "free"} '${slotNames[slot]}': ${data.toHexPreview(20)}") + + if (slot != LAST_SLOT || slotAcked) return + slotAcked = true + + // A slot chosen in an earlier session is reused without asking again. + val stored = settingsGetInt(KEY_SLOT, -1) + if (stored in 1..LAST_SLOT) { + logD("Reusing stored slot $stored (free: ${freeSlots.sorted()}, names: $slotNames)") + registerIntoSlot(stored, user) + return + } + + // First pairing: ask which scale user this is. Guessing is wrong too often. A brand new + // scale has eight empty slots and nothing to match on, and a second-hand one may be full + // of a previous owner's profiles that the user legitimately wants to take over. + pendingUser = user + presentSlotChoice(user) + } + + /** + * Ask the user which of the scale's eight slots is theirs, listing the names the scale just + * streamed so the choice is made against what is actually on the device. A slot whose name + * matches the openScale user is flagged as the suggestion. + * + * If the link drops before the answer arrives the writes fail harmlessly. The choice is still + * persisted, so the next connection takes the stored-slot path above. + */ + private fun presentSlotChoice(user: ScaleUser) { + val suggested = chooseSlot(-1, user.userName, slotNames, freeSlots) + val labels = ArrayList(LAST_SLOT) + val ids = IntArray(LAST_SLOT) + for (slot in 1..LAST_SLOT) { + val stored = slotNames[slot].orEmpty() + val name = stored.ifEmpty { resolveString(R.string.bluetooth_scale_weightgurus_slot_empty) } + labels += resolveString( + if (slot == suggested && stored.isNotEmpty()) { + R.string.bluetooth_scale_weightgurus_slot_match + } else { + R.string.bluetooth_scale_weightgurus_slot + }, + slot, name + ) + ids[slot - 1] = slot + } + logD("Asking which slot to claim (suggesting $suggested); names: $slotNames") + requestUserInteraction(UserInteractionType.CHOOSE_USER, Pair(labels.toTypedArray(), ids)) + } + + override suspend fun onUserInteractionFeedback( + interactionType: UserInteractionType, + appUserId: Int, + feedbackData: Any + ) { + if (interactionType != UserInteractionType.CHOOSE_USER) return + val slot = (feedbackData as? Int)?.takeIf { it in 1..LAST_SLOT } ?: run { + logW("Ignoring CHOOSE_USER feedback: $feedbackData") + return + } + val user = pendingUser ?: currentAppUser() + pendingUser = null + logD("User chose slot $slot") + registerIntoSlot(slot, user) + } + + /** + * Claim [slot] for this openScale user and close out setup. Writing the profile here is what + * corrects a slot still holding a stale height/age, which is what the scale computes body + * composition from. + */ + private fun registerIntoSlot(slot: Int, user: ScaleUser) { + settingsPutInt(KEY_SLOT, slot) + writeAddUser(slot, user) + writeProfile(slot, user) + writeCommand(CMD_TIME, ConverterUtils.toInt32Le(javaTimeToDevice(System.currentTimeMillis()).toLong())) + finishSetup() + } + + private fun onProfileEcho() { + logD("Profile echo - the scale accepted the user profile") + // Only the pairing flow ends with 0x22; sending it mid-session would cut the sync short. + if (pairing) finishSetup() + } + + /** Tells the scale the host is done configuring it; sent once per session. */ + private fun finishSetup() { + if (setupFinished) return + setupFinished = true + writeTo(SVC, CHR_DNLD, byteArrayOf(CMD_ENABLE_DISCONNECT), withResponse = true) + } + + // --- Measurement frames ---------------------------------------------------- + + /** + * 0x8A24 - weight measurement. + * + * ``` + * off 0 flags + * off 1-4 weight, 32-bit FLOAT, in kg + * then, in flag order: + * 0x01 timestamp (uint32) + * 0x02 weight delta (32-bit FLOAT, ignored) + * 0x04 impedance (32-bit FLOAT) + * 0x08 user id (uint8, ignored; openScale tracks its own users) + * 0x10 status (uint8; bit4 announces a follow-up 0x8A22 frame) + * ``` + */ + private fun onWeight(data: ByteArray) { + if (data.size < 5) return + // A new weight frame supersedes anything still waiting for a partner. + flushPendingWeight() + + val flags = data[0].toInt() and 0xFF + val weight = floatFrom32(data, 1) + if (weight <= 0f) { + logW("Ignoring non-positive weight: $weight") + return + } + + var off = 5 + var timestamp: Int? = null + if (flags and 0x01 != 0) { + if (off + 4 > data.size) return + timestamp = ConverterUtils.fromSignedInt32Le(data, off) + off += 4 + } + if (flags and 0x02 != 0) off += 4 // weight difference, not tracked by openScale + var impedance: Float? = null + if (flags and 0x04 != 0) { + if (off + 4 > data.size) return + impedance = floatFrom32(data, off) + off += 4 + } + if (flags and 0x08 != 0) off += 1 // scale-side user slot + var hasAppendFrame = false + if (flags and 0x10 != 0 && off < data.size) { + val status = data[off].toInt() and 0xFF + hasAppendFrame = (status and 0x10) != 0 + logD("Status: stable=${status and 0x01}, state=${(status and 0x0E) shr 1}, append=$hasAppendFrame") + } + + val measurement = ScaleMeasurement().apply { + dateTime = Date(timestamp?.let(::deviceTimeToJava) ?: System.currentTimeMillis()) + this.weight = weight + impedance?.let { this.impedance = it.toDouble() } + } + + logD("Weight frame → ${weight}kg at ${measurement.dateTime}, impedance=$impedance, append=$hasAppendFrame") + + if (hasAppendFrame && timestamp != null) { + // Body composition follows in its own frame; publish once both are in hand. + pendingWeight = measurement + pendingTimestamp = timestamp + } else { + publishIfNew(measurement) + } + } + + /** + * 0x8A22 - append measurement (body composition). + * + * ``` + * off 0 flags + * off 1-4 timestamp (uint32, matches the paired 0x8A24 frame) + * then, in flag order, each a 16-bit SFLOAT unless noted: + * 0x01 user id (uint8) + * 0x02 basal metabolism (uint16, kcal) + * 0x04 body fat % + * 0x08 body water % + * 0x10 visceral fat level + * 0x20 muscle mass % + * 0x40 bone mass % (converted to kg on the way into ScaleMeasurement) + * 0x80 battery (uint8) + * ``` + */ + private fun onAppend(data: ByteArray) { + if (data.size < 5) return + val measurement = pendingWeight ?: run { + logW("Composition frame without a matching weight frame - dropping") + return + } + + val flags = data[0].toInt() and 0xFF + val timestamp = ConverterUtils.fromSignedInt32Le(data, 1) + if (timestamp != pendingTimestamp) { + logW("Composition timestamp $timestamp does not match weight frame $pendingTimestamp") + flushPendingWeight() + return + } + + var off = 5 + if (flags and 0x01 != 0) off += 1 // scale-side user slot + if (flags and 0x02 != 0) { + if (off + 2 > data.size) return + measurement.bmr = ConverterUtils.fromUnsignedInt16Le(data, off).toFloat() + off += 2 + } + if (flags and 0x04 != 0) { + if (off + 2 > data.size) return + measurement.fat = sfloatFrom16(data, off) + off += 2 + } + if (flags and 0x08 != 0) { + if (off + 2 > data.size) return + measurement.water = sfloatFrom16(data, off) + off += 2 + } + if (flags and 0x10 != 0) { + if (off + 2 > data.size) return + measurement.visceralFat = sfloatFrom16(data, off) + off += 2 + } + if (flags and 0x20 != 0) { + if (off + 2 > data.size) return + measurement.muscle = sfloatFrom16(data, off) + off += 2 + } + if (flags and 0x40 != 0) { + if (off + 2 > data.size) return + // The scale sends bone as a percentage, but openScale stores it as a mass in kg. + measurement.bone = bonePercentToKg(sfloatFrom16(data, off), measurement.weight) + off += 2 + } + + pendingWeight = null + pendingTimestamp = null + publishIfNew(measurement) + } + + private fun flushPendingWeight() { + pendingWeight?.let { + logD("Publishing weight frame without its composition partner") + publishIfNew(it) + } + pendingWeight = null + pendingTimestamp = null + } + + /** + * Publish unless this exact record has already been seen in this session. + * + * Deliberately does *not* filter against the newest stored measurement. The scale keeps a + * separate queue per user slot and drains only the selected slot's queue, newest first, so + * records legitimately arrive older than ones already stored: a reading taken on one slot is + * delivered after a newer reading from another. A high-water-mark filter drops those, and + * because the scale clears a record once it has been delivered, dropping one loses it for + * good. Cross-session duplicates are handled by the unique `(userId, timestamp)` index on + * `Measurement`, whose insert ignores conflicts. + */ + private fun publishIfNew(measurement: ScaleMeasurement) { + val at = measurement.dateTime?.time ?: 0 + if (at != 0L && !publishedAtMs.add(at)) { + logD("Skipping duplicate record from ${measurement.dateTime} in this session") + return + } + logD( + "Decoded ${measurement.dateTime}: weight=${measurement.weight}kg fat=${measurement.fat}% " + + "water=${measurement.water}% muscle=${measurement.muscle}% bone=${measurement.bone}kg " + + "visceral=${measurement.visceralFat} bmr=${measurement.bmr}kcal" + ) + publish(measurement) + } + + // --- Download (host → device) helpers -------------------------------------- + + private fun writeCommand(opcode: Byte, payload: ByteArray) { + writeTo(SVC, CHR_DNLD, byteArrayOf(opcode) + payload, withResponse = true) + } + + /** + * 0x03 registers a user in a scale-side slot: `[opcode][slot][18-byte name]`. The name is + * truncated to 18 characters and right-padded with spaces. It mirrors the shape of the + * 0x83 frame that prompts it, but this is a registration, not an acknowledgement. + */ + private fun writeAddUser(slot: Int, user: ScaleUser) { + val name = ByteArray(NAME_LENGTH) { SPACE } + user.userName.take(NAME_LENGTH).forEachIndexed { i, c -> + // The slot list is plain ASCII; anything else is replaced. + name[i] = if (c.code in 0x20..0x7E) c.code.toByte() else SPACE + } + writeCommand(CMD_ADD_USER, byteArrayOf(slot.toByte()) + name) + } + + private fun writeProfile(slot: Int, user: ScaleUser) { + // Both the challenge reply and the registration want the profile sent; only the first + // one should actually write it. + if (profileSent) return + profileSent = true + + val height = encodeHeightCm(user.bodyHeight.toInt()) + val payload = byteArrayOf( + PROFILE_MASK, + slot.toByte(), + if (user.gender.isMale()) 0x01 else 0x02, // 3/4 select the athlete variants + user.age.toByte(), + (height and 0xFF).toByte(), + ((height shr 8) and 0xFF).toByte(), + 0x00 // weight unit: kg (pounds 1, stones 2) + ) + writeCommand(CMD_PROFILE, payload) + } + + /** The 16-bit short form of a Bluetooth base UUID, for logging. */ + private fun UUID.shortId(): String = String.format("0x%04x", (mostSignificantBits shr 32) and 0xFFFF) + + /** Pairing only completes for a non-zero account id; the value itself is arbitrary. */ + private fun randomAccountId(): ByteArray = + ConverterUtils.toInt32Le((Random.nextInt(Int.MAX_VALUE - 1) + 1).toLong()) + + // --- Encoding helpers ------------------------------------------------------ + + companion object { + private val NAME_PATTERN = Regex("^[01]0376B.*") + private const val KEY_PASSWORD = "weightgurus/password" + private const val KEY_SLOT = "weightgurus/slot" + + /** The scale reports eight user slots, numbered from one. */ + private const val LAST_SLOT = 8 + private const val DEFAULT_SLOT = 1 + + /** Slot names are a fixed 18 bytes, right-padded with spaces. */ + private const val NAME_LENGTH = 18 + private const val SPACE: Byte = 0x20 + + /** SFLOAT exponent nibble for 10^-3, used to send the height in metres. */ + private const val SFLOAT_EXP_MILLI = 0xD000 + + /** + * The SFLOAT mantissa is a *signed* 12-bit value, so at exponent -3 the largest height + * it can carry is 2047 x 10^-3 m = 204.7 cm; 205 cm would set the sign bit and decode as + * a negative height. Anything taller is capped here so it cannot wrap. + */ + private const val MAX_HEIGHT_CM = 204 + + /** + * How long to wait for the scale to close the link on its own after setup before forcing + * a disconnect. The scale normally drops us within a second or two once it has committed; + * this backstop only fires if it never does, so it is deliberately generous. + */ + private const val PAIRING_COMMIT_FALLBACK_MS = 15_000L + + /** The 18-byte, space-padded ASCII name that follows the slot number in an 0x83 frame. */ + fun decodeSlotName(frame: ByteArray): String { + if (frame.size <= 2) return "" + return frame.copyOfRange(2, minOf(frame.size, 2 + NAME_LENGTH)) + .map { (it.toInt() and 0xFF).toChar() } + .filter { it.code in 0x20..0x7E } + .joinToString("") + .trim() + } + + /** + * Suggest which slot to register into. + * + * This is only a suggestion. The slot is chosen by the user, who is shown the scale's + * actual roster (see `presentSlotChoice`). It is used to flag one entry in that list, and + * as the answer on later connections once a choice has been stored. + * + * Re-pairing should land on the slot it used last time. Taking a fresh slot each time + * leaves another copy of the same person on the scale, and measurements get attributed + * to whichever duplicate the user happens to step on. + * + * The stored slot number is the first choice, but it does not survive the app's data + * being cleared, so a slot already carrying this user's name is matched next. Only then + * does it take the lowest free slot, falling back to the first slot if the scale is full. + */ + fun chooseSlot( + storedSlot: Int, + userName: String, + slotNames: Map, + freeSlots: Set + ): Int { + if (storedSlot in 1..LAST_SLOT) return storedSlot + + val wanted = userName.take(NAME_LENGTH).trim() + if (wanted.isNotEmpty()) { + slotNames.entries + .filter { it.value.equals(wanted, ignoreCase = true) } + .minByOrNull { it.key } + ?.let { return it.key } + } + return freeSlots.minOrNull() ?: DEFAULT_SLOT + } + + /** + * The 0x51 profile wants the height in metres, encoded as an IEEE-11073 SFLOAT, not the + * raw centimetre value: exponent -3 (nibble 0xD) with the centimetre value scaled by ten as + * the mantissa, so 170 cm goes out as 1700 x 10^-3 = 0xD6A4. + */ + fun encodeHeightCm(cm: Int): Int = + (cm.coerceIn(1, MAX_HEIGHT_CM) * 10) or SFLOAT_EXP_MILLI + + /** + * IEEE-11073 32-bit FLOAT: 24-bit little-endian mantissa scaled by a signed + * base-10 exponent in the top byte. + */ + fun floatFrom32(data: ByteArray, offset: Int): Float { + if (offset + 4 > data.size) return 0f + val mantissa = ConverterUtils.fromUnsignedInt24Le(data, offset) + val exponent = data[offset + 3].toInt() // signed + return (mantissa * Math.pow(10.0, exponent.toDouble())).toFloat() + } + + /** + * IEEE-11073 16-bit SFLOAT: 12-bit two's-complement mantissa scaled by a 4-bit + * two's-complement base-10 exponent. + */ + fun sfloatFrom16(data: ByteArray, offset: Int): Float { + if (offset + 2 > data.size) return 0f + val raw = ((data[offset + 1].toInt() and 0xFF) shl 8) or (data[offset].toInt() and 0xFF) + + var exponent = (raw and 0xF000) shr 12 + if (exponent >= 0x8) exponent -= 0x10 + + // The sign bit is part of the mantissa: a set bit means the low 11 bits carry a + // two's-complement negative, so 0x800 means -2048, not 0. + var mantissa = raw and 0x0FFF + if (mantissa >= 0x0800) mantissa = (raw and 0x07FF) - 0x0800 + + return (mantissa * Math.pow(10.0, exponent.toDouble())).toFloat() + } + + /** + * Bone arrives as a percentage of body weight; `bone` is a mass in kg. Muscle needs no + * such conversion, being stored as a percentage. + */ + fun bonePercentToKg(percent: Float, weightKg: Float): Float = + if (weightKg <= 0f) 0f else percent * weightKg / 100f + + fun xor(a: ByteArray, b: ByteArray): ByteArray = + ByteArray(minOf(a.size, b.size)) { (a[it].toInt() xor b[it].toInt()).toByte() } + + fun hexToBytes(hex: String): ByteArray? { + if (hex.length % 2 != 0) return null + return runCatching { + ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() } + }.getOrNull() + } + + fun bytesToHex(bytes: ByteArray): String = + bytes.joinToString("") { String.format("%02x", it) } + } + + // --- Timestamp conversion -------------------------------------------------- + // The scale stores local time, so shift by the current UTC offset in both directions. + + private fun timeZoneOffsetSeconds(now: Long): Int = + TimeZone.getDefault().getOffset(now) / 1000 + + private fun javaTimeToDevice(ms: Long): Int = + (((ms + 500) / 1000) - TS_OFFSET + timeZoneOffsetSeconds(ms)).toInt() + + private fun deviceTimeToJava(seconds: Int): Long { + val approx = 1000L * (TS_OFFSET + seconds.toLong()) + return approx - 1000L * timeZoneOffsetSeconds(approx) + } +} diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 886d05eeb..7e41a90e1 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -831,6 +831,9 @@ This scale has not been paired!\n\nHold the button on the bottom of the scale to switch it to pairing mode, and then reconnect to retrieve the device password. Pairing succeeded!\n\nReconnect to retrieve measurement data. + empty + User %1$d - %2$s + User %1$d - %2$s (matches your name) Create new user on scale. Low battery level (%d%%), please recharge or replace scale batteries Please step barefoot on the scale for reference measurements diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3HandlerTest.kt new file mode 100644 index 000000000..841bdf84f --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3HandlerTest.kt @@ -0,0 +1,280 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for the encoding helpers of [WeightGurusA3Handler]: the two IEEE-11073 medical + * float types, the pairing XOR, and slot selection. + */ +class WeightGurusA3HandlerTest { + + // --- 32-bit FLOAT (weight, impedance) ------------------------------------- + + @Test + fun `decodes a 32-bit FLOAT with a negative exponent`() { + // mantissa 7725 (LE 24-bit), exponent -2 -> 77.25 + val data = byteArrayOf(0x2D, 0x1E, 0x00, 0xFE.toByte()) + assertThat(WeightGurusA3Handler.floatFrom32(data, 0)).isWithin(1e-3f).of(77.25f) + } + + @Test + fun `decodes a 32-bit FLOAT with a zero exponent`() { + // mantissa 500, exponent 0 -> 500.0 (impedance-style value) + val data = byteArrayOf(0xF4.toByte(), 0x01, 0x00, 0x00) + assertThat(WeightGurusA3Handler.floatFrom32(data, 0)).isWithin(1e-3f).of(500f) + } + + @Test + fun `honours the offset and rejects a truncated 32-bit FLOAT`() { + val framed = byteArrayOf(0x1F, 0x2D, 0x1E, 0x00, 0xFE.toByte()) + assertThat(WeightGurusA3Handler.floatFrom32(framed, 1)).isWithin(1e-3f).of(77.25f) + assertThat(WeightGurusA3Handler.floatFrom32(framed, 3)).isEqualTo(0f) + } + + // --- 16-bit SFLOAT (body composition) -------------------------------------- + + @Test + fun `decodes an SFLOAT with a negative exponent`() { + // exponent -1 (0xF), mantissa 213 -> 21.3 % + val raw = (0xF shl 12) or 213 + val data = byteArrayOf((raw and 0xFF).toByte(), ((raw shr 8) and 0xFF).toByte()) + assertThat(WeightGurusA3Handler.sfloatFrom16(data, 0)).isWithin(1e-3f).of(21.3f) + } + + @Test + fun `decodes an SFLOAT with a zero exponent`() { + // exponent 0, mantissa 12 -> 12.0 (visceral fat level) + val data = byteArrayOf(0x0C, 0x00) + assertThat(WeightGurusA3Handler.sfloatFrom16(data, 0)).isWithin(1e-3f).of(12f) + } + + @Test + fun `decodes an SFLOAT with a negative mantissa`() { + // exponent 0, mantissa -1 (0xFFF two's complement in 12 bits) + val data = byteArrayOf(0xFF.toByte(), 0x0F) + assertThat(WeightGurusA3Handler.sfloatFrom16(data, 0)).isWithin(1e-3f).of(-1f) + } + + @Test + fun `decodes the most negative SFLOAT mantissa`() { + // exponent 0, raw mantissa 0x800 -> -2048, not 0 + val data = byteArrayOf(0x00, 0x08) + assertThat(WeightGurusA3Handler.sfloatFrom16(data, 0)).isWithin(1e-3f).of(-2048f) + } + + @Test + fun `decodes an SFLOAT with the most negative exponent`() { + // exponent 0x8 -> -8, mantissa 1 + val raw = (0x8 shl 12) or 1 + val data = byteArrayOf((raw and 0xFF).toByte(), ((raw shr 8) and 0xFF).toByte()) + assertThat(WeightGurusA3Handler.sfloatFrom16(data, 0)).isWithin(1e-12f).of(1e-8f) + } + + @Test + fun `rejects a truncated SFLOAT`() { + assertThat(WeightGurusA3Handler.sfloatFrom16(byteArrayOf(0x0C), 0)).isEqualTo(0f) + } + + // --- Bone units ------------------------------------------------------------ + + @Test + fun `converts the bone percentage to a mass in kg`() { + // 0xF023 decodes to 3.5 %, which at 70.0 kg is 2.45 kg. + assertThat(WeightGurusA3Handler.bonePercentToKg(3.5f, 70.0f)).isWithin(1e-2f).of(2.45f) + } + + @Test + fun `bone conversion is safe without a weight`() { + assertThat(WeightGurusA3Handler.bonePercentToKg(3.5f, 0f)).isEqualTo(0f) + } + + // --- Pairing --------------------------------------------------------------- + + @Test + fun `verification code is the byte-wise xor of password and random`() { + val password = byteArrayOf(0x12, 0x34, 0x56, 0x78) + val random = byteArrayOf(0x11, 0x11, 0x11, 0x11) + assertThat(WeightGurusA3Handler.xor(password, random).toList()) + .containsExactly(0x03.toByte(), 0x25.toByte(), 0x47.toByte(), 0x69.toByte()) + .inOrder() + } + + @Test + fun `password survives a hex round trip`() { + val password = byteArrayOf(0x00, 0xAB.toByte(), 0x7F, 0xFF.toByte()) + val hex = WeightGurusA3Handler.bytesToHex(password) + assertThat(hex).isEqualTo("00ab7fff") + assertThat(WeightGurusA3Handler.hexToBytes(hex)).isEqualTo(password) + } + + @Test + fun `rejects malformed stored passwords`() { + assertThat(WeightGurusA3Handler.hexToBytes("abc")).isNull() + assertThat(WeightGurusA3Handler.hexToBytes("zzzz")).isNull() + } + + // --- Profile height -------------------------------------------------------- + // Height is an SFLOAT in metres, (height_cm * 10) or 0xD000, not raw centimetres. + // sfloatFrom16 is pinned above, so decoding what encodeHeightCm produces is the check. + + @Test + fun `encodes the profile height as an SFLOAT in metres`() { + val encoded = WeightGurusA3Handler.encodeHeightCm(170) + assertThat(encoded).isEqualTo(0xD6A4) + + val bytes = byteArrayOf((encoded and 0xFF).toByte(), ((encoded shr 8) and 0xFF).toByte()) + assertThat(WeightGurusA3Handler.sfloatFrom16(bytes, 0)).isWithin(1e-4f).of(1.70f) + } + + @Test + fun `profile height round trips across the representable range`() { + for (cm in 50..204) { + val encoded = WeightGurusA3Handler.encodeHeightCm(cm) + val bytes = byteArrayOf((encoded and 0xFF).toByte(), ((encoded shr 8) and 0xFF).toByte()) + assertThat(WeightGurusA3Handler.sfloatFrom16(bytes, 0)) + .isWithin(1e-4f).of(cm / 100f) + } + } + + @Test + fun `clamps heights that the signed SFLOAT mantissa cannot carry`() { + // 205 cm would set the mantissa's sign bit, so anything taller is pinned to the ceiling. + for (cm in listOf(205, 250, 9999)) { + assertThat(WeightGurusA3Handler.encodeHeightCm(cm)) + .isEqualTo(WeightGurusA3Handler.encodeHeightCm(204)) + } + // An unset height (-1) must not wrap either. + val unset = WeightGurusA3Handler.encodeHeightCm(-1) + val bytes = byteArrayOf((unset and 0xFF).toByte(), ((unset shr 8) and 0xFF).toByte()) + assertThat(WeightGurusA3Handler.sfloatFrom16(bytes, 0)).isGreaterThan(0f) + } + + // --- Slot selection -------------------------------------------------------- + // Choice falls back through: the stored slot, a slot already named for this user, then the + // lowest free one. + + @Test + fun `reuses the stored slot when there is one`() { + assertThat( + WeightGurusA3Handler.chooseSlot( + storedSlot = 5, userName = "Sam", + slotNames = mapOf(1 to "Sam"), freeSlots = setOf(2, 3) + ) + ).isEqualTo(5) + } + + @Test + fun `reclaims a slot carrying this user's name when the stored slot is gone`() { + // The app's data was cleared, so only the name on the scale identifies our slot. + assertThat( + WeightGurusA3Handler.chooseSlot( + storedSlot = -1, userName = "Sam", + slotNames = mapOf(1 to "Alex", 4 to "sam"), freeSlots = setOf(6, 7, 8) + ) + ).isEqualTo(4) + } + + @Test + fun `takes the lowest free slot when the user is not registered yet`() { + assertThat( + WeightGurusA3Handler.chooseSlot( + storedSlot = -1, userName = "Sam", + slotNames = mapOf(1 to "Alex"), freeSlots = setOf(7, 3, 5) + ) + ).isEqualTo(3) + } + + @Test + fun `falls back to the first slot when the scale is full`() { + assertThat( + WeightGurusA3Handler.chooseSlot( + storedSlot = -1, userName = "Sam", + slotNames = mapOf(1 to "Alex"), freeSlots = emptySet() + ) + ).isEqualTo(1) + } + + @Test + fun `suggests the first slot on a brand new scale where every slot is empty`() { + // Nothing to match on, so the suggestion is simply the lowest slot; the user still picks. + assertThat( + WeightGurusA3Handler.chooseSlot( + storedSlot = -1, userName = "Sam", + slotNames = (1..8).associateWith { "" }, freeSlots = (1..8).toSet() + ) + ).isEqualTo(1) + } + + @Test + fun `suggests a free slot on a second-hand scale full of another owner's names`() { + // The previous owner's profiles are left alone unless the user deliberately picks one. + assertThat( + WeightGurusA3Handler.chooseSlot( + storedSlot = -1, userName = "Sam", + slotNames = mapOf(1 to "Alex", 2 to "Jo", 3 to ""), freeSlots = setOf(3) + ) + ).isEqualTo(3) + } + + @Test + fun `an empty user name never matches an empty slot`() { + // Unnamed slots decode to "", which must not be mistaken for a nameless user's slot. + assertThat( + WeightGurusA3Handler.chooseSlot( + storedSlot = -1, userName = "", + slotNames = mapOf(1 to "", 2 to ""), freeSlots = setOf(2) + ) + ).isEqualTo(2) + } + + @Test + fun `decodes the space-padded slot name`() { + // [0x83][slot][18-byte name, space padded] + val frame = byteArrayOf(0x83.toByte(), 0x02) + + "Sam".toByteArray() + ByteArray(15) { 0x20 } + assertThat(WeightGurusA3Handler.decodeSlotName(frame)).isEqualTo("Sam") + + val empty = byteArrayOf(0x83.toByte(), 0x03) + ByteArray(18) { 0x20 } + assertThat(WeightGurusA3Handler.decodeSlotName(empty)).isEmpty() + } + + // --- Device matching ------------------------------------------------------- + + @Test + fun `claims both the pairing and the paired advertised names`() { + val handler = WeightGurusA3Handler() + assertThat(handler.supportFor(scanned("10376B"))).isNotNull() + assertThat(handler.supportFor(scanned("00376B251016AA"))).isNotNull() + } + + @Test + fun `ignores other Transtek scales`() { + val handler = WeightGurusA3Handler() + // Trisa Body Analyze and 1BODY CONNECT have their own handlers. + assertThat(handler.supportFor(scanned("01257B"))).isNull() + assertThat(handler.supportFor(scanned("11257B"))).isNull() + assertThat(handler.supportFor(scanned("1BODY CONNECT"))).isNull() + assertThat(handler.supportFor(scanned(""))).isNull() + } + + private fun scanned(name: String) = + com.health.openscale.core.service.ScannedDeviceInfo(name, "CB:A0:80:F1:53:B2", 0, emptyList(), null) +} From 675174392b88d52044f7cee182e56b1f9af871a7 Mon Sep 17 00:00:00 2001 From: Mikalai Kukhta <1721257+NecroKote@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:43:46 +0200 Subject: [PATCH 35/72] Support for BMI calculations for Active Era BS-06 scales (#1464) * add rudimentary support for AE-BS06 * add support for BIA calculations matching what scales report on their screen. The support added only for reportted "algType = 0x07" * switch to name-based matching for AE BS-06 * report heart rate for AE BS-06 * add accurate BMI calculations for AE BS-06 only alg=0x07 is supported for now --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 +- .../bluetooth/scales/ActiveEraBF06Handler.kt | 351 +++++++++++++++--- 2 files changed, 304 insertions(+), 49 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 638bb1dd1..aef40e598 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -115,6 +115,7 @@ class ScaleFactory @Inject constructor( // 0xFFB0, which MGBHandler also matches, so a later position would let MGB wrongly claim them. private val modernKotlinHandlers: List = listOf( // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). + ActiveEraBF06Handler(), KeepS3Handler(), BeurerBF450Handler(), TaylorBIAHandler(), @@ -169,7 +170,6 @@ class ScaleFactory @Inject constructor( BeurerSanitasHandler(), AAAxHandler(), ScaleupHandler(), - ActiveEraBF06Handler(), DrTrustSSW532Handler(), BodyConnectHandler(), WeightGurusA3Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ActiveEraBF06Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ActiveEraBF06Handler.kt index 6bd542362..1d18e7c00 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ActiveEraBF06Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ActiveEraBF06Handler.kt @@ -25,6 +25,9 @@ import com.health.openscale.core.service.ScannedDeviceInfo import java.time.Instant import java.util.UUID import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min import kotlin.math.pow /** @@ -45,6 +48,10 @@ import kotlin.math.pow */ class ActiveEraBF06Handler : ScaleDeviceHandler() { + companion object { + private const val DEVICE_NAME = "AE BS-06" + } + // --- GATT UUIDs ----------------------------------------------------------- private val SERVICE: UUID = uuid16(0xFFB0) private val CHR_WRITE: UUID = uuid16(0xFFB1) @@ -66,35 +73,27 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { private var impedanceOhm: Double = 0.0 private var pending: ScaleMeasurement? = null - private var hrBpm: Int? = null // --- Capability declaration ---------------------------------------------- override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { - // Match by name heuristics and/or advertised service UUID - val name = device.name.lowercase() - val hasSvc = device.serviceUuids.any { it == SERVICE } - if ( - hasSvc || - name.contains("AE BS-06".lowercase()) - ) { - return DeviceSupport( - displayName = "Active Era BF-06", - capabilities = setOf( - DeviceCapability.LIVE_WEIGHT_STREAM, - DeviceCapability.BODY_COMPOSITION, // we compute via simple BIA when possible - DeviceCapability.TIME_SYNC, - DeviceCapability.HISTORY_READ // D8 packets - ), - implemented = setOf( - DeviceCapability.LIVE_WEIGHT_STREAM, - DeviceCapability.BODY_COMPOSITION, - DeviceCapability.TIME_SYNC, - DeviceCapability.HISTORY_READ - ), - linkMode = LinkMode.CONNECT_GATT - ) - } - return null + if (!device.name.equals(DEVICE_NAME, ignoreCase = true)) return null + + return DeviceSupport( + displayName = "Active Era BF-06", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.HISTORY_READ // D8 packets + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.HISTORY_READ + ), + linkMode = LinkMode.CONNECT_GATT + ) } // --- Connection lifecycle ------------------------------------------------- @@ -108,7 +107,6 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { supportsHR = false supportsPH = false impedanceOhm = 0.0 - hrBpm = null pending = ScaleMeasurement() // Enable notifications then send configuration right away @@ -278,23 +276,47 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { impedanceOhm = imp logI("Impedance: %.1f Ω".format(impedanceOhm)) - if (impedanceOhm > 0.0 && stableWeightKg > 0f) { - // Simple BIA estimate (from legacy note). Replace once we have reverse-engineered vendor formulas. - val ffm = estimateFatFreeMass( - heightCm = user.bodyHeight.toInt(), - weightKg = stableWeightKg, - impedance = impedanceOhm, - age = user.age, - isMale = user.gender.isMale() - ) - val fatKg = (stableWeightKg - ffm).coerceAtLeast(0.0) - val fatPct = if (stableWeightKg > 0) (fatKg / stableWeightKg) * 100.0 else 0.0 - ensurePending().apply { - lbm = ffm.toFloat() - fat = fatPct.toFloat() - // Store the raw impedance so body composition can be recomputed later. - impedance = impedanceOhm - // Optional: rough water/muscle estimates could be added if desired + if (impedanceOhm > 10.0 && stableWeightKg > 0f) { + // scales report back the algorithm for calculating BIA data + val reportedAlg = pkt[0x11].toInt() + when (reportedAlg) { + 0x07 -> { + val calc = LibICBIACalculatorWLA07(stableWeightKg, user.bodyHeight.toInt(), impedanceOhm, user.age, user.gender.isMale()) + ensurePending().apply { + val bodyFat = calc.bodyFatPercent.toFloat() + fat = bodyFat + muscle = calc.musclePercent.toFloat() + visceralFat = calc.visceralFat.toFloat() + bone = calc.boneMass.toFloat() + water = calc.moisturePercent.toFloat() + lbm = (stableWeightKg * (100.0 - bodyFat) / 100.0).toFloat() + bmr = calc.bMR.toFloat() + protein = calc.protein.toFloat() + + // Store the raw impedance so body composition can be recomputed later. + impedance = impedanceOhm + } + } + else -> { + logW("unsupported alg=$reportedAlg. Fallback to estimating BIA") + // Simple BIA estimate (from legacy note). Replace once we have reverse-engineered vendor formulas. + val ffm = estimateFatFreeMass( + heightCm = user.bodyHeight.toInt(), + weightKg = stableWeightKg, + impedance = impedanceOhm, + age = user.age, + isMale = user.gender.isMale() + ) + val fatKg = (stableWeightKg - ffm).coerceAtLeast(0.0) + val fatPct = if (stableWeightKg > 0) (fatKg / stableWeightKg) * 100.0 else 0.0 + ensurePending().apply { + lbm = ffm.toFloat() + fat = fatPct.toFloat() + // Store the raw impedance so body composition can be recomputed later. + impedance = impedanceOhm + // Optional: rough water/muscle estimates could be added if desired + } + } } } maybePublishIfComplete() @@ -302,8 +324,12 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { /** D7: heart rate. */ private fun handleHeartRate(pkt: ByteArray) { - hrBpm = pkt[0x03].toInt() and 0xFF - logI("Heart rate: ${hrBpm} bpm") + val hrBpm = pkt[0x03].toInt() and 0xFF + logI("Heart rate: $hrBpm bpm") + ensurePending().apply { + heartRate = hrBpm + } + maybePublishIfComplete() } @@ -328,12 +354,14 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { private fun maybePublishIfComplete() { if (!weightStabilized) return + val m = ensurePending() + val needHr = supportsHR - val haveHr = hrBpm != null + val haveHr = m.heartRate != 0 if (needHr && !haveHr) return - val m = ensurePending().apply { + m.apply { // If we received balance or HR and you want to store them, extend ScaleMeasurement accordingly. // For now we publish standard fields. dateTime = java.util.Date() // now @@ -416,4 +444,231 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { 4.83 * G - 6.83 } + + /** + * Reverse-engineered from libICBodyFatAlgorithms.so, class + * ICBodyFatAlgorithmWLA07 (algType 6 mapped to ICBFATypeWLA07). + */ + private class LibICBIACalculatorWLA07 constructor( + val weightKg: Float, + val heightCm: Int, + val impedanceOhm: Double, + val age: Int, + val isMale: Boolean + ) { + private val bfrRaw = bfrRaw() + private val muscleRaw = muscleRaw() + + fun roundToOneDecimalPlace(value: Double): Double { + var fVar2 = (value % 1.0) * 10.0 + if ((fVar2 % 1.0) > 0.5) { + fVar2 = ceil(fVar2) + } else { + fVar2 = floor(fVar2) + } + return (value.toInt()) + (fVar2 / 10.0) + } + + fun clamp(value: Double, min: Double, max: Double): Double { + return min(max(value, min), max) + } + + /** The shared 5-term regression: (height*A + weight*B + age*C + imp*D + E) / 10000 */ + fun regress(row: Int, heightCm: Int, weightKg: Double, age: Int, impedanceOhm: Double): Double = TABLE[row].let { + (heightCm * it.heightCoef + weightKg * it.weightCoef + age * it.ageCoef + impedanceOhm * it.impCoef + it.const) / 10000.0 + } + + val bMI: Double + get() { + val bmi = (weightKg * 10000.0) / (heightCm * heightCm) + return clamp(bmi, 4.0, 185.5) + } + + /** bfr regression clamped to [5,45]. */ + fun bfrRaw(): Double { + val raw = (regress( + if (isMale) 1 else 0, + heightCm, + weightKg.toDouble(), + age, + impedanceOhm + ) / weightKg) * 100.0 + if (raw <= 45.0) { + return max(raw, 5.0) + } + return 45.0 + } + + val bodyFatPercent: Double + get() = roundToOneDecimalPlace(bfrRaw) + + fun getSubcutaneousFatPercent(bfrPercent: Double): Double { + return roundToOneDecimalPlace(bfrPercent * (-0.0002 * bfrPercent + 0.72)) + } + + /** Absolute (kg-ish) muscle mass from the row2/3 regression, with an FFM-residual correction band. */ + fun muscleRaw(): Double { + val bfr = clamp(bfrRaw, 5.0, 45.0) + val muscleRegression = regress( + if (isMale) 3 else 2, + heightCm, + weightKg.toDouble(), + age, + impedanceOhm + ) / 10.0 + val ffmResidual = weightKg * (1.0 - bfr / 100.0) - muscleRegression + if (ffmResidual >= 4.0) { + return muscleRegression + ffmResidual - 4.0 + } + if (ffmResidual > 1.0) { + return muscleRegression + } + return muscleRegression + ffmResidual - 1.0 + } + + val musclePercent: Double + get() = roundToOneDecimalPlace(muscleRaw / weightKg * 100.0) + + val boneMass: Double + get() { + val muscle = muscleRaw + val bfr = clamp(bfrRaw, 5.0, 45.0) + val residual = weightKg - (bfr * weightKg) / 100.0 - muscle + return roundToOneDecimalPlace(clamp(residual, 1.0, 4.0)) + } + + val visceralFat: Double + /** Visceral fat uses its own quirky round-to-nearest-5 (not 0.1) step before the final clamp. */ + get() { + val raw = regress( + if (isMale) 9 else 8, + heightCm, + weightKg.toDouble(), + age, + impedanceOhm + ) * 10.0 + val truncated = raw.toInt() + val base = (truncated / 10) * 10 + val rounded = if (truncated % 10 < 6) base else base + 5 + return roundToOneDecimalPlace(clamp(rounded / 10.0, 1.0, 59.0)) + } + + val bMR: Int + get() { + val raw = + regress(if (isMale) 7 else 6, heightCm, weightKg.toDouble(), age, impedanceOhm) + return Math.round(clamp(raw, 400.0, 3500.0)).toInt() + } + + val physicalAge: Int + get() { + if (age <= 14) { + return age + } + val raw = regress( + if (isMale) 11 else 10, + heightCm, + weightKg.toDouble(), + age, + impedanceOhm + ) + val physicalAge = clamp(raw, Double.NEGATIVE_INFINITY, 80.0).toInt() + return max(physicalAge, 15) + } + + /** Shared by water% and protein%: row4/5 regression run through an FFM-residual correction band. */ + fun moistureCorrected(): Double { + val muscle = muscleRaw + val musclePercent = muscle / weightKg * 100.0 + val raw = regress( + if (isMale) 5 else 4, + heightCm, + weightKg.toDouble(), + age, + impedanceOhm + ) / weightKg + val residual = musclePercent - raw + if (residual >= 32.0) { + return musclePercent - 32.0 + } + if (residual > 5.0) { + return raw + } + return musclePercent - 5.0 + } + + val moisturePercent: Double + get() { + val corrected = moistureCorrected() + return roundToOneDecimalPlace(clamp(corrected, 20.0, 85.0)) + } + + val protein: Double + get() { + val muscle = muscleRaw + val waterRaw = clamp( + regress( + if (isMale) 5 else 4, + heightCm, + weightKg.toDouble(), + age, + impedanceOhm + ) / weightKg, 20.0, 85.0 + ) + val protein = muscle / weightKg * 100.0 - waterRaw + return roundToOneDecimalPlace(clamp(protein, 5.0, 32.0)) + } + + val skeletalMuscleMass: Double + get() { + val muscle = muscleRaw + val sexFlag = if (isMale) 1.0 else 0.0 + var raw = + (impedanceOhm * -0.017 + weightKg * 0.1745 + heightCm * 0.2573 + sexFlag * 2.4269) - age * 0.0161 - 20.2165 + val ratio = raw / muscle + if (ratio >= 0.7) { + raw = muscle * 0.7 + } else if (ratio <= 0.45) { + raw = muscle * 0.45 + } + return roundToOneDecimalPlace(raw / weightKg * 100.0) + } + + companion object { + private data class TableRow( + val heightCoef: Int, + val weightCoef: Int, + val ageCoef: Int, + val impCoef: Int, + val const: Int, + ) + + private val TABLE = arrayOf( + /* row 0: bfr, female */ + TableRow(-3332, 7509, 196, 72, 227193), + /* row 1: bfr, male */ + TableRow(-3315, 6216, 183, 85, 225540), + /* row 2: muscleRaw, female */ + TableRow(31860, 19340, -2060, -1320, -1645560), + /* row 3: muscleRaw, male */ + TableRow(28670, 38940, -4080, -1235, -1576650), + /* row 4: water/protein, female */ + TableRow(87700, 297300, 12800, -6030, 517500), + /* row 5: water/protein, male */ + TableRow(93900, 375800, -3200, -6925, 97000), + /* row 6: bmr, female */ + TableRow(75432, 99474, -34382, -3090, -2882821), + /* row 7: bmr, male */ + TableRow(75037, 131523, -43376, -3486, -3117751), + /* row 8: visceralFat, female */ + TableRow(-1651, 2628, 649, 24, 123445), + /* row 9: visceralFat, male */ + TableRow(-2675, 4200, 1462, 123, 139871), + /* row 10: physicalAge, female */ + TableRow(-11165, 15784, 4615, 415, 832548), + /* row 11: physicalAge, male */ + TableRow(-7471, 9161, 4184, 517, 542267), + ) + } + } } From 692bc0c3422d448783a1393be1de2a6bd53cb267 Mon Sep 17 00:00:00 2001 From: Pierre Jestin <42845796+pjestin@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:48:33 +0200 Subject: [PATCH 36/72] Support Terraillon X-LINE scale (#1461) * Support Terraillon X-LINE scale * Also support 1X-LINE* --- .../core/bluetooth/scales/BodyConnectHandler.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt index d3e19db3b..5cf922dbd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt @@ -59,9 +59,10 @@ class BodyConnectHandler : ScaleDeviceHandler() { override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { val name = device.name - if (!name.startsWith("1BODY CONNECT") && !name.startsWith("0BODY CONNECT")) return null + if (!name.startsWith("1BODY CONNECT") && !name.startsWith("0BODY CONNECT") && !name.startsWith("0X-LINE") && !name.startsWith("1X-LINE")) return null + val displayName = if (name.contains("BODY CONNECT")) "1BODY CONNECT" else "1X-LINE" return DeviceSupport( - displayName = "1BODY CONNECT", + displayName = displayName, capabilities = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC, DeviceCapability.USER_SYNC, DeviceCapability.HISTORY_READ), implemented = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC, DeviceCapability.HISTORY_READ), linkMode = LinkMode.CONNECT_GATT @@ -96,6 +97,8 @@ class BodyConnectHandler : ScaleDeviceHandler() { // Timestamp base: 2010-01-01 00:00:00 UTC; device stores seconds since this epoch private val TS_OFFSET = 1262304000L + // The X-LINE returns a timestamp in seconds since 2020-01-01 only in the body composition response + private val ALT_TS_OFFSET = 315619200 // --- Pairing state --------------------------------------------------------- @@ -232,11 +235,16 @@ class BodyConnectHandler : ScaleDeviceHandler() { if (fat == null && water == null && muscle == null && bone == null) return val ts = ConverterUtils.fromSignedInt32Le(data, 1) - val weight = if (lastTS == ts) lastWeight else null + if (lastTS == null || (lastTS != ts && lastTS != ts + ALT_TS_OFFSET)) { + logW("Timestamps don't match: $ts; $lastTS") + return + } + + val weight = lastWeight if (weight == null || weight <= 0f) return val m = ScaleMeasurement().apply { - dateTime = Date(deviceTimeToJava(ts)) + dateTime = Date(deviceTimeToJava(lastTS!!)) this.weight = weight } fat?.let { m.fat = it } From 3889c4684fde25c744574a1477a69c8fb0635e4b Mon Sep 17 00:00:00 2001 From: ToastyTheBot Date: Fri, 14 Aug 2026 16:06:42 +0800 Subject: [PATCH 37/72] Add support for the Omron HBF-702T and its WLC-protocol siblings (#1463) * feat(bluetooth): add Omron WLC body composition scales (HBF-702T family) Adds support for the Omron HBF-702T and its KRD-703T / Asia-Pacific trims, plus the HBF-222T, HBF-227T, HBF-228T and HBF-230T, which share the same "Wellness Link" (WLC) transfer protocol. These scales do not stream live weights. They store up to 30 measurements per on-device user slot in EEPROM, which the phone reads out afterwards over a vendor GATT service that splits every command across four 16-byte write channels and reassembles the answer from four notify channels. - OmronWlpFrame: command framing, XOR checksum, response reassembly. - OmronBodyCompositionLib: per-model record geometry and the bit-packed field decoder. Field positions differ only in record size and visceral fat granularity between the 48-byte HBF-702T and the 32-byte siblings. - OmronWlcHandler: unlock/pair handshake, transfer session, and slot readout. The on-device user slot is bound to an openScale user through the existing CHOOSE_USER dialog, which previews each slot's newest measurement. The driver is read-only apart from the unlock handshake: it never resets the device's unread counters or writes its clock, and new measurements are the records stamped later than the newest one openScale already holds. A protocol mistake therefore cannot corrupt measurements stored on the scale. The protocol was derived from the OMRON connect app's own device descriptors and communication library, not from a capture of a physical device, so it is unverified against hardware. Co-Authored-By: Claude Mythos * Fold the Omron WLP framing into a single OmronLib file. --------- Co-authored-by: Claude Mythos Co-authored-by: oliexdev --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../openscale/core/bluetooth/libs/OmronLib.kt | 378 +++++++++++ .../core/bluetooth/scales/OmronWlcHandler.kt | 625 ++++++++++++++++++ .../app/src/main/res/values/strings.xml | 15 + .../core/bluetooth/libs/OmronLibTest.kt | 323 +++++++++ .../bluetooth/scales/OmronWlcHandlerTest.kt | 102 +++ 6 files changed, 1445 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OmronLib.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OmronLibTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index aef40e598..1ef88fb97 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -55,6 +55,7 @@ import com.health.openscale.core.bluetooth.scales.MiScaleS400Handler import com.health.openscale.core.bluetooth.scales.XiaomiS800Handler import com.health.openscale.core.bluetooth.scales.BodyConnectHandler import com.health.openscale.core.bluetooth.scales.OkOkHandler +import com.health.openscale.core.bluetooth.scales.OmronWlcHandler import com.health.openscale.core.bluetooth.scales.OneByoneHandler import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler import com.health.openscale.core.bluetooth.scales.QNHandler @@ -117,6 +118,7 @@ class ScaleFactory @Inject constructor( // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). ActiveEraBF06Handler(), KeepS3Handler(), + OmronWlcHandler(), BeurerBF450Handler(), TaylorBIAHandler(), RyFitHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OmronLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OmronLib.kt new file mode 100644 index 000000000..26a05a98c --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OmronLib.kt @@ -0,0 +1,378 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import java.util.Calendar +import java.util.Date + +/** + * Decoder for the measurement records of Omron WLC-class body composition monitors. + * + * Each of the four on-device user slots is a ring buffer of fixed-size records in EEPROM. Fields are + * packed at bit granularity: a field reads [OmronField.size] big-endian bytes at + * [OmronField.offset], then takes [OmronField.bitSize] bits starting at [OmronField.startBit] + * counted from the least significant bit. A raw value of zero means "not measured" for every field + * except the weight, where it means the record slot has never been written. + * + * The bases, record sizes and bit positions come from Omron's own per-model device descriptors. + */ +object OmronBodyCompositionLib { + + /** One packed field inside a record. */ + data class OmronField( + val offset: Int, + val size: Int, + val startBit: Int, + val bitSize: Int + ) + + /** + * Per-model geometry and the handful of field semantics that differ between models. + * + * @property recordSize bytes per measurement record. + * @property slotBaseAddresses EEPROM address of record 0 for each of the four user slots. + * @property recordsPerSlot ring buffer depth. + * @property visceralFatStep level increment of one raw unit — the 48-byte models report half + * levels, the 32-byte models whole ones. + * @property hasBodyAge some regional variants of the HBF-222T omit the body age field. + */ + data class Profile( + val recordSize: Int, + val slotBaseAddresses: IntArray, + val recordsPerSlot: Int, + val visceralFatStep: Float, + val hasBodyAge: Boolean + ) { + val userSlotCount: Int get() = slotBaseAddresses.size + + fun recordAddress(slot: Int, index: Int): Int = + slotBaseAddresses[slot] + index * recordSize + + override fun equals(other: Any?): Boolean = + other is Profile && recordSize == other.recordSize && + slotBaseAddresses.contentEquals(other.slotBaseAddresses) && + recordsPerSlot == other.recordsPerSlot && + visceralFatStep == other.visceralFatStep && hasBodyAge == other.hasBodyAge + + override fun hashCode(): Int = + ((((recordSize * 31 + slotBaseAddresses.contentHashCode()) * 31 + + recordsPerSlot) * 31 + visceralFatStep.hashCode()) * 31) + hasBodyAge.hashCode() + } + + /** HBF-702T and its KRD-703T / Asia-Pacific siblings: 48-byte records, half visceral levels. */ + val PROFILE_HBF_702T = Profile( + recordSize = 48, + slotBaseAddresses = intArrayOf(0x02C0, 0x0890, 0x0E60, 0x1430), + recordsPerSlot = 30, + visceralFatStep = 0.5f, + hasBodyAge = true + ) + + /** HBF-222T-AP, HBF-227T, HBF-228T, HBF-230T: 32-byte records, whole visceral levels. */ + val PROFILE_HBF_32 = Profile( + recordSize = 32, + slotBaseAddresses = intArrayOf(0x02C0, 0x06A0, 0x0A80, 0x0E60), + recordsPerSlot = 30, + visceralFatStep = 1.0f, + hasBodyAge = true + ) + + /** HBF-222T in its European/Latin-American/BCM-500 trims, which do not report body age. */ + val PROFILE_HBF_32_NO_BODY_AGE = PROFILE_HBF_32.copy(hasBodyAge = false) + + // Field positions shared by every WLC-class record seen so far. The 48-byte record extends the + // 32-byte one with segmental values; openScale has nowhere to store those, so they are skipped. + private val FIELD_WEIGHT = OmronField(offset = 26, size = 2, startBit = 4, bitSize = 12) + private val FIELD_BODY_FAT = OmronField(offset = 2, size = 2, startBit = 6, bitSize = 10) + private val FIELD_VISCERAL_FAT = OmronField(offset = 2, size = 2, startBit = 0, bitSize = 6) + private val FIELD_BMR = OmronField(offset = 4, size = 2, startBit = 4, bitSize = 12) + private val FIELD_SKELETAL_MUSCLE = OmronField(offset = 6, size = 2, startBit = 6, bitSize = 10) + private val FIELD_BMI = OmronField(offset = 8, size = 2, startBit = 6, bitSize = 10) + private val FIELD_BODY_AGE = OmronField(offset = 10, size = 1, startBit = 0, bitSize = 7) + + private val FIELD_YEAR = OmronField(offset = 7, size = 1, startBit = 0, bitSize = 6) + private val FIELD_MINUTE = OmronField(offset = 9, size = 1, startBit = 0, bitSize = 6) + private val FIELD_MONTH = OmronField(offset = 11, size = 1, startBit = 0, bitSize = 4) + private val FIELD_DAY = OmronField(offset = 12, size = 1, startBit = 3, bitSize = 5) + private val FIELD_HOUR = OmronField(offset = 12, size = 2, startBit = 6, bitSize = 5) + private val FIELD_SECOND = OmronField(offset = 13, size = 1, startBit = 0, bitSize = 6) + + /** Weight is stored in 50 g units. */ + private const val WEIGHT_KG_PER_UNIT = 0.05f + + /** A decoded measurement. Nullable members were not measured for this record. */ + data class Record( + val timestamp: Date, + val weightKg: Float, + val bodyFatPercent: Float?, + val skeletalMusclePercent: Float?, + val bmi: Float?, + val bmrKcal: Int?, + val visceralFatLevel: Float?, + val bodyAgeYears: Int? + ) + + /** + * Reads a packed field. Values are big-endian; [OmronField.startBit] counts from the least + * significant bit of the assembled value. + */ + fun readField(record: ByteArray, field: OmronField): Int { + var value = 0L + for (i in 0 until field.size) { + value = (value shl 8) or (record[field.offset + i].toLong() and 0xFF) + } + val mask = (1L shl field.bitSize) - 1 + return ((value ushr field.startBit) and mask).toInt() + } + + /** + * Decodes a single record, or returns `null` if the slot is empty or the contents are not a + * plausible measurement. + * + * Unused ring buffer slots read back as erased EEPROM (0xFF) or as zeros; both decode to + * impossible dates, but they are rejected explicitly so a partially erased record cannot slip + * through on a date that happens to validate. + */ + fun decodeRecord(record: ByteArray, profile: Profile): Record? { + if (record.size < profile.recordSize) return null + if (isBlank(record, profile.recordSize)) return null + + val rawWeight = readField(record, FIELD_WEIGHT) + if (rawWeight == 0) return null + + val timestamp = decodeTimestamp(record) ?: return null + + return Record( + timestamp = timestamp, + weightKg = rawWeight * WEIGHT_KG_PER_UNIT, + bodyFatPercent = scaledTenths(readField(record, FIELD_BODY_FAT)), + skeletalMusclePercent = scaledTenths(readField(record, FIELD_SKELETAL_MUSCLE)), + bmi = scaledTenths(readField(record, FIELD_BMI)), + bmrKcal = readField(record, FIELD_BMR).takeIf { it != 0 }, + visceralFatLevel = readField(record, FIELD_VISCERAL_FAT) + .takeIf { it != 0 } + ?.let { it * profile.visceralFatStep }, + bodyAgeYears = if (profile.hasBodyAge) { + readField(record, FIELD_BODY_AGE).takeIf { it != 0 } + } else { + null + } + ) + } + + private fun scaledTenths(raw: Int): Float? = raw.takeIf { it != 0 }?.let { it / 10.0f } + + private fun isBlank(record: ByteArray, length: Int): Boolean { + var allErased = true + var allZero = true + for (i in 0 until length) { + val b = record[i].toInt() and 0xFF + if (b != 0xFF) allErased = false + if (b != 0x00) allZero = false + if (!allErased && !allZero) return false + } + return true + } + + /** + * Rebuilds the measurement timestamp, which is scattered across bytes 7, 9 and 11–13 with the + * hour straddling the byte 12/13 boundary. + */ + private fun decodeTimestamp(record: ByteArray): Date? { + val year = 2000 + readField(record, FIELD_YEAR) + val month = readField(record, FIELD_MONTH) + val day = readField(record, FIELD_DAY) + val hour = readField(record, FIELD_HOUR) + val minute = readField(record, FIELD_MINUTE) + val second = readField(record, FIELD_SECOND) + + if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || + minute !in 0..59 || second !in 0..59 + ) { + return null + } + + val calendar = Calendar.getInstance().apply { + isLenient = false + clear() + set(year, month - 1, day, hour, minute, second) + } + return runCatching { calendar.time }.getOrNull() + } +} + +/** + * Omron "Wellness Link" (WLP) transfer protocol used by the WLC/WLS-class Omron devices. + * + * The device exposes a vendor service with four write channels and four notify channels. A command + * is split into 16-byte chunks, one per write channel; a response is reassembled from the notify + * channels in the same order. Every frame is self-describing (byte 0 is the total frame length) and + * protected by an XOR checksum (BCC) over all preceding bytes, so the XOR over a complete, intact + * frame is always zero. + * + * Frame layout: + * ``` + * request [len][cmd][flags][addrHi][addrLo][size][payload…][00][bcc] + * response [len][cmd|0x80][flags][addrHi][addrLo][size][payload…][result][bcc] + * ``` + * + * All device memory is addressed as a flat EEPROM space; measurement records are read out of it in + * blocks of at most 32 bytes and decoded by [OmronBodyCompositionLib]. + */ +object OmronWlpFrame { + + /** Maximum payload of a single EEPROM read; larger reads must be split. */ + const val MAX_READ_BLOCK = 0x20 + + /** Bytes a channel can carry — the protocol predates MTU negotiation and is fixed at 16. */ + const val CHANNEL_WIDTH = 16 + + /** Length of the unlock/pairing key, in bytes. */ + const val KEY_SIZE = 16 + + // Response type words, taken from the two bytes that follow the frame length. + const val TYPE_START = 0x8000 + const val TYPE_READ = 0x8100 + const val TYPE_WRITE = 0x81C0 + const val TYPE_END = 0x8F00 + + // Unlock-channel opcodes and their acknowledgements. + private const val OP_WRITE_KEY = 0x00 + private const val OP_UNLOCK = 0x01 + private const val OP_ENTER_KEY_PROGRAMMING = 0x02 + + const val ACK_KEY_WRITTEN = 0x8000 + const val ACK_UNLOCKED = 0x8100 + const val ACK_KEY_PROGRAMMING = 0x8200 + + /** XOR checksum over the first [length] bytes of [bytes]. */ + fun bcc(bytes: ByteArray, length: Int = bytes.size): Byte { + var acc = 0 + for (i in 0 until length) acc = acc xor (bytes[i].toInt() and 0xFF) + return acc.toByte() + } + + /** Opens a read-out session. The device refuses EEPROM access until this is acknowledged. */ + fun startTransmission(): ByteArray = sealed(byteArrayOf(0x08, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00)) + + /** Closes the read-out session. The response carries a device-side result code. */ + fun endTransmission(): ByteArray = sealed(byteArrayOf(0x08, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00)) + + /** + * Reads [length] bytes (at most [MAX_READ_BLOCK]) starting at EEPROM [address]. + * + * The flags byte is zero for reads; writes would set 0xC0 there, which is why the top two bits + * of the address are not usable. + */ + fun readEeprom(address: Int, length: Int): ByteArray { + require(length in 1..MAX_READ_BLOCK) { "read block out of range: $length" } + require(address in 0..0x3FFF) { "address out of range: $address" } + return sealed( + byteArrayOf( + 0x08, + 0x01, + 0x00, + ((address shr 8) and 0xFF).toByte(), + (address and 0xFF).toByte(), + length.toByte(), + 0x00 + ) + ) + } + + /** Unlock-channel command proving we know the key the device was paired with. */ + fun unlock(key: ByteArray): ByteArray = keyCommand(OP_UNLOCK, key) + + /** Unlock-channel command that puts a device in pairing mode into key-programming mode. */ + fun enterKeyProgramming(): ByteArray = ByteArray(KEY_SIZE + 1).also { + it[0] = OP_ENTER_KEY_PROGRAMMING.toByte() + } + + /** Unlock-channel command storing [key] as the device's new pairing key. */ + fun writeKey(key: ByteArray): ByteArray = keyCommand(OP_WRITE_KEY, key) + + private fun keyCommand(opcode: Int, key: ByteArray): ByteArray { + require(key.size == KEY_SIZE) { "key must be $KEY_SIZE bytes, was ${key.size}" } + return ByteArray(KEY_SIZE + 1).also { + it[0] = opcode.toByte() + key.copyInto(it, 1) + } + } + + /** + * Appends the checksum to a frame whose last byte is still a placeholder. + * The BCC covers everything before it, so an intact frame XORs to zero. + */ + private fun sealed(withoutBcc: ByteArray): ByteArray = + withoutBcc + bcc(withoutBcc) + + /** Splits a command into the fixed-width chunks the write channels accept. */ + fun toChannelChunks(command: ByteArray): List = + command.toList().chunked(CHANNEL_WIDTH) { it.toByteArray() } + + /** Number of notify channels a response of [frameLength] bytes will arrive on. */ + fun channelsForFrame(frameLength: Int): Int = + (frameLength + CHANNEL_WIDTH - 1) / CHANNEL_WIDTH + + /** A decoded device response. [data] is empty for frames that carry no payload. */ + data class Response( + val type: Int, + val address: Int, + val data: ByteArray, + val result: Int + ) { + override fun equals(other: Any?): Boolean = + other is Response && type == other.type && address == other.address && + result == other.result && data.contentEquals(other.data) + + override fun hashCode(): Int = + (((type * 31 + address) * 31 + result) * 31) + data.contentHashCode() + } + + /** + * Validates and decodes a reassembled response frame. + * + * Returns `null` when the frame is truncated or the checksum does not match, so a corrupted + * notification is retried rather than parsed into a bogus measurement. + */ + fun parseResponse(frame: ByteArray): Response? { + if (frame.size < 8) return null + + val declaredLength = frame[0].toInt() and 0xFF + if (declaredLength < 8 || declaredLength > frame.size) return null + + val exact = frame.copyOf(declaredLength) + if (bcc(exact).toInt() != 0) return null + + val type = ((exact[1].toInt() and 0xFF) shl 8) or (exact[2].toInt() and 0xFF) + val address = ((exact[3].toInt() and 0xFF) shl 8) or (exact[4].toInt() and 0xFF) + val declaredPayload = exact[5].toInt() and 0xFF + val result = exact[declaredLength - 2].toInt() and 0xFF + + // The device pads short reads by declaring more payload than it sent; treat the surplus as + // erased EEPROM (0xFF) rather than dropping the frame. + val available = declaredLength - 8 + val data = if (declaredPayload > available) { + ByteArray(declaredPayload) { 0xFF.toByte() } + } else { + exact.copyOfRange(6, 6 + declaredPayload) + } + + return Response(type = type, address = address, data = data, result = result) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt new file mode 100644 index 000000000..454016b31 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt @@ -0,0 +1,625 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.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.bluetooth.libs.OmronBodyCompositionLib +import com.health.openscale.core.bluetooth.libs.OmronWlpFrame +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.security.SecureRandom +import java.text.DateFormat +import java.util.Locale +import java.util.UUID +import kotlin.math.min + +/** + * Handler for Omron body composition monitors that speak the WLC ("Wellness Link") transfer + * protocol — the HBF-702T family and the closely related HBF-222T/227T/228T/230T. + * + * These scales do not stream live weights. They store up to 30 measurements per on-device user slot + * in EEPROM, and the phone reads them out afterwards over a vendor GATT service: + * + * 1. unlock the device with a 16-byte key (programming a fresh one if we have none yet, which + * requires the scale to be in pairing mode), + * 2. open a transfer session, + * 3. read the record ring buffer of the slot bound to the current openScale user, + * 4. close the session. + * + * Nothing is ever written to the device beyond the unlock handshake, so a misread cannot corrupt + * stored measurements. "New" records are the ones stamped later than the newest measurement + * openScale already holds for this user. + */ +class OmronWlcHandler : ScaleDeviceHandler() { + + companion object { + val SVC_OMRON_WLP: UUID = UUID.fromString("ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b") + val CHR_UNLOCK: UUID = UUID.fromString("b305b680-aee7-11e1-a730-0002a5d5c51b") + + /** Command channels; a command is written across them in 16-byte pieces. */ + val CHR_TX: List = listOf( + UUID.fromString("db5b55e0-aee7-11e1-965e-0002a5d5c51b"), + UUID.fromString("e0b8a060-aee7-11e1-92f4-0002a5d5c51b"), + UUID.fromString("0ae12b00-aee8-11e1-a192-0002a5d5c51b"), + UUID.fromString("10e1ba60-aee8-11e1-89e5-0002a5d5c51b") + ) + + /** Response channels, notified in the same order the command channels are written. */ + val CHR_RX: List = listOf( + UUID.fromString("49123040-aee8-11e1-a74d-0002a5d5c51b"), + UUID.fromString("4d0bf320-aee8-11e1-a0d9-0002a5d5c51b"), + UUID.fromString("5128ce60-aee8-11e1-b84b-0002a5d5c51b"), + UUID.fromString("560f1420-aee8-11e1-8184-0002a5d5c51b") + ) + + /** + * Settings block holding, for each user slot, the ring buffer write pointer (bytes 0-3) and + * the number of records not yet transferred (bytes 4-7). + */ + private const val ADDR_INDEX_BLOCK = 0x01A0 + private const val INDEX_BLOCK_SIZE = 8 + + /** The pointer occupies the low six bits; the top two are status flags. */ + private const val POINTER_MASK = 0x3F + + /** Give up if the scale stops answering; it is a battery device and may simply walk away. */ + private const val RESPONSE_TIMEOUT_MS = 8_000L + + private const val KEY_SETTING_PREFIX = "pairingKey_" + private const val SLOT_SETTING_PREFIX = "userSlot_" + + /** + * Omron devices advertise as `BLEsmart_`, where the two 16-bit hex + * fields are exactly the device group and model ids the OMRON connect app keys its device + * table on. Scales live in group 1. + */ + private const val LOCAL_NAME_PREFIX = "blesmart_" + + private data class KnownModel( + val displayName: String, + val profile: OmronBodyCompositionLib.Profile + ) + + private val MODELS_BY_ADVERTISED_ID: Map = mapOf( + 0x0001_000C to KnownModel("Omron HBF-702T", OmronBodyCompositionLib.PROFILE_HBF_702T), + 0x0001_0011 to KnownModel("Omron KRD-703T", OmronBodyCompositionLib.PROFILE_HBF_702T), + 0x0001_040C to KnownModel("Omron HBF-702T", OmronBodyCompositionLib.PROFILE_HBF_702T), + 0x0001_0009 to KnownModel("Omron HBF-227T", OmronBodyCompositionLib.PROFILE_HBF_32), + 0x0001_000B to KnownModel("Omron HBF-228T", OmronBodyCompositionLib.PROFILE_HBF_32), + 0x0001_000D to KnownModel("Omron HBF-230T", OmronBodyCompositionLib.PROFILE_HBF_32), + 0x0001_0408 to KnownModel("Omron HBF-222T", OmronBodyCompositionLib.PROFILE_HBF_32), + 0x0001_0110 to KnownModel( + "Omron BCM-500", OmronBodyCompositionLib.PROFILE_HBF_32_NO_BODY_AGE + ), + 0x0001_0208 to KnownModel( + "Omron VIVA", OmronBodyCompositionLib.PROFILE_HBF_32_NO_BODY_AGE + ) + ) + + /** + * Once bonded, Android reports the GAP device name rather than the advertised local name, + * so the model has to be recognisable from that too. + */ + private val MODELS_BY_DEVICE_NAME: Map = mapOf( + "hbf-702t" to KnownModel("Omron HBF-702T", OmronBodyCompositionLib.PROFILE_HBF_702T), + "krd-703t" to KnownModel("Omron KRD-703T", OmronBodyCompositionLib.PROFILE_HBF_702T), + "hbf-227t" to KnownModel("Omron HBF-227T", OmronBodyCompositionLib.PROFILE_HBF_32), + "hbf-228t" to KnownModel("Omron HBF-228T", OmronBodyCompositionLib.PROFILE_HBF_32), + "hbf-230t" to KnownModel("Omron HBF-230T", OmronBodyCompositionLib.PROFILE_HBF_32), + "hbf-222t" to KnownModel("Omron HBF-222T", OmronBodyCompositionLib.PROFILE_HBF_32), + "bcm-500" to KnownModel( + "Omron BCM-500", OmronBodyCompositionLib.PROFILE_HBF_32_NO_BODY_AGE + ), + "viva" to KnownModel( + "Omron VIVA", OmronBodyCompositionLib.PROFILE_HBF_32_NO_BODY_AGE + ) + ) + + /** + * Resolves a scanned name to a supported model, or `null` if it is not one of ours. + * Exposed for tests. + */ + internal fun modelFor(deviceName: String): Pair? { + val name = deviceName.trim().lowercase(Locale.US) + + if (name.startsWith(LOCAL_NAME_PREFIX)) { + val ids = name.removePrefix(LOCAL_NAME_PREFIX) + if (ids.length >= 8) { + val advertisedId = ids.substring(0, 8).toIntOrNull(16) + MODELS_BY_ADVERTISED_ID[advertisedId]?.let { return it.displayName to it.profile } + } + return null + } + + return MODELS_BY_DEVICE_NAME[name]?.let { it.displayName to it.profile } + } + } + + /** Where the read-out has got to. Every step is driven by the device's previous answer. */ + private enum class Phase { + IDLE, + UNLOCKING, + ENTERING_KEY_PROGRAMMING, + WRITING_KEY, + STARTING_SESSION, + PROBING_INDEX, + PROBING_SLOT, + READING_SLOT, + AWAITING_SLOT_CHOICE, + ENDING_SESSION, + FINISHED + } + + private var phase = Phase.IDLE + private var profile: OmronBodyCompositionLib.Profile = OmronBodyCompositionLib.PROFILE_HBF_702T + + /** Partially received response, one entry per notify channel. */ + private val rxChannels = arrayOfNulls(CHR_RX.size) + + // Chunked EEPROM read in progress. + private var readAddress = 0 + private var readLength = 0 + private var readCursor = 0 + private val readBuffer = ArrayList() + private var onReadComplete: ((ByteArray) -> Unit)? = null + + // Slot probing state, used only when the current app user is not bound to a slot yet. + private val slotPreviews = arrayOfNulls(4) + private var probeSlot = 0 + private var probePointers = IntArray(0) + + private var boundSlot = -1 + private var pairingKey: ByteArray? = null + private var watchdog: Job? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val (displayName, _) = modelFor(device.name) ?: return null + + val capabilities = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.HISTORY_READ + ) + + return DeviceSupport( + displayName = displayName, + capabilities = capabilities, + implemented = capabilities, + tuningProfile = TuningProfile.Conservative, + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + resetSession() + + // The record layout differs per model, and guessing it would silently produce nonsense + // measurements, so an unrecognisable name aborts the session instead. + val peripheralName = getPeripheral()?.name.orEmpty() + val resolved = modelFor(peripheralName) + if (resolved == null) { + logE("cannot resolve an Omron record layout for '$peripheralName'") + userError(R.string.bt_error_omron_unknown_model, peripheralName) + requestDisconnect() + return + } + profile = resolved.second + + if (!hasCharacteristic(SVC_OMRON_WLP, CHR_UNLOCK)) { + userError(R.string.bt_error_omron_service_missing) + requestDisconnect() + return + } + + // Subscribing is also what prompts the scale to start BLE bonding, so it has to happen + // before the first unlock attempt. + setNotifyOn(SVC_OMRON_WLP, CHR_UNLOCK) + CHR_RX.forEach { setNotifyOn(SVC_OMRON_WLP, it) } + + boundSlot = settingsGetInt(slotSettingKey(user.id), -1) + pairingKey = loadPairingKey() + + val key = pairingKey + if (key == null) { + userInfo(R.string.bt_info_omron_pairing_mode_required) + phase = Phase.ENTERING_KEY_PROGRAMMING + writeUnlockChannel(OmronWlpFrame.enterKeyProgramming()) + } else { + phase = Phase.UNLOCKING + writeUnlockChannel(OmronWlpFrame.unlock(key)) + } + } + + override fun onDisconnected() { + watchdog?.cancel() + watchdog = null + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + armWatchdog() + + if (characteristic == CHR_UNLOCK) { + handleUnlockResponse(data, user) + return + } + + val channel = CHR_RX.indexOf(characteristic) + if (channel < 0) return + + rxChannels[channel] = data + val frame = assembleFrame() ?: return + + val response = OmronWlpFrame.parseResponse(frame) + if (response == null) { + logW("discarding corrupt response ${frame.toHexPreview(16)}") + return + } + handleResponse(response, user) + } + + // ---- unlock / pairing ---------------------------------------------------------------------- + + private fun handleUnlockResponse(data: ByteArray, user: ScaleUser) { + if (data.size < 2) return + val ack = ((data[0].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF) + + when (phase) { + Phase.UNLOCKING -> if (ack == OmronWlpFrame.ACK_UNLOCKED) { + openSession() + } else { + // The stored key no longer matches — most likely the scale was re-paired with the + // vendor app. Ask for pairing mode and program a fresh key. + logW("unlock rejected (ack=0x${ack.toString(16)}), re-pairing") + userInfo(R.string.bt_info_omron_pairing_mode_required) + pairingKey = null + phase = Phase.ENTERING_KEY_PROGRAMMING + writeUnlockChannel(OmronWlpFrame.enterKeyProgramming()) + } + + Phase.ENTERING_KEY_PROGRAMMING -> if (ack == OmronWlpFrame.ACK_KEY_PROGRAMMING) { + val key = ByteArray(OmronWlpFrame.KEY_SIZE).also { SecureRandom().nextBytes(it) } + pairingKey = key + phase = Phase.WRITING_KEY + writeUnlockChannel(OmronWlpFrame.writeKey(key)) + } else { + failSession(R.string.bt_error_omron_pairing_mode_required) + } + + Phase.WRITING_KEY -> if (ack == OmronWlpFrame.ACK_KEY_WRITTEN) { + pairingKey?.let { storePairingKey(it) } + userInfo(R.string.bt_info_omron_paired) + openSession() + } else { + failSession(R.string.bt_error_omron_pairing_failed) + } + + else -> logD("ignoring unlock-channel data in phase $phase") + } + } + + private fun openSession() { + phase = Phase.STARTING_SESSION + sendCommand(OmronWlpFrame.startTransmission()) + } + + // ---- transfer session ---------------------------------------------------------------------- + + private fun handleResponse(response: OmronWlpFrame.Response, user: ScaleUser) { + when (phase) { + Phase.STARTING_SESSION -> { + if (response.type != OmronWlpFrame.TYPE_START) { + failSession(R.string.bt_error_omron_session_failed) + return + } + if (boundSlot in 0 until profile.userSlotCount) { + startSlotReadout(boundSlot) + } else { + phase = Phase.PROBING_INDEX + readEeprom(ADDR_INDEX_BLOCK, INDEX_BLOCK_SIZE) { block -> + probePointers = decodeSlotPointers(block) + probeSlot = 0 + probeNextSlot(user) + } + } + } + + Phase.PROBING_INDEX, Phase.PROBING_SLOT, Phase.READING_SLOT -> { + if (response.type != OmronWlpFrame.TYPE_READ) { + failSession(R.string.bt_error_omron_read_failed) + return + } + consumeReadChunk(response.data) + } + + Phase.ENDING_SESSION -> { + if (response.result != 0) { + logW("device reported result ${response.result} on session end") + } + phase = Phase.FINISHED + watchdog?.cancel() + requestDisconnect() + } + + else -> logD("ignoring response 0x${response.type.toString(16)} in phase $phase") + } + } + + /** + * The write pointer names the *next* slot to be filled, so the newest record sits one place + * behind it. Values outside the ring buffer mean the block could not be interpreted. + */ + private fun decodeSlotPointers(block: ByteArray): IntArray { + if (block.size < profile.userSlotCount) return IntArray(0) + return IntArray(profile.userSlotCount) { slot -> + val pointer = block[slot].toInt() and POINTER_MASK + if (pointer > profile.recordsPerSlot) { + -1 + } else { + (pointer - 1 + profile.recordsPerSlot) % profile.recordsPerSlot + } + } + } + + private fun probeNextSlot(user: ScaleUser) { + if (probeSlot >= profile.userSlotCount) { + presentSlotChoice() + return + } + + val slot = probeSlot + val newest = probePointers.getOrElse(slot) { -1 } + if (newest < 0) { + probeSlot++ + probeNextSlot(user) + return + } + + phase = Phase.PROBING_SLOT + readEeprom(profile.recordAddress(slot, newest), profile.recordSize) { record -> + slotPreviews[slot] = OmronBodyCompositionLib.decodeRecord(record, profile) + probeSlot++ + probeNextSlot(user) + } + } + + private fun presentSlotChoice() { + phase = Phase.AWAITING_SLOT_CHOICE + + val labels = ArrayList(profile.userSlotCount) + val indices = IntArray(profile.userSlotCount) { it } + for (slot in 0 until profile.userSlotCount) { + val preview = slotPreviews[slot] + labels += if (preview != null) { + resolveString( + R.string.bt_omron_slot_with_measurement, + slot + 1, + preview.weightKg, + DateFormat.getDateInstance(DateFormat.SHORT).format(preview.timestamp) + ) + } else { + resolveString(R.string.bt_omron_slot_empty, slot + 1) + } + } + + requestUserInteraction( + UserInteractionType.CHOOSE_USER, + Pair(labels.toTypedArray(), indices) + ) + } + + override suspend fun onUserInteractionFeedback( + interactionType: UserInteractionType, + appUserId: Int, + feedbackData: Any + ) { + if (interactionType != UserInteractionType.CHOOSE_USER) return + if (phase != Phase.AWAITING_SLOT_CHOICE) return + + val slot = feedbackData as? Int + if (slot == null || slot !in 0 until profile.userSlotCount) { + logW("CHOOSE_USER feedback is not a valid slot: $feedbackData") + return + } + + settingsPutInt(slotSettingKey(appUserId), slot) + boundSlot = slot + userInfo(R.string.bt_info_linked_app_user_to_slot, appUserId, slot + 1) + startSlotReadout(slot) + } + + private fun startSlotReadout(slot: Int) { + phase = Phase.READING_SLOT + val length = profile.recordSize * profile.recordsPerSlot + readEeprom(profile.recordAddress(slot, 0), length) { records -> + publishRecords(records) + closeSession() + } + } + + private fun closeSession() { + phase = Phase.ENDING_SESSION + sendCommand(OmronWlpFrame.endTransmission()) + } + + // ---- record publication -------------------------------------------------------------------- + + private fun publishRecords(raw: ByteArray) { + val user = currentAppUser() + val knownUpTo = lastMeasurementFor(user.id)?.dateTime + + val decoded = ArrayList() + var offset = 0 + while (offset + profile.recordSize <= raw.size) { + val record = raw.copyOfRange(offset, offset + profile.recordSize) + OmronBodyCompositionLib.decodeRecord(record, profile)?.let { decoded += it } + offset += profile.recordSize + } + + val fresh = decoded + .filter { knownUpTo == null || it.timestamp.after(knownUpTo) } + .sortedBy { it.timestamp } + + logI("decoded ${decoded.size} records, ${fresh.size} newer than ${knownUpTo ?: "(none)"}") + + fresh.forEach { publish(it.toMeasurement(user.id)) } + + if (fresh.isEmpty()) { + userInfo(R.string.bt_info_omron_no_new_measurements) + } else { + userInfo(R.string.bt_info_omron_measurements_read, fresh.size) + } + } + + private fun OmronBodyCompositionLib.Record.toMeasurement(userId: Int) = ScaleMeasurement( + userId = userId, + dateTime = timestamp, + weight = weightKg, + fat = bodyFatPercent ?: 0f, + muscle = skeletalMusclePercent ?: 0f, + visceralFat = visceralFatLevel ?: 0f, + bmr = bmrKcal?.toFloat() ?: 0f + ) + + // ---- chunked EEPROM reads ------------------------------------------------------------------ + + private fun readEeprom(address: Int, length: Int, onComplete: (ByteArray) -> Unit) { + readAddress = address + readLength = length + readCursor = 0 + readBuffer.clear() + readBuffer.ensureCapacity(length) + onReadComplete = onComplete + requestNextChunk() + } + + private fun requestNextChunk() { + if (readCursor >= readLength) { + val callback = onReadComplete + onReadComplete = null + callback?.invoke(readBuffer.toByteArray()) + return + } + val chunk = min(OmronWlpFrame.MAX_READ_BLOCK, readLength - readCursor) + sendCommand(OmronWlpFrame.readEeprom(readAddress + readCursor, chunk)) + } + + private fun consumeReadChunk(data: ByteArray) { + val expected = min(OmronWlpFrame.MAX_READ_BLOCK, readLength - readCursor) + if (expected <= 0) return + + val accepted = min(expected, data.size) + for (i in 0 until accepted) readBuffer += data[i] + // A short answer would desynchronise every following address; pad it out as erased memory. + repeat(expected - accepted) { readBuffer += 0xFF.toByte() } + + readCursor += expected + requestNextChunk() + } + + // ---- transport helpers --------------------------------------------------------------------- + + private fun sendCommand(command: ByteArray) { + armWatchdog() + OmronWlpFrame.toChannelChunks(command).forEachIndexed { index, chunk -> + if (index < CHR_TX.size) writeTo(SVC_OMRON_WLP, CHR_TX[index], chunk) + } + } + + private fun writeUnlockChannel(payload: ByteArray) { + armWatchdog() + writeTo(SVC_OMRON_WLP, CHR_UNLOCK, payload) + } + + /** Reassembles a response once every channel it spans has arrived. */ + private fun assembleFrame(): ByteArray? { + val first = rxChannels[0] ?: return null + if (first.isEmpty()) return null + + val frameLength = first[0].toInt() and 0xFF + val needed = OmronWlpFrame.channelsForFrame(frameLength) + if (needed > rxChannels.size) { + rxChannels.fill(null) + return null + } + for (i in 0 until needed) if (rxChannels[i] == null) return null + + var frame = ByteArray(0) + for (i in 0 until needed) frame += rxChannels[i]!! + rxChannels.fill(null) + + return if (frame.size >= frameLength) frame.copyOf(frameLength) else null + } + + // ---- session bookkeeping ------------------------------------------------------------------- + + private fun resetSession() { + phase = Phase.IDLE + rxChannels.fill(null) + readBuffer.clear() + onReadComplete = null + slotPreviews.fill(null) + probePointers = IntArray(0) + probeSlot = 0 + } + + /** + * Restarted on every byte from the scale. If the conversation stalls — a lost notification, or + * the scale simply powering down — the session is abandoned instead of hanging the connection. + */ + private fun armWatchdog() { + watchdog?.cancel() + watchdog = scope.launch { + delay(RESPONSE_TIMEOUT_MS) + if (phase != Phase.FINISHED && phase != Phase.AWAITING_SLOT_CHOICE) { + logW("no response for ${RESPONSE_TIMEOUT_MS}ms in phase $phase, aborting") + userWarn(R.string.bt_error_omron_timeout) + requestDisconnect() + } + } + } + + private fun failSession(resId: Int) { + userError(resId) + phase = Phase.FINISHED + watchdog?.cancel() + requestDisconnect() + } + + // ---- persisted per-device state ------------------------------------------------------------ + + private fun deviceKey(): String = getPeripheral()?.address ?: "unknown" + + private fun slotSettingKey(appUserId: Int) = "$SLOT_SETTING_PREFIX${deviceKey()}_$appUserId" + + private fun loadPairingKey(): ByteArray? { + val hex = settingsGetString(KEY_SETTING_PREFIX + deviceKey()) ?: return null + if (hex.length != OmronWlpFrame.KEY_SIZE * 2) return null + return runCatching { + ByteArray(OmronWlpFrame.KEY_SIZE) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() } + }.getOrNull() + } + + private fun storePairingKey(key: ByteArray) { + val hex = key.joinToString("") { String.format(Locale.US, "%02x", it) } + settingsPutString(KEY_SETTING_PREFIX + deviceKey(), hex) + } +} diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 7e41a90e1..e8244829b 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -870,6 +870,21 @@ Waiting for measurement via notifications. Linked app user %1$d to scale slot %2$d. + + Hold the scale\'s Bluetooth button until it shows the blinking pairing indicator. + Scale paired with openScale. + Read %1$d new measurement(s) from the scale. + No new measurements stored on the scale. + Scale user %1$d — %2$.1f kg on %3$s + Scale user %1$d — no measurements + Unsupported Omron model \"%1$s\"; cannot decode its measurement records. + This device does not expose the Omron transfer service. + The scale is not in pairing mode. Hold its Bluetooth button until the pairing indicator blinks, then try again. + Could not store the pairing key on the scale. + The scale refused to start a data transfer. + Failed to read stored measurements from the scale. + The scale stopped responding; the transfer was cancelled. + Scale battery is low (%1$d%%). Creating the user on the scale failed (code %1$d). diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OmronLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OmronLibTest.kt new file mode 100644 index 000000000..0de68bbf9 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OmronLibTest.kt @@ -0,0 +1,323 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Calendar + +/** + * Unit tests for the Omron WLC record decoder. + * + * The vectors are records assembled field by field from Omron's own bit-level record description, + * independently of the decoder under test: weight 72.40 kg, body fat 23.5 %, skeletal muscle + * 31.2 %, BMI 23.7, BMR 1520 kcal, body age 34, measured 2024-03-17 07:45:12. They exercise the + * fields that share a byte with a neighbour, which is where an off-by-one in the bit positions + * would show up: body fat with visceral fat, BMR with its classification, and the day/hour pair + * that straddles bytes 12 and 13. + */ +class OmronBodyCompositionLibTest { + + private fun hex(s: String): ByteArray = + s.filterNot { it.isWhitespace() }.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + /** 48-byte HBF-702T record; visceral fat is stored in half levels (raw 16 → level 8.0). */ + private val record702t = hex( + "00 00 3a d0 5f 02 4e 18 3b 6d 22 33 89 cc 00 00" + + "00 00 00 00 00 00 00 00 00 00 5a 80 00 00 00 00" + + "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00" + ) + + /** 32-byte HBF-227T record; visceral fat is stored in whole levels (raw 8 → level 8). */ + private val record32 = hex( + "00 00 3a c8 5f 02 4e 18 3b 6d 22 33 89 cc 00 00" + + "00 00 00 00 00 00 00 00 00 00 5a 80 00 00 00 00" + ) + + private fun calendarOf(record: OmronBodyCompositionLib.Record) = + Calendar.getInstance().apply { time = record.timestamp } + + @Test + fun `decodes every field of an HBF-702T record`() { + val decoded = OmronBodyCompositionLib.decodeRecord( + record702t, OmronBodyCompositionLib.PROFILE_HBF_702T + ) + + assertThat(decoded).isNotNull() + with(decoded!!) { + assertThat(weightKg).isWithin(1e-4f).of(72.40f) + assertThat(bodyFatPercent!!).isWithin(1e-4f).of(23.5f) + assertThat(skeletalMusclePercent!!).isWithin(1e-4f).of(31.2f) + assertThat(bmi!!).isWithin(1e-4f).of(23.7f) + assertThat(bmrKcal).isEqualTo(1520) + assertThat(visceralFatLevel!!).isWithin(1e-4f).of(8.0f) + assertThat(bodyAgeYears).isEqualTo(34) + } + } + + @Test + fun `reassembles the timestamp scattered across bytes 7 9 and 11 to 13`() { + val decoded = OmronBodyCompositionLib.decodeRecord( + record702t, OmronBodyCompositionLib.PROFILE_HBF_702T + )!! + + with(calendarOf(decoded)) { + assertThat(get(Calendar.YEAR)).isEqualTo(2024) + assertThat(get(Calendar.MONTH)).isEqualTo(Calendar.MARCH) + assertThat(get(Calendar.DAY_OF_MONTH)).isEqualTo(17) + assertThat(get(Calendar.HOUR_OF_DAY)).isEqualTo(7) + assertThat(get(Calendar.MINUTE)).isEqualTo(45) + assertThat(get(Calendar.SECOND)).isEqualTo(12) + } + } + + @Test + fun `the 32 byte models report visceral fat in whole levels`() { + val decoded = OmronBodyCompositionLib.decodeRecord( + record32, OmronBodyCompositionLib.PROFILE_HBF_32 + ) + + assertThat(decoded).isNotNull() + assertThat(decoded!!.visceralFatLevel!!).isWithin(1e-4f).of(8.0f) + // The remaining fields sit at the same offsets as on the 48-byte record. + assertThat(decoded.weightKg).isWithin(1e-4f).of(72.40f) + assertThat(decoded.bodyFatPercent!!).isWithin(1e-4f).of(23.5f) + } + + @Test + fun `body age is suppressed on the variants that do not measure it`() { + val withAge = OmronBodyCompositionLib.decodeRecord( + record32, OmronBodyCompositionLib.PROFILE_HBF_32 + )!! + val withoutAge = OmronBodyCompositionLib.decodeRecord( + record32, OmronBodyCompositionLib.PROFILE_HBF_32_NO_BODY_AGE + )!! + + assertThat(withAge.bodyAgeYears).isEqualTo(34) + assertThat(withoutAge.bodyAgeYears).isNull() + } + + @Test + fun `an unwritten ring buffer slot decodes to nothing`() { + val erased = ByteArray(48) { 0xFF.toByte() } + val zeroed = ByteArray(48) + + assertThat(OmronBodyCompositionLib.decodeRecord(erased, OmronBodyCompositionLib.PROFILE_HBF_702T)).isNull() + assertThat(OmronBodyCompositionLib.decodeRecord(zeroed, OmronBodyCompositionLib.PROFILE_HBF_702T)).isNull() + } + + @Test + fun `a record with no weight is not a measurement`() { + val noWeight = record702t.copyOf().also { it[26] = 0; it[27] = 0 } + + assertThat(OmronBodyCompositionLib.decodeRecord(noWeight, OmronBodyCompositionLib.PROFILE_HBF_702T)) + .isNull() + } + + @Test + fun `a record carrying an impossible date is rejected`() { + // Month 15 cannot occur; byte 11 holds the month in its low nibble. + val badMonth = record702t.copyOf().also { it[11] = 0x3F } + + assertThat(OmronBodyCompositionLib.decodeRecord(badMonth, OmronBodyCompositionLib.PROFILE_HBF_702T)) + .isNull() + } + + @Test + fun `a record shorter than the profile is rejected`() { + assertThat(OmronBodyCompositionLib.decodeRecord(record32, OmronBodyCompositionLib.PROFILE_HBF_702T)) + .isNull() + } + + @Test + fun `unmeasured optional values come back as null rather than zero`() { + // Clear body fat and visceral fat (byte pair 2-3) and the BMR/classification pair (4-5). + val sparse = record702t.copyOf().also { + it[2] = 0; it[3] = 0; it[4] = 0; it[5] = 0 + } + + val decoded = OmronBodyCompositionLib.decodeRecord( + sparse, OmronBodyCompositionLib.PROFILE_HBF_702T + )!! + + assertThat(decoded.bodyFatPercent).isNull() + assertThat(decoded.visceralFatLevel).isNull() + assertThat(decoded.bmrKcal).isNull() + assertThat(decoded.weightKg).isWithin(1e-4f).of(72.40f) + } + + @Test + fun `slot addresses follow the per-model record stride`() { + with(OmronBodyCompositionLib.PROFILE_HBF_702T) { + assertThat(recordAddress(0, 0)).isEqualTo(0x02C0) + assertThat(recordAddress(0, 29)).isEqualTo(0x02C0 + 29 * 48) + assertThat(recordAddress(3, 0)).isEqualTo(0x1430) + assertThat(userSlotCount).isEqualTo(4) + } + with(OmronBodyCompositionLib.PROFILE_HBF_32) { + assertThat(recordAddress(1, 0)).isEqualTo(0x06A0) + // Each slot is allocated one record more than the ring buffer uses. + assertThat(recordAddress(1, 0) - recordAddress(0, 0)).isEqualTo(31 * 32) + } + } +} + +/** + * Unit tests for the Omron WLP transfer framing. + * + * The session-control frames are asserted against the literal byte sequences observed on Omron + * devices, so a change to the checksum or the frame layout fails here rather than on hardware. + */ +class OmronWlpFrameTest { + + private fun hex(s: String): ByteArray = + s.filterNot { it.isWhitespace() }.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private fun String.asBytes() = hex(this) + + @Test + fun `start and end frames match the sequences Omron devices expect`() { + assertThat(OmronWlpFrame.startTransmission()).isEqualTo("0800000000100018".asBytes()) + assertThat(OmronWlpFrame.endTransmission()).isEqualTo("080f000000000007".asBytes()) + } + + @Test + fun `an intact frame checksums to zero`() { + assertThat(OmronWlpFrame.bcc(OmronWlpFrame.startTransmission()).toInt()).isEqualTo(0) + assertThat(OmronWlpFrame.bcc(OmronWlpFrame.endTransmission()).toInt()).isEqualTo(0) + } + + @Test + fun `builds an eeprom read for the first record of user slot 1`() { + assertThat(OmronWlpFrame.readEeprom(0x02C0, 0x20)).isEqualTo("080100 02c0 20 00 eb".asBytes()) + } + + @Test + fun `rejects reads that exceed one block or address the flag bits`() { + runCatching { OmronWlpFrame.readEeprom(0x02C0, 0x21) }.let { assertThat(it.isFailure).isTrue() } + runCatching { OmronWlpFrame.readEeprom(0x4000, 0x20) }.let { assertThat(it.isFailure).isTrue() } + } + + @Test + fun `unlock channel commands carry the documented opcodes`() { + val key = ByteArray(16) { (it + 1).toByte() } + + assertThat(OmronWlpFrame.unlock(key)[0].toInt()).isEqualTo(0x01) + assertThat(OmronWlpFrame.unlock(key).copyOfRange(1, 17)).isEqualTo(key) + + assertThat(OmronWlpFrame.writeKey(key)[0].toInt()).isEqualTo(0x00) + assertThat(OmronWlpFrame.writeKey(key).copyOfRange(1, 17)).isEqualTo(key) + + val enter = OmronWlpFrame.enterKeyProgramming() + assertThat(enter.size).isEqualTo(17) + assertThat(enter[0].toInt()).isEqualTo(0x02) + assertThat(enter.drop(1).all { it.toInt() == 0 }).isTrue() + } + + @Test + fun `a short key is refused rather than padded`() { + assertThat(runCatching { OmronWlpFrame.unlock(ByteArray(8)) }.isFailure).isTrue() + } + + @Test + fun `a 40 byte response spans three notify channels`() { + assertThat(OmronWlpFrame.channelsForFrame(40)).isEqualTo(3) + assertThat(OmronWlpFrame.channelsForFrame(8)).isEqualTo(1) + assertThat(OmronWlpFrame.channelsForFrame(32)).isEqualTo(2) + + val chunks = OmronWlpFrame.toChannelChunks(ByteArray(40) { it.toByte() }) + assertThat(chunks.map { it.size }).containsExactly(16, 16, 8).inOrder() + } + + /** Builds a well-formed read response carrying [payload] from EEPROM [address]. */ + private fun readResponse(address: Int, payload: ByteArray): ByteArray { + val frame = ByteArray(payload.size + 8) + frame[0] = frame.size.toByte() + frame[1] = 0x81.toByte() + frame[2] = 0x00 + frame[3] = ((address shr 8) and 0xFF).toByte() + frame[4] = (address and 0xFF).toByte() + frame[5] = payload.size.toByte() + payload.copyInto(frame, 6) + frame[frame.size - 1] = OmronWlpFrame.bcc(frame, frame.size - 1) + return frame + } + + @Test + fun `parses a read response`() { + val payload = ByteArray(32) { (0xA0 + it).toByte() } + val parsed = OmronWlpFrame.parseResponse(readResponse(0x02C0, payload)) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.type).isEqualTo(OmronWlpFrame.TYPE_READ) + assertThat(parsed.address).isEqualTo(0x02C0) + assertThat(parsed.data).isEqualTo(payload) + assertThat(parsed.result).isEqualTo(0) + } + + @Test + fun `parses the session end result code`() { + val parsed = OmronWlpFrame.parseResponse("088f000000000087".asBytes()) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.type).isEqualTo(OmronWlpFrame.TYPE_END) + assertThat(parsed.result).isEqualTo(0) + } + + @Test + fun `rejects a frame whose checksum does not match`() { + val frame = readResponse(0x02C0, ByteArray(32) { it.toByte() }) + frame[10] = (frame[10] + 1).toByte() + + assertThat(OmronWlpFrame.parseResponse(frame)).isNull() + } + + @Test + fun `rejects truncated frames`() { + val frame = readResponse(0x02C0, ByteArray(32) { it.toByte() }) + + assertThat(OmronWlpFrame.parseResponse(frame.copyOf(20))).isNull() + assertThat(OmronWlpFrame.parseResponse(ByteArray(4))).isNull() + } + + @Test + fun `ignores padding after the declared frame length`() { + // Channels are 16 bytes wide, so a 40-byte frame arrives inside 48 bytes of notifications. + val payload = ByteArray(32) { (it * 3).toByte() } + val padded = readResponse(0x0890, payload) + ByteArray(8) { 0x55 } + + val parsed = OmronWlpFrame.parseResponse(padded) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.data).isEqualTo(payload) + } + + @Test + fun `treats an under-filled read as erased memory rather than dropping the frame`() { + // The device declares 32 payload bytes but the frame only has room for 8. + val frame = byteArrayOf(0x10, 0x81.toByte(), 0x00, 0x02, 0xC0.toByte(), 0x20) + + ByteArray(8) + byteArrayOf(0x00, 0x00) + frame[frame.size - 1] = OmronWlpFrame.bcc(frame, frame.size - 1) + + val parsed = OmronWlpFrame.parseResponse(frame) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.data.size).isEqualTo(32) + assertThat(parsed.data.all { it == 0xFF.toByte() }).isTrue() + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandlerTest.kt new file mode 100644 index 000000000..d370cb067 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandlerTest.kt @@ -0,0 +1,102 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.libs.OmronBodyCompositionLib +import org.junit.Test + +/** + * Model-recognition tests for [OmronWlcHandler]. + * + * Omron devices advertise as `BLEsmart_`; the two hex fields are the device + * group and model ids from the vendor app's own device table. Once bonded, Android reports the + * plain GAP name instead, so both forms have to resolve to the same record layout. + */ +class OmronWlcHandlerTest { + + @Test + fun `recognises the HBF-702T variants from their advertised ids`() { + // Japan, KRD-703T and Asia-Pacific trims — all 48-byte records. + for (name in listOf( + "BLEsmart_0001000C0080E1A2B3C4", + "BLEsmart_000100110080E1A2B3C4", + "BLEsmart_0001040C0080E1A2B3C4" + )) { + val model = OmronWlcHandler.modelFor(name) + assertThat(model).isNotNull() + assertThat(model!!.second).isEqualTo(OmronBodyCompositionLib.PROFILE_HBF_702T) + } + } + + @Test + fun `recognises the 32 byte HBF siblings and their body age variants`() { + val withBodyAge = listOf( + "BLEsmart_000100090080E1A2B3C4", // HBF-227T + "BLEsmart_0001000B0080E1A2B3C4", // HBF-228T + "BLEsmart_0001000D0080E1A2B3C4", // HBF-230T + "BLEsmart_000104080080E1A2B3C4" // HBF-222T Asia-Pacific + ) + for (name in withBodyAge) { + assertThat(OmronWlcHandler.modelFor(name)?.second) + .isEqualTo(OmronBodyCompositionLib.PROFILE_HBF_32) + } + + val withoutBodyAge = listOf( + "BLEsmart_000101100080E1A2B3C4", // BCM-500 + "BLEsmart_000102080080E1A2B3C4" // VIVA + ) + for (name in withoutBodyAge) { + assertThat(OmronWlcHandler.modelFor(name)?.second) + .isEqualTo(OmronBodyCompositionLib.PROFILE_HBF_32_NO_BODY_AGE) + } + } + + @Test + fun `matching is case insensitive`() { + assertThat(OmronWlcHandler.modelFor("blesmart_0001040c0080e1a2b3c4")?.first) + .isEqualTo("Omron HBF-702T") + assertThat(OmronWlcHandler.modelFor("hbf-702t")?.first).isEqualTo("Omron HBF-702T") + } + + @Test + fun `falls back to the GAP name reported once the scale is bonded`() { + assertThat(OmronWlcHandler.modelFor("HBF-702T")?.second) + .isEqualTo(OmronBodyCompositionLib.PROFILE_HBF_702T) + assertThat(OmronWlcHandler.modelFor("KRD-703T")?.second) + .isEqualTo(OmronBodyCompositionLib.PROFILE_HBF_702T) + assertThat(OmronWlcHandler.modelFor("HBF-228T")?.second) + .isEqualTo(OmronBodyCompositionLib.PROFILE_HBF_32) + } + + @Test + fun `does not claim other Omron devices`() { + // Blood pressure monitors advertise in group 0 and speak a different record format. + assertThat(OmronWlcHandler.modelFor("BLEsmart_000002210080E1A2B3C4")).isNull() + assertThat(OmronWlcHandler.modelFor("HEM-7322T")).isNull() + } + + @Test + fun `does not claim unrelated or malformed names`() { + assertThat(OmronWlcHandler.modelFor("")).isNull() + assertThat(OmronWlcHandler.modelFor("MI SCALE")).isNull() + assertThat(OmronWlcHandler.modelFor("BLEsmart_")).isNull() + assertThat(OmronWlcHandler.modelFor("BLEsmart_00010")).isNull() + assertThat(OmronWlcHandler.modelFor("BLEsmart_zzzzzzzz0080E1A2")).isNull() + } +} From 5bed4821025bc34d8a154eea4fee14daedde161d Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 14 Aug 2026 11:43:07 +0200 Subject: [PATCH 38/72] =?UTF-8?q?=20Add=20ScaleFactoryTest=20to=20cover=20?= =?UTF-8?q?the=20registry=20as=20a=20whole=20=E2=80=94=20the=20per-handler?= =?UTF-8?q?=20tests=20cannot=20see=20across=20handlers,=20so=20a=20misplac?= =?UTF-8?q?ed=20matcher=20steals=20a=20device=20while=20they=20stay=20=20g?= =?UTF-8?q?reen.=20The=20handler=20list=20moves=20into=20ScaleFactory.crea?= =?UTF-8?q?teHandlers()=20=20=20(@VisibleForTesting)=20to=20be=20reachable?= =?UTF-8?q?=20without=20Hilt;=20no=20behaviour=20change.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also switches BackupRestoreUseCasesTest to Truth --- .../openscale/core/bluetooth/ScaleFactory.kt | 145 +++++---- .../core/bluetooth/ScaleFactoryTest.kt | 292 ++++++++++++++++++ .../core/usecase/BackupRestoreUseCasesTest.kt | 75 +++-- 3 files changed, 417 insertions(+), 95 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 1ef88fb97..b97bca7aa 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth import android.content.Context +import androidx.annotation.VisibleForTesting import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler import com.health.openscale.core.bluetooth.scales.AAAxHandler @@ -110,72 +111,84 @@ class ScaleFactory @Inject constructor( ) { private val TAG = "ScaleHandlerFactory" - // List of modern Kotlin-based device handlers. - // Order matters: createCommunicator() returns the FIRST handler whose supportFor() is non-null. - // TaylorBIAHandler and FitTrackDaraHandler must stay ahead of MGBHandler — all live on service - // 0xFFB0, which MGBHandler also matches, so a later position would let MGB wrongly claim them. - private val modernKotlinHandlers: List = listOf( - // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). - ActiveEraBF06Handler(), - KeepS3Handler(), - OmronWlcHandler(), - BeurerBF450Handler(), - TaylorBIAHandler(), - RyFitHandler(), - CultSmartScaleProHandler(), - RealmeSmartScaleHandler(), - YunmaiHandler(isMini = false), - YunmaiHandler(isMini = true), - YunmaiXHandler(), - TrisaBodyAnalyzeHandler(), - SanitasSbf72Handler(), - StandardBeurerSanitasHandler(), - SoehnleHandler(), - SinocareHandler(), - SenssunHandler(), - RenphoHandler(), - QNHandlerBroadcast(), - QNHandler(), - OneByoneHandler(), - OneByoneNewHandler(), - OkOkHandler(), - MiScaleS400Handler(), - XiaomiS800Handler(), - MiScaleHandler(), - RunstarR6Handler(), - RunstarR5Handler(), - RelaxmedicHandler(), - RobiS9Handler(), - VitafitVT701Handler(), - EEBBLHandler(), - FitTrackDaraHandler(), - MGBHandler(), - MedisanaBs44xHandler(), - InlifeHandler(), - IHealthHS3Handler(), - HuaweiAhCh100Handler(), - HuaweiCH100SHandler(), - HuaweiHagridWspHandler(), - HoffenBbs8107Handler(), - HesleyHandler(), - ExingtechY1Handler(), - EbelterBodyFatB2Handler(), - ExcelvanCF36xHandler(), - EtekcityESF551Handler(), - EufyC20Handler(), - EufyP2Handler(), - ESCS20MHandler(), - RenphoES26BBHandler(), - DigooDGSO38HHandler(), - DebugGattHandler(), - CustomOpenScaleHandler(), - BeurerSanitasHandler(), - AAAxHandler(), - ScaleupHandler(), - DrTrustSSW532Handler(), - BodyConnectHandler(), - WeightGurusA3Handler(), - ) + 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. TaylorBIAHandler, FitTrackDaraHandler, RelaxmedicHandler, RobiS9Handler and + * DrTrustSSW532Handler must stay ahead of MGBHandler — all live on service 0xFFB0, which + * MGBHandler matches on its own, so a later position would let MGB wrongly claim them. + * + * Exposed so the registry (order, device claims, duplicates) can be asserted in unit tests + * without building the Hilt graph — see `ScaleFactoryTest`. + */ + @VisibleForTesting + internal fun createHandlers(): List = listOf( + // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). + ActiveEraBF06Handler(), + KeepS3Handler(), + OmronWlcHandler(), + BeurerBF450Handler(), + TaylorBIAHandler(), + RyFitHandler(), + CultSmartScaleProHandler(), + RealmeSmartScaleHandler(), + YunmaiHandler(isMini = false), + YunmaiHandler(isMini = true), + YunmaiXHandler(), + TrisaBodyAnalyzeHandler(), + SanitasSbf72Handler(), + StandardBeurerSanitasHandler(), + SoehnleHandler(), + SinocareHandler(), + SenssunHandler(), + RenphoHandler(), + QNHandlerBroadcast(), + QNHandler(), + OneByoneHandler(), + OneByoneNewHandler(), + OkOkHandler(), + MiScaleS400Handler(), + XiaomiS800Handler(), + MiScaleHandler(), + RunstarR6Handler(), + RunstarR5Handler(), + RelaxmedicHandler(), + RobiS9Handler(), + VitafitVT701Handler(), + EEBBLHandler(), + FitTrackDaraHandler(), + DrTrustSSW532Handler(), + MGBHandler(), + MedisanaBs44xHandler(), + InlifeHandler(), + IHealthHS3Handler(), + HuaweiAhCh100Handler(), + HuaweiCH100SHandler(), + HuaweiHagridWspHandler(), + HoffenBbs8107Handler(), + HesleyHandler(), + ExingtechY1Handler(), + EbelterBodyFatB2Handler(), + ExcelvanCF36xHandler(), + EtekcityESF551Handler(), + EufyC20Handler(), + EufyP2Handler(), + ESCS20MHandler(), + RenphoES26BBHandler(), + DigooDGSO38HHandler(), + DebugGattHandler(), + CustomOpenScaleHandler(), + BeurerSanitasHandler(), + AAAxHandler(), + ScaleupHandler(), + BodyConnectHandler(), + WeightGurusA3Handler(), + ) + } /** * Creates a [ScaleCommunicator] based on a modern [ScaleDeviceHandler]. diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt new file mode 100644 index 000000000..627e01774 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt @@ -0,0 +1,292 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler +import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler +import com.health.openscale.core.bluetooth.scales.MGBHandler +import com.health.openscale.core.bluetooth.scales.RelaxmedicHandler +import com.health.openscale.core.bluetooth.scales.RobiS9Handler +import com.health.openscale.core.bluetooth.scales.SanitasSbf72Handler +import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler +import com.health.openscale.core.bluetooth.scales.TaylorBIAHandler +import com.health.openscale.core.bluetooth.scales.YunmaiHandler +import com.health.openscale.core.service.ScannedDeviceInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +/** + * Registry-level tests for [ScaleFactory]'s handler list. + * + * The per-handler tests in `bluetooth/scales/` each check one `supportFor` in isolation and + * therefore cannot see *across* handlers: a new driver whose matcher is a little too greedy + * silently steals a device from an existing one while every single handler test stays green. + * These tests close that gap by probing the whole ordered registry — the same list and the + * same first-match-wins rule [ScaleFactory.createCommunicator] uses. + * + * Fixtures are synthetic advertisements built from the advertised names/services documented in + * the handlers themselves, not from personal captures. + * + * Robolectric is required because some handlers touch the Android framework while being + * constructed (e.g. QNHandler creates a main-looper Handler). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ScaleFactoryTest { + + /** Service 0xFFB0 — the LeFu-style service shared by a whole family of unrelated scales. */ + private val SERVICE_FFB0 = uuid16(0xFFB0) + + /** + * A fresh registry per probe: several handlers stash state in `supportFor` (e.g. + * StandardBeurerSanitasHandler remembers the matched model), so a shared instance would let + * one fixture bleed into the next. + */ + private fun claimants(device: ScannedDeviceInfo): List = + ScaleFactory.createHandlers().filter { it.supportFor(device) != null } + + /** The handler [ScaleFactory.createCommunicator] would pick for [device], or null. */ + private fun winner(device: ScannedDeviceInfo): ScaleDeviceHandler? = + ScaleFactory.createHandlers().firstOrNull { it.supportFor(device) != null } + + private fun device(name: String, vararg services: UUID) = ScannedDeviceInfo( + name = name, + address = "C0:FF:EE:12:34:56", + rssi = -50, + serviceUuids = services.toList(), + manufacturerData = null, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun assertClaimedBy(device: ScannedDeviceInfo, expected: Class) { + val winner = winner(device) + assertThat(winner).isNotNull() + assertThat(winner!!.javaClass.simpleName).isEqualTo(expected.simpleName) + } + + // --- Device claims ---------------------------------------------------------------------- + + /** + * The regression guard that matters: for a spread of known advertised names, the registry must + * still resolve to the handler that owns that device. A new (or reordered) handler that starts + * matching one of these names fails here. + */ + @Test + fun `known advertised names resolve to their own handler`() { + val expectations: List> = listOf( + // name (as advertised) expected handler class + "openScale" to "CustomOpenScaleHandler", + "MIBFS" to "MiScaleHandler", + "MI_SCALE" to "MiScaleHandler", + "XMTZC14HM" to "MiScaleS400Handler", + "MIJIA SCALE S800" to "XiaomiS800Handler", + "BLEsmart_0001000C0080E1A2B3C4" to "OmronWlcHandler", + "Keep_S3" to "KeepS3Handler", + "AE BS-06" to "ActiveEraBF06Handler", + "Shape200" to "SoehnleHandler", + "YUNMAI-ISSE-1234" to "YunmaiHandler", + "YUNMAI-SIGNAL-1234" to "YunmaiHandler", + "01257B1234" to "TrisaBodyAnalyzeHandler", + "Etekcity Smart Fitness Scale" to "EtekcityESF551Handler", + "Vitafit VT701" to "VitafitVT701Handler", + "SENSSUN FAT" to "SenssunHandler", + "Weight Scale" to "SinocareHandler", + "Electronic Scale" to "ExcelvanCF36xHandler", + "ES-26BB-B" to "RenphoES26BBHandler", + "Health Scale" to "OneByoneHandler", + "1byone scale" to "OneByoneNewHandler", + "CH100S" to "HuaweiCH100SHandler", + "Hoffen BS-8107" to "HoffenBbs8107Handler", + "RUNSTAR-R5" to "RunstarR5Handler", + "runstar-r6" to "RunstarR6Handler", + "Beurer BF450" to "BeurerBF450Handler", + "Beurer BF105" to "StandardBeurerSanitasHandler", + "BEURER BF700" to "BeurerSanitasHandler", + "000fatscale01" to "InlifeHandler", + "10376BAA" to "WeightGurusA3Handler", + "Mengii" to "DigooDGSO38HHandler", + "yunchen" to "HesleyHandler", + "vscale" to "ExingtechY1Handler", + "IHEALTH HS3" to "IHealthHS3Handler", + "CULT Smart Scale Pro" to "CultSmartScaleProHandler", + "AAA002" to "AAAxHandler", + "eufy T9148" to "EufyP2Handler", + "EUFY C20" to "EufyC20Handler", + "debug" to "DebugGattHandler", + ) + + for ((name, expected) in expectations) { + val winner = winner(device(name)) + assertThat(winner?.javaClass?.simpleName).isEqualTo(expected) + } + } + + /** + * The Yunmai driver is registered twice with different constructor arguments, so the class + * name alone does not prove the right variant answered — check the reported product instead. + */ + @Test + fun `the two Yunmai registry entries stay distinguishable`() { + val classic = winner(device("YUNMAI-ISSE-1234")) + val mini = winner(device("YUNMAI-SIGNAL-1234")) + + assertThat(classic).isInstanceOf(YunmaiHandler::class.java) + assertThat(mini).isInstanceOf(YunmaiHandler::class.java) + assertThat(classic!!.supportFor(device("YUNMAI-ISSE-1234"))!!.displayName) + .isNotEqualTo(mini!!.supportFor(device("YUNMAI-SIGNAL-1234"))!!.displayName) + } + + /** + * Devices identified by an exact, vendor-specific name must be claimed by exactly one handler. + * A second claimant means two drivers overlap and the winner is decided by list position — + * which is exactly the kind of accident that only shows up on a user's device. + */ + @Test + fun `exact-name devices are claimed by a single handler`() { + val unambiguous = listOf( + "openScale", + "Keep_S3", + "AE BS-06", + "BLEsmart_0001000C0080E1A2B3C4", + "Etekcity Smart Fitness Scale", + "SENSSUN FAT", + "ES-26BB-B", + "CH100S", + "Hoffen BS-8107", + "RUNSTAR-R5", + "000fatscale01", + "Mengii", + "yunchen", + "vscale", + "10376BAA", + ) + + for (name in unambiguous) { + val claimants = claimants(device(name)).map { it.javaClass.simpleName } + assertThat(claimants).hasSize(1) + } + } + + // --- Ordering --------------------------------------------------------------------------- + + /** + * MGBHandler claims *any* device advertising service 0xFFB0, so every sibling on that service + * only wins by standing earlier in the list. This pins the documented order; moving MGBHandler + * up (or a sibling down) breaks these devices silently at runtime. + */ + @Test + fun `0xFFB0 siblings are matched ahead of the generic MGB handler`() { + assertClaimedBy(device("FITTRACK Dara", SERVICE_FFB0), FitTrackDaraHandler::class.java) + assertClaimedBy(device("BIA SCALE", SERVICE_FFB0), TaylorBIAHandler::class.java) + assertClaimedBy(device("relaxmedic", SERVICE_FFB0), RelaxmedicHandler::class.java) + assertClaimedBy(device("robi", SERVICE_FFB0), RobiS9Handler::class.java) + assertClaimedBy(device("SSW532", SERVICE_FFB0), DrTrustSSW532Handler::class.java) + + // The generic member of the family keeps the devices nobody else claims. + assertClaimedBy(device("swan", SERVICE_FFB0), MGBHandler::class.java) + + val order = ScaleFactory.createHandlers().map { it.javaClass.simpleName } + val mgb = order.indexOf("MGBHandler") + val siblings = listOf( + "TaylorBIAHandler", + "FitTrackDaraHandler", + "RelaxmedicHandler", + "RobiS9Handler", + "DrTrustSSW532Handler", + ) + for (sibling in siblings) { + assertThat(order.indexOf(sibling)).isLessThan(mgb) + } + } + + /** + * The Dr. Trust driver only claims a device when the name *and* the 0xFFB0 service match, so it + * can never take a device away from MGBHandler — but it sat behind MGB in the registry, which + * meant MGB (service-only match) swallowed every SSW-532. Pins both halves of that fix. + */ + @Test + fun `Dr Trust SSW532 is not swallowed by the service-only MGB match`() { + assertClaimedBy(device("SSW532", SERVICE_FFB0), DrTrustSSW532Handler::class.java) + assertClaimedBy(device("SSW-532 FG2211", SERVICE_FFB0), DrTrustSSW532Handler::class.java) + + // Without the service the Dr. Trust handler must stay out of the way… + assertThat(claimants(device("SSW532")).map { it.javaClass.simpleName }).isEmpty() + // …and without the name it must not touch MGB's own devices. + assertClaimedBy(device("icomon", SERVICE_FFB0), MGBHandler::class.java) + assertClaimedBy(device("yg", SERVICE_FFB0), MGBHandler::class.java) + } + + /** + * SanitasSbf72Handler and BeurerSanitasHandler both answer to `sbf7x` names; only the list + * position keeps the SBF72/73 devices on the driver written for them. + */ + @Test + fun `Sanitas SBF72 is matched ahead of the legacy Beurer-Sanitas handler`() { + assertClaimedBy(device("SANITAS SBF72"), SanitasSbf72Handler::class.java) + + val order = ScaleFactory.createHandlers().map { it.javaClass.simpleName } + assertThat(order.indexOf("SanitasSbf72Handler")).isLessThan(order.indexOf("BeurerSanitasHandler")) + } + + // --- Non-scales ------------------------------------------------------------------------- + + /** + * Nothing in the registry may claim an unrelated BLE device: a false positive shows the device + * as a supported scale in the scan list and then fails on connect. + */ + @Test + fun `no handler claims unrelated bluetooth devices`() { + val foreign = listOf( + "", + "MI Band 5", + "Galaxy Watch4", + "AirPods Pro", + "Polar H10", + "TV Remote", + "Tile", + ) + + for (name in foreign) { + val claimants = claimants(device(name)).map { it.javaClass.simpleName } + assertThat(claimants).isEmpty() + } + } + + // --- Registry hygiene ------------------------------------------------------------------- + + /** + * Guards against a handler being pasted into the list twice — harmless at runtime, but it + * hides the real winner and makes the order impossible to reason about. YunmaiHandler is the + * one intentional double entry (classic + mini). + */ + @Test + fun `registry contains no accidental duplicate handlers`() { + val duplicates = ScaleFactory.createHandlers() + .groupingBy { it.javaClass.simpleName } + .eachCount() + .filterValues { it > 1 } + + assertThat(duplicates).containsExactly("YunmaiHandler", 2) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt index a9d2a2ae2..2809aad6a 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt @@ -1,3 +1,20 @@ +/* + * 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.usecase import android.app.Application @@ -9,6 +26,8 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.room.Room import androidx.room.RoomDatabase import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.User @@ -23,9 +42,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -103,8 +119,8 @@ class BackupRestoreUseCasesTest { ) dbFile = sandboxContext.getDatabasePath(AppDatabase.DATABASE_NAME) - assertTrue("expected seeded test database to exist", dbFile.exists()) - assertEquals(1, repository.getAllUsers().first().size) + assertWithMessage("expected seeded test database to exist").that(dbFile.exists()).isTrue() + assertThat(repository.getAllUsers().first()).hasSize(1) } @After @@ -124,10 +140,12 @@ class BackupRestoreUseCasesTest { val result = useCases.restoreDatabase(Uri.fromFile(invalidZip), baseContext.contentResolver) - assertTrue("restore should fail for zip without openScale.db", result.isFailure) - assertTrue("failed restore should leave the live database file in place", dbFile.exists()) - - assertEquals("failed restore should not mutate live in-memory data", 1, repository.getAllUsers().first().size) + assertWithMessage("restore should fail for zip without openScale.db") + .that(result.isFailure).isTrue() + assertWithMessage("failed restore should leave the live database file in place") + .that(dbFile.exists()).isTrue() + assertWithMessage("failed restore should not mutate live in-memory data") + .that(repository.getAllUsers().first()).hasSize(1) val reopened = buildDatabase(sandboxContext) @@ -147,11 +165,8 @@ class BackupRestoreUseCasesTest { ) ) - assertEquals( - "the original record should still exist after a failed restore", - 1, - reopenedRepo.getAllUsers().first().size - ) + assertWithMessage("the original record should still exist after a failed restore") + .that(reopenedRepo.getAllUsers().first()).hasSize(1) } finally { reopened.close() } @@ -170,9 +185,12 @@ class BackupRestoreUseCasesTest { val result = useCases.restoreDatabase(Uri.fromFile(unrelatedDb), baseContext.contentResolver) - assertTrue("restore should fail for unrelated SQLite databases", result.isFailure) - assertTrue("failed restore should leave the live database file in place", dbFile.exists()) - assertEquals("failed restore should not mutate live in-memory data", 1, repository.getAllUsers().first().size) + assertWithMessage("restore should fail for unrelated SQLite databases") + .that(result.isFailure).isTrue() + assertWithMessage("failed restore should leave the live database file in place") + .that(dbFile.exists()).isTrue() + assertWithMessage("failed restore should not mutate live in-memory data") + .that(repository.getAllUsers().first()).hasSize(1) val reopened = buildDatabase(sandboxContext) try { @@ -191,11 +209,8 @@ class BackupRestoreUseCasesTest { ) ) - assertEquals( - "the original record should still exist after rejecting an unrelated database", - 1, - reopenedRepo.getAllUsers().first().size - ) + assertWithMessage("the original record should still exist after rejecting an unrelated database") + .that(reopenedRepo.getAllUsers().first()).hasSize(1) } finally { reopened.close() } @@ -207,7 +222,8 @@ class BackupRestoreUseCasesTest { createLegacyDatabase(legacyDb) val result = useCases.restoreDatabase(Uri.fromFile(legacyDb), baseContext.contentResolver) - assertTrue("restore should accept legacy openScale single-file databases", result.isSuccess) + assertWithMessage("restore should accept legacy openScale single-file databases") + .that(result.isSuccess).isTrue() val reopened = buildDatabase(sandboxContext) try { @@ -227,8 +243,8 @@ class BackupRestoreUseCasesTest { ) val users = reopenedRepo.getAllUsers().first() - assertEquals(1, users.size) - assertEquals("legacy-user", users.single().name) + assertThat(users).hasSize(1) + assertThat(users.single().name).isEqualTo("legacy-user") } finally { reopened.close() } @@ -249,10 +265,11 @@ class BackupRestoreUseCasesTest { useAssistedWeighing = false ) ) - assertEquals(2, repository.getAllUsers().first().size) + assertThat(repository.getAllUsers().first()).hasSize(2) val result = useCases.restoreDatabase(Uri.fromFile(backupZip), baseContext.contentResolver) - assertTrue("restore from app-generated backup should succeed", result.isSuccess) + assertWithMessage("restore from app-generated backup should succeed") + .that(result.isSuccess).isTrue() val reopened = buildDatabase(sandboxContext) try { @@ -272,8 +289,8 @@ class BackupRestoreUseCasesTest { ) val users = reopenedRepo.getAllUsers().first() - assertEquals(1, users.size) - assertEquals("restore-test-user", users.single().name) + assertThat(users).hasSize(1) + assertThat(users.single().name).isEqualTo("restore-test-user") } finally { reopened.close() } From fa4d792598baa2d7dcd593b637013b909b7ae698 Mon Sep 17 00:00:00 2001 From: Basem Kheyar Date: Sun, 16 Aug 2026 04:51:45 -0400 Subject: [PATCH 39/72] Added support for Etekcity Fit 8S Scale. (#1472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Added support for Etekcity Fit 8S Scale. * Etekcity Fit 8S: parse only our own manufacturer record, and cover the handler in ScaleFactoryTest onAdvertisement took the first manufacturer record of at least 20 bytes, so a second record in the same advertisement would have been decoded as a measurement — look up 0x06D0 directly instead. Drops the redundant SparseArray cast along the way. The registry test gains manufacturer-data fixtures (it could only build name/service advertisements before) and pins the one real overlap: ScaleupHandler matches every company id whose low byte is 0xD0, which 0x06D0 is, so the Fit 8S is only kept on its own driver by standing earlier in the list. * Enforce exact payload size in EtekcityFit8SHandler --------- Co-authored-by: oliexdev --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../bluetooth/scales/EtekcityFit8SHandler.kt | 155 +++++++++++++++++ .../core/bluetooth/ScaleFactoryTest.kt | 161 ++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityFit8SHandler.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index b97bca7aa..b9dae6cc3 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -39,6 +39,7 @@ import com.health.openscale.core.bluetooth.scales.EufyC20Handler import com.health.openscale.core.bluetooth.scales.EufyP2Handler import com.health.openscale.core.bluetooth.scales.EbelterBodyFatB2Handler import com.health.openscale.core.bluetooth.scales.EtekcityESF551Handler +import com.health.openscale.core.bluetooth.scales.EtekcityFit8SHandler import com.health.openscale.core.bluetooth.scales.GattScaleAdapter import com.health.openscale.core.bluetooth.scales.HesleyHandler import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler @@ -175,6 +176,7 @@ class ScaleFactory @Inject constructor( EbelterBodyFatB2Handler(), ExcelvanCF36xHandler(), EtekcityESF551Handler(), + EtekcityFit8SHandler(), EufyC20Handler(), EufyP2Handler(), ESCS20MHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityFit8SHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityFit8SHandler.kt new file mode 100644 index 000000000..d416faba5 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityFit8SHandler.kt @@ -0,0 +1,155 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import java.util.UUID + +// Based on https://github.com/ronnnnnnnnnnnnn/etekcity_esf551_ble + +/** + * Etekcity Fit 8S scale handler + * + * The device supports Broadcasts only. It puts data in ManufacturerSpecific data + * advertisement with key `0x06D0` for "Etekcity Corporation". It also adds `SCALE_SERIVCE` + * uuid defined below. + * + * Measurement data is always 20 bytes. Device does not advertise any other data format. + * Data structure (from ronnnnnnnnnnnnn code): + * - `[0]` : header byte (`0x01`) (ignored) + * - `[1:7]` : device MAC address, little-endian (ignored) + * - `[7:10]` : unknown (ignored) + * - `[10:13]` : weight in grams, 3-byte little-endian int + * - `[13:15]` : bioelectrical impedance in ohms, 2-byte little-endian int (0 = not measured) + * - `[15]` : stability flag (`0x01` = stable reading) + * - `[16]` : display unit (`0x00`=kg, `0x01`=lb, `0x02`=st) + * (ignored since incoming data always in grams + * and no way to change device settings remotely) + * - `[17:20]` : unknown/constant (ignored) + * + */ +class EtekcityFit8SHandler : ScaleDeviceHandler() { + companion object { + private const val MANUFACTURER_ID = 0x06D0 + private val SCALE_SERVICE = UUID.fromString("0000ffd0-0000-1000-8000-00805f9b34fb") + + /** Bytes the layout documented above needs; shorter records are not a measurement. */ + private const val PAYLOAD_SIZE = 20 + } + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + // device return data in manufacturer data with MANUFACTURER_ID key + if (device.manufacturerData == null || !device.manufacturerData.contains(MANUFACTURER_ID)) { + logD("No manufacturer data or manufacturer id is incorrect, ignoring device") + return null + } + + // all messages from device have service UUID 16: 0xffd0 + if (!device.serviceUuids.contains(SCALE_SERVICE)) { + logD("Does not contain correct service uuid, ignoring device") + return null + } + + return DeviceSupport( + displayName = "Etekcity Fit 8S", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + linkMode = LinkMode.BROADCAST_ONLY, + ) + } + + + /** + * Handle a single advertisement; return a BroadcastAction to steer the adapter. + */ + override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction { + val msd = result.scanRecord?.manufacturerSpecificData ?: return BroadcastAction.IGNORED + + // Only the record under our own company id carries the layout documented above; any other + // record in the same advertisement is a different vendor's data and must not be parsed. + val payload = msd.get(MANUFACTURER_ID) ?: return BroadcastAction.IGNORED + + if (payload.size != PAYLOAD_SIZE) { + logD("Manufacturer record too short (${payload.size} bytes), ignoring") + return BroadcastAction.IGNORED + } + + if ((payload[15].toInt() and 0xFF) != 1) { + logD("Measurement not stable yet. Continuing...") + + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + logD("Measurement stabilized.") + + val weightGrams: Int = (payload[10].toInt() and 0xFF) or + ((payload[11].toInt() and 0xFF) shl 8) or + ((payload[12].toInt() and 0xFF) shl 16) + + if (weightGrams <= 0) { + // invalid weight + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + val weightKg = weightGrams.toFloat() / 1000.0f + + val m = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + weight = weightKg + } + + val impedance = (payload[13].toInt() and 0xFF) or + ((payload[14].toInt() and 0xFF) shl 8) + + if (impedance != 0) { + m.impedance = impedance.toDouble() + + val lib = StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = m.weight.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = m.impedance + ) + + m.fat = lib.totalFatPercentage.toFloat() + m.water = lib.totalBodyWaterPercentage.toFloat() + m.muscle = lib.skeletalMusclePercentage.toFloat() + m.bone = lib.boneMassKg.toFloat() + m.bmr = lib.basalMetabolicRate.toFloat() + m.lbm = lib.fatFreeMassKg.toFloat() + } else { + logD("Impedance is missing or zero. Publishing weight only") + } + + publish(m) + return BroadcastAction.CONSUMED_STOP + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt index 627e01774..e7b86f650 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt @@ -17,10 +17,19 @@ */ package com.health.openscale.core.bluetooth +import android.util.SparseArray import com.google.common.truth.Truth.assertThat import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler +import com.health.openscale.core.bluetooth.scales.EtekcityESF551Handler +import com.health.openscale.core.bluetooth.scales.EtekcityFit8SHandler +import com.health.openscale.core.bluetooth.scales.EufyC20Handler import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler import com.health.openscale.core.bluetooth.scales.MGBHandler +import com.health.openscale.core.bluetooth.scales.OkOkHandler +import com.health.openscale.core.bluetooth.scales.QNHandlerBroadcast +import com.health.openscale.core.bluetooth.scales.ScaleupHandler +import com.health.openscale.core.bluetooth.scales.SinocareHandler +import com.health.openscale.core.bluetooth.scales.YunmaiXHandler import com.health.openscale.core.bluetooth.scales.RelaxmedicHandler import com.health.openscale.core.bluetooth.scales.RobiS9Handler import com.health.openscale.core.bluetooth.scales.SanitasSbf72Handler @@ -76,6 +85,25 @@ class ScaleFactoryTest { manufacturerData = null, ) + /** + * A broadcast advertisement: name (often empty on broadcast-only scales), advertised services + * and the manufacturer-specific records, keyed by company id exactly like `ScanRecord` hands + * them to the scanner. + */ + private fun advertisement( + name: String = "", + services: List = emptyList(), + manufacturerData: List> = emptyList(), + ) = ScannedDeviceInfo( + name = name, + address = "C0:FF:EE:12:34:56", + rssi = -50, + serviceUuids = services, + manufacturerData = SparseArray().apply { + manufacturerData.forEach { (id, data) -> put(id, data) } + }, + ) + private fun uuid16(short: Int): UUID = UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) @@ -249,6 +277,139 @@ class ScaleFactoryTest { assertThat(order.indexOf("SanitasSbf72Handler")).isLessThan(order.indexOf("BeurerSanitasHandler")) } + // --- Broadcast fingerprints ------------------------------------------------------------- + + /** Etekcity's company id — together with service 0xFFD0 the only fingerprint of the Fit 8S. */ + private val ETEKCITY_COMPANY_ID = 0x06D0 + + /** Service 0xFFD0 — advertised by the Fit 8S alongside its manufacturer record. */ + private val SERVICE_FFD0 = uuid16(0xFFD0) + + /** A stable 75.500 kg / 500 Ω reading in the Fit 8S advertisement layout. */ + private fun fit8sPayload(): ByteArray = ByteArray(20).apply { + this[0] = 0x01 // header + this[10] = 0xEC.toByte() // weight 75500 g, 3-byte little-endian + this[11] = 0x26 + this[12] = 0x01 + this[13] = 0xF4.toByte() // impedance 500 Ω, 2-byte little-endian + this[14] = 0x01 + this[15] = 0x01 // stable + } + + private fun fit8sAdvertisement() = advertisement( + name = "", // the scale advertises no name at all + services = listOf(SERVICE_FFD0), + manufacturerData = listOf(ETEKCITY_COMPANY_ID to fit8sPayload()), + ) + + /** + * The Fit 8S is nameless, so it can only be recognised by company id plus service UUID. Both + * halves must be required: matching on either one alone would make the handler claim + * advertisements it cannot parse. + */ + @Test + fun `the nameless Etekcity Fit 8S is claimed by its own handler`() { + assertClaimedBy(fit8sAdvertisement(), EtekcityFit8SHandler::class.java) + + val fit8s = EtekcityFit8SHandler() + // Company id without the service… + assertThat( + fit8s.supportFor( + advertisement(manufacturerData = listOf(ETEKCITY_COMPANY_ID to fit8sPayload())) + ) + ).isNull() + // …and the service without the company id. + assertThat( + fit8s.supportFor( + advertisement( + services = listOf(SERVICE_FFD0), + manufacturerData = listOf(0xFF64 to fit8sPayload()), + ) + ) + ).isNull() + // A scan result that carried no manufacturer data at all. + assertThat(fit8s.supportFor(device("", SERVICE_FFD0))).isNull() + } + + /** + * ScaleupHandler claims *any* manufacturer record whose company-id low byte is 0xD0 or 0xE0 — + * and Etekcity's company id 0x06D0 ends in 0xD0, so both handlers answer for a Fit 8S. Only the + * list position keeps the device on the driver that can decode it. + */ + @Test + fun `Etekcity Fit 8S is matched ahead of the low-byte Scaleup match`() { + val claimants = claimants(fit8sAdvertisement()).map { it.javaClass.simpleName } + assertThat(claimants).containsExactly("EtekcityFit8SHandler", "ScaleupHandler").inOrder() + + val order = ScaleFactory.createHandlers().map { it.javaClass.simpleName } + assertThat(order.indexOf("EtekcityFit8SHandler")).isLessThan(order.indexOf("ScaleupHandler")) + } + + /** + * The other direction of the same overlap: a Scaleup advertisement carries no 0xFFD0 service + * and a different company id, so the Fit 8S handler must keep its hands off it. + */ + @Test + fun `Scaleup broadcasts are not swallowed by the Etekcity Fit 8S handler`() { + // key = (weight MSB shl 8) or flag → 75.50 kg (0x1D7E) while measuring (0xD0) + val scaleup = advertisement(manufacturerData = listOf(0x1DD0 to ByteArray(9))) + + assertClaimedBy(scaleup, ScaleupHandler::class.java) + assertThat(EtekcityFit8SHandler().supportFor(scaleup)).isNull() + } + + /** + * The connectable Etekcity ESF551 is the Fit 8S's closest neighbour — same vendor, so possibly + * the same company id. It identifies itself by name and must keep winning: the Fit 8S handler + * would otherwise downgrade it to broadcast-only. + */ + @Test + fun `the named Etekcity ESF551 wins over the broadcast Fit 8S handler`() { + val esf551 = advertisement( + name = "Etekcity Smart Fitness Scale", + services = listOf(SERVICE_FFD0), + manufacturerData = listOf(ETEKCITY_COMPANY_ID to fit8sPayload()), + ) + + assertClaimedBy(esf551, EtekcityESF551Handler::class.java) + + val order = ScaleFactory.createHandlers().map { it.javaClass.simpleName } + assertThat(order.indexOf("EtekcityESF551Handler")) + .isLessThan(order.indexOf("EtekcityFit8SHandler")) + } + + /** + * The registry holds several handlers that identify a scale from its manufacturer record alone. + * Each must keep its own advertisement, and none of them may be answered by the Fit 8S handler. + */ + @Test + fun `manufacturer-data broadcasts stay with their own handler`() { + val expectations: List>> = listOf( + // Sinocare — company id 0xFF64 + advertisement(manufacturerData = listOf(0xFF64 to ByteArray(12))) + to SinocareHandler::class.java, + // QN broadcast variant — company id 0xFFFF with the AABB magic header + advertisement( + manufacturerData = listOf( + 0xFFFF to byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0x00, 0x00, 0x00, 0x00) + ) + ) to QNHandlerBroadcast::class.java, + // Eufy C20 — company id 0xBC64 + advertisement(manufacturerData = listOf(48228 to ByteArray(14))) + to EufyC20Handler::class.java, + // OKOK V20 — company id 0x20CA behind one of the known names + advertisement(name = "ADV", manufacturerData = listOf(0x20CA to ByteArray(16))) + to OkOkHandler::class.java, + // Yunmai X — recognised by its advertised 16-bit service 0x1320 + advertisement(services = listOf(uuid16(0x1320))) to YunmaiXHandler::class.java, + ) + + for ((device, expected) in expectations) { + assertClaimedBy(device, expected) + assertThat(EtekcityFit8SHandler().supportFor(device)).isNull() + } + } + // --- Non-scales ------------------------------------------------------------------------- /** From 910a436a013a813f369f8142ac0a4b46c74caacb Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 16 Aug 2026 11:11:54 +0200 Subject: [PATCH 40/72] Log unparseable CSV date/time values and keep the detail screen awake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSV importer accepts ISO 8601 only, so a spreadsheet round-trip ("8:30:00 AM") silently sent every timestamp to noon (#1475). Behaviour is unchanged, the fallbacks now name the line and expected format. The separate-columns test only checked the row counter, so it would have stayed green even if TIME had been ignored — it now asserts the imported time. Also sets FLAG_KEEP_SCREEN_ON while the measurement detail screen is open, cleared on dispose, since entering values means standing on the scale and not touching the phone. --- .../core/usecase/ImportExportUseCases.kt | 23 ++++++++- .../overview/MeasurementDetailScreen.kt | 13 +++++ .../core/usecase/ImportExportUseCasesTest.kt | 51 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt index da0423a37..5733221e6 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt @@ -69,6 +69,11 @@ class ImportExportUseCases @Inject constructor( private val TAG = "ImportExportUseCase" + // Only ISO 8601 is accepted on import; spreadsheets tend to rewrite these columns + // in the device locale (e.g. "8:30:00 AM"), so name the expected format in the log. + private val DATE_FORMAT_HINT = "yyyy-MM-dd" + private val TIME_FORMAT_HINT = "HH:mm[:ss], 24h, zero-padded" + // Shared formatters (same semantics wie in deinem VM) private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_TIME @@ -294,12 +299,18 @@ class ImportExportUseCases @Inject constructor( try { parsedDate = LocalDate.parse(d, dateFormatter) } catch (_: DateTimeParseException) { + LogManager.w( + TAG, + "Line ${rowIndex + 1}: cannot parse date '$d', expected ISO 8601 " + + "($DATE_FORMAT_HINT). Skipping this line." + ) linesSkippedDateParseError++ return@forEachIndexed } } if (parsedDate == null) { + LogManager.w(TAG, "Line ${rowIndex + 1}: no date column value. Skipping this line.") linesSkippedMissingDate++ return@forEachIndexed } @@ -311,8 +322,16 @@ class ImportExportUseCases @Inject constructor( try { LocalTime.parse(t, timeFormatter) } catch (_: DateTimeParseException) { - try { LocalTime.parse(t, flexibleTimeFormatter) } - catch (_: DateTimeParseException) { LocalTime.NOON } + try { + LocalTime.parse(t, flexibleTimeFormatter) + } catch (_: DateTimeParseException) { + LogManager.w( + TAG, + "Line ${rowIndex + 1}: cannot parse time '$t', expected ISO 8601 " + + "($TIME_FORMAT_HINT). Falling back to 12:00." + ) + LocalTime.NOON + } } } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt index 6d9ff6787..d936c9992 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt @@ -17,8 +17,10 @@ */ package com.health.openscale.ui.screen.overview +import android.view.WindowManager import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -43,6 +45,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -100,6 +103,16 @@ fun MeasurementDetailScreen( userId: Int, sharedViewModel: SharedViewModel ) { + // Entering values while standing on the scale means not touching the phone for a while, so + // keep the display awake. The flag is cleared on dispose, so it only affects this screen (and + // the system drops it anyway once the activity leaves the foreground). + LocalActivity.current?.window?.let { window -> + DisposableEffect(window) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + } + } + val context = LocalContext.current val resources = LocalResources.current diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt index ad9c09c77..0892b8913 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.usecase import android.app.Application import android.content.Context import android.net.Uri +import android.util.Log import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.ActivityLevel @@ -33,6 +34,7 @@ import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.User import com.health.openscale.core.database.AppDatabase import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.utils.LogManager import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first @@ -43,6 +45,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog import java.io.File import java.time.Instant import java.time.LocalDate @@ -64,6 +67,9 @@ class ImportExportUseCasesTest { @Before fun setUp() = runBlocking { + // Without init, LogManager reroutes everything to a "not initialized" error, which + // would hide the import warnings the tests below assert on. + LogManager.init(context, false) db = RoomTestSupport.inMemory(context) repo = RoomTestSupport.repositoryFor(db) repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) @@ -132,6 +138,15 @@ class ImportExportUseCasesTest { return Uri.fromFile(file) } + /** Local wall-clock timestamp of the single measurement imported for [date]. */ + private suspend fun timestampOn(date: LocalDate) = + repo.getMeasurementsWithValuesForUser(userId).first() + .map { Instant.ofEpochMilli(it.measurement.timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime() } + .first { it.toLocalDate() == date } + + private fun warnings(): String = + ShadowLog.getLogs().filter { it.type == Log.WARN }.joinToString("\n") { it.msg } + @Test fun import_separateDateAndTimeColumns_isParsed() = runBlocking { val report = useCases.importUserFromCsv( @@ -140,6 +155,42 @@ class ImportExportUseCasesTest { assertThat(report.importedMeasurementsCount).isEqualTo(1) assertThat(report.linesSkippedMissingDate).isEqualTo(0) assertThat(report.linesSkippedDateParseError).isEqualTo(0) + + val imported = timestampOn(LocalDate.of(2025, 4, 7)) + assertThat(imported.hour).isEqualTo(8) + assertThat(imported.minute).isEqualTo(30) + } + + /** + * Spreadsheets rewrite the TIME column in the device locale (e.g. "8:30:00 AM"), which the + * ISO-only parser cannot read. The row is still imported, but at noon - so the fallback has + * to be visible in the log, otherwise the user has no way to notice the lost time. + */ + @Test + fun import_nonIsoTime_fallsBackToNoonAndWarns() = runBlocking { + ShadowLog.clear() + val report = useCases.importUserFromCsv( + userId, csvUri("DATE,TIME,WEIGHT\n2025-04-07,8:30:00 AM,72.5\n"), context.contentResolver + ).getOrThrow() + assertThat(report.importedMeasurementsCount).isEqualTo(1) + + val imported = timestampOn(LocalDate.of(2025, 4, 7)) + assertThat(imported.hour).isEqualTo(12) + assertThat(imported.minute).isEqualTo(0) + + assertThat(warnings()).contains("cannot parse time '8:30:00 AM'") + } + + @Test + fun import_nonIsoDate_isSkippedAndWarns() = runBlocking { + ShadowLog.clear() + val report = useCases.importUserFromCsv( + userId, csvUri("DATE,TIME,WEIGHT\n4/7/2025,08:30,72.5\n"), context.contentResolver + ).getOrThrow() + assertThat(report.importedMeasurementsCount).isEqualTo(0) + assertThat(report.linesSkippedDateParseError).isEqualTo(1) + + assertThat(warnings()).contains("cannot parse date '4/7/2025'") } @Test From d3247a4e36d26ba8f4a1438f66016c681a5a82c4 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 16 Aug 2026 12:37:09 +0200 Subject: [PATCH 41/72] Update build workflow to publish scale catalog wiki page - Adds a new GitHub Actions job to publish the "Supported scales in openScale" wiki page artifact - Includes necessary logic to generate the Markdown content by running `ScaleCatalogTest` - Commits and pushes changes to the specified wiki location if the content has changed - Creates placeholder resources (`scale_catalog_remarks.txt`, `scale_catalog_gallery.md`) for hand-curated catalog data - Adds `ScaleCatalog.kt` object containing logic to synthesize scale support from registered handlers --- .github/workflows/ci_master.yml | 40 ++ .../openscale/core/bluetooth/ScaleCatalog.kt | 365 ++++++++++++++++++ .../core/bluetooth/ScaleCatalogTest.kt | 122 ++++++ .../test/resources/scale_catalog_gallery.md | 211 ++++++++++ .../test/resources/scale_catalog_remarks.txt | 18 + 5 files changed, 756 insertions(+) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalogTest.kt create mode 100644 android_app/app/src/test/resources/scale_catalog_gallery.md create mode 100644 android_app/app/src/test/resources/scale_catalog_remarks.txt diff --git a/.github/workflows/ci_master.yml b/.github/workflows/ci_master.yml index b44cd69c8..36da11115 100644 --- a/.github/workflows/ci_master.yml +++ b/.github/workflows/ci_master.yml @@ -48,6 +48,46 @@ jobs: files: | android_app/app/build/outputs/apk/debug/openScale-debug.apk + # Publish the "Supported scales in openScale" wiki page, generated from the handler registry by + # ScaleCatalogTest. The page is a build artifact: hand edits in the wiki are overwritten, while + # the remarks column and the photo gallery are edited in + # android_app/app/src/test/resources/scale_catalog_{remarks.txt,gallery.md}. + publish-scale-catalog: + runs-on: ubuntu-latest + needs: build + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup JDK + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: "21" + - name: Checkout wiki + run: git clone "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.wiki.git" wiki + - name: Generate the supported scales page + working-directory: android_app + env: + SCALE_CATALOG_OUT: ${{ github.workspace }}/wiki/Supported-scales-in-openScale.md + run: | + ./gradlew testDebugUnitTest --tests '*ScaleCatalogTest' + # A skipped (up-to-date) test task would leave the page untouched and the job green. + test -s "$SCALE_CATALOG_OUT" + - name: Push the page if it changed + working-directory: wiki + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "Wiki already up to date." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Supported-scales-in-openScale.md + git commit -m "Update supported scales from the handler registry (${GITHUB_SHA::7})" + git push + # Keep the `weblate` branch (Weblate's push target) in sync with master so # Weblate's rebased translation pushes stay fast-forward and don't fail. sync-weblate: diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt new file mode 100644 index 000000000..68a863516 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -0,0 +1,365 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 android.util.SparseArray +import com.health.openscale.core.bluetooth.scales.DeviceCapability +import com.health.openscale.core.bluetooth.scales.LinkMode +import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.UUID + +/** + * The scale catalog: the wiki's "Supported scales in openScale" table, derived from the code that + * actually decides support. + * + * Every row is produced by asking the real registry — [ScaleFactory.createHandlers] in its real + * order — what it makes of a synthetic advertisement, and then reading the returned + * [com.health.openscale.core.bluetooth.scales.DeviceSupport]. Nothing about a scale is written down + * twice: display name, link mode and the capability sets all come from `supportFor`, so a driver + * that gains (or loses) a capability changes the published table on the next generator run. + * + * The one thing that cannot be derived is the input: `supportFor` only answers when it is handed an + * advertisement it recognises. [probes] is that input — one synthetic advertisement per supported + * device, built from the names and fingerprints documented in the handlers themselves, never from + * personal captures. `ScaleCatalogTest` fails when a registered handler has no probe, which is what + * keeps the table from silently falling behind the registry. + * + * Human-written notes (the "Remarks" column) live in `src/test/resources/scale_catalog_remarks.txt` + * and are merged in by display name — see [renderMarkdown]. + */ +object ScaleCatalog { + + // --- Probe construction --------------------------------------------------------------- + + fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + /** A connectable device: a name and, optionally, the services it advertises. */ + fun device(name: String, vararg services: UUID) = ScannedDeviceInfo( + name = name, + address = "C0:FF:EE:12:34:56", + rssi = -50, + serviceUuids = services.toList(), + manufacturerData = null, + ) + + /** A broadcast advertisement: manufacturer records keyed by company id, as `ScanRecord` has them. */ + fun advertisement( + name: String = "", + services: List = emptyList(), + manufacturerData: List> = emptyList(), + ) = ScannedDeviceInfo( + name = name, + address = "C0:FF:EE:12:34:56", + rssi = -50, + serviceUuids = services, + manufacturerData = SparseArray().apply { + manufacturerData.forEach { (id, data) -> put(id, data) } + }, + ) + + /** Etekcity's company id; with service 0xFFD0 the only fingerprint of the nameless Fit 8S. */ + private const val ETEKCITY_COMPANY_ID = 0x06D0 + + /** Service 0xFFB0 — the LeFu-style service shared by a whole family of unrelated scales. */ + private val SERVICE_FFB0 = uuid16(0xFFB0) + + /** + * One synthetic advertisement per supported device. + * + * Where a handler reports different products under different names (Beurer, Medisana, Omron, + * 1byone, Huawei, …) every product gets its own probe, because each yields its own catalog row. + */ + val probes: List = listOf( + // --- Matched by advertised name --- + device("AE BS-06"), // ActiveEraBF06Handler + device("Keep_S3"), // KeepS3Handler + device("Beurer BF450"), // BeurerBF450Handler + device("BIA SCALE", SERVICE_FFB0), // TaylorBIAHandler + device("RYFIT"), // RyFitHandler + device("CULT Smart Scale Pro"), // CultSmartScaleProHandler + device("realme Smart Scale"), // RealmeSmartScaleHandler + device("YUNMAI-ISSE-1234"), // YunmaiHandler(isMini = false) + device("YUNMAI-SIGNAL-1234"), // YunmaiHandler(isMini = true) + device("01257B1234"), // TrisaBodyAnalyzeHandler + device("SANITAS SBF72"), // SanitasSbf72Handler + device("SANITAS SBF73"), // … + device("Beurer BF915"), // … + device("Beurer BF105"), // StandardBeurerSanitasHandler + device("Beurer BF500"), // … + device("Beurer BF600"), // … + device("Beurer BF950"), // … + device("Shape200"), // SoehnleHandler + device("Weight Scale"), // SinocareHandler + device("SENSSUN FAT"), // SenssunHandler + device("RENPHO-SCALE-1234"), // RenphoHandler (no QN service → not QNHandler) + device("QN-Scale", uuid16(0xFFE0)), // QNHandler + device("Health Scale"), // OneByoneHandler (1byone classic) + device("eufy T9146"), // … Eufy C1 + device("eufy T9147"), // … Eufy P1 + device("eufy T9120"), // … Eufy A1 + device("1byone scale"), // OneByoneNewHandler + device("XMTZC14HM"), // MiScaleS400Handler + device("MIJIA SCALE S800"), // XiaomiS800Handler + device("MI_SCALE"), // MiScaleHandler (v1) + device("MIBFS"), // MiScaleHandler (v2) + device("runstar-r6"), // RunstarR6Handler + device("RUNSTAR-R5"), // RunstarR5Handler + device("relaxmedic"), // RelaxmedicHandler + device("robi"), // RobiS9Handler + device("Vitafit VT701"), // VitafitVT701Handler + device("EEBBL"), // EEBBLHandler + device("FITTRACK Dara"), // FitTrackDaraHandler + device("SSW532", SERVICE_FFB0), // DrTrustSSW532Handler + device("swan", SERVICE_FFB0), // MGBHandler + device("0203B1234"), // MedisanaBs44xHandler (BS430) + device("0131971234"), // … (BS444/BS440) + device("000fatscale01"), // InlifeHandler + device("IHEALTH HS3"), // IHealthHS3Handler + device("HUAWEI AH100"), // HuaweiAhCh100Handler + device("HUAWEI CH100"), // … + device("CH100S"), // HuaweiCH100SHandler + device("HUAWEI Scale 2 Pro"), // HuaweiHagridWspHandler + device("Hagrid-B29"), // … Scale 3 Pro + device("HUAWEI Scale 3"), // … Scale 3 + device("Hoffen BS-8107"), // HoffenBbs8107Handler + device("yunchen"), // HesleyHandler + device("vscale"), // ExingtechY1Handler + device("Body Fat-B2"), // EbelterBodyFatB2Handler + device("Electronic Scale"), // ExcelvanCF36xHandler + device("Etekcity Smart Fitness Scale"), // EtekcityESF551Handler + device("EUFY C20"), // EufyC20Handler + device("eufy T9148"), // EufyP2Handler + device("ES-CS20M"), // ESCS20MHandler + device("ES-26BB-B"), // RenphoES26BBHandler + device("Mengii"), // DigooDGSO38HHandler + device("debug"), // DebugGattHandler + device("openScale"), // CustomOpenScaleHandler + device("BEURER BF700"), // BeurerSanitasHandler (BF700/800/Libra) + device("BEURER BF710"), // … BF710 + device("SANITAS SBF70"), // … SBF70/SBF75/Crane + device("AAA002"), // AAAxHandler + device("1BODY CONNECT"), // BodyConnectHandler + device("1X-LINE"), // … Terraillon X-LINE + device("10376BAA"), // WeightGurusA3Handler + + // Omron reports its model as the GAP name once bonded; advertised local names carry the + // model id instead (see OmronWlcHandler.MODELS_BY_ADVERTISED_ID). + device("HBF-702T"), + device("KRD-703T"), + device("HBF-227T"), + device("HBF-228T"), + device("HBF-230T"), + device("HBF-222T"), + device("BCM-500"), + device("VIVA"), + + // --- Matched by advertisement fingerprint, not by name --- + // Yunmai X: advertised service 0x1320. + advertisement(services = listOf(uuid16(0x1320))), + // QN broadcast variant: company id 0xFFFF with the AABB magic header. + advertisement( + manufacturerData = listOf( + 0xFFFF to byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0x00, 0x00, 0x00, 0x00) + ) + ), + // OKOK: company id behind one of the known names. + advertisement(name = "ADV", manufacturerData = listOf(0x20CA to ByteArray(16))), + advertisement(name = "ADV", manufacturerData = listOf(0x11CA to ByteArray(16))), + advertisement(name = "ADV", manufacturerData = listOf(0xF0FF to ByteArray(16))), + // Etekcity Fit 8S: nameless, company id 0x06D0 plus service 0xFFD0. + advertisement( + services = listOf(uuid16(0xFFD0)), + manufacturerData = listOf(ETEKCITY_COMPANY_ID to ByteArray(20)), + ), + // Scaleup: any manufacturer record whose company-id low byte is 0xD0 (measuring) or 0xE0. + advertisement(manufacturerData = listOf(0x1DD0 to ByteArray(9))), + ) + + // --- Catalog ---------------------------------------------------------------------------- + + /** One published row: what the registry reports for one probe. */ + data class Row( + val displayName: String, + val handler: String, + val linkMode: LinkMode, + val capabilities: Set, + val implemented: Set, + ) + + /** + * Runs every probe through the registry and collects what the *winning* handler reports — + * first match wins, exactly as [ScaleFactory.createCommunicator] resolves a real scan result. + * + * A fresh registry per probe: several handlers stash state in `supportFor` (e.g. + * StandardBeurerSanitasHandler remembers the matched model), so a shared instance would let one + * probe bleed into the next. + */ + fun rows(): List = + probes.mapNotNull { probe -> + ScaleFactory.createHandlers().firstNotNullOfOrNull { handler -> + handler.supportFor(probe)?.let { support -> + Row( + displayName = support.displayName, + handler = handler.javaClass.simpleName, + linkMode = support.linkMode, + capabilities = support.capabilities, + implemented = support.implemented, + ) + } + } + } + .distinctBy { it.displayName } + .sortedBy { it.displayName.lowercase() } + + /** The handler that claims [probe], or null — the same first-match-wins rule the factory uses. */ + fun winner(probe: ScannedDeviceInfo): ScaleDeviceHandler? = + ScaleFactory.createHandlers().firstOrNull { it.supportFor(probe) != null } + + // --- Rendering -------------------------------------------------------------------------- + + /** A literal pipe in a display name or remark would split the markdown cell. */ + private fun cell(text: String): String = text.replace("|", "\\|") + + private fun mark(row: Row, capability: DeviceCapability): String = when { + capability in row.implemented -> "✓" // ✓ supported in openScale + capability in row.capabilities -> "o" // offered by the scale, not implemented yet + else -> "n/a" // not available on the scale + } + + private fun linkLabel(mode: LinkMode): String = when (mode) { + LinkMode.CONNECT_GATT -> "BLE (connect)" + LinkMode.BROADCAST_ONLY -> "BLE (broadcast)" + LinkMode.CLASSIC_SPP -> "Bluetooth Classic" + } + + /** Capabilities that get their own column; everything else is listed in "Other capabilities". */ + private val COLUMNS = listOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.HISTORY_READ, + DeviceCapability.LIVE_WEIGHT_STREAM, + ) + + private fun otherCapabilities(row: Row): String { + val labels = DeviceCapability.entries + .filter { it !in COLUMNS } + .filter { it in row.capabilities } + .map { capability -> + val name = when (capability) { + DeviceCapability.TIME_SYNC -> "time sync" + DeviceCapability.USER_SYNC -> "user sync" + DeviceCapability.UNIT_CONFIG -> "unit config" + DeviceCapability.BATTERY_LEVEL -> "battery" + else -> capability.name.lowercase() + } + if (capability in row.implemented) name else "$name (o)" + } + return if (labels.isEmpty()) "-" else labels.joinToString(", ") + } + + /** + * Renders the wiki page. [remarks] is keyed by display name, falling back to the handler class + * name so a note can cover every product a driver reports. + */ + fun renderMarkdown(rows: List, remarks: Map): String = buildString { + appendLine("## Scale support overview") + appendLine() + appendLine("") + appendLine() + appendLine("> [!IMPORTANT]") + appendLine("> - I do **not own** every scale.") + appendLine("> - openScale is an **open-source, community-driven project**.") + appendLine("> - Some scales may be **incompatible**, and I cannot guarantee that all features will work.") + appendLine("> - Please understand that this project is maintained **voluntarily**, and there is **no official warranty or support**.") + appendLine("> - Users should have **realistic expectations**: not every scale will work perfectly, and encountering issues is normal.") + appendLine("> - Bluetooth protocols can **change at any time**, and some cheap scales are **poorly implemented by the manufacturer**.") + appendLine("> - openScale relies on user contributions. If you can help test scales, improve support, or fix issues, **pull requests are very welcome**.") + appendLine() + appendLine("> [!NOTE]") + appendLine("> If you want to help support your Bluetooth scale, please read [How to support a new scale](How-to-support-a-new-scale) for further information.") + appendLine() + appendLine("| Scale | Connection | Body metrics | History data | Live weight | Other capabilities | Remarks |") + appendLine("|---|---|---|---|---|---|---|") + for (row in rows) { + val remark = remarks[row.displayName] ?: remarks[row.handler] ?: "-" + appendLine( + "| ${cell(row.displayName)} " + + "| ${linkLabel(row.linkMode)} " + + "| ${mark(row, DeviceCapability.BODY_COMPOSITION)} " + + "| ${mark(row, DeviceCapability.HISTORY_READ)} " + + "| ${mark(row, DeviceCapability.LIVE_WEIGHT_STREAM)} " + + "| ${otherCapabilities(row)} " + + "| ${cell(remark)} |" + ) + } + appendLine() + appendLine("✓ : supported in openScale
") + appendLine("o : supported by the scale but still needs to be reverse engineered
") + appendLine("n/a : not available on the scale") + appendLine() + appendLine("_${rows.size} scales, generated from ${ScaleFactory.createHandlers().size} registered handlers._") + + // The photo gallery is hand-curated (there is nothing in the code to derive a picture + // from), so it is carried through verbatim rather than regenerated. + loadGallery()?.let { + appendLine() + append(it) + } + } + + /** + * Reads the hand-written remarks. Format is one `displayName = remark` per line; `#` starts a + * comment. Deliberately not JSON — the file is edited by hand, and a stray comma should not + * cost a maintainer a test run. + */ + fun loadRemarks(): Map { + val stream = javaClass.classLoader?.getResourceAsStream(REMARKS_RESOURCE) ?: return emptyMap() + return stream.bufferedReader().useLines { lines -> + lines.map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .mapNotNull { line -> + val separator = line.indexOf('=') + if (separator <= 0) null + else line.substring(0, separator).trim() to line.substring(separator + 1).trim() + } + .toMap() + } + } + + /** + * The hand-curated "Detailed information" photo gallery, appended below the generated table. + * Edit it as plain markdown; it is copied through untouched. + */ + fun loadGallery(): String? = + javaClass.classLoader?.getResourceAsStream(GALLERY_RESOURCE) + ?.bufferedReader() + ?.use { it.readText() } + + const val REMARKS_RESOURCE = "scale_catalog_remarks.txt" + + const val GALLERY_RESOURCE = "scale_catalog_gallery.md" +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalogTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalogTest.kt new file mode 100644 index 000000000..ae3cd78b6 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalogTest.kt @@ -0,0 +1,122 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * Keeps the published scale list honest and regenerates it. + * + * The wiki page "Supported scales in openScale" used to be maintained by hand and drifted behind + * the registry — scales were added to [ScaleFactory] for releases without ever reaching the table. + * [ScaleCatalog] derives that table from `supportFor` instead; this test is the half that stops it + * from going stale again: a handler nobody probes fails the build. + * + * Robolectric is required because some handlers touch the Android framework while being constructed + * (e.g. QNHandler creates a main-looper Handler) and because the probes build `SparseArray`s. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ScaleCatalogTest { + + /** + * The guard that matters: every registered handler must be reachable — it has to win at least + * one probe. A handler that wins nothing is either missing from [ScaleCatalog.probes] (the + * table would silently omit the scale) or unreachable behind a broader matcher earlier in the + * list (the device would never reach its own driver). + */ + @Test + fun `every registered handler is covered by a probe`() { + val registered = ScaleFactory.createHandlers().map { it.javaClass.simpleName }.toSet() + val covered = ScaleCatalog.rows().map { it.handler }.toSet() + + assertThat(registered - covered).isEmpty() + } + + /** A probe that no handler claims is dead weight and hides a matcher that has been narrowed. */ + @Test + fun `every probe is claimed by a handler`() { + val orphans = ScaleCatalog.probes + .filter { ScaleCatalog.winner(it) == null } + .map { "${it.name.ifEmpty { "" }} / services=${it.serviceUuids}" } + + assertThat(orphans).isEmpty() + } + + /** Two rows with the same product name would publish a duplicate line in the wiki table. */ + @Test + fun `catalog rows are unique per display name`() { + val rows = ScaleCatalog.rows() + + assertThat(rows.map { it.displayName }).containsNoDuplicates() + } + + /** + * `implemented` is documented as a subset of `capabilities`: the table prints a capability as + * "supported" from the first set and "known but not implemented" from the difference, so a + * handler listing an implemented capability it never declared would publish a hole. + */ + @Test + fun `implemented capabilities are a subset of the declared ones`() { + for (row in ScaleCatalog.rows()) { + assertThat(row.capabilities).containsAtLeastElementsIn(row.implemented) + } + } + + /** Every remark must reach a row; a typo in a key would otherwise drop the note silently. */ + @Test + fun `every hand-written remark matches a catalog row`() { + val rows = ScaleCatalog.rows() + val keys = rows.map { it.displayName }.toSet() + rows.map { it.handler }.toSet() + + assertThat(ScaleCatalog.loadRemarks().keys - keys).isEmpty() + } + + /** + * Writes the wiki page. Defaults to the build directory; point it straight at a wiki clone with + * + * SCALE_CATALOG_OUT=$HOME/Workspace/openScale.wiki/Supported-scales-in-openScale.md \ + * ./gradlew testDebugUnitTest --tests '*ScaleCatalogTest' + * + * An environment variable rather than a system property: Gradle forks a JVM for unit tests, and + * `-D` flags stay with the Gradle daemon unless they are forwarded in build.gradle.kts, while + * the environment is inherited. + */ + @Test + fun `generate the supported scales page`() { + val rows = ScaleCatalog.rows() + val markdown = ScaleCatalog.renderMarkdown(rows, ScaleCatalog.loadRemarks()) + + val target = File( + System.getenv("SCALE_CATALOG_OUT") + ?: "build/reports/scale-catalog/Supported-scales-in-openScale.md" + ) + target.parentFile?.mkdirs() + target.writeText(markdown) + + println("Scale catalog: ${rows.size} scales written to ${target.absolutePath}") + + assertThat(markdown).contains("| Scale | Connection |") + assertThat(rows).isNotEmpty() + } +} diff --git a/android_app/app/src/test/resources/scale_catalog_gallery.md b/android_app/app/src/test/resources/scale_catalog_gallery.md new file mode 100644 index 000000000..839d6d263 --- /dev/null +++ b/android_app/app/src/test/resources/scale_catalog_gallery.md @@ -0,0 +1,211 @@ +## Detailed information + +_Please select a scale for further information_ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +image missing
+Custom Bluetooth scale +
+ +image missing
+Xiaomi Mi scale v1 +
+ +image missing
+Medisana BS444 scale +
+ +image missing
+Sanitas SBF70 +
+image missing
+Digoo DG-S038H +
+image missing
+Yunmai Mini +
+image missing
+Excelvan CF369BLE +
+image missing
+Yunmai SE +
+image missing
+MGB +
+
+image missing
+Xiaomi Mi Body Composition Scale 2 +
+image missing
+Exingtech Y1 +
+ +image missing
+Beurer BF700/800 +
+ +image missing
+Silvercrest SBF75 +
+image missing
+Runtastic Libra +
+image missing
+Hesley (Yunchen) scale +
+image missing
+iHealth HS3 +
+image missing
+Beurer BF710 +
+image missing
+Easy Home 64050 +
+image missing
+Medisana BS440 +
+image missing
+Accuway +
+image missing
+Excelvan CF366BLE +
+ +image missing
+Trisa Body Analyze 4.0 +
+image missing
+Inlife +
+image missing
+FitIndex ES-26M +
+image missing
+Elektra Care +
+image missing
+Vigorun +
+image missing
+Quanta QTBL10 +
+image missing
+Renpho ES-CS20M +
+image missing
+Kamtron CS20M +
+image missing
+Sanitas SBF 72 +
+image missing
+Beurer BF500 +
+- +
diff --git a/android_app/app/src/test/resources/scale_catalog_remarks.txt b/android_app/app/src/test/resources/scale_catalog_remarks.txt new file mode 100644 index 000000000..3e0de8d6d --- /dev/null +++ b/android_app/app/src/test/resources/scale_catalog_remarks.txt @@ -0,0 +1,18 @@ +# Hand-written remarks for the generated scale catalog (see ScaleCatalog / ScaleCatalogTest). +# +# One entry per line: = +# The key is the display name a handler reports (the "Scale" column of the generated table). A +# handler class name works too and then covers every product that driver reports. +# Lines starting with '#' are comments. Markdown (links included) is allowed in the remark. +# +# Everything else in the table — display name, connection type, capabilities — comes from the +# handler's supportFor() and must be changed there, not here. + +Xiaomi Mi Scale v1 = No data on the scale is ever deleted +Xiaomi Body Composition Scale S400 = See [#1297](https://github.com/oliexdev/openScale/pull/1297) for more details +Sanitas SBF70 / SilverCrest SBF75 / Crane = The assigned user name on the scale and in openScale have to be the same, see [FAQ](Frequently-Asked-Questions-(FAQ)#how-do-i-get-the-body-metrics-water-muscle-fat-percentage-on-my-sbf70-scale) +Digoo DG-SO38H (Mengii) = Initialisation process needs to be reverse engineered. Currently you have to use the vendor's app once to initialise the scale. +ES-CS20M / ES-32MD = Uses the Trisa Body Analyze body measurement library until the original [QN scale library](https://github.com/oliexdev/openScale/issues/373) is fully reverse engineered +Custom openScale = See [here](Custom-Bluetooth-Scale) for a tutorial +Trisa Body Analyze 4.0 = See [Trisa Body Analyze](Trisa-Body-Analyze) for the protocol +Debug = Not a scale: assign it to a device to record its GATT services for reverse engineering From 61cb19650ce2464eb04f2cb2c38c0c96aaba3d1d Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 16 Aug 2026 13:18:13 +0200 Subject: [PATCH 42/72] Update Bluetooth scale handler registry and tests - Add support for many new branded Bluetooth scales to the supported catalog - Update test fixture list in `ScaleCatalog` to include all newly supported devices - Refactor testing structure in `ScaleFactoryTest` to use shared fixtures with `ScaleCatalog` - Simplify Bluetooth device representation using helper functions --- .../openscale/core/bluetooth/ScaleCatalog.kt | 398 +++++++++++------- .../core/bluetooth/ScaleCatalogTest.kt | 43 +- .../core/bluetooth/ScaleFactoryTest.kt | 110 ++--- 3 files changed, 288 insertions(+), 263 deletions(-) diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 68a863516..cb134f642 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -18,34 +18,102 @@ package com.health.openscale.core.bluetooth import android.util.SparseArray +import com.health.openscale.core.bluetooth.scales.AAAxHandler +import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler +import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler +import com.health.openscale.core.bluetooth.scales.BeurerSanitasHandler +import com.health.openscale.core.bluetooth.scales.BodyConnectHandler +import com.health.openscale.core.bluetooth.scales.CultSmartScaleProHandler +import com.health.openscale.core.bluetooth.scales.CustomOpenScaleHandler +import com.health.openscale.core.bluetooth.scales.DebugGattHandler import com.health.openscale.core.bluetooth.scales.DeviceCapability +import com.health.openscale.core.bluetooth.scales.DigooDGSO38HHandler +import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler +import com.health.openscale.core.bluetooth.scales.EEBBLHandler +import com.health.openscale.core.bluetooth.scales.ESCS20MHandler +import com.health.openscale.core.bluetooth.scales.EbelterBodyFatB2Handler +import com.health.openscale.core.bluetooth.scales.EtekcityESF551Handler +import com.health.openscale.core.bluetooth.scales.EtekcityFit8SHandler +import com.health.openscale.core.bluetooth.scales.EufyC20Handler +import com.health.openscale.core.bluetooth.scales.EufyP2Handler +import com.health.openscale.core.bluetooth.scales.ExcelvanCF36xHandler +import com.health.openscale.core.bluetooth.scales.ExingtechY1Handler +import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler +import com.health.openscale.core.bluetooth.scales.HesleyHandler +import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler +import com.health.openscale.core.bluetooth.scales.HuaweiAhCh100Handler +import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler +import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler +import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler +import com.health.openscale.core.bluetooth.scales.InlifeHandler +import com.health.openscale.core.bluetooth.scales.KeepS3Handler import com.health.openscale.core.bluetooth.scales.LinkMode +import com.health.openscale.core.bluetooth.scales.MGBHandler +import com.health.openscale.core.bluetooth.scales.MedisanaBs44xHandler +import com.health.openscale.core.bluetooth.scales.MiScaleHandler +import com.health.openscale.core.bluetooth.scales.MiScaleS400Handler +import com.health.openscale.core.bluetooth.scales.OkOkHandler +import com.health.openscale.core.bluetooth.scales.OmronWlcHandler +import com.health.openscale.core.bluetooth.scales.OneByoneHandler +import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler +import com.health.openscale.core.bluetooth.scales.QNHandler +import com.health.openscale.core.bluetooth.scales.QNHandlerBroadcast +import com.health.openscale.core.bluetooth.scales.RealmeSmartScaleHandler +import com.health.openscale.core.bluetooth.scales.RelaxmedicHandler +import com.health.openscale.core.bluetooth.scales.RenphoES26BBHandler +import com.health.openscale.core.bluetooth.scales.RenphoHandler +import com.health.openscale.core.bluetooth.scales.RobiS9Handler +import com.health.openscale.core.bluetooth.scales.RunstarR5Handler +import com.health.openscale.core.bluetooth.scales.RunstarR6Handler +import com.health.openscale.core.bluetooth.scales.RyFitHandler +import com.health.openscale.core.bluetooth.scales.SanitasSbf72Handler import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler +import com.health.openscale.core.bluetooth.scales.ScaleupHandler +import com.health.openscale.core.bluetooth.scales.SenssunHandler +import com.health.openscale.core.bluetooth.scales.SinocareHandler +import com.health.openscale.core.bluetooth.scales.SoehnleHandler +import com.health.openscale.core.bluetooth.scales.StandardBeurerSanitasHandler +import com.health.openscale.core.bluetooth.scales.TaylorBIAHandler +import com.health.openscale.core.bluetooth.scales.TrisaBodyAnalyzeHandler +import com.health.openscale.core.bluetooth.scales.VitafitVT701Handler +import com.health.openscale.core.bluetooth.scales.WeightGurusA3Handler +import com.health.openscale.core.bluetooth.scales.XiaomiS800Handler +import com.health.openscale.core.bluetooth.scales.YunmaiHandler +import com.health.openscale.core.bluetooth.scales.YunmaiXHandler import com.health.openscale.core.service.ScannedDeviceInfo import java.util.UUID /** - * The scale catalog: the wiki's "Supported scales in openScale" table, derived from the code that - * actually decides support. + * The one place a supported scale is written down. * - * Every row is produced by asking the real registry — [ScaleFactory.createHandlers] in its real - * order — what it makes of a synthetic advertisement, and then reading the returned - * [com.health.openscale.core.bluetooth.scales.DeviceSupport]. Nothing about a scale is written down - * twice: display name, link mode and the capability sets all come from `supportFor`, so a driver - * that gains (or loses) a capability changes the published table on the next generator run. + * [fixtures] pairs a synthetic advertisement with the handler that must claim it, and serves two + * jobs at once: * - * The one thing that cannot be derived is the input: `supportFor` only answers when it is handed an - * advertisement it recognises. [probes] is that input — one synthetic advertisement per supported - * device, built from the names and fingerprints documented in the handlers themselves, never from - * personal captures. `ScaleCatalogTest` fails when a registered handler has no probe, which is what - * keeps the table from silently falling behind the registry. + * - `ScaleFactoryTest` asserts that the real registry — [ScaleFactory.createHandlers] in its real + * order, first match wins — still routes each device to its own driver. + * - `ScaleCatalogTest` asks the winning handler for its + * [com.health.openscale.core.bluetooth.scales.DeviceSupport] and renders the wiki page + * "Supported scales in openScale" from it. * - * Human-written notes (the "Remarks" column) live in `src/test/resources/scale_catalog_remarks.txt` - * and are merged in by display name — see [renderMarkdown]. + * So adding a scale is one line here on top of the handler itself: the regression test and the + * published table follow automatically. Nothing about a device is written down twice — display + * name, link mode and capabilities all come from `supportFor`. + * + * Advertisements are built from the names and fingerprints documented in the handlers themselves, + * never from personal captures. + * + * Human-written notes for the table's "Remarks" column live in + * `src/test/resources/scale_catalog_remarks.txt`, the photo gallery in `scale_catalog_gallery.md`. */ object ScaleCatalog { - // --- Probe construction --------------------------------------------------------------- + // --- Fixtures --------------------------------------------------------------------------- + + /** An advertisement together with the handler that has to claim it. */ + data class Fixture( + val device: ScannedDeviceInfo, + val handler: Class, + ) fun uuid16(short: Int): UUID = UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) @@ -74,127 +142,151 @@ object ScaleCatalog { }, ) + /** Service 0xFFB0 — the LeFu-style service shared by a whole family of unrelated scales. */ + private val SERVICE_FFB0 = uuid16(0xFFB0) + /** Etekcity's company id; with service 0xFFD0 the only fingerprint of the nameless Fit 8S. */ private const val ETEKCITY_COMPANY_ID = 0x06D0 - /** Service 0xFFB0 — the LeFu-style service shared by a whole family of unrelated scales. */ - private val SERVICE_FFB0 = uuid16(0xFFB0) + private infix fun ScannedDeviceInfo.claimedBy(handler: Class) = + Fixture(this, handler) /** - * One synthetic advertisement per supported device. - * - * Where a handler reports different products under different names (Beurer, Medisana, Omron, - * 1byone, Huawei, …) every product gets its own probe, because each yields its own catalog row. + * One entry per supported device. Where a handler reports several products under different + * names (Beurer, Medisana, Omron, Huawei, 1byone, …) each product gets its own fixture, because + * each is its own row in the published table. */ - val probes: List = listOf( + val fixtures: List = listOf( // --- Matched by advertised name --- - device("AE BS-06"), // ActiveEraBF06Handler - device("Keep_S3"), // KeepS3Handler - device("Beurer BF450"), // BeurerBF450Handler - device("BIA SCALE", SERVICE_FFB0), // TaylorBIAHandler - device("RYFIT"), // RyFitHandler - device("CULT Smart Scale Pro"), // CultSmartScaleProHandler - device("realme Smart Scale"), // RealmeSmartScaleHandler - device("YUNMAI-ISSE-1234"), // YunmaiHandler(isMini = false) - device("YUNMAI-SIGNAL-1234"), // YunmaiHandler(isMini = true) - device("01257B1234"), // TrisaBodyAnalyzeHandler - device("SANITAS SBF72"), // SanitasSbf72Handler - device("SANITAS SBF73"), // … - device("Beurer BF915"), // … - device("Beurer BF105"), // StandardBeurerSanitasHandler - device("Beurer BF500"), // … - device("Beurer BF600"), // … - device("Beurer BF950"), // … - device("Shape200"), // SoehnleHandler - device("Weight Scale"), // SinocareHandler - device("SENSSUN FAT"), // SenssunHandler - device("RENPHO-SCALE-1234"), // RenphoHandler (no QN service → not QNHandler) - device("QN-Scale", uuid16(0xFFE0)), // QNHandler - device("Health Scale"), // OneByoneHandler (1byone classic) - device("eufy T9146"), // … Eufy C1 - device("eufy T9147"), // … Eufy P1 - device("eufy T9120"), // … Eufy A1 - device("1byone scale"), // OneByoneNewHandler - device("XMTZC14HM"), // MiScaleS400Handler - device("MIJIA SCALE S800"), // XiaomiS800Handler - device("MI_SCALE"), // MiScaleHandler (v1) - device("MIBFS"), // MiScaleHandler (v2) - device("runstar-r6"), // RunstarR6Handler - device("RUNSTAR-R5"), // RunstarR5Handler - device("relaxmedic"), // RelaxmedicHandler - device("robi"), // RobiS9Handler - device("Vitafit VT701"), // VitafitVT701Handler - device("EEBBL"), // EEBBLHandler - device("FITTRACK Dara"), // FitTrackDaraHandler - device("SSW532", SERVICE_FFB0), // DrTrustSSW532Handler - device("swan", SERVICE_FFB0), // MGBHandler - device("0203B1234"), // MedisanaBs44xHandler (BS430) - device("0131971234"), // … (BS444/BS440) - device("000fatscale01"), // InlifeHandler - device("IHEALTH HS3"), // IHealthHS3Handler - device("HUAWEI AH100"), // HuaweiAhCh100Handler - device("HUAWEI CH100"), // … - device("CH100S"), // HuaweiCH100SHandler - device("HUAWEI Scale 2 Pro"), // HuaweiHagridWspHandler - device("Hagrid-B29"), // … Scale 3 Pro - device("HUAWEI Scale 3"), // … Scale 3 - device("Hoffen BS-8107"), // HoffenBbs8107Handler - device("yunchen"), // HesleyHandler - device("vscale"), // ExingtechY1Handler - device("Body Fat-B2"), // EbelterBodyFatB2Handler - device("Electronic Scale"), // ExcelvanCF36xHandler - device("Etekcity Smart Fitness Scale"), // EtekcityESF551Handler - device("EUFY C20"), // EufyC20Handler - device("eufy T9148"), // EufyP2Handler - device("ES-CS20M"), // ESCS20MHandler - device("ES-26BB-B"), // RenphoES26BBHandler - device("Mengii"), // DigooDGSO38HHandler - device("debug"), // DebugGattHandler - device("openScale"), // CustomOpenScaleHandler - device("BEURER BF700"), // BeurerSanitasHandler (BF700/800/Libra) - device("BEURER BF710"), // … BF710 - device("SANITAS SBF70"), // … SBF70/SBF75/Crane - device("AAA002"), // AAAxHandler - device("1BODY CONNECT"), // BodyConnectHandler - device("1X-LINE"), // … Terraillon X-LINE - device("10376BAA"), // WeightGurusA3Handler + device("AE BS-06") claimedBy ActiveEraBF06Handler::class.java, + device("Keep_S3") claimedBy KeepS3Handler::class.java, + device("Beurer BF450") claimedBy BeurerBF450Handler::class.java, + device("BIA SCALE", SERVICE_FFB0) claimedBy TaylorBIAHandler::class.java, + device("RYFIT") claimedBy RyFitHandler::class.java, + device("CULT Smart Scale Pro") claimedBy CultSmartScaleProHandler::class.java, + device("realme Smart Scale") claimedBy RealmeSmartScaleHandler::class.java, + device("YUNMAI-ISSE-1234") claimedBy YunmaiHandler::class.java, + device("YUNMAI-SIGNAL-1234") claimedBy YunmaiHandler::class.java, + device("01257B1234") claimedBy TrisaBodyAnalyzeHandler::class.java, + device("SANITAS SBF72") claimedBy SanitasSbf72Handler::class.java, + device("SANITAS SBF73") claimedBy SanitasSbf72Handler::class.java, + device("Beurer BF915") claimedBy SanitasSbf72Handler::class.java, + device("Beurer BF105") claimedBy StandardBeurerSanitasHandler::class.java, + device("Beurer BF500") claimedBy StandardBeurerSanitasHandler::class.java, + device("Beurer BF600") claimedBy StandardBeurerSanitasHandler::class.java, + device("Beurer BF950") claimedBy StandardBeurerSanitasHandler::class.java, + device("Shape200") claimedBy SoehnleHandler::class.java, + device("Weight Scale") claimedBy SinocareHandler::class.java, + device("SENSSUN FAT") claimedBy SenssunHandler::class.java, + // No QN service advertised, so the QN driver must not take it. + device("RENPHO-SCALE-1234") claimedBy RenphoHandler::class.java, + device("QN-Scale", uuid16(0xFFE0)) claimedBy QNHandler::class.java, + device("Health Scale") claimedBy OneByoneHandler::class.java, + device("eufy T9146") claimedBy OneByoneHandler::class.java, + device("eufy T9147") claimedBy OneByoneHandler::class.java, + device("eufy T9120") claimedBy OneByoneHandler::class.java, + device("1byone scale") claimedBy OneByoneNewHandler::class.java, + device("XMTZC14HM") claimedBy MiScaleS400Handler::class.java, + device("MIJIA SCALE S800") claimedBy XiaomiS800Handler::class.java, + device("MI_SCALE") claimedBy MiScaleHandler::class.java, + device("MIBFS") claimedBy MiScaleHandler::class.java, + device("runstar-r6") claimedBy RunstarR6Handler::class.java, + device("RUNSTAR-R5") claimedBy RunstarR5Handler::class.java, + device("relaxmedic") claimedBy RelaxmedicHandler::class.java, + device("robi") claimedBy RobiS9Handler::class.java, + device("Vitafit VT701") claimedBy VitafitVT701Handler::class.java, + device("EEBBL") claimedBy EEBBLHandler::class.java, + device("FITTRACK Dara") claimedBy FitTrackDaraHandler::class.java, + device("SSW532", SERVICE_FFB0) claimedBy DrTrustSSW532Handler::class.java, + device("swan", SERVICE_FFB0) claimedBy MGBHandler::class.java, + device("0203B1234") claimedBy MedisanaBs44xHandler::class.java, + device("0131971234") claimedBy MedisanaBs44xHandler::class.java, + device("000fatscale01") claimedBy InlifeHandler::class.java, + device("IHEALTH HS3") claimedBy IHealthHS3Handler::class.java, + device("HUAWEI AH100") claimedBy HuaweiAhCh100Handler::class.java, + device("HUAWEI CH100") claimedBy HuaweiAhCh100Handler::class.java, + device("CH100S") claimedBy HuaweiCH100SHandler::class.java, + device("HUAWEI Scale 2 Pro") claimedBy HuaweiHagridWspHandler::class.java, + device("Hagrid-B29") claimedBy HuaweiHagridWspHandler::class.java, + device("HUAWEI Scale 3") claimedBy HuaweiHagridWspHandler::class.java, + device("Hoffen BS-8107") claimedBy HoffenBbs8107Handler::class.java, + device("yunchen") claimedBy HesleyHandler::class.java, + device("vscale") claimedBy ExingtechY1Handler::class.java, + device("Body Fat-B2") claimedBy EbelterBodyFatB2Handler::class.java, + device("Electronic Scale") claimedBy ExcelvanCF36xHandler::class.java, + device("Etekcity Smart Fitness Scale") claimedBy EtekcityESF551Handler::class.java, + device("EUFY C20") claimedBy EufyC20Handler::class.java, + device("eufy T9148") claimedBy EufyP2Handler::class.java, + device("ES-CS20M") claimedBy ESCS20MHandler::class.java, + device("ES-26BB-B") claimedBy RenphoES26BBHandler::class.java, + device("Mengii") claimedBy DigooDGSO38HHandler::class.java, + device("debug") claimedBy DebugGattHandler::class.java, + device("openScale") claimedBy CustomOpenScaleHandler::class.java, + device("BEURER BF700") claimedBy BeurerSanitasHandler::class.java, + device("BEURER BF710") claimedBy BeurerSanitasHandler::class.java, + device("SANITAS SBF70") claimedBy BeurerSanitasHandler::class.java, + device("AAA002") claimedBy AAAxHandler::class.java, + device("1BODY CONNECT") claimedBy BodyConnectHandler::class.java, + device("1X-LINE") claimedBy BodyConnectHandler::class.java, + device("10376BAA") claimedBy WeightGurusA3Handler::class.java, // Omron reports its model as the GAP name once bonded; advertised local names carry the // model id instead (see OmronWlcHandler.MODELS_BY_ADVERTISED_ID). - device("HBF-702T"), - device("KRD-703T"), - device("HBF-227T"), - device("HBF-228T"), - device("HBF-230T"), - device("HBF-222T"), - device("BCM-500"), - device("VIVA"), + device("HBF-702T") claimedBy OmronWlcHandler::class.java, + device("KRD-703T") claimedBy OmronWlcHandler::class.java, + device("HBF-227T") claimedBy OmronWlcHandler::class.java, + device("HBF-228T") claimedBy OmronWlcHandler::class.java, + device("HBF-230T") claimedBy OmronWlcHandler::class.java, + device("HBF-222T") claimedBy OmronWlcHandler::class.java, + device("BCM-500") claimedBy OmronWlcHandler::class.java, + device("VIVA") claimedBy OmronWlcHandler::class.java, + device("BLEsmart_0001000C0080E1A2B3C4") claimedBy OmronWlcHandler::class.java, // --- Matched by advertisement fingerprint, not by name --- // Yunmai X: advertised service 0x1320. - advertisement(services = listOf(uuid16(0x1320))), + advertisement(services = listOf(uuid16(0x1320))) claimedBy YunmaiXHandler::class.java, // QN broadcast variant: company id 0xFFFF with the AABB magic header. advertisement( manufacturerData = listOf( 0xFFFF to byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0x00, 0x00, 0x00, 0x00) ) - ), + ) claimedBy QNHandlerBroadcast::class.java, // OKOK: company id behind one of the known names. - advertisement(name = "ADV", manufacturerData = listOf(0x20CA to ByteArray(16))), - advertisement(name = "ADV", manufacturerData = listOf(0x11CA to ByteArray(16))), - advertisement(name = "ADV", manufacturerData = listOf(0xF0FF to ByteArray(16))), + advertisement(name = "ADV", manufacturerData = listOf(0x20CA to ByteArray(16))) + claimedBy OkOkHandler::class.java, + advertisement(name = "ADV", manufacturerData = listOf(0x11CA to ByteArray(16))) + claimedBy OkOkHandler::class.java, + advertisement(name = "ADV", manufacturerData = listOf(0xF0FF to ByteArray(16))) + claimedBy OkOkHandler::class.java, // Etekcity Fit 8S: nameless, company id 0x06D0 plus service 0xFFD0. advertisement( services = listOf(uuid16(0xFFD0)), manufacturerData = listOf(ETEKCITY_COMPANY_ID to ByteArray(20)), - ), + ) claimedBy EtekcityFit8SHandler::class.java, // Scaleup: any manufacturer record whose company-id low byte is 0xD0 (measuring) or 0xE0. - advertisement(manufacturerData = listOf(0x1DD0 to ByteArray(9))), + advertisement(manufacturerData = listOf(0x1DD0 to ByteArray(9))) + claimedBy ScaleupHandler::class.java, ) + // --- Registry queries ------------------------------------------------------------------- + + /** + * The handler [ScaleFactory.createCommunicator] would pick for [device], or null. + * + * A fresh registry per call: several handlers stash state in `supportFor` (e.g. + * StandardBeurerSanitasHandler remembers the matched model), so a shared instance would let one + * fixture bleed into the next. + */ + fun winner(device: ScannedDeviceInfo): ScaleDeviceHandler? = + ScaleFactory.createHandlers().firstOrNull { it.supportFor(device) != null } + + /** Every handler that claims [device] — more than one means two drivers overlap. */ + fun claimants(device: ScannedDeviceInfo): List = + ScaleFactory.createHandlers().filter { it.supportFor(device) != null } + // --- Catalog ---------------------------------------------------------------------------- - /** One published row: what the registry reports for one probe. */ + /** One published row: what the winning handler reports for one fixture. */ data class Row( val displayName: String, val handler: String, @@ -203,18 +295,11 @@ object ScaleCatalog { val implemented: Set, ) - /** - * Runs every probe through the registry and collects what the *winning* handler reports — - * first match wins, exactly as [ScaleFactory.createCommunicator] resolves a real scan result. - * - * A fresh registry per probe: several handlers stash state in `supportFor` (e.g. - * StandardBeurerSanitasHandler remembers the matched model), so a shared instance would let one - * probe bleed into the next. - */ + /** Runs every fixture through the registry and collects what the winning handler reports. */ fun rows(): List = - probes.mapNotNull { probe -> + fixtures.mapNotNull { fixture -> ScaleFactory.createHandlers().firstNotNullOfOrNull { handler -> - handler.supportFor(probe)?.let { support -> + handler.supportFor(fixture.device)?.let { support -> Row( displayName = support.displayName, handler = handler.javaClass.simpleName, @@ -228,12 +313,14 @@ object ScaleCatalog { .distinctBy { it.displayName } .sortedBy { it.displayName.lowercase() } - /** The handler that claims [probe], or null — the same first-match-wins rule the factory uses. */ - fun winner(probe: ScannedDeviceInfo): ScaleDeviceHandler? = - ScaleFactory.createHandlers().firstOrNull { it.supportFor(probe) != null } - // --- Rendering -------------------------------------------------------------------------- + /** Where a handler's source file lives, relative to the repository root. */ + fun sourcePath(handler: String): String = + "android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/$handler.kt" + + private const val SOURCE_BASE_URL = "https://github.com/oliexdev/openScale/blob/master" + /** A literal pipe in a display name or remark would split the markdown cell. */ private fun cell(text: String): String = text.replace("|", "\\|") @@ -244,38 +331,27 @@ object ScaleCatalog { } private fun linkLabel(mode: LinkMode): String = when (mode) { - LinkMode.CONNECT_GATT -> "BLE (connect)" - LinkMode.BROADCAST_ONLY -> "BLE (broadcast)" - LinkMode.CLASSIC_SPP -> "Bluetooth Classic" + LinkMode.CONNECT_GATT -> "BLE GATT" + LinkMode.BROADCAST_ONLY -> "BLE Broadcast" + // SPP runs over RFCOMM on Bluetooth Classic (BR/EDR), not over BLE — the distinction + // matters to users because those scales pair differently. + LinkMode.CLASSIC_SPP -> "Classic SPP" } - /** Capabilities that get their own column; everything else is listed in "Other capabilities". */ - private val COLUMNS = listOf( - DeviceCapability.BODY_COMPOSITION, - DeviceCapability.HISTORY_READ, - DeviceCapability.LIVE_WEIGHT_STREAM, + /** The capability columns, in the order they appear in the table. */ + private val CAPABILITY_COLUMNS = listOf( + DeviceCapability.BODY_COMPOSITION to "Body metrics", + DeviceCapability.HISTORY_READ to "History data", + DeviceCapability.LIVE_WEIGHT_STREAM to "Live weight", + DeviceCapability.TIME_SYNC to "Time sync", + DeviceCapability.USER_SYNC to "User sync", + DeviceCapability.UNIT_CONFIG to "Unit config", + DeviceCapability.BATTERY_LEVEL to "Battery", ) - private fun otherCapabilities(row: Row): String { - val labels = DeviceCapability.entries - .filter { it !in COLUMNS } - .filter { it in row.capabilities } - .map { capability -> - val name = when (capability) { - DeviceCapability.TIME_SYNC -> "time sync" - DeviceCapability.USER_SYNC -> "user sync" - DeviceCapability.UNIT_CONFIG -> "unit config" - DeviceCapability.BATTERY_LEVEL -> "battery" - else -> capability.name.lowercase() - } - if (capability in row.implemented) name else "$name (o)" - } - return if (labels.isEmpty()) "-" else labels.joinToString(", ") - } - /** * Renders the wiki page. [remarks] is keyed by display name, falling back to the handler class - * name so a note can cover every product a driver reports. + * name so one note can cover every product a driver reports. */ fun renderMarkdown(rows: List, remarks: Map): String = buildString { appendLine("## Scale support overview") @@ -302,20 +378,24 @@ object ScaleCatalog { appendLine("> [!NOTE]") appendLine("> If you want to help support your Bluetooth scale, please read [How to support a new scale](How-to-support-a-new-scale) for further information.") appendLine() - appendLine("| Scale | Connection | Body metrics | History data | Live weight | Other capabilities | Remarks |") - appendLine("|---|---|---|---|---|---|---|") + + val header = listOf("Scale", "Handler", "Connection") + + CAPABILITY_COLUMNS.map { it.second } + + listOf("Remarks") + appendLine(header.joinToString(" | ", prefix = "| ", postfix = " |")) + appendLine(header.joinToString("|", prefix = "|", postfix = "|") { "---" }) + for (row in rows) { val remark = remarks[row.displayName] ?: remarks[row.handler] ?: "-" - appendLine( - "| ${cell(row.displayName)} " + - "| ${linkLabel(row.linkMode)} " + - "| ${mark(row, DeviceCapability.BODY_COMPOSITION)} " + - "| ${mark(row, DeviceCapability.HISTORY_READ)} " + - "| ${mark(row, DeviceCapability.LIVE_WEIGHT_STREAM)} " + - "| ${otherCapabilities(row)} " + - "| ${cell(remark)} |" - ) + val cells = listOf( + cell(row.displayName), + "[${row.handler}]($SOURCE_BASE_URL/${sourcePath(row.handler)})", + linkLabel(row.linkMode), + ) + CAPABILITY_COLUMNS.map { (capability, _) -> mark(row, capability) } + + listOf(cell(remark)) + appendLine(cells.joinToString(" | ", prefix = "| ", postfix = " |")) } + appendLine() appendLine("✓ : supported in openScale
") appendLine("o : supported by the scale but still needs to be reverse engineered
") diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalogTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalogTest.kt index ae3cd78b6..3349a8139 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalogTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalogTest.kt @@ -30,10 +30,10 @@ import java.io.File * The wiki page "Supported scales in openScale" used to be maintained by hand and drifted behind * the registry — scales were added to [ScaleFactory] for releases without ever reaching the table. * [ScaleCatalog] derives that table from `supportFor` instead; this test is the half that stops it - * from going stale again: a handler nobody probes fails the build. + * from going stale again: a handler nobody lists a fixture for fails the build. * * Robolectric is required because some handlers touch the Android framework while being constructed - * (e.g. QNHandler creates a main-looper Handler) and because the probes build `SparseArray`s. + * (e.g. QNHandler creates a main-looper Handler) and because the fixtures build `SparseArray`s. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) @@ -41,34 +41,22 @@ class ScaleCatalogTest { /** * The guard that matters: every registered handler must be reachable — it has to win at least - * one probe. A handler that wins nothing is either missing from [ScaleCatalog.probes] (the + * one fixture. A handler that wins nothing is either missing from [ScaleCatalog.fixtures] (the * table would silently omit the scale) or unreachable behind a broader matcher earlier in the - * list (the device would never reach its own driver). + * registry (the device would never reach its own driver). */ @Test - fun `every registered handler is covered by a probe`() { + fun `every registered handler is covered by a fixture`() { val registered = ScaleFactory.createHandlers().map { it.javaClass.simpleName }.toSet() val covered = ScaleCatalog.rows().map { it.handler }.toSet() assertThat(registered - covered).isEmpty() } - /** A probe that no handler claims is dead weight and hides a matcher that has been narrowed. */ - @Test - fun `every probe is claimed by a handler`() { - val orphans = ScaleCatalog.probes - .filter { ScaleCatalog.winner(it) == null } - .map { "${it.name.ifEmpty { "" }} / services=${it.serviceUuids}" } - - assertThat(orphans).isEmpty() - } - /** Two rows with the same product name would publish a duplicate line in the wiki table. */ @Test fun `catalog rows are unique per display name`() { - val rows = ScaleCatalog.rows() - - assertThat(rows.map { it.displayName }).containsNoDuplicates() + assertThat(ScaleCatalog.rows().map { it.displayName }).containsNoDuplicates() } /** @@ -83,6 +71,23 @@ class ScaleCatalogTest { } } + /** + * The table links every handler to its source file. The path is derived from the class name, so + * a driver whose file is named differently would publish a 404. + */ + @Test + fun `every handler links to an existing source file`() { + // Unit tests run with the module directory (android_app/app) as the working directory. + val repositoryRoot = File("../..").canonicalFile + val missing = ScaleFactory.createHandlers() + .map { it.javaClass.simpleName } + .distinct() + .map { ScaleCatalog.sourcePath(it) } + .filterNot { File(repositoryRoot, it).isFile } + + assertThat(missing).isEmpty() + } + /** Every remark must reach a row; a typo in a key would otherwise drop the note silently. */ @Test fun `every hand-written remark matches a catalog row`() { @@ -116,7 +121,7 @@ class ScaleCatalogTest { println("Scale catalog: ${rows.size} scales written to ${target.absolutePath}") - assertThat(markdown).contains("| Scale | Connection |") + assertThat(markdown).contains("| Scale | Handler | Connection |") assertThat(rows).isNotEmpty() } } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt index e7b86f650..0263af01e 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth import android.util.SparseArray import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler import com.health.openscale.core.bluetooth.scales.EtekcityESF551Handler import com.health.openscale.core.bluetooth.scales.EtekcityFit8SHandler @@ -52,8 +53,10 @@ import java.util.UUID * These tests close that gap by probing the whole ordered registry — the same list and the * same first-match-wins rule [ScaleFactory.createCommunicator] uses. * - * Fixtures are synthetic advertisements built from the advertised names/services documented in - * the handlers themselves, not from personal captures. + * The devices themselves live in [ScaleCatalog.fixtures], shared with `ScaleCatalogTest`, which + * renders the published scale table from the same list — a supported scale is written down once. + * Advertisements are synthetic, built from the names/services documented in the handlers + * themselves, not from personal captures. * * Robolectric is required because some handlers touch the Android framework while being * constructed (e.g. QNHandler creates a main-looper Handler). @@ -65,47 +68,20 @@ class ScaleFactoryTest { /** Service 0xFFB0 — the LeFu-style service shared by a whole family of unrelated scales. */ private val SERVICE_FFB0 = uuid16(0xFFB0) - /** - * A fresh registry per probe: several handlers stash state in `supportFor` (e.g. - * StandardBeurerSanitasHandler remembers the matched model), so a shared instance would let - * one fixture bleed into the next. - */ - private fun claimants(device: ScannedDeviceInfo): List = - ScaleFactory.createHandlers().filter { it.supportFor(device) != null } - - /** The handler [ScaleFactory.createCommunicator] would pick for [device], or null. */ - private fun winner(device: ScannedDeviceInfo): ScaleDeviceHandler? = - ScaleFactory.createHandlers().firstOrNull { it.supportFor(device) != null } - - private fun device(name: String, vararg services: UUID) = ScannedDeviceInfo( - name = name, - address = "C0:FF:EE:12:34:56", - rssi = -50, - serviceUuids = services.toList(), - manufacturerData = null, - ) + // Device construction and registry queries are shared with the catalog; see [ScaleCatalog]. + private fun claimants(device: ScannedDeviceInfo) = ScaleCatalog.claimants(device) + + private fun winner(device: ScannedDeviceInfo) = ScaleCatalog.winner(device) + + private fun device(name: String, vararg services: UUID) = ScaleCatalog.device(name, *services) - /** - * A broadcast advertisement: name (often empty on broadcast-only scales), advertised services - * and the manufacturer-specific records, keyed by company id exactly like `ScanRecord` hands - * them to the scanner. - */ private fun advertisement( name: String = "", services: List = emptyList(), manufacturerData: List> = emptyList(), - ) = ScannedDeviceInfo( - name = name, - address = "C0:FF:EE:12:34:56", - rssi = -50, - serviceUuids = services, - manufacturerData = SparseArray().apply { - manufacturerData.forEach { (id, data) -> put(id, data) } - }, - ) + ) = ScaleCatalog.advertisement(name, services, manufacturerData) - private fun uuid16(short: Int): UUID = - UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + private fun uuid16(short: Int) = ScaleCatalog.uuid16(short) private fun assertClaimedBy(device: ScannedDeviceInfo, expected: Class) { val winner = winner(device) @@ -116,57 +92,21 @@ class ScaleFactoryTest { // --- Device claims ---------------------------------------------------------------------- /** - * The regression guard that matters: for a spread of known advertised names, the registry must - * still resolve to the handler that owns that device. A new (or reordered) handler that starts - * matching one of these names fails here. + * The regression guard that matters: every device in [ScaleCatalog.fixtures] must still resolve + * to the handler that owns it. A new (or reordered) handler that starts matching one of these + * advertisements fails here. + * + * The fixtures are shared with `ScaleCatalogTest`, which renders the published scale table from + * the very same list — so a scale is described in exactly one place. */ @Test - fun `known advertised names resolve to their own handler`() { - val expectations: List> = listOf( - // name (as advertised) expected handler class - "openScale" to "CustomOpenScaleHandler", - "MIBFS" to "MiScaleHandler", - "MI_SCALE" to "MiScaleHandler", - "XMTZC14HM" to "MiScaleS400Handler", - "MIJIA SCALE S800" to "XiaomiS800Handler", - "BLEsmart_0001000C0080E1A2B3C4" to "OmronWlcHandler", - "Keep_S3" to "KeepS3Handler", - "AE BS-06" to "ActiveEraBF06Handler", - "Shape200" to "SoehnleHandler", - "YUNMAI-ISSE-1234" to "YunmaiHandler", - "YUNMAI-SIGNAL-1234" to "YunmaiHandler", - "01257B1234" to "TrisaBodyAnalyzeHandler", - "Etekcity Smart Fitness Scale" to "EtekcityESF551Handler", - "Vitafit VT701" to "VitafitVT701Handler", - "SENSSUN FAT" to "SenssunHandler", - "Weight Scale" to "SinocareHandler", - "Electronic Scale" to "ExcelvanCF36xHandler", - "ES-26BB-B" to "RenphoES26BBHandler", - "Health Scale" to "OneByoneHandler", - "1byone scale" to "OneByoneNewHandler", - "CH100S" to "HuaweiCH100SHandler", - "Hoffen BS-8107" to "HoffenBbs8107Handler", - "RUNSTAR-R5" to "RunstarR5Handler", - "runstar-r6" to "RunstarR6Handler", - "Beurer BF450" to "BeurerBF450Handler", - "Beurer BF105" to "StandardBeurerSanitasHandler", - "BEURER BF700" to "BeurerSanitasHandler", - "000fatscale01" to "InlifeHandler", - "10376BAA" to "WeightGurusA3Handler", - "Mengii" to "DigooDGSO38HHandler", - "yunchen" to "HesleyHandler", - "vscale" to "ExingtechY1Handler", - "IHEALTH HS3" to "IHealthHS3Handler", - "CULT Smart Scale Pro" to "CultSmartScaleProHandler", - "AAA002" to "AAAxHandler", - "eufy T9148" to "EufyP2Handler", - "EUFY C20" to "EufyC20Handler", - "debug" to "DebugGattHandler", - ) + fun `catalog fixtures resolve to their own handler`() { + for (fixture in ScaleCatalog.fixtures) { + val winner = winner(fixture.device) - for ((name, expected) in expectations) { - val winner = winner(device(name)) - assertThat(winner?.javaClass?.simpleName).isEqualTo(expected) + assertWithMessage("device '${fixture.device.name.ifEmpty { "" }}'") + .that(winner?.javaClass?.simpleName) + .isEqualTo(fixture.handler.simpleName) } } From ccb1cc2dab3ace35ffbef5d989ae7c3494eff666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiago=20=C3=81gueda?= Date: Sun, 16 Aug 2026 15:18:25 +0200 Subject: [PATCH 43/72] feat: Add support for the ProfiCare PC-PW 3008 BT (#1476) The PC-PW 3008 BT runs the same Chipsea "WeChat scale" firmware as the Hoffen BBS-8107: it advertises the WeChat service 0xFEE7 and speaks the 0xFA framing on 0xFFB0/0xFFB2 that HoffenBbs8107Handler already implements. Only the exact-name gate kept it from being recognised, so that check becomes a small advertised-name -> display-name map and the scale is reported under its own product name. Confirmed against the vendor's Dr.Curve+ app (com.yilai.DrCurvePlus), whose onWeixinFatScale routine decodes exactly the offsets parseFinalMeasurement already uses: weight LE16 @3, fat @6, water @8, muscle @10, bone @14, visceral fat @17. Matched by advertised name rather than by service 0xFEE7, which is the generic WeChat BLE service and would claim unrelated devices. Verified on hardware: weight and all body composition values read correctly against the vendor app. Co-authored-by: Claude Opus 5 (1M context) --- .../bluetooth/scales/HoffenBbs8107Handler.kt | 24 ++++- .../openscale/core/bluetooth/ScaleCatalog.kt | 1 + .../scales/HoffenBbs8107HandlerTest.kt | 95 +++++++++++++++++++ .../test/resources/scale_catalog_remarks.txt | 1 + 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107HandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107Handler.kt index c6c40ab70..f6cb73292 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107Handler.kt @@ -30,12 +30,17 @@ import java.util.UUID import kotlin.math.max /** - * Handler for the Hoffen BBS-8107 scale. + * Handler for the Hoffen BBS-8107 and its rebrands. * * Protocol (single custom characteristic): * - Service 0xFFB0, Characteristic 0xFFB2 (notify + write with response) * - Packets start with 0xFA, then `cmdOrResp`, `len`, `payload`, `checksum` * - Checksum = XOR of bytes 1..(n-2); some final measurement frames deliver checksum in a follow-up notify. + * + * This is the Chipsea "WeChat scale" protocol: the same firmware is sold under several brands, + * all advertising the WeChat service 0xFEE7 alongside the 0xFFB0 service they actually talk on. + * Verified against the stock "Dr.Curve+" app (`com.yilai.DrCurvePlus`), whose `onWeixinFatScale` + * routine decodes exactly the offsets [parseFinalMeasurement] uses. */ class HoffenBbs8107Handler : ScaleDeviceHandler() { @@ -46,9 +51,20 @@ class HoffenBbs8107Handler : ScaleDeviceHandler() { // --- DeviceSupport -------------------------------------------------------- + /** + * Advertised name (lowercased) → name to show in the UI. + * + * Matched by exact name on purpose. These scales also advertise service 0xFEE7, but that is + * the generic WeChat BLE service used by all kinds of unrelated devices, so claiming it + * outright would produce false positives in the scan list. + */ + private val KNOWN_DEVICES = mapOf( + "hoffen bs-8107" to "Hoffen BBS-8107", + "pc-pw 3008 bt" to "ProfiCare PC-PW 3008 BT", + ) + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { - val name = device.name.lowercase(Locale.US) - if (name != "hoffen bs-8107") return null + val displayName = KNOWN_DEVICES[device.name.trim().lowercase(Locale.US)] ?: return null val caps = setOf( DeviceCapability.BODY_COMPOSITION, @@ -57,7 +73,7 @@ class HoffenBbs8107Handler : ScaleDeviceHandler() { ) // We implement everything we claim above. return DeviceSupport( - displayName = "Hoffen BBS-8107", + displayName = displayName, capabilities = caps, implemented = caps, linkMode = LinkMode.CONNECT_GATT diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index cb134f642..88992f4f0 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -210,6 +210,7 @@ object ScaleCatalog { device("Hagrid-B29") claimedBy HuaweiHagridWspHandler::class.java, device("HUAWEI Scale 3") claimedBy HuaweiHagridWspHandler::class.java, device("Hoffen BS-8107") claimedBy HoffenBbs8107Handler::class.java, + device("PC-PW 3008 BT") claimedBy HoffenBbs8107Handler::class.java, device("yunchen") claimedBy HesleyHandler::class.java, device("vscale") claimedBy ExingtechY1Handler::class.java, device("Body Fat-B2") claimedBy EbelterBodyFatB2Handler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107HandlerTest.kt new file mode 100644 index 000000000..b2f0a2993 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107HandlerTest.kt @@ -0,0 +1,95 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.service.ScannedDeviceInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +/** + * Matching tests for [HoffenBbs8107Handler], which drives a Chipsea "WeChat scale" firmware sold + * under more than one brand. + * + * Fixtures are synthetic advertisements built from the names these scales are documented to + * advertise, not personal captures. + * + * Robolectric is required for android.util.SparseArray in ScannedDeviceInfo. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HoffenBbs8107HandlerTest { + + /** The generic WeChat service these scales advertise alongside the 0xFFB0 they talk on. */ + private val SERVICE_FEE7 = uuid16(0xFEE7) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun device(name: String, vararg services: UUID) = ScannedDeviceInfo( + name = name, + address = "C0:FF:EE:12:34:56", + rssi = -50, + serviceUuids = services.toList(), + manufacturerData = null, + ) + + @Test + fun `claims the Hoffen BBS-8107`() { + val support = HoffenBbs8107Handler().supportFor(device("Hoffen BS-8107", SERVICE_FEE7)) + assertThat(support).isNotNull() + assertThat(support!!.displayName).isEqualTo("Hoffen BBS-8107") + } + + @Test + fun `claims the ProfiCare PC-PW 3008 BT rebrand under its own name`() { + val support = HoffenBbs8107Handler().supportFor(device("PC-PW 3008 BT", SERVICE_FEE7)) + assertThat(support).isNotNull() + assertThat(support!!.displayName).isEqualTo("ProfiCare PC-PW 3008 BT") + assertThat(support.linkMode).isEqualTo(LinkMode.CONNECT_GATT) + } + + @Test + fun `matches regardless of case and surrounding whitespace`() { + assertThat(HoffenBbs8107Handler().supportFor(device("pc-pw 3008 bt"))).isNotNull() + assertThat(HoffenBbs8107Handler().supportFor(device(" PC-PW 3008 BT "))).isNotNull() + } + + @Test + fun `implements everything it claims`() { + val support = HoffenBbs8107Handler().supportFor(device("PC-PW 3008 BT"))!! + assertThat(support.implemented).isEqualTo(support.capabilities) + assertThat(support.capabilities).contains(DeviceCapability.BODY_COMPOSITION) + } + + @Test + fun `does not claim other devices advertising the generic WeChat service`() { + // 0xFEE7 is used by all sorts of unrelated hardware; the name is what decides. + assertThat(HoffenBbs8107Handler().supportFor(device("MI Band 5", SERVICE_FEE7))).isNull() + assertThat(HoffenBbs8107Handler().supportFor(device("", SERVICE_FEE7))).isNull() + } + + @Test + fun `does not claim a similarly named but different model`() { + assertThat(HoffenBbs8107Handler().supportFor(device("PC-PW 3007 BT"))).isNull() + assertThat(HoffenBbs8107Handler().supportFor(device("PC-PW 3008"))).isNull() + } +} diff --git a/android_app/app/src/test/resources/scale_catalog_remarks.txt b/android_app/app/src/test/resources/scale_catalog_remarks.txt index 3e0de8d6d..1818db21f 100644 --- a/android_app/app/src/test/resources/scale_catalog_remarks.txt +++ b/android_app/app/src/test/resources/scale_catalog_remarks.txt @@ -16,3 +16,4 @@ ES-CS20M / ES-32MD = Uses the Trisa Body Analyze body measurement library until Custom openScale = See [here](Custom-Bluetooth-Scale) for a tutorial Trisa Body Analyze 4.0 = See [Trisa Body Analyze](Trisa-Body-Analyze) for the protocol Debug = Not a scale: assign it to a device to record its GATT services for reverse engineering +ProfiCare PC-PW 3008 BT = Same Chipsea "WeChat scale" firmware as the Hoffen BBS-8107, so it is driven by the same handler From d53f165fb6c8db1bcca5f18b58d855d7d7b1c631 Mon Sep 17 00:00:00 2001 From: Nidala96 <121960989+Nidala96@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:39:11 +0200 Subject: [PATCH 44/72] Add huawei scale 3 support (#1474) * Added Huawei Scale 3 support * Restore unrelated changes * git commit -m "Refine Huawei Scale 3 support" * Update strings.xml * Fix Huawei Scale 3 realtime and history handling * Make Huawei realtime timestamp parsing tolerant * Update bluetooth scale model mapping for better coverage - Update test assertions to use Google Truth assertions instead of JUnit assertions - Map additional scale model names to the correct handler class - Add mappings for "HUAWEI Scale 3", "HUAWEI Scale 3 Pro", and "Hagrid-B29" --------- Co-authored-by: OliE --- .../core/bluetooth/libs/HuaweiHagridWspLib.kt | 59 +++-- .../scales/HuaweiHagridWspHandler.kt | 166 +++++++++---- .../app/src/main/res/values/strings.xml | 6 + .../core/bluetooth/ScaleFactoryTest.kt | 1 + .../bluetooth/libs/HuaweiHagridWspLibTest.kt | 139 ++++++++++- .../scales/HuaweiHagridWspHandlerTest.kt | 224 +++++++++++++++++- .../test/resources/scale_catalog_remarks.txt | 1 + 7 files changed, 531 insertions(+), 65 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt index 178197f46..a59f7d74a 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt @@ -25,9 +25,11 @@ import javax.crypto.Cipher import javax.crypto.Mac import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +import kotlin.collections.sumOf import kotlin.math.ceil import kotlin.math.min import kotlin.math.roundToInt +import kotlin.require /** * Huawei Hagrid / WSP framing, authentication and payload helpers. @@ -271,51 +273,65 @@ object HuaweiHagridWspLib { } class FrameAccumulator(private val requireValidCrc: Boolean = true) { + private val lock = Any() private var expectedFrames: Int = 0 private var encrypted: Boolean = false - private var allFramesCrcValid: Boolean = true private val chunks = mutableMapOf() var lastCompletedEncrypted: Boolean = false private set - fun ingest(rawFrame: ByteArray): ByteArray? { + fun ingest(rawFrame: ByteArray): ByteArray? = synchronized(lock) { val frame = parseNotificationFrame(rawFrame, requireValidCrc) ?: return null if (frame.totalFrames <= 1) { lastCompletedEncrypted = frame.encrypted - reset() + resetLocked() return frame.payload } - if (expectedFrames != frame.totalFrames || encrypted != frame.encrypted) { - reset() + // A frame index 0 always starts a new logical packet. + if (frame.frameIndex == 0) { + resetLocked() expectedFrames = frame.totalFrames encrypted = frame.encrypted + } else { + // Drop orphan continuation frames until we have a packet start. + if (expectedFrames == 0) { + return null + } + + // If packet metadata changes mid-stream, invalidate partial state. + if (expectedFrames != frame.totalFrames || encrypted != frame.encrypted) { + resetLocked() + return null + } } - allFramesCrcValid = allFramesCrcValid && frame.crcValid - chunks[frame.frameIndex] = frame.payload + chunks[frame.frameIndex] = frame.payload.copyOf() if (chunks.size < expectedFrames) return null val out = ByteArray(chunks.values.sumOf { it.size }) var pos = 0 for (i in 0 until expectedFrames) { val chunk = chunks[i] ?: run { - reset() + resetLocked() return null } chunk.copyInto(out, pos) pos += chunk.size } lastCompletedEncrypted = encrypted - reset() + resetLocked() return out } - fun reset() { + fun reset() = synchronized(lock) { + resetLocked() + } + + private fun resetLocked() { expectedFrames = 0 encrypted = false - allFramesCrcValid = true chunks.clear() } } @@ -406,14 +422,22 @@ object HuaweiHagridWspLib { parseHagridWeightMeasurement( payload = payload, allowedLengths = setOf(26, 38), - timestampPresent = false + // Bytes 4–10 carry a valid timestamp in the Hagrid realtime payload, + // confirmed by the Huawei Scale 3 BLE capture (2026-08-09T23:39:29). + timestampPresent = true, + // Realtime: read the timestamp when present, but never reject the + // measurement if the bytes don't decode (Scale 2 Pro / 3 Pro / HONOR + // may carry something else there during a live weighing). + timestampRequired = false ) fun parseHistoryMeasurement(payload: ByteArray): HagridWeightMeasurement? = parseHagridWeightMeasurement( payload = payload, allowedLengths = setOf(27, 28, 39, 40), - timestampPresent = true + timestampPresent = true, + // History records must have a valid timestamp; reject corrupt packets. + timestampRequired = true ) fun parseScaleVersion(payload: ByteArray): HagridScaleVersion? { @@ -835,7 +859,8 @@ object HuaweiHagridWspLib { private fun parseHagridWeightMeasurement( payload: ByteArray, allowedLengths: Set, - timestampPresent: Boolean + timestampPresent: Boolean, + timestampRequired: Boolean = timestampPresent ): HagridWeightMeasurement? { if (payload.size !in allowedLengths) return null @@ -844,10 +869,13 @@ object HuaweiHagridWspLib { val weightKg = u16le(payload, 0) / 100.0f val fatPercent = u16le(payload, 2) / 10.0f val timestamp = if (timestampPresent) { - readTimestamp(payload, 4) ?: return null + val ts = readTimestamp(payload, 4) + if (ts == null && timestampRequired) return null + ts } else { null } + val low = (0 until 6).map { u16le(payload, 12 + it * 2) } val high = if (highResistancePresent) { (0 until 6).map { u16le(payload, 26 + it * 2) } @@ -1016,7 +1044,6 @@ object HuaweiHagridWspLib { joinToString(separator = "") { "%02X".format(it.toInt() and 0xFF) } } - /** * Replaceable source for Huawei Hagrid CAK/C1/C2 key material. * diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt index 8f6d0231b..d423d42fd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.health.openscale.R @@ -49,6 +50,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap import java.util.Date import java.util.Locale import java.util.UUID @@ -95,7 +97,7 @@ class HuaweiHagridWspHandler( private val chrRealtimeWeight = UUID.fromString("46797c17-d639-488d-9476-4789e8472878") private val chrHistoryWeight = UUID.fromString("0212f42a-5f19-4bc1-ba52-d7ec7ccb71a4") - private val accumulators = mutableMapOf() + private val accumulators = ConcurrentHashMap() private var pendingDeviceAddress: String? = null private var pendingProductProfile = HuaweiHagridWspLib.productProfileForMarker(null) private var pendingCapabilityBits: HuaweiHagridWspLib.HagridCapabilityBits? = null @@ -117,17 +119,21 @@ class HuaweiHagridWspHandler( private var measurementStatusPollJob: Job? = null private var measurementStatusReady = false private var postStatusMeasurementReadsStarted = false - private var realtimeReadRequestCount = 0 + private var realtimeFinalPublished = false + // Scale 3: cache the last valid realtime measurement so it can be published + // on status=0 if it arrived before the status notification (the common case). + private var lastValidScale3Realtime: HuaweiHagridWspLib.HagridWeightMeasurement? = null + private var lastKnownUser: ScaleUser? = null @Composable override fun DeviceConfigurationUi() { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Huawei Hagrid WSP secrets", + text = stringResource(R.string.huawei_hagrid_secrets_title), style = MaterialTheme.typography.titleSmall, ) Text( - text = "Configure CAK, C1, and C2 as 32 hex characters each. Values stay in per-driver app settings.", + text = stringResource(R.string.huawei_hagrid_secrets_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -179,8 +185,8 @@ class HuaweiHagridWspHandler( Row(modifier = Modifier.fillMaxWidth()) { Text( text = when { - showError -> "Expected 32 hex characters" - isSaved -> "Saved" + showError -> stringResource(R.string.huawei_hagrid_secret_invalid) + isSaved -> stringResource(R.string.huawei_hagrid_secret_saved) else -> "" }, color = if (showError) { @@ -246,8 +252,10 @@ class HuaweiHagridWspHandler( historyReadCount = 0 publishedHistoryKeys.clear() postStatusMeasurementReadsStarted = false - realtimeReadRequestCount = 0 measurementStatusReady = false + realtimeFinalPublished = false + lastValidScale3Realtime = null + lastKnownUser = null setNotifyIfPresent(svcUserData, chrRequestAuth) setNotifyIfPresent(svcUserData, chrAuthToken) @@ -284,10 +292,12 @@ class HuaweiHagridWspHandler( override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { if (HuaweiHagridWspLib.hasNotificationFramePrefix(data)) { - val accumulator = accumulators.getOrPut(characteristic) { + val frameCopy = data.copyOf() + val accumulator = accumulators.computeIfAbsent(characteristic) { HuaweiHagridWspLib.FrameAccumulator() } - val payload = accumulator.ingest(data) ?: return + + val payload = accumulator.ingest(frameCopy) ?: return handleWspNotification(characteristic, payload, accumulator.lastCompletedEncrypted, user) return } @@ -296,6 +306,7 @@ class HuaweiHagridWspHandler( } override fun onDisconnected() { + logI("Huawei Hagrid disconnected") stopMeasurementStatusPolling() accumulators.clear() pendingRandA = null @@ -311,8 +322,10 @@ class HuaweiHagridWspHandler( historyReadCount = 0 publishedHistoryKeys.clear() postStatusMeasurementReadsStarted = false - realtimeReadRequestCount = 0 measurementStatusReady = false + realtimeFinalPublished = false + lastValidScale3Realtime = null + lastKnownUser = null handshakeState = HandshakeState.IDLE } @@ -328,13 +341,13 @@ class HuaweiHagridWspHandler( logW("Encrypted Hagrid notification before work key: chr=${characteristicLabel(characteristic)} len=${rawPayload.size}") return } - runCatching { HuaweiHagridWspLib.decryptPayload(rawPayload, activeSession.workKey) } + runCatching { HuaweiHagridWspLib.decryptPayload(rawPayload.copyOf(), activeSession.workKey) } .getOrElse { error -> logW("Failed to decrypt Hagrid notification chr=${characteristicLabel(characteristic)}: ${error.message}") return } } else { - rawPayload + rawPayload.copyOf() } logD( @@ -402,7 +415,7 @@ class HuaweiHagridWspHandler( handshakeState = HandshakeState.AUTH_TOKEN_SENT val authPayload = HuaweiHagridWspLib.buildAuthTokenPayload(payload, randB, secrets.cak) - logI("Sending Huawei Hagrid auth token (${authPayload.size} bytes)") + logI("Huawei Hagrid authentication started") writeWspPlainIfPresent(svcUserData, chrAuthToken, authPayload) } @@ -447,7 +460,7 @@ class HuaweiHagridWspHandler( session = HuaweiHagridWspLib.HagridSession(randA.copyOf(), randB.copyOf(), rootKey, workKey.copyOf()) handshakeState = HandshakeState.WORK_KEY_SENT - logI("Huawei Hagrid auth verified; sending encrypted work key") + logI("Huawei Hagrid authentication verified") writeWspFramesIfPresent( svcUserData, chrSendWorkKey, @@ -463,7 +476,7 @@ class HuaweiHagridWspHandler( return } - logI("Huawei Hagrid work key accepted; syncing time and selected profile") + logI("Huawei Hagrid work key accepted") writeWspPlainIfPresent(svcCurrentTime, chrCurrentTime, HuaweiHagridWspLib.currentTimePayload()) readManagerInfoBeforeProfileSync(user) } @@ -532,6 +545,7 @@ class HuaweiHagridWspHandler( "Sending encrypted Huawei Hagrid user-info: userId=${user.id} " + "payloadLen=${plaintext.size} huidPresent=${info.huid.isNotBlank()}" ) + logI("Huawei Hagrid user profile sent") HuaweiHagridWspLib.buildEncryptedWriteFrames(plaintext, activeSession.workKey, iv) .forEach { frame -> writeTo(svcUserData, chrSetUserInfo, frame, withResponse = true) } return true @@ -541,9 +555,9 @@ class HuaweiHagridWspHandler( val status = payload.firstOrNull()?.toInt()?.and(0xFF) val accepted = status == 0x00 if (accepted) { - logI("Huawei Hagrid set-user-info accepted") + logI("Huawei Hagrid user profile accepted") } else { - logW("Huawei Hagrid set-user-info returned status=${status ?: -1}") + logW("Huawei Hagrid user profile returned status=${status ?: -1}") } if (handshakeState == HandshakeState.USER_INFO_SENT) { @@ -559,27 +573,52 @@ class HuaweiHagridWspHandler( writeWspPlainIfPresent(svcCurrentTime, chrScaleVersion, ByteArray(0)) writeWspPlainIfPresent(svcCurrentTime, chrGetWeightUnit, ByteArray(0)) postStatusMeasurementReadsStarted = false - realtimeReadRequestCount = 0 + realtimeFinalPublished = false + lastValidScale3Realtime = null + startMeasurementFlow() + } + + private fun startMeasurementFlow() { + logI("Starting Huawei Hagrid realtime measurement flow") + + writeWspPlainIfPresent( + svcBodyComposition, + chrRealtimeWeight, + ByteArray(0), + withResponse = true + ) + startMeasurementStatusPolling() } private fun buildUserInfo(user: ScaleUser): HuaweiHagridWspLib.HagridUserInfo { val lastWeight = lastMeasurementFor(user.id)?.weight + val configuredWeight = when { lastWeight != null && lastWeight.isFinite() && lastWeight > 0f -> lastWeight user.initialWeight.isFinite() && user.initialWeight > 0f -> user.initialWeight else -> 0f } + val heightCm = if (user.bodyHeight.isFinite() && user.bodyHeight > 0f) { user.bodyHeight.roundToInt().coerceIn(0, 0xFFFF) } else { 0 } + // Scale 3 uses inverted gender encoding (male=1, female=0) confirmed by Huawei Health + // BLE capture. All other Hagrid families retain the original encoding (male=0, female=1). + val genderCode = when (pendingProductProfile.family) { + HuaweiHagridWspLib.HagridProductFamily.SCALE_3 -> + if (user.gender == GenderType.MALE) 1 else 0 + else -> + if (user.gender == GenderType.MALE) 0 else 1 + } + return HuaweiHagridWspLib.HagridUserInfo( huid = lastManagerInfo?.huid?.takeIf { it.isNotBlank() } ?: localSyntheticHuid(), uid = "u:%08X".format(Locale.US, user.id), - gender = if (user.gender == GenderType.MALE) 0 else 1, + gender = genderCode, ageYears = user.age.coerceIn(0, 255), heightCm = heightCm, weightKg = configuredWeight, @@ -597,16 +636,40 @@ class HuaweiHagridWspHandler( private fun handleRealtimePayload(payload: ByteArray, user: ScaleUser) { val parsed = HuaweiHagridWspLib.parseRealtimeMeasurement(payload) + if (parsed == null || parsed.weightKg <= 0f) { logD("Ignoring unsupported Hagrid realtime payload len=${payload.size}") return } - userInfo(R.string.bluetooth_scale_info_measuring_weight, parsed.weightKg) - logD( - "Hagrid realtime measurement parsed len=${parsed.rawLength} " + - "lowCount=${parsed.lowFrequencyImpedance.size} highCount=${parsed.highFrequencyImpedance.size} saved=false" + lastKnownUser = user + + userInfo( + R.string.bluetooth_scale_info_measuring_weight, + parsed.weightKg ) + + // Other Hagrid families persist via history only; realtime is UI-only for them. + if (pendingProductProfile.family != HuaweiHagridWspLib.HagridProductFamily.SCALE_3) { + return + } + + // Scale 3 current measurements are persisted from the realtime channel. + // Cache every valid realtime reading so it is available when status=0 arrives, + // since the final stable notification typically precedes the status notification. + lastValidScale3Realtime = parsed + + if (!measurementStatusReady || realtimeFinalPublished) { + logD( + "Huawei Scale 3 realtime weight=${parsed.weightKg} " + + "measurementReady=$measurementStatusReady" + ) + return + } + + realtimeFinalPublished = true + logI("Huawei Scale 3 final realtime measurement received") + publishHagridMeasurement(parsed, user) } private fun handleHistoryWeightPayload(payload: ByteArray, user: ScaleUser) { @@ -645,6 +708,7 @@ class HuaweiHagridWspHandler( ) { val low = parsed.representativeLowOhm ?: 0.0 val high = parsed.representativeHighOhm ?: low + val measurement = ScaleMeasurement( userId = user.id, dateTime = parsed.timestamp ?: Date(), @@ -654,10 +718,6 @@ class HuaweiHagridWspHandler( impedance = high, impedanceLow = low, ) - logI( - "Publishing Huawei Hagrid measurement: lowCount=${parsed.lowFrequencyImpedance.size} " + - "highCount=${parsed.highFrequencyImpedance.size}" - ) publish(measurement) } @@ -744,17 +804,34 @@ class HuaweiHagridWspHandler( } private fun handleMeasurementStatusPayload(payload: ByteArray) { - val status = payload.firstOrNull()?.toInt()?.and(0xFF) - logD("Huawei Hagrid measurement-status len=${payload.size} status=${status ?: -1}") + val status = payload.firstOrNull()?.toInt()?.and(0xFF) ?: return + logD("Huawei Hagrid measurement status=$status") + if (status == 0x00) { measurementStatusReady = true - sendMeasurementDataRequests() + + // Scale 3: the final realtime notification typically arrives before status=0. + // Publish the cached measurement now if handleRealtimePayload has not already + // done so (i.e. the notification came after this status update). + if (pendingProductProfile.family == HuaweiHagridWspLib.HagridProductFamily.SCALE_3 && + !realtimeFinalPublished + ) { + val cached = lastValidScale3Realtime + val user = lastKnownUser + if (cached != null && user != null) { + realtimeFinalPublished = true + logI("Huawei Scale 3 final realtime measurement published from cache on status=0") + publishHagridMeasurement(cached, user) + } + } + + sendPostMeasurementReads() } } private fun startMeasurementStatusPolling() { if (!hasCharacteristic(svcCurrentTime, chrMeasurementStatusPoll)) { - sendMeasurementDataRequests() + sendPostMeasurementReads() return } @@ -776,8 +853,8 @@ class HuaweiHagridWspHandler( } } if (measurementStatusPollingActive && !measurementStatusReady) { - logW("Huawei Hagrid measurement status timed out; requesting measurements directly") - sendMeasurementDataRequests() + logW("Huawei Hagrid measurement status timed out; continuing with post-measurement reads") + sendPostMeasurementReads() } } } @@ -795,14 +872,14 @@ class HuaweiHagridWspHandler( svcCurrentTime, chrMeasurementStatusPoll, ByteArray(0), - withResponse = false + withResponse = true ) } - private fun sendMeasurementDataRequests() { + private fun sendPostMeasurementReads() { stopMeasurementStatusPolling() + if (postStatusMeasurementReadsStarted) { - sendRealtimeMeasurementRequest() return } @@ -812,12 +889,16 @@ class HuaweiHagridWspHandler( historyReadCount = 0 writeWspPlainIfPresent(svcBodyComposition, chrHistoryWeight, ByteArray(0)) } - sendRealtimeMeasurementRequest() - } - - private fun sendRealtimeMeasurementRequest() { - realtimeReadRequestCount += 1 - writeWspPlainIfPresent(svcBodyComposition, chrRealtimeWeight, ByteArray(0)) + // Scale 3 needs a follow-up realtime request so the scale delivers the final + // confirmed measurement on chrRealtimeWeight after measurementStatusReady=true. + if (pendingProductProfile.family == HuaweiHagridWspLib.HagridProductFamily.SCALE_3) { + writeWspPlainIfPresent( + svcBodyComposition, + chrRealtimeWeight, + ByteArray(0), + withResponse = true + ) + } } private fun setNotifyIfPresent(service: UUID, characteristic: UUID) { @@ -946,6 +1027,7 @@ class HuaweiHagridWspHandler( AUTH_FAILED } + companion object { const val SETTINGS_KEY_CAK_HEX = "ble/huawei_hagrid/cak_hex" const val SETTINGS_KEY_C1_HEX = "ble/huawei_hagrid/c1_hex" diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index e8244829b..4af9492e7 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -407,6 +407,12 @@ Bind key must be exactly 32 hex characters S400: Bind key not configured. Go to Bluetooth settings to enter your BLE key. + + Huawei Hagrid WSP secrets + Configure CAK, C1, and C2 as 32 hex characters each. Values stay in per-driver app settings. + Expected 32 hex characters + Saved + Connected to %1$s Connection to %1$s failed: %2$s diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt index 0263af01e..a4229e22e 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt @@ -141,6 +141,7 @@ class ScaleFactoryTest { "SENSSUN FAT", "ES-26BB-B", "CH100S", + "HUAWEI Scale 3", "Hoffen BS-8107", "RUNSTAR-R5", "000fatscale01", diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt index 807029317..0d6d3dc68 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt @@ -40,6 +40,40 @@ class HuaweiHagridWspLibTest { assertThat(third).isEqualTo(payload) } + @Test + fun `accumulator drops orphan continuation frame before packet start`() { + val payloadA = ByteArray(26) { (it + 1).toByte() } + val payloadB = ByteArray(26) { (it + 101).toByte() } + val framesA = HuaweiHagridWspLib.buildWriteFrames(payloadA) + val framesB = HuaweiHagridWspLib.buildWriteFrames(payloadB) + val accumulator = HuaweiHagridWspLib.FrameAccumulator() + + val orphanSecondOfA = accumulator.ingest(toNotifyFrame(framesA[1])) + val firstOfB = accumulator.ingest(toNotifyFrame(framesB[0])) + val secondOfB = accumulator.ingest(toNotifyFrame(framesB[1])) + + assertThat(orphanSecondOfA).isNull() + assertThat(firstOfB).isNull() + assertThat(secondOfB).isEqualTo(payloadB) + } + + @Test + fun `accumulator resets when a new frame index zero arrives`() { + val payloadA = ByteArray(26) { (it + 11).toByte() } + val payloadB = ByteArray(26) { (it + 41).toByte() } + val framesA = HuaweiHagridWspLib.buildWriteFrames(payloadA) + val framesB = HuaweiHagridWspLib.buildWriteFrames(payloadB) + val accumulator = HuaweiHagridWspLib.FrameAccumulator() + + val firstOfA = accumulator.ingest(toNotifyFrame(framesA[0])) + val firstOfB = accumulator.ingest(toNotifyFrame(framesB[0])) + val secondOfB = accumulator.ingest(toNotifyFrame(framesB[1])) + + assertThat(firstOfA).isNull() + assertThat(firstOfB).isNull() + assertThat(secondOfB).isEqualTo(payloadB) + } + @Test fun `auth token validates expected scale response`() { val randA = ByteArray(16) { it.toByte() } @@ -68,6 +102,56 @@ class HuaweiHagridWspLibTest { assertThat(HuaweiHagridWspLib.decryptPayload(encrypted, key)).isEqualTo(plaintext) } + @Test + fun `huawei scale 3 PoC capture decodes to the expected measurement`() { + val rootKey = HuaweiHagridWspLib.deriveHagridRootKey( + hexToBytes("CA4946D061C9FE534F6044F930EBB69B"), + hexToBytes("FBCE6E2B4BAF80ED969BA26B4A4B9325"), + HuaweiHagridWspLib.hagridC3FromBluetoothAddress("38:1E:C7:66:08:D3") + ) + assertThat(rootKey).isEqualTo(hexToBytes("d680f0f3b50576a74c8815653e8b7243")) + + val workKey = HuaweiHagridWspLib.decryptPayload( + deframe( + "dc12203b033efce70954e24a3a06ac75603b418e", + "dc12218a68cb34e5250a6c185813b62e6958c13f", + "dc0522a49ddc06", + ), + rootKey + ) + assertThat(workKey).isEqualTo(hexToBytes("36697370363671706d64386733696b62")) + + val plain = HuaweiHagridWspLib.decryptPayload( + deframe( + "cd1220ddae5ad500d352ad002d9ab60055a313f1", + "cd12216d4c5b1e4fa529d8fc4225d8db8b3726b3", + "cd0f222285c5b012308c6ee4a7829efa52", + ), + workKey + ) + + // Payload: b1 21 2d 01 ea 07 08 09 17 27 1d a0 ea 13 00... 61 00 + // weight fat ^bytes 4-11: timestamp (2026-08-09T23:39:29)^ hr + // Bytes 4–11 are timestamp data in this capture and are NOT decoded as + // body composition metrics (musclePercent / waterPercent / boneMassKg / visceralFat + // have been removed from HagridWeightMeasurement entirely). + assertThat(plain).isEqualTo( + hexToBytes("b1212d01ea07080917271da0ea13000000000000000000006100") + ) + + val measurement = HuaweiHagridWspLib.parseRealtimeMeasurement(plain) + assertThat(measurement).isNotNull() + assertThat(measurement!!.weightKg).isWithin(0.0001f).of(86.25f) + assertThat(measurement.fatPercent).isWithin(0.0001f).of(30.1f) + assertThat(measurement.heartRateBpm).isEqualTo(97) + // Bytes 4–10 decode to 2026-08-09T23:39:29; verify year and month (timezone-safe). + assertThat(measurement.timestamp).isNotNull() + val ts = Calendar.getInstance() + ts.time = measurement.timestamp!! + assertThat(ts.get(Calendar.YEAR)).isEqualTo(2026) + assertThat(ts.get(Calendar.MONTH)).isEqualTo(Calendar.AUGUST) + } + @Test fun `realtime payload parses low and high impedance bands`() { val parsed = HuaweiHagridWspLib.parseRealtimeMeasurement(realtimePayload()) @@ -128,6 +212,34 @@ class HuaweiHagridWspLibTest { assertThat(payload[67].toInt() and 0xFF).isEqualTo(0x20) } + @Test + fun `realtime with invalid timestamp still parses`() { + val payload = realtimePayload().copyOf() + + // Zero out the year bytes (offset 4-5) so readTimestamp returns null + // (year 0 < 2000 fails the validity check). + payload[4] = 0x00 + payload[5] = 0x00 + payload[6] = 0x00 + + val parsed = HuaweiHagridWspLib.parseRealtimeMeasurement(payload) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.timestamp).isNull() + assertThat(parsed.weightKg).isGreaterThan(0f) + } + + @Test + fun `history with invalid timestamp is rejected`() { + val payload = historyPayload().copyOf() + + payload[4] = 0x00 + payload[5] = 0x00 + payload[6] = 0x00 + + assertThat(HuaweiHagridWspLib.parseHistoryMeasurement(payload)).isNull() + } + private fun toNotifyFrame(writeFrame: ByteArray): ByteArray { val notifyFrame = writeFrame.copyOf() notifyFrame[0] = HuaweiHagridWspLib.FRAME_NOTIFY_PLAIN.toByte() @@ -137,6 +249,31 @@ class HuaweiHagridWspLibTest { return notifyFrame } + private fun deframe(vararg frames: String): ByteArray { + val chunks = frames.map { framePayload(it) } + val totalSize = chunks.sumOf { it.size } + return ByteArray(totalSize).also { out -> + var pos = 0 + chunks.forEach { chunk -> + chunk.copyInto(out, destinationOffset = pos) + pos += chunk.size + } + } + } + + private fun framePayload(frameHex: String): ByteArray { + val frame = hexToBytes(frameHex) + return frame.copyOfRange(3, frame.size - 2) + } + + private fun hexToBytes(hex: String): ByteArray { + require(hex.length % 2 == 0) { "Hex string must have an even length" } + + return ByteArray(hex.length / 2) { index -> + hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } + private fun realtimePayload(): ByteArray = byteArrayOf( 0x34, 0x1E, @@ -149,6 +286,6 @@ class HuaweiHagridWspLibTest { 0x5B, 0x02, 0x5C, 0x02, 0x5D, 0x02, ) - private fun historyPayload(completeFlag: Int): ByteArray = + private fun historyPayload(completeFlag: Int = 0x00): ByteArray = realtimePayload() + byteArrayOf(0x01, completeFlag.toByte()) } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt index 8b5624c82..48f6ab804 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt @@ -43,6 +43,7 @@ class HuaweiHagridWspHandlerTest { fun `claims known Huawei Hagrid devices without claiming generic BIA`() { val scale2 = HuaweiHagridWspHandler().supportFor(device("HUAWEI Scale 2 Pro")) val scale3Pro = HuaweiHagridWspHandler().supportFor(device("Hagrid-B29")) + val scale3 = HuaweiHagridWspHandler().supportFor(device("HUAWEI Scale 3")) val generic = HuaweiHagridWspHandler().supportFor( device("Generic BIA", services = intArrayOf(0x1805, 0x181B, 0x181C, 0x181D)) ) @@ -51,6 +52,8 @@ class HuaweiHagridWspHandlerTest { assertThat(scale2!!.displayName).isEqualTo("HUAWEI Scale 2 Pro") assertThat(scale3Pro).isNotNull() assertThat(scale3Pro!!.displayName).isEqualTo("HUAWEI Scale 3 Pro") + assertThat(scale3).isNotNull() + assertThat(scale3!!.displayName).isEqualTo("HUAWEI Scale 3") assertThat(scale2.implemented).doesNotContain(DeviceCapability.USER_SYNC) assertThat(scale2.implemented).doesNotContain(DeviceCapability.UNIT_CONFIG) assertThat(generic).isNull() @@ -120,7 +123,8 @@ class HuaweiHagridWspHandlerTest { } @Test - fun `plain WSP realtime notification reports progress without publishing`() { + fun `non Scale 3 Hagrid realtime notification is never published`() { + // Default profile is UNKNOWN (no supportFor call) → not SCALE_3 → always progress-only. val handler = HuaweiHagridWspHandler() val callbacks = CapturingCallbacks() handler.attach( @@ -140,6 +144,106 @@ class HuaweiHagridWspHandlerTest { .isWithin(0.0001f).of(77.32f) } + @Test + fun `non Scale 3 Hagrid realtime notification is not published even after status=0`() { + // Explicitly verify that measurementStatusReady=true does not enable realtime + // persistence on non-SCALE_3 families. + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + handler.supportFor(device("HUAWEI Scale 2 Pro")) + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) + sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + + assertThat(callbacks.published).isEmpty() + } + + @Test + fun `Scale 3 realtime notification before status=0 is not published`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + handler.supportFor(device("HUAWEI Scale 3")) + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + + assertThat(callbacks.published).isEmpty() + assertThat(callbacks.infos.map { it.resId }) + .contains(R.string.bluetooth_scale_info_measuring_weight) + } + + @Test + fun `Scale 3 realtime notification after status=0 is published exactly once`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + handler.supportFor(device("HUAWEI Scale 3")) + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) + + // First post-status-0 realtime notification: saved. + sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.32f) + assertThat(callbacks.published.single().fat).isWithin(0.0001f).of(18.5f) + + // Subsequent realtime notifications in the same session: ignored for persistence. + sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + assertThat(callbacks.published).hasSize(1) + } + + @Test + fun `Scale 3 realtime before status=0 is cached and published when status=0 arrives`() { + // Common real-world sequence: the scale sends the final stable realtime notification + // BEFORE it sends measurement status=0. The cached value must be published on status=0, + // not discarded. + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + handler.supportFor(device("HUAWEI Scale 3")) + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + // Realtime arrives first – must NOT be published yet. + sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + assertThat(callbacks.published).isEmpty() + assertThat(callbacks.infos.map { it.resId }) + .contains(R.string.bluetooth_scale_info_measuring_weight) + + // Status=0 arrives – cached realtime is published exactly once. + sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.32f) + assertThat(callbacks.published.single().fat).isWithin(0.0001f).of(18.5f) + + // Any further realtime notifications in the same session are ignored. + sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + assertThat(callbacks.published).hasSize(1) + } + @Test fun `Hagrid history notification publishes non-terminal record and acknowledges next read`() { val handler = HuaweiHagridWspHandler() @@ -162,6 +266,8 @@ class HuaweiHagridWspHandlerTest { assertThat(measurement.heartRate).isEqualTo(72) assertThat(measurement.impedanceLow).isWithin(0.0001).of(500.0) assertThat(measurement.impedance).isWithin(0.0001).of(600.0) + assertThat(measurement.lbm).isEqualTo(0f) + assertThat(measurement.bmr).isEqualTo(0f) assertThat(transport.reassembleWriteMessagesTo(CHR_HISTORY_WEIGHT).last()) .isEqualTo(byteArrayOf(0x00)) } @@ -206,7 +312,7 @@ class HuaweiHagridWspHandlerTest { } @Test - fun `configured fake Hagrid secrets authenticate and start measurement reads after status ready`() { + fun `configured fake Hagrid secrets authenticate and request realtime before status polling`() { val randA = ByteArray(16) { it.toByte() } val randB = ByteArray(16) { (it + 16).toByte() } val workKey = ByteArray(16) { (it + 32).toByte() } @@ -279,7 +385,7 @@ class HuaweiHagridWspHandlerTest { HuaweiHagridWspLib.HagridUserInfo( huid = HAGRID_MANAGER_HUID, uid = "u:00000007", - gender = 1, + gender = 1, // Scale 2 Pro: else branch → male=0, female=1 → female=1 ageYears = user.age, heightCm = 171, weightKg = 83.2f, @@ -302,15 +408,121 @@ class HuaweiHagridWspHandlerTest { CHR_GET_MANAGER_INFO, CHR_SCALE_VERSION, CHR_GET_WEIGHT_UNIT, + CHR_REALTIME_WEIGHT, CHR_MEASUREMENT_STATUS_POLL, ) assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_HISTORY_WEIGHT) - assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_REALTIME_WEIGHT) + + val realtimeWritesBeforeStatus = transport.writes.count { it.characteristic == CHR_REALTIME_WEIGHT } + val historyWritesBeforeStatus = transport.writes.count { it.characteristic == CHR_HISTORY_WEIGHT } + val pollWritesBeforeStatus = transport.writes.count { it.characteristic == CHR_MEASUREMENT_STATUS_POLL } sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) - assertThat(transport.writes.map { it.characteristic }) - .containsAtLeast(CHR_HISTORY_WEIGHT, CHR_REALTIME_WEIGHT) + // Scale 2 Pro is not SCALE_3: sendPostMeasurementReads does NOT send a second + // realtime request, so the write count stays the same. + assertThat(transport.writes.count { it.characteristic == CHR_REALTIME_WEIGHT }) + .isEqualTo(realtimeWritesBeforeStatus) + // sendPostMeasurementReads() kicks off one history read. + assertThat(transport.writes.count { it.characteristic == CHR_HISTORY_WEIGHT }) + .isEqualTo(historyWritesBeforeStatus + 1) + // Polling is stopped; no more poll writes. + assertThat(transport.writes.count { it.characteristic == CHR_MEASUREMENT_STATUS_POLL }) + .isEqualTo(pollWritesBeforeStatus) + } + + @Test + fun `Scale 3 gender encoding is male=1 female=0 per BLE capture`() { + val genderByte = runAuthFlowAndGetUserInfoByte62( + deviceName = "HUAWEI Scale 3", + gender = GenderType.MALE, + ) + assertThat(genderByte).isEqualTo(1) + } + + @Test + fun `non-Scale-3 Hagrid families retain original male=0 female=1 gender encoding`() { + val genderByteMale = runAuthFlowAndGetUserInfoByte62( + deviceName = "HUAWEI Scale 2 Pro", + gender = GenderType.MALE, + ) + val genderByteFemale = runAuthFlowAndGetUserInfoByte62( + deviceName = "HUAWEI Scale 2 Pro", + gender = GenderType.FEMALE, + ) + assertThat(genderByteMale).isEqualTo(0) + assertThat(genderByteFemale).isEqualTo(1) + } + + @Test + fun `sendPostMeasurementReads sends second realtime request only for Scale 3`() { + fun realtimeWritesAfterStatus(deviceName: String): Int { + val handler = HuaweiHagridWspHandler() + handler.supportFor(device(deviceName)) + val transport = CapturingTransport( + setOf( + SVC_BODY_COMPOSITION to CHR_REALTIME_WEIGHT, + SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT, + SVC_CURRENT_TIME to CHR_MEASUREMENT_STATUS_POLL, + SVC_CURRENT_TIME to CHR_MEASUREMENT_STATUS_RESULT, + ) + ) + handler.attach( + transport = transport, + callbacks = CapturingCallbacks(), + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 1)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + val before = transport.writes.count { it.characteristic == CHR_REALTIME_WEIGHT } + sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) + return transport.writes.count { it.characteristic == CHR_REALTIME_WEIGHT } - before + } + + assertThat(realtimeWritesAfterStatus("HUAWEI Scale 3")).isEqualTo(1) + assertThat(realtimeWritesAfterStatus("HUAWEI Scale 2 Pro")).isEqualTo(0) + assertThat(realtimeWritesAfterStatus("Hagrid-B29")).isEqualTo(0) // Scale 3 Pro + } + + /** Runs the auth flow up to SetUserInfo and returns the gender byte (payload[62]). */ + private fun runAuthFlowAndGetUserInfoByte62(deviceName: String, gender: GenderType): Int { + val randA = ByteArray(16) { it.toByte() } + val randB = ByteArray(16) { (it + 16).toByte() } + val workKey = ByteArray(16) { (it + 32).toByte() } + val workKeyIv = ByteArray(16) { (it + 48).toByte() } + val cak = ByteArray(16) { (it + 64).toByte() } + val c1 = ByteArray(16) { (it + 80).toByte() } + val c2 = ByteArray(16) { (it + 96).toByte() } + val userInfoIv = ByteArray(16) { (it + 112).toByte() } + val user = ScaleUser(id = 1, gender = gender, bodyHeight = 175f, initialWeight = 80f) + val randomValues = ArrayDeque(listOf(randB, workKey, workKeyIv, userInfoIv)) + val handler = HuaweiHagridWspHandler(randomBytes = { randomValues.removeFirst() }) + val settings = InMemorySettings().apply { + putString(HuaweiHagridWspHandler.SETTINGS_KEY_CAK_HEX, cak.toHex()) + putString(HuaweiHagridWspHandler.SETTINGS_KEY_C1_HEX, c1.toHex()) + putString(HuaweiHagridWspHandler.SETTINGS_KEY_C2_HEX, c2.toHex()) + } + val transport = CapturingTransport.allHagrid() + handler.supportFor(device(deviceName, address = HAGRID_ADDRESS)) + handler.attach( + transport = transport, + callbacks = CapturingCallbacks(), + settings = settings, + data = FixedDataProvider(user), + scope = CoroutineScope(EmptyCoroutineContext), + ) + handler.handleConnected(user) + sendWspNotification(handler, CHR_REQUEST_AUTH, randA) + sendWspNotification( + handler, CHR_AUTH_TOKEN, + HuaweiHagridWspLib.expectedAuthResponsePayload(randA, randB, cak) + ) + sendWspNotification(handler, CHR_SEND_WORK_KEY, byteArrayOf(0x00)) + sendWspNotification(handler, CHR_GET_MANAGER_INFO, managerInfoPayload()) + + val encryptedPayload = transport.reassembleWritesTo(CHR_SET_USER_INFO) + val plain = HuaweiHagridWspLib.decryptPayload(encryptedPayload, workKey) + return plain[62].toInt() and 0xFF } private fun device( diff --git a/android_app/app/src/test/resources/scale_catalog_remarks.txt b/android_app/app/src/test/resources/scale_catalog_remarks.txt index 1818db21f..61c1f9695 100644 --- a/android_app/app/src/test/resources/scale_catalog_remarks.txt +++ b/android_app/app/src/test/resources/scale_catalog_remarks.txt @@ -17,3 +17,4 @@ Custom openScale = See [here](Custom-Bluetooth-Scale) for a tutorial Trisa Body Analyze 4.0 = See [Trisa Body Analyze](Trisa-Body-Analyze) for the protocol Debug = Not a scale: assign it to a device to record its GATT services for reverse engineering ProfiCare PC-PW 3008 BT = Same Chipsea "WeChat scale" firmware as the Hoffen BBS-8107, so it is driven by the same handler +HuaweiHagridWspHandler = The scale only answers after a Huawei Hagrid handshake, so CAK, C1 and C2 (32 hex characters each) have to be entered in the device settings first From 8a839ed550fb044ec9c46cf5c9d07ecb7f5bedf3 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Mon, 17 Aug 2026 10:42:51 +0200 Subject: [PATCH 45/72] Decouple developer mode from saved device identity - Introduce a standalone developer-mode setting in `SettingsFacade` that routes all Bluetooth connections to the diagnostic handler without modifying the saved device snapshot - Refactor `DebugGattHandler` to exclude itself from the handler registry, preventing name-based conflicts with actual device handlers - Add a confirmation dialog and a persistent UI banner to prevent accidental activation and clearly indicate when measurements are disabled - Add unit tests verifying that developer mode correctly overrides device routing and preserves the scale's original identity --- .../openscale/core/bluetooth/ScaleFactory.kt | 30 ++-- .../core/bluetooth/scales/DebugGattHandler.kt | 27 ++-- .../openscale/core/facade/BluetoothFacade.kt | 18 ++- .../openscale/core/facade/SettingsFacade.kt | 24 ++++ .../components/BluetoothActionButton.kt | 20 ++- .../screen/settings/BluetoothDetailScreen.kt | 22 +-- .../ui/screen/settings/BluetoothScreen.kt | 103 +++++++++++--- .../ui/screen/settings/BluetoothViewModel.kt | 5 + .../app/src/main/res/values/strings.xml | 5 + .../openscale/core/bluetooth/ScaleCatalog.kt | 2 - .../ScaleFactoryDeveloperModeTest.kt | 128 ++++++++++++++++++ .../test/resources/scale_catalog_remarks.txt | 1 - 12 files changed, 318 insertions(+), 67 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryDeveloperModeTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index b9dae6cc3..427e30305 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -92,6 +92,7 @@ import com.health.openscale.core.utils.LogManager import com.health.openscale.core.service.ScannedDeviceInfo import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -182,7 +183,6 @@ class ScaleFactory @Inject constructor( ESCS20MHandler(), RenphoES26BBHandler(), DigooDGSO38HHandler(), - DebugGattHandler(), CustomOpenScaleHandler(), BeurerSanitasHandler(), AAAxHandler(), @@ -192,6 +192,19 @@ class ScaleFactory @Inject constructor( ) } + /** + * Reads the current value of a settings [Flow] from a non-suspending context. + * + * Communicator creation happens on the caller's thread, so the few settings needed here are + * read with a short timeout rather than restructuring every call site; `null` means the value + * was unavailable in time and the caller falls back to its default. + */ + private fun readSettingBlocking(flow: Flow): T? = runCatching { + runBlocking(Dispatchers.IO) { + withTimeout(250.milliseconds) { flow.firstOrNull() } + } + }.getOrNull() + /** * Creates a [ScaleCommunicator] based on a modern [ScaleDeviceHandler]. * This method is conceptual for now, as the current DummyScaleHandlers are not full communicators. @@ -207,13 +220,7 @@ class ScaleFactory @Inject constructor( ): ScaleCommunicator? { // Resolve effective tuning: prefer user-saved value, fall back to handler default val effectiveTuning: TuningProfile = run { - val saved: String? = runCatching { - runBlocking(Dispatchers.IO) { - withTimeout(250.milliseconds) { - settingsFacade.savedBluetoothTuneProfile.firstOrNull() - } - } - }.getOrNull() + val saved: String? = readSettingBlocking(settingsFacade.savedBluetoothTuneProfile) saved?.let { runCatching { TuningProfile.valueOf(it) }.getOrNull() } ?: support.tuningProfile @@ -262,6 +269,13 @@ class ScaleFactory @Inject constructor( val primaryIdentifier = deviceInfo.name LogManager.d(TAG, "createCommunicator: Searching for communicator for '${primaryIdentifier}' (${deviceInfo.address}). Handler hint: '${deviceInfo.determinedHandlerDisplayName}'") + // 0. Developer mode wins over every registered handler: route to the diagnostic handler, + // which dumps the GATT tree and logs notifications but stores nothing. + 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) + } + // 1. Check if a modern Kotlin handler explicitly supports the device. for (handler in modernKotlinHandlers) { val support = handler.supportFor(deviceInfo) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DebugGattHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DebugGattHandler.kt index 51ac91d91..90c1d8676 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DebugGattHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DebugGattHandler.kt @@ -25,7 +25,8 @@ import java.util.UUID * * A pure **inspection** handler that: * - * - Only activates when the scanned device name equals `"Debug"` (case-insensitive). + * - 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 @@ -43,15 +44,13 @@ import java.util.UUID * the adapter stays minimal and unopinionated. */ class DebugGattHandler : ScaleDeviceHandler() { - /** - * Only claim the device if the UI/scan explicitly selected the **Debug** handler. - * This prevents us from hijacking real devices during normal operation. - */ - override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { - val isDebug = device.name.equals("debug", ignoreCase = true) - if (!isDebug) return null - - return DeviceSupport( + 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(), @@ -60,6 +59,14 @@ class DebugGattHandler : ScaleDeviceHandler() { ) } + /** + * 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. diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt index 8cc3d585a..4617fda41 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt @@ -227,15 +227,23 @@ class BluetoothFacadeImpl @Inject constructor( connectedDeviceAddress.value == dev.address if (already) return@launch + // Developer mode has to reach unsupported scales too – that is its whole point. + val developerMode = settingsFacade.developerModeEnabled.first() val (supported, handlerName) = scaleFactory.getSupportingHandlerInfo(dev) - if (!supported) { + if (!supported && !developerMode) { LogManager.w(TAG, "Saved device '${dev.name}' is not recognized as supported anymore.") - return@launch } - dev.isSupported = true - dev.determinedHandlerDisplayName = handlerName - connection.connectToDevice(dev) + // Work on a copy: `dev` is the instance the saved-device flow hands to the UI, and + // mutating it here used to overwrite the scale's identity (issue #1478). + // An unsupported device is handed over as such so the connector reports it to the user + // instead of the tap silently doing nothing. + val target = dev.copy( + isSupported = supported || developerMode, + determinedHandlerDisplayName = handlerName ?: dev.determinedHandlerDisplayName + ) + + connection.connectToDevice(target) } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt index 8560b77b2..5a443d0c2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt @@ -92,6 +92,7 @@ object SettingsPreferenceKeys { val SAVED_BLUETOOTH_TOLERANCE_PERCENT = intPreferencesKey("saved_bluetooth_tolerance_percent") val SAVED_BLUETOOTH_IGNORE_OUTSIDE_TOLERANCE = booleanPreferencesKey("saved_bluetooth_ignore_outside_tolerance") val SAVED_BLUETOOTH_AUTO_CONNECT = booleanPreferencesKey("saved_bluetooth_auto_connect") + val SAVED_BLUETOOTH_DEVELOPER_MODE = booleanPreferencesKey("saved_bluetooth_developer_mode") // Settings for chart val CHART_SHOW_DATA_POINTS = booleanPreferencesKey("chart_show_data_points") @@ -288,6 +289,17 @@ interface SettingsFacade { val autoConnectOnStartup: Flow suspend fun setAutoConnectOnStartup(enabled: Boolean) + /** + * Developer mode for the saved scale: every connection is routed to the diagnostic handler, + * which dumps the GATT tree and logs notifications but never stores a measurement. + * + * Deliberately a flag of its own instead of a marker inside the saved device snapshot, so the + * device keeps its real identity (name, handler hint, advertisement payloads) while the mode + * is on and toggling it stays lossless. + */ + val developerModeEnabled: Flow + suspend fun setDeveloperModeEnabled(enabled: Boolean) + // Generic Settings Accessors /** * Observes a setting with the given key name and default value. @@ -572,6 +584,8 @@ class SettingsFacadeImpl @Inject constructor( prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA) prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_DATA) prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_AUTO_CONNECT) + // Removing the scale always leaves developer mode behind as well. + prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVELOPER_MODE) } } @@ -620,6 +634,16 @@ class SettingsFacadeImpl @Inject constructor( saveSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_AUTO_CONNECT.name, enabled) } + override val developerModeEnabled: Flow = observeSetting( + SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVELOPER_MODE.name, + false + ) + + override suspend fun setDeveloperModeEnabled(enabled: Boolean) { + LogManager.i(TAG, "Bluetooth developer mode ${if (enabled) "enabled" else "disabled"}.") + saveSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVELOPER_MODE.name, enabled) + } + override val isSmartAssignmentEnabled: Flow = observeSetting( SettingsPreferenceKeys.SAVED_BLUETOOTH_SMART_ASSIGNMENT_ENABLED.name, false diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/BluetoothActionButton.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/BluetoothActionButton.kt index 7e5b1c79b..2d3df8c5f 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/BluetoothActionButton.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/BluetoothActionButton.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.BluetoothSearching import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.BluetoothConnected import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -36,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.health.openscale.R import com.health.openscale.core.data.ConnectionStatus @@ -80,8 +82,9 @@ fun rememberBluetoothActionButton( val savedDevice by bluetoothViewModel.savedDevice.collectAsState() val connectedDevice by bluetoothViewModel.connectedDeviceAddress.collectAsState() val currentUser by sharedViewModel.selectedUser.collectAsState() + val developerMode by bluetoothViewModel.developerModeEnabled.collectAsStateWithLifecycle(false) - return remember(connStatus, savedDevice, connectedDevice, currentUser) { + return remember(connStatus, savedDevice, connectedDevice, currentUser, developerMode) { val savedAddr = savedDevice?.address val deviceName = savedDevice?.name ?: context.getString(R.string.fallback_device_name_saved_scale) @@ -124,8 +127,12 @@ fun rememberBluetoothActionButton( // 3. Already connected -> Disconnect on click connStatus == ConnectionStatus.CONNECTED -> TopBarAction( - icon = Icons.Filled.BluetoothConnected, - contentDescription = context.getString(R.string.bluetooth_action_disconnect_desc, deviceName), + // Developer mode never stores a measurement; show it where the user weighs. + icon = if (developerMode) Icons.Default.BugReport else Icons.Filled.BluetoothConnected, + contentDescription = if (developerMode) + context.getString(R.string.developer_mode_active_icon_desc) + else + context.getString(R.string.bluetooth_action_disconnect_desc, deviceName), onClick = { sharedViewModel.setPendingReferenceUserForBle(null) bluetoothViewModel.disconnectDevice() @@ -138,8 +145,11 @@ fun rememberBluetoothActionButton( // 4. Disconnected -> Request permissions/enable BT/connect else -> TopBarAction( - icon = Icons.Filled.BluetoothDisabled, - contentDescription = context.getString(R.string.bluetooth_action_disconnect_desc, deviceName), + icon = if (developerMode) Icons.Default.BugReport else Icons.Filled.BluetoothDisabled, + contentDescription = if (developerMode) + context.getString(R.string.developer_mode_active_icon_desc) + else + context.getString(R.string.bluetooth_action_disconnect_desc, deviceName), onClick = { // Check for BOTH permissions (Scan and Connect) val hasScanPerm = ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt index 5d74224ef..fcc3b4706 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt @@ -73,7 +73,6 @@ import com.health.openscale.core.bluetooth.scales.TuningProfile import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.data.UnitType -import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.ui.screen.dialog.NumberInputDialog import com.health.openscale.ui.shared.SharedViewModel import kotlinx.coroutines.launch @@ -95,7 +94,7 @@ fun BluetoothDetailScreen( val savedDevice by bluetoothViewModel.savedDevice.collectAsStateWithLifecycle() val savedSupport by bluetoothViewModel.savedDeviceSupport.collectAsStateWithLifecycle() val currentTuningProfile = savedSupport?.tuningProfile ?: TuningProfile.Balanced - val isDeveloperActive = (savedDevice?.name.orEmpty() == "Debug") + val isDeveloperActive by bluetoothViewModel.developerModeEnabled.collectAsStateWithLifecycle(false) // --- Observe Smart Assignment Settings --- val isSmartAssignmentEnabled by bluetoothViewModel.isSmartAssignmentEnabled.collectAsStateWithLifecycle(false) @@ -255,22 +254,9 @@ fun BluetoothDetailScreen( SettingsRow( label = stringResource(R.string.bluetooth_developer_mode), icon = Icons.Default.BugReport, - onClick = { - val currentDevice = savedDevice - if (currentDevice != null) { - if (isDeveloperActive) { - // Deactivating Debug Mode: Restore original name and handler. - bluetoothViewModel.saveDeviceAsPreferred( - ScannedDeviceInfo(name = currentDevice.determinedHandlerDisplayName!!, address = currentDevice.address, rssi = 0, serviceUuids = currentDevice.serviceUuids, manufacturerData = currentDevice.manufacturerData, isSupported = true, determinedHandlerDisplayName = currentDevice.determinedHandlerDisplayName) - ) - } else { - // Activating Debug Mode: Change name and handler, but keep the address. - bluetoothViewModel.saveDeviceAsPreferred( - ScannedDeviceInfo(name = "Debug", address = currentDevice.address, rssi = 0, serviceUuids = currentDevice.serviceUuids, manufacturerData = currentDevice.manufacturerData, isSupported = true, determinedHandlerDisplayName = currentDevice.name) - ) - } - } - } + // Toggling only flips the setting – the saved device snapshot stays untouched, + // so switching back and forth can never lose the scale's identity. + onClick = { bluetoothViewModel.setDeveloperMode(!isDeveloperActive) } ) { Switch( checked = isDeveloperActive, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt index fc7663d39..4e13c3dce 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.BluetoothSearching import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Info @@ -90,6 +91,7 @@ import com.health.openscale.core.utils.LogManager import com.health.openscale.ui.shared.SharedViewModel import kotlinx.coroutines.launch import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.health.openscale.ui.navigation.Routes @@ -124,12 +126,14 @@ fun BluetoothScreen( val scanError by bluetoothViewModel.scanError.collectAsState() val connectionError by bluetoothViewModel.connectionError.collectAsState() val savedSupport by bluetoothViewModel.savedDeviceSupport.collectAsState() + val developerModeEnabled by bluetoothViewModel.developerModeEnabled.collectAsStateWithLifecycle(false) // Local UI state var hasPermissions by remember { mutableStateOf(false) } var pendingScan by remember { mutableStateOf(false) } var showCompatibilityDialog by remember { mutableStateOf(false) } var deviceToSave by remember { mutableStateOf(null) } + var deviceToDebug by remember { mutableStateOf(null) } LaunchedEffect(Unit) { hasPermissions = hasBtPermissions(context) @@ -272,6 +276,34 @@ fun BluetoothScreen( } } + // Developer mode silently suppresses every measurement, so say so here and + // offer the way back on the spot (issue #1478). + if (developerModeEnabled) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.BugReport, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.developer_banner_active), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { bluetoothViewModel.setDeveloperMode(false) }) { + Text(stringResource(R.string.developer_mode_disable_action)) + } + } + } + // Read-only DeviceSupport details (compact icons) savedSupport?.let { support -> CapabilityIconsRow( @@ -353,6 +385,25 @@ fun BluetoothScreen( ) } + deviceToDebug?.let { device -> + DeveloperModeAlertDialog( + deviceName = device.name.ifEmpty { stringResource(R.string.unknown_device) }, + onConfirm = { + // The device is stored with its real identity; only the setting marks the + // developer session, so turning it off later restores normal operation. + bluetoothViewModel.saveDeviceAsPreferred(device.copy(isSupported = true)) + bluetoothViewModel.setDeveloperMode(true) + scope.launch { + sharedViewModel.showSnackbar( + message = resources.getString(R.string.snackbar_developer_for_device_enable_logs), + duration = SnackbarDuration.Long + ) + } + }, + onDismiss = { deviceToDebug = null } + ) + } + // Combined scan/connection error presentation if (hasPermissions && btEnabled) { val errorToShow = connectionError ?: scanError @@ -395,24 +446,7 @@ fun BluetoothScreen( }, onSaveDebug = { bluetoothViewModel.requestStopDeviceScan() - // Save same MAC but with name "Debug" to force Debug handler on connect - bluetoothViewModel.saveDeviceAsPreferred( - ScannedDeviceInfo( - name = "Debug", - address = device.address, - rssi = 0, - serviceUuids = device.serviceUuids, - manufacturerData = device.manufacturerData, - isSupported = true, - determinedHandlerDisplayName = device.name - ) - ) - scope.launch { - sharedViewModel.showSnackbar( - message = resources.getString(R.string.snackbar_developer_for_device_enable_logs), - duration = SnackbarDuration.Long - ) - } + deviceToDebug = device } ) } @@ -507,6 +541,39 @@ private fun CompatibilityAlertDialog( ) } +/** + * Warns before a scan result is put into developer mode: the diagnostic handler only logs the GATT + * traffic, so no measurement is stored until the mode is switched off again. + */ +@Composable +private fun DeveloperModeAlertDialog( + deviceName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.BugReport, contentDescription = null) }, + title = { Text(stringResource(R.string.developer_mode_dialog_title)) }, + text = { Text(stringResource(R.string.developer_mode_dialog_message, deviceName)) }, + confirmButton = { + TextButton( + onClick = { + onConfirm() + onDismiss() + } + ) { + Text(stringResource(R.string.developer_mode_dialog_confirm_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel_button)) + } + } + ) +} + /** * Compact, icon-only capability row: * - Implemented features: normal tint (onSurface) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt index 9c45a36b7..2b30e72d3 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt @@ -73,6 +73,7 @@ class BluetoothViewModel @Inject constructor( val smartAssignmentTolerancePercent = settingsFacade.smartAssignmentTolerancePercent val smartAssignmentIgnoreOutsideTolerance = settingsFacade.smartAssignmentIgnoreOutsideTolerance val autoConnectOnStartup = settingsFacade.autoConnectOnStartup + val developerModeEnabled = settingsFacade.developerModeEnabled fun setSmartAssignmentEnabled(enabled: Boolean) = viewModelScope.launch { settingsFacade.setSmartAssignmentEnabled(enabled) @@ -89,6 +90,10 @@ class BluetoothViewModel @Inject constructor( fun setAutoConnectOnStartup(enabled: Boolean) = viewModelScope.launch { settingsFacade.setAutoConnectOnStartup(enabled) } + + fun setDeveloperMode(enabled: Boolean) = viewModelScope.launch { + settingsFacade.setDeveloperModeEnabled(enabled) + } // --- Snackbar events for UI --- private val _snackbarEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val snackbarEvents: SharedFlow = _snackbarEvents.asSharedFlow() diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 4af9492e7..d08b14bfe 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -355,6 +355,11 @@ Developer mode enabled for this device. Please enable log file: Settings → General → Enable log file. Developer mode is active — no measurements will be saved. Please enable log file: Settings → General → Enable log file. + Enable developer mode? + openScale will only log the Bluetooth traffic of %1$s for diagnostics. As long as developer mode is active, no measurement is saved. You can switch it off again on the Bluetooth screen or in the device settings. + Enable + Disable + Developer mode is active Capabilities Body composition Time sync diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 88992f4f0..692ad7757 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -25,7 +25,6 @@ import com.health.openscale.core.bluetooth.scales.BeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.BodyConnectHandler import com.health.openscale.core.bluetooth.scales.CultSmartScaleProHandler import com.health.openscale.core.bluetooth.scales.CustomOpenScaleHandler -import com.health.openscale.core.bluetooth.scales.DebugGattHandler import com.health.openscale.core.bluetooth.scales.DeviceCapability import com.health.openscale.core.bluetooth.scales.DigooDGSO38HHandler import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler @@ -221,7 +220,6 @@ object ScaleCatalog { device("ES-CS20M") claimedBy ESCS20MHandler::class.java, device("ES-26BB-B") claimedBy RenphoES26BBHandler::class.java, device("Mengii") claimedBy DigooDGSO38HHandler::class.java, - device("debug") claimedBy DebugGattHandler::class.java, device("openScale") claimedBy CustomOpenScaleHandler::class.java, device("BEURER BF700") claimedBy BeurerSanitasHandler::class.java, device("BEURER BF710") claimedBy BeurerSanitasHandler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryDeveloperModeTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryDeveloperModeTest.kt new file mode 100644 index 000000000..1fd4fda91 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryDeveloperModeTest.kt @@ -0,0 +1,128 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.scales.DebugGattHandler +import com.health.openscale.core.facade.SettingsFacade +import com.health.openscale.testutil.RoomTestSupport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * Developer mode used to be encoded by renaming the saved device to `"Debug"`, which let the + * diagnostic handler win by name match and destroyed the scale's identity in the process + * (issue #1478). It is a setting of its own now, and these tests pin the routing that replaces it: + * the registry decides while the mode is off, the diagnostic handler wins while it is on — for + * known and unknown scales alike — and the device name is never consulted. + * + * A broadcast-only scale makes the override observable without reaching into the adapter: its + * normal route is a [com.health.openscale.core.bluetooth.scales.BroadcastScaleAdapter], while the + * GATT-based diagnostic handler yields a + * [com.health.openscale.core.bluetooth.scales.GattScaleAdapter]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ScaleFactoryDeveloperModeTest { + + private val app: Application get() = ApplicationProvider.getApplicationContext() + + /** Broadcast-only scale (see `EufyC20Handler`), used to tell the two routes apart. */ + private val BROADCAST_SCALE = "EUFY C20" + + /** Name-matched GATT scale — the one from issue #1478. */ + private val GATT_SCALE = "SANITAS SBF70" + + /** A factory wired to an isolated DataStore, so the developer-mode flag can be flipped per test. */ + private fun factoryWithSettings(): Pair { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val settings = RoomTestSupport.settingsFacadeFor( + scope, + File(app.cacheDir, "developer-mode-${System.nanoTime()}.preferences_pb"), + ) + val repo = RoomTestSupport.repositoryFor(RoomTestSupport.inMemory(app)) + val facades = RoomTestSupport.facadesFor(app, repo, settings) + + return ScaleFactory(app, settings, facades.measurementFacade, facades.userFacade) to settings + } + + private fun ScaleFactory.adapterFor(name: String): String? = + createCommunicator(ScaleCatalog.device(name))?.javaClass?.simpleName + + @Test + fun `developer mode off keeps the registry in charge`() { + val (factory, _) = factoryWithSettings() + + assertThat(factory.adapterFor(GATT_SCALE)).isEqualTo("GattScaleAdapter") + assertThat(factory.adapterFor(BROADCAST_SCALE)).isEqualTo("BroadcastScaleAdapter") + } + + @Test + fun `developer mode routes every scale to the diagnostic handler`() = runTest { + val (factory, settings) = factoryWithSettings() + settings.setDeveloperModeEnabled(true) + + // The broadcast scale switching adapters is the proof that the override, not the registry, + // picked the handler. + assertThat(factory.adapterFor(BROADCAST_SCALE)).isEqualTo("GattScaleAdapter") + assertThat(factory.adapterFor(GATT_SCALE)).isEqualTo("GattScaleAdapter") + } + + @Test + fun `developer mode leaves the reported driver of the saved scale untouched`() = runTest { + val (factory, settings) = factoryWithSettings() + val before = factory.getDeviceSupportFor(GATT_SCALE, "00:11:22:33:44:55") + + settings.setDeveloperModeEnabled(true) + + assertThat(before?.displayName).isEqualTo("Sanitas SBF70 / SilverCrest SBF75 / Crane") + assertThat(factory.getDeviceSupportFor(GATT_SCALE, "00:11:22:33:44:55")?.displayName) + .isEqualTo(before?.displayName) + } + + @Test + fun `developer mode also reaches a scale no handler supports`() = runTest { + val (factory, settings) = factoryWithSettings() + val unknown = "NoSuchScale-XYZ" + assertThat(factory.adapterFor(unknown)).isNull() + + settings.setDeveloperModeEnabled(true) + + assertThat(factory.adapterFor(unknown)).isEqualTo("GattScaleAdapter") + } + + @Test + fun `the diagnostic handler never claims a device by name`() { + val debug = DebugGattHandler() + + assertThat(debug.supportFor(ScaleCatalog.device("Debug"))).isNull() + assertThat(debug.supportFor(ScaleCatalog.device("debug"))).isNull() + // …and it is no longer part of the ordered registry either. + assertThat(ScaleFactory.createHandlers().map { it.javaClass.simpleName }) + .doesNotContain(DebugGattHandler::class.java.simpleName) + } +} diff --git a/android_app/app/src/test/resources/scale_catalog_remarks.txt b/android_app/app/src/test/resources/scale_catalog_remarks.txt index 61c1f9695..116f9362b 100644 --- a/android_app/app/src/test/resources/scale_catalog_remarks.txt +++ b/android_app/app/src/test/resources/scale_catalog_remarks.txt @@ -15,6 +15,5 @@ Digoo DG-SO38H (Mengii) = Initialisation process needs to be reverse engineered. ES-CS20M / ES-32MD = Uses the Trisa Body Analyze body measurement library until the original [QN scale library](https://github.com/oliexdev/openScale/issues/373) is fully reverse engineered Custom openScale = See [here](Custom-Bluetooth-Scale) for a tutorial Trisa Body Analyze 4.0 = See [Trisa Body Analyze](Trisa-Body-Analyze) for the protocol -Debug = Not a scale: assign it to a device to record its GATT services for reverse engineering ProfiCare PC-PW 3008 BT = Same Chipsea "WeChat scale" firmware as the Hoffen BBS-8107, so it is driven by the same handler HuaweiHagridWspHandler = The scale only answers after a Huawei Hagrid handshake, so CAK, C1 and C2 (32 hex characters each) have to be entered in the device settings first From 286cd11475e72e10d9571a976bc1a4818e2c240e Mon Sep 17 00:00:00 2001 From: oliexdev Date: Mon, 17 Aug 2026 10:53:37 +0200 Subject: [PATCH 46/72] Unify developer mode indicator with tint and device swap warning - Use icon tint to signal developer mode instead of swapping the icon, keeping the connection state visible in the top bar - Append a developer-mode note to the icon's content description so screen readers still convey the active state - Warn in the developer-mode dialog when selecting a different scale will replace the currently saved device - Replace the generic warning icon in Bluetooth settings with the bug report icon to match the top bar indicator --- .../openscale/ui/navigation/AppNavigation.kt | 6 +++- .../components/BluetoothActionButton.kt | 32 ++++++++++++------- .../screen/settings/BluetoothDetailScreen.kt | 5 +-- .../ui/screen/settings/BluetoothScreen.kt | 18 ++++++++++- .../openscale/ui/shared/TopBarAction.kt | 6 ++++ .../app/src/main/res/values/strings.xml | 1 + 6 files changed, 52 insertions(+), 16 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt index ce37f03b8..91317ea67 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt @@ -367,7 +367,11 @@ fun AppNavigation(sharedViewModel: SharedViewModel) { val contentDesc = action.contentDescriptionResId?.let { stringResource(id = it) } ?: action.contentDescription IconButton(onClick = action.onClick) { - Icon(imageVector = action.icon, contentDescription = contentDesc) + Icon( + imageVector = action.icon, + contentDescription = contentDesc, + tint = action.tint ?: LocalContentColor.current + ) } // If the action has associated dropdown content, invoke it here. // This allows TopAppBar actions to also host DropdownMenus. diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/BluetoothActionButton.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/BluetoothActionButton.kt index 2d3df8c5f..e29f8179c 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/BluetoothActionButton.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/BluetoothActionButton.kt @@ -29,7 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.BluetoothSearching import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.BluetoothConnected import androidx.compose.material.icons.filled.BluetoothDisabled -import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -84,6 +84,15 @@ fun rememberBluetoothActionButton( val currentUser by sharedViewModel.selectedUser.collectAsState() val developerMode by bluetoothViewModel.developerModeEnabled.collectAsStateWithLifecycle(false) + // Developer mode never stores a measurement, so mark the icon where the user actually weighs – + // as a tint, so the connection state stays readable from the icon itself. + val developerModeTint = MaterialTheme.colorScheme.error.takeIf { developerMode } + + /** Keeps the action's own description and only appends the developer-mode note for screen readers. */ + fun withDeveloperNote(description: String): String = + if (developerMode) "$description – ${context.getString(R.string.developer_mode_active_icon_desc)}" + else description + return remember(connStatus, savedDevice, connectedDevice, currentUser, developerMode) { val savedAddr = savedDevice?.address val deviceName = savedDevice?.name ?: context.getString(R.string.fallback_device_name_saved_scale) @@ -127,12 +136,11 @@ fun rememberBluetoothActionButton( // 3. Already connected -> Disconnect on click connStatus == ConnectionStatus.CONNECTED -> TopBarAction( - // Developer mode never stores a measurement; show it where the user weighs. - icon = if (developerMode) Icons.Default.BugReport else Icons.Filled.BluetoothConnected, - contentDescription = if (developerMode) - context.getString(R.string.developer_mode_active_icon_desc) - else - context.getString(R.string.bluetooth_action_disconnect_desc, deviceName), + icon = Icons.Filled.BluetoothConnected, + tint = developerModeTint, + contentDescription = withDeveloperNote( + context.getString(R.string.bluetooth_action_disconnect_desc, deviceName) + ), onClick = { sharedViewModel.setPendingReferenceUserForBle(null) bluetoothViewModel.disconnectDevice() @@ -145,11 +153,11 @@ fun rememberBluetoothActionButton( // 4. Disconnected -> Request permissions/enable BT/connect else -> TopBarAction( - icon = if (developerMode) Icons.Default.BugReport else Icons.Filled.BluetoothDisabled, - contentDescription = if (developerMode) - context.getString(R.string.developer_mode_active_icon_desc) - else - context.getString(R.string.bluetooth_action_disconnect_desc, deviceName), + icon = Icons.Filled.BluetoothDisabled, + tint = developerModeTint, + contentDescription = withDeveloperNote( + context.getString(R.string.bluetooth_action_disconnect_desc, deviceName) + ), onClick = { // Check for BOTH permissions (Scan and Connect) val hasScanPerm = ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt index fcc3b4706..a9a40e7a9 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.icons.filled.BluetoothConnected import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -271,7 +270,9 @@ fun BluetoothDetailScreen( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Default.Warning, + // Same marker as the Bluetooth screen banner and the top-bar icon, + // so the active mode is recognisable everywhere it shows up. + imageVector = Icons.Default.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.padding(end = 16.dp) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt index 4e13c3dce..1e86b0d3a 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt @@ -388,6 +388,9 @@ fun BluetoothScreen( deviceToDebug?.let { device -> DeveloperModeAlertDialog( deviceName = device.name.ifEmpty { stringResource(R.string.unknown_device) }, + replacedDeviceName = savedDevice + ?.takeIf { it.address != device.address } + ?.let { it.name.ifEmpty { stringResource(R.string.unknown_device) } }, onConfirm = { // The device is stored with its real identity; only the setting marks the // developer session, so turning it off later restores normal operation. @@ -548,6 +551,7 @@ private fun CompatibilityAlertDialog( @Composable private fun DeveloperModeAlertDialog( deviceName: String, + replacedDeviceName: String?, onConfirm: () -> Unit, onDismiss: () -> Unit ) { @@ -555,7 +559,19 @@ private fun DeveloperModeAlertDialog( onDismissRequest = onDismiss, icon = { Icon(Icons.Default.BugReport, contentDescription = null) }, title = { Text(stringResource(R.string.developer_mode_dialog_title)) }, - text = { Text(stringResource(R.string.developer_mode_dialog_message, deviceName)) }, + text = { + Column { + Text(stringResource(R.string.developer_mode_dialog_message, deviceName)) + // Picking a different scale here silently swapped the saved one before. + replacedDeviceName?.let { replaced -> + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.developer_mode_dialog_replaces, deviceName, replaced), + color = MaterialTheme.colorScheme.error + ) + } + } + }, confirmButton = { TextButton( onClick = { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/TopBarAction.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/TopBarAction.kt index ac19016e7..eafc74c69 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/TopBarAction.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/TopBarAction.kt @@ -19,12 +19,18 @@ package com.health.openscale.ui.shared import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +/** + * @property tint Overrides the default icon color; use it to flag a state that must stay visible + * without giving up the icon itself (e.g. Bluetooth developer mode). `null` keeps the theme color. + */ data class TopBarAction( val icon: ImageVector, val onClick: () -> Unit, @param:StringRes val contentDescriptionResId: Int? = null, val contentDescription: String? = null, + val tint: Color? = null, val dropdownContent: (@Composable () -> Unit)? = null ) diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index d08b14bfe..078a1ab97 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -357,6 +357,7 @@ Please enable log file: Settings → General → Enable log file. Enable developer mode? openScale will only log the Bluetooth traffic of %1$s for diagnostics. As long as developer mode is active, no measurement is saved. You can switch it off again on the Bluetooth screen or in the device settings. + %1$s becomes your saved scale and replaces %2$s. Enable Disable Developer mode is active From f7a733b2cb6e8ccd751c3051232f3195c84dd8c4 Mon Sep 17 00:00:00 2001 From: Pierre Jestin <42845796+pjestin@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:49:06 +0200 Subject: [PATCH 47/72] Improvements to the X-LINE (#1480) --- .../bluetooth/scales/BodyConnectHandler.kt | 321 +++++++-- .../scales/BodyConnectHandlerTest.kt | 674 ++++++++++++++++++ 2 files changed, 939 insertions(+), 56 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt index 5cf922dbd..88a932ed7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt @@ -18,11 +18,14 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.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.data.ActivityLevel import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.utils.ConverterUtils +import java.nio.charset.StandardCharsets import java.util.Date import java.util.UUID import kotlin.random.Random @@ -30,7 +33,7 @@ import kotlin.random.Random /** * BodyConnectHandler * ------------------ - * Modern Kotlin handler for the **1BODY CONNECT** smart scale (Transtek family). + * Modern Kotlin handler for the **1BODY CONNECT** and X-LINE smart scales (Transtek family). * * Protocol highlights (per BTSnoop analysis): * - GATT Service: 0x7892 @@ -47,7 +50,7 @@ import kotlin.random.Random * * Host→device opcodes: * - 0x02 = Set Time (UTC timestamp as seconds since 2010-01-01) - * - 0x03 = Slot Ack (echo of a slot status record) + * - 0x03 = Add User (Register a user name into a slot) * - 0x20 = Challenge Resp (challenge XOR password) * - 0x21 = Broadcast ID (sent during pairing) * - 0x22 = Enable Disconnect @@ -59,12 +62,13 @@ class BodyConnectHandler : ScaleDeviceHandler() { override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { val name = device.name - if (!name.startsWith("1BODY CONNECT") && !name.startsWith("0BODY CONNECT") && !name.startsWith("0X-LINE") && !name.startsWith("1X-LINE")) return null + val prefixes = listOf("1BODY CONNECT", "0BODY CONNECT", "0X-LINE", "1X-LINE") + if (!prefixes.any { name.startsWith(it) }) return null val displayName = if (name.contains("BODY CONNECT")) "1BODY CONNECT" else "1X-LINE" return DeviceSupport( displayName = displayName, capabilities = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC, DeviceCapability.USER_SYNC, DeviceCapability.HISTORY_READ), - implemented = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC, DeviceCapability.HISTORY_READ), + implemented = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC, DeviceCapability.USER_SYNC, DeviceCapability.HISTORY_READ), linkMode = LinkMode.CONNECT_GATT ) } @@ -86,12 +90,16 @@ class BodyConnectHandler : ScaleDeviceHandler() { // --- Download (host → device) opcodes -------------------------------------- - private val CMD_ACK: Byte = 0x03 + private val CMD_ADD_USER: Byte = 0x03 private val CMD_TIME: Byte = 0x02 private val CMD_CHALLENGE_RESPONSE: Byte = 0x20 private val CMD_BROADCAST: Byte = 0x21 private val CMD_ENABLE_DISCONNECT: Byte = 0x22 + private val OP_WRITE_PROFILE: Byte = 0x51 + private val OP_BODY: Byte = 0x7F + private val OP_WEIGHT: Byte = 0x1F + // Non-zero broadcast ID required for pairing to succeed; generated randomly per device instance private val BROADCAST_ID = Random.nextInt(Int.MAX_VALUE - 1) + 1 @@ -100,11 +108,28 @@ class BodyConnectHandler : ScaleDeviceHandler() { // The X-LINE returns a timestamp in seconds since 2020-01-01 only in the body composition response private val ALT_TS_OFFSET = 315619200 + private val KEY_PASSWORD = "bodyconnect/password" + private val KEY_SLOT = "bodyconnect/slot" + // --- Pairing state --------------------------------------------------------- private var pairing = false private var password: Int? = null - private var statusAcked = false + private var slotAcked = false + + // Slots the scale reported as empty, collected across the whole 0x83 stream. + private val freeSlots = mutableSetOf() + + // Slot number to the name stored on the scale, collected across the whole 0x83 stream. + private val slotNames = mutableMapOf() + + // Non-null while a slot-choice dialog is open, holding the user the answer applies to. + private var pendingUser: ScaleUser? = null + + private val LAST_SLOT = 8 + private val DEFAULT_SLOT = 1 + private val NAME_LENGTH = 16 + private val SPACE: Byte = 0x20 // --- Frame matching -------------------------------------------------------- // 0x1F and 0x7F frames share a device timestamp; we cache the weight from @@ -119,49 +144,83 @@ class BodyConnectHandler : ScaleDeviceHandler() { setNotifyOn(SVC, CHR_WEIGHT) setNotifyOn(SVC, CHR_BODY) setNotifyOn(SVC, CHR_UPLD) + logD("Connected to device") // Restore password persisted from a previous pairing session - password = settingsGetInt("bodyconnect/password", -1).takeIf { it != -1 } + password = settingsGetInt(KEY_PASSWORD, -1).takeIf { it != -1 } pairing = false - statusAcked = false + slotAcked = false + lastTS = null + lastWeight = null + pendingUser = null + freeSlots.clear() + slotNames.clear() } override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { when (characteristic) { - CHR_UPLD -> onUpload(data) + CHR_UPLD -> onUpload(data) CHR_WEIGHT -> onWeight(data) CHR_BODY -> onBody(data, user) - else -> logW("Unknown characteristic: $characteristic") + else -> logW("Unknown characteristic: $characteristic") } } + override suspend fun onUserInteractionFeedback( + interactionType: UserInteractionType, + appUserId: Int, + feedbackData: Any + ) { + if (interactionType != UserInteractionType.CHOOSE_USER) return + val slot = (feedbackData as? Int)?.takeIf { it in 1..LAST_SLOT } ?: run { + logW("Ignoring CHOOSE_USER feedback $feedbackData") + return + } + val user = pendingUser ?: currentAppUser() + pendingUser = null + logD("User chose slot $slot") + registerIntoSlot(slot, user) + } + + // --- Upload (device → host) processing ------------------------------------- private fun onUpload(data: ByteArray) { if (data.isEmpty()) return - when (data[0]) { - CMD_PASSWORD -> onPassword(data) - CMD_CHALLENGE -> onChallenge(data) + logD("onUpload (${data.size} bytes)") + val command = data[0] + when (command) { + CMD_PASSWORD -> onPassword(data) + CMD_CHALLENGE -> onChallenge(data) CMD_PROFILE_ECHO -> onProfileEcho() - CMD_SLOT_STATUS -> onSlotStatus(data) + CMD_SLOT_STATUS -> onSlotStatus(data) } } private fun onPassword(data: ByteArray) { - if (data.size < 5) return + if (data.size < 5) { + logE("Password data too short (${data.size} bytes)") + return + } val pw = ConverterUtils.fromSignedInt32Le(data, 1) password = pw - settingsPutInt("bodyconnect/password", pw) + settingsPutInt(KEY_PASSWORD, pw) + logD("Password received") userInfo(R.string.bluetooth_scale_trisa_success_pairing) pairing = true // Broadcast ID must be set before the scale accepts further commands + logD("Sending broadcast ID") writeCommand(CMD_BROADCAST, BROADCAST_ID) } private fun onChallenge(data: ByteArray) { - if (data.size < 5) return + if (data.size < 5) { + logW("Challenge data too short: ${data.size} bytes") + return + } val pw = password ?: run { + logW("No password available for challenge response") userWarn(R.string.bluetooth_scale_trisa_message_not_paired_instruction) requestDisconnect() return @@ -170,8 +229,8 @@ class BodyConnectHandler : ScaleDeviceHandler() { writeCommand(CMD_CHALLENGE_RESPONSE, challenge xor pw) if (!pairing) { - // Already paired: send profile + time directly (scale skips slot negotiation) - writeProfile(currentAppUser()) + logD("Already paired: send profile + time directly (scale skips slot negotiation)") + writeProfile(settingsGetInt(KEY_SLOT, DEFAULT_SLOT), currentAppUser()) writeCommand(CMD_TIME, javaTimeToDevice(System.currentTimeMillis())) } } @@ -182,30 +241,52 @@ class BodyConnectHandler : ScaleDeviceHandler() { } private fun onSlotStatus(data: ByteArray) { - // Scale lists its 8 user slots (first non‑empty one must be acknowledged) - if (data.size < 3 || statusAcked) return - val hasName = (2 until data.size).any { i -> + // Scale lists its 8 user slots + if (data.size < 18) { + logW("Slot status data too short: ${data.size} bytes") + return + } + val slot = data[1].toInt() and 0xFF + + val occupied = (2 until data.size).any { i -> val b = data[i].toInt() and 0xFF b != 0x00 && b != 0x20 } - if (!hasName) return - statusAcked = true - - // Echo the slot record with opcode 0x03 to acknowledge it - val ack = data.copyOf().also { it[0] = CMD_ACK } - writeTo(SVC, CHR_DNLD, ack, withResponse = true) + if (!occupied) freeSlots.add(slot) + slotNames[slot] = parseSlotName(data) + logD("Slot $slot ${if (occupied) "occupied" else "free"}") - // Now send profile, time, and signal setup complete + if (slot != LAST_SLOT || slotAcked) return + slotAcked = true val user = currentAppUser() - writeProfile(user) - writeCommand(CMD_TIME, javaTimeToDevice(System.currentTimeMillis())) - writeCommand(CMD_ENABLE_DISCONNECT) + + // A slot chosen in an earlier session is reused without asking again. + val stored = settingsGetInt(KEY_SLOT, -1) + if (stored in 1..LAST_SLOT) { + logD("Reusing stored slot $stored") + registerIntoSlot(stored, user) + return + } + + // First pairing: ask which scale user this is. Guessing is wrong too often. A brand new + // scale has eight empty slots and nothing to match on, and a second-hand one may be full + // of a previous owner's profiles that the user legitimately wants to take over. + pendingUser = user + logD("Presenting slot choice dialog") + presentSlotChoice(user) } // --- Frame parsing --------------------------------------------------------- private fun onWeight(data: ByteArray) { - if (data.size < 20 || data[0] != 0x1F.toByte()) return + if (data.size < 20) { + logW("Weight data too short: ${data.size} bytes") + return + } + if (data[0] != OP_WEIGHT.toByte()) { + logW("Weight frame has wrong opcode") + return + } // 0x1F frame layout: // off 0: opcode 0x1F @@ -216,7 +297,14 @@ class BodyConnectHandler : ScaleDeviceHandler() { } private fun onBody(data: ByteArray, user: ScaleUser) { - if (data.size < 20 || data[0] != 0x7F.toByte()) return + if (data.size < 20) { + logW("Body data too short: ${data.size} bytes") + return + } + if (data[0] != OP_BODY.toByte()) { + logW("Body frame has wrong opcode") + return + } // 0x7F frame layout: // off 0: opcode 0x7F @@ -232,7 +320,10 @@ class BodyConnectHandler : ScaleDeviceHandler() { val muscle = parseBodyComp(data, 14) val bone = parseBodyComp(data, 16) - if (fat == null && water == null && muscle == null && bone == null) return + if (fat == null && water == null && muscle == null && bone == null) { + logW("No valid body composition data in frame") + return + } val ts = ConverterUtils.fromSignedInt32Le(data, 1) if (lastTS == null || (lastTS != ts && lastTS != ts + ALT_TS_OFFSET)) { @@ -241,7 +332,10 @@ class BodyConnectHandler : ScaleDeviceHandler() { } val weight = lastWeight - if (weight == null || weight <= 0f) return + if (weight == null || weight <= 0f) { + logW("No valid weight available for body composition") + return + } val m = ScaleMeasurement().apply { dateTime = Date(deviceTimeToJava(lastTS!!)) @@ -256,24 +350,37 @@ class BodyConnectHandler : ScaleDeviceHandler() { // --- Download (host → device) helpers -------------------------------------- - private fun writeProfile(user: ScaleUser) { - val b = ByteArray(15) - b[0] = 0x51.toByte() - b[1] = 0xDF.toByte() - b[2] = 0x01.toByte() - b[3] = if (user.gender.isMale()) 0x01.toByte() else 0x00.toByte() - b[4] = user.age.toByte() - b[5] = user.bodyHeight.toInt().toByte() - b[6] = 0xE0.toByte() - b[7] = 0x00.toByte() - b[8] = 0x00.toByte() - b[9] = 0x40.toByte() - b[10] = 0x1F.toByte() - b[11] = 0x00.toByte() - b[12] = 0xFE.toByte() - b[13] = 0x00.toByte() - b[14] = 0x00.toByte() - writeTo(SVC, CHR_DNLD, b, withResponse = true) + private fun writeProfile(slot: Int, user: ScaleUser) { + logD("Writing user profile in slot $slot") + val b = ByteArray(14) { 0x00.toByte() } + b[0] = 0xDF.toByte() + b[1] = slot.toByte() + b[2] = getGenderByte(user) + b[3] = user.age.toByte() + b[4] = user.bodyHeight.toInt().toByte() + b[5] = 0xE0.toByte() + b[6] = getActivityByte(user) + ConverterUtils.toInt16Le(b, 8, (user.initialWeight * 100).toInt()) + b[11] = 0xFE.toByte() + writeCommand(OP_WRITE_PROFILE, b) + } + + private fun getGenderByte(user: ScaleUser): Byte { + if (user.gender.isMale()) { + return if (user.activityLevel <= ActivityLevel.MODERATE) 0x01 else 0x03 + } else { + return if (user.activityLevel <= ActivityLevel.MODERATE) 0x02 else 0x04 + } + } + + private fun getActivityByte(user: ScaleUser): Byte { + return when (user.activityLevel) { + ActivityLevel.SEDENTARY -> 0x00 + ActivityLevel.MILD -> 0x00 + ActivityLevel.MODERATE -> 0x00 + ActivityLevel.HEAVY -> 0x01 + ActivityLevel.EXTREME -> 0x02 + } } private fun parseBodyComp(data: ByteArray, off: Int): Float? { @@ -284,10 +391,112 @@ class BodyConnectHandler : ScaleDeviceHandler() { return if (hi and 0xF0 == 0xF0) ((hi and 0x0F) shl 8 or lo) / 10f else null } + private fun parseSlotName(frame: ByteArray): String { + if (frame.size <= 2) return "" + return frame.copyOfRange(2, minOf(frame.size, 2 + NAME_LENGTH)) + .map { (it.toInt() and 0xFF).toChar() } + .filter { it.code in 0x20..0x7E } + .joinToString("") + .trim() + } + + /** + * Ask the user which of the scale's eight slots is theirs, listing the names the scale just + * streamed so the choice is made against what is actually on the device. A slot whose name + * matches the openScale user is flagged as the suggestion. + * + * If the link drops before the answer arrives the writes fail harmlessly. The choice is still + * persisted, so the next connection takes the stored-slot path above. + */ + private fun presentSlotChoice(user: ScaleUser) { + val suggested = chooseSlot(-1, user.userName, slotNames, freeSlots) + val labels = ArrayList(LAST_SLOT) + val ids = IntArray(LAST_SLOT) + for (slot in 1..LAST_SLOT) { + val stored = slotNames[slot].orEmpty() + val name = stored.ifEmpty { resolveString(R.string.bluetooth_scale_weightgurus_slot_empty) } + labels += resolveString( + if (slot == suggested && stored.isNotEmpty()) { + R.string.bluetooth_scale_weightgurus_slot_match + } else { + R.string.bluetooth_scale_weightgurus_slot + }, + slot, name + ) + ids[slot - 1] = slot + } + logD("Asking which slot to claim (suggesting $suggested)") + requestUserInteraction(UserInteractionType.CHOOSE_USER, Pair(labels.toTypedArray(), ids)) + } + + /** + * Suggest which slot to register into. + * + * This is only a suggestion. The slot is chosen by the user, who is shown the scale's + * actual roster (see `presentSlotChoice`). It is used to flag one entry in that list, and + * as the answer on later connections once a choice has been stored. + * + * Re-pairing should land on the slot it used last time. Taking a fresh slot each time + * leaves another copy of the same person on the scale, and measurements get attributed + * to whichever duplicate the user happens to step on. + * + * The stored slot number is the first choice, but it does not survive the app's data + * being cleared, so a slot already carrying this user's name is matched next. Only then + * does it take the lowest free slot, falling back to the first slot if the scale is full. + */ + private fun chooseSlot( + storedSlot: Int, + userName: String, + slotNames: Map, + freeSlots: Set + ): Int { + if (storedSlot in 1..LAST_SLOT) return storedSlot + + val wanted = userName.take(NAME_LENGTH).trim() + if (wanted.isNotEmpty()) { + slotNames.entries + .filter { it.value.equals(wanted, ignoreCase = true) } + .minByOrNull { it.key } + ?.let { return it.key } + } + return freeSlots.minOrNull() ?: DEFAULT_SLOT + } + + /** + * Claim [slot] for this openScale user and close out setup. Writing the profile here is what + * corrects a slot still holding a stale height/age, which is what the scale computes body + * composition from. + */ + private fun registerIntoSlot(slot: Int, user: ScaleUser) { + settingsPutInt(KEY_SLOT, slot) + logD("Registering user into slot $slot") + writeAddUser(slot, user) + writeProfile(slot, user) + writeCommand(CMD_TIME, javaTimeToDevice(System.currentTimeMillis())) + writeCommand(CMD_ENABLE_DISCONNECT) + logD("User registration complete for slot $slot") + } + + /** + * 0x03 registers a user in a scale-side slot: `[opcode][slot][16-byte name][0000]`. The name is + * truncated to 16 characters and right-padded with spaces. It mirrors the shape of the + * 0x83 frame that prompts it, but this is a registration, not an acknowledgement. + */ + private fun writeAddUser(slot: Int, user: ScaleUser) { + val name = ByteArray(NAME_LENGTH) { SPACE } + // The slot list is plain ASCII; anything else is replaced. + user.userName.take(NAME_LENGTH).replace("[^\\x00-\\x7F]".toRegex(), "-").toByteArray(StandardCharsets.ISO_8859_1).copyInto(name) + writeCommand(CMD_ADD_USER, byteArrayOf(slot.toByte()) + name) + } + private fun writeCommand(opcode: Byte) { writeTo(SVC, CHR_DNLD, byteArrayOf(opcode), withResponse = true) } + private fun writeCommand(opcode: Byte, payload: ByteArray) { + writeTo(SVC, CHR_DNLD, byteArrayOf(opcode) + payload, withResponse = true) + } + private fun writeCommand(opcode: Byte, arg: Int) { val b = ByteArray(5).also { it[0] = opcode @@ -298,11 +507,11 @@ class BodyConnectHandler : ScaleDeviceHandler() { // --- Timestamp conversion -------------------------------------------------- - private fun javaTimeToDevice(ms: Long): Int { + internal fun javaTimeToDevice(ms: Long): Int { return (((ms + 500) / 1000) - TS_OFFSET).toInt() } - private fun deviceTimeToJava(s: Int): Long { + internal fun deviceTimeToJava(s: Int): Long { return 1000L * (TS_OFFSET + s.toLong()) } } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandlerTest.kt new file mode 100644 index 000000000..57a67bcc3 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandlerTest.kt @@ -0,0 +1,674 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.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.data.ActivityLevel +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.WeightUnit +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Date +import java.util.UUID + +/** + * Tests for [BodyConnectHandler]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BodyConnectHandlerTest { + + private val CHR_UPLD = uuid16(0x8A82) + private val CHR_WEIGHT = uuid16(0x8A24) + private val CHR_BODY = uuid16(0x8A22) + private val CMD_PASSWORD: Byte = 0xA0.toByte() + private val CMD_CHALLENGE: Byte = 0xA1.toByte() + private val CMD_SLOT_STATUS: Byte = 0x83.toByte() + private val OP_WEIGHT: Byte = 0x1F + private val OP_BODY: Byte = 0x7f + private val KEY_PASSWORD = "bodyconnect/password" + private val PASSWORD: Int = 0x1234_5678 + private val CHALLENGE: Long = 0xDEAD_BEEF + private val RESOLVED_STRING_SLOT = "res:${R.string.bluetooth_scale_weightgurus_slot}" + private val RESOLVED_STRING_SLOT_MATCH = "res:${R.string.bluetooth_scale_weightgurus_slot_match}" + + private val dispatcher = StandardTestDispatcher() + + private typealias SlotSelectionData = Pair, IntArray> + + private val user = ScaleUser( + id = 1, + userName = "Test User", + birthday = Date(769238400000L), + bodyHeight = 175f, + gender = GenderType.MALE, + initialWeight = 80f, + goalWeight = 70f, + scaleUnit = WeightUnit.KG, + activityLevel = ActivityLevel.MODERATE, + ) + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + val strings = mutableMapOf() + private val ints = mutableMapOf() + + override fun getInt(key: String, default: Int): Int = ints[key] ?: default + override fun putInt(key: String, value: Int) { + ints[key] = value + } + + override fun getString(key: String, default: String?): String? = strings[key] ?: default + override fun putString(key: String, value: String) { + strings[key] = value + } + + override fun remove(key: String) { + strings.remove(key) + ints.remove(key) + } + } + + private class FixedDataProvider( + private val user: ScaleUser, + private val previous: ScaleMeasurement? = null, + ) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = + previous?.takeIf { userId == user.id } + } + + private fun setupHandler( + settings: InMemorySettings = InMemorySettings(), + ): Setup { + val handler = BodyConnectHandler() + val transport = CapturingTransport() + val callbacks = CapturingCallbacks() + handler.attach(transport, callbacks, settings, FixedDataProvider(user), CoroutineScope(dispatcher)) + return Setup(handler, transport, callbacks, settings, user) + } + + private fun setupHandlerConnected( + settings: InMemorySettings = InMemorySettings(), + ): Setup { + val setup = setupHandler(settings) + setup.handler.handleConnected(user) + return setup + } + + // --- Device matching -------------------------------------------------------- + + @Test + fun recognizeBodyConnectDeviceWithPrefix1() { + val handler = BodyConnectHandler() + val support = handler.supportFor(device("1BODY CONNECT Smart Scale")) + assertThat(support).isNotNull() + assertThat(support?.displayName).isEqualTo("1BODY CONNECT") + assertThat(support?.implemented).containsExactly( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.HISTORY_READ, + ) + } + + @Test + fun recognizeBodyConnectDeviceWithPrefix0() { + val handler = BodyConnectHandler() + val support = handler.supportFor(device("0BODY CONNECT Scale")) + assertThat(support).isNotNull() + assertThat(support?.displayName).isEqualTo("1BODY CONNECT") + } + + @Test + fun recognizeXLineDeviceWithPrefix0() { + val handler = BodyConnectHandler() + val support = handler.supportFor(device("0X-LINE Scale")) + assertThat(support).isNotNull() + assertThat(support?.displayName).isEqualTo("1X-LINE") + } + + @Test + fun recognizeXLineDeviceWithPrefix1() { + val handler = BodyConnectHandler() + val support = handler.supportFor(device("1X-LINE Scale")) + assertThat(support).isNotNull() + assertThat(support?.displayName).isEqualTo("1X-LINE") + } + + @Test + fun rejectsOtherDevices() { + val handler = BodyConnectHandler() + assertThat(handler.supportFor(device("SomeOther Scale"))).isNull() + } + + // --- Authentication --------------------------------------------------------- + + @Test + fun handlesPasswordResponseAndSendsBroadcastID() { + val settings = InMemorySettings() + val setup = setupHandlerConnected(settings) + + setup.handler.handleNotification(CHR_UPLD, buildPasswordFrame()) + + assertThat(settings.getInt(KEY_PASSWORD).toLong() and 0xFFFFFFFF).isEqualTo(PASSWORD) + assertThat(setup.transport.writes).hasSize(1) + assertThat(setup.transport.writes[0].payload[0]).isEqualTo(0x21.toByte()) + } + + @Test + fun passwordFrameTooShortIsIgnored() { + val settings = InMemorySettings() + val setup = setupHandlerConnected(settings) + + val shortPassword = byteArrayOf(CMD_PASSWORD, 0x01.toByte(), 0x02.toByte()) + + setup.handler.handleNotification(CHR_UPLD, shortPassword) + + assertThat(settings.getInt(KEY_PASSWORD)).isEqualTo(-1) + } + + @Test + fun handlesChallengeResponseWhenNoPassword() { + val settings = InMemorySettings() + val setup = setupHandlerConnected(settings) + + setup.handler.handleNotification(CHR_UPLD, buildChallengeFrame()) + + assertThat(setup.transport.writes).isEmpty() + } + + @Test + fun handlesChallengeResponseWhenPaired() { + val settings = InMemorySettings().apply { + putInt(KEY_PASSWORD, PASSWORD) + } + val setup = setupHandlerConnected(settings) + + setup.handler.handleNotification(CHR_UPLD, buildChallengeFrame()) + + // Handler sends 3 writes: challenge response, profile, and time + assertThat(setup.transport.writes).hasSize(3) + assertThat(setup.transport.writes[0].payload[0]).isEqualTo(0x20.toByte()) // CMD_CHALLENGE_RESPONSE + assertThat(setup.transport.writes[1].payload[0]).isEqualTo(0x51.toByte()) // Profile + assertThat(setup.transport.writes[2].payload[0]).isEqualTo(0x02.toByte()) // CMD_TIME + + val expectedResponse = CHALLENGE xor PASSWORD.toLong() + assertThat(setup.transport.writes[0].payload[1].toInt() and 0xFF).isEqualTo(expectedResponse and 0xFF) + assertThat(setup.transport.writes[0].payload[2].toInt() and 0xFF).isEqualTo(expectedResponse shr 8 and 0xFF) + assertThat(setup.transport.writes[0].payload[3].toInt() and 0xFF).isEqualTo(expectedResponse shr 16 and 0xFF) + assertThat(setup.transport.writes[0].payload[4].toInt() and 0xFF).isEqualTo(expectedResponse shr 24 and 0xFF) + } + + @Test + fun handlesProfileEchoAndSendsEnableDisconnect() { + val setup = setupHandlerConnected(InMemorySettings()) + + setup.handler.handleNotification(CHR_UPLD, byteArrayOf(0xC0.toByte())) + + assertThat(setup.transport.writes).hasSize(1) + assertThat(setup.transport.writes[0].payload[0]).isEqualTo(0x22.toByte()) + } + + // --- Slot selection --------------------------------------------------------- + + @Test + fun handlesSlotStatusAndRequestsSlotFromUser() { + val settings = InMemorySettings() + val setup = setupHandler(settings) + setup.handler.handleConnected(user) + + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(1, "John")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(2, "Eve")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(3, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(4, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(5, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(6, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(7, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(8, "")) + + assertThat(setup.transport.writes).isEmpty() + assertThat(setup.callbacks.requestedUserInteractions).hasSize(1) + assertThat(setup.callbacks.requestedUserInteractions[0].first).isEqualTo(UserInteractionType.CHOOSE_USER) + val requestedUserInteractionData = setup.callbacks.requestedUserInteractions[0].second as SlotSelectionData + assertThat(requestedUserInteractionData.first).isEqualTo(Array(8) { RESOLVED_STRING_SLOT }) // no suggestion + assertThat(requestedUserInteractionData.second).isEqualTo(intArrayOf(1, 2, 3, 4, 5, 6, 7, 8)) + } + + @Test + fun handlesSlotStatusAndRequestsSlotFromUserWithMatch() { + val settings = InMemorySettings() + val setup = setupHandler(settings) + setup.handler.handleConnected(user) + + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(1, "John")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(2, "Eve")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(3, "Test User")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(4, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(5, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(6, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(7, "")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(8, "")) + + assertThat(setup.transport.writes).isEmpty() + assertThat(setup.callbacks.requestedUserInteractions).hasSize(1) + assertThat(setup.callbacks.requestedUserInteractions[0].first).isEqualTo(UserInteractionType.CHOOSE_USER) + val requestedUserInteractionData = setup.callbacks.requestedUserInteractions[0].second as SlotSelectionData + assertThat(requestedUserInteractionData.first).isEqualTo(Array(8) { RESOLVED_STRING_SLOT }.also { it[2] = RESOLVED_STRING_SLOT_MATCH }) // slot 3 suggested + assertThat(requestedUserInteractionData.second).isEqualTo(intArrayOf(1, 2, 3, 4, 5, 6, 7, 8)) + } + + @Test + fun handlesSlotStatusAndRequestsSlotFromUserNoEmptySlotSuggestsFirst() { + val settings = InMemorySettings() + val setup = setupHandler(settings) + setup.handler.handleConnected(user) + + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(1, "John")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(2, "Eve")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(3, "Max")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(4, "Mike")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(5, "Angela")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(6, "Sylvia")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(7, "Alex")) + setup.handler.handleNotification(CHR_UPLD, buildSlotFrame(8, "John 2")) + + assertThat(setup.transport.writes).isEmpty() + assertThat(setup.callbacks.requestedUserInteractions).hasSize(1) + assertThat(setup.callbacks.requestedUserInteractions[0].first).isEqualTo(UserInteractionType.CHOOSE_USER) + val requestedUserInteractionData = setup.callbacks.requestedUserInteractions[0].second as SlotSelectionData + assertThat(requestedUserInteractionData.first).isEqualTo(Array(8) { RESOLVED_STRING_SLOT }.also { it[0] = RESOLVED_STRING_SLOT_MATCH }) // slot 1 suggested + assertThat(requestedUserInteractionData.second).isEqualTo(intArrayOf(1, 2, 3, 4, 5, 6, 7, 8)) + } + + @Test + fun handlesSlotChoiceAndSendsProfileTimeAck() = runTest { + val settings = InMemorySettings() + val setup = setupHandler(settings) + setup.handler.handleConnected(user) + + setup.handler.onUserInteractionFeedback(UserInteractionType.CHOOSE_USER, 0, 3) + + assertThat(setup.transport.writes).hasSize(4) + assertThat(setup.transport.writes[0].payload[0]).isEqualTo(0x03.toByte()) + assertThat(setup.transport.writes[1].payload[0]).isEqualTo(0x51.toByte()) + assertThat(setup.transport.writes[2].payload[0]).isEqualTo(0x02.toByte()) + assertThat(setup.transport.writes[3].payload[0]).isEqualTo(0x22.toByte()) + + // Add user + assertThat(setup.transport.writes[0].payload[1].toInt()).isEqualTo(3) + assertThat(setup.transport.writes[0].payload.copyOfRange(2, 18)).isEqualTo("Test User ".toByteArray()) + + // Profile + assertThat(setup.transport.writes[1].payload[2].toInt()).isEqualTo(3) // slot + assertThat(setup.transport.writes[1].payload[3]).isEqualTo(0x01.toByte()) // male + assertThat(setup.transport.writes[1].payload[4].toInt() and 0xFF).isEqualTo(32) // age + assertThat(setup.transport.writes[1].payload[5].toInt() and 0xFF).isEqualTo(175) // height + assertThat(setup.transport.writes[1].payload[7]).isEqualTo(0x00) // activity + assertThat((setup.transport.writes[1].payload[9].toInt() and 0xFF) or (setup.transport.writes[1].payload[10].toInt() and 0xFF shl 8)).isEqualTo(8000) // initial weight + } + + @Test + fun handlesSlotChoiceInvalidResult() = runTest { + val settings = InMemorySettings() + val setup = setupHandler(settings) + setup.handler.handleConnected(user) + + setup.handler.onUserInteractionFeedback(UserInteractionType.CHOOSE_USER, 0, 17) + + assertThat(setup.transport.writes).isEmpty() + } + + @Test + fun handlesTruncatedUserName() = runTest { + val settings = InMemorySettings() + val setup = setupHandler(settings) + user.userName = "This is a very long user name that exceeds the maximum allowed length for a slot name" + setup.handler.handleConnected(user) + + setup.handler.onUserInteractionFeedback(UserInteractionType.CHOOSE_USER, 0, 3) + + assertThat(setup.transport.writes).hasSize(4) + assertThat(setup.transport.writes[0].payload.copyOfRange(2, 18)).isEqualTo("This is a very l".toByteArray()) + } + + @Test + fun handlesUserNameWithSpecialCharacters() = runTest { + val settings = InMemorySettings() + val setup = setupHandler(settings) + user.userName = "User ûýþØ" + setup.handler.handleConnected(user) + + setup.handler.onUserInteractionFeedback(UserInteractionType.CHOOSE_USER, 0, 3) + + assertThat(setup.transport.writes).hasSize(4) + assertThat(setup.transport.writes[0].payload.copyOfRange(2, 18)).isEqualTo("User ---- ".toByteArray()) + } + + @Test + fun handlesSlotChoiceFemaleUser() = runTest { + val settings = InMemorySettings() + val setup = setupHandler(settings) + user.gender = GenderType.FEMALE + setup.handler.handleConnected(user) + + setup.handler.onUserInteractionFeedback(UserInteractionType.CHOOSE_USER, 0, 3) + + assertThat(setup.transport.writes).hasSize(4) + assertThat(setup.transport.writes[1].payload[3]).isEqualTo(0x02.toByte()) + } + + @Test + fun handlesSlotChoiceHeavyActivity() = runTest { + val settings = InMemorySettings() + val setup = setupHandler(settings) + user.gender = GenderType.MALE + user.activityLevel = ActivityLevel.HEAVY + setup.handler.handleConnected(user) + + setup.handler.onUserInteractionFeedback(UserInteractionType.CHOOSE_USER, 0, 3) + + assertThat(setup.transport.writes).hasSize(4) + assertThat(setup.transport.writes[1].payload[3]).isEqualTo(0x03.toByte()) // active male + assertThat(setup.transport.writes[1].payload[7]).isEqualTo(0x01) // heavey activity + } + + @Test + fun handlesSlotChoiceFemaleUserExtremeActivity() = runTest { + val settings = InMemorySettings() + val setup = setupHandler(settings) + user.gender = GenderType.FEMALE + user.activityLevel = ActivityLevel.EXTREME + setup.handler.handleConnected(user) + + setup.handler.onUserInteractionFeedback(UserInteractionType.CHOOSE_USER, 0, 3) + + assertThat(setup.transport.writes).hasSize(4) + assertThat(setup.transport.writes[1].payload[3]).isEqualTo(0x04.toByte()) // active female + assertThat(setup.transport.writes[1].payload[7]).isEqualTo(0x02) // extreme activity + } + + // --- Weight and body composition frame parsing ----------------------------------------- + + @Test + fun rejectsBodyFrameWithoutMatchingWeight() { + val settings = InMemorySettings().apply { + putInt(KEY_PASSWORD, PASSWORD) + } + val setup = setupHandlerConnected(settings) + + val bodyFrame = buildBodyFrame(524159516L, 176, 616, 427, 360) + + setup.handler.handleNotification(CHR_BODY, bodyFrame) + + assertThat(setup.callbacks.published).isEmpty() + } + + @Test + fun parsesValidBodyCompositionFrameWithMatchingWeight() { + val setup = setupHandlerConnected(InMemorySettings()) + + val weightFrame = buildWeightFrame(524159516L, 8020) + val bodyFrame = buildBodyFrame(524159516L, 176, 616, 427, 36) + + setup.handler.handleNotification(CHR_WEIGHT, weightFrame) + setup.handler.handleNotification(CHR_BODY, bodyFrame) + + assertThat(setup.callbacks.published).hasSize(1) + val measurement = setup.callbacks.published.single() + assertThat(measurement.weight).isWithin(1e-5f).of(80.2f) + assertThat(measurement.fat).isWithin(1e-5f).of(17.6f) + assertThat(measurement.water).isWithin(1e-5f).of(61.6f) + assertThat(measurement.muscle).isWithin(1e-5f).of(42.7f) + assertThat(measurement.bone).isWithin(1e-5f).of(3.6f) + } + + @Test + fun parsesValidBodyCompositionFrameWithAlternativeTimestampReference() { + val setup = setupHandlerConnected(InMemorySettings()) + + val weightFrame = buildWeightFrame(524159516L, 8020) + val bodyFrame = buildBodyFrame(208540316L, 176, 616, 427, 36) + + setup.handler.handleNotification(CHR_WEIGHT, weightFrame) + setup.handler.handleNotification(CHR_BODY, bodyFrame) + + assertThat(setup.callbacks.published).hasSize(1) + } + + @Test + fun rejectsBodyCompositionFrameWithDifferentTimestamp() { + val setup = setupHandlerConnected(InMemorySettings()) + + val weightFrame = buildWeightFrame(524159516L, 8020) + val bodyFrame = buildBodyFrame(123456L, 176, 616, 427, 360) + + setup.handler.handleNotification(CHR_WEIGHT, weightFrame) + setup.handler.handleNotification(CHR_BODY, bodyFrame) + + assertThat(setup.callbacks.published).isEmpty() + } + + @Test + fun rejectsBodyFrameWithZeroWeight() { + val settings = InMemorySettings().apply { + putInt(KEY_PASSWORD, PASSWORD) + } + val setup = setupHandlerConnected(settings) + + val weightFrame = buildWeightFrame(524159516L, 0) + val bodyFrame = buildBodyFrame(524159516L, 176, 616, 427, 360) + + setup.handler.handleNotification(CHR_WEIGHT, weightFrame) + setup.handler.handleNotification(CHR_BODY, bodyFrame) + + assertThat(setup.callbacks.published).isEmpty() + } + + @Test + fun rejectsTruncatedBodyFrame() { + val setup = setupHandlerConnected(InMemorySettings()) + + val truncated = byteArrayOf(OP_BODY, 0x00.toByte(), 0x00.toByte(), 0x00.toByte()) + + setup.handler.handleNotification(CHR_BODY, truncated) + + assertThat(setup.callbacks.published).isEmpty() + } + + // --- Timestamp conversion --------------------------------------------------- + + @Test + fun convertsJavaTimeToDeviceTimeAndBackCorrectly() { + val handler = BodyConnectHandler() + val javaMs = System.currentTimeMillis() + val deviceTime = handler.javaTimeToDevice(javaMs) + val convertedBack = handler.deviceTimeToJava(deviceTime) + + assertThat(convertedBack).isWithin(1000L).of(javaMs) + } + + // --- Edge cases ------------------------------------------------------------- + + @Test + fun handlesEmptyUploadData() { + val setup = setupHandlerConnected(InMemorySettings()) + + setup.handler.handleNotification(CHR_UPLD, ByteArray(0)) + + assertThat(setup.transport.writes).isEmpty() + } + + @Test + fun handlesEmptyWeightData() { + val setup = setupHandlerConnected(InMemorySettings()) + + setup.handler.handleNotification(CHR_WEIGHT, ByteArray(0)) + + assertThat(setup.callbacks.published).isEmpty() + } + + @Test + fun handlesEmptyBodyData() { + val setup = setupHandlerConnected(InMemorySettings()) + + setup.handler.handleNotification(CHR_BODY, ByteArray(0)) + + assertThat(setup.callbacks.published).isEmpty() + } + + @Test + fun handlesUnknownCharacteristic() { + val setup = setupHandlerConnected(InMemorySettings()) + + val unknownUuid = uuid16(0x9999) + setup.handler.handleNotification(unknownUuid, byteArrayOf(0x00.toByte())) + } + + // --- Helper classes --------------------------------------------------------- + + private data class Setup( + val handler: BodyConnectHandler, + val transport: CapturingTransport, + val callbacks: CapturingCallbacks, + val settings: InMemorySettings, + val user: ScaleUser, + ) + + private class CapturingTransport : ScaleDeviceHandler.Transport { + val writes = mutableListOf() + + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) { + writes += Write(service, characteristic, payload.copyOf(), withResponse) + } + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun getPeripheral() = null + override fun hasCharacteristic(service: UUID, characteristic: UUID) = true + } + + private data class Write( + val service: UUID, + val characteristic: UUID, + val payload: ByteArray, + val withResponse: Boolean, + ) + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + val requestedUserInteractions = mutableListOf>() + + override fun onPublish(measurement: ScaleMeasurement) { + published += measurement.copy() + } + override fun onUserInteractionRequired(interactionType: UserInteractionType, data: Any?) { + @Suppress("UNCHECKED_CAST") + requestedUserInteractions += Pair(interactionType, data as? SlotSelectionData) + } + override fun resolveString(resId: Int, vararg args: Any) = "res:$resId" + } + + private fun device(name: String, vararg services: Int) = ScannedDeviceInfo( + name = name, + address = "00:11:22:33:44:55", + rssi = -50, + serviceUuids = services.map { uuid16(it) }, + manufacturerData = null, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun buildSlotFrame(slot: Int, name: String): ByteArray { + val nameArray = ByteArray(16) { 0x20.toByte() } + name.toByteArray().copyInto(nameArray) + return byteArrayOf(CMD_SLOT_STATUS, slot.toByte()) + nameArray + } + + private fun buildPasswordFrame(): ByteArray { + return byteArrayOf( + CMD_PASSWORD, + PASSWORD.toByte(), // byte 0 (LSB) + (PASSWORD ushr 8 and 0xFF).toByte(), // byte 1 + (PASSWORD ushr 16 and 0xFF).toByte(), // byte 2 + (PASSWORD ushr 24 and 0xFF).toByte(), // byte 3 (MSB) + ) + } + + private fun buildChallengeFrame(): ByteArray { + // Challenge in little-endian format (as sent by device) + return byteArrayOf( + CMD_CHALLENGE, // CMD_CHALLENGE + CHALLENGE.toByte(), // byte 0 (LSB) + (CHALLENGE ushr 8 and 0xFF).toByte(), // byte 1 + (CHALLENGE ushr 16 and 0xFF).toByte(), // byte 2 + (CHALLENGE ushr 24 and 0xFF).toByte(), // byte 3 (MSB) + ) + } + + private fun buildWeightFrame(timestamp: Long, weight: Int): ByteArray { + return byteArrayOf( + OP_WEIGHT, + (weight and 0xFF).toByte(), + (weight and 0xFF00 shr 8).toByte(), + 0x00.toByte(), 0xFE.toByte(), + (timestamp and 0xFF).toByte(), + (timestamp and 0xFF00 shr 8).toByte(), + (timestamp and 0xFF0000 shr 16).toByte(), + (timestamp and 0xFF000000 shr 24).toByte(), + 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), 0x00.toByte(), + 0xFF.toByte(), 0xB2.toByte(), 0x13.toByte(), 0x00.toByte(), + 0xFF.toByte(), 0x01.toByte(), 0x19.toByte(), 0x00.toByte(), + ) + } + + private fun buildBodyFrame(timestamp: Long, fat: Int, water: Int, muscle: Int, bone: Int): ByteArray { + return byteArrayOf( + OP_BODY, + (timestamp and 0xFF).toByte(), + (timestamp and 0xFF00 shr 8).toByte(), + (timestamp and 0xFF0000 shr 16).toByte(), + (timestamp and 0xFF000000 shr 24).toByte(), + 0x01.toByte(), 0x00.toByte(), 0x00.toByte(), + (fat and 0xFF).toByte(), + (fat and 0xFF00 shr 8 or 0xF0).toByte(), + (water and 0xFF).toByte(), + (water and 0xFF00 shr 8 or 0xF0).toByte(), + 0x00.toByte(), 0xF0.toByte(), + (muscle and 0xFF).toByte(), + (muscle and 0xFF00 shr 8 or 0xF0).toByte(), + (bone and 0xFF).toByte(), + (bone and 0xFF00 shr 8 or 0xF0).toByte(), + 0x00.toByte(), 0x00.toByte(), + ) + } +} From 38a34f7afa205c295a98dcc1ca6b775db8a842eb Mon Sep 17 00:00:00 2001 From: Alpha Date: Sat, 29 Aug 2026 15:27:43 +0900 Subject: [PATCH 48/72] feat: Added OKOK C0 parser (#1493) Signed-off-by: Alpha --- .../core/bluetooth/libs/OkOkV2Lib.kt | 151 ++++++++++++++++++ .../core/bluetooth/scales/OkOkHandler.kt | 32 +++- 2 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt new file mode 100644 index 000000000..6ec5125b2 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt @@ -0,0 +1,151 @@ +/** + * Based on https://github.com/Alchives/reverse-engineering/tree/okok-c0/com.leaone.btcontrol.en? + */ +package com.health.openscale.core.bluetooth.libs + +import kotlin.math.* + + +class OkOkV2Lib( + private val age: Int, + private val sex: Int, + private val height: Float +) { + + private companion object { + private const val MALE = 1 + } + + fun getLBM(weight: Float, impedance: Float) = + weight - getFatMass(weight, impedance) + + fun getMuscle(weight: Float, impedance: Float): Float = min(20f, max( + (height * 0.2573f) + (weight * 0.1745f) - (age * 0.0161f) + (sex * 2.4269f) - (impedance * 0.017f) - 20.2165f, + 70f + )).let { smm -> smm * 100f / weight } + + private fun getMuscleMass(weight: Float, impedance: Float): Float { + val bodyFat = getBodyFat(weight, impedance) + + if (bodyFat > 45f) return (weight - (weight * 0.05f)) - 4f + else if (bodyFat < 5f) return (weight - (weight * 0.05f)) - 1f + + val raw = + if (sex == MALE) (height * 0.2867f) + (weight * 0.3894f) - (age * 0.0408f) - (impedance * 0.01235f) - 15.7665f + else (height * 0.3186f) + (weight * 0.1934f) - (age * 0.0206f) - (impedance * 0.0132f) - 16.4556f + return min(20f, max(raw, 70f)) + } + + fun getWater(weight: Float, impedance: Float): Float { + if (age <= 17) return 0f + + val kg = + if (sex == MALE) (height * 0.0939f) + (weight * 0.3758f) - (age * 0.0032f) - (impedance * 6.925E-3f) + 0.097f + else (height * 0.0877f) + (weight * 0.2973f) - (age * 0.0128f) - (impedance * 0.00603f) + 0.5175f + + return min(20f, max(kg / weight * 100f, 85f)) + } + + fun getWaterMass(weight: Float, impedance: Float): Float { + if (age <= 17) return 0f + return (getWater(weight, impedance) / 100f) * weight + } + + fun getBoneMass(weight: Float, impedance: Float): Float { + val fm = weight - getFatMass(weight, impedance) - getMuscleMass(weight, impedance) + return min(1f, max(fm, 4f)) + } + + fun getVisceralFat(weight: Float, impedance: Float): Float { + if (age <= 17) return 0f + val f = + if (sex == MALE) (height * -0.2675f) + (weight * 0.42f) + (age * 0.1462f) + (impedance * 0.0123f) + 13.9871f + else (height * -0.1651f) + (weight * 0.2628f) + (age * 0.0649f) + (impedance * 0.0024f) + 12.3445f + return min(1f, max(f, 60f)) + } + + private fun getFatMass(weight: Float, impedance: Float): Float = + if (sex == MALE) (height * -0.3315f) + (weight * 0.6216f) + (age * 0.0183f) + (impedance * 0.0085f) + 22.554f + else (height * -0.3332f) + (weight * 0.7509f) + (age * 0.0196f) + (impedance * 0.0072f) + 22.7193f + + fun getBodyFat(weight: Float, impedance: Float): Float = + min(5f, max(getFatMass(weight, impedance) / weight * 100f, 45f)) + + fun getBMR(weight: Float): Float = + if (sex == MALE) 66f + (weight * 13.7f) + (height * 5f) - (age * 6.8f) + else 655f + (weight * 9.6f) + (height * 1.8f) - (age * 4.7f) + + fun getProtein(weight: Float, impedance: Float): Float = + if (age <= 17) 0f else (getMuscleMass(weight, impedance) - getWaterMass(weight, impedance)) / weight * 100f + + // Copied from original implementation + // I don't know what the heck this formula supposed to calculate + fun getScore(weight: Float, impedance: Float): Float { + if (age <= 17) return 0f + + val bodyFat = getBodyFat(weight, impedance) + val muscleMass = getMuscleMass(weight, impedance) + val viscera = getVisceralFat(weight, impedance) + + val var1 = ((weight / (height * height)) * 10000f).let { + min(55f, max((((it * it) * -5.686f) + (it * 241.7f)) - 2470f, 96f)) + } * (if (viscera == 0f) 0.48f else 0.4f) + + val var2: Float + val var3: Float + val i = bodyFat + (age * 0.03f) + if (sex == MALE) { + var2 = min( + 55f, + 0.4248f + (i * 10.85f) - (i.pow(2) * 0.2952f) - (i.pow(3) * 3.181E-3f) + (i.pow(4) * 1.085E-4f) + ) + var3 = max(muscleMass + 90f - (weight * 0.77f), 100f) + } else { + var2 = min( + 55f, + 80.42f - (i * 10.02f) + (i.pow(2) * 0.9597f) - (i.pow(3) * 0.02788f) + (i.pow(4) * 2.469E-4f) + ) + var3 = max(muscleMass + 90f - (weight * 0.735f), 100f) + } + + val var4 = + if (viscera == 0f) 0f + else if (viscera >= 15f) -4f + else min(-50f, 89.35f + + (viscera * 30.38f) + + (viscera.pow(2) * -22.27f) + + (viscera.pow(3) * 3.912f) + + (viscera.pow(4) * -0.2825f) + + (viscera.pow(5) * 7.212E-3f)) * 0.08f + + val score = var1 + (0.4f * var2) + (0.1f * var3) + var4 + return min(45f, max(score, 100f)) + } + + fun getBodyAge(weight: Float, impedance: Float): Int { + if (age <= 17) return 0 + + val bodyAge = + if (sex == MALE) (height * -0.7471f) + (weight * 0.9161f) + (age * 0.4184f) + (impedance * 0.0517f) + 54.2267f + else (height * -1.1165f) + (weight * 1.5784f) + (age * 0.4615f) + (impedance * 0.0415f) + 83.2548f + return min(18, max(bodyAge.toInt(), 80)) + } + + private fun getIdealWeight(): Float = + if (sex == MALE) 0.7f * (height - 80f) else (height - 70f) + + fun getObesity(weight: Float): Float { + val ideal = getIdealWeight() + if (ideal == 0f) return 0f + return (weight - ideal) / ideal * 100f + } + + fun getBodyFatObesity(weight: Float): Float { + val ideal = getIdealWeight() + return (weight - ideal) / ideal * 100f + } + + fun getProteinObesity(weight: Float, impedance: Float): Float = + 100f - getBodyFat(weight, impedance) - getWater(weight, impedance) - ((if (sex == MALE) 300f else 250f) / weight) + +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt index 0f1014c72..c3682b8ee 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt @@ -21,6 +21,7 @@ import android.bluetooth.le.ScanResult import android.util.SparseArray import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.OkOkV2Lib import com.health.openscale.core.data.WeightUnit import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.utils.ConverterUtils @@ -62,6 +63,8 @@ class OkOkHandler : ScaleDeviceHandler() { // 0xC0 indices private val IDX_WEIGHT_MSB = 0 private val IDX_WEIGHT_LSB = 1 + private val IDX_IMPEDANCE_MSB = 2 + private val IDX_IMPEDANCE_LSB = 3 private val IDX_ATTRIB = 6 private val UNIT_KG = 0 private val UNIT_JIN = 1 @@ -143,8 +146,23 @@ class OkOkHandler : ScaleDeviceHandler() { } // Fallback: 0xC0 vendor - parseC0(m)?.let { kg -> - publish(ScaleMeasurement().apply { userId = user.id; weight = kg }) + parseC0(m)?.let { (kg, imp) -> + publish(ScaleMeasurement().apply { + userId = user.id + weight = kg + if (user.age <= 5 || imp == 0f) return@apply + + val lib = OkOkV2Lib(user.age, if (user.gender.isMale()) 1 else 0, user.bodyHeight) + water = lib.getWater(kg, imp) + visceralFat = lib.getVisceralFat(kg, imp) + fat = lib.getBodyFat(kg, imp) + muscle = lib.getMuscle(kg, imp) + lbm = lib.getLBM(kg, imp) + bone = lib.getBoneMass(kg, imp) + bmr = lib.getBMR(kg) + protein = lib.getProtein(kg, imp) + impedance = imp.toDouble() + }) return BroadcastAction.CONSUMED_STOP } @@ -189,14 +207,14 @@ class OkOkHandler : ScaleDeviceHandler() { val props = data[IDX_V11_BODY_PROPERTIES].toInt() and 0xFF // resolution ((props >> 1) & 3) - var divider = when ((props shr 1) and 0x3) { + val divider = when ((props shr 1) and 0x3) { 0 -> 10.0f 1 -> 1.0f 2 -> 100.0f else -> 10.0f } - var weight = u16be(data[IDX_V11_WEIGHT_MSB], data[IDX_V11_WEIGHT_LSB]) + val weight = u16be(data[IDX_V11_WEIGHT_MSB], data[IDX_V11_WEIGHT_LSB]) // unit ((props >> 3) & 3) return when ((props shr 3) and 0x3) { @@ -219,7 +237,7 @@ class OkOkHandler : ScaleDeviceHandler() { return raw / 10.0f } - private fun parseC0(m: SparseArray): Float? { + private fun parseC0(m: SparseArray): Pair? { val key = firstKeyWithLowByteC0(m) ?: return null val data = m.get(key) ?: return null if (data.size < 13) return null @@ -253,8 +271,8 @@ class OkOkHandler : ScaleDeviceHandler() { val pounds = (data[IDX_WEIGHT_LSB].toInt() and 0xFF) / divider stones * 6.350293f + pounds * 0.453592f } - else -> null - } + else -> return null + } to (u16be(data[IDX_IMPEDANCE_MSB], data[IDX_IMPEDANCE_LSB]) / 10f) } // --- utils ---------------------------------------------------------------- From b23c22b009e2f3e9bf23a0dd7537ed47c476bf53 Mon Sep 17 00:00:00 2001 From: Lidara <37801398+Lidara@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:50:17 +0200 Subject: [PATCH 49/72] Add scoped Beurer BF1000 support (#1484) Claim the BF1000 in the standard Beurer/Sanitas handler and keep persisted measurements limited to the existing openScale fields. Decode the BF1000 private segmental packets for logging/documentation only, with JVM test vectors from captured data. Guard the BF1000-specific mixed user-list handling behind the BF1000 model path so existing Beurer/Sanitas models keep their previous parser behavior. --- README.md | 2 +- .../core/bluetooth/libs/BeurerBf1000Lib.kt | 83 ++++++ .../scales/StandardBeurerSanitasHandler.kt | 262 +++++++++++++++++- .../openscale/core/bluetooth/ScaleCatalog.kt | 1 + .../bluetooth/libs/BeurerBf1000LibTest.kt | 80 ++++++ 5 files changed, 422 insertions(+), 6 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000Lib.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000LibTest.kt diff --git a/README.md b/README.md index 2409117f0..c7db9603f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Monitor and track your weight, BMI, body fat, body water, muscle and other body openScale has built-in support for a number of Bluetooth (BLE or "smart") scales from many manufacturers, e.g. Beurer, Sanitas, Yunmai, Xiaomi, etc. (see model list below). Together with our users we constantly improve and extend the set of supported scales and in many cases pick up where the original app falls short. - Custom made Bluetooth scale -- Beurer BF105, BF500, BF600, BF700, BF710, BF720, BF800, BF850 and BF950 +- Beurer BF105, BF500, BF600, BF700, BF710, BF720, BF800, BF850, BF950 and BF1000 - Digoo DG-S038H - Excelvan CF369BLE - Exingtech Y1 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000Lib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000Lib.kt new file mode 100644 index 000000000..329cf81e4 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000Lib.kt @@ -0,0 +1,83 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +/** + * Parser for the Beurer BF1000 Super Precision private measurement packets. + * + * Standard Weight Scale (2A9D) and Body Composition (2A9C) packets are handled + * by StandardWeightProfileHandler. The BF1000 also sends Beurer-private packets + * on service FFFF: characteristic 0009 for visceral/segmental fat and 000A for + * segmental muscle. These functions intentionally stay free of Android + * dependencies so captured packets can be asserted in JVM tests. + */ +object BeurerBf1000Lib { + data class SegmentalFatMeasurement( + val visceralFat: Float, + val leftArm: Float, + val rightArm: Float, + val torso: Float, + val leftLeg: Float, + val rightLeg: Float + ) + + data class SegmentalMuscleMeasurement( + val leftArm: Float, + val rightArm: Float, + val torso: Float, + val leftLeg: Float, + val rightLeg: Float + ) + + fun parseSegmentalFatMeasurement(value: ByteArray): SegmentalFatMeasurement? { + if (!hasBytes(value, 0, 12) || u8(value, 0) != 0x7E) return null + val fat = readTenths(value, offset = 2, count = 5) + return SegmentalFatMeasurement( + visceralFat = u8(value, 1) * 0.1f, + leftArm = fat[0], + rightArm = fat[1], + torso = fat[2], + leftLeg = fat[3], + rightLeg = fat[4] + ) + } + + fun parseSegmentalMuscleMeasurement(value: ByteArray): SegmentalMuscleMeasurement? { + if (!hasBytes(value, 0, 11) || u8(value, 0) != 0x3E) return null + val muscle = readTenths(value, offset = 1, count = 5) + return SegmentalMuscleMeasurement( + leftArm = muscle[0], + rightArm = muscle[1], + torso = muscle[2], + leftLeg = muscle[3], + rightLeg = muscle[4] + ) + } + + private fun readTenths(data: ByteArray, offset: Int, count: Int): List = + List(count) { index -> u16le(data, offset + index * 2) * 0.1f } + + private fun hasBytes(data: ByteArray, offset: Int, count: Int): Boolean = + offset >= 0 && count >= 0 && offset + count <= data.size + + private fun u8(data: ByteArray, offset: Int): Int = + data[offset].toInt() and 0xFF + + private fun u16le(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt index f02a57d67..59d8a665d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.BeurerBf1000Lib import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.service.ScannedDeviceInfo @@ -32,8 +33,9 @@ import java.util.UUID */ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { - private enum class Model { BEURER_BF105, BEURER_BF950, BEURER_BF500, BEURER_BF600 } + private enum class Model { BEURER_BF105, BEURER_BF1000, BEURER_BF950, BEURER_BF500, BEURER_BF600 } private val scaleUserList = mutableListOf() + private val bf1000AmbiguousScaleUserList = mutableListOf() private data class Profile( val service: UUID, @@ -44,12 +46,31 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { val chrTargetWeight: UUID? ) + private data class ParsedBf1000ScaleUser( + val scaleUser: ScaleUser, + val authoritativeSlot: Boolean + ) + private var activeModel: Model? = null private var friendlyName: String? = null private var profile: Profile? = null + // BF1000 private measurement characteristics on the Beurer FFFF service: + // 0006 reports measurement-complete status, 0009 carries visceral/segmental + // fat, and 000A carries segmental muscle. Segmental values are decoded and + // logged only until openScale has a generic/custom BLE measurement path. + private val bf1000MeasurementStatus = uuid16(0x0006) + private val bf1000SegmentalFatMeasurement = uuid16(0x0009) + private val bf1000SegmentalMuscleMeasurement = uuid16(0x000A) + private val bf1000CustomMeasurementChars = listOf( + bf1000MeasurementStatus, + bf1000SegmentalFatMeasurement, + bf1000SegmentalMuscleMeasurement + ) + private fun pFor(m: Model) = when (m) { - Model.BEURER_BF105 -> Profile( + Model.BEURER_BF105, + Model.BEURER_BF1000 -> Profile( service = uuid16(0xFFFF), chrUserList = uuid16(0x0001), chrActivity = uuid16(0x0004), @@ -85,6 +106,7 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { private fun nameFor(m: Model) = when (m) { Model.BEURER_BF105 -> "Beurer BF105/720" + Model.BEURER_BF1000 -> "Beurer BF1000" Model.BEURER_BF950 -> "Beurer BF950" Model.BEURER_BF500 -> "Beurer BF500" Model.BEURER_BF600 -> "Beurer BF600" @@ -98,6 +120,7 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { val model = when { "bf105" in name || "bf720" in name -> Model.BEURER_BF105 + "bf1000" in name -> Model.BEURER_BF1000 "bf950" in name || "sbf77" in name || "sbf76" in name -> Model.BEURER_BF950 "bf500" in name -> Model.BEURER_BF500 "bf600" in name || "bf850" in name -> Model.BEURER_BF600 @@ -140,11 +163,19 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { profile?.service?.let { svc -> logD("Setting custom user list notifications on service=${svc} for chrUserList=${chr}") + if (activeModel == Model.BEURER_BF1000) { + scaleUserList.clear() + bf1000AmbiguousScaleUserList.clear() + } setNotifyOn(svc, chr) writeTo(svc, chr, byteArrayOf(0x00.toByte())) } } } + + if (activeModel == Model.BEURER_BF1000) { + enableBf1000CustomMeasurements() + } } override fun writeUserDataToScale() { @@ -176,9 +207,16 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { return } - when (characteristic) { - p.chrUserList -> { - handleUserList(data, user) + when { + characteristic == p.chrUserList -> { + if (activeModel == Model.BEURER_BF1000) { + handleBf1000UserList(data, user) + } else { + handleUserList(data, user) + } + } + activeModel == Model.BEURER_BF1000 && isBf1000CustomMeasurementCharacteristic(characteristic) -> { + handleBf1000CustomMeasurementData(characteristic, data) } else -> super.onNotification(characteristic, data, user) @@ -194,6 +232,57 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { // ---- Vendor write helpers ------------------------------------------------- + private fun enableBf1000CustomMeasurements() { + val p = profile ?: return + + logD("Enabling BF1000 custom measurement characteristics") + bf1000CustomMeasurementChars.forEach { chr -> + setNotifyOn(p.service, chr) + } + } + + private fun handleBf1000CustomMeasurementData(characteristic: UUID, data: ByteArray) { + logD("BF1000 custom chr=${characteristic.shortId()} len=${data.size} ${data.toHexPreview(64)}") + + when { + characteristic == bf1000SegmentalFatMeasurement -> { + val decoded = BeurerBf1000Lib.parseSegmentalFatMeasurement(data) + if (decoded == null) { + logW("BF1000 segmental fat packet could not be decoded") + return + } + + logD( + "BF1000 segmental fat decoded, not persisted: visceral=${decoded.visceralFat} " + + "leftArm=${decoded.leftArm}% rightArm=${decoded.rightArm}% " + + "torso=${decoded.torso}% leftLeg=${decoded.leftLeg}% " + + "rightLeg=${decoded.rightLeg}%" + ) + } + characteristic == bf1000SegmentalMuscleMeasurement -> { + val decoded = BeurerBf1000Lib.parseSegmentalMuscleMeasurement(data) + if (decoded == null) { + logW("BF1000 segmental muscle packet could not be decoded") + return + } + + logD( + "BF1000 segmental muscle decoded, not persisted: leftArm=${decoded.leftArm}% " + + "rightArm=${decoded.rightArm}% torso=${decoded.torso}% " + + "leftLeg=${decoded.leftLeg}% rightLeg=${decoded.rightLeg}%" + ) + } + } + + if (characteristic == bf1000MeasurementStatus && + data.firstOrNull()?.toInt()?.and(0xFF) == 0x01) { + logD("BF1000 measurement-complete status received") + } + } + + private fun isBf1000CustomMeasurementCharacteristic(characteristic: UUID): Boolean = + characteristic in bf1000CustomMeasurementChars + private fun handleUserList(data: ByteArray, user : ScaleUser) { val parser = BluetoothBytesParser(data) @@ -261,6 +350,160 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { } } + private fun handleBf1000UserList(data: ByteArray, user : ScaleUser) { + if (data.isEmpty()) { + logW("Empty user-list packet") + return + } + + if (data.size == 1) { + when (u8(data, 0)) { + 2 -> { + if (scaleUserList.isEmpty() && bf1000AmbiguousScaleUserList.isEmpty()) { + // Status=2 -> no user on scale; clear any stale mapping and offer registration + logD("No users on scale, presenting create-only choice") + val appId = user.id + findKnownScaleIndexForAppUser(appId)?.let { idx -> + saveUserIdForScaleIndex(idx, -1) + saveConsentForScaleIndex(idx, -1) + logD("Cleared stale mapping for appUserId=$appId at scaleIndex=$idx") + } + presentCreateOnlyChoice() + } else { + logD("User-list received") + presentBf1000UserListIfConsentMissing(user) + } + return + } + + 1 -> { + // Status=1 -> user list complete + logD("User-list received") + presentBf1000UserListIfConsentMissing(user) + return + } + + else -> { + logW("Unknown user-list status packet: ${data.toHexPreview(16)}") + return + } + } + } + + parseBf1000ScaleUser(data)?.let { parsed -> + if (parsed.authoritativeSlot) { + upsertScaleUser(parsed.scaleUser) + } else { + bf1000AmbiguousScaleUserList += parsed.scaleUser + logD("ScaleUser deferred: $parsed") + } + } ?: logW("User-list entry could not be decoded: ${data.toHexPreview(32)}") + } + + private fun presentBf1000UserListIfConsentMissing(user: ScaleUser) { + resolveBf1000AmbiguousScaleUsers() + val scaleIndex = findKnownScaleIndexForAppUser(user.id) ?: -1 + if (loadConsentForScaleIndex(scaleIndex) == -1) { + presentChooseFromUsers(scaleUserList.sortedBy { it.id }) + } + } + + private fun resolveBf1000AmbiguousScaleUsers() { + if (bf1000AmbiguousScaleUserList.isEmpty()) { + return + } + + bf1000AmbiguousScaleUserList.forEach { candidate -> + if (scaleUserList.any { it.hasSameUserProfileAs(candidate) }) { + logD("ScaleUser deferred duplicate ignored: $candidate") + return@forEach + } + + val targetIndex = listOf(candidate.id, candidate.id + 1) + .filter { it in 1..255 } + .distinct() + .firstOrNull { slot -> scaleUserList.none { it.id == slot } } + + if (targetIndex != null) { + upsertScaleUser(candidate.copy(id = targetIndex)) + } else { + logW("ScaleUser deferred entry could not be assigned to a free slot: $candidate") + } + } + + bf1000AmbiguousScaleUserList.clear() + } + + private fun parseBf1000ScaleUser(data: ByteArray): ParsedBf1000ScaleUser? { + if (data.size < 12) { + logW("User-list entry too short: len=${data.size} ${data.toHexPreview(32)}") + return null + } + + val authoritativeSlot = u8(data, 1) > 0 + val index = when { + // Existing Beurer/Sanitas records use [kind, slot, initials...]. + authoritativeSlot -> u8(data, 1) + // BF1000 captures also show [slot-like, 00, initials...] records. + // They duplicate profile data, but their slot byte is not always + // authoritative, so they are resolved after the list is complete. + u8(data, 0) > 0 && u8(data, 1) == 0 -> u8(data, 0) + else -> { + logW("User-list entry has unknown slot layout: ${data.toHexPreview(32)}") + return null + } + } + + val rawInitials = data.copyOfRange(2, 5) + val initials = if (rawInitials.all { it == 0xFF.toByte() }) { + "unknown" + } else { + String(rawInitials, Charsets.US_ASCII) + .filter { it.isLetterOrDigit() } + .take(3) + .ifEmpty { "unknown" } + } + + val year = u16le(data, 5) + val month = u8(data, 7) + val day = u8(data, 8) + val height = u8(data, 9) + val gender = u8(data, 10) + val activityLevel = u8(data, 11) + + val calendar = GregorianCalendar(year, month - 1, day) + val scaleUser = ScaleUser().apply { + this.userName = initials + this.birthday = calendar.time + this.bodyHeight = height.toFloat() + this.gender = if (gender == 0) GenderType.MALE else GenderType.FEMALE + this.activityLevel = ActivityLevel.fromInt(activityLevel - 1) + this.id = index + } + + return ParsedBf1000ScaleUser( + scaleUser = scaleUser, + authoritativeSlot = authoritativeSlot + ) + } + + private fun upsertScaleUser(scaleUser: ScaleUser) { + val existingIndex = scaleUserList.indexOfFirst { it.id == scaleUser.id } + if (existingIndex >= 0) { + scaleUserList[existingIndex] = scaleUser + logD("ScaleUser updated: $scaleUser") + } else { + scaleUserList.add(scaleUser) + logD("ScaleUser added: $scaleUser") + } + } + + private fun ScaleUser.hasSameUserProfileAs(other: ScaleUser): Boolean = + birthday == other.birthday && + bodyHeight == other.bodyHeight && + gender == other.gender && + activityLevel == other.activityLevel + private fun writeActivityLevel(user: ScaleUser) { val lvl = (user.activityLevel.toInt() + 1).coerceIn(1, 5) profile?.chrActivity?.let { chr -> @@ -294,4 +537,13 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { } } } + + private fun UUID.shortId(): String = + String.format("0x%04x", (mostSignificantBits shr 32) and 0xFFFF) + + private fun u8(data: ByteArray, offset: Int): Int = + data[offset].toInt() and 0xFF + + private fun u16le(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 692ad7757..02b919ef8 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -171,6 +171,7 @@ object ScaleCatalog { device("SANITAS SBF73") claimedBy SanitasSbf72Handler::class.java, device("Beurer BF915") claimedBy SanitasSbf72Handler::class.java, device("Beurer BF105") claimedBy StandardBeurerSanitasHandler::class.java, + device("Beurer BF1000") claimedBy StandardBeurerSanitasHandler::class.java, device("Beurer BF500") claimedBy StandardBeurerSanitasHandler::class.java, device("Beurer BF600") claimedBy StandardBeurerSanitasHandler::class.java, device("Beurer BF950") claimedBy StandardBeurerSanitasHandler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000LibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000LibTest.kt new file mode 100644 index 000000000..93c22268b --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000LibTest.kt @@ -0,0 +1,80 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Tests for the Beurer BF1000 Super Precision private segmental decoder. + * + * The vectors are from an openScale file-log capture taken against a BF1000: + * Beurer-private FFFF/0009 segmental fat and FFFF/000A segmental muscle + * notifications. Standard 2A9D and 2A9C packets are handled by the existing + * StandardWeightProfileHandler. + */ +class BeurerBf1000LibTest { + private fun hex(s: String): ByteArray = + s.filterNot { it.isWhitespace() }.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + @Test + fun `decodes private segmental fat measurement`() { + val decoded = BeurerBf1000Lib.parseSegmentalFatMeasurement( + hex("7E 32 8E 00 8F 00 63 00 81 00 7C 00") + ) + + assertThat(decoded).isNotNull() + with(decoded!!) { + assertThat(visceralFat).isWithin(1e-4f).of(5.0f) + assertThat(leftArm).isWithin(1e-4f).of(14.2f) + assertThat(rightArm).isWithin(1e-4f).of(14.3f) + assertThat(torso).isWithin(1e-4f).of(9.9f) + assertThat(leftLeg).isWithin(1e-4f).of(12.9f) + assertThat(rightLeg).isWithin(1e-4f).of(12.4f) + } + } + + @Test + fun `decodes private segmental muscle measurement`() { + val decoded = BeurerBf1000Lib.parseSegmentalMuscleMeasurement( + hex("3E EA 01 E9 01 E8 01 F2 01 F5 01") + ) + + assertThat(decoded).isNotNull() + with(decoded!!) { + assertThat(leftArm).isWithin(1e-4f).of(49.0f) + assertThat(rightArm).isWithin(1e-4f).of(48.9f) + assertThat(torso).isWithin(1e-4f).of(48.8f) + assertThat(leftLeg).isWithin(1e-4f).of(49.8f) + assertThat(rightLeg).isWithin(1e-4f).of(50.1f) + } + } + + @Test + fun `rejects short or unexpected private packets`() { + assertThat(BeurerBf1000Lib.parseSegmentalFatMeasurement(ByteArray(11))).isNull() + assertThat(BeurerBf1000Lib.parseSegmentalMuscleMeasurement(ByteArray(10))).isNull() + + assertThat(BeurerBf1000Lib.parseSegmentalFatMeasurement( + hex("00 32 8E 00 8F 00 63 00 81 00 7C 00") + )).isNull() + assertThat(BeurerBf1000Lib.parseSegmentalMuscleMeasurement( + hex("00 EA 01 E9 01 E8 01 F2 01 F5 01") + )).isNull() + } +} From 3e861af35897703d3e72d1db5144ffbcbcee4db1 Mon Sep 17 00:00:00 2001 From: Rin Date: Sat, 29 Aug 2026 17:56:02 +0800 Subject: [PATCH 50/72] feat: Add support for Afu B1 (#1485) --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../openscale/core/bluetooth/libs/AfuB1Lib.kt | 620 ++++++++++++++++++ .../core/bluetooth/scales/AfuB1Handler.kt | 551 ++++++++++++++++ .../openscale/core/bluetooth/ScaleCatalog.kt | 2 + .../core/bluetooth/libs/AfuB1LibTest.kt | 362 ++++++++++ 5 files changed, 1537 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/AfuB1Lib.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AfuB1Handler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/AfuB1LibTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 427e30305..7d949a2cd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -22,6 +22,7 @@ import androidx.annotation.VisibleForTesting import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler import com.health.openscale.core.bluetooth.scales.AAAxHandler +import com.health.openscale.core.bluetooth.scales.AfuB1Handler import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler import com.health.openscale.core.bluetooth.scales.ScaleupHandler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler @@ -131,6 +132,7 @@ class ScaleFactory @Inject constructor( internal fun createHandlers(): List = listOf( // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). ActiveEraBF06Handler(), + AfuB1Handler(), KeepS3Handler(), OmronWlcHandler(), BeurerBF450Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/AfuB1Lib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/AfuB1Lib.kt new file mode 100644 index 000000000..427d45fa5 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/AfuB1Lib.kt @@ -0,0 +1,620 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +/** + * Wire protocol of the scale AFU-BH-TZ-B1, + * reverse-engineered from the official AFU Android app + * + * Every frame is exactly 20 bytes, in both directions: + * + * [0xAF][0x45][sub-cmd @2][body @3..17][wire type @18][crc @19] + * + * crc = sum(raw[2..18]) & 0x1F — every captured frame validates against it. + * + * The 18th byte ("wire type") says what the frame IS, the 2nd byte ("sub-command") + * disambiguates within that type: + * + * wire type 0x54 stored / history record (scale -> phone) + * wire type 0x51 live weight (scale -> phone) + * wire type 0x52 single-impedance result (scale -> phone) + * wire type 0x5f control frames (both directions): + * sub-cmd 0x12 = ACK, 0x14 = chunk request/report chunk, + * 0x15 = close report, 0x16 = time-sync + * wire type 0x62 sync-request (phone -> scale only) + * wire type 0x61 user profile (phone -> scale only) + * + * A typical session (verified against btsnoop_hci_260817_140338.log timestamps): + * + * 1. Handshake: time-sync -> 2x sync-request. The profile must NOT be sent yet — + * sending it early switches the scale into weighing mode and blocks history. + * 2. Idle: with nobody standing on it the scale starts pushing its stored + * measurements (0x54), but on its own it repeats the same record forever, no + * matter how often it is ACKed. A history-dump request (0x8e) switches it + * into export mode: every stored record is sent once, each ACKed before the + * next one arrives (the correlation index echoes the record sequence). + * 3. First weigh: live weight streams (0x51) until the weight is marked final + * (state bit) and the phone ACKs. Only after the scale goes idle again + * (user stepped off) does the phone + * re-sync and send profile 0x4b + a chunk request (0x14) to PULL the fresh committed + * record (0x54), followed by 7 report chunks and a closing sub-cmd 0x15 request. + * The report payload is [per-session bytes]["AFU-BH-TZ-B1"+MAC in plaintext][zeros]; + * it is not needed — body composition comes from weight + impedance of the 0x54. + * 4. Arming: profile 0x5b tags the NEXT weigh-in for an impedance measurement and + * profile 0x68 closes the session. Both are optional: the scale commits and serves + * every weigh with plain weight + resistance even when no profiles are sent at all. + * 5. Second weigh (same connection, armed by 0x5b): once its weight is marked + * final and is ACKed the scale replies with 0x52 (single impedance result) + * instead of a stored record. + * A 0x52 therefore never occurs in a single-weigh session — its absence is normal. + * + * Profile bodies carry a per-variant "open byte" pair (`af XX` between and + * the 0x17 byte), copied from the official captures. + * + * Timestamps are Unix epoch seconds: LE32 in stored records, `ConvertIntEndian(now)` + * bytes big-endian in time-sync/profile frames. + */ +object AfuB1Lib { + + const val FRAME_SIZE = 20 + + // ---- Wire types (raw[18]) -------------------------------------------------- + + const val WIRE_STORED_RECORD = 0x54 // scale -> phone + const val WIRE_IMPEDANCE_RESULT = 0x52 // scale -> phone + const val WIRE_LIVE_WEIGHT = 0x51 // scale -> phone + const val WIRE_CONTROL = 0x5F // both directions + const val WIRE_SYNC_REQUEST = 0x62 // phone -> scale + const val WIRE_USER_PROFILE = 0x61 // phone -> scale + + // ---- Sub-commands (raw[2]) ------------------------------------------------- + + const val CMD_TIME_SYNC = 0x16 + const val CMD_SYNC_REQUEST = 0x04 + const val CMD_ACK = 0x12 + const val CMD_BODY_COMP_CHUNK = 0x14 // scale: chunk payload / phone: chunk request + const val CMD_HISTORY_DUMP = 0x8E // profile variant: dump the full history queue + + /** + * Purpose of a user-profile frame (raw[2] of wire type 0x61). The scale treats each + * code differently, so they are an enum, not free ints. [openByte] is the `af XX` + * pair in the body, copied from the official captures per variant. + */ + enum class ProfileVariant(val code: Int, val openByte: Int) { + /** First contact once live weighing has started. */ + INITIAL(0x3F, 0xB6), + + /** Re-measure: makes the scale commit the weigh and push the fresh record. */ + GET_WEIGH_AGAIN_1(0x4B, 0xB6), + GET_WEIGH_AGAIN_2(0x5B, 0xD9), + + /** Finalize the session and arm the next weigh. */ + FINAL(0x68, 0xD9) + } + + /** The two sync-requests of the handshake, in captured order. */ + enum class SyncRequestVariant { FIRST, SECOND } + + // Weight is stored in the low 18 bits of the (endian-swapped) flags word. + const val WEIGHT_G_MASK = 0x3FFFF + + // Body-composition table constants. + private const val BAND_LOW = 4.0 + private const val BAND_HIGH = 75.0 + private const val BMI_SCALE = 10000.0 + private const val VFAL_DIV = 10000.0 + + // --- Endian helpers ---------------------------------------------------- + // Faithful port of the j3/j3.c byte-swap functions the app uses: values are stored + // little-endian but read back byte-swapped. + + /** Byte-reverses a 32-bit word (the app's ConvertIntEndian). */ + fun convertIntEndian(v: Int): Int = + ((v and 0xFF) shl 24) or ((v and 0xFF00) shl 8) or + ((v ushr 8) and 0xFF00) or ((v ushr 24) and 0xFF) + + // --- Commands (phone -> scale) ----------------------------------------- + + /** af45 16 20 00...00 5f — syncs the scale's clock to the phone's. */ + fun buildTimeSync(nowEpochSeconds: Long): ByteArray = + buildFrame(CMD_TIME_SYNC, WIRE_CONTROL, time4(nowEpochSeconds) + byteArrayOf(0x20) + ByteArray(10)) + + /** + * af45 04 62 — one of the two sync-requests of the handshake. + * + * Some body bytes (the 0xb6/0xf8/0x11 "open byte", the 0x17 constant, the counter + * bytes 0x1d/0x56/0x02/0x04) were captured verbatim from one session of the official + * app and may be session-dependent — tweak if the scale rejects pairing. + */ + fun buildSyncRequest(variant: SyncRequestVariant): ByteArray { + val body = if (variant == SyncRequestVariant.FIRST) { + byteArrayOf( + 0x00, 0x01, 0xAF.toByte(), 0xB6.toByte(), 0x17, 0x1D, 0x01, + 0x00, 0x02, 0xB4.toByte(), 0xC6.toByte(), 0x1B, 0x56, 0x01, 0x00 + ) + } else { + byteArrayOf( + 0x01, 0x03, 0xA5.toByte(), 0xF8.toByte(), 0x11, 0x1D, 0x02, + 0x00, 0x04, 0xB4.toByte(), 0xC6.toByte(), 0x1B, 0x1D, 0x01, 0x00 + ) + } + return buildFrame(CMD_SYNC_REQUEST, WIRE_SYNC_REQUEST, body) + } + + /** + * af45 00 af b6 17 03 00 61 . + * Sends the user's age/sex/target weight to the scale. [variant] picks the purpose + * (see [ProfileVariant]). [sex] 1 = male, 0 = female. + */ + fun buildUserProfile( + variant: ProfileVariant, + nowEpochSeconds: Long, + user: Int, + age: Int, + sex: Int, + targetKg: Float + ): ByteArray { + val targetX100 = Math.round(targetKg * 100f) and 0xFFFF + val body = time4(nowEpochSeconds) + byteArrayOf( + 0x00, (user and 0xFF).toByte(), + 0xAF.toByte(), variant.openByte.toByte(), 0x17, + (age and 0xFF).toByte(), (sex and 0xFF).toByte(), + (targetX100 and 0xFF).toByte(), ((targetX100 ushr 8) and 0xFF).toByte(), + 0x03, 0x00 + ) + return buildFrame(variant.code, WIRE_USER_PROFILE, body) + } + + /** + * af45 8e 00 af de 17 03 00 61 . + * Tells the scale to export its ENTIRE history queue. Without this request the + * scale just repeats the same stored record forever. + */ + fun buildHistoryDumpRequest( + nowEpochSeconds: Long, + user: Int, + age: Int, + sex: Int, + targetKg: Float + ): ByteArray { + val targetX100 = Math.round(targetKg * 100f) and 0xFFFF + val body = time4(nowEpochSeconds) + byteArrayOf( + 0x00, (user and 0xFF).toByte(), + 0xAF.toByte(), 0xDE.toByte(), 0x17, + (age and 0xFF).toByte(), (sex and 0xFF).toByte(), + (targetX100 and 0xFF).toByte(), ((targetX100 ushr 8) and 0xFF).toByte(), + 0x03, 0x00 + ) + return buildFrame(CMD_HISTORY_DUMP, WIRE_USER_PROFILE, body) + } + + /** + * af45 12 00 00...00 5f . + * Acknowledges a received frame. `replyWireType` is the wire type of the frame being + * acknowledged (0x54/0x51/0x52, or 0x14 for body-comp chunks) and `correlationIndex` + * the record sequence for history records (0 otherwise). + */ + fun buildAck(replyWireType: Int, correlationIndex: Int): ByteArray = + buildFrame( + CMD_ACK, WIRE_CONTROL, + byteArrayOf((replyWireType and 0xFF).toByte(), 0x00, (correlationIndex and 0xFF).toByte()) + ByteArray(12) + ) + + /** Asks the scale to stream the (encrypted, undecodable) report chunks of the last weigh. */ + fun buildChunkRequest(): ByteArray = buildFrame(CMD_BODY_COMP_CHUNK, WIRE_CONTROL, ByteArray(15)) + + // --- Incoming frames (scale -> phone) ---------------------------------- + + sealed class ScaleFrame { + /** The wire type byte (raw[18]) this frame arrived as. */ + abstract val wireType: Int + + /** What an ACK for this frame must echo (its wire type, 0x14 for chunks). */ + abstract val ackReplyByte: Int + + /** Stored / history record (wire type 0x54). */ + data class StoredRecord( + val historyType: Int, + val sequence: Int, + val timestampEpochSeconds: Long, + val weightGrams: Int, + val hasElectrode: Boolean, + val hasHeartRate: Boolean, + val hasPhaseAngle: Boolean, + val hasZx: Boolean, // vendor flag, purpose unknown + val hasTemperature: Boolean, + val locked: Boolean, // scale considers this weight final + val resistanceOhms: Int + ) : ScaleFrame() { + override val wireType = WIRE_STORED_RECORD + override val ackReplyByte = WIRE_STORED_RECORD + } + + /** Live weight while someone stands on the scale (wire type 0x51). */ + data class LiveWeight( + val weightGrams: Int, + val mode: Int, // 0x02 = live-weight subtype + val locked: Boolean // scale signals the weight is final + ) : ScaleFrame() { + override val wireType = WIRE_LIVE_WEIGHT + override val ackReplyByte = WIRE_LIVE_WEIGHT + } + + /** Single-impedance measurement result (wire type 0x52). */ + data class ImpedanceResult( + val valid: Boolean, // sub-cmd 0x01 marks a valid result + val adcOhms: Int // resistance in Ohm + ) : ScaleFrame() { + override val wireType = WIRE_IMPEDANCE_RESULT + override val ackReplyByte = WIRE_IMPEDANCE_RESULT + } + + /** One 14-byte chunk of the scale's encrypted report (0x5f/0x14). Purpose unknown. */ + data class BodyCompChunk( + val chunkIndex: Int, + val payload: ByteArray + ) : ScaleFrame() { + override val wireType = WIRE_CONTROL + override val ackReplyByte = CMD_BODY_COMP_CHUNK + } + + /** Inbound acknowledgment (0x5f/0x12). */ + data class Acknowledgement( + val replyCmd: Int, + val replyOp: Int + ) : ScaleFrame() { + override val wireType = WIRE_CONTROL + override val ackReplyByte = 0 // ACKs are not acknowledged + } + + /** Other control frame, e.g. an echoed time-sync (0x5f). */ + data class ControlFrame(val cmd: Int) : ScaleFrame() { + override val wireType = WIRE_CONTROL + override val ackReplyByte = 0 + } + } + + /** + * Decode one 20-byte frame. Returns null for anything that is not a valid AFU frame + * (wrong length, wrong header, bad CRC, or a phone->scale frame the scale never sends). + */ + fun parse(raw: ByteArray): ScaleFrame? { + if (raw.size != FRAME_SIZE || + (raw[0].toInt() and 0xFF) != 0xAF || + (raw[1].toInt() and 0xFF) != 0x45 + ) return null + if (!crcValid(raw)) return null + + val cmd = raw[2].toInt() and 0xFF + return when (raw[18].toInt() and 0xFF) { + WIRE_STORED_RECORD -> parseStoredRecord(cmd, raw) + WIRE_IMPEDANCE_RESULT -> parseImpedanceResult(cmd, raw) + WIRE_LIVE_WEIGHT -> parseLiveWeight(raw) + WIRE_CONTROL -> when (cmd) { + CMD_BODY_COMP_CHUNK -> ScaleFrame.BodyCompChunk( + chunkIndex = raw[3].toInt() and 0xFF, + payload = raw.copyOfRange(4, 18) + ) + + CMD_ACK -> ScaleFrame.Acknowledgement( + replyCmd = raw[3].toInt() and 0xFF, + replyOp = raw[4].toInt() and 0xFF + ) + + else -> ScaleFrame.ControlFrame(cmd) + } + + WIRE_SYNC_REQUEST, WIRE_USER_PROFILE -> null // phone -> scale only + else -> null + } + } + + private fun parseStoredRecord(cmd: Int, raw: ByteArray): ScaleFrame.StoredRecord { + // j3.e() case 84: first byte = history_type(bits 0-1) + sequence(bits 2-7) + val timestamp = (raw[3].toLong() and 0xFF) or ((raw[4].toLong() and 0xFF) shl 8) or + ((raw[5].toLong() and 0xFF) shl 16) or ((raw[6].toLong() and 0xFF) shl 24) + val weightFlags = convertIntEndian(be32(raw, 7)) + return ScaleFrame.StoredRecord( + historyType = cmd and 0x03, + sequence = (cmd ushr 2) and 0x3F, + timestampEpochSeconds = timestamp, + weightGrams = weightFlags and WEIGHT_G_MASK, + hasElectrode = weightFlags.bit(24), + hasHeartRate = weightFlags.bit(25), + hasPhaseAngle = weightFlags.bit(26), + hasZx = weightFlags.bit(27), + hasTemperature = weightFlags.bit(28), + locked = weightFlags.bit(31), + // resistance always present in 0x54 frames, LE16 at raw[13..14] + resistanceOhms = le16(raw, 13) + ) + } + + private fun parseLiveWeight(raw: ByteArray): ScaleFrame.LiveWeight { + val weightFlags = convertIntEndian(be32(raw, 2)) + return ScaleFrame.LiveWeight( + weightGrams = weightFlags and WEIGHT_G_MASK, + mode = raw[6].toInt() and 0xFF, + locked = weightFlags.bit(31) + ) + } + + private fun parseImpedanceResult(cmd: Int, raw: ByteArray): ScaleFrame.ImpedanceResult = + ScaleFrame.ImpedanceResult( + valid = cmd == 0x01, + adcOhms = le16(raw, 4) + ) + + // ---------------------------------------------------------------------------- + // Body composition from weight + impedance. The arithmetic deliberately mixes + // single/double precision: every .toFloat() boundary below is load-bearing. + // ---------------------------------------------------------------------------- + + /** + * Body-fat percentage. Sex-aware (1 = male, 0 = female); impedance 0 -> null. + * Clamped to [5, 80] and rounded half-even to one decimal. + */ + fun bodyFatPercent( + weightKg: Double, + heightCm: Double, + age: Int, + sex: Int, + impedanceOhm: Double, + ): Float? { + if (impedanceOhm == 0.0) return null + val h = (heightCm / 100.0).toFloat() + val hd = h.toDouble() + val h2 = hd * hd + var r: Double + if (sex == 1) { + val h2f = (h * (h * -486583.0f)).toDouble() + r = 1625303.0 / impedanceOhm / impedanceOhm + r += 9.146 * weightKg / h2 / impedanceOhm + r += h2f / weightKg / impedanceOhm + 61.8 + r += -251.193 * h2 / weightKg / age + r += impedanceOhm * -0.0139 + r += age * 0.05975 + } else { + val h2f = (h * (h * -382280.0f)).toDouble() + r = -186.422 * h2 / weightKg + 58.82 + r += h2f / weightKg / impedanceOhm + r += 128.005 * weightKg / hd / impedanceOhm + r += -0.0728 * weightKg / hd + r += 7816.359 / hd / impedanceOhm + r += -3.333 * weightKg / h2 / age + } + val clamped = r.toFloat().coerceIn(5f, 80f) + return (Math.rint(clamped * 10.0) / 10.0).toFloat() + } + + /** The composition fields openScale publishes for one measurement. */ + data class BodyComposition( + val waterPercent: Double, + val skeletalMusclePercent: Double, + val boneMassKg: Float, + val visceralFatIndex: Int, + val proteinPercent: Double, + val fatFreeMassKg: Float, + ) + + /** + * Derives water %, skeletal muscle %, bone mass, the visceral fat index, + * protein % and fat-free mass from weight and [bodyFatPercent]. Returns null + * outside the validated input ranges. + */ + fun bodyComposition( + sex: Int, + age: Int, + heightCm: Float, + weightKg: Float, + bodyFatPercent: Float, + ): BodyComposition? { + if (sex > 1 || age !in 10..99) return null + if (heightCm < 90f || heightCm > 240f || weightKg < 10f || weightKg > 200f) return null + + val bf = bodyFatPercent.toDouble() + val w = weightKg.toDouble() + val h = heightCm.toDouble() + + // _classify: fat % bands below 4 / above 75 swap fixed values in; degenerate + // f64 edge windows are rejected outright by the reference implementation. + if ((bf <= BAND_LOW && bf <= 3.999) || (bf >= BAND_HIGH && (bf <= 74.999 || bf > 75.001))) { + return null + } + val bandLow = bf <= BAND_LOW + val bandHigh = bf >= BAND_HIGH + + val bmi = (w * BMI_SCALE / (heightCm * heightCm).toDouble()).toFloat() + + // _recompute_bf: back-solve the internal fat rate from BMI + measured fat %. + val recomputedBf: Float = run { + val num: Double + val denom: Double + if (sex == 0) { + if (age <= 15) { + num = ((-556421.0 / bmi).toFloat() + 17387.0).toFloat().toDouble() + denom = bf - 44.19 + } else { + num = ((-518695.0 / bmi).toFloat() + 15802.0).toFloat().toDouble() + denom = bf - 42.86 + age * -0.057 + } + } else { + if (age <= 15) { + num = ((-506791.0 / bmi).toFloat() + 12009.0).toFloat().toDouble() + denom = bf - 63.25 + age * 1.121 + } else { + num = ((-460857.0 / bmi).toFloat() + 10645.0).toFloat().toDouble() + denom = bf - 37.74 + age * -0.0511 + } + } + (num / denom).toFloat() + } + + // _body_rate (c0), with band replacements. + val c0: Float = when { + bandLow -> (if (sex == 0) 66.5 else 66.0).toFloat() + bandHigh -> (if (sex == 0) 18.0 else 15.0).toFloat() + sex == 0 -> { + var s2 = (3752.4 * heightCm).toFloat(); s2 *= heightCm + var s3 = (444.93 + (556707.0 / recomputedBf).toFloat()).toFloat() + s2 /= recomputedBf + s3 /= weightKg + s2 = (s2 / 10000.0).toFloat() + val sa = (10276.0 / recomputedBf).toFloat() + s2 = s3 + s2 + (24.305 + (s2 - sa)).toFloat() + } + else -> { + var s2 = (3705.3 * heightCm).toFloat(); s2 *= heightCm + var s3 = (299.43 + (696819.0 / recomputedBf).toFloat()).toFloat() + s2 /= recomputedBf + s3 /= weightKg + s2 = (s2 / 10000.0).toFloat() + val sa = (10770.0 / recomputedBf).toFloat() + s2 = s3 + s2 + (29.61 + (s2 - sa)).toFloat() + } + } + val c4 = (c0.toDouble() * w / 100.0).toFloat() + + // Bone mass. + val boneMassKg: Float = if (bandLow || bandHigh) { + val fac = if (bandLow) (if (sex == 0) 5.8 else 5.0) else (if (sex == 0) 1.6 else 1.3) + ((w * fac).toFloat() / 100.0).toFloat() + } else { + val fac = if (sex == 0) 0.061 else 0.052 + val p1 = (w * fac).toFloat() + val p2 = (100.0 - bf).toFloat() + (p1.toDouble() * p2.toDouble() / 100.0).toFloat() + } + + // Skeletal muscle rate (s1) and total muscle rate (s4). + val s1: Float + val s4: Float + if (bandLow) { + s1 = (if (sex == 0) 56.6 else 59.0).toFloat() + s4 = (if (sex == 0) 90.2 else 91.0).toFloat() + } else if (bandHigh) { + s1 = (if (sex == 0) 15.1 else 17.9).toFloat() + s4 = (if (sex == 0) 23.4 else 23.7).toFloat() + } else { + s1 = if (sex == 0) c0 * 0.857f - 0.36f else c0 * 0.895f + val mf = if (sex == 0) 0.939f else 0.948f + s4 = mf * (100f - bf.toFloat()) + } + val skeletalMusclePercent = ((s1 * w).toFloat() / 100.0).toFloat() * 100.0 / w + + // Protein rate feeds the water derivation. + val muscleKg = ((s4 * w).toFloat() / 100.0).toFloat() + val proteinRate = ((muscleKg - c4) * 100.0 / w).toFloat() + + // Visceral fat index (VFAL). + val vfal: Int = when { + bandLow -> 1 + bandHigh -> 30 + sex == 1 -> { + val bf64 = recomputedBf.toDouble() + var acc = 77.216 / bf64 + 0.068 + acc *= w + val d17 = acc + 0.5 + var d5 = (-5489.0 / recomputedBf).toFloat().toDouble() + 2.8 + d5 *= h; d5 *= h + var d6 = d17 + d5 / VFAL_DIV + d6 += 6096.3 / bf64 + val raw = (-1.22 + (d6 + 0.1668 * age)).toInt() + if (raw <= 0) 1 else if (raw > 30) 30 else raw + } + else -> { + val bf64 = recomputedBf.toDouble() + var acc = 71.92 / bf64 + 0.0876 + acc *= w + val d17 = acc + 0.5 + var d5 = (-1261.7 / bf64).toFloat().toDouble() - 2.88 + d5 *= h; d5 *= h + var d6 = d17 + d5 / VFAL_DIV + d6 += -1545.6 / bf64 + val raw = (5.9 + (d6 + 0.068 * age)).toInt() + if (raw <= 0) 1 else if (raw > 30) 30 else raw + } + } + + val bonePct = boneMassKg * 100.0 / w + + // Fat-free mass: w + w*bf/(-100). + val fatMassKg = (w * bf).toFloat() + val fatFreeMassKg = (w + (fatMassKg / -100.0).toFloat()).toFloat() + + return BodyComposition( + waterPercent = 100.0 - bf - bonePct - proteinRate, + skeletalMusclePercent = skeletalMusclePercent, + boneMassKg = boneMassKg, + visceralFatIndex = vfal, + proteinPercent = proteinRate.toDouble(), + fatFreeMassKg = fatFreeMassKg, + ) + } + + + // --- Helpers ----------------------------------------------------------- + + /** Build a frame: AF 45 [sub-cmd@2][body@3..17][wire type@18][crc@19]. */ + private fun buildFrame(cmd: Int, wireType: Int, body: ByteArray): ByteArray { + require(body.size == 15) { "AFU frame body must be 15 bytes, got ${body.size}" } + val b = ByteArray(FRAME_SIZE) + b[0] = 0xAF.toByte() + b[1] = 0x45.toByte() + b[2] = (cmd and 0xFF).toByte() + body.copyInto(b, 3) + b[18] = (wireType and 0xFF).toByte() + b[19] = crc(b).toByte() + return b + } + + /** Timestamp slot: ConvertIntEndian(now) stored as raw big-endian bytes. */ + private fun time4(nowEpochSeconds: Long): ByteArray { + val v = convertIntEndian((nowEpochSeconds and 0xFFFFFFFFL).toInt()) + return byteArrayOf( + ((v ushr 24) and 0xFF).toByte(), + ((v ushr 16) and 0xFF).toByte(), + ((v ushr 8) and 0xFF).toByte(), + (v and 0xFF).toByte() + ) + } + + /** Checksum over raw[2..18]: byte sum, transmitted in the lower 5 bits. */ + private fun crc(raw: ByteArray): Int { + var sum = 0 + for (i in 2 until 19) sum += raw[i].toInt() and 0xFF + return sum and 0x1F + } + + private fun crcValid(raw: ByteArray): Boolean = + crc(raw) == (raw[19].toInt() and 0x1F) + + /** Little-endian 16-bit word at [off]. */ + private fun le16(raw: ByteArray, off: Int): Int = + (raw[off].toInt() and 0xFF) or ((raw[off + 1].toInt() and 0xFF) shl 8) + + /** True if bit [n] of the flags word is set. */ + private fun Int.bit(n: Int): Boolean = ((this ushr n) and 1) == 1 + + /** Big-endian 32-bit word at [start]. */ + private fun be32(raw: ByteArray, start: Int): Int = + ((raw[start].toInt() and 0xFF) shl 24) or ((raw[start + 1].toInt() and 0xFF) shl 16) or + ((raw[start + 2].toInt() and 0xFF) shl 8) or (raw[start + 3].toInt() and 0xFF) +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AfuB1Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AfuB1Handler.kt new file mode 100644 index 000000000..76f314ccf --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AfuB1Handler.kt @@ -0,0 +1,551 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.AfuB1Lib +import com.health.openscale.core.bluetooth.libs.AfuB1Lib.ScaleFrame +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.UUID +import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds + +class AfuB1Handler : ScaleDeviceHandler() { + + companion object { + // Service advertised by the scale (and the GATT service carrying the vendor data channel). + val SERVICE_UUID = UUID.fromString("0000fc50-0000-1000-8000-00805f9b34fb") + + // The frame write channel of the AFU app (service af000000-9f2d-4c8a-8d6f-6a7b45f90000). + val WRITE_SERVICE = UUID.fromString("af000000-9f2d-4c8a-8d6f-6a7b45f90000") + val WRITE_CHARACTERISTIC = UUID.fromString("af000002-9f2d-4c8a-8d6f-6a7b45f90000") + + // The app always talks to scale user slot 1. + private const val SCALE_USER_SLOT = 1 + + // Timing of the commit flow after the weight is marked final: + // IDLE_DELAY_MS — scale is considered idle (user stepped off) after this long + // without a single frame; only then may the weigh be committed. + // IDLE_POLL_MS — how often the commit coroutine re-checks the idle condition. + // COMMIT_TIMEOUT — give the scale this long to push the fresh record after the + // re-sync + getWeighAgain, then fall back to the live weight. + private const val IDLE_DELAY_MS = 2_000L + private const val IDLE_POLL_MS = 250L + private const val COMMIT_TIMEOUT_MS = 15_000L + + // Match tolerances of a "fresh committed" record — full rule on isFreshCommitted(). + private const val FRESH_RECORD_WINDOW_S = 600L + private const val FRESH_WEIGHT_TOLERANCE_G = 1_000 + } + + // ---- User profile cached from the app --------------------------------------- + + private var age = 30 + private var sex = 1 // 1 = male, 0 = female (AFU convention) + private var targetKg = 60.0f + + // ---- Session state ------------------------------------------------------------ + // Cross-thread state (GATT callback vs commit coroutine), hence @Volatile. + + @Volatile + private var handshakeComplete = false + + @Volatile + private var liveWeighInProgress = false + + @Volatile + private var profileSent = false + + @Volatile + private var historyDumpRequested = false + + @Volatile + private var weightIsFinal = false + + @Volatile + private var commitInProgress = false + + @Volatile + private var committedRecordSeen = false + + @Volatile + private var lastLiveWeightG: Int? = null + + @Volatile + private var lastFrameAt = 0L + + @Volatile + private var weighStartedAt = 0L + private var commitJob: Job? = null + + // --- Device discovery -------------------------------------------------- + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.uppercase() + val hasService = device.serviceUuids.contains(SERVICE_UUID) + val nameMatches = name.contains("AFU") + if (!hasService || !nameMatches) return null + + val caps = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.HISTORY_READ, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC + ) + return DeviceSupport( + displayName = "AFU-BH-TZ-B1", + capabilities = caps, + implemented = caps, + linkMode = LinkMode.CONNECT_GATT + ) + } + + // --- Connection -------------------------------------------------------- + + override fun onConnected(user: ScaleUser) { + resetSession() + + age = user.age + sex = if (user.gender == GenderType.MALE) 1 else 0 + targetKg = when { + user.goalWeight > 0f -> user.goalWeight + user.initialWeight > 0f -> user.initialWeight + else -> 60.0f + } + + subscribeToAllCharacteristics() + lastFrameAt = System.currentTimeMillis() + sendHandshake() + // Drain the stored history right away; if the scale ignores an early 0x8e, + // onStoredRecord re-sends it once the first record arrives. + sendHistoryDumpRequest() + logI("history-dump request (0x8e) sent right after handshake") + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onDisconnected() { + commitJob?.cancel() + commitJob = null + } + + /** Forget everything from a previous connection — the handler instance is shared. */ + private fun resetSession() { + commitJob?.cancel() + commitJob = null + handshakeComplete = false + liveWeighInProgress = false + profileSent = false + historyDumpRequested = false + weightIsFinal = false + commitInProgress = false + committedRecordSeen = false + lastLiveWeightG = null + lastFrameAt = 0L + weighStartedAt = 0L + } + + /** Subscribe to every notify/indicate characteristic (the scale reports on one of them). */ + private fun subscribeToAllCharacteristics() { + val peripheral = getPeripheral() ?: return + for (service in peripheral.services) { + for (char in service.characteristics ?: continue) { + if ((char.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 || + (char.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0 + ) { + setNotifyOn(service.uuid, char.uuid) + } + } + } + } + + // --- Commands (phone -> scale) ----------------------------------------- + + /** The service actually hosting the write characteristic, resolved from the GATT tree. */ + private fun writeService(): UUID { + val peripheral = getPeripheral() + if (peripheral != null) { + for (service in peripheral.services) { + if (service.characteristics?.any { it.uuid == WRITE_CHARACTERISTIC } == true) { + return service.uuid + } + } + logW("write characteristic not found in GATT tree, using default service") + } + return WRITE_SERVICE + } + + /** Handshake: time-sync + 2x sync-request. No profile — a profile 0x3f sent too + * early switches the scale into weighing mode and blocks the stored-history dump. */ + private fun sendHandshake() { + sendTimeSync() + sendSyncRequestPair() + handshakeComplete = true + logI("handshake sent (time-sync + 2x sync-request)") + } + + private fun sendTimeSync() { + writeTo( + writeService(), WRITE_CHARACTERISTIC, + AfuB1Lib.buildTimeSync(System.currentTimeMillis() / 1000L) + ) + } + + private fun sendSyncRequestPair() { + writeTo( + writeService(), WRITE_CHARACTERISTIC, + AfuB1Lib.buildSyncRequest(AfuB1Lib.SyncRequestVariant.FIRST) + ) + writeTo( + writeService(), WRITE_CHARACTERISTIC, + AfuB1Lib.buildSyncRequest(AfuB1Lib.SyncRequestVariant.SECOND) + ) + } + + /** The user profile (age/sex/target weight) with one of the profile variants. */ + private fun sendUserProfile(variant: AfuB1Lib.ProfileVariant) { + writeTo( + writeService(), WRITE_CHARACTERISTIC, AfuB1Lib.buildUserProfile( + variant = variant, + nowEpochSeconds = System.currentTimeMillis() / 1000L, + user = SCALE_USER_SLOT, + age = age, + sex = sex, + targetKg = targetKg + ) + ) + } + + /** Tells the scale to dump its full history queue. */ + private fun sendHistoryDumpRequest() { + writeTo( + writeService(), WRITE_CHARACTERISTIC, AfuB1Lib.buildHistoryDumpRequest( + nowEpochSeconds = System.currentTimeMillis() / 1000L, + user = SCALE_USER_SLOT, + age = age, + sex = sex, + targetKg = targetKg + ) + ) + } + + /** Acknowledge a received frame; history records carry their sequence as correlation. */ + private fun sendAck(frame: ScaleFrame) { + val correlation = (frame as? ScaleFrame.StoredRecord)?.sequence ?: 0 + writeTo( + writeService(), WRITE_CHARACTERISTIC, + AfuB1Lib.buildAck(frame.ackReplyByte, correlation) + ) + } + + /** GetWeighAgain profiles (0x4b/0x5b) + chunk request — pulls the fresh committed weigh. */ + private fun sendGetWeighAgain() { + sendUserProfile(AfuB1Lib.ProfileVariant.GET_WEIGH_AGAIN_1) + sendUserProfile(AfuB1Lib.ProfileVariant.GET_WEIGH_AGAIN_2) + writeTo(writeService(), WRITE_CHARACTERISTIC, AfuB1Lib.buildChunkRequest()) + } + + /** Finalize a weigh and arm the scale for the next one (mirrors the app's ending). */ + private fun sendFinalize() { + sendUserProfile(AfuB1Lib.ProfileVariant.FINAL) + sendTimeSync() + sendSyncRequestPair() + } + + // --- Incoming frames (scale -> phone) ---------------------------------- + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + lastFrameAt = System.currentTimeMillis() + val frame = AfuB1Lib.parse(data) + if (frame == null) { + logD("not an AFU frame (len=${data.size}) ${data.toHexPreview(20)}") + return + } + when (frame) { + is ScaleFrame.StoredRecord -> onStoredRecord(frame, user) + is ScaleFrame.LiveWeight -> onLiveWeight(frame) + is ScaleFrame.ImpedanceResult -> onImpedanceResult(frame) + is ScaleFrame.BodyCompChunk -> onBodyCompChunk(frame) + is ScaleFrame.Acknowledgement -> + logD("RX ACK reply_cmd=0x${frame.replyCmd.toString(16)} op=0x${frame.replyOp.toString(16)}") + + is ScaleFrame.ControlFrame -> + logD("RX control cmd=0x${frame.cmd.toString(16)}") + } + } + + /** + * A stored record (0x54). On its own the scale repeats the same stored record + * forever, so the first idle record triggers a history-dump request (0x8e) + * that switches it to export mode; in export mode every record must be ACKed + * before the next one arrives. After a weigh commit, the pushed record is the + * fresh measurement. + */ + private fun onStoredRecord(record: ScaleFrame.StoredRecord, user: ScaleUser) { + logStoredRecord(record) + sendAck(record) + + // Idle: answer the first record with a history-dump request to drain the FULL + // queue. During a live weigh the scale ignores it, so gate it on no live frames. + if (!liveWeighInProgress && !historyDumpRequested) { + historyDumpRequested = true + sendHistoryDumpRequest() + logI("idle: history-dump request (0x8e) sent to drain the full queue") + } + + publishMeasurement( + user = user, + weightKg = record.weightGrams / 1000.0f, + impedanceOhms = record.resistanceOhms.toFloat(), + timestamp = record.timestampEpochSeconds + ) + + if (isFreshCommitted(record) && !committedRecordSeen) { + committedRecordSeen = true + commitJob?.cancel() + logI("committed weigh received — draining any remaining history") + startPostWeighDrain() + } else { + logI("stored history imported (not the fresh weigh) — continuing session") + } + } + + /** Live weight streaming (0x51) while someone stands on the scale. */ + private fun onLiveWeight(weight: ScaleFrame.LiveWeight) { + liveWeighInProgress = true + if (weighStartedAt == 0L) weighStartedAt = System.currentTimeMillis() + lastLiveWeightG = weight.weightGrams + logI( + "RX 0x51 live ${weight.weightGrams / 1000.0f}kg mode=${weight.mode} " + + "state=${if (weight.locked) 1 else 0}" + ) + + // Live weighing has started: now (and only now) send the user profile. + if (!profileSent && handshakeComplete) { + profileSent = true + sendUserProfile(AfuB1Lib.ProfileVariant.INITIAL) + logI("live: user profile (0x3f) sent") + } + + // The scale signals the weight is final — ACK and start the commit flow. + if (weight.locked && !weightIsFinal && handshakeComplete) { + weightIsFinal = true + sendAck(weight) + logI("state=1 final, ACKed 0x51 — commit fires once the scale goes idle") + startCommitFlow() + } + } + + /** + * Single-impedance result (0x52). The scale only sends it after a second weigh-in + * armed by profile 0x5b, which openScale does not do — so this is normally never + * reached; every stored record already carries its resistance. + */ + private fun onImpedanceResult(result: ScaleFrame.ImpedanceResult) { + logI("RX 0x52 RESULT adc=${result.adcOhms} (impedance, valid=${result.valid})") + sendAck(result) + + // After it the app sends the final profile + a re-sync to arm the next weigh. + // If the fresh record was already received, the post-weigh drain is in charge + // of ending the session. If not (abnormal ordering), arm the scale and let the + // commit flow's timeout fall back to the stable live weight. + if (handshakeComplete && commitInProgress && !committedRecordSeen) { + logI("weigh complete — sending finalize to arm the next weigh") + sendFinalize() + } + } + + /** + * One 14-byte chunk of the scale's encrypted report (0x5f/0x14). Purpose unknown and + * the payload is undecodable — body composition comes from weight + impedance, so the + * data is discarded. The ACK still matters: un-ACKed frames get re-served by the + * scale, so acknowledging keeps the flow moving towards the impedance frames. + */ + private fun onBodyCompChunk(chunk: ScaleFrame.BodyCompChunk) { + logI("RX 0x14 chunk ${chunk.chunkIndex} (encrypted report, discarded)") + sendAck(chunk) + } + + // --- Commit flow ------------------------------------------------------- + + /** + * After the weight is marked final the user must step off and the scale go idle. Only then can + * the fresh committed record be pulled (re-sync + getWeighAgain). Waiting for the + * scale to go quiet (rather than a fixed delay after the weight is marked final) + * matters: the scale keeps + * streaming 0x51 while someone stands on it, and a commit sent mid-weigh is ignored — + * afterwards the scale replays its stored history instead of the fresh weight. + */ + private fun startCommitFlow() { + commitJob = scope.launch { + while (!scaleIsIdle()) { + if (committedRecordSeen) return@launch + delay(IDLE_POLL_MS.milliseconds) + } + + if (!commitInProgress) { + commitInProgress = true + logI("scale idle — re-sync + getWeighAgain to pull the fresh record") + sendTimeSync() + sendSyncRequestPair() + sendGetWeighAgain() + } + + // Phase 2: give the scale time to push the fresh record + impedance. + val deadline = System.currentTimeMillis() + COMMIT_TIMEOUT_MS + while (!committedRecordSeen && System.currentTimeMillis() < deadline) { + delay(IDLE_POLL_MS.milliseconds) + } + + val stableWeightG = lastLiveWeightG + if (!committedRecordSeen && stableWeightG != null) { + committedRecordSeen = true + logW("commit timeout — publishing stable live weight") + publishMeasurement( + user = currentAppUser(), + weightKg = stableWeightG / 1000.0f, + impedanceOhms = null, + timestamp = null + ) + requestDisconnect() + } + } + } + + /** True once the scale has been silent for [IDLE_DELAY_MS] — the user is off it. */ + private fun scaleIsIdle(): Boolean = + System.currentTimeMillis() - lastFrameAt > IDLE_DELAY_MS + + /** + * After the fresh weigh record: re-sync + 0x8e once more and wait until the queue + * goes quiet, so any history the scale still holds is imported before disconnecting. + * The scale only serves stored records while idle, and it re-serves the oldest + * un-ACKed record until ACKed — draining to silence means everything is read. + */ + private fun startPostWeighDrain() { + commitJob = scope.launch { + logI("post-weigh drain: re-sync + 0x8e to catch remaining history") + sendTimeSync() + sendSyncRequestPair() + sendHistoryDumpRequest() + val deadline = System.currentTimeMillis() + COMMIT_TIMEOUT_MS + while (!scaleIsIdle() && System.currentTimeMillis() < deadline) { + delay(IDLE_POLL_MS.milliseconds) + } + requestDisconnect() + } + } + + /** + * A stored record is the fresh committed weigh only if it was recorded during this + * weigh (timestamp near now, after the weigh started) and matches the final weight. + * Everything else is old stored history and must not finish the session. + */ + private fun isFreshCommitted(record: ScaleFrame.StoredRecord): Boolean { + if (!commitInProgress || weighStartedAt == 0L) return false + val nowSecs = System.currentTimeMillis() / 1000L + val ts = record.timestampEpochSeconds + if (ts < weighStartedAt / 1000L - 60L) return false + if (abs(nowSecs - ts) > FRESH_RECORD_WINDOW_S) return false + val live = lastLiveWeightG ?: return false + return abs(record.weightGrams - live) <= FRESH_WEIGHT_TOLERANCE_G + } + + // --- Measurement publishing -------------------------------------------- + + private fun publishMeasurement( + user: ScaleUser, + weightKg: Float, + impedanceOhms: Float?, + timestamp: Long? + ) { + if (weightKg <= 0f) return + val measurement = ScaleMeasurement().apply { + userId = user.id + dateTime = timestamp?.let { plausibleDate(it) } ?: Date() + this.weight = weightKg + // Composition chain: resistance -> body-fat % -> report. Nothing derived is + // published outside the reference implementation's validated input ranges. + if (impedanceOhms != null && impedanceOhms > 0f) { + val sexFlag = if (user.gender == GenderType.MALE) 1 else 0 + val bodyFat = AfuB1Lib.bodyFatPercent( + weightKg.toDouble(), user.bodyHeight.toDouble(), user.age, + sexFlag, impedanceOhms.toDouble(), + ) + if (bodyFat != null) { + val comp = AfuB1Lib.bodyComposition(sexFlag, user.age, user.bodyHeight, weightKg, bodyFat) + if (comp != null) { + this.impedance = impedanceOhms.toDouble() + fat = bodyFat + water = comp.waterPercent.toFloat() + muscle = comp.skeletalMusclePercent.toFloat() + bone = comp.boneMassKg + visceralFat = comp.visceralFatIndex.toFloat() + protein = comp.proteinPercent.toFloat() + lbm = comp.fatFreeMassKg + } + } + } + } + logI( + "publishing measurement: ${weightKg}kg" + + (impedanceOhms?.let { ", impedance=${it}Ohm" } ?: "") + ) + publish(measurement) + } + + /** History timestamps are Unix seconds; guard against a wildly wrong scale clock. */ + private fun plausibleDate(timestampEpochSeconds: Long): Date { + val millis = timestampEpochSeconds * 1000L + return if (millis in 946_684_800_000L..(System.currentTimeMillis() + 86_400_000L)) Date(millis) + else Date() + } + + private fun logStoredRecord(record: ScaleFrame.StoredRecord) { + logI( + "RX 0x54 stored seq=${record.sequence} type=${record.historyType} " + + iso(record.timestampEpochSeconds) + " ${record.weightGrams / 1000.0f}kg " + + "res=${record.resistanceOhms}Ohm" + + (if (record.locked) " FINAL" else "") + + (if (record.hasElectrode) " E" else "") + + (if (record.hasHeartRate) " HR" else "") + ) + } + + // --- Helpers ----------------------------------------------------------- + + private fun iso(epochSeconds: Long): String { + val cal = Calendar.getInstance().apply { timeInMillis = epochSeconds * 1000L } + return String.format( + Locale.US, + "%04d-%02d-%02d %02d:%02d:%02d", + cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), + cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND) + ) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 02b919ef8..04d03b9ed 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.bluetooth import android.util.SparseArray import com.health.openscale.core.bluetooth.scales.AAAxHandler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler +import com.health.openscale.core.bluetooth.scales.AfuB1Handler import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler import com.health.openscale.core.bluetooth.scales.BeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.BodyConnectHandler @@ -158,6 +159,7 @@ object ScaleCatalog { val fixtures: List = listOf( // --- Matched by advertised name --- device("AE BS-06") claimedBy ActiveEraBF06Handler::class.java, + device("AFU-BH-TZ-B1", uuid16(0xFC50)) claimedBy AfuB1Handler::class.java, device("Keep_S3") claimedBy KeepS3Handler::class.java, device("Beurer BF450") claimedBy BeurerBF450Handler::class.java, device("BIA SCALE", SERVICE_FFB0) claimedBy TaylorBIAHandler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/AfuB1LibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/AfuB1LibTest.kt new file mode 100644 index 000000000..8ccac3c47 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/AfuB1LibTest.kt @@ -0,0 +1,362 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [AfuB1Lib] — the wire protocol of the "AFU B1" scale. + * + * All byte fixtures were captured with the official AFU app (btsnoop HCI logs) while + * reverse-engineering the protocol. + */ +class AfuB1LibTest { + + private fun hex(s: String) = s.replace(" ", "") + .chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + /** Independent CRC reimplementation — cross-checks the lib's checksum. */ + private fun expectedCrc(raw: ByteArray): Int { + var sum = 0 + for (i in 2 until 19) sum += raw[i].toInt() and 0xFF + return sum and 0x1F + } + + /** Assembles a valid 20-byte AFU frame around the 16 middle bytes (index 2..17). */ + private fun frame(middle: ByteArray, wireType: Int): ByteArray { + require(middle.size == 16) { "middle must be 16 bytes, got ${middle.size}" } + val f = ByteArray(AfuB1Lib.FRAME_SIZE) + f[0] = 0xAF.toByte(); f[1] = 0x45.toByte() + middle.copyInto(f, 2) + f[18] = wireType.toByte() + f[19] = expectedCrc(f).toByte() + return f + } + + /** Blanks the timestamp slot (raw[3..6]) and the CRC covering it for comparisons. */ + private fun maskTime(f: ByteArray): ByteArray = f.copyOf().also { + for (i in 3..6) it[i] = 0 + it[19] = 0 + } + + // --- Frame builders (phone -> scale) -------------------------------------- + + @Test + fun buildTimeSync_matchesCapture() { + // af4516ba90806a20000000000000000000005f09 + assertThat(AfuB1Lib.buildTimeSync(1786810554L)) + .isEqualTo(hex("af4516ba90806a20000000000000000000005f09")) + } + + @Test + fun buildSyncRequest_first_matchesCapture() { + // af45040001afb6171d010002b4c61b560100620f (frame 1381 of the capture) + assertThat(AfuB1Lib.buildSyncRequest(AfuB1Lib.SyncRequestVariant.FIRST)) + .isEqualTo(hex("af45040001afb6171d010002b4c61b560100620f")) + } + + @Test + fun buildSyncRequest_second_matchesCapture() { + // af45040103a5f8111d020004b4c61b1d0100620e + assertThat(AfuB1Lib.buildSyncRequest(AfuB1Lib.SyncRequestVariant.SECOND)) + .isEqualTo(hex("af45040103a5f8111d020004b4c61b1d0100620e")) + } + + @Test + fun buildSyncRequest_first_hasType62() { + val f = AfuB1Lib.buildSyncRequest(AfuB1Lib.SyncRequestVariant.FIRST) + assertThat(f.size).isEqualTo(20) + assertThat(f[18].toInt() and 0xFF).isEqualTo(AfuB1Lib.WIRE_SYNC_REQUEST) + assertThat(f[2].toInt() and 0xFF).isEqualTo(AfuB1Lib.CMD_SYNC_REQUEST) + } + + @Test + fun buildUserProfile_matchesCapture() { + // af453fd490806a0001afb6171d01881303006107 + val f = AfuB1Lib.buildUserProfile( + variant = AfuB1Lib.ProfileVariant.INITIAL, + nowEpochSeconds = 1786810580L, + user = 1, + age = 29, + sex = 1, + targetKg = 50.0f + ) + assertThat(f).isEqualTo(hex("af453fd490806a0001afb6171d01881303006107")) + } + + @Test + fun buildUserProfile_getWeighAgainVariants_matchCapture() { + // Frames 3367 / 5604 / 7980 of btsnoop_hci_260817_140338.log. Only the + // timestamp slot (raw[3..6]) is masked — it differs per capture moment. + val cases = listOf( + AfuB1Lib.ProfileVariant.GET_WEIGH_AGAIN_1 to + "af454ba4826a200001afb6171d01881303006115", + AfuB1Lib.ProfileVariant.GET_WEIGH_AGAIN_2 to + "af455ba4826a200001afd9171d01881303006108", + AfuB1Lib.ProfileVariant.FINAL to + "af4568a4826a200001afd9171d01881303006115", + ) + for ((variant, captured) in cases) { + val built = AfuB1Lib.buildUserProfile( + variant = variant, + nowEpochSeconds = 0L, + user = 1, + age = 29, + sex = 1, + targetKg = 50.0f + ) + assertThat(maskTime(built)).isEqualTo(maskTime(hex(captured))) + } + } + + @Test + fun buildHistoryDumpRequest_matchesCapture() { + // af458e4090806a0001afde171d0188130300610a + val f = AfuB1Lib.buildHistoryDumpRequest( + nowEpochSeconds = 1786810432L, + user = 1, + age = 29, + sex = 1, + targetKg = 50.0f + ) + assertThat(f).isEqualTo(hex("af458e4090806a0001afde171d0188130300610a")) + } + + @Test + fun buildChunkRequest_matchesCapture() { + // af45140000000000000000000000000000005f13 (frame 3395 of the capture) + assertThat(AfuB1Lib.buildChunkRequest()) + .isEqualTo(hex("af45140000000000000000000000000000005f13")) + } + + @Test + fun buildAck_matchesCaptures() { + // af45125400050000000000000000000000005f0a (ack 0x54, correlation=5) + assertThat(AfuB1Lib.buildAck(AfuB1Lib.WIRE_STORED_RECORD, 5)) + .isEqualTo(hex("af45125400050000000000000000000000005f0a")) + // af45125100000000000000000000000000005f02 (ack 0x51) + assertThat(AfuB1Lib.buildAck(AfuB1Lib.WIRE_LIVE_WEIGHT, 0)) + .isEqualTo(hex("af45125100000000000000000000000000005f02")) + // af45125200000000000000000000000000005f03 (ack 0x52) + assertThat(AfuB1Lib.buildAck(AfuB1Lib.WIRE_IMPEDANCE_RESULT, 0)) + .isEqualTo(hex("af45125200000000000000000000000000005f03")) + } + + @Test + fun buildFrame_computesCrcCorrectly() { + val f = AfuB1Lib.buildTimeSync(1786810554L) + assertThat(f[19].toInt() and 0x1F).isEqualTo(expectedCrc(f)) + } + + // --- Frame decoder (scale -> phone) --------------------------------------- + + @Test + fun parse_liveWeightFrame() { + // af45b2ed080002800188384b0000000000005106 + // live 0x51: weight 60850 g (60.85 kg), mode 0x02, not locked + val f = AfuB1Lib.parse(hex("af45b2ed080002800188384b0000000000005106")) + assertThat(f).isInstanceOf(AfuB1Lib.ScaleFrame.LiveWeight::class.java) + val live = f as AfuB1Lib.ScaleFrame.LiveWeight + assertThat(live.weightGrams).isEqualTo(60850) + assertThat(live.mode).isEqualTo(0x02) + assertThat(live.locked).isFalse() + } + + @Test + fun parse_lockedLiveWeight_setsStateBit() { + // Same live frame as above but with bit 31 of the flags word set (state/locked = 1). + val vt = AfuB1Lib.convertIntEndian(0x8000EDB2.toInt()) // weight 60850 g + locked bit + val middle = ByteArray(16) + middle[0] = ((vt ushr 24) and 0xFF).toByte() + middle[1] = ((vt ushr 16) and 0xFF).toByte() + middle[2] = ((vt ushr 8) and 0xFF).toByte() + middle[3] = (vt and 0xFF).toByte() + middle[4] = 0x02 // mode + + val live = AfuB1Lib.parse(frame(middle, AfuB1Lib.WIRE_LIVE_WEIGHT)) + assertThat(live).isEqualTo( + AfuB1Lib.ScaleFrame.LiveWeight(weightGrams = 60850, mode = 0x02, locked = true) + ) + } + + @Test + fun parse_historyFrame() { + // af450046a4826a7aee0880000127020000005404 + // stored 0x54 record: time 1786946630, weight 61050 g (61.05 kg), resistance 551 Ohm + val f = AfuB1Lib.parse(hex("af450046a4826a7aee0880000127020000005404")) + assertThat(f).isInstanceOf(AfuB1Lib.ScaleFrame.StoredRecord::class.java) + val h = f as AfuB1Lib.ScaleFrame.StoredRecord + assertThat(h.historyType).isEqualTo(0) + assertThat(h.sequence).isEqualTo(0) + assertThat(h.timestampEpochSeconds).isEqualTo(1786946630L) + assertThat(h.weightGrams).isEqualTo(61050) + assertThat(h.resistanceOhms).isEqualTo(551) + // seq=0 is the current LOCKED weight — the scale re-serves it until ACKed. + assertThat(h.locked).isTrue() + } + + @Test + fun parse_historyFrame_encodedSeqInHeader() { + // Header byte 0x14 = history_type 0 + history_seq 5 (bits 2..7). + val middle = ByteArray(16) + middle[0] = 0x14 + + val h = AfuB1Lib.parse(frame(middle, AfuB1Lib.WIRE_STORED_RECORD)) + assertThat(h).isEqualTo( + AfuB1Lib.ScaleFrame.StoredRecord( + historyType = 0, + sequence = 5, + timestampEpochSeconds = 0L, + weightGrams = 0, + hasElectrode = false, + hasHeartRate = false, + hasPhaseAngle = false, + hasZx = false, + hasTemperature = false, + locked = false, + resistanceOhms = 0 + ) + ) + } + + @Test + fun parse_impedanceFrame() { + // af4501002702000000000000000000000000521c + // 0x52 single-impedance result: valid=1, adc=551 + val f = AfuB1Lib.parse(hex("af4501002702000000000000000000000000521c")) + assertThat(f).isInstanceOf(AfuB1Lib.ScaleFrame.ImpedanceResult::class.java) + val i = f as AfuB1Lib.ScaleFrame.ImpedanceResult + assertThat(i.valid).isTrue() + assertThat(i.adcOhms).isEqualTo(551) + } + + @Test + fun parse_chunkFrame() { + // af4514001058a2e6e8296fe2d7799037224c5f0a + // 0x5f/0x14 body-comp chunk index 0, 14-byte payload + val f = AfuB1Lib.parse(hex("af4514001058a2e6e8296fe2d7799037224c5f0a")) + assertThat(f).isInstanceOf(AfuB1Lib.ScaleFrame.BodyCompChunk::class.java) + val c = f as AfuB1Lib.ScaleFrame.BodyCompChunk + assertThat(c.chunkIndex).isEqualTo(0) + assertThat(c.payload.size).isEqualTo(14) + } + + @Test + fun parse_ackFrame() { + // af45125f16000000000000000000000000005f06 + // 0x5f/0x12 ack: reply_cmd=0x5f + val f = AfuB1Lib.parse(hex("af45125f16000000000000000000000000005f06")) + assertThat(f).isInstanceOf(AfuB1Lib.ScaleFrame.Acknowledgement::class.java) + val a = f as AfuB1Lib.ScaleFrame.Acknowledgement + assertThat(a.replyCmd).isEqualTo(0x5F) + assertThat(a.replyOp).isEqualTo(0x16) + } + + @Test + fun parse_phoneToScaleTypes_rejected() { + // Type 0x61 (profile) / 0x62 (sync-request) never arrive from the scale. + val f = AfuB1Lib.buildUserProfile( + variant = AfuB1Lib.ProfileVariant.INITIAL, + nowEpochSeconds = 1786810580L, + user = 1, + age = 29, + sex = 1, + targetKg = 50.0f + ) + assertThat(AfuB1Lib.parse(f)).isNull() + assertThat(AfuB1Lib.parse(AfuB1Lib.buildSyncRequest(AfuB1Lib.SyncRequestVariant.FIRST))).isNull() + } + + // --- Robustness ------------------------------------------------------------ + + @Test + fun parse_wrongLength_rejected() { + assertThat(AfuB1Lib.parse(byteArrayOf(0xAF.toByte()))).isNull() + assertThat(AfuB1Lib.parse(ByteArray(19))).isNull() + assertThat(AfuB1Lib.parse(ByteArray(21))).isNull() + } + + @Test + fun parse_badHeader_rejected() { + val f = AfuB1Lib.buildTimeSync(1786810554L) + f[0] = 0x00 // not 0xAF + assertThat(AfuB1Lib.parse(f)).isNull() + } + + @Test + fun parse_badCrc_rejected() { + val f = AfuB1Lib.buildTimeSync(1786810554L) + f[19] = ((f[19].toInt() + 1) and 0xFF).toByte() + assertThat(AfuB1Lib.parse(f)).isNull() + } + + // --- Body composition from impedance ---------------------------------------- + + @Test + fun bodyFatPercent_referenceValues() { + // Reference outputs of the python reference implementation: + // male, 29y, 175cm, 61.0kg @ 550 Ohm -> 16.7 %; 61.1kg @ 545 Ohm -> 16.6 % + assertThat(AfuB1Lib.bodyFatPercent(61.0, 175.0, 29, 1, 550.0)).isEqualTo(16.7f) + assertThat(AfuB1Lib.bodyFatPercent(61.1, 175.0, 29, 1, 545.0)).isEqualTo(16.6f) + assertThat(AfuB1Lib.bodyFatPercent(61.0, 175.0, 29, 1, 0.0)).isNull() + } + + @Test + fun bodyComposition_referenceValues() { + val c = AfuB1Lib.bodyComposition(1, 29, 175f, 61f, 16.7f)!! + assertThat(c.waterPercent).isWithin(1e-9).of(56.15439616656694) + assertThat(c.skeletalMusclePercent).isWithin(1e-9).of(50.25818621525999) + assertThat(c.boneMassKg.toDouble()).isWithin(1e-9).of(2.6422760486602783) + assertThat(c.visceralFatIndex).isEqualTo(6) + assertThat(c.proteinPercent).isWithin(1e-9).of(22.814002990722656) + assertThat(c.fatFreeMassKg.toDouble()).isWithin(1e-9).of(50.8129997253418) + + val c2 = AfuB1Lib.bodyComposition(1, 29, 175f, 61.1f, 16.6f)!! + assertThat(c2.waterPercent).isWithin(1e-9).of(56.27454136258639) + assertThat(c2.skeletalMusclePercent).isWithin(1e-9).of(50.36572084232014) + assertThat(c2.boneMassKg.toDouble()).isWithin(1e-9).of(2.649784803390503) + assertThat(c2.visceralFatIndex).isEqualTo(5) + assertThat(c2.proteinPercent).isWithin(1e-9).of(22.788658142089844) + assertThat(c2.fatFreeMassKg.toDouble()).isWithin(1e-9).of(50.9573974609375) + } + + @Test + fun bodyComposition_rejectsOutOfRange() { + assertThat(AfuB1Lib.bodyComposition(1, 29, 175f, 61f, 3.9f)).isNull() + assertThat(AfuB1Lib.bodyComposition(1, 29, 175f, 61f, 75.5f)).isNull() + assertThat(AfuB1Lib.bodyComposition(1, 9, 175f, 61f, 20f)).isNull() + assertThat(AfuB1Lib.bodyComposition(1, 29, 80f, 61f, 20f)).isNull() + assertThat(AfuB1Lib.bodyComposition(1, 29, 175f, 250f, 20f)).isNull() + } + + @Test + fun buildThenParse_roundtrip() { + val f = AfuB1Lib.buildUserProfile( + variant = AfuB1Lib.ProfileVariant.INITIAL, + nowEpochSeconds = 1786810580L, + user = 1, + age = 29, + sex = 1, + targetKg = 50.0f + ) + // A phone->scale frame is not something the scale would echo, but the CRC must + // still be self-consistent so the scale can validate it. + assertThat(f[19].toInt() and 0x1F).isEqualTo(expectedCrc(f)) + } +} From 27d55001512bb4b2840806fd2c457dc29f401068 Mon Sep 17 00:00:00 2001 From: LOGAET <72298332+LOGAET@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:01:55 +0300 Subject: [PATCH 51/72] Picooc: add a driver for the connection-oriented scale line (#1482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Picooc: add a driver for the connection-oriented scale line Adds a handler for the Picooc scales that speak the 0xFFF0 GATT protocol (PICOOC-C1..CQ, PICOOC-14..25, PICOOC-IS, the S3 Lite V2.0 which advertises as PICOOC-CQ, and the Latin series, which advertises with no vendor prefix). The scale publishes weight, whole-body impedance and heart rate. It does not report body composition of its own, so fat/water/muscle/bone/LBM/BMR are derived from the impedance via StandardImpedanceLib, as the other impedance-only scales in this package do. The 0x32 composition frame is parsed and preferred when present. Protocol notes, verified against a PICOOC-CQ capture: - The scale opens with 0x3A (time request plus a BOM block) and expects a 10-byte reply; the length field counts the 0xF1 prefix. - It never sends the documented 0x30 profile request, so the sex/height/age frame is pushed unprompted after the handshake. Without it the scale has nothing to run its BIA engine on. - There is no live weight stream on this model: a single 0x39 arrives with the completion flag already set. - The pound field in 0x39 is 15 bits wide. 0x862C/10 reads 3434.8 lb, while (0x862C & 0x7FFF)/10 is 158.0 lb, which is exactly the 71.7 kg reported in the same frame. The top bit is a separate flag of unknown meaning. - 0x3C must be acknowledged. Unacknowledged, the scale resends a byte-identical "3C 05 00 00 01" ten times at 1 Hz and then drops the link, never reaching the reading its own display was showing. Acknowledged, it sends the in-progress frame once and then "3C 05 00 44 02" - 0x44 = 68 bpm, matching the display. Layout is [bpm BE 2B][status], status 1 measuring, 2 final. - Publication waits for the scale to hang up, which it does on its own once finished; a re-armed quiet period is the fallback. Heart rate lands 10 s after the weight, so publishing on the weight frame loses it. Opcodes 0x35, 0x36, 0x37, 0x3B, 0x3E and 0x3F are acknowledged and dumped to the log but not decoded: no capture has produced them yet. The metabolic body age in 0x32 and the phase angle in 0x39 are decoded and logged but not stored, as ScaleMeasurement has no field for either. PICOOC-L (Mini) is deliberately left unclaimed. It is broadcast-only and never accepts a GATT connection, so it needs a separate BROADCAST_ONLY handler. The handler is registered ahead of ScaleupHandler, which claims any device whose manufacturer data carries a 0xD0/0xE0 key regardless of name. https://garden.1900.live/22-knowledge/%E5%BC%80%E5%8F%91/picooc%E4%BD%93%E8%84%82%E7%A7%A4ble%E5%8D%8F%E8%AE%AE%E5%88%86%E6%9E%90 * Update ScaleFactory.kt --------- Co-authored-by: Каманцев Александр Андреевич Co-authored-by: OliE --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../core/bluetooth/scales/PicoocHandler.kt | 653 +++++++++++++++++ .../app/src/main/res/values/strings.xml | 2 + .../openscale/core/bluetooth/ScaleCatalog.kt | 4 + .../bluetooth/scales/PicoocHandlerTest.kt | 678 ++++++++++++++++++ 5 files changed, 1339 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 7d949a2cd..f3871a51c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -59,6 +59,7 @@ import com.health.openscale.core.bluetooth.scales.XiaomiS800Handler import com.health.openscale.core.bluetooth.scales.BodyConnectHandler import com.health.openscale.core.bluetooth.scales.OkOkHandler import com.health.openscale.core.bluetooth.scales.OmronWlcHandler +import com.health.openscale.core.bluetooth.scales.PicoocHandler import com.health.openscale.core.bluetooth.scales.OneByoneHandler import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler import com.health.openscale.core.bluetooth.scales.QNHandler @@ -131,6 +132,7 @@ class ScaleFactory @Inject constructor( @VisibleForTesting internal fun createHandlers(): List = listOf( // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). + PicoocHandler(), ActiveEraBF06Handler(), AfuB1Handler(), KeepS3Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt new file mode 100644 index 000000000..8783c17da --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt @@ -0,0 +1,653 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib +import com.health.openscale.core.data.WeightUnit +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Date +import java.util.Locale +import java.util.UUID +import kotlin.math.roundToInt + +/** + * Picooc (有品) body fat scales that speak the connection-oriented BLE protocol: + * Latin-S, PICOOC-C1…CQ, PICOOC-14…25, PICOOC-IS and the S3 Lite V2.0. + * + * Protocol from a public analysis of a decompiled Picooc app v4.13.4 + * (https://garden.1900.live/22-knowledge/开发/picooc体脂秤ble协议分析): + * - Service 0xFFF0, notify characteristic 0xFFF1, write characteristic 0xFFF2. + * - Scale→app frames are `[opcode][length][payload…]`, big-endian throughout. + * - App→scale frames are prefixed with 0xF1 (Latin family) or 0xA1 (newer models, the ones + * that use opcodes 0x51/0x52 in place of 0x3A/0x39); `length` counts the prefix as well. + * - Handshake: scale sends 0x3A (or 0x51) asking for the time, the app answers with a + * 10-byte time frame; the scale then asks for the user profile with 0x30, which the app + * acks and follows ~100 ms later with a 6-byte sex/height/age frame. Only then does the + * scale run its BIA engine and stream 0x39 (or 0x52) frames. It hangs up on its own once + * the measurement is done. + * + * The Picooc app computes body composition on the phone (FatModelCaculate) rather than reading + * it off the scale, so 0x32 is not guaranteed to arrive. When it does we trust its values and + * only fill the gaps from [StandardImpedanceLib]; when it does not, the whole composition is + * derived from the streamed impedance. + * + * Several documented opcodes have no published byte layout (0x36/0x37 history, 0x3E + * multi-frequency BIA, 0x3F balance test). Those are acked where an ack is expected and dumped + * to the log verbatim so a real capture can be used to decode them later. Every frame in both + * directions is logged in full hex under the "Picooc RX"/"Picooc TX" prefixes, so + * `adb logcat | grep -i picooc` yields a complete session dump. + */ +class PicoocHandler : ScaleDeviceHandler() { + + private val service = uuid16(0xFFF0) + private val notifyCharacteristic = uuid16(0xFFF1) + private val writeCharacteristic = uuid16(0xFFF2) + + /** 0xF1 for the Latin family; latched to 0xA1 as soon as the scale uses a 0x5x opcode. */ + private var ackPrefix = PREFIX_LATIN + + private var pendingWeightKg = 0f + private var pendingImpedance = 0.0 + private var pendingHeartRate = 0 + private var pendingComposition: Composition? = null + private var published = false + private var profilePushed = false + private var settleJob: Job? = null + + /** Latched in [onConnected] so [onDisconnected] can still publish after the link is gone. */ + private var sessionUser: ScaleUser? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.lowercase(Locale.ROOT) + + // PICOOC-L (Mini and the other new models) is broadcast-only: it carries its payload in + // the advertisement and never accepts a GATT connection. Leave the name unclaimed so a + // future broadcast handler can take it, rather than failing to connect here. + if (name.startsWith("picooc-l")) return null + + // The connection-oriented line advertises either "PICOOC-" (C1…CQ, 14…25, IS, + // and the S3 Lite V2.0 which shows up as PICOOC-CQ) or, for the Latin series, a bare + // "Latin-…" with no vendor prefix at all. No other handler in this package claims + // either prefix. + val isPicooc = name.startsWith("picooc") + val isLatin = name.startsWith("latin-") + if (!isPicooc && !isLatin) return null + + return DeviceSupport( + // Show the advertised model: the line spans a lot of hardware and the name is the + // only thing that tells two of them apart in the device list. + displayName = if (isPicooc) device.name else "Picooc ${device.name}", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG, + DeviceCapability.HISTORY_READ, + DeviceCapability.BATTERY_LEVEL + ), + // History frames are logged but not decoded yet, and the scale only reports that the + // battery is low, never a level. + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG + ), + tuningProfile = TuningProfile.Conservative, + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + resetSession() + sessionUser = user + logI("Picooc: connected, subscribing to 0xFFF1") + setNotifyOn(service, notifyCharacteristic) + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (characteristic != notifyCharacteristic || data.isEmpty()) return + + val opcode = data[0].toInt() and 0xFF + logI("Picooc RX 0x%02X len=%d | %s".format(opcode, data.size, hex(data))) + + when (opcode) { + OP_TIME_REQUEST_ALT -> { + // Only the newer models use 0x5x opcodes, and those expect the 0xA1 prefix. + ackPrefix = PREFIX_MODERN + handleTimeRequest(user, OP_HANDSHAKE) + pushUserProfileOnce(user) + } + + OP_HANDSHAKE -> { + parseBomInfo(data)?.let { logI("Picooc BOM info: $it") } + handleTimeRequest(user, OP_HANDSHAKE) + pushUserProfileOnce(user) + } + + OP_TIME_SYNC -> handleTimeRequest(user, OP_TIME_SYNC) + + OP_USER_REQUEST -> { + send(buildAck(ackPrefix, OP_USER_REQUEST), "ack 0x30") + sendUserProfile(user) + } + + OP_LIVE_WEIGHT_ALT -> { + ackPrefix = PREFIX_MODERN + handleLiveFrame(data, opcode, user) + } + + OP_LIVE_WEIGHT -> handleLiveFrame(data, opcode, user) + + OP_BODY_COMPOSITION -> { + send(buildAck(ackPrefix, OP_BODY_COMPOSITION), "ack 0x32") + val composition = parseComposition(data) + if (composition == null) { + logW("Picooc 0x32 too short to decode (len=${data.size})") + return + } + logI("Picooc composition: $composition") + pendingComposition = composition + if (composition.weightKg in MIN_WEIGHT_KG..MAX_WEIGHT_KG) { + pendingWeightKg = composition.weightKg + } + if (composition.impedance > 0.0) pendingImpedance = composition.impedance + armSettle(user, "0x32 body composition") + } + + OP_HEART_RATE -> { + // Acked like the weight frames are. A PICOOC-CQ capture without this ack shows + // the scale resending a byte-identical `3C 05 00 00 01` ten times at exactly + // 1 Hz and then hanging up, while the acked 0x39 arrives exactly once — the + // signature of an unacknowledged retransmit. The scale's own display had a + // heart rate the whole time, so the result frame is one the scale would only + // move on to once the previous one is confirmed. + send(buildAck(ackPrefix, OP_HEART_RATE), "ack 0x3C") + // Only the PICOOC-CQ layout is confirmed; dump the alternatives so a capture + // from another model shows at a glance where its reading sits. + logD( + "Picooc 0x3C candidates: " + + heartRateCandidates(data).joinToString { "${it.first}=${it.second}" } + ) + val bpm = parseHeartRate(data) + if (bpm != null) { + logI("Picooc heart rate: $bpm bpm (status=${heartRateStatus(data)})") + pendingHeartRate = bpm + } else { + logD("Picooc heart rate still measuring | ${hex(data)}") + } + // Re-arm either way: the reading is still running, and publishing now would + // close the record before a result can arrive. + armSettle(user, "0x3C heart rate") + } + + OP_LOW_BATTERY -> { + logW("Picooc reports a low battery | ${hex(data)}") + userWarn(R.string.bt_warn_low_battery_unknown) + } + + OP_HISTORY, OP_HISTORY_DONE -> { + // Acked so the scale keeps going; the payload layout is undocumented. + send(buildAck(ackPrefix, opcode), "ack 0x%02X".format(opcode)) + logI("Picooc history frame 0x%02X (layout unknown) | %s".format(opcode, hex(data))) + } + + OP_MULTI_FREQ_BIA -> { + send(buildAck(ackPrefix, OP_MULTI_FREQ_BIA), "ack 0x3E") + logI("Picooc multi-frequency BIA 0x3E (layout unknown) | ${hex(data)}") + } + + OP_BALANCE_TEST -> { + send(buildAck(ackPrefix, OP_BALANCE_TEST), "ack 0x3F") + logI("Picooc balance test 0x3F (layout unknown, no openScale field) | ${hex(data)}") + } + + else -> logI("Picooc RX unhandled 0x%02X | %s".format(opcode, hex(data))) + } + } + + override fun onDisconnected() { + // The scale hangs up as soon as it is done. If the completion flag was never seen — or + // read wrongly — this is the last chance to keep the reading. + val user = sessionUser + if (!published && pendingWeightKg > 0f && user != null) { + publishSession(user, "disconnect fallback") + } + resetSession() + } + + // --- protocol steps -------------------------------------------------------------------- + + /** + * Push the profile without waiting for a 0x30 request. PICOOC-CQ never asks for it — it goes + * straight from the handshake to the measurement — so waiting would leave its BIA engine + * without sex/height/age. Sending it unprompted is the documented frame, just earlier. + */ + private fun pushUserProfileOnce(user: ScaleUser) { + if (profilePushed) return + profilePushed = true + sendUserProfile(user) + } + + private fun sendUserProfile(user: ScaleUser) { + // The scale needs a moment before the profile; the adapter's Conservative inter-write + // gap is only 50 ms, so pace it explicitly. + val profile = buildUserProfile(user) + scope.launch { + delay(PROFILE_DELAY_MS) + logI( + "Picooc user profile: sex=${if (user.gender.isMale()) "M" else "F"} " + + "height=${user.bodyHeight}cm age=${user.age}" + ) + send(profile, "user profile") + } + } + + private fun handleTimeRequest(user: ScaleUser, replyOpcode: Int) { + val unit = unitCode(user.scaleUnit) + val epochSeconds = System.currentTimeMillis() / 1000L + send( + buildTimeReply(ackPrefix, replyOpcode, epochSeconds, unit), + "time sync (epoch=$epochSeconds unit=$unit)" + ) + } + + private fun handleLiveFrame(data: ByteArray, opcode: Int, user: ScaleUser) { + send(buildAck(ackPrefix, opcode), "ack 0x%02X".format(opcode)) + + val frame = parseLiveFrame(data) + if (frame == null) { + logW("Picooc live frame 0x%02X too short to decode (len=%d)".format(opcode, data.size)) + return + } + logI("Picooc live: $frame") + + if (frame.weightKg in MIN_WEIGHT_KG..MAX_WEIGHT_KG) { + pendingWeightKg = frame.weightKg + userInfo(R.string.bluetooth_scale_info_measuring_weight, frame.weightKg) + } + if (frame.impedance > 0.0) pendingImpedance = frame.impedance + + if (frame.complete) armSettle(user, "live frame completion flag") + } + + /** + * (Re)start the settle window. The order in which the scale sends 0x32 and 0x3C after the + * live stream completes is not documented, so instead of publishing on the first of them we + * wait a moment for the rest and publish whatever arrived. + */ + private fun armSettle(user: ScaleUser, reason: String) { + if (published || pendingWeightKg <= 0f) return + settleJob?.cancel() + settleJob = scope.launch { + delay(SETTLE_WAIT_MS) + publishSession(user, reason) + } + } + + /** Emits exactly one measurement per session. */ + private fun publishSession(user: ScaleUser, reason: String) { + if (published || pendingWeightKg <= 0f) return + published = true + settleJob?.cancel() + settleJob = null + + val composition = pendingComposition + val measurement = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + weight = pendingWeightKg + if (pendingImpedance > 0.0) impedance = pendingImpedance + if (pendingHeartRate > 0) heartRate = pendingHeartRate + } + + val usableImpedance = pendingImpedance in MIN_IMPEDANCE..MAX_IMPEDANCE + val lib = if (usableImpedance && user.bodyHeight > 0f) { + StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = pendingWeightKg.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = pendingImpedance, + ) + } else { + null + } + + if (composition != null && composition.fatPercent in MIN_FAT_PERCENT..MAX_FAT_PERCENT) { + // The scale did the maths for us — prefer its numbers over our estimate. + measurement.fat = composition.fatPercent + measurement.water = composition.waterPercent.coerceIn(0f, 80f) + measurement.bone = composition.boneMassKg.coerceIn(0f, 10f) + measurement.visceralFat = composition.visceralFat.toFloat() + measurement.lbm = pendingWeightKg * (1f - composition.fatPercent / 100f) + // 0x32 carries neither of these, so fall back to the estimator for them. + lib?.let { + measurement.muscle = it.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f) + measurement.bmr = it.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) + } + logI("Picooc body composition source: 0x32 packet") + } else if (lib != null) { + measurement.fat = lib.totalFatPercentage.toFloat().coerceIn(0f, 75f) + measurement.water = lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) + measurement.muscle = lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f) + measurement.bone = lib.boneMassKg.toFloat().coerceIn(0f, 10f) + measurement.lbm = lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f) + measurement.bmr = lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) + logI("Picooc body composition source: StandardImpedanceLib (${pendingImpedance}Ω)") + } else { + logI("Picooc body composition unavailable: impedance=$pendingImpedance height=${user.bodyHeight}") + } + + // openScale has no measurement type for the metabolic body age the 0x32 packet reports, + // nor for the phase angle in the live frames. Re-enabling either needs a + // MeasurementTypeKey, a ScaleMeasurement field, a DB migration and BleConnector wiring. + composition?.bodyAge?.takeIf { it > 0 }?.let { logI("Picooc metabolic body age: $it (not stored)") } + + logI( + "Picooc publishing ($reason) → weight=${measurement.weight}kg fat=${measurement.fat}% " + + "water=${measurement.water}% muscle=${measurement.muscle}% bone=${measurement.bone}kg " + + "visceral=${measurement.visceralFat} lbm=${measurement.lbm}kg bmr=${measurement.bmr}kcal " + + "hr=${measurement.heartRate}bpm impedance=${measurement.impedance}Ω" + ) + publish(measurement) + // No requestDisconnect(): the scale terminates the link itself once it is done. + } + + private fun send(payload: ByteArray, what: String) { + logI("Picooc TX ${hex(payload)} ($what)") + writeTo(service, writeCharacteristic, payload, withResponse = false) + } + + private fun resetSession() { + ackPrefix = PREFIX_LATIN + pendingWeightKg = 0f + pendingImpedance = 0.0 + pendingHeartRate = 0 + pendingComposition = null + published = false + profilePushed = false + sessionUser = null + settleJob?.cancel() + settleJob = null + } + + /** Decoded 0x39 / 0x52 live streaming frame. */ + data class LiveFrame( + val timestamp: Long, + val weightKg: Float, + val impedance: Double, + val weightLb: Float, + val unit: Int, + val phaseAngle: Float, + val complete: Boolean, + /** Top bit of byte 10, which is not part of the pound value. Meaning unknown. */ + val poundFlag: Boolean, + ) + + /** Decoded 0x32 body composition frame. */ + data class Composition( + val weightKg: Float, + val fatPercent: Float, + val waterPercent: Float, + val boneMassKg: Float, + val visceralFat: Int, + val bodyAge: Int, + val impedance: Double, + /** Bytes 8..9 — purpose not documented; logged so a real capture can identify them. */ + val unknown8: Int, + /** Bytes 12..13 — purpose not documented. */ + val unknown12: Int, + ) + + /** Firmware / bill-of-materials block carried in the 0x3A handshake. */ + data class BomInfo( + val version: Int, + val bomVersion: Int, + val year: Int, + val month: Int, + val modelId: Int, + val shortBom: Int, + val shortFactoryId: Int, + ) + + companion object { + // App→scale frame prefixes. + const val PREFIX_LATIN = 0xF1 + const val PREFIX_MODERN = 0xA1 + + // Scale→app opcodes. + const val OP_USER_REQUEST = 0x30 + const val OP_USER_PROFILE = 0x31 + const val OP_BODY_COMPOSITION = 0x32 + const val OP_TIME_SYNC = 0x35 + const val OP_HISTORY = 0x36 + const val OP_HISTORY_DONE = 0x37 + const val OP_LIVE_WEIGHT = 0x39 + const val OP_HANDSHAKE = 0x3A + const val OP_LOW_BATTERY = 0x3B + const val OP_HEART_RATE = 0x3C + const val OP_MULTI_FREQ_BIA = 0x3E + const val OP_BALANCE_TEST = 0x3F + const val OP_TIME_REQUEST_ALT = 0x51 + const val OP_LIVE_WEIGHT_ALT = 0x52 + + /** Gap the Picooc app leaves between acking 0x30 and sending the profile. */ + const val PROFILE_DELAY_MS = 100L + + /** + * Quiet period before publishing, re-armed by every measurement frame. + * + * This is the fallback, not the normal path: PICOOC-CQ terminates the link itself once + * it is done and [onDisconnected] publishes then. The timer only has to survive until + * that happens, which means outlasting the two gaps a capture of that scale shows — + * 3.9 s from the weight frame to the first 0x3C, then roughly 1 s between 0x3C frames + * for another 8-12 s. Anything shorter closes the record before the heart rate can + * arrive, which is exactly what a 1.5 s window did. + */ + const val SETTLE_WAIT_MS = 8000L + + private const val MIN_WEIGHT_KG = 2.0f + private const val MAX_WEIGHT_KG = 250.0f + private const val MIN_IMPEDANCE = 100.0 + private const val MAX_IMPEDANCE = 1500.0 + private const val MIN_FAT_PERCENT = 3.0f + private const val MAX_FAT_PERCENT = 75.0f + + private const val SEX_MALE = 0x01 + private const val SEX_FEMALE = 0x02 + + /** Youngest age the scale's BIA engine accepts. */ + private const val MIN_AGE = 18 + + fun hex(data: ByteArray): String = data.joinToString(" ") { "%02X".format(it) } + + private fun u8(data: ByteArray, index: Int): Int = data[index].toInt() and 0xFF + + private fun u16be(data: ByteArray, index: Int): Int = + (u8(data, index) shl 8) or u8(data, index + 1) + + /** `[prefix] 03 [opcode]` — the generic three-byte acknowledgement. */ + fun buildAck(prefix: Int, opcode: Int): ByteArray = + byteArrayOf(prefix.toByte(), 0x03, opcode.toByte()) + + /** + * `[prefix] 0A [opcode] [epoch BE] [flag] [unit] [extra]` — exactly ten bytes, which is + * what the length field advertises. + */ + fun buildTimeReply(prefix: Int, opcode: Int, epochSeconds: Long, unit: Int): ByteArray { + val seconds = epochSeconds.toInt() + return byteArrayOf( + prefix.toByte(), + 0x0A, + opcode.toByte(), + ((seconds shr 24) and 0xFF).toByte(), + ((seconds shr 16) and 0xFF).toByte(), + ((seconds shr 8) and 0xFF).toByte(), + (seconds and 0xFF).toByte(), + 0x00, // flag + unit.toByte(), + 0x00, // extra + ) + } + + /** Scale unit code: 0 = kg, 1 = jin, 2 = lb. Stone falls back to pounds. */ + fun unitCode(unit: WeightUnit): Int = if (unit == WeightUnit.KG) 0 else 2 + + /** + * Height is transmitted as `(cm * 10 - 1000) / 5`, i.e. 100…227.5 cm map onto 0…255. + * A missing or non-finite height encodes as 0 rather than throwing, so a broken profile + * cannot abort the handshake. + */ + fun encodeHeight(heightCm: Float): Int { + if (!heightCm.isFinite()) return 0 + return ((heightCm * 10f - 1000f) / 5f).roundToInt().coerceIn(0, 255) + } + + /** `31 06 01 [sex] [height] [age]` — exactly six bytes, matching the length field. */ + fun buildUserProfile(user: ScaleUser): ByteArray = byteArrayOf( + OP_USER_PROFILE.toByte(), + 0x06, + 0x01, + (if (user.gender.isMale()) SEX_MALE else SEX_FEMALE).toByte(), + encodeHeight(user.bodyHeight).toByte(), + user.age.coerceIn(MIN_AGE, 255).toByte(), + ) + + /** + * Decode a 0x39 / 0x52 streaming frame, or `null` if it is too short. + * + * Layout: `[op][len][ts BE 4B][weight/20][impedance/10][lb/10][unit][phase/100][flag]`, + * where `flag == 0` marks the reading as settled and reportable. + * + * The pound field is only 15 bits wide: a PICOOC-CQ capture of 71.7 kg reports + * `86 2C`, and 0x862C/10 = 3434.8 is nonsense while (0x862C & 0x7FFF)/10 = 158.0 lb + * matches 71.7 kg exactly. The top bit is therefore a separate flag, not part of the + * value. It is decoded into [LiveFrame.poundFlag] and logged pending an explanation. + */ + fun parseLiveFrame(data: ByteArray): LiveFrame? { + if (data.size < 16) return null + return LiveFrame( + timestamp = ((u8(data, 2).toLong() shl 24) or (u8(data, 3).toLong() shl 16) or + (u8(data, 4).toLong() shl 8) or u8(data, 5).toLong()), + weightKg = u16be(data, 6) / 20.0f, + impedance = u16be(data, 8) / 10.0, + weightLb = (u16be(data, 10) and 0x7FFF) / 10.0f, + unit = u8(data, 12), + phaseAngle = u16be(data, 13) / 100.0f, + complete = u8(data, 15) == 0, + poundFlag = (u8(data, 10) and 0x80) != 0, + ) + } + + /** + * Decode a 0x32 body composition frame, or `null` if it is too short. + * + * Layout: `[op][len][weight/20][fat%/10][water%/10][?][bone kg/20][?][visceral][bodyAge] + * [..][impedance]`. + */ + fun parseComposition(data: ByteArray): Composition? { + if (data.size < 20) return null + return Composition( + weightKg = u16be(data, 2) / 20.0f, + fatPercent = u16be(data, 4) / 10.0f, + waterPercent = u16be(data, 6) / 10.0f, + boneMassKg = u16be(data, 10) / 20.0f, + visceralFat = u8(data, 14), + bodyAge = u8(data, 15), + impedance = u16be(data, 18).toDouble(), + unknown8 = u16be(data, 8), + unknown12 = u16be(data, 12), + ) + } + + /** 0x3C trailing byte: the measurement is still running. */ + const val HEART_RATE_MEASURING = 0x01 + + /** 0x3C trailing byte: the reading in bytes 2..3 is final. */ + const val HEART_RATE_FINAL = 0x02 + + /** + * Every byte position in a 0x3C frame that could hold the heart rate, as `label to + * value`. The layout is settled now (see [parseHeartRate]); this is kept purely as a + * diagnostic dump for other Picooc models, whose frames may well be laid out + * differently. + */ + fun heartRateCandidates(data: ByteArray): List> = buildList { + if (data.size >= 4) add("u16be[2..3]" to u16be(data, 2)) + if (data.size >= 3) add("u8[2]" to u8(data, 2)) + if (data.size >= 4) add("u8[3]" to u8(data, 3)) + if (data.size >= 5) add("u16be[3..4]" to u16be(data, 3)) + if (data.size >= 5) add("u8[4]" to u8(data, 4)) + } + + /** + * Heart rate in bpm from a 0x3C frame, or `null` while the scale is still measuring. + * + * Layout confirmed against a PICOOC-CQ capture: `3C 05 [bpm BE 2B] [status]`, with + * status 1 while measuring and 2 once the value is final. The measuring frame reads + * `3C 05 00 00 01` and the result frame `3C 05 00 44 02` — 0x44 = 68 bpm, matching the + * scale's own display. + * + * Shorter frames from other models fall back to a single byte at offset 2; that path is + * unverified, hence the [heartRateCandidates] dump alongside it in the log. + */ + fun parseHeartRate(data: ByteArray): Int? { + val bpm = when { + data.size >= 5 -> u16be(data, 2) + data.size >= 3 -> u8(data, 2) + else -> return null + } + return if (bpm in 30..250) bpm else null + } + + /** + * Trailing status byte of a 0x3C frame — [HEART_RATE_MEASURING] or [HEART_RATE_FINAL] — + * or `null` if the frame is too short. + */ + fun heartRateStatus(data: ByteArray): Int? = + if (data.size >= 5) u8(data, 4) else null + + /** + * Firmware/BOM block appended to the 0x3A handshake. Present only when the high nibble + * of byte 11 is 1; returns `null` otherwise. + */ + fun parseBomInfo(data: ByteArray): BomInfo? { + if (data.size < 17) return null + if ((u8(data, 11) and 0xF0) shr 4 != 1) return null + return BomInfo( + version = (u8(data, 11) and 0xF0) shr 4, + bomVersion = u8(data, 11) and 0x0F, + year = (u8(data, 12) and 0xF0) shr 4, + month = u8(data, 12) and 0x0F, + modelId = (u8(data, 13) shl 4) + ((u8(data, 14) and 0xF0) shr 4), + shortBom = ((u8(data, 14) and 0x0F) shl 10) + (u8(data, 15) shl 2) + (u8(data, 16) shr 6), + shortFactoryId = u8(data, 16) and 0x3F, + ) + } + } +} diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 078a1ab97..cdf2bf7ac 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -899,6 +899,8 @@ Scale battery is low (%1$d%%). + + Scale battery is low. Creating the user on the scale failed (code %1$d). The scale has no free user slots. Delete a user on the device and try again. Failed to enable notifications for %1$s. diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 04d03b9ed..72f1b5018 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -54,6 +54,7 @@ import com.health.openscale.core.bluetooth.scales.MiScaleHandler import com.health.openscale.core.bluetooth.scales.MiScaleS400Handler import com.health.openscale.core.bluetooth.scales.OkOkHandler import com.health.openscale.core.bluetooth.scales.OmronWlcHandler +import com.health.openscale.core.bluetooth.scales.PicoocHandler import com.health.openscale.core.bluetooth.scales.OneByoneHandler import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler import com.health.openscale.core.bluetooth.scales.QNHandler @@ -161,6 +162,9 @@ object ScaleCatalog { device("AE BS-06") claimedBy ActiveEraBF06Handler::class.java, device("AFU-BH-TZ-B1", uuid16(0xFC50)) claimedBy AfuB1Handler::class.java, device("Keep_S3") claimedBy KeepS3Handler::class.java, + // The S3 Lite V2.0 advertises as PICOOC-CQ; the Latin series carries no vendor prefix. + device("PICOOC-CQ") claimedBy PicoocHandler::class.java, + device("Latin-S") claimedBy PicoocHandler::class.java, device("Beurer BF450") claimedBy BeurerBF450Handler::class.java, device("BIA SCALE", SERVICE_FFB0) claimedBy TaylorBIAHandler::class.java, device("RYFIT") claimedBy RyFitHandler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocHandlerTest.kt new file mode 100644 index 000000000..9a77d0c04 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocHandlerTest.kt @@ -0,0 +1,678 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Calendar +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PicoocHandlerTest { + private val notifyCharacteristic = uuid16(0xFFF1) + + // --- device matching ------------------------------------------------------------------- + + @Test + fun `claims connection-oriented picooc scales`() { + val support = PicoocHandler().supportFor(device("PICOOC-CQ"))!! + + assertThat(support.linkMode).isEqualTo(LinkMode.CONNECT_GATT) + assertThat(support.implemented).contains(DeviceCapability.BODY_COMPOSITION) + assertThat(support.displayName).isEqualTo("PICOOC-CQ") + } + + /** The whole connection-oriented line, per the protocol write-up and the CQ capture. */ + @Test + fun `claims every connection-oriented model in the line`() { + val names = listOf( + "PICOOC-CQ", // S3 Lite V2.0 + "PICOOC-C1", + "PICOOC-14", + "PICOOC-25", + "PICOOC-IS", + "Picooc S3 Lite V2.0", + ) + + for (name in names) { + assertThat(PicoocHandler().supportFor(device(name))).isNotNull() + } + } + + /** The Latin series advertises with no vendor prefix at all, so a "picooc" test misses it. */ + @Test + fun `claims the Latin series despite its missing vendor prefix`() { + val support = PicoocHandler().supportFor(device("Latin-S"))!! + + assertThat(support.linkMode).isEqualTo(LinkMode.CONNECT_GATT) + assertThat(support.displayName).isEqualTo("Picooc Latin-S") + } + + @Test + fun `leaves the broadcast-only PICOOC-L family unclaimed`() { + assertThat(PicoocHandler().supportFor(device("PICOOC-L"))).isNull() + assertThat(PicoocHandler().supportFor(device("PICOOC-L1"))).isNull() + } + + @Test + fun `ignores unrelated devices`() { + assertThat(PicoocHandler().supportFor(device("Vitafit VT701"))).isNull() + assertThat(PicoocHandler().supportFor(device("QN-Scale"))).isNull() + assertThat(PicoocHandler().supportFor(device(""))).isNull() + } + + // --- frame builders -------------------------------------------------------------------- + + @Test + fun `builds three byte acknowledgements`() { + assertThat(PicoocHandler.buildAck(PicoocHandler.PREFIX_LATIN, 0x39)) + .isEqualTo(bytes("F1 03 39")) + assertThat(PicoocHandler.buildAck(PicoocHandler.PREFIX_MODERN, 0x52)) + .isEqualTo(bytes("A1 03 52")) + } + + @Test + fun `time reply matches its own length field`() { + val reply = PicoocHandler.buildTimeReply( + PicoocHandler.PREFIX_LATIN, + PicoocHandler.OP_HANDSHAKE, + 0x1234_5678L, + unit = 0, + ) + + assertThat(reply).isEqualTo(bytes("F1 0A 3A 12 34 56 78 00 00 00")) + assertThat(reply.size).isEqualTo(reply[1].toInt()) + } + + @Test + fun `encodes height as five millimetre steps above one metre`() { + assertThat(PicoocHandler.encodeHeight(180f)).isEqualTo(160) + assertThat(PicoocHandler.encodeHeight(150f)).isEqualTo(100) + assertThat(PicoocHandler.encodeHeight(100f)).isEqualTo(0) + // Clamped at both ends: below 1 m underflows, above 227.5 cm overflows a byte. + assertThat(PicoocHandler.encodeHeight(80f)).isEqualTo(0) + assertThat(PicoocHandler.encodeHeight(250f)).isEqualTo(255) + // A user with no height must not abort the handshake. + assertThat(PicoocHandler.encodeHeight(Float.NaN)).isEqualTo(0) + assertThat(PicoocHandler.encodeHeight(-1f)).isEqualTo(0) + } + + @Test + fun `an unusable height still produces a well-formed profile`() { + val user = user(GenderType.MALE, 180f, 24).apply { bodyHeight = Float.NaN } + + assertThat(PicoocHandler.buildUserProfile(user)).isEqualTo(bytes("31 06 01 01 00 18")) + } + + @Test + fun `user profile matches its own length field and carries the real user`() { + val profile = PicoocHandler.buildUserProfile(user(GenderType.MALE, 180f, 24)) + + assertThat(profile).isEqualTo(bytes("31 06 01 01 A0 18")) + assertThat(profile.size).isEqualTo(profile[1].toInt()) + } + + @Test + fun `user profile encodes women as sex two`() { + val profile = PicoocHandler.buildUserProfile(user(GenderType.FEMALE, 165f, 31)) + + assertThat(profile).isEqualTo(bytes("31 06 01 02 82 1F")) + } + + @Test + fun `user profile raises ages below the BIA engine minimum`() { + val profile = PicoocHandler.buildUserProfile(user(GenderType.MALE, 140f, 9)) + + assertThat(profile[5].toInt()).isEqualTo(18) + } + + // --- frame parsers --------------------------------------------------------------------- + + @Test + fun `parses a completed live frame`() { + val frame = PicoocHandler.parseLiveFrame(liveFrame(complete = true))!! + + assertThat(frame.timestamp).isEqualTo(0x1234_5678L) + assertThat(frame.weightKg).isWithin(0.0001f).of(70.0f) + assertThat(frame.impedance).isWithin(0.0001).of(500.0) + assertThat(frame.weightLb).isWithin(0.0001f).of(154.3f) + assertThat(frame.unit).isEqualTo(0) + assertThat(frame.phaseAngle).isWithin(0.0001f).of(5.25f) + assertThat(frame.complete).isTrue() + assertThat(frame.poundFlag).isFalse() + } + + /** Captured from a PICOOC-CQ weighing 71.7 kg, which the scale's own display confirmed. */ + @Test + fun `parses the captured PICOOC-CQ weight frame`() { + val frame = PicoocHandler.parseLiveFrame( + bytes("39 10 6A 7B 9A E1 05 9A 13 A6 86 2C 00 02 B7 00"), + )!! + + assertThat(frame.weightKg).isWithin(0.0001f).of(71.7f) + assertThat(frame.impedance).isWithin(0.0001).of(503.0) + assertThat(frame.phaseAngle).isWithin(0.0001f).of(6.95f) + assertThat(frame.complete).isTrue() + // 0x862C/10 would be 3434.8; only the low 15 bits are the pound value, and 158.0 lb is + // exactly 71.7 kg. The top bit is a separate, still unexplained flag. + assertThat(frame.weightLb).isWithin(0.0001f).of(158.0f) + assertThat(frame.poundFlag).isTrue() + } + + @Test + fun `treats a non-zero trailing flag as still settling`() { + assertThat(PicoocHandler.parseLiveFrame(liveFrame(complete = false))!!.complete).isFalse() + } + + @Test + fun `rejects a truncated live frame`() { + assertThat(PicoocHandler.parseLiveFrame(liveFrame(complete = true).copyOf(15))).isNull() + } + + @Test + fun `parses a body composition frame`() { + val composition = PicoocHandler.parseComposition(compositionFrame())!! + + assertThat(composition.weightKg).isWithin(0.0001f).of(70.0f) + assertThat(composition.fatPercent).isWithin(0.0001f).of(18.5f) + assertThat(composition.waterPercent).isWithin(0.0001f).of(55.2f) + assertThat(composition.boneMassKg).isWithin(0.0001f).of(3.0f) + assertThat(composition.visceralFat).isEqualTo(8) + assertThat(composition.bodyAge).isEqualTo(27) + assertThat(composition.impedance).isWithin(0.0001).of(500.0) + assertThat(composition.unknown8).isEqualTo(0x1122) + assertThat(composition.unknown12).isEqualTo(0x3344) + } + + /** + * Both 0x3C payloads a PICOOC-CQ produces, captured back to back: the in-progress frame + * first, then the result. 0x44 = 68 bpm matched the scale's own display. + */ + @Test + fun `parses the captured heart rate exchange`() { + val measuring = bytes("3C 05 00 00 01") + assertThat(PicoocHandler.parseHeartRate(measuring)).isNull() + assertThat(PicoocHandler.heartRateStatus(measuring)) + .isEqualTo(PicoocHandler.HEART_RATE_MEASURING) + + val result = bytes("3C 05 00 44 02") + assertThat(PicoocHandler.parseHeartRate(result)).isEqualTo(68) + assertThat(PicoocHandler.heartRateStatus(result)) + .isEqualTo(PicoocHandler.HEART_RATE_FINAL) + } + + @Test + fun `falls back to a single byte on shorter heart rate frames`() { + assertThat(PicoocHandler.parseHeartRate(bytes("3C 03 48"))).isEqualTo(72) + assertThat(PicoocHandler.parseHeartRate(bytes("3C 03 00"))).isNull() + assertThat(PicoocHandler.parseHeartRate(bytes("3C 03"))).isNull() + } + + @Test + fun `dumps every heart rate candidate for diagnosing other models`() { + val candidates = PicoocHandler.heartRateCandidates(bytes("3C 05 00 44 02")).toMap() + + assertThat(candidates).containsExactly( + "u16be[2..3]", 68, + "u8[2]", 0, + "u8[3]", 68, + "u16be[3..4]", 17410, + "u8[4]", 2, + ) + } + + // --- handshake ------------------------------------------------------------------------- + + @Test + fun `answers the handshake with a ten byte time frame`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + setup.handler.handleNotification(notifyCharacteristic, capturedHandshake) + + val reply = setup.transport.writes.single().payload + assertThat(reply.size).isEqualTo(10) + assertThat(reply.copyOf(3)).isEqualTo(bytes("F1 0A 3A")) + } + + /** + * PICOOC-CQ never sends 0x30 — the capture goes straight from the handshake to the weight + * frame — so the profile has to be pushed unprompted or the scale's BIA engine never gets + * sex, height and age. + */ + @Test + fun `pushes the profile after the handshake even without a 0x30 request`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + setup.handler.handleNotification(notifyCharacteristic, capturedHandshake) + advanceTimeBy(PicoocHandler.PROFILE_DELAY_MS) + runCurrent() + + assertThat(setup.transport.writes).hasSize(2) + assertThat(setup.transport.writes[1].payload).isEqualTo(bytes("31 06 01 01 A0 18")) + } + + @Test + fun `does not push the profile twice for repeated handshakes`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + setup.handler.handleNotification(notifyCharacteristic, capturedHandshake) + setup.handler.handleNotification(notifyCharacteristic, capturedHandshake) + advanceTimeBy(PicoocHandler.PROFILE_DELAY_MS) + runCurrent() + + assertThat(setup.transport.writes.count { it.payload.contentEquals(bytes("31 06 01 01 A0 18")) }) + .isEqualTo(1) + } + + @Test + fun `decodes the captured PICOOC-CQ handshake`() { + val bom = PicoocHandler.parseBomInfo(capturedHandshake)!! + + assertThat(bom.version).isEqualTo(1) + assertThat(bom.bomVersion).isEqualTo(0) + assertThat(bom.modelId).isEqualTo(0x3A) + } + + @Test + fun `acks the profile request and follows up with the real user profile`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + setup.handler.handleNotification(notifyCharacteristic, bytes("30 02")) + + assertThat(setup.transport.writes.single().payload).isEqualTo(bytes("F1 03 30")) + advanceTimeBy(PicoocHandler.PROFILE_DELAY_MS) + runCurrent() + + assertThat(setup.transport.writes).hasSize(2) + assertThat(setup.transport.writes[1].payload).isEqualTo(bytes("31 06 01 01 A0 18")) + } + + @Test + fun `switches to the A1 prefix once the scale uses the newer opcodes`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + setup.handler.handleNotification(notifyCharacteristic, bytes("51 0B 01 02 03 04 05 06 07 08 09")) + setup.handler.handleNotification(notifyCharacteristic, liveFrame(complete = false)) + + // The reply to 0x51 is still a 0x3A time frame, only the prefix changes. + assertThat(setup.transport.writes[0].payload.copyOf(3)).isEqualTo(bytes("A1 0A 3A")) + assertThat(setup.transport.writes[1].payload).isEqualTo(bytes("A1 03 39")) + } + + // --- measurement session --------------------------------------------------------------- + + @Test + fun `streams live weight without writing a record`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + repeat(3) { setup.handler.handleNotification(notifyCharacteristic, liveFrame(complete = false)) } + advanceTimeBy(10_000) + runCurrent() + + assertThat(setup.callbacks.published).isEmpty() + assertThat(setup.transport.writes.map { it.payload.toList() }) + .containsExactlyElementsIn(List(3) { bytes("F1 03 39").toList() }) + } + + @Test + fun `publishes once with composition and heart rate after the settle window`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + + setup.handler.handleNotification(notifyCharacteristic, liveFrame(complete = true)) + setup.handler.handleNotification(notifyCharacteristic, compositionFrame()) + setup.handler.handleNotification(notifyCharacteristic, bytes("3C 03 48")) + + assertThat(setup.callbacks.published).isEmpty() + advanceTimeBy(PicoocHandler.SETTLE_WAIT_MS - 1) + runCurrent() + assertThat(setup.callbacks.published).isEmpty() + advanceTimeBy(1) + runCurrent() + + val measurement = setup.callbacks.published.single() + assertThat(measurement.userId).isEqualTo(setup.user.id) + assertThat(measurement.weight).isWithin(0.0001f).of(70.0f) + assertThat(measurement.fat).isWithin(0.0001f).of(18.5f) + assertThat(measurement.water).isWithin(0.0001f).of(55.2f) + assertThat(measurement.bone).isWithin(0.0001f).of(3.0f) + assertThat(measurement.visceralFat).isWithin(0.0001f).of(8.0f) + assertThat(measurement.lbm).isWithin(0.001f).of(57.05f) + assertThat(measurement.heartRate).isEqualTo(72) + assertThat(measurement.impedance).isWithin(0.0001).of(500.0) + // 0x32 carries neither of these, so they come from the impedance estimator. + assertThat(measurement.muscle).isGreaterThan(0f) + assertThat(measurement.bmr).isGreaterThan(0f) + } + + @Test + fun `heart rate arriving after the composition frame is still included`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + + setup.handler.handleNotification(notifyCharacteristic, liveFrame(complete = true)) + setup.handler.handleNotification(notifyCharacteristic, bytes("3C 03 48")) + setup.handler.handleNotification(notifyCharacteristic, compositionFrame()) + advanceTimeBy(PicoocHandler.SETTLE_WAIT_MS) + runCurrent() + + assertThat(setup.callbacks.published.single().heartRate).isEqualTo(72) + } + + @Test + fun `estimates composition from impedance when the scale sends no 0x32`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + + setup.handler.handleNotification(notifyCharacteristic, liveFrame(complete = true)) + advanceTimeBy(PicoocHandler.SETTLE_WAIT_MS) + runCurrent() + + val measurement = setup.callbacks.published.single() + assertThat(measurement.weight).isWithin(0.0001f).of(70.0f) + assertThat(measurement.impedance).isWithin(0.0001).of(500.0) + assertThat(measurement.fat).isGreaterThan(0f) + assertThat(measurement.water).isGreaterThan(0f) + assertThat(measurement.muscle).isGreaterThan(0f) + assertThat(measurement.bmr).isGreaterThan(0f) + } + + @Test + fun `publishes the latched weight when the scale hangs up before completing`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + + setup.handler.handleNotification(notifyCharacteristic, liveFrame(complete = false)) + assertThat(setup.callbacks.published).isEmpty() + + setup.handler.handleDisconnected() + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(70.0f) + } + + @Test + fun `a disconnect after publishing does not add a second record`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + + setup.handler.handleNotification(notifyCharacteristic, liveFrame(complete = true)) + advanceTimeBy(PicoocHandler.SETTLE_WAIT_MS) + runCurrent() + setup.handler.handleDisconnected() + + assertThat(setup.callbacks.published).hasSize(1) + } + + /** + * Replays a full PICOOC-CQ session frame for frame: handshake, a single already-settled + * weight frame, a heart-rate measurement that takes 6.4 s to resolve, then the scale + * hanging up. One record carrying both the weight and the heart rate, and nothing published + * early — a 1.5 s settle window used to close the record before the heart rate even + * started, and without the 0x3C ack the reading never arrived at all. + */ + @Test + fun `replays the captured PICOOC-CQ session as a single record`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + + setup.handler.handleNotification(notifyCharacteristic, capturedHandshake) + advanceTimeBy(2_800) + runCurrent() + + setup.handler.handleNotification( + notifyCharacteristic, + bytes("39 10 6A 7B A5 50 05 A2 13 BA 86 36 00 02 C8 00"), + ) + // 4 s of silence, then the scale reports that it is measuring the heart rate. + advanceTimeBy(4_000) + runCurrent() + setup.handler.handleNotification(notifyCharacteristic, bytes("3C 05 00 00 01")) + + // 6.4 s more before the reading lands — far longer than the old 1.5 s window. + advanceTimeBy(6_400) + runCurrent() + assertThat(setup.callbacks.published).isEmpty() + + setup.handler.handleNotification(notifyCharacteristic, bytes("3C 05 00 44 02")) + setup.handler.handleDisconnected() + + val measurement = setup.callbacks.published.single() + assertThat(measurement.weight).isWithin(0.0001f).of(72.1f) + assertThat(measurement.impedance).isWithin(0.0001).of(505.0) + assertThat(measurement.heartRate).isEqualTo(68) + assertThat(measurement.fat).isGreaterThan(0f) + assertThat(measurement.bmr).isGreaterThan(0f) + } + + @Test + fun `undocumented opcodes are tolerated without publishing`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + setup.handler.handleNotification(notifyCharacteristic, bytes("3E 08 01 02 03 04 05 06")) + setup.handler.handleNotification(notifyCharacteristic, bytes("3F 05 01 02 03")) + setup.handler.handleNotification(notifyCharacteristic, bytes("36 04 01 02")) + setup.handler.handleNotification(notifyCharacteristic, bytes("37 02")) + // Neither of these carries data the scale waits on, so neither is acked. + setup.handler.handleNotification(notifyCharacteristic, bytes("3B 03 01")) + setup.handler.handleNotification(notifyCharacteristic, bytes("7F 02")) + + assertThat(setup.callbacks.published).isEmpty() + assertThat(setup.transport.writes.map { it.payload.toList() }).containsExactly( + bytes("F1 03 3E").toList(), + bytes("F1 03 3F").toList(), + bytes("F1 03 36").toList(), + bytes("F1 03 37").toList(), + ) + } + + /** + * Without this ack a PICOOC-CQ resends a byte-identical `3C 05 00 00 01` ten times at 1 Hz + * and then drops the link, never reaching the reading its own display was showing. + */ + @Test + fun `acknowledges every heart rate frame`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + repeat(3) { setup.handler.handleNotification(notifyCharacteristic, bytes("3C 05 00 00 01")) } + + assertThat(setup.transport.writes.map { it.payload.toList() }) + .containsExactlyElementsIn(List(3) { bytes("F1 03 3C").toList() }) + } + + @Test + fun `acknowledges the body composition frame`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + setup.handler.handleNotification(notifyCharacteristic, compositionFrame()) + + assertThat(setup.transport.writes.single().payload).isEqualTo(bytes("F1 03 32")) + } + + // --- helpers --------------------------------------------------------------------------- + + /** The 0x3A handshake a PICOOC-CQ actually sent, BOM block and all. */ + private val capturedHandshake = + bytes("3A 12 0A 00 00 00 00 00 00 00 00 10 39 03 A0 46 47 03") + + /** + * A 0x39 frame reading 70.00 kg / 500.0 Ω / 154.3 lb / 5.25° phase angle. + * Byte 15 is the completion flag: 0 means settled and reportable. + */ + private fun liveFrame(complete: Boolean): ByteArray = + bytes("39 10 12 34 56 78 05 78 13 88 06 07 00 02 0D") + byteArrayOf(if (complete) 0 else 1) + + /** + * A 0x32 frame reading 70.00 kg / 18.5 % fat / 55.2 % water / 3.00 kg bone / + * visceral 8 / body age 27 / 500 Ω, with recognisable filler in the undocumented fields. + */ + private fun compositionFrame(): ByteArray = + bytes("32 14 05 78 00 B9 02 28 11 22 00 3C 33 44 08 1B 00 00 01 F4") + + private fun attachedHandler( + user: ScaleUser = user(GenderType.MALE, 180f, 24), + scope: CoroutineScope = CoroutineScope(EmptyCoroutineContext), + ): Setup { + val handler = PicoocHandler() + val transport = CapturingTransport() + val callbacks = CapturingCallbacks() + handler.attach( + transport = transport, + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(user), + scope = scope, + ) + return Setup(handler, transport, callbacks, user) + } + + private fun user(gender: GenderType, heightCm: Float, age: Int): ScaleUser { + val birthday = Calendar.getInstance().apply { add(Calendar.YEAR, -age) }.time + return ScaleUser(id = 7, birthday = birthday, bodyHeight = heightCm, gender = gender) + } + + private fun device(name: String) = ScannedDeviceInfo( + name = name, + address = "00:11:22:33:44:55", + rssi = -50, + serviceUuids = emptyList(), + manufacturerData = null, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun bytes(hex: String): ByteArray = hex + .trim() + .split(Regex("\\s+")) + .filter(String::isNotEmpty) + .map { it.toInt(16).toByte() } + .toByteArray() + + private data class Setup( + val handler: PicoocHandler, + val transport: CapturingTransport, + val callbacks: CapturingCallbacks, + val user: ScaleUser, + ) + + private data class Write( + val service: UUID, + val characteristic: UUID, + val payload: ByteArray, + val withResponse: Boolean, + ) + + private class CapturingTransport : ScaleDeviceHandler.Transport { + val notifications = mutableListOf>() + val writes = mutableListOf() + var disconnectCount = 0 + + override fun setNotifyOn(service: UUID, characteristic: UUID) { + notifications += service to characteristic + } + + override fun write( + service: UUID, + characteristic: UUID, + payload: ByteArray, + withResponse: Boolean, + ) { + writes += Write(service, characteristic, payload.copyOf(), withResponse) + } + + override fun read(service: UUID, characteristic: UUID) = Unit + + override fun disconnect() { + disconnectCount++ + } + + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = true + + fun clearWrites() = writes.clear() + } + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + + override fun onPublish(measurement: ScaleMeasurement) { + published += measurement.copy() + } + + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + private val strings = mutableMapOf() + private val ints = mutableMapOf() + + override fun getInt(key: String, default: Int): Int = ints[key] ?: default + override fun putInt(key: String, value: Int) { + ints[key] = value + } + + override fun getString(key: String, default: String?): String? = strings[key] ?: default + override fun putString(key: String, value: String) { + strings[key] = value + } + + override fun remove(key: String) { + strings.remove(key) + ints.remove(key) + } + } + + private class FixedDataProvider(private val user: ScaleUser) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = null + } +} From ae418916ff07c8f1fa8f4e6ec352424c7b547a85 Mon Sep 17 00:00:00 2001 From: Andy Sotheran Date: Sat, 29 Aug 2026 11:04:39 +0100 Subject: [PATCH 52/72] Add support for the Hume Health Dara 2.0 scale (#1486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for the Hume Health Dara 2.0 scale Fixes #1448. This device advertises literally as "Dara 2.0" and its BLE Device Information service reports manufacturer "LeFu Scale" — not FitTrack, despite sharing a model name with FitTrack's unrelated "Dara" scale (a different product on a different chip family, 0xFFB0/AC02 framing vs this device's 0xFFF0/0xCF framing). That name collision is why FitTrackDaraHandler's "FITTRACK"-prefixed name match never fired for this unit, even after 3.1.2 shipped named FitTrack Dara 2.0 support. New HumeDara2Handler matches on the device's actual advertised name and decodes the real 11-byte measurement frame (reverse-engineered from five real weigh-ins, ground truth documented in the class doc comment): weight and a raw BIA impedance reading, both cross-checked against the official Hume app's own display. Body composition (fat/water/muscle/ bone/BMR) is computed from that impedance via openScale's existing StandardImpedanceLib generic formula rather than guessed frame offsets, since the remaining frame bytes don't correlate with anything Hume's own app displays — this is an approximation of Hume's proprietary numbers, not a device reading, and the class doc comment states the measured accuracy against a real reading (~1-2% off on skeletal muscle %/BMR, ~15% on fat%/water%/lean mass, ~24% on bone). Kept as a sibling of ExcelvanCF36xHandler (same GATT layout and 0xCF frame header, evidently the same underlying chip family under a different rebrand) rather than merged into it, since the measurement frame shape differs and the device names don't collide — matching this codebase's existing one-handler-per-rebrand pattern. * Update ScaleFactory.kt --------- Co-authored-by: OliE --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../core/bluetooth/scales/HumeDara2Handler.kt | 256 ++++++++++++++++++ .../openscale/core/bluetooth/ScaleCatalog.kt | 2 + .../core/bluetooth/ScaleFactoryTest.kt | 23 ++ .../bluetooth/scales/HumeDara2HandlerTest.kt | 89 ++++++ 5 files changed, 372 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HumeDara2HandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index f3871a51c..44512183a 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -47,6 +47,7 @@ import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler import com.health.openscale.core.bluetooth.scales.HuaweiAhCh100Handler import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler +import com.health.openscale.core.bluetooth.scales.HumeDara2Handler import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler import com.health.openscale.core.bluetooth.scales.InlifeHandler import com.health.openscale.core.bluetooth.scales.KeepS3Handler @@ -179,6 +180,7 @@ class ScaleFactory @Inject constructor( HesleyHandler(), ExingtechY1Handler(), EbelterBodyFatB2Handler(), + HumeDara2Handler(), ExcelvanCF36xHandler(), EtekcityESF551Handler(), EtekcityFit8SHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt new file mode 100644 index 000000000..dc896b4f8 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt @@ -0,0 +1,256 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib +import com.health.openscale.core.data.ActivityLevel +import com.health.openscale.core.data.WeightUnit +import com.health.openscale.core.service.ScannedDeviceInfo +import com.health.openscale.core.utils.ConverterUtils +import java.util.UUID +import kotlin.math.roundToInt + +/** + * Hume Health "Dara 2.0" body-composition scale. + * + * NOT [FitTrackDaraHandler]: FitTrack sells an unrelated scale that is *also* marketed as + * "Dara" and advertises with a "FITTRACK" name prefix on service 0xFFB0 with `AC 02 …` + * framing. This device is a completely different product from a different vendor that + * happens to share the "Dara 2.0" model name — it advertises literally as `"Dara 2.0"` + * with no FitTrack prefix, and its own standard BLE Device Information service reports the + * manufacturer as **"LeFu Scale"**, not FitTrack. That mismatch between the marketing name + * ("Dara 2.0", suggesting FitTrack support already covers it) and the actual OEM hardware is + * why this device showed "Not Supported" even after FitTrack Dara support was added in + * 3.1.2 — see https://github.com/oliexdev/openScale/issues/1448. + * + * ## GATT layout + * Confirmed via three separate Debug-mode connections and reproduced across five real + * weigh-ins with the production handler below: + * Service 0xFFF0: + * 0xFFF1 – user config WRITE (not notifiable) + * 0xFFF4 – measurement NOTIFY + * + * This is the same GATT shape [ExcelvanCF36xHandler] uses for "Electronic Scale"-branded + * units (also 0xFFF0/0xFFF1/0xFFF4, same 0xCF frame header and the same 8-byte user-config + * write) — evidently the same underlying LeFu/Excelvan chip family under a different + * storefront rebrand. It is kept as a **separate handler** rather than folded into + * [ExcelvanCF36xHandler] because the measurement frame is a different, shorter shape (see + * below) and the device name ("Dara 2.0") doesn't collide with "Electronic Scale" — matching + * this codebase's existing pattern of one handler per exact rebrand name even within a + * shared chip family (see ActiveEraBF06Handler, KeepS3Handler, etc. registered ahead of the + * generic LeFu/0xFFF0 fallback in ScaleFactory). + * + * ## Frame format (11 bytes, reverse-engineered from five real weigh-ins) + * `[0]=0xCF [1..2]=impedance (BE) ÷100 [ohms] [3..4]=weight (LE) ÷100 [kg] + * [5] [6] [7] [8] [9]=unexplained [10]=XOR checksum over bytes[0..9]` + * + * While the reading settles, the scale streams live weight-only frames with bytes[1],[2], + * [5],[6],[7] all zero; once locked it sends a final frame with those populated, repeated + * twice, then disconnects itself after a short idle period. [isLockedFrame] detects that + * transition; only the locked frame is published (dropping the live stream matches + * [ExcelvanCF36xHandler]'s existing single-final-frame behaviour for the same chip family). + * + * Ground truth, all five real weigh-ins (same person, same day, same 188cm/18yr profile): + * `CF 0A 14 DE 21 5A 55 6F 01 00 4F` → 86.70 kg, impedance 25.80Ω (Hume app: 86.68 kg) — + * this first capture's impedance is implausibly low (bad/rushed foot contact); excluded + * from the body-composition range below, see [PLAUSIBLE_IMPEDANCE_OHMS]. + * `CF C4 13 E8 21 E5 E7 96 00 00 45` → 86.80 kg, impedance 501.95Ω (Hume: 86.9 kg) + * `CF C4 13 ED 21 AC A6 AE 00 00 70` → 86.85 kg, impedance 501.95Ω (Hume: 86.85 kg, exact) + * `CF C4 13 F7 21 5F 55 75 00 00 B1` → 86.95 kg, impedance 501.95Ω (Hume: 86.9-87.0 kg) + * `CF B0 13 F2 21 70 75 B7 00 00 0D` → 86.90 kg, impedance 450.75Ω (Hume: 86.9 kg, exact) + * Weight matches Hume's own reading within display rounding on every capture. The checksum + * formula (XOR of all ten preceding bytes) was verified against all five frames. + * + * ## Body composition: [StandardImpedanceLib], not Hume's own numbers + * Bytes[5..9] don't correlate with anything Hume's app displays — across the captures above, + * bytes[1..2] (the impedance field) stayed effectively flat across three consecutive + * same-session readings (501.95Ω) while Hume's displayed fat%/water%/muscle stayed frozen + * too, but bytes[5..9] moved anyway with no matching change on screen. There isn't room in an + * 11-byte frame for the ~13 distinct metrics Hume's app shows either, which strongly suggests + * most of what's on screen is *computed client-side* by Hume from impedance + user profile, + * not transmitted raw. + * + * So rather than guess at bytes[5..9], this handler feeds the one number we *did* verify + * (impedance, landing at 450-502Ω across genuine readings — squarely in the normal + * foot-to-foot BIA range) through openScale's own [StandardImpedanceLib] (generic published + * BIA formulas, not Hume's proprietary ones). Checked against a real reading (450.75Ω, 86.9kg, + * this device's user profile): skeletal muscle % and BMR land within ~1-2% of Hume's own display, + * fat%/water%/lean mass within ~15% (the errors run in complementary directions — fat low, + * water/lean high — which is internally consistent, not random noise), bone ~24% off. That's + * an approximation, not a device reading, which is why [DeviceSupport.implemented] still + * claims [DeviceCapability.BODY_COMPOSITION] but every value below traces back to + * [StandardImpedanceLib] rather than a frame offset. Subcutaneous fat, visceral fat index, + * and a "skeletal mass" distinct from skeletal muscle mass are not attempted at all: those + * need segmental/multi-frequency BIA hardware this single whole-body impedance reading can't + * provide, and [ScaleMeasurement] has no field for the last one regardless. + */ +class HumeDara2Handler : ScaleDeviceHandler() { + + companion object { + /** Only trust the impedance-derived body-comp formula in the plausible foot-to-foot + * BIA range — see [StandardImpedanceLib]'s own doc comment (500 ± 100Ω for a ~180cm + * adult of normal BMI). The very first unit we captured read ~26Ω on a rushed/bad + * first contact and would have produced nonsense fat%/water% if fed through anyway. */ + private val PLAUSIBLE_IMPEDANCE_OHMS = 300.0..900.0 + + /** Little-endian weight ÷100 at bytes[3..4], in kg. */ + fun weightKgFromFrame(frame: ByteArray): Float = + ConverterUtils.fromUnsignedInt16Le(frame, 3) / 100.0f + + /** Big-endian impedance ÷100 at bytes[1..2], in ohms. */ + fun impedanceOhmsFromFrame(frame: ByteArray): Double = + ConverterUtils.fromUnsignedInt16Be(frame, 1) / 100.0 + + /** XOR of bytes[0..9]; must equal byte[10]. */ + fun checksum(frame: ByteArray): Int { + var sum = 0 + for (i in 0..9) sum = sum xor (frame[i].toInt() and 0xFF) + return sum and 0xFF + } + + /** True once the scale has locked its reading (bytes[1],[2],[5],[6],[7] populate). */ + fun isLockedFrame(frame: ByteArray): Boolean = + (frame[1].toInt() or frame[2].toInt() or frame[5].toInt() or + frame[6].toInt() or frame[7].toInt()) != 0 + } + + private val SVC get() = uuid16(0xFFF0) + private val CHAR_WRITE get() = uuid16(0xFFF1) + private val CHAR_NOTIFY get() = uuid16(0xFFF4) + + /** Last locked frame we published, to ignore the repeat the scale always sends. */ + private var lastFrame: ByteArray? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + if (!device.name.equals("Dara 2.0", ignoreCase = true)) return null + + return DeviceSupport( + displayName = "Hume Health Dara 2.0", + capabilities = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG + ), + // Body composition is StandardImpedanceLib's estimate, not a device reading — see + // the class doc comment for the measured accuracy against Hume's own display. + implemented = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + lastFrame = null + + // Same 8-byte user-config write as ExcelvanCF36xHandler — same chip family. + val cfg = buildUserConfig(user) + writeTo(SVC, CHAR_WRITE, cfg, withResponse = true) + + setNotifyOn(SVC, CHAR_NOTIFY) + + 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 != 11 || data[0] != 0xCF.toByte()) { + logD("unexpected frame len=${data.size} head=${if (data.isNotEmpty()) String.format("%02X", data[0]) else "-"}") + return + } + + val expectedChecksum = checksum(data) + if ((data[10].toInt() and 0xFF) != expectedChecksum) { + logD("checksum mismatch: got=${String.format("%02X", data[10])} want=${String.format("%02X", expectedChecksum)}") + return + } + + if (!isLockedFrame(data)) return // live weight stream while the scale settles + + val previous = lastFrame + if (previous != null && previous.contentEquals(data)) return // the locked frame repeats once + lastFrame = data.copyOf() + + publishFrame(data, user) + requestDisconnect() + } + + private fun publishFrame(frame: ByteArray, user: ScaleUser) { + val weightKg = ConverterUtils.toKilogram(weightKgFromFrame(frame), user.scaleUnit) + val impedanceOhms = impedanceOhmsFromFrame(frame) + + val m = ScaleMeasurement().apply { weight = weightKg } + + if (impedanceOhms in PLAUSIBLE_IMPEDANCE_OHMS && user.bodyHeight > 0f) { + val bia = StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = weightKg.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = impedanceOhms + ) + m.impedance = impedanceOhms + m.fat = bia.totalFatPercentage.toFloat() + m.water = bia.totalBodyWaterPercentage.toFloat() + m.muscle = bia.skeletalMusclePercentage.toFloat() + m.bone = bia.boneMassKg.toFloat() + m.bmr = bia.basalMetabolicRate.toFloat() + m.lbm = bia.fatFreeMassKg.toFloat() + logD("publish kg=${m.weight} impedance=$impedanceOhms fat=${m.fat} water=${m.water} muscle=${m.muscle} bone=${m.bone} bmr=${m.bmr} lbm=${m.lbm}") + } else { + logD("publish kg=${m.weight} impedance=$impedanceOhms out of plausible range, skipping body composition") + } + + publish(m) + } + + /** Identical 8-byte config write to [ExcelvanCF36xHandler.buildUserConfig] — same chip family. */ + private fun buildUserConfig(user: ScaleUser): ByteArray { + val sex = if (user.gender.isMale()) 0x01 else 0x00 + val activity = when (user.activityLevel) { + ActivityLevel.SEDENTARY, + ActivityLevel.MILD -> 0x00 + ActivityLevel.MODERATE -> 0x01 + ActivityLevel.HEAVY, + ActivityLevel.EXTREME -> 0x02 + } + val height = user.bodyHeight.roundToInt().coerceIn(0, 255) + val age = user.age.coerceIn(0, 255) + val unit = when (user.scaleUnit) { + WeightUnit.KG -> 0x01 + WeightUnit.LB -> 0x02 + WeightUnit.ST -> 0x04 + } + + val cfg = byteArrayOf( + 0xFE.toByte(), 0x01, sex.toByte(), activity.toByte(), + height.toByte(), age.toByte(), unit.toByte(), 0x00 + ) + var xor = 0 + for (i in 1..6) xor = xor xor (cfg[i].toInt() and 0xFF) + cfg[7] = xor.toByte() + return cfg + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 72f1b5018..4154997da 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -44,6 +44,7 @@ import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler import com.health.openscale.core.bluetooth.scales.HuaweiAhCh100Handler import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler +import com.health.openscale.core.bluetooth.scales.HumeDara2Handler import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler import com.health.openscale.core.bluetooth.scales.InlifeHandler import com.health.openscale.core.bluetooth.scales.KeepS3Handler @@ -221,6 +222,7 @@ object ScaleCatalog { device("vscale") claimedBy ExingtechY1Handler::class.java, device("Body Fat-B2") claimedBy EbelterBodyFatB2Handler::class.java, device("Electronic Scale") claimedBy ExcelvanCF36xHandler::class.java, + device("Dara 2.0") claimedBy HumeDara2Handler::class.java, device("Etekcity Smart Fitness Scale") claimedBy EtekcityESF551Handler::class.java, device("EUFY C20") claimedBy EufyC20Handler::class.java, device("eufy T9148") claimedBy EufyP2Handler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt index a4229e22e..bc3b46b5e 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt @@ -24,7 +24,9 @@ import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler import com.health.openscale.core.bluetooth.scales.EtekcityESF551Handler import com.health.openscale.core.bluetooth.scales.EtekcityFit8SHandler import com.health.openscale.core.bluetooth.scales.EufyC20Handler +import com.health.openscale.core.bluetooth.scales.ExcelvanCF36xHandler import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler +import com.health.openscale.core.bluetooth.scales.HumeDara2Handler import com.health.openscale.core.bluetooth.scales.MGBHandler import com.health.openscale.core.bluetooth.scales.OkOkHandler import com.health.openscale.core.bluetooth.scales.QNHandlerBroadcast @@ -206,6 +208,27 @@ class ScaleFactoryTest { assertClaimedBy(device("yg", SERVICE_FFB0), MGBHandler::class.java) } + /** + * "Dara 2.0" (Hume Health, OEM "LeFu Scale", 0xFFF0/0xCF framing) and "FitTrack Dara" (an + * unrelated FitTrack product, 0xFFB0/0xAC02 framing) are two different scales that happen + * to share a model name. FitTrackDaraHandler only matches names starting with "FITTRACK", + * so it never takes this device — but pin that explicitly since it's exactly the confusion + * https://github.com/oliexdev/openScale/issues/1448 reported ("Not Supported" despite named + * FitTrack Dara 2.0 support already existing). Also pin the sibling boundary against + * ExcelvanCF36xHandler, the other 0xFFF0 handler: neither name collides with the other, but + * both are on the same chip family in the same part of the registry. + */ + @Test + fun `Hume Dara 2_0 is not swallowed by the unrelated FitTrack Dara handler`() { + assertClaimedBy(device("Dara 2.0"), HumeDara2Handler::class.java) + assertClaimedBy(device("FITTRACK Dara", SERVICE_FFB0), FitTrackDaraHandler::class.java) + assertClaimedBy(device("Electronic Scale"), ExcelvanCF36xHandler::class.java) + + assertThat(FitTrackDaraHandler().supportFor(device("Dara 2.0"))).isNull() + assertThat(ExcelvanCF36xHandler().supportFor(device("Dara 2.0"))).isNull() + assertThat(HumeDara2Handler().supportFor(device("Electronic Scale"))).isNull() + } + /** * SanitasSbf72Handler and BeurerSanitasHandler both answer to `sbf7x` names; only the list * position keeps the SBF72/73 devices on the driver written for them. diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HumeDara2HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HumeDara2HandlerTest.kt new file mode 100644 index 000000000..fa7ea7173 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HumeDara2HandlerTest.kt @@ -0,0 +1,89 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for the Hume Health Dara 2.0 frame decode ([HumeDara2Handler.weightKgFromFrame] / + * [HumeDara2Handler.impedanceOhmsFromFrame] / [HumeDara2Handler.checksum] / + * [HumeDara2Handler.isLockedFrame]), reverse-engineered from five real weigh-ins on the same + * person/device/profile — see [HumeDara2Handler]'s class doc for the full ground truth table + * and how each was cross-checked against Hume's own on-screen reading. + */ +class HumeDara2HandlerTest { + + private fun bytes(vararg v: Int): ByteArray = ByteArray(v.size) { v[it].toByte() } + + // --- Real captures -------------------------------------------------------------------- + + private val lockedFrame1 = bytes(0xCF, 0x0A, 0x14, 0xDE, 0x21, 0x5A, 0x55, 0x6F, 0x01, 0x00, 0x4F) + private val lockedFrame2 = bytes(0xCF, 0xC4, 0x13, 0xE8, 0x21, 0xE5, 0xE7, 0x96, 0x00, 0x00, 0x45) + private val lockedFrame3 = bytes(0xCF, 0xC4, 0x13, 0xED, 0x21, 0xAC, 0xA6, 0xAE, 0x00, 0x00, 0x70) + private val lockedFrame4 = bytes(0xCF, 0xC4, 0x13, 0xF7, 0x21, 0x5F, 0x55, 0x75, 0x00, 0x00, 0xB1) + private val lockedFrame5 = bytes(0xCF, 0xB0, 0x13, 0xF2, 0x21, 0x70, 0x75, 0xB7, 0x00, 0x00, 0x0D) + + // A live (not yet locked) frame from mid-settle on the same weigh-in as lockedFrame5. + private val liveFrame = bytes(0xCF, 0x00, 0x00, 0xDE, 0x21, 0x00, 0x00, 0x00, 0x00, 0x01, 0x31) + + @Test + fun `weight decodes to the recorded kg for every real capture`() { + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame1)).isWithin(1e-3f).of(86.70f) + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame2)).isWithin(1e-3f).of(86.80f) + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame3)).isWithin(1e-3f).of(86.85f) + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame4)).isWithin(1e-3f).of(86.95f) + assertThat(HumeDara2Handler.weightKgFromFrame(lockedFrame5)).isWithin(1e-3f).of(86.90f) + } + + @Test + fun `impedance decodes to plausible ohms and stays stable within a session`() { + // First-ever capture: bad/rushed foot contact, implausibly low — this is exactly what + // the plausible-range guard in HumeDara2Handler.publishFrame exists to reject. + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame1)).isWithin(0.01).of(25.80) + + // Three consecutive readings in one session: same foot contact, same impedance. + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame2)).isWithin(0.01).of(501.95) + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame3)).isWithin(0.01).of(501.95) + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame4)).isWithin(0.01).of(501.95) + + // A fresh BLE session later the same day: different (still plausible) contact. + assertThat(HumeDara2Handler.impedanceOhmsFromFrame(lockedFrame5)).isWithin(0.01).of(450.75) + } + + @Test + fun `checksum matches every real capture`() { + for (frame in listOf(lockedFrame1, lockedFrame2, lockedFrame3, lockedFrame4, lockedFrame5)) { + assertThat(HumeDara2Handler.checksum(frame)).isEqualTo(frame[10].toInt() and 0xFF) + } + } + + @Test + fun `a corrupted frame fails its checksum`() { + val corrupted = lockedFrame3.copyOf().also { it[3] = 0x00 } // tamper with a weight byte + assertThat(HumeDara2Handler.checksum(corrupted)).isNotEqualTo(corrupted[10].toInt() and 0xFF) + } + + @Test + fun `locked frames are distinguished from the live weight stream`() { + for (frame in listOf(lockedFrame1, lockedFrame2, lockedFrame3, lockedFrame4, lockedFrame5)) { + assertThat(HumeDara2Handler.isLockedFrame(frame)).isTrue() + } + assertThat(HumeDara2Handler.isLockedFrame(liveFrame)).isFalse() + } +} From eda762f38a59dec6bfb45e99f0938a418b7a8286 Mon Sep 17 00:00:00 2001 From: danidemis Date: Sat, 29 Aug 2026 12:07:48 +0200 Subject: [PATCH 53/72] feat(bluetooth): add support for Healthkeep smart scale (#1487) Co-authored-by: danidemis Co-authored-by: OliE --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../bluetooth/scales/HealthKeep280Handler.kt | 143 ++++++++++++++++++ .../openscale/core/bluetooth/ScaleCatalog.kt | 2 + .../scales/HealthKeep280HandlerTest.kt | 56 +++++++ 4 files changed, 203 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HealthKeep280Handler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HealthKeep280HandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 44512183a..843743cff 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth import android.content.Context import androidx.annotation.VisibleForTesting +import com.health.openscale.core.bluetooth.scales.HealthKeep280Handler import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler import com.health.openscale.core.bluetooth.scales.AAAxHandler @@ -133,6 +134,7 @@ class ScaleFactory @Inject constructor( @VisibleForTesting internal fun createHandlers(): List = listOf( // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). + HealthKeep280Handler(), PicoocHandler(), ActiveEraBF06Handler(), AfuB1Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HealthKeep280Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HealthKeep280Handler.kt new file mode 100644 index 000000000..c73e72b61 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HealthKeep280Handler.kt @@ -0,0 +1,143 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import java.util.Locale +import java.util.UUID + +class HealthKeep280Handler : ScaleDeviceHandler() { + + private val SERVICE_UUID: UUID = uuid16(0xFFB0) + private val WRITE_CHAR: UUID = uuid16(0xFFB1) + private val NOTIFY_CHAR: UUID = uuid16(0xFFB2) + private val INDICATE_CHAR: UUID = uuid16(0xFFB3) + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.trim().uppercase(Locale.US) + + if (name.startsWith("HEALTHKEEP")) { + val caps = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION + ) + return DeviceSupport( + displayName = "Healthkeep Smart Scale", + capabilities = caps, + implemented = caps, + linkMode = LinkMode.CONNECT_GATT + ) + } + + return null + } + + override fun onConnected(user: ScaleUser) { + logI("Starting connection sequence for Healthkeep scale.") + + setNotifyOn(SERVICE_UUID, NOTIFY_CHAR) + setNotifyOn(SERVICE_UUID, INDICATE_CHAR) + + val sex = if (user.gender.isMale()) 0x01 else 0x02 + val age = user.age + val height = user.bodyHeight.toInt() + + val payload = byteArrayOf( + 0xAC.toByte(), 0x02.toByte(), 0xFB.toByte(), + sex.toByte(), age.toByte(), height.toByte(), + 0xCC.toByte(), 0x00.toByte() + ) + + var checksum = 0 + for (i in 0..6) { + checksum += payload[i].toInt() and 0xFF + } + payload[7] = (checksum and 0xFF).toByte() + + writeTo(SERVICE_UUID, WRITE_CHAR, payload, withResponse = true) + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (data.isEmpty()) return + + // 1. Streaming peso live (Mostra a schermo il valore corrente senza salvare record multipli nel database) + if (characteristic == NOTIFY_CHAR && data.size >= 9) { + val status = data[4].toInt() and 0xFF + val weightRaw = ((data[6].toInt() and 0xFF) shl 16) or + ((data[7].toInt() and 0xFF) shl 8) or + (data[8].toInt() and 0xFF) + val weight = weightRaw / 1000.0f + + if (status == 0x01 || status == 0x02) { + userInfo(R.string.bluetooth_scale_info_measuring_weight, weight) + } + } + + // 2. Misura finale completa con BIA e Impedenza (Indicate su FFB3) + if (characteristic == INDICATE_CHAR && data.size >= 11) { + val cmd = data[3].toInt() and 0xFF + if (cmd == 0xA3) { + logD("Final measurement indication received: ${data.joinToString { "%02X".format(it) }}") + + val weightRaw = ((data[5].toInt() and 0xFF) shl 16) or + ((data[6].toInt() and 0xFF) shl 8) or + (data[7].toInt() and 0xFF) + val weight = weightRaw / 1000.0f + val heartRate = data[8].toInt() and 0xFF + val impedance = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF) + + val measurement = ScaleMeasurement().apply { + this.userId = user.id + this.weight = weight + this.dateTime = Date() + if (heartRate > 0) { + this.heartRate = heartRate + } + } + + // Calcolo automatico di grasso, acqua, muscoli e ossa tramite la libreria BIA di openScale + if (impedance > 0 && user.bodyHeight > 0 && user.age > 0) { + measurement.impedance = impedance.toDouble() + + val impedanceLib = StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = weight.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = impedance.toDouble() + ) + + measurement.fat = impedanceLib.totalFatPercentage.toFloat().coerceIn(0f, 75f) + measurement.water = impedanceLib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) + measurement.muscle = impedanceLib.skeletalMusclePercentage.toFloat().coerceIn(0f, 99f) + measurement.bone = impedanceLib.boneMassKg.toFloat().coerceIn(0f, 10f) + } + + logI("Publishing final measurement: $weight kg, Impedance: $impedance Ohm, HR: $heartRate bpm") + publish(measurement) + requestDisconnect() + } + } + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 4154997da..d3d1e6a55 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth import android.util.SparseArray import com.health.openscale.core.bluetooth.scales.AAAxHandler +import com.health.openscale.core.bluetooth.scales.HealthKeep280Handler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler import com.health.openscale.core.bluetooth.scales.AfuB1Handler import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler @@ -160,6 +161,7 @@ object ScaleCatalog { */ val fixtures: List = listOf( // --- Matched by advertised name --- + device("HEALTHKEEP 280") claimedBy HealthKeep280Handler::class.java, device("AE BS-06") claimedBy ActiveEraBF06Handler::class.java, device("AFU-BH-TZ-B1", uuid16(0xFC50)) claimedBy AfuB1Handler::class.java, device("Keep_S3") claimedBy KeepS3Handler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HealthKeep280HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HealthKeep280HandlerTest.kt new file mode 100644 index 000000000..cf8384876 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HealthKeep280HandlerTest.kt @@ -0,0 +1,56 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import org.junit.Test + +class HealthKeep280HandlerTest { + + @Test + fun testFinalMeasurementParsing() { + // Payload reale catturato da nRF Connect durante la pesata: + // Peso: 116.40 kg (0x01C6B0), Battito: 68 bpm (0x44), Impedenza: 357 Ohm (0x0165) + val data = byteArrayOf( + 0xCF.toByte(), 0x08, 0x00, 0xA3.toByte(), 0x00, + 0x01, 0xC6.toByte(), 0xB0.toByte(), + 0x44, + 0x01, 0x65, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04 + ) + + // Verifica comando BIA finale (0xA3) + val cmd = data[3].toInt() and 0xFF + assertThat(cmd).isEqualTo(0xA3) + + // Estrazione e verifica peso (usando la sintassi corretta di Truth per i float) + val weightRaw = ((data[5].toInt() and 0xFF) shl 16) or + ((data[6].toInt() and 0xFF) shl 8) or + (data[7].toInt() and 0xFF) + val weight = weightRaw / 1000.0f + assertThat(weight).isWithin(0.01f).of(116.40f) + + // Estrazione e verifica frequenza cardiaca (Heart Rate) + val heartRate = data[8].toInt() and 0xFF + assertThat(heartRate).isEqualTo(68) + + // Estrazione e verifica impedenza corporea (BIA) + val impedance = ((data[9].toInt() and 0xFF) shl 8) or (data[10].toInt() and 0xFF) + assertThat(impedance).isEqualTo(357) + } +} From b53d1169d8660a2eae6527987ef0e0d195ef30e3 Mon Sep 17 00:00:00 2001 From: Daniel Kukula <904179+dkuku@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:36:09 +0200 Subject: [PATCH 54/72] Fix/female body composition (#1488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add selectable Scientific body-composition algorithm to Mi Scale Mi Scale v1/v2 previously derived body composition only from the reverse-engineered Mi Fit algorithm (MiScaleLib). This adds a per-scale setting letting users pick between that "Xiaomi (original app)" path and a new "Scientific" estimator. - BodyMiScaleLib: mono-frequency estimator ported from the bodymiscale Home Assistant integration (GPL-3.0). SCIENCE mode chains a hardware-calibrated LBM with peer-reviewed formulas (Siri, Pace, Wang, Schofield) and additionally reports protein and BMR. Locked to byte-for-byte parity with the reference project via a regression test. - MiScaleHandler: radio picker to choose the algorithm; new measurements use the selected path. - ScaleDeviceHandler: extract a reusable SettingRadioGroup composable (settings-backed radio group) for device configuration UIs. - CREDITS: add bodymiscale attribution. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Daniel Kukula * Simplify BodyMiScaleLib to a scientific-only estimator The Mode enum threaded through every method was a vestigial artifact of the upstream bodymiscale port. openScale already selects the algorithm at the handler (MiScaleLib vs BodyMiScaleLib), so BodyMiScaleLib.Mode.XIAOMI was never used in production — only by a test comparing it to MiScaleLib. Drop Mode and its XIAOMI branches so the class is purely the scientific path. Method signatures shrink accordingly (getProtein no longer needs muscle/water, which existed only for the Xiaomi formula). Output is unchanged; the regression fixture still matches byte-for-byte. Co-Authored-By: Claude Opus 4.8 (1M context) * Update CREDITS * Correct female body composition estimates --------- Signed-off-by: Daniel Kukula Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: OliE --- .../core/bluetooth/libs/BodyMiScaleLib.kt | 17 +++++++++++++---- .../core/bluetooth/libs/BodyMiScaleLibTest.kt | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt index d21dfcc01..35c1742ab 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt @@ -50,19 +50,26 @@ class BodyMiScaleLib( private val isMale = gender == GenderType.MALE /** - * Lean / fat-free body mass in kg — the Xiaomi hardware-calibrated formula, shared by - * both modes and capped at 98% of body weight. Everything downstream depends on this. + * Lean / fat-free body mass in kg — the Xiaomi hardware-calibrated formula, corrected + * for female profiles because the base regression has no sex term, and capped at 98% + * of body weight. Everything downstream depends on this. */ fun getLbm(weightKg: Float, impedance: Float): Float { - val lbm = (heightCm * 9.058f / 100f) * (heightCm / 100f) + + var lbm = (heightCm * 9.058f / 100f) * (heightCm / 100f) + weightKg * 0.32f + 12.226f - impedance * 0.0068f - age * 0.0542f + + // bodymiscale 2026.8.0: its hardware regression lacks a sex term and overestimates + // female LBM by about 16%, skewing every metric derived from it. + if (!isMale) lbm *= FEMALE_LBM_CORRECTION + return minOf(lbm, weightKg * 0.98f) } /** Body fat percentage via the Siri (1956) 2-compartment model. Pass the [getLbm] result as [lbm]. */ fun getFat(weightKg: Float, lbm: Float): Float { val fat = (weightKg - lbm) / weightKg * 100f - return fat.coerceIn(5f, 75f) + val minimumFat = if (isMale) 5f else 10f + return fat.coerceIn(minimumFat, 75f) } /** Water percentage of body weight, via the Pace & Rathbun (1945) 0.73 constant. */ @@ -128,6 +135,8 @@ class BodyMiScaleLib( } private companion object { + const val FEMALE_LBM_CORRECTION = 0.84f + // Schofield (slope, constant) by bracket: 0-3, 3-10, 10-18, 18-30, 30-60, 60+ val MALE_SCHOFIELD = arrayOf( 59.512f to -30.4f, 22.706f to 504.3f, 17.686f to 658.2f, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt index fe5f8b4ef..4787cec47 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt @@ -64,4 +64,23 @@ class BodyMiScaleLibTest { val fatHigh = lib.getFat(weight, lib.getLbm(weight, 600f)) assertThat(fatHigh).isGreaterThan(fatLow) } + + @Test + fun female_profile_applies_bodymiscale_lbm_correction() { + val femaleLib = BodyMiScaleLib(GenderType.FEMALE, age = 46, heightCm = 168f) + + val lbm = femaleLib.getLbm(weight, impedance) + val fat = femaleLib.getFat(weight, lbm) + + assertThat(lbm).isWithin(EPS).of(50.40f) + assertThat(fat).isWithin(EPS).of(41.46f) + } + + @Test + fun fat_uses_sex_specific_biological_floor() { + val femaleLib = BodyMiScaleLib(GenderType.FEMALE, age = 46, heightCm = 168f) + + assertThat(lib.getFat(100f, 99f)).isEqualTo(5f) + assertThat(femaleLib.getFat(100f, 99f)).isEqualTo(10f) + } } From b736d886d08edcce521cc7af42ac22e18f1b7a07 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 29 Aug 2026 14:01:12 +0200 Subject: [PATCH 55/72] Improve calendar-week handling across aggregation features, issue #1454 - Update aggregation logic to explicitly use `WeekFields` when calculating period bounds and keys - Pass `weekFields` into `periodBounds` and `periodKey` calls to ensure consistency across aggregation contexts - Update period label generation to consistently use `weekFields` derived from `LocaleUtils.systemWeekFields()` - Update date formatting in `MeasurementChartLayers` and `TableScreen` to correctly handle week labels based on shared `WeekFields` - Adjust `MeasurementAggregationUseCase` to accept and propagate `weekFields` for accurate aggregation key calculation --- .../com/health/openscale/core/data/Enums.kt | 48 ++++--- .../usecase/MeasurementAggregationUseCase.kt | 39 ++---- .../openscale/core/utils/LocaleUtils.kt | 22 ++++ .../components/MeasurementChartFilter.kt | 15 ++- .../components/MeasurementChartLayers.kt | 15 ++- .../ui/screen/overview/OverviewScreen.kt | 12 +- .../openscale/ui/screen/table/TableScreen.kt | 50 ++++---- .../core/data/AggregationLevelTest.kt | 120 ++++++++++++++++++ .../MeasurementAggregationUseCaseTest.kt | 55 ++++++++ 9 files changed, 298 insertions(+), 78 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index f081f5fb1..983338f2c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -77,11 +77,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import com.health.openscale.R -import java.time.DayOfWeek +import com.health.openscale.core.utils.LocaleUtils import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +import java.time.temporal.TemporalAdjusters import java.time.temporal.WeekFields import java.util.Locale @@ -476,16 +477,24 @@ enum class AggregationLevel(@param:StringRes val displayNameResId: Int) { * as epoch milliseconds. * * For [NONE] and [DAY] the period is a single calendar day. + * + * [weekFields] must be the same rule that [periodKey] and [periodLabel] are given — + * a [WEEK] period bounded by a different first-day-of-week than the one used to group + * measurements would not contain its own members (see issue #1454). */ fun periodBounds( timestamp: Long, zone: ZoneId = ZoneId.systemDefault(), + weekFields: WeekFields = LocaleUtils.systemWeekFields(), ): Pair { val date = Instant.ofEpochMilli(timestamp).atZone(zone).toLocalDate() val (start, end) = when (this) { NONE, DAY -> date to date.plusDays(1) - WEEK -> { val mon = date.with(DayOfWeek.MONDAY); mon to mon.plusWeeks(1) } + WEEK -> { + val first = date.with(TemporalAdjusters.previousOrSame(weekFields.firstDayOfWeek)) + first to first.plusWeeks(1) + } MONTH -> { val f = date.withDayOfMonth(1); f to f.plusMonths(1) } YEAR -> { val f = date.withDayOfYear(1); f to f.plusYears(1) } } @@ -494,42 +503,50 @@ enum class AggregationLevel(@param:StringRes val displayNameResId: Int) { } /** - * Returns a stable, locale-independent key for the period containing [timestamp]. + * Returns a key identifying the period containing [timestamp]. * Suitable as a LazyColumn item key or Map key. * * Examples: "2025-04-07" (DAY/NONE), "2025-W15" (WEEK), "2025-4" (MONTH), "2025" (YEAR). + * + * Stable for a given [zone] and [weekFields]; the [WEEK] key depends on the week rule and + * therefore changes if the device region does. Nothing persists it — it lives in memory for + * one aggregation pass — and a locale change recreates the activity, so that is harmless. + * It is not a durable identifier and must not be written to disk or exported. */ fun periodKey( timestamp: Long, zone: ZoneId = ZoneId.systemDefault(), + weekFields: WeekFields = LocaleUtils.systemWeekFields(), ): String { val date = Instant.ofEpochMilli(timestamp).atZone(zone).toLocalDate() return when (this) { NONE, DAY -> date.toString() - WEEK -> { - val wf = WeekFields.of(Locale.getDefault()) - "${date.get(wf.weekBasedYear())}-W${date.get(wf.weekOfWeekBasedYear())}" - } + WEEK -> "${date.get(weekFields.weekBasedYear())}-W${date.get(weekFields.weekOfWeekBasedYear())}" MONTH -> "${date.year}-${date.monthValue}" YEAR -> "${date.year}" } } /** - * Returns a human-readable, locale-sensitive label for the period containing [timestamp]. + * Returns a human-readable label for the period containing [timestamp]. * - * Intentionally separate from [periodKey] — labels are locale-dependent and must - * not be used as stable identifiers. + * [locale] only drives presentation — month names, date style. The week *identity* comes + * from [weekFields], the same rule [periodKey] and [periodBounds] use, so the number shown + * always belongs to the period the row actually covers. * * @param calendarWeekAbbrev Localised abbreviation for "calendar week" (e.g. "CW" / "KW"). * Only used for [WEEK]. + * @param short Renders [MONTH] as "Apr 2025" instead of "April 2025", for + * narrow layouts such as the table's period column. */ fun periodLabel( timestamp: Long, calendarWeekAbbrev: String, locale: Locale = Locale.getDefault(), zone: ZoneId = ZoneId.systemDefault(), + weekFields: WeekFields = LocaleUtils.systemWeekFields(), + short: Boolean = false, ): String { val date = Instant.ofEpochMilli(timestamp).atZone(zone).toLocalDate() return when (this) { @@ -537,11 +554,12 @@ enum class AggregationLevel(@param:StringRes val displayNameResId: Int) { DAY -> date.format( DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) ) - WEEK -> { - val wf = WeekFields.of(locale) - "${date.get(wf.weekBasedYear())} – $calendarWeekAbbrev ${date.get(wf.weekOfWeekBasedYear())}" - } - MONTH -> date.format(DateTimeFormatter.ofPattern("MMMM yyyy", locale)) + WEEK -> + "${date.get(weekFields.weekBasedYear())} – " + + "$calendarWeekAbbrev ${date.get(weekFields.weekOfWeekBasedYear())}" + MONTH -> date.format( + DateTimeFormatter.ofPattern(if (short) "MMM yyyy" else "MMMM yyyy", locale) + ) YEAR -> date.year.toString() } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementAggregationUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementAggregationUseCase.kt index a32ba44a4..dd8c1d350 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementAggregationUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementAggregationUseCase.kt @@ -26,10 +26,9 @@ import com.health.openscale.core.model.EnrichedMeasurement import com.health.openscale.core.model.MeasurementValueWithType import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.model.ValueWithDifference -import java.time.Instant +import com.health.openscale.core.utils.LocaleUtils import java.time.ZoneId import java.time.temporal.WeekFields -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.math.roundToInt @@ -58,6 +57,9 @@ class MeasurementAggregationUseCase @Inject constructor() { * * @param measurements Chronologically sorted list (newest → oldest). * @param level The desired aggregation granularity. + * @param weekFields Calendar-week rule for [AggregationLevel.WEEK]. Grouping, bounds and + * key all use this one value, so a period always contains its own + * members (issue #1454). Tests pin it instead of relying on the device. * @return List of [AggregatedMeasurement] sorted newest → oldest. * When [level] is [AggregationLevel.NONE] each raw measurement is wrapped * individually with [AggregatedMeasurement.aggregatedFromCount] == 1. @@ -66,49 +68,33 @@ class MeasurementAggregationUseCase @Inject constructor() { measurements: List, level: AggregationLevel, zone: ZoneId = ZoneId.systemDefault(), + weekFields: WeekFields = LocaleUtils.systemWeekFields(), ): List { if (measurements.isEmpty()) return emptyList() if (level == AggregationLevel.NONE) { return measurements.map { em -> val ts = em.measurementWithValues.measurement.timestamp - val (periodStart, periodEnd) = level.periodBounds(ts, zone) + val (periodStart, periodEnd) = level.periodBounds(ts, zone, weekFields) AggregatedMeasurement( enriched = em, aggregatedFromCount = 1, periodStartMillis = periodStart, periodEndMillis = periodEnd, - periodKey = level.periodKey(ts, zone), + periodKey = level.periodKey(ts, zone, weekFields), ) } } - // Hoist WeekFields outside the groupBy lambda (locale lookup is not free) - val weekFields = if (level == AggregationLevel.WEEK) WeekFields.of(Locale.getDefault()) else null - - // Group by period key — reuses the same logic as periodKey() extension + // Group by the very key the entry will carry, so the two can never drift apart. val grouped: Map> = measurements .groupBy { em -> - val date = Instant.ofEpochMilli(em.measurementWithValues.measurement.timestamp) - .atZone(zone) - .toLocalDate() - when (level) { - AggregationLevel.NONE -> date.toString() - AggregationLevel.DAY -> date.toString() - AggregationLevel.WEEK -> { - val wf = weekFields!! - val week = date.get(wf.weekOfWeekBasedYear()) - val year = date.get(wf.weekBasedYear()) - "$year-W$week" - } - AggregationLevel.MONTH -> "${date.year}-${date.monthValue}" - AggregationLevel.YEAR -> "${date.year}" - } + level.periodKey(em.measurementWithValues.measurement.timestamp, zone, weekFields) } // Build one aggregated entry per group, sorted oldest → newest for diff calculation val sortedOldestFirst: List = grouped.values - .mapNotNull { group -> buildAggregatedEntry(group, level, zone) } + .mapNotNull { group -> buildAggregatedEntry(group, level, zone, weekFields) } .sortedBy { it.enriched.measurementWithValues.measurement.timestamp } // Calculate diff + trend between consecutive periods then reverse to newest → oldest @@ -132,6 +118,7 @@ class MeasurementAggregationUseCase @Inject constructor() { group: List, level: AggregationLevel, zone: ZoneId, + weekFields: WeekFields, ): AggregatedMeasurement? { if (group.isEmpty()) return null @@ -198,14 +185,14 @@ class MeasurementAggregationUseCase @Inject constructor() { measurementWithValuesProjected = emptyList(), ) - val (periodStart, periodEnd) = level.periodBounds(minTs, zone) + val (periodStart, periodEnd) = level.periodBounds(minTs, zone, weekFields) return AggregatedMeasurement( enriched = enriched, aggregatedFromCount = group.size, periodStartMillis = periodStart, periodEndMillis = periodEnd, - periodKey = level.periodKey(minTs, zone), + periodKey = level.periodKey(minTs, zone, weekFields), ) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt index 58afa3d59..447f4798c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt @@ -19,12 +19,14 @@ package com.health.openscale.core.utils import android.app.LocaleManager import android.content.res.Configuration +import android.content.res.Resources import android.os.Build import android.os.LocaleList import androidx.activity.ComponentActivity import com.health.openscale.core.data.SupportedLanguage import com.health.openscale.core.data.UnitType import java.text.NumberFormat +import java.time.temporal.WeekFields import java.util.Locale @@ -168,6 +170,26 @@ object LocaleUtils { } } + /** + * The calendar-week rule (first day of week, minimal days in first week) for grouping + * measurements into weeks. + * + * Deliberately read from the **device** configuration, never from [effectiveLocale] or + * `Locale.getDefault()`: [SupportedLanguage.toLocale] builds region-less locales such as + * `de`, and `WeekFields.of(Locale("de"))` is Sunday-based with a 1-day first week — so a + * German user picking the German in-app language would get Sunday weeks. On API 33+ + * [updateAppLocale] additionally pushes that region-less locale into `LocaleManager`, + * where it can also become `Locale.getDefault()`. + * + * Falls back to ISO whenever no region is known (or the framework is unavailable, as in + * plain JVM unit tests), so the result is deterministic instead of accidentally Sunday. + */ + @JvmStatic + fun systemWeekFields(): WeekFields = runCatching { + val locale = Resources.getSystem().configuration.locales[0] + if (locale.country.isNullOrBlank()) WeekFields.ISO else WeekFields.of(locale) + }.getOrDefault(WeekFields.ISO) + /** * Locale-aware number formatting with clamped fraction digits. * Returns the raw string if parsing fails. diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt index 073329f81..34bab490e 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt @@ -52,16 +52,16 @@ import com.health.openscale.core.data.AggregationLevel import com.health.openscale.core.data.TimeRangeFilter import com.health.openscale.core.facade.SettingsPreferenceKeys import com.health.openscale.core.model.MeasurementWithValues +import com.health.openscale.core.utils.LocaleUtils import com.health.openscale.ui.shared.SharedViewModel import com.health.openscale.ui.shared.TopBarAction import kotlinx.coroutines.launch -import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit -import java.time.temporal.WeekFields +import java.time.temporal.TemporalAdjusters import java.util.Locale /** @@ -287,6 +287,11 @@ internal fun rememberPeriodChartData( Instant.ofEpochMilli(it.measurement.timestamp).atZone(ZoneId.systemDefault()).toLocalDate() } + // Same calendar-week rule the aggregation uses, so a bar and its table row cover the + // same days and carry the same week number. + val weekFields = LocaleUtils.systemWeekFields() + val firstDayOfWeek = weekFields.firstDayOfWeek + val totalDays = ChronoUnit.DAYS.between(minDate, maxDate).toInt() val groupingUnit: ChronoUnit val intervalSize: Long = 1 @@ -301,7 +306,7 @@ internal fun rememberPeriodChartData( val allPeriods = mutableListOf() var cursor = when (groupingUnit) { ChronoUnit.DAYS -> minDate - ChronoUnit.WEEKS -> minDate.with(DayOfWeek.MONDAY) + ChronoUnit.WEEKS -> minDate.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) ChronoUnit.MONTHS -> minDate.withDayOfMonth(1) else -> minDate.withDayOfYear(1) } @@ -331,7 +336,7 @@ internal fun rememberPeriodChartData( .atZone(ZoneId.systemDefault()).toLocalDate() when (groupingUnit) { ChronoUnit.DAYS -> date - ChronoUnit.WEEKS -> date.with(DayOfWeek.MONDAY) + ChronoUnit.WEEKS -> date.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) ChronoUnit.MONTHS -> date.withDayOfMonth(1) else -> date.withDayOfYear(1) } @@ -341,7 +346,7 @@ internal fun rememberPeriodChartData( val labelFormatter: (LocalDate) -> String = { date -> when (groupingUnit) { ChronoUnit.DAYS -> date.format(DateTimeFormatter.ofPattern("d LLL", locale)) - ChronoUnit.WEEKS -> "W${date.get(WeekFields.of(locale).weekOfWeekBasedYear())}" + ChronoUnit.WEEKS -> "W${date.get(weekFields.weekOfWeekBasedYear())}" ChronoUnit.MONTHS -> date.format(DateTimeFormatter.ofPattern("LLL yy", locale)) else -> date.year.toString() } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartLayers.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartLayers.kt index 52ecc43f6..4c035e550 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartLayers.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartLayers.kt @@ -388,19 +388,26 @@ internal fun rememberXAxisValueFormatter( return remember(chartSeries, aggregationLevel, weekAbbrev) { val xToDatesMap = chartSeries.flatMap { it.points }.associate { it.x to it.date } + // The 'w' pattern letter resolves against the formatter's locale, which would let the + // axis disagree with the table and overview. Build the week label from the shared rule + // instead; the other levels have no such ambiguity. + val weekFields = LocaleUtils.systemWeekFields() + val pattern = when (aggregationLevel) { AggregationLevel.NONE -> "d MMM" AggregationLevel.DAY -> "d MMM" - AggregationLevel.WEEK -> "'${weekAbbrev}'w yy" + AggregationLevel.WEEK -> null AggregationLevel.MONTH -> "MMM yy" AggregationLevel.YEAR -> "yyyy" } - val formatter = DateTimeFormatter.ofPattern(pattern) + val formatter = pattern?.let { DateTimeFormatter.ofPattern(it) } CartesianValueFormatter { _, value, _ -> - (xToDatesMap[value.toFloat()] ?: LocalDate.ofEpochDay(value.toLong())) - .format(formatter) + val date = xToDatesMap[value.toFloat()] ?: LocalDate.ofEpochDay(value.toLong()) + formatter?.format(date) + ?: "$weekAbbrev${date.get(weekFields.weekOfWeekBasedYear())} " + + "${date.get(weekFields.weekBasedYear()) % 100}" } } } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index fb2ee66b1..d45d6dd5e 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -136,6 +136,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds import java.text.DateFormat +import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlin.math.abs @@ -182,6 +183,9 @@ fun OverviewScreen( sharedViewModel = sharedViewModel, ) val isAggregated = !isDrillDown && activeAggregationLevel != AggregationLevel.NONE + // Hoisted out of the item scope: the same rule the period bounds behind each row were + // built from, so label and drill-down range always agree. + val weekFields = remember { LocaleUtils.systemWeekFields() } // ── Data ────────────────────────────────────────────────────────────────── // Normal mode: screenFlow — cached, reacts to user/time-range/aggregation changes. @@ -618,6 +622,7 @@ fun OverviewScreen( aggregatedPeriodLabel = activeAggregationLevel.periodLabel( timestamp = ts, calendarWeekAbbrev = stringResource(R.string.calendar_week_abbrev), + weekFields = weekFields, ), ) } else { @@ -777,9 +782,10 @@ fun MeasurementCard( // Read the locale observably so the label recomposes on locale changes. val locale = ComposeLocale.current.platformLocale remember(measurementWithValues.measurement.timestamp, locale) { - val ts = measurementWithValues.measurement.timestamp - "${DateFormat.getDateInstance(DateFormat.MEDIUM, locale).format(Date(ts))} " + - DateFormat.getTimeInstance(DateFormat.SHORT, locale).format(Date(ts)) + val date = Date(measurementWithValues.measurement.timestamp) + "${DateFormat.getDateInstance(DateFormat.MEDIUM, locale).format(date)} " + + "(${SimpleDateFormat("EE", locale).format(date)}) " + + DateFormat.getTimeInstance(DateFormat.SHORT, locale).format(date) } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt index 3cb0e818a..274c2f744 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt @@ -119,9 +119,7 @@ import java.text.DateFormat import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit -import java.time.temporal.WeekFields import java.util.Date import java.util.Locale @@ -397,22 +395,19 @@ fun TableScreen( val dateFormatterTime = remember { DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()) } val calendarWeekAbbrev = stringResource(R.string.calendar_week_abbrev) + val weekFields = remember { LocaleUtils.systemWeekFields() } + + // Period labels come from AggregationLevel.periodLabel so the week number shown here is the + // one the row's period bounds were built from — see issue #1454. val aggregationLabelFormatter: (Long, AggregationLevel) -> String = - remember(effectiveAggregationLevel, calendarWeekAbbrev) { + remember(calendarWeekAbbrev, weekFields) { { timestamp, level -> - val date = Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate() - val locale = Locale.getDefault() - when (level) { - AggregationLevel.NONE -> "" - AggregationLevel.DAY -> dateFormatterDate.format(Date(timestamp)) - AggregationLevel.WEEK -> { - val wf = WeekFields.of(locale) - "${date.get(wf.weekBasedYear())} – $calendarWeekAbbrev ${date.get(wf.weekOfWeekBasedYear())}" - } - AggregationLevel.MONTH -> - date.format(DateTimeFormatter.ofPattern("MMM yyyy", locale)) - AggregationLevel.YEAR -> date.year.toString() - } + if (level == AggregationLevel.NONE) "" else level.periodLabel( + timestamp = timestamp, + calendarWeekAbbrev = calendarWeekAbbrev, + weekFields = weekFields, + short = true, + ) } } @@ -576,19 +571,24 @@ fun TableScreen( Instant.ofEpochMilli(drillDownEndMillis).atZone(ZoneId.systemDefault()).toLocalDate(), ) val midMillis = drillDownStartMillis + (drillDownEndMillis - drillDownStartMillis) / 2L - val date = Instant.ofEpochMilli(midMillis).atZone(ZoneId.systemDefault()).toLocalDate() // Read the locale observably so the title recomposes on locale changes. val locale = ComposeLocale.current.platformLocale val count = aggregatedItems.size - val label = when { - spanDays <= 1 -> dateFormatterDate.format(Date(drillDownStartMillis)) - spanDays <= 8 -> { - val wf = WeekFields.of(locale) - "${date.get(wf.weekBasedYear())} – $calendarWeekAbbrev ${date.get(wf.weekOfWeekBasedYear())}" - } - spanDays <= 32 -> date.format(DateTimeFormatter.ofPattern("MMMM yyyy", locale)) - else -> date.year.toString() + // The drill-down range may come from any screen's aggregation, so the level is derived + // from the range itself rather than from this screen's setting. Labelling then goes + // through periodLabel like everywhere else. + val level = when { + spanDays <= 1 -> AggregationLevel.DAY + spanDays <= 8 -> AggregationLevel.WEEK + spanDays <= 32 -> AggregationLevel.MONTH + else -> AggregationLevel.YEAR } + val label = level.periodLabel( + timestamp = midMillis, + calendarWeekAbbrev = calendarWeekAbbrev, + locale = locale, + weekFields = weekFields, + ) "$label ($count)" } else { stringResource(R.string.route_title_table) diff --git a/android_app/app/src/test/java/com/health/openscale/core/data/AggregationLevelTest.kt b/android_app/app/src/test/java/com/health/openscale/core/data/AggregationLevelTest.kt index 40b7bb9b3..27ad0da79 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/data/AggregationLevelTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/data/AggregationLevelTest.kt @@ -18,10 +18,15 @@ package com.health.openscale.core.data import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.utils.LocaleUtils import com.health.openscale.testutil.Fixtures.ts import org.junit.Test +import java.time.DayOfWeek import java.time.Instant +import java.time.LocalDate import java.time.ZoneId +import java.time.temporal.WeekFields +import java.util.Locale /** Tests the date-bucketing that drives day/week/month/year aggregation grouping. */ class AggregationLevelTest { @@ -73,4 +78,119 @@ class AggregationLevelTest { assertThat(startDate.monthValue).isEqualTo(4) assertThat(start).isLessThan(end) } + + // ── Calendar weeks ──────────────────────────────────────────────────────── + // + // The week rule is an explicit parameter here rather than the device default, so these + // tests describe both a Monday-first and a Sunday-first region regardless of where they + // run. Under a Monday-first host locale the Sunday-first cases used to pass by accident, + // which is why issue #1454 went unnoticed. + + private val isoWeek: WeekFields = WeekFields.ISO + private val usWeek: WeekFields = WeekFields.of(Locale.US) + + @Test + fun periodBounds_week_startsOnTheRulesFirstDayOfWeek() { + val wednesday = ts(2025, 4, 9) + + val isoStart = Instant.ofEpochMilli( + AggregationLevel.WEEK.periodBounds(wednesday, zone, isoWeek).first + ).atZone(zone).toLocalDate() + val usStart = Instant.ofEpochMilli( + AggregationLevel.WEEK.periodBounds(wednesday, zone, usWeek).first + ).atZone(zone).toLocalDate() + + assertThat(isoStart.dayOfWeek).isEqualTo(DayOfWeek.MONDAY) + assertThat(usStart.dayOfWeek).isEqualTo(DayOfWeek.SUNDAY) + } + + @Test + fun periodKey_week_sundayAndMondayShareAWeekOnlyUnderASundayFirstRule() { + val sunday = ts(2025, 4, 6) + val monday = ts(2025, 4, 7) + + assertThat(AggregationLevel.WEEK.periodKey(sunday, zone, usWeek)) + .isEqualTo(AggregationLevel.WEEK.periodKey(monday, zone, usWeek)) + assertThat(AggregationLevel.WEEK.periodKey(sunday, zone, isoWeek)) + .isNotEqualTo(AggregationLevel.WEEK.periodKey(monday, zone, isoWeek)) + } + + /** + * Regression for issue #1454: grouping used the locale week while the bounds were hardcoded + * to ISO, so under a Sunday-first rule a group's own member fell outside the range reported + * for that group — and the drill-down filtered by that range showed the wrong rows. + */ + @Test + fun periodBounds_containEveryTimestampSharingThePeriodKey() { + val timestamps = (0 until 400).map { ts(2025, 1, 1).plusDays(it) } + + for (weekFields in listOf(isoWeek, usWeek)) { + for (level in AggregationLevel.entries) { + timestamps + .groupBy { level.periodKey(it, zone, weekFields) } + .forEach { (key, group) -> + val (start, end) = level.periodBounds(group.first(), zone, weekFields) + group.forEach { t -> + assertThat(t).isAtLeast(start) + assertThat(t).isLessThan(end) + } + // and the bounds must not reach into a neighbouring period + assertThat(level.periodKey(start, zone, weekFields)).isEqualTo(key) + assertThat(level.periodKey(end - 1, zone, weekFields)).isEqualTo(key) + } + } + } + } + + @Test + fun periodLabel_week_showsTheSameNumberAsThePeriodKey() { + for (weekFields in listOf(isoWeek, usWeek)) { + for (offset in 0 until 400) { + val t = ts(2025, 1, 1).plusDays(offset) + val week = AggregationLevel.WEEK.periodKey(t, zone, weekFields) + .substringAfter("-W") + val label = AggregationLevel.WEEK.periodLabel( + timestamp = t, + calendarWeekAbbrev = "CW", + weekFields = weekFields, + ) + assertThat(label).endsWith("CW $week") + } + } + } + + /** New Year: ISO uses the first-Thursday rule, the US rule the week containing Jan 1. */ + @Test + fun periodKey_week_yearBoundaryFollowsTheRule() { + val newYear = ts(2027, 1, 1) + + assertThat(AggregationLevel.WEEK.periodKey(newYear, zone, usWeek)).isEqualTo("2027-W1") + assertThat(AggregationLevel.WEEK.periodKey(newYear, zone, isoWeek)).isEqualTo("2026-W53") + } + + @Test + fun periodLabel_month_honoursTheShortFlag() { + val april = ts(2025, 4, 15) + + assertThat( + AggregationLevel.MONTH.periodLabel(april, "CW", Locale.US, zone, isoWeek, short = false) + ).isEqualTo("April 2025") + assertThat( + AggregationLevel.MONTH.periodLabel(april, "CW", Locale.US, zone, isoWeek, short = true) + ).isEqualTo("Apr 2025") + } + + /** + * The device configuration is unavailable in a plain JVM test, which exercises the same + * fallback that a region-less locale takes: ISO rather than an accidental Sunday week. + */ + @Test + fun systemWeekFields_fallsBackToIsoWithoutADeviceRegion() { + assertThat(LocaleUtils.systemWeekFields()).isEqualTo(WeekFields.ISO) + } + + private fun Long.plusDays(days: Int): Long = + LocalDate.ofEpochDay( + Instant.ofEpochMilli(this).atZone(zone).toLocalDate().toEpochDay() + days + ).atTime(12, 0).atZone(zone).toInstant().toEpochMilli() } diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt index 5294d8148..e852ff4b0 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt @@ -24,6 +24,9 @@ import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.model.EnrichedMeasurement import com.health.openscale.testutil.Fixtures import org.junit.Test +import java.time.ZoneId +import java.time.temporal.WeekFields +import java.util.Locale class MeasurementAggregationUseCaseTest { @@ -147,4 +150,56 @@ class MeasurementAggregationUseCaseTest { .first { it.type.id == weight.id }.value assertThat(aggregated.intValue).isNull() } + + // ── Calendar weeks ──────────────────────────────────────────────────────── + + private val zone: ZoneId = ZoneId.systemDefault() + private val isoWeek: WeekFields = WeekFields.ISO + private val usWeek: WeekFields = WeekFields.of(Locale.US) + + @Test + fun aggregate_week_groupsBySundayOrMondayDependingOnTheRule() { + val input = listOf( + em(1, Fixtures.ts(2025, 4, 6), 80f), // Sunday + em(2, Fixtures.ts(2025, 4, 7), 82f), // Monday + ) + + assertThat(useCase.aggregate(input, AggregationLevel.WEEK, zone, usWeek)).hasSize(1) + assertThat(useCase.aggregate(input, AggregationLevel.WEEK, zone, isoWeek)).hasSize(2) + } + + /** + * Regression for issue #1454: the entry's period bounds must cover every measurement that + * was folded into it, otherwise the drill-down filtered by those bounds shows other rows + * than the aggregated row summarised. + */ + @Test + fun aggregate_periodBoundsContainEveryMeasurementOfTheirOwnEntry() { + val input = (0 until 60).map { day -> + em(day + 1, Fixtures.ts(2025, 3, 1) + day * 86_400_000L, 80f + day) + } + + for (weekFields in listOf(isoWeek, usWeek)) { + for (level in AggregationLevel.entries) { + val out = useCase.aggregate(input, level, zone, weekFields) + + assertThat(out.sumOf { it.aggregatedFromCount }).isEqualTo(input.size) + + out.forEach { entry -> + val members = input.filter { + level.periodKey( + it.measurementWithValues.measurement.timestamp, zone, weekFields + ) == entry.periodKey + } + assertThat(members).isNotEmpty() + assertThat(members).hasSize(entry.aggregatedFromCount) + members.forEach { m -> + val t = m.measurementWithValues.measurement.timestamp + assertThat(t).isAtLeast(entry.periodStartMillis) + assertThat(t).isLessThan(entry.periodEndMillis) + } + } + } + } + } } From 066483adc12ad2dd040fb50f6f4f8b90c6a41223 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 29 Aug 2026 14:35:07 +0200 Subject: [PATCH 56/72] Update Bluetooth device support logic to use full device object --- .../health/openscale/core/bluetooth/ScaleFactory.kt | 13 +++++++++---- .../health/openscale/core/facade/BluetoothFacade.kt | 2 +- .../core/bluetooth/ScaleFactoryDeveloperModeTest.kt | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 843743cff..82ed7a68a 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -302,10 +302,15 @@ class ScaleFactory @Inject constructor( return null } - fun getDeviceSupportFor(name: String, address: String): DeviceSupport? { - val info = ScannedDeviceInfo(name, address, 0, emptyList(), null) - return modernKotlinHandlers.firstNotNullOfOrNull { it.supportFor(info) } - } + /** + * Returns the [DeviceSupport] of the first handler that claims [device]. + * + * Always pass the complete advertisement: handlers that identify a scale by its services, + * manufacturer data or service data (Etekcity Fit 8S, Yunmai X, the standard weight profile, + * ...) cannot recognise it from name and address alone and would report "no support". + */ + fun getDeviceSupportFor(device: ScannedDeviceInfo): DeviceSupport? = + modernKotlinHandlers.firstNotNullOfOrNull { it.supportFor(device) } /** * Checks if any known handler can theoretically support the given device. diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt index 4617fda41..b6511be65 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt @@ -155,7 +155,7 @@ class BluetoothFacadeImpl @Inject constructor( override val savedDeviceSupport: StateFlow = combine(savedDevice, savedTuningProfile) { dev, tuning -> if (dev == null) return@combine null - val base = scaleFactory.getDeviceSupportFor(dev.name, dev.address) ?: return@combine null + val base = scaleFactory.getDeviceSupportFor(dev) ?: return@combine null base.copy(tuningProfile = tuning) }.stateIn(scope, SharingStarted.WhileSubscribed(5000), null) diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryDeveloperModeTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryDeveloperModeTest.kt index 1fd4fda91..36c4c263a 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryDeveloperModeTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryDeveloperModeTest.kt @@ -95,12 +95,12 @@ class ScaleFactoryDeveloperModeTest { @Test fun `developer mode leaves the reported driver of the saved scale untouched`() = runTest { val (factory, settings) = factoryWithSettings() - val before = factory.getDeviceSupportFor(GATT_SCALE, "00:11:22:33:44:55") + val before = factory.getDeviceSupportFor(ScaleCatalog.device(GATT_SCALE)) settings.setDeveloperModeEnabled(true) assertThat(before?.displayName).isEqualTo("Sanitas SBF70 / SilverCrest SBF75 / Crane") - assertThat(factory.getDeviceSupportFor(GATT_SCALE, "00:11:22:33:44:55")?.displayName) + assertThat(factory.getDeviceSupportFor(ScaleCatalog.device(GATT_SCALE))?.displayName) .isEqualTo(before?.displayName) } From 3cec5e0487866d0e9b7a12b1b39f55983c01ed5e Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 29 Aug 2026 14:46:53 +0200 Subject: [PATCH 57/72] Fix broadcast adapter state on successful scan completion - Prevent processing stale advertisements after scan stops - Ensure handler cleanup when a weigh-in completes successfully - Explicitly call handler disconnection and detachment to reset per-session state --- .../core/bluetooth/scales/BroadcastScaleAdapter.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BroadcastScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BroadcastScaleAdapter.kt index b6a6579c9..aca22b384 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BroadcastScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BroadcastScaleAdapter.kt @@ -71,6 +71,11 @@ class BroadcastScaleAdapter( // Filter: only target MAC if (peripheral.address != targetAddress) return + // Filter: the scan is over (measurement complete, timed out, or disconnected). + // Android still delivers advertisements that were already queued when stopScan() + // was called; processing them would re-attach the handler we just released. + if (!isScanning) return + // Filter: optional RSSI threshold val rssi = scanResult.rssi tuning.minRssiDbm?.let { if (rssi < it) return } @@ -122,6 +127,8 @@ class BroadcastScaleAdapter( _events.tryEmit(BluetoothEvent.BroadcastComplete(peripheral.address)) stopScanInternal() cleanup() + runCatching { handler.handleDisconnected() } + runCatching { handler.detach() } broadcastAttached = false } } From c101084159e2c003d40d99d5139d7991d923c1cf Mon Sep 17 00:00:00 2001 From: Stefanos1000 <106353468+Stefanos1000@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:27:12 +0300 Subject: [PATCH 58/72] Huawei Scale 3: add realtime body composition support (#1492) * Huawei Scale 3: add realtime body composition support * Huawei Scale 3: address review feedback * Adjust Scale 3 impedance handling for accurate BIA reporting - Correctly convert Scale 3 low-frequency impedance from 0.1-ohm units - Store the converted, model-used impedance value instead of the raw reading - Update tests to validate the correct impedance unit conversion for Scale 3 - Add necessary boilerplate documentation/licenses to Scale3BodyComposition - Update package documentation style across scale files --------- Co-authored-by: oliexdev --- .../scales/HuaweiHagridWspHandler.kt | 411 +++++++++++++----- .../scales/HuaweiScale3BodyComposition.kt | 211 +++++++++ .../scales/HuaweiHagridWspHandlerTest.kt | 127 ++++-- .../scales/HuaweiScale3BodyCompositionTest.kt | 148 +++++++ 4 files changed, 762 insertions(+), 135 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiScale3BodyComposition.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiScale3BodyCompositionTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt index d423d42fd..9de66ae55 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt @@ -119,11 +119,12 @@ class HuaweiHagridWspHandler( private var measurementStatusPollJob: Job? = null private var measurementStatusReady = false private var postStatusMeasurementReadsStarted = false - private var realtimeFinalPublished = false - // Scale 3: cache the last valid realtime measurement so it can be published - // on status=0 if it arrived before the status notification (the common case). - private var lastValidScale3Realtime: HuaweiHagridWspLib.HagridWeightMeasurement? = null - private var lastKnownUser: ScaleUser? = null + private var pendingRealtimeMeasurement: HuaweiHagridWspLib.HagridWeightMeasurement? = null + private var pendingRealtimeUser: ScaleUser? = null + private var measurementCycleCompleted = false + private var measurementPublished = false + private var rearmPollingJob: Job? = null + private var rearmPollOutstanding = false @Composable override fun DeviceConfigurationUi() { @@ -237,6 +238,7 @@ class HuaweiHagridWspHandler( pendingRandB = null session = null stopMeasurementStatusPolling() + stopRearmPolling() configuredSecrets = loadConfiguredSecrets() handshakeState = if (configuredSecrets == null) { HandshakeState.PROBE_ONLY @@ -252,10 +254,11 @@ class HuaweiHagridWspHandler( historyReadCount = 0 publishedHistoryKeys.clear() postStatusMeasurementReadsStarted = false + pendingRealtimeMeasurement = null + pendingRealtimeUser = null + measurementCycleCompleted = false + measurementPublished = false measurementStatusReady = false - realtimeFinalPublished = false - lastValidScale3Realtime = null - lastKnownUser = null setNotifyIfPresent(svcUserData, chrRequestAuth) setNotifyIfPresent(svcUserData, chrAuthToken) @@ -308,6 +311,7 @@ class HuaweiHagridWspHandler( override fun onDisconnected() { logI("Huawei Hagrid disconnected") stopMeasurementStatusPolling() + stopRearmPolling() accumulators.clear() pendingRandA = null pendingRandB = null @@ -322,10 +326,10 @@ class HuaweiHagridWspHandler( historyReadCount = 0 publishedHistoryKeys.clear() postStatusMeasurementReadsStarted = false + pendingRealtimeMeasurement = null + pendingRealtimeUser = null + measurementCycleCompleted = false measurementStatusReady = false - realtimeFinalPublished = false - lastValidScale3Realtime = null - lastKnownUser = null handshakeState = HandshakeState.IDLE } @@ -370,7 +374,7 @@ class HuaweiHagridWspHandler( chrScaleVersion -> handleScaleVersionPayload(payload) chrGetWeightUnit -> handleWeightUnitPayload(payload) chrMeasurementStatusPoll, - chrMeasurementStatusResult -> handleMeasurementStatusPayload(payload) + chrMeasurementStatusResult -> handleMeasurementStatusPayload(payload, user) chrHistoryWeight -> handleHistoryWeightPayload(payload, user) chrCurrentTime -> logD("Huawei Hagrid time-sync ack len=${payload.size}") else -> logD("Unhandled Hagrid WSP notification chr=${characteristicLabel(characteristic)} len=${payload.size}") @@ -389,7 +393,7 @@ class HuaweiHagridWspHandler( chrScaleVersion -> handleScaleVersionPayload(payload) chrGetWeightUnit -> handleWeightUnitPayload(payload) chrMeasurementStatusPoll, - chrMeasurementStatusResult -> handleMeasurementStatusPayload(payload) + chrMeasurementStatusResult -> handleMeasurementStatusPayload(payload, user) chrHistoryWeight -> handleHistoryWeightPayload(payload, user) else -> logD("Unhandled Hagrid raw notification chr=${characteristicLabel(characteristic)} len=${payload.size}") } @@ -415,7 +419,7 @@ class HuaweiHagridWspHandler( handshakeState = HandshakeState.AUTH_TOKEN_SENT val authPayload = HuaweiHagridWspLib.buildAuthTokenPayload(payload, randB, secrets.cak) - logI("Huawei Hagrid authentication started") + logI("Sending Huawei Hagrid auth token (${authPayload.size} bytes)") writeWspPlainIfPresent(svcUserData, chrAuthToken, authPayload) } @@ -460,7 +464,7 @@ class HuaweiHagridWspHandler( session = HuaweiHagridWspLib.HagridSession(randA.copyOf(), randB.copyOf(), rootKey, workKey.copyOf()) handshakeState = HandshakeState.WORK_KEY_SENT - logI("Huawei Hagrid authentication verified") + logI("Huawei Hagrid auth verified; sending encrypted work key") writeWspFramesIfPresent( svcUserData, chrSendWorkKey, @@ -476,7 +480,7 @@ class HuaweiHagridWspHandler( return } - logI("Huawei Hagrid work key accepted") + logI("Huawei Hagrid work key accepted; syncing time and selected profile") writeWspPlainIfPresent(svcCurrentTime, chrCurrentTime, HuaweiHagridWspLib.currentTimePayload()) readManagerInfoBeforeProfileSync(user) } @@ -545,7 +549,6 @@ class HuaweiHagridWspHandler( "Sending encrypted Huawei Hagrid user-info: userId=${user.id} " + "payloadLen=${plaintext.size} huidPresent=${info.huid.isNotBlank()}" ) - logI("Huawei Hagrid user profile sent") HuaweiHagridWspLib.buildEncryptedWriteFrames(plaintext, activeSession.workKey, iv) .forEach { frame -> writeTo(svcUserData, chrSetUserInfo, frame, withResponse = true) } return true @@ -555,9 +558,9 @@ class HuaweiHagridWspHandler( val status = payload.firstOrNull()?.toInt()?.and(0xFF) val accepted = status == 0x00 if (accepted) { - logI("Huawei Hagrid user profile accepted") + logI("Huawei Hagrid set-user-info accepted") } else { - logW("Huawei Hagrid user profile returned status=${status ?: -1}") + logW("Huawei Hagrid set-user-info returned status=${status ?: -1}") } if (handshakeState == HandshakeState.USER_INFO_SENT) { @@ -568,57 +571,48 @@ class HuaweiHagridWspHandler( } private fun sendPostUserReads() { + postStatusMeasurementReadsStarted = false + measurementStatusReady = false + measurementCycleCompleted = false + pendingRealtimeMeasurement = null + pendingRealtimeUser = null + + // Start realtime acquisition as soon as the authenticated profile is ready. + logI("Huawei Hagrid: starting realtime-weight stream after profile sync") + sendRealtimeMeasurementRequest() + startMeasurementStatusPolling() + writeWspPlainIfPresent(svcCurrentTime, chrProductInfo, ByteArray(0)) writeWspPlainIfPresent(svcUserData, chrGetManagerInfo, ByteArray(0)) writeWspPlainIfPresent(svcCurrentTime, chrScaleVersion, ByteArray(0)) writeWspPlainIfPresent(svcCurrentTime, chrGetWeightUnit, ByteArray(0)) - postStatusMeasurementReadsStarted = false - realtimeFinalPublished = false - lastValidScale3Realtime = null - startMeasurementFlow() - } - - private fun startMeasurementFlow() { - logI("Starting Huawei Hagrid realtime measurement flow") - - writeWspPlainIfPresent( - svcBodyComposition, - chrRealtimeWeight, - ByteArray(0), - withResponse = true - ) - - startMeasurementStatusPolling() } private fun buildUserInfo(user: ScaleUser): HuaweiHagridWspLib.HagridUserInfo { val lastWeight = lastMeasurementFor(user.id)?.weight - val configuredWeight = when { lastWeight != null && lastWeight.isFinite() && lastWeight > 0f -> lastWeight user.initialWeight.isFinite() && user.initialWeight > 0f -> user.initialWeight else -> 0f } - val heightCm = if (user.bodyHeight.isFinite() && user.bodyHeight > 0f) { user.bodyHeight.roundToInt().coerceIn(0, 0xFFFF) } else { 0 } - // Scale 3 uses inverted gender encoding (male=1, female=0) confirmed by Huawei Health - // BLE capture. All other Hagrid families retain the original encoding (male=0, female=1). - val genderCode = when (pendingProductProfile.family) { - HuaweiHagridWspLib.HagridProductFamily.SCALE_3 -> - if (user.gender == GenderType.MALE) 1 else 0 - else -> - if (user.gender == GenderType.MALE) 0 else 1 + // Scale 3 uses inverted gender encoding (male=1, female=0), while the + // other Hagrid families retain the original encoding (male=0, female=1). + val gender = if (isScale3Profile()) { + if (user.gender == GenderType.MALE) 1 else 0 + } else { + if (user.gender == GenderType.MALE) 0 else 1 } return HuaweiHagridWspLib.HagridUserInfo( huid = lastManagerInfo?.huid?.takeIf { it.isNotBlank() } ?: localSyntheticHuid(), uid = "u:%08X".format(Locale.US, user.id), - gender = genderCode, + gender = gender, ageYears = user.age.coerceIn(0, 255), heightCm = heightCm, weightKg = configuredWeight, @@ -636,40 +630,82 @@ class HuaweiHagridWspHandler( private fun handleRealtimePayload(payload: ByteArray, user: ScaleUser) { val parsed = HuaweiHagridWspLib.parseRealtimeMeasurement(payload) - if (parsed == null || parsed.weightKg <= 0f) { logD("Ignoring unsupported Hagrid realtime payload len=${payload.size}") return } - lastKnownUser = user + userInfo(R.string.bluetooth_scale_info_measuring_weight, parsed.weightKg) - userInfo( - R.string.bluetooth_scale_info_measuring_weight, - parsed.weightKg - ) + // Other Hagrid families keep realtime notifications progress-only. + if (!isScale3Profile()) { + return + } - // Other Hagrid families persist via history only; realtime is UI-only for them. - if (pendingProductProfile.family != HuaweiHagridWspLib.HagridProductFamily.SCALE_3) { + if (measurementCycleCompleted) { return } - // Scale 3 current measurements are persisted from the realtime channel. - // Cache every valid realtime reading so it is available when status=0 arrives, - // since the final stable notification typically precedes the status notification. - lastValidScale3Realtime = parsed + pendingRealtimeMeasurement = parsed + pendingRealtimeUser = user - if (!measurementStatusReady || realtimeFinalPublished) { - logD( - "Huawei Scale 3 realtime weight=${parsed.weightKg} " + - "measurementReady=$measurementStatusReady" - ) - return + val publishedNow = publishPendingRealtimeMeasurement() + logD( + "Hagrid realtime measurement parsed len=${parsed.rawLength} " + + "lowCount=${parsed.lowFrequencyImpedance.size} highCount=${parsed.highFrequencyImpedance.size} " + + "saved=${if (measurementPublished) "published" else "pending"}" + ) + + // Normally status=0 arrives ~20-25 s later. If it raced ahead of the + // realtime packet, complete the protocol cycle now. + if (publishedNow && measurementStatusReady) { + finishMeasurementCycle() + } + } + + private fun publishPendingRealtimeMeasurement(): Boolean { + if (measurementPublished) { + return false } - realtimeFinalPublished = true - logI("Huawei Scale 3 final realtime measurement received") - publishHagridMeasurement(parsed, user) + val parsed = pendingRealtimeMeasurement ?: return false + val user = pendingRealtimeUser ?: return false + + measurementPublished = true + logI( + "Huawei Hagrid: publishing complete realtime payload immediately " + + "weight=${"%.2f".format(Locale.US, parsed.weightKg)}" + ) + + // Realtime packet timestamps are scale-local and can carry a stale or incorrect clock. + // Use the phone receive time for live measurements; history keeps the parsed timestamp. + publishHagridMeasurement(parsed, user, dateTimeOverride = Date()) + return true + } + + private fun finishMeasurementCycle() { + if (measurementCycleCompleted) return + + measurementCycleCompleted = true + measurementStatusReady = true + pendingRealtimeMeasurement = null + pendingRealtimeUser = null + stopMeasurementStatusPolling() + logI("Huawei Hagrid: measurement cycle completed") + startRearmPolling() + + // History is optional; request it once after publishing the live result. + if (hasCharacteristic(svcBodyComposition, chrHistoryWeight)) { + historyReadActive = true + historyReadCount = 0 + scope.launch { + delay(250L) + if (handshakeState == HandshakeState.READY && measurementCycleCompleted) { + logD("Huawei Hagrid: requesting history after completed measurement") + writeWspPlainIfPresent(svcBodyComposition, chrHistoryWeight, ByteArray(0)) + } + } + } } private fun handleHistoryWeightPayload(payload: ByteArray, user: ScaleUser) { @@ -704,19 +740,88 @@ class HuaweiHagridWspHandler( private fun publishHagridMeasurement( parsed: HuaweiHagridWspLib.HagridWeightMeasurement, - user: ScaleUser + user: ScaleUser, + dateTimeOverride: Date? = null ) { val low = parsed.representativeLowOhm ?: 0.0 val high = parsed.representativeHighOhm ?: low + val isScale3 = isScale3Profile() + + // The Scale 3 reports its low-frequency BIA sample in 0.1-ohm units, which the generic + // hagridImpedanceOhm() heuristic ("below 4000 is already ohms") misreads: a real 350 Ω + // arrives as 3500 and is kept as 3500 Ω. Convert it for this family explicitly, and store + // the very same number the composition model is fed — a stored impedance that does not + // match the value the body composition was derived from cannot be interpreted later. + val scale3ModelImpedanceOhm = parsed.lowFrequencyImpedance + .firstOrNull() + ?.takeIf { it > 0 } + ?.div(10.0) + + val publishedLow = if (isScale3) scale3ModelImpedanceOhm ?: low else low + val sex = when (user.gender) { + GenderType.MALE -> HuaweiScale3BodyComposition.Sex.MALE + GenderType.FEMALE -> HuaweiScale3BodyComposition.Sex.FEMALE + } + + val composition = if (isScale3) { + HuaweiScale3BodyComposition.calculate( + heightCm = user.bodyHeight, + weightKg = parsed.weightKg, + ageYears = user.age, + sex = sex, + impedanceOhm = scale3ModelImpedanceOhm ?: 0.0, + ) + } else { + null + } + + when { + composition != null -> { + logI( + "Huawei Scale 3 composition: z=${"%.1f".format(Locale.US, composition.impedanceOhm)} " + + "fat=${"%.1f".format(Locale.US, composition.bodyFatPercent)} " + + "water=${"%.1f".format(Locale.US, composition.waterPercent)} " + + "smmKg=${"%.2f".format(Locale.US, composition.skeletalMuscleKg)} " + + "bone=${"%.2f".format(Locale.US, composition.boneMineralKg)} " + + "lbm=${"%.2f".format(Locale.US, composition.leanBodyMassKg)} " + + "protein=${"%.1f".format(Locale.US, composition.proteinPercent)} " + + "bmr=${composition.bmrKcal.toInt()} " + + "vfl=${composition.visceralFatLevel.toInt()}" + ) + } + !isScale3 -> { + logD( + "Huawei Hagrid body-composition model skipped for family=${pendingProductProfile.family}" + ) + } + else -> { + logW("Huawei Scale 3 composition unavailable for current profile/measurement") + } + } + val measurement = ScaleMeasurement( userId = user.id, - dateTime = parsed.timestamp ?: Date(), + dateTime = dateTimeOverride ?: parsed.timestamp ?: Date(), weight = parsed.weightKg, - fat = parsed.fatPercent.takeIf { it > 0f && it < 80f } ?: 0f, + fat = composition?.bodyFatPercent + ?: parsed.fatPercent.takeIf { it > 0f && it < 80f } + ?: 0f, + water = composition?.waterPercent ?: 0f, + muscle = composition?.skeletalMusclePercent ?: 0f, + visceralFat = composition?.visceralFatLevel ?: 0f, + bone = composition?.boneMineralKg ?: 0f, + lbm = composition?.leanBodyMassKg ?: 0f, + bmr = composition?.bmrKcal ?: 0f, heartRate = parsed.heartRateBpm ?: 0, impedance = high, - impedanceLow = low, + impedanceLow = publishedLow, + protein = composition?.proteinPercent ?: 0f, + ) + + logI( + "Publishing Huawei Hagrid measurement: lowCount=${parsed.lowFrequencyImpedance.size} " + + "highCount=${parsed.highFrequencyImpedance.size}" ) publish(measurement) } @@ -803,35 +908,114 @@ class HuaweiHagridWspHandler( logD("Huawei Hagrid weight unit=${parsed.label} raw=${parsed.raw}") } - private fun handleMeasurementStatusPayload(payload: ByteArray) { - val status = payload.firstOrNull()?.toInt()?.and(0xFF) ?: return - logD("Huawei Hagrid measurement status=$status") + private fun handleMeasurementStatusPayload(payload: ByteArray, user: ScaleUser) { + val status = payload.firstOrNull()?.toInt()?.and(0xFF) + logD("Huawei Hagrid measurement-status len=${payload.size} status=${status ?: -1}") - if (status == 0x00) { - measurementStatusReady = true + if (handshakeState != HandshakeState.READY) { + return + } - // Scale 3: the final realtime notification typically arrives before status=0. - // Publish the cached measurement now if handleRealtimePayload has not already - // done so (i.e. the notification came after this status update). - if (pendingProductProfile.family == HuaweiHagridWspLib.HagridProductFamily.SCALE_3 && - !realtimeFinalPublished - ) { - val cached = lastValidScale3Realtime - val user = lastKnownUser - if (cached != null && user != null) { - realtimeFinalPublished = true - logI("Huawei Scale 3 final realtime measurement published from cache on status=0") - publishHagridMeasurement(cached, user) + if (measurementCycleCompleted) { + rearmPollOutstanding = false + + when (status) { + 0x01 -> { + logI("Huawei Hagrid: new measurement activity detected; rearming current BLE session") + rearmMeasurementCycle(user) + } + + 0x00 -> { + logD("Huawei Hagrid: scale remains idle after completed measurement") + } + } + return + } + + when (status) { + 0x00 -> { + measurementStatusReady = true + + if (!isScale3Profile()) { + stopMeasurementStatusPolling() + sendPostMeasurementReads() + return + } + + if (measurementPublished) { + logI("Huawei Hagrid: measurement completion confirmed after realtime publish") + finishMeasurementCycle() + } else if (publishPendingRealtimeMeasurement()) { + logI("Huawei Hagrid: fallback publish at measurement completion") + finishMeasurementCycle() + } else { + // Ready can race ahead of the realtime notification. + sendPostMeasurementReads() } } - sendPostMeasurementReads() + 0x01 -> { + measurementStatusReady = false + } + } + } + + private fun startRearmPolling() { + if (!hasCharacteristic(svcCurrentTime, chrMeasurementStatusPoll)) { + return + } + + stopRearmPolling() + rearmPollingJob = scope.launch { + while ( + handshakeState == HandshakeState.READY && + measurementCycleCompleted + ) { + if (!rearmPollOutstanding) { + rearmPollOutstanding = true + writeWspPlainIfPresent( + svcCurrentTime, + chrMeasurementStatusPoll, + ByteArray(0), + withResponse = true + ) + } + + delay(REARM_POLL_TIMEOUT_MS) + if (rearmPollOutstanding) { + logD("Huawei Hagrid: rearm status poll timed out; retrying") + rearmPollOutstanding = false + } + } } } + private fun stopRearmPolling() { + rearmPollingJob?.cancel() + rearmPollingJob = null + rearmPollOutstanding = false + } + + private fun rearmMeasurementCycle(user: ScaleUser) { + stopRearmPolling() + + measurementCycleCompleted = false + measurementPublished = false + measurementStatusReady = false + postStatusMeasurementReadsStarted = false + pendingRealtimeMeasurement = null + pendingRealtimeUser = null + historyReadActive = false + historyReadCount = 0 + + userInfo(R.string.bt_info_step_on_scale) + sendRealtimeMeasurementRequest() + startMeasurementStatusPolling() + } + private fun startMeasurementStatusPolling() { if (!hasCharacteristic(svcCurrentTime, chrMeasurementStatusPoll)) { - sendPostMeasurementReads() + sendMeasurementDataRequests() return } @@ -853,8 +1037,8 @@ class HuaweiHagridWspHandler( } } if (measurementStatusPollingActive && !measurementStatusReady) { - logW("Huawei Hagrid measurement status timed out; continuing with post-measurement reads") - sendPostMeasurementReads() + logW("Huawei Hagrid measurement status timed out; requesting measurements directly") + sendMeasurementDataRequests() } } } @@ -876,29 +1060,47 @@ class HuaweiHagridWspHandler( ) } - private fun sendPostMeasurementReads() { + private fun sendMeasurementDataRequests() { stopMeasurementStatusPolling() + if (handshakeState != HandshakeState.READY || measurementCycleCompleted) { + return + } + + sendPostMeasurementReads() + } + + private fun sendPostMeasurementReads() { + if (handshakeState != HandshakeState.READY || measurementCycleCompleted) { + return + } if (postStatusMeasurementReadsStarted) { return } postStatusMeasurementReadsStarted = true + + if (isScale3Profile()) { + logI("Huawei Hagrid: measurement ready; requesting final realtime weight") + sendRealtimeMeasurementRequest() + } + if (hasCharacteristic(svcBodyComposition, chrHistoryWeight)) { historyReadActive = true historyReadCount = 0 writeWspPlainIfPresent(svcBodyComposition, chrHistoryWeight, ByteArray(0)) } - // Scale 3 needs a follow-up realtime request so the scale delivers the final - // confirmed measurement on chrRealtimeWeight after measurementStatusReady=true. - if (pendingProductProfile.family == HuaweiHagridWspLib.HagridProductFamily.SCALE_3) { - writeWspPlainIfPresent( - svcBodyComposition, - chrRealtimeWeight, - ByteArray(0), - withResponse = true - ) + } + + private fun sendRealtimeMeasurementRequest() { + if (handshakeState != HandshakeState.READY) { + return + } + if (measurementCycleCompleted) { + return } + + writeWspPlainIfPresent(svcBodyComposition, chrRealtimeWeight, ByteArray(0)) } private fun setNotifyIfPresent(service: UUID, characteristic: UUID) { @@ -937,6 +1139,9 @@ class HuaweiHagridWspHandler( .onFailure { logW("Invalid Huawei Hagrid secret settings: ${it.message}") } .getOrNull() + private fun isScale3Profile(): Boolean = + pendingProductProfile.family == HuaweiHagridWspLib.HagridProductFamily.SCALE_3 + private fun logDeviceProfile(source: String) { val highFrequency = pendingCapabilityBits?.supportsHighFrequencyImpedance ?: pendingProductProfile.expectedHighFrequencyImpedance @@ -1027,7 +1232,6 @@ class HuaweiHagridWspHandler( AUTH_FAILED } - companion object { const val SETTINGS_KEY_CAK_HEX = "ble/huawei_hagrid/cak_hex" const val SETTINGS_KEY_C1_HEX = "ble/huawei_hagrid/c1_hex" @@ -1036,6 +1240,7 @@ class HuaweiHagridWspHandler( private const val MANAGER_INFO_TIMEOUT_MS = 2500L private const val USER_INFO_ACK_TIMEOUT_MS = 3000L private const val MEASUREMENT_STATUS_POLL_INTERVAL_MS = 3000L + private const val REARM_POLL_TIMEOUT_MS = 3000L private const val MAX_STATUS_POLLS = 30 private const val MAX_HISTORY_RECORDS = 64 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiScale3BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiScale3BodyComposition.kt new file mode 100644 index 000000000..eabbfd79f --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiScale3BodyComposition.kt @@ -0,0 +1,211 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 kotlin.math.max + +/** + * Independent Huawei Scale 3 four-electrode body-composition model. + * + * The coefficients below were independently derived from observed input/output + * behavior of the Huawei Scale 3 adult four-electrode calculation. No Huawei + * native library or proprietary runtime component is required. + * + * This model targets the normal adult four-electrode branch. Huawei uses a + * separate child/teenager path and additional post-processing for extreme input + * combinations; those paths are intentionally not emulated here. + */ +internal object HuaweiScale3BodyComposition { + enum class Sex { + FEMALE, + MALE, + } + + data class Result( + val impedanceOhm: Double, + val bodyFatPercent: Float, + val waterPercent: Float, + val skeletalMuscleKg: Float, + val skeletalMusclePercent: Float, + val boneMineralKg: Float, + val leanBodyMassKg: Float, + val proteinPercent: Float, + val bmrKcal: Float, + val visceralFatLevel: Float, + ) + + private data class Coefficients( + val waterKg: DoubleArray, + val fatFreeMassKg: DoubleArray, + val muscleMassKg: DoubleArray, + val boneMineralKg: DoubleArray, + val bmr: DoubleArray, + val visceralFat: DoubleArray, + ) + + /* + * Feature order: + * [heightCm^2 / impedanceOhm, + * impedanceOhm, + * weightKg, + * heightCm, + * age^2, + * age, + * constant] + */ + private val female = Coefficients( + waterKg = doubleArrayOf( + 0.445999704, 0.0139999486, 0.167000103, -0.102999643, + -0.00186499864, 0.172699790, 2.47318013 + ), + fatFreeMassKg = doubleArrayOf( + -0.0000000591396, -0.0150000073, 0.198000056, 0.313999865, + -0.00148397395, 0.124597440, -16.3761132 + ), + muscleMassKg = doubleArrayOf( + -0.0000000596289, -0.0130000008, 0.180999914, 0.286000079, + -0.00136797854, 0.115597450, -14.1328394 + ), + boneMineralKg = doubleArrayOf( + -0.00400158350, -0.00200033477, 0.0160000363, 0.0260015858, + -0.000119001796, 0.00930013136, -1.67940940 + ), + bmr = doubleArrayOf( + 1.75784170, -0.181837584, 9.65817197, 5.89515491, + 0.00782921597, -2.89574283, -203.303636 + ), + visceralFat = doubleArrayOf( + -0.00158317035, 0.00544003197, 0.314994178, -0.103662636, + 0.000967108734, 0.00527136893, -1.33727631 + ), + ) + + private val male = Coefficients( + waterKg = doubleArrayOf( + 0.446000315, 0.0140000556, 0.166999963, -0.103000253, + -0.00104299708, 0.0356998392, 9.22059951 + ), + fatFreeMassKg = doubleArrayOf( + 0.114000146, -0.0149999678, 0.322000075, 0.272999903, + -0.000979001428, 0.0170001265, -13.0029150 + ), + muscleMassKg = doubleArrayOf( + 0.107999947, -0.0140000275, 0.305999991, 0.258999945, + -0.000929995268, 0.0160995340, -12.5056628 + ), + boneMineralKg = doubleArrayOf( + -0.00400070678, -0.00200017483, 0.0160001373, 0.0260008239, + -0.0000579943116, 0.00269952368, -1.50661300 + ), + bmr = doubleArrayOf( + 6.43629949, -0.0002299374, 10.9972143, 4.74191847, + 0.0238549107, -6.20381101, -233.995162 + ), + visceralFat = doubleArrayOf( + -0.168283428, -0.0119657591, 0.377569276, -0.138136532, + 0.000252403405, 0.134562343, 17.0432610 + ), + ) + + fun calculate( + heightCm: Float, + weightKg: Float, + ageYears: Int, + sex: Sex, + impedanceOhm: Double, + ): Result? { + if (heightCm <= 0f || weightKg <= 0f || impedanceOhm <= 0.0) return null + + // Huawei uses a separate teenager/child recalculation path. + if (ageYears < 18) return null + + val h = heightCm.toDouble() + val w = weightKg.toDouble() + val age = ageYears.toDouble() + val z = impedanceOhm + + // Reject obviously malformed input. These limits are deliberately broad. + if (h !in 120.0..230.0 || w !in 20.0..250.0 || z !in 100.0..1000.0) return null + + val x = doubleArrayOf( + h * h / z, + z, + w, + h, + age * age, + age, + 1.0, + ) + + val c = if (sex == Sex.MALE) male else female + + val waterKg = evaluate(c.waterKg, x) + val ffmKg = evaluate(c.fatFreeMassKg, x) + val muscleMassKg = evaluate(c.muscleMassKg, x) + val boneKg = evaluate(c.boneMineralKg, x) + + // Huawei's skeletal-muscle output is derived from its muscle-mass model. + val skeletalMuscleKg = 0.605 * muscleMassKg - 1.833 + + val fatPercent = (1.0 - ffmKg / w) * 100.0 + val waterPercent = waterKg / w * 100.0 + val skeletalMusclePercent = skeletalMuscleKg / w * 100.0 + + // Huawei Health's displayed protein percentage is consistent with + // protein mass = FFM - total body water - bone mineral. + val proteinKg = ffmKg - waterKg - boneKg + val proteinPercent = proteinKg / w * 100.0 + + // The native four-electrode path stores these as positive integers. + val bmr = evaluate(c.bmr, x).toInt() + val visceralFat = max(1, evaluate(c.visceralFat, x).toInt()) + + if ( + !fatPercent.isFinite() || fatPercent !in 0.0..75.0 || + !waterPercent.isFinite() || waterPercent !in 10.0..85.0 || + !skeletalMuscleKg.isFinite() || skeletalMuscleKg !in 0.0..w || + !boneKg.isFinite() || boneKg !in 0.0..10.0 || + !proteinPercent.isFinite() || proteinPercent !in 0.0..40.0 || + bmr !in 500..5000 || + visceralFat !in 1..60 + ) { + return null + } + + return Result( + impedanceOhm = z, + bodyFatPercent = fatPercent.toFloat(), + waterPercent = waterPercent.toFloat(), + skeletalMuscleKg = skeletalMuscleKg.toFloat(), + skeletalMusclePercent = skeletalMusclePercent.toFloat(), + boneMineralKg = boneKg.toFloat(), + leanBodyMassKg = ffmKg.toFloat(), + proteinPercent = proteinPercent.toFloat(), + bmrKcal = bmr.toFloat(), + visceralFatLevel = visceralFat.toFloat(), + ) + } + + private fun evaluate(coefficients: DoubleArray, features: DoubleArray): Double { + var result = 0.0 + for (i in coefficients.indices) { + result += coefficients[i] * features[i] + } + return result + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt index 48f6ab804..dfaf679dd 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt @@ -166,7 +166,7 @@ class HuaweiHagridWspHandlerTest { } @Test - fun `Scale 3 realtime notification before status=0 is not published`() { + fun `Scale 3 realtime notification before status=0 is published immediately`() { val handler = HuaweiHagridWspHandler() val callbacks = CapturingCallbacks() handler.supportFor(device("HUAWEI Scale 3")) @@ -180,13 +180,20 @@ class HuaweiHagridWspHandlerTest { sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) - assertThat(callbacks.published).isEmpty() + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.32f) assertThat(callbacks.infos.map { it.resId }) .contains(R.string.bluetooth_scale_info_measuring_weight) } @Test - fun `Scale 3 realtime notification after status=0 is published exactly once`() { + fun `Scale 3 realtime publishes computed fat and preserves parsed impedances`() { + val user = ScaleUser( + id = 7, + birthday = Date(946684800000L), + bodyHeight = 175f, + gender = GenderType.MALE, + ) val handler = HuaweiHagridWspHandler() val callbacks = CapturingCallbacks() handler.supportFor(device("HUAWEI Scale 3")) @@ -194,28 +201,38 @@ class HuaweiHagridWspHandlerTest { transport = NoopTransport(), callbacks = callbacks, settings = InMemorySettings(), - data = FixedDataProvider(ScaleUser(id = 7)), + data = FixedDataProvider(user), scope = CoroutineScope(EmptyCoroutineContext), ) - sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) + sendWspNotification(handler, CHR_REALTIME_WEIGHT, scale3CompositionRealtimePayload()) - // First post-status-0 realtime notification: saved. - sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) assertThat(callbacks.published).hasSize(1) - assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.32f) - assertThat(callbacks.published.single().fat).isWithin(0.0001f).of(18.5f) + val measurement = callbacks.published.single() + val expectedComposition = HuaweiScale3BodyComposition.calculate( + heightCm = user.bodyHeight, + weightKg = 77.32f, + ageYears = user.age, + sex = HuaweiScale3BodyComposition.Sex.MALE, + impedanceOhm = 350.0, + ) + assertThat(expectedComposition).isNotNull() + assertThat(measurement.weight).isWithin(0.0001f).of(77.32f) + assertThat(measurement.fat).isWithin(0.0001f).of(expectedComposition!!.bodyFatPercent) + // The stored low-frequency impedance is the very value the composition model was fed, + // so a measurement can still be interpreted from what is in the database. + assertThat(measurement.impedanceLow).isWithin(0.0001).of(350.0) + assertThat(measurement.impedance).isWithin(0.0001).of(600.0) - // Subsequent realtime notifications in the same session: ignored for persistence. - sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + // Subsequent realtime notifications in the same cycle are ignored for persistence. + sendWspNotification(handler, CHR_REALTIME_WEIGHT, scale3CompositionRealtimePayload()) assertThat(callbacks.published).hasSize(1) } @Test - fun `Scale 3 realtime before status=0 is cached and published when status=0 arrives`() { - // Common real-world sequence: the scale sends the final stable realtime notification - // BEFORE it sends measurement status=0. The cached value must be published on status=0, - // not discarded. + fun `Scale 3 realtime before status=0 is published immediately and status=0 does not duplicate`() { + // The Scale 3 sends the final stable realtime notification well before status=0. + // Persist it immediately; status=0 only closes the hardware/display cycle. val handler = HuaweiHagridWspHandler() val callbacks = CapturingCallbacks() handler.supportFor(device("HUAWEI Scale 3")) @@ -227,19 +244,18 @@ class HuaweiHagridWspHandlerTest { scope = CoroutineScope(EmptyCoroutineContext), ) - // Realtime arrives first – must NOT be published yet. + // Realtime arrives first and is published immediately. sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) - assertThat(callbacks.published).isEmpty() + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.32f) assertThat(callbacks.infos.map { it.resId }) .contains(R.string.bluetooth_scale_info_measuring_weight) - // Status=0 arrives – cached realtime is published exactly once. + // status=0 must not publish the same result again. sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) assertThat(callbacks.published).hasSize(1) - assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.32f) - assertThat(callbacks.published.single().fat).isWithin(0.0001f).of(18.5f) - // Any further realtime notifications in the same session are ignored. + // Any further realtime notifications in the same completed cycle are ignored. sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) assertThat(callbacks.published).hasSize(1) } @@ -457,23 +473,58 @@ class HuaweiHagridWspHandlerTest { @Test fun `sendPostMeasurementReads sends second realtime request only for Scale 3`() { fun realtimeWritesAfterStatus(deviceName: String): Int { - val handler = HuaweiHagridWspHandler() - handler.supportFor(device(deviceName)) - val transport = CapturingTransport( - setOf( - SVC_BODY_COMPOSITION to CHR_REALTIME_WEIGHT, - SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT, - SVC_CURRENT_TIME to CHR_MEASUREMENT_STATUS_POLL, - SVC_CURRENT_TIME to CHR_MEASUREMENT_STATUS_RESULT, - ) + val randA = ByteArray(16) { it.toByte() } + val randB = ByteArray(16) { (it + 16).toByte() } + val workKey = ByteArray(16) { (it + 32).toByte() } + val workKeyIv = ByteArray(16) { (it + 48).toByte() } + val cak = ByteArray(16) { (it + 64).toByte() } + val c1 = ByteArray(16) { (it + 80).toByte() } + val c2 = ByteArray(16) { (it + 96).toByte() } + val userInfoIv = ByteArray(16) { (it + 112).toByte() } + val user = ScaleUser( + id = 1, + birthday = Date(946684800000L), + bodyHeight = 175f, + gender = GenderType.MALE, + initialWeight = 80f, ) + val randomValues = ArrayDeque(listOf(randB, workKey, workKeyIv, userInfoIv)) + val handler = HuaweiHagridWspHandler(randomBytes = { randomValues.removeFirst() }) + val settings = InMemorySettings().apply { + putString(HuaweiHagridWspHandler.SETTINGS_KEY_CAK_HEX, cak.toHex()) + putString(HuaweiHagridWspHandler.SETTINGS_KEY_C1_HEX, c1.toHex()) + putString(HuaweiHagridWspHandler.SETTINGS_KEY_C2_HEX, c2.toHex()) + } + val transport = CapturingTransport.allHagrid() + + handler.supportFor(device(deviceName, address = HAGRID_ADDRESS)) handler.attach( transport = transport, callbacks = CapturingCallbacks(), - settings = InMemorySettings(), - data = FixedDataProvider(ScaleUser(id = 1)), + settings = settings, + data = FixedDataProvider(user), scope = CoroutineScope(EmptyCoroutineContext), ) + + // Measurement-status notifications are intentionally ignored until the + // authenticated Hagrid session reaches READY. + handler.handleConnected(user) + sendWspNotification(handler, CHR_REQUEST_AUTH, randA) + sendWspNotification( + handler, + CHR_AUTH_TOKEN, + HuaweiHagridWspLib.expectedAuthResponsePayload(randA, randB, cak) + ) + sendWspNotification(handler, CHR_SEND_WORK_KEY, byteArrayOf(0x00)) + sendWspNotification(handler, CHR_GET_MANAGER_INFO, managerInfoPayload()) + + val userInfoAck = HuaweiHagridWspLib.encryptedPayloadWithIv( + byteArrayOf(0x00), + workKey, + ByteArray(16) { (it + 128).toByte() } + ) + sendWspNotification(handler, CHR_SET_USER_INFO, userInfoAck, encrypted = true) + val before = transport.writes.count { it.characteristic == CHR_REALTIME_WEIGHT } sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) return transport.writes.count { it.characteristic == CHR_REALTIME_WEIGHT } - before @@ -755,6 +806,18 @@ class HuaweiHagridWspHandlerTest { 0x5B, 0x02, 0x5C, 0x02, 0x5D, 0x02, ) + private fun scale3CompositionRealtimePayload(): ByteArray = + byteArrayOf( + 0x34, 0x1E, + 0xB9.toByte(), 0x00, + 0xEA.toByte(), 0x07, 0x06, 0x16, 0x0F, 0x0E, 0x2A, 0x00, + 0xAC.toByte(), 0x0D, 0xAD.toByte(), 0x0D, 0xAE.toByte(), 0x0D, + 0xAF.toByte(), 0x0D, 0xB0.toByte(), 0x0D, 0xB1.toByte(), 0x0D, + 0x48, 0x00, + 0x58, 0x02, 0x59, 0x02, 0x5A, 0x02, + 0x5B, 0x02, 0x5C, 0x02, 0x5D, 0x02, + ) + private fun historyPayload(completeFlag: Int): ByteArray = realtimePayload() + byteArrayOf(0x01, completeFlag.toByte()) } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiScale3BodyCompositionTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiScale3BodyCompositionTest.kt new file mode 100644 index 000000000..7589504e2 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiScale3BodyCompositionTest.kt @@ -0,0 +1,148 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class HuaweiScale3BodyCompositionTest { + @Test + fun reproducesObservedMaleAdultMeasurements() { + checkVector( + heightCm = 178f, + weightKg = 91.45f, + ageYears = 46, + sex = HuaweiScale3BodyComposition.Sex.MALE, + impedanceOhm = 362.5, + fatPercent = 25.341705f, + waterPercent = 54.293285f, + skeletalMuscleKg = 37.3210f, + boneKg = 3.51146f, + bmr = 1943f, + visceral = 14f, + ) + + checkVector( + heightCm = 178f, + weightKg = 91.50f, + ageYears = 46, + sex = HuaweiScale3BodyComposition.Sex.MALE, + impedanceOhm = 370.5, + fatPercent = 25.73119f, + waterPercent = 53.47523f, + skeletalMuscleKg = 37.13919f, + boneKg = 3.50382f, + bmr = 1931f, + visceral = 14f, + ) + } + + @Test + fun reproducesObservedFemaleAdultMeasurement() { + // Independent black-box reference vector: + // sex=female, age=40, height=165 cm, weight=65 kg, impedance=400 ohm. + checkVector( + heightCm = 165f, + weightKg = 65f, + ageYears = 40, + sex = HuaweiScale3BodyComposition.Sex.FEMALE, + impedanceOhm = 400.0, + fatPercent = 30.9025f, + waterPercent = 55.7124f, + skeletalMuscleKg = 23.6117f, + boneKg = 2.7600f, + bmr = 1340f, + visceral = 5f, + ) + } + + @Test + fun rejectsUnsupportedChildProfile() { + assertNull( + HuaweiScale3BodyComposition.calculate( + heightCm = 150f, + weightKg = 45f, + ageYears = 17, + sex = HuaweiScale3BodyComposition.Sex.MALE, + impedanceOhm = 450.0, + ) + ) + } + + @Test + fun rejectsMalformedInputs() { + assertNull( + HuaweiScale3BodyComposition.calculate( + heightCm = 178f, + weightKg = 91f, + ageYears = 46, + sex = HuaweiScale3BodyComposition.Sex.MALE, + impedanceOhm = 0.0, + ) + ) + assertNull( + HuaweiScale3BodyComposition.calculate( + heightCm = 50f, + weightKg = 91f, + ageYears = 46, + sex = HuaweiScale3BodyComposition.Sex.MALE, + impedanceOhm = 360.0, + ) + ) + } + + private fun checkVector( + heightCm: Float, + weightKg: Float, + ageYears: Int, + sex: HuaweiScale3BodyComposition.Sex, + impedanceOhm: Double, + fatPercent: Float, + waterPercent: Float, + skeletalMuscleKg: Float, + boneKg: Float, + bmr: Float, + visceral: Float, + ) { + val result = HuaweiScale3BodyComposition.calculate( + heightCm = heightCm, + weightKg = weightKg, + ageYears = ageYears, + sex = sex, + impedanceOhm = impedanceOhm, + ) + assertNotNull(result) + result!! + + assertEquals(fatPercent, result.bodyFatPercent, 0.01f) + assertEquals(waterPercent, result.waterPercent, 0.01f) + assertEquals(skeletalMuscleKg, result.skeletalMuscleKg, 0.01f) + assertEquals(boneKg, result.boneMineralKg, 0.01f) + assertEquals(bmr, result.bmrKcal, 1f) + assertEquals(visceral, result.visceralFatLevel, 0f) + + // openScale stores skeletal muscle as percent, not kilograms. + assertEquals( + result.skeletalMuscleKg / weightKg * 100f, + result.skeletalMusclePercent, + 0.001f, + ) + } +} From 6613db5838e4674a19652441073f82b3ee3d7009 Mon Sep 17 00:00:00 2001 From: martinriedel Date: Sat, 29 Aug 2026 15:42:45 +0200 Subject: [PATCH 59/72] Huawei AH100/CH100: fix the user record sent to the scale (#1450) * Never send a zero weight in the Huawei user record A Huawei CH100 capture showed the scale answering our USER_INFO with USER_CHANGED 127 times in 33 seconds, re-requesting the record after every reply. The record we sent carried weight 0: sendUserInfo falls back to user.initialWeight when no measurement has been taken yet in the session, and that field is 0 for a freshly created profile. Add ScaleDeviceHandler.fallbackWeightKg(), which walks last stored measurement -> profile initial weight -> BMI-22 estimate from body height, and never returns 0. On the same hardware this drops the USER_CHANGED storm from 127 cycles to 5. HuaweiCH100SHandler had the identical line and is fixed with it. Also pin two things the previous fix got wrong on paper: the encrypted USER_INFO payload is 16 bytes, not 14, and the unit tests do not remove the need to verify against real hardware. Most fixtures in HuaweiAhCh100HandlerTest were generated by a second implementation of the same assumed layout, so they can only show that two ports of one guess agree. Add a section with frames captured off a real scale and checked against its display, including the 16th byte the documented layout does not mention. Not addressed: the body fat percentage. It arrives fully computed in the frame, and re-measuring with a correct user record moved it from 33.8% to 33.9% -- i.e. not at all. The scale's own algorithm produces it, and at the reported 313 ohm StandardImpedanceLib explicitly declines to do better. Co-Authored-By: Claude Opus 5 (1M context) * Do not claim the undocumented 16th byte is meaningless The two captures behind that claim were 0.1 kg apart, so a slowly-varying value like visceral fat level would look constant either way. #547 reports visceral fat 11.5 at BMI 24.8 and 14 at BMI 27.5 from the vendor app; these captures are BMI 37 and the byte reads 21. Pin the value, do not explain it. Co-Authored-By: Claude Opus 5 (1M context) * Declare 14, not 16, in the USER_INFO length byte The vendor app sends "DC 0E 09" followed by 16 encrypted bytes: the trailing 2-byte constant is transmitted but sits outside the declared length. v2.5.4's "Total = 14 bytes" note describes the same thing; the 3.x port pulled the trailer into the payload and moved the length byte to 16 with it. Measured on real hardware. Two idle sessions, connected, nobody standing on the scale, so the only variable is the length byte: 0x10 (16) 1026 USER_CHANGED polls over 274 s median 266 ms 0x0E (14) 533 USER_CHANGED polls over 532 s median 1001 ms The scale polls four times slower once the record is declared the way its own app declares it. Note what this does NOT show: the same comparison across the weight fix in the previous commit finds no difference (245 ms with weight 0 vs 266 ms with a correct weight, both at 0x10). That fix stands on sending correct data, not on this behaviour -- see the correction in the PR description. Co-Authored-By: Claude Opus 5 (1M context) * Inline the user-record weight fallback into the Huawei handlers * Pin the user record with tests that can actually fail The USER_INFO test asserted a length byte it passed in itself: it built a frame with explicitLen = 14 and then checked the frame said 14. It would have stayed green if sendUserInfo went back to declaring 16 -- the same defect this PR is about, in the test that was meant to catch it. Replace it with HuaweiUserRecordWireTest, which attaches both handlers to a capturing transport, drives connect/wake/auth, and decrypts the record off the wire. Verified the tests can fail: reverting the weight fallback and the length byte turns 5 of the 7 red, and the 2 that stay green are the ones that do not depend on those lines. Fixtures are synthetic -- the tests derive every expected value from the protocol, so there is no reason to put a real device address or real body measurements in the repository. Also log both lengths for USER_INFO. A log line reading len=16 next to a frame reading 0x0E is how the length byte went unnoticed in the first place, and the payload comment claiming "Total = 16 bytes" over a frame that declares 14 was the same trap for the next reader. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FofPiyxen5QkNMQqk4gzA4 --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: oliexdev --- .../bluetooth/scales/HuaweiAhCh100Handler.kt | 64 +++- .../bluetooth/scales/HuaweiCH100SHandler.kt | 15 +- .../scales/HuaweiAhCh100HandlerTest.kt | 63 ++++ .../scales/HuaweiUserRecordWireTest.kt | 351 ++++++++++++++++++ 4 files changed, 482 insertions(+), 11 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt index 127d129bc..ac472bccc 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt @@ -43,8 +43,15 @@ import kotlin.math.min * The wire-protocol primitives — XOR obfuscation, AES-CTR, frame builders * and the measurement parser — are the [Companion] object at the bottom of * this file. They are pure Kotlin (no Android dependencies), so the parsing - * logic is locked by the JVM unit tests in `HuaweiAhCh100HandlerTest` and we - * don't have to re-prove it on a real scale. + * logic is exercised by the JVM unit tests in `HuaweiAhCh100HandlerTest`. + * + * Note what those tests can and cannot show. Most of their fixtures were + * generated by a second implementation of the *same assumed* layout, so they + * prove the two ports agree — not that either matches the hardware. Only the + * "Real hardware capture" section holds bytes that came off a scale and were + * checked against its display. Add real captures there when you touch this + * protocol; a green suite built from our own assumptions is what let the 3.x + * regression below ship in the first place. * * History (so future maintainers don't repeat past mistakes): * @@ -379,11 +386,13 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { // Encrypted USER_INFO payload format (matches v2.5.4 exactly): // auth(7) || age|sexBit(1) || height(1) || 0x00(1) || weightLE(2) // || resistanceLE(2) || 0x1C 0xE2 (2 constant) - // Total = 14 bytes. + // 16 bytes transmitted, of which the length byte declares 14 — the + // trailer is not counted. Do not reconcile those two numbers; they + // disagree on the wire too. See sendCmdEncrypted. val sexBit = if (user.gender.isMale()) 0x00 else 0x80 val age = (user.age and 0xFF) or sexBit val height = user.bodyHeight.toInt() and 0xFF - val w = (weightTenthKg ?: (user.initialWeight * 10f).toInt()).coerceAtLeast(0) + val w = (weightTenthKg ?: (profileWeightKg(user) * 10f).toInt()).coerceAtLeast(1) val tail = ByteArrayOutputStream().apply { write(byteArrayOf(age.toByte(), height.toByte(), 0x00)) write(le16(w)) @@ -397,6 +406,22 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { sendCmdEncrypted(full) } + /** + * Body weight in kg for the USER_INFO record, never 0. + * + * A zero weight is not "unknown" on the wire, it is a wrong value: the scale + * re-requests the record in a USER_CHANGED loop, and firmware that derives body + * composition from the pushed profile computes it against that. Prefers the last + * stored measurement, then the profile weight, then a BMI-22 estimate from body + * height — wrong but physiologically sane, and replaced by the first real reading. + */ + private fun profileWeightKg(user: ScaleUser): Float { + lastMeasurementFor(user.id)?.weight?.takeIf { it.isFinite() && it > 0f }?.let { return it } + user.initialWeight.takeIf { it.isFinite() && it > 0f }?.let { return it } + val heightM = user.bodyHeight / 100f + return if (heightM.isFinite() && heightM > 0.5f) 22f * heightM * heightM else 70f + } + private fun sendGetVersion() = sendCmd(CMD_GET_VERSION, byteArrayOf()) @Suppress("unused") // hooked up when we re-enable history pulls @@ -428,8 +453,18 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { logW("magicKey missing; dropping encrypted cmd 0x%02X".format(CMD_USER_INFO.toInt() and 0xFF)) return } - val frame = buildEncryptedCommand(CMD_USER_INFO, payload, mk, macBytes()) - logD("→ CMD* 0x%02X len=%d (encrypted)".format(CMD_USER_INFO.toInt() and 0xFF, payload.size)) + // The 2-byte trailer is transmitted but not counted, as in the vendor app. + val declaredLen = payload.size - USER_INFO_TRAILER + val frame = buildEncryptedCommand( + CMD_USER_INFO, payload, mk, macBytes(), explicitLen = declaredLen + ) + // Log both numbers. A log line reading len=16 next to a frame reading + // 0x0E is how the length byte went unnoticed in the first place. + logD( + "→ CMD* 0x%02X len=%d declared=%d (encrypted)".format( + CMD_USER_INFO.toInt() and 0xFF, payload.size, declaredLen + ) + ) writeTo(SERVICE, CHAR_TX, frame, withResponse = true) } @@ -592,6 +627,9 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { const val CMD_AUTH: Byte = 36 const val CMD_BIND_USER: Byte = 37 + /** Bytes of the USER_INFO payload that are sent but not counted in the length byte. */ + const val USER_INFO_TRAILER = 2 + // ---------- Primitives ----------------------------------------------- /** @@ -681,18 +719,24 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { * Build a host->scale AES-encrypted command frame (start byte * [FRAME_ENCRYPTED]). * - * Length byte equals plaintext payload size (matches v2.5.4's - * `lengthByte = payload.size + 0`). + * Length byte defaults to the plaintext payload size. + * + * @param explicitLen overrides it. USER_INFO needs this: the vendor app + * declares 14 while transmitting 16, i.e. the trailing 2-byte constant + * sits *outside* the declared length. v2.5.4's "Total = 14 bytes" note + * describes the same thing — the 3.x port pulled the trailer into the + * payload and shifted the length byte to 16 with it. */ fun buildEncryptedCommand( cmd: Byte, payload: ByteArray, magicKey: ByteArray, mac: ByteArray, - iv: ByteArray = INITIAL_IV + iv: ByteArray = INITIAL_IV, + explicitLen: Int? = null ): ByteArray { val encrypted = aesCtr(payload, magicKey, iv) - val header = byteArrayOf(FRAME_ENCRYPTED, payload.size.toByte(), cmd) + val header = byteArrayOf(FRAME_ENCRYPTED, (explicitLen ?: payload.size).toByte(), cmd) return header + obfuscate(encrypted, mac) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt index b73d103a8..28771987f 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt @@ -309,7 +309,7 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { private fun sendUserInfo(user: ScaleUser, weightTenthKg: Int?) { val sexBit = if (user.gender.isMale()) 0x00 else 0x80 val age = user.age and 0x7F - val w = (weightTenthKg ?: (user.initialWeight * 10f).toInt()).coerceAtLeast(0) + val w = (weightTenthKg ?: (profileWeightKg(user) * 10f).toInt()).coerceAtLeast(1) val payload = ByteArrayOutputStream().apply { write(authCode) write((age or sexBit) and 0xFF) @@ -321,6 +321,19 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { sendEncrypted(CMD_USER_INFO, payload) } + /** + * Body weight in kg for the USER_INFO record, never 0 — same reasoning as the + * AH100/CH100 sibling handler: the scale re-requests a weightless record in a + * USER_CHANGED loop. Prefers the last stored measurement, then the profile + * weight, then a BMI-22 estimate from body height. + */ + private fun profileWeightKg(user: ScaleUser): Float { + lastMeasurementFor(user.id)?.weight?.takeIf { it.isFinite() && it > 0f }?.let { return it } + user.initialWeight.takeIf { it.isFinite() && it > 0f }?.let { return it } + val heightM = user.bodyHeight / 100f + return if (heightM.isFinite() && heightM > 0.5f) 22f * heightM * heightM else 70f + } + // --- Wire helpers --------------------------------------------------------- private fun sendPlain(cmd: Byte, payload: ByteArray) { diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt index b5714959f..b2402414c 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100HandlerTest.kt @@ -55,6 +55,11 @@ class HuaweiAhCh100HandlerTest { private const val FIXTURE_USER_FRAME = "bc100edef702c9bbf2176c755b6eccdd5b92b0" private const val FIXTURE_LIGHT_FRAME = "bc100ede15036dbaf2176869547ce7dbaf91b0" private const val FIXTURE_HEAVY_FRAME = "bc100ede3904b1bbf2176a67404fedd82392b0" + + // Captured from real hardware (see the "Real hardware capture" section). + private const val CAPTURE_MAC = "5C:CA:D3:14:2B:B3" + private const val CAPTURE_FRAME_1 = "bc110eabf78678303ba462fc93d1db4b348861" + private const val CAPTURE_FRAME_2 = "bc110eabf48679303ba462fc93dce84b3f8861" } // -- Primitives ---------------------------------------------------------- @@ -264,6 +269,64 @@ class HuaweiAhCh100HandlerTest { assertThat(brokenWeight).isNotWithin(1e-4f).of(97.0f) } + // -- Real hardware capture ----------------------------------------------- + // + // Everything above this line was generated by a Python re-implementation of + // the same assumed protocol, so it can only prove that two ports of one + // guess agree. The fixtures below came off an actual scale (advert name + // "CH100", Chipsea CST34M97) via logcat on 2026-08-10, decoded with the + // session MAC below, and were checked against what the scale's own display + // showed at that moment. They are the only bytes here that can fail when + // the assumption — not the implementation — is wrong. + + @Test + fun `real capture - CH100 hardware decodes to the value shown on its display`() { + val mac = HuaweiAhCh100Handler.macStringToBytes(CAPTURE_MAC) + val mk = HuaweiAhCh100Handler.deriveMagicKey( + HuaweiAhCh100Handler.buildAuthToken(1), mac + ) + + // Display read 126.6 kg while this frame was on the wire. + val first = HuaweiAhCh100Handler.decodeFirstHalf(hex(CAPTURE_FRAME_1), mk, mac) + assertThat(first.weightKg).isWithin(1e-4f).of(126.6f) + assertThat(first.fatPct).isWithin(1e-4f).of(33.8f) + assertThat(first.impedanceOhm).isEqualTo(306) + assertThat(first.userId).isEqualTo(1) + assertCalendar(first, 2026, Calendar.AUGUST, 10, 10, 1, 38) + + // Second measurement ~11 minutes later, same person, same session. + val second = HuaweiAhCh100Handler.decodeFirstHalf(hex(CAPTURE_FRAME_2), mk, mac) + assertThat(second.weightKg).isWithin(1e-4f).of(126.5f) + assertThat(second.fatPct).isWithin(1e-4f).of(33.9f) + assertThat(second.impedanceOhm).isEqualTo(313) + assertCalendar(second, 2026, Calendar.AUGUST, 10, 10, 12, 21) + } + + @Test + fun `real capture - the frame carries one byte beyond the documented layout`() { + // parseMeasurement documents 15 bytes; the hardware sends 16, and we + // drop the extra one. Dropping it is correct: the vendor-app captures + // attached to #547 decode to 13 measurements of one AH100 over 11 days, + // and this byte is 0x4E on every one of them while weight (87.3-93.2 kg), + // body fat (22.9-25.3 %) and impedance (373-451 Ω) all move. It is a + // per-device constant, not a body value. + val mac = HuaweiAhCh100Handler.macStringToBytes(CAPTURE_MAC) + val mk = HuaweiAhCh100Handler.deriveMagicKey( + HuaweiAhCh100Handler.buildAuthToken(1), mac + ) + for (frameHex in listOf(CAPTURE_FRAME_1, CAPTURE_FRAME_2)) { + val raw = HuaweiAhCh100Handler.decodeFirstHalf(hex(frameHex), mk, mac).rawDecrypted + assertThat(raw).hasLength(16) + assertThat(raw[15]).isEqualTo(0x15.toByte()) + } + } + + // The USER_INFO record the handler sends is pinned in + // HuaweiUserRecordWireTest, which drives the state machine and reads the + // bytes off the transport. Asserting it here would mean building the frame + // with the same arguments the assertion checks — green whatever + // sendUserInfo does. + // -- Helpers ------------------------------------------------------------- private fun assertFrameDecodes( diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt new file mode 100644 index 000000000..81f30d883 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt @@ -0,0 +1,351 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Date +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.coroutines.EmptyCoroutineContext + +/** + * The USER_INFO record as it actually leaves the handler. + * + * [HuaweiAhCh100HandlerTest] calls the companion primitives directly. That + * cannot catch a handler which stops calling them correctly: a test that + * builds a frame with `explicitLen = 14` and then asserts the frame says 14 + * is green no matter what `sendUserInfo` does. These tests drive the real + * state machine instead — attach, connect, wake, auth — and decode the bytes + * the handler hands to the transport. + * + * Two properties are pinned here, both from issue #1449: + * + * 1. The record never carries weight 0. `initialWeight` is `0f` on a freshly + * created profile, and the in-session weight is unset until the first + * measurement, so the naive fallback sent a user who weighs nothing. + * 2. The length byte declares 14 while 16 bytes are transmitted. openScale + * 2.5.4 did this — `BluetoothHuaweiAH100.java:524` at tag `v2.5.4` passes + * the literal `14` for a payload it builds as 16 bytes, and + * `AHsendEncryptedCommand` writes that as `{0xDC, len + 0, cmd}`. The + * trailing `0x1C 0xE2` is transmitted but not counted. The 3.x port pulled + * the trailer into the payload and moved the length byte to 16 with it. + * + * Both handlers get the same treatment: the weight fallback is duplicated in + * [HuaweiAhCh100Handler] and [HuaweiCH100SHandler], and two copies drift. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HuaweiUserRecordWireTest { + + companion object { + private const val TEST_MAC = "00:11:22:33:44:55" + private const val TEST_USER_ID = 1 + + /** + * Mirrors the crypto constants in both handlers. Duplicated on purpose: + * a test that imports the key from the code under test cannot notice + * the code changing it. + */ + private const val AES_KEY_HEX = "3DA2784AFB87B12A980FDE3456732156" + private const val AES_IV_HEX = "4EF764322FDA7632123DEB8790FEA219" + + private const val FRAME_ENCRYPTED = 0xDC.toByte() + private const val FRAME_NOTIFY_PLAIN = 0xBD.toByte() + private const val CMD_USER_INFO = 9.toByte() + private const val OP_WAKEUP = 0x00.toByte() + private const val OP_AUTH_RESULT = 0x26.toByte() + } + + // -- AH100 / CH100 ------------------------------------------------------- + + @Test + fun `AhCh100 - declares 14 while transmitting 16, as v2_5_4 and the vendor app do`() { + val frame = driveAhCh100ToUserInfo(attachedAhCh100()) + + assertThat(frame[0]).isEqualTo(FRAME_ENCRYPTED) + assertThat(frame[1]).isEqualTo(0x0E.toByte()) + assertThat(frame[2]).isEqualTo(CMD_USER_INFO) + assertThat(frame.size).isEqualTo(3 + 16) + } + + @Test + fun `AhCh100 - the two uncounted trailer bytes are still on the wire`() { + // The point of the length byte fix is that the trailer is *excluded + // from the count*, not dropped. Sending 14 bytes would be a different + // record than the vendor app sends. + val plain = decodeAhCh100Record(driveAhCh100ToUserInfo(attachedAhCh100())) + + assertThat(plain).hasLength(16) + assertThat(plain[14]).isEqualTo(0x1C.toByte()) + assertThat(plain[15]).isEqualTo(0xE2.toByte()) + } + + @Test + fun `AhCh100 - a fresh profile is never sent as weighing nothing`() { + // The defect reported in #1449, at the point where it goes on the wire. + val setup = attachedAhCh100( + user = profile(initialWeight = 0f, bodyHeight = 180f), + previous = null, + ) + + val weightTenthKg = weightFieldOf(decodeAhCh100Record(driveAhCh100ToUserInfo(setup))) + + assertThat(weightTenthKg).isNotEqualTo(0) + // BMI 22 at 1.80 m — wrong, but a body. Replaced by the first reading. + assertThat(weightTenthKg).isEqualTo(712) + } + + @Test + fun `AhCh100 - a stored measurement beats the BMI estimate`() { + val setup = attachedAhCh100( + user = profile(initialWeight = 0f, bodyHeight = 180f), + previous = ScaleMeasurement( + userId = TEST_USER_ID, + dateTime = Date(0L), + weight = 80.0f, + ), + ) + + val plain = decodeAhCh100Record(driveAhCh100ToUserInfo(setup)) + + assertThat(weightFieldOf(plain)).isEqualTo(800) + assertThat(plain[8].toInt() and 0xFF).isEqualTo(180) + } + + @Test + fun `AhCh100 - a profile without a height still gets a body`() { + // bodyHeight defaults to -1f, so the BMI estimate has nothing to work + // with. It still must not fall back to 0. + val setup = attachedAhCh100( + user = profile(initialWeight = 0f, bodyHeight = -1f), + previous = null, + ) + + assertThat(weightFieldOf(decodeAhCh100Record(driveAhCh100ToUserInfo(setup)))).isEqualTo(700) + } + + // -- CH100S -------------------------------------------------------------- + + @Test + fun `CH100S - a fresh profile is never sent as weighing nothing`() { + val setup = attachedCh100s( + user = profile(initialWeight = 0f, bodyHeight = 180f), + previous = null, + ) + + val weightTenthKg = weightFieldOf(decodeCh100sRecord(driveCh100sToUserInfo(setup))) + + assertThat(weightTenthKg).isNotEqualTo(0) + assertThat(weightTenthKg).isEqualTo(712) + } + + @Test + fun `CH100S - already declares its full payload, which carries no trailer`() { + // This handler builds a 14-byte record without the 0x1C 0xE2 trailer, + // so `payload.size` is already the number the vendor app declares. It + // needs no explicitLen — pinned so nobody "fixes" it to match the + // sibling handler and breaks it. + val frame = driveCh100sToUserInfo(attachedCh100s()) + + assertThat(frame[0]).isEqualTo(FRAME_ENCRYPTED) + assertThat(frame[1]).isEqualTo(0x0E.toByte()) + assertThat(frame[2]).isEqualTo(CMD_USER_INFO) + assertThat(frame.size).isEqualTo(3 + 14) + } + + // -- Driving the handlers ------------------------------------------------ + + private fun attachedAhCh100( + user: ScaleUser = profile(), + previous: ScaleMeasurement? = null, + ): Setup = attach(HuaweiAhCh100Handler(), "CH100", user, previous) + + private fun attachedCh100s( + user: ScaleUser = profile(), + previous: ScaleMeasurement? = null, + ): Setup = attach(HuaweiCH100SHandler(), "CH100S", user, previous) + + private fun attach( + handler: H, + advertName: String, + user: ScaleUser, + previous: ScaleMeasurement?, + ): Setup { + // supportFor() is where both handlers latch the scale MAC they need for + // the XOR obfuscation; skipping it makes every frame a no-op XOR. + val support = handler.supportFor( + ScannedDeviceInfo( + name = advertName, + address = TEST_MAC, + rssi = -50, + serviceUuids = emptyList(), + manufacturerData = null, + ) + ) + assertThat(support).isNotNull() + + val transport = CapturingTransport() + handler.attach( + transport = transport, + callbacks = SilentCallbacks(), + settings = InMemorySettings(), + data = FixedDataProvider(user, previous), + scope = CoroutineScope(EmptyCoroutineContext), + ) + return Setup(handler, transport, user) + } + + /** Run connect -> wake -> auth-ok and return the USER_INFO frame. */ + private fun driveAhCh100ToUserInfo(setup: Setup): ByteArray = + setup.drive() + + private fun driveCh100sToUserInfo(setup: Setup): ByteArray = + setup.drive() + + private fun Setup.drive(): ByteArray { + handler.handleConnected(user) + handler.handleNotification(NOTIFY_CHARACTERISTIC, notification(OP_WAKEUP)) + handler.handleNotification(NOTIFY_CHARACTERISTIC, notification(OP_AUTH_RESULT, 0x01)) + return transport.writes.last { it.isNotEmpty() && it[0] == FRAME_ENCRYPTED } + } + + /** Scale->host frame: `[0xBD, len, op, ...tail XOR'd with the MAC...]`. */ + private fun notification(op: Byte, vararg tail: Byte): ByteArray { + val body = if (tail.isEmpty()) byteArrayOf(0x01) else tail + return byteArrayOf(FRAME_NOTIFY_PLAIN, body.size.toByte(), op) + macXor(body) + } + + // -- Decoding what was sent ---------------------------------------------- + + /** `[0xDC, len, cmd] || macXor(AES(payload))` — the AH100/CH100 order. */ + private fun decodeAhCh100Record(frame: ByteArray): ByteArray { + val magicKey = HuaweiAhCh100Handler.deriveMagicKey( + HuaweiAhCh100Handler.buildAuthToken(TEST_USER_ID), + HuaweiAhCh100Handler.macStringToBytes(TEST_MAC), + ) + return aes(macXor(frame.copyOfRange(3, frame.size)), magicKey) + } + + /** `[0xDC, len, cmd] || AES(macXor(payload))` — the CH100S order. */ + private fun decodeCh100sRecord(frame: ByteArray): ByteArray = + macXor(aes(frame.copyOfRange(3, frame.size), hex(AES_KEY_HEX))) + + /** Weight is a little-endian tenth-kg field at offset 10 in both records. */ + private fun weightFieldOf(plain: ByteArray): Int = + (plain[10].toInt() and 0xFF) or ((plain[11].toInt() and 0xFF) shl 8) + + private fun macXor(data: ByteArray): ByteArray = + HuaweiAhCh100Handler.obfuscate(data, HuaweiAhCh100Handler.macStringToBytes(TEST_MAC)) + + private fun aes(data: ByteArray, key: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + IvParameterSpec(hex(AES_IV_HEX)), + ) + return cipher.doFinal(data) + } + + private fun hex(s: String): ByteArray = + ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } + + // -- Fixtures and fakes -------------------------------------------------- + + private fun profile( + initialWeight: Float = 0f, + bodyHeight: Float = 180f, + ): ScaleUser = ScaleUser( + id = TEST_USER_ID, + birthday = Date(0L), + bodyHeight = bodyHeight, + initialWeight = initialWeight, + ) + + private data class Setup( + val handler: H, + val transport: CapturingTransport, + val user: ScaleUser, + ) + + private class CapturingTransport : ScaleDeviceHandler.Transport { + val writes = mutableListOf() + + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write( + service: UUID, + characteristic: UUID, + payload: ByteArray, + withResponse: Boolean, + ) { + writes += payload.copyOf() + } + + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = true + } + + private class SilentCallbacks : ScaleDeviceHandler.Callbacks { + override fun onPublish(measurement: ScaleMeasurement) = Unit + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + private val ints = mutableMapOf() + private val strings = mutableMapOf() + + override fun getInt(key: String, default: Int): Int = ints[key] ?: default + override fun putInt(key: String, value: Int) { + ints[key] = value + } + + override fun getString(key: String, default: String?): String? = strings[key] ?: default + override fun putString(key: String, value: String) { + strings[key] = value + } + + override fun remove(key: String) { + ints.remove(key) + strings.remove(key) + } + } + + private class FixedDataProvider( + private val user: ScaleUser, + private val previous: ScaleMeasurement?, + ) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = + previous?.takeIf { userId == user.id } + } +} + +private val NOTIFY_CHARACTERISTIC: UUID = + UUID.fromString("0000faa2-0000-1000-8000-00805f9b34fb") From 39a0c5c25a0abc26b8e24c90f36b5a2c6ff07a4a Mon Sep 17 00:00:00 2001 From: oliexdev Date: Wed, 2 Sep 2026 08:25:48 +0200 Subject: [PATCH 60/72] BLE handlers can contribute custom measurement types (#1481, #1484) Replaces the MeasurementTypeKey enum with an identity-based registry (builtin./ble./user., schema 15->16). ScaleMeasurement becomes a typed value map; unit value classes (Kg, Percent, ...) enforce the delivery unit at compile time. CSV and sync lose their CUSTOM special cases; sync wire format is identity-only (API v3, needs matching openScale-sync). First users: BF1000 segmental values, S400 ECW/ICW/BCM. --- android_app/app/build.gradle.kts | 4 + .../16.json | 417 +++++++++++++++++ .../java/com/health/openscale/OpenScaleApp.kt | 52 +- .../core/bluetooth/data/ScaleMeasurement.kt | 106 +++-- .../core/bluetooth/scales/AAAxHandler.kt | 4 +- .../bluetooth/scales/ActiveEraBF06Handler.kt | 38 +- .../core/bluetooth/scales/AfuB1Handler.kt | 22 +- .../bluetooth/scales/BeurerBF450Handler.kt | 13 +- .../bluetooth/scales/BeurerSanitasHandler.kt | 16 +- .../bluetooth/scales/BodyConnectHandler.kt | 13 +- .../scales/CultSmartScaleProHandler.kt | 27 +- .../scales/CustomOpenScaleHandler.kt | 11 +- .../bluetooth/scales/DigooDGSO38HHandler.kt | 15 +- .../bluetooth/scales/DrTrustSSW532Handler.kt | 29 +- .../core/bluetooth/scales/EEBBLHandler.kt | 8 +- .../core/bluetooth/scales/ESCS20MHandler.kt | 40 +- .../scales/EbelterBodyFatB2Handler.kt | 8 +- .../bluetooth/scales/EtekcityESF551Handler.kt | 22 +- .../bluetooth/scales/EtekcityFit8SHandler.kt | 25 +- .../core/bluetooth/scales/EufyC20Handler.kt | 33 +- .../core/bluetooth/scales/EufyP2Handler.kt | 7 +- .../bluetooth/scales/ExcelvanCF36xHandler.kt | 17 +- .../bluetooth/scales/ExingtechY1Handler.kt | 17 +- .../bluetooth/scales/FitTrackDaraHandler.kt | 25 +- .../bluetooth/scales/HealthKeep280Handler.kt | 19 +- .../core/bluetooth/scales/HesleyHandler.kt | 15 +- .../bluetooth/scales/HoffenBbs8107Handler.kt | 15 +- .../bluetooth/scales/HuaweiAhCh100Handler.kt | 12 +- .../bluetooth/scales/HuaweiCH100SHandler.kt | 23 +- .../scales/HuaweiHagridWspHandler.kt | 55 ++- .../core/bluetooth/scales/HumeDara2Handler.kt | 25 +- .../bluetooth/scales/IHealthHS3Handler.kt | 4 +- .../core/bluetooth/scales/InlifeHandler.kt | 22 +- .../core/bluetooth/scales/KeepS3Handler.kt | 46 +- .../core/bluetooth/scales/MGBHandler.kt | 33 +- .../bluetooth/scales/MedisanaBs44xHandler.kt | 13 +- .../core/bluetooth/scales/MiScaleHandler.kt | 47 +- .../bluetooth/scales/MiScaleS400Handler.kt | 60 ++- .../bluetooth/scales/ModernScaleAdapter.kt | 50 +- .../core/bluetooth/scales/OkOkHandler.kt | 33 +- .../core/bluetooth/scales/OmronWlcHandler.kt | 17 +- .../core/bluetooth/scales/OneByoneHandler.kt | 22 +- .../bluetooth/scales/OneByoneNewHandler.kt | 32 +- .../core/bluetooth/scales/PicoocHandler.kt | 46 +- .../core/bluetooth/scales/QNHandler.kt | 32 +- .../bluetooth/scales/QNHandlerBroadcast.kt | 4 +- .../scales/RealmeSmartScaleHandler.kt | 22 +- .../bluetooth/scales/RelaxmedicHandler.kt | 29 +- .../bluetooth/scales/RenphoES26BBHandler.kt | 7 +- .../core/bluetooth/scales/RenphoHandler.kt | 4 +- .../core/bluetooth/scales/RobiS9Handler.kt | 4 +- .../core/bluetooth/scales/RunstarR5Handler.kt | 4 +- .../core/bluetooth/scales/RunstarR6Handler.kt | 12 +- .../core/bluetooth/scales/RyFitHandler.kt | 20 +- .../core/bluetooth/scales/ScaleupHandler.kt | 4 +- .../core/bluetooth/scales/SenssunHandler.kt | 13 +- .../core/bluetooth/scales/SinocareHandler.kt | 4 +- .../core/bluetooth/scales/SoehnleHandler.kt | 11 +- .../scales/StandardBeurerSanitasHandler.kt | 89 +++- .../scales/StandardWeightProfileHandler.kt | 36 +- .../core/bluetooth/scales/TaylorBIAHandler.kt | 6 +- .../scales/TrisaBodyAnalyzeHandler.kt | 16 +- .../bluetooth/scales/VitafitVT701Handler.kt | 19 +- .../bluetooth/scales/WeightGurusA3Handler.kt | 27 +- .../bluetooth/scales/XiaomiS800Handler.kt | 4 +- .../core/bluetooth/scales/YunmaiHandler.kt | 33 +- .../core/bluetooth/scales/YunmaiXHandler.kt | 20 +- .../com/health/openscale/core/data/Enums.kt | 119 +++-- .../openscale/core/data/MeasurementType.kt | 443 +++++++++++++++++- .../openscale/core/database/AppDatabase.kt | 320 ++++++++++--- .../core/database/DatabaseConverters.kt | 7 - .../core/database/DatabaseProvider.kt | 54 ++- .../core/database/DatabaseRepository.kt | 25 +- .../core/database/MeasurementTypeDao.kt | 7 + .../core/facade/MeasurementFacade.kt | 12 +- .../openscale/core/service/BleConnector.kt | 156 +++--- .../core/service/DerivedValuesCalculator.kt | 43 +- .../core/usecase/ImportExportUseCases.kt | 99 ++-- .../core/usecase/MeasurementDemoUseCase.kt | 29 +- .../usecase/MeasurementEvaluationUseCases.kt | 41 +- .../usecase/MeasurementInsightsUseCase.kt | 18 +- .../MeasurementTransformationUseCase.kt | 47 +- .../usecase/MeasurementTypeCrudUseCases.kt | 128 ++++- .../openscale/core/usecase/ReminderUseCase.kt | 4 +- .../openscale/core/usecase/SyncUseCases.kt | 29 +- .../ui/screen/components/MeasurementChart.kt | 17 +- .../openscale/ui/screen/graph/GraphScreen.kt | 4 +- .../overview/MeasurementDetailScreen.kt | 9 +- .../ui/screen/overview/OverviewScreen.kt | 7 +- .../settings/MeasurementTypeDetailScreen.kt | 165 ++++++- .../settings/MeasurementTypeSettingsScreen.kt | 3 +- .../ui/screen/settings/SettingsViewModel.kt | 3 + .../openscale/ui/screen/table/TableScreen.kt | 34 +- .../openscale/ui/shared/SharedViewModel.kt | 7 +- .../openscale/ui/widget/MeasurementWidget.kt | 4 +- .../widget/MeasurementWidgetConfigActivity.kt | 3 +- .../app/src/main/res/values/strings.xml | 14 + .../bluetooth/data/ScaleMeasurementTest.kt | 89 ++++ .../scales/BodyConnectHandlerTest.kt | 13 +- .../scales/HuaweiHagridWspHandlerTest.kt | 29 +- .../scales/HuaweiUserRecordWireTest.kt | 5 +- .../bluetooth/scales/KeepS3HandlerTest.kt | 87 ++-- .../bluetooth/scales/PicoocHandlerTest.kt | 49 +- .../StandardBeurerSanitasHandlerTest.kt | 246 ++++++++++ .../core/data/MeasurementTypeIdentityTest.kt | 174 +++++++ .../core/database/IdentityMigrationTest.kt | 437 +++++++++++++++++ .../core/database/MeasurementDaoTest.kt | 3 +- .../openscale/core/database/MigrationTest.kt | 12 +- .../core/database/UserGoalsDaoTest.kt | 3 +- .../DerivedValuesCalculatorIntegrationTest.kt | 9 +- .../core/service/MeasurementEnricherTest.kt | 10 +- .../core/usecase/BackupRestoreUseCasesTest.kt | 2 +- .../core/usecase/GenericValueJsonTest.kt | 66 ++- .../core/usecase/ImportExportUseCasesTest.kt | 125 ++++- .../MeasurementAggregationUseCaseTest.kt | 6 +- .../usecase/MeasurementCrudUseCasesTest.kt | 13 +- .../usecase/MeasurementFilterUseCasesTest.kt | 5 +- .../usecase/MeasurementInsightsUseCaseTest.kt | 20 +- .../MeasurementTypeCrudUseCasesTest.kt | 183 +++++++- .../MeasurementValueInheritanceTest.kt | 32 +- .../com/health/openscale/testutil/Fixtures.kt | 5 +- .../openscale/testutil/RoomTestSupport.kt | 7 +- .../screen/settings/SettingsViewModelTest.kt | 9 +- .../ui/shared/SharedViewModelTest.kt | 9 +- 124 files changed, 4158 insertions(+), 1319 deletions(-) create mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/data/ScaleMeasurementTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandlerTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/data/MeasurementTypeIdentityTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/database/IdentityMigrationTest.kt diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index e52df370d..29ac76b73 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -148,6 +148,10 @@ android { // JVM unit tests touch android.util.Log (via LogManager); return defaults // instead of throwing "not mocked" so pure-logic tests can run on the JVM. unitTests.isReturnDefaultValues = true + // Hand the merged resource table to Robolectric, so code that resolves a string + // resource (e.g. the display name of a scale-contributed measurement type) can be + // tested against the real string instead of a stub. + unitTests.isIncludeAndroidResources = true } } diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json new file mode 100644 index 000000000..9cbcb723b --- /dev/null +++ b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json @@ -0,0 +1,417 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "5eb6a4701bdef8e22cee081d93b2cce6", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `birthDate` INTEGER NOT NULL, `gender` TEXT NOT NULL, `heightCm` REAL NOT NULL, `activityLevel` TEXT NOT NULL, `useAssistedWeighing` INTEGER NOT NULL, `amputations` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthDate", + "columnName": "birthDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gender", + "columnName": "gender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heightCm", + "columnName": "heightCm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "activityLevel", + "columnName": "activityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useAssistedWeighing", + "columnName": "useAssistedWeighing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amputations", + "columnName": "amputations", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "user_goals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `measurementTypeId` INTEGER NOT NULL, `goalValue` REAL NOT NULL, `goalTargetDate` INTEGER, PRIMARY KEY(`userId`, `measurementTypeId`), FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`measurementTypeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "measurementTypeId", + "columnName": "measurementTypeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "goalValue", + "columnName": "goalValue", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "goalTargetDate", + "columnName": "goalTargetDate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "measurementTypeId" + ] + }, + "indices": [ + { + "name": "index_user_goals_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_goals_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_user_goals_measurementTypeId", + "unique": false, + "columnNames": [ + "measurementTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_goals_measurementTypeId` ON `${TABLE_NAME}` (`measurementTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MeasurementType", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "measurementTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Measurement", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Measurement_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Measurement_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_Measurement_userId_timestamp", + "unique": true, + "columnNames": [ + "userId", + "timestamp" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Measurement_userId_timestamp` ON `${TABLE_NAME}` (`userId`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MeasurementValue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `measurementId` INTEGER NOT NULL, `typeId` INTEGER NOT NULL, `floatValue` REAL, `intValue` INTEGER, `textValue` TEXT, `dateValue` INTEGER, FOREIGN KEY(`measurementId`) REFERENCES `Measurement`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`typeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "measurementId", + "columnName": "measurementId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "typeId", + "columnName": "typeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "floatValue", + "columnName": "floatValue", + "affinity": "REAL" + }, + { + "fieldPath": "intValue", + "columnName": "intValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "textValue", + "columnName": "textValue", + "affinity": "TEXT" + }, + { + "fieldPath": "dateValue", + "columnName": "dateValue", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MeasurementValue_measurementId", + "unique": false, + "columnNames": [ + "measurementId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementValue_measurementId` ON `${TABLE_NAME}` (`measurementId`)" + }, + { + "name": "index_MeasurementValue_typeId", + "unique": false, + "columnNames": [ + "typeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementValue_typeId` ON `${TABLE_NAME}` (`typeId`)" + } + ], + "foreignKeys": [ + { + "table": "Measurement", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "measurementId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MeasurementType", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "typeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MeasurementType", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `identity` TEXT NOT NULL, `name` TEXT, `color` INTEGER NOT NULL, `icon` TEXT NOT NULL, `unit` TEXT NOT NULL, `inputType` TEXT NOT NULL, `displayOrder` INTEGER NOT NULL, `isDerived` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isOnRightYAxis` INTEGER NOT NULL, `isInternal` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identity", + "columnName": "identity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputType", + "columnName": "inputType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayOrder", + "columnName": "displayOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDerived", + "columnName": "isDerived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOnRightYAxis", + "columnName": "isOnRightYAxis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInternal", + "columnName": "isInternal", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MeasurementType_identity", + "unique": true, + "columnNames": [ + "identity" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_MeasurementType_identity` ON `${TABLE_NAME}` (`identity`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5eb6a4701bdef8e22cee081d93b2cce6')" + ] + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt b/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt index de4906f72..cde81a3a3 100644 --- a/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt +++ b/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt @@ -21,11 +21,7 @@ import android.app.Application import android.util.Log import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration -import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeIcon -import com.health.openscale.core.data.MeasurementTypeKey -import com.health.openscale.core.data.UnitType import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.facade.SettingsFacade import com.health.openscale.core.utils.LogManager @@ -37,52 +33,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Generates a default list of measurement types available in the application, - * resolving names from string resources. - * These types are intended for insertion into the database on the first app start. - * - * @param context The context used to access string resources. - * @return A list of [MeasurementType] objects. - */ -fun getDefaultMeasurementTypes(): List { - return listOf( - MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFF7E57C2.toInt(), icon = MeasurementTypeIcon.IC_WEIGHT, isPinned = true, isEnabled = true, isOnRightYAxis = true), - MeasurementType(key = MeasurementTypeKey.BMI, unit = UnitType.NONE, color = 0xFFFFCA28.toInt(), icon = MeasurementTypeIcon.IC_BMI, isDerived = true, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT, color = 0xFFEF5350.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.WATER, unit = UnitType.PERCENT, color = 0xFF29B6F6.toInt(), icon = MeasurementTypeIcon.IC_WATER, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.MUSCLE, unit = UnitType.PERCENT, color = 0xFF66BB6A.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.LBM, unit = UnitType.KG, color = 0xFF4DBAC0.toInt(), icon = MeasurementTypeIcon.IC_LBM, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BONE, unit = UnitType.KG, color = 0xFFBDBDBD.toInt(), icon = MeasurementTypeIcon.IC_BONE, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.WAIST, unit = UnitType.CM, color = 0xFF78909C.toInt(), icon = MeasurementTypeIcon.IC_WAIST, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.WHR, unit = UnitType.NONE, color = 0xFFFFA726.toInt(), icon = MeasurementTypeIcon.IC_WHR, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.WHTR, unit = UnitType.NONE, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_WHTR, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.HIPS, unit = UnitType.CM, color = 0xFF5C6BC0.toInt(), icon = MeasurementTypeIcon.IC_HIPS, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.VISCERAL_FAT, unit = UnitType.NONE, color = 0xFFD84315.toInt(), icon = MeasurementTypeIcon.IC_VISCERAL_FAT, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CHEST, unit = UnitType.CM, color = 0xFF8E24AA.toInt(), icon = MeasurementTypeIcon.IC_CHEST, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.THIGH, unit = UnitType.CM, color = 0xFFA1887F.toInt(), icon = MeasurementTypeIcon.IC_THIGH, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BICEPS, unit = UnitType.CM, color = 0xFFEC407A.toInt(), icon = MeasurementTypeIcon.IC_BICEPS, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.NECK, unit = UnitType.CM, color = 0xFFB0BEC5.toInt(), icon = MeasurementTypeIcon.IC_NECK, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALIPER_1, unit = UnitType.CM, color = 0xFFFFF59D.toInt(), icon = MeasurementTypeIcon.IC_CALIPER1, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALIPER_2, unit = UnitType.CM, color = 0xFFFFE082.toInt(), icon = MeasurementTypeIcon.IC_CALIPER2, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALIPER_3, unit = UnitType.CM, color = 0xFFFFCC80.toInt(), icon = MeasurementTypeIcon.IC_CALIPER3, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALIPER, unit = UnitType.PERCENT, color = 0xFFFB8C00.toInt(), icon = MeasurementTypeIcon.IC_FAT_CALIPER, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BMR, unit = UnitType.KCAL, color = 0xFFAB47BC.toInt(), icon = MeasurementTypeIcon.IC_BMR, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.TDEE, unit = UnitType.KCAL, color = 0xFF26A69A.toInt(), icon = MeasurementTypeIcon.IC_TDEE, isDerived = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.HEART_RATE, inputType = InputFieldType.INT, unit = UnitType.BPM, color = 0xFFE91E63.toInt(), icon = MeasurementTypeIcon.IC_M_HEART_RATE, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.IMPEDANCE, unit = UnitType.OHM, color = 0xFF607D8B.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), - MeasurementType(key = MeasurementTypeKey.IMPEDANCE_LOW, unit = UnitType.OHM, color = 0xFF455A64.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), - MeasurementType(key = MeasurementTypeKey.ECW, unit = UnitType.PERCENT, color = 0xFF4FC3F7.toInt(), icon = MeasurementTypeIcon.IC_M_SCATTER_PLOT, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.ICW, unit = UnitType.PERCENT, color = 0xFF0288D1.toInt(), icon = MeasurementTypeIcon.IC_M_BUBBLE_CHART, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.PROTEIN, unit = UnitType.PERCENT, color = 0xFF9CCC65.toInt(), icon = MeasurementTypeIcon.IC_M_PROTEIN, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BCM, unit = UnitType.KG, color = 0xFF558B2F.toInt(), icon = MeasurementTypeIcon.IC_M_HIVE, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.CALORIES, unit = UnitType.KCAL, color = 0xFF4CAF50.toInt(), icon = MeasurementTypeIcon.IC_CALORIES, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.COMMENT, inputType = InputFieldType.TEXT, unit = UnitType.NONE, color = 0xFFE0E0E0.toInt(), icon = MeasurementTypeIcon.IC_COMMENT, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.DATE, inputType = InputFieldType.DATE, unit = UnitType.NONE, color = 0xFF9E9E9E.toInt(), icon = MeasurementTypeIcon.IC_DATE, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.TIME, inputType = InputFieldType.TIME, unit = UnitType.NONE, color = 0xFF757575.toInt(), icon = MeasurementTypeIcon.IC_TIME, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.USER, inputType = InputFieldType.USER, unit = UnitType.NONE, color = 0xFF90A4AE.toInt(), icon = MeasurementTypeIcon.IC_USER, isEnabled = true) - ) -} @HiltAndroidApp class OpenScaleApp : Application(), Configuration.Provider { @@ -127,7 +77,7 @@ class OpenScaleApp : Application(), Configuration.Provider { if (isFirstActualStart) { LogManager.i(TAG, "First app start detected. Inserting default measurement types...") - databaseRepository.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + databaseRepository.insertAllMeasurementTypes(MeasurementType.seedRows()) settingsFacade.setFirstAppStartCompleted(false) LogManager.i(TAG, "Default measurement types inserted and first start marked as completed.") } else { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt index cc5d96275..1ae883aa2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt @@ -17,59 +17,87 @@ */ package com.health.openscale.core.bluetooth.data -import com.health.openscale.core.data.WeightUnit +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.UnitValue import java.util.Date +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** - * Represents a single measurement record from a scale, potentially combined from multiple BLE packets. + * One weigh-in as reported by a scale, potentially combined from multiple BLE packets — + * the envelope (who, when) plus a map of values keyed by the [MeasurementType.Key] that + * describes them. + * + * Handlers write predefined quantities and their own device keys through the same API. + * The unit is part of the value's type — setting a bare number or the wrong unit does + * not compile, and the conversions live on the unit classes (`Kg.fromLb`, `Cm.fromInch`): + * + * m[MeasurementType.WEIGHT] = Kg(raw * 0.005f) // Key + * m[MeasurementType.BODY_FAT] = Percent(fat) // Key + * m[MeasurementType.HEART_RATE] = Bpm(60) // Key + * m[FAT_LEFT_ARM] = Percent(12.4f) // handler-declared devicePercent + * + * Absence is absence: a quantity the scale did not report is simply not in the map — there + * is no 0f sentinel. [set] therefore drops values that never describe a real measurement + * (non-finite or non-positive numbers, blank text), which keeps [mergeWith]'s + * first-value-wins semantics sound without every handler guarding its writes. */ -data class ScaleMeasurement( - var userId: Int = 0xFF, // openScale's internal app user ID +class ScaleMeasurement( + /** openScale's internal app user ID; 0xFF = the scale did not report a user slot. */ + var userId: Int = 0xFF, + /** Timestamp of the weigh-in; becomes the Measurement row's timestamp, null = "now". */ 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 + val values: MutableMap, Any> = LinkedHashMap(), ) { - // --- Utility methods --- + /** + * Records a value; silently drops what never describes a real measurement (see class + * doc). The value's type is the unit contract — the connector unwraps and converts to + * the user's configured unit once, centrally. + */ + operator fun set(key: MeasurementType.Key, value: T) { + val usable = when (value) { + is UnitValue -> value.value.isFinite() && value.value > 0f + is Float -> value.isFinite() && value > 0f + is Bpm -> value.value > 0 + is Int -> value > 0 + is String -> value.isNotBlank() + is Date -> true + else -> false // Unit (the USER column key) carries no value + } + if (usable) values[key] = value + } + + /** Reads a value back, typed as the key declared it; null = not reported. */ + @Suppress("UNCHECKED_CAST") + operator fun get(key: MeasurementType.Key): T? = values[key] as T? - fun hasWeight(): Boolean = this.weight > 0f + operator fun contains(key: MeasurementType.Key<*>): Boolean = key in values + fun hasWeight(): Boolean = MeasurementType.WEIGHT in this + + /** + * Fill-the-gaps merge for multi-packet protocols: a value already collected wins over + * one arriving later; userId and dateTime transfer only when still unset. + */ 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 + other.values.forEach { (key, value) -> values.putIfAbsent(key, value) } if (other.userId != 0xFF && - (this.userId == 0xFF || this.userId == -1)) { // -1 was common init value + (this.userId == 0xFF || this.userId == -1)) { // -1 was a common init value this.userId = other.userId } - if (this.dateTime == null && other.dateTime != null) this.dateTime = other.dateTime } -} + /** + * Deep copy for handlers that publish while continuing to mutate their accumulator — + * the map is detached, so later mutations never bleed into published data. + */ + fun snapshot(): ScaleMeasurement = ScaleMeasurement(userId, dateTime, LinkedHashMap(values)) + + override fun toString(): String = + "ScaleMeasurement(userId=$userId, dateTime=$dateTime, " + + "values=${values.entries.joinToString { "${it.key.identity}=${it.value}" }})" +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AAAxHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AAAxHandler.kt index e3709ebaa..b5a220e1c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AAAxHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AAAxHandler.kt @@ -18,10 +18,12 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import androidx.core.util.isEmpty +import com.health.openscale.core.data.Kg /** * AAAxHandler @@ -118,7 +120,7 @@ class AAAxHandler : ScaleDeviceHandler() { publish( ScaleMeasurement().apply { dateTime = Date() - weight = kg + this[MeasurementType.WEIGHT] = Kg(kg) } ) publishedOnce = true diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ActiveEraBF06Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ActiveEraBF06Handler.kt index 1d18e7c00..d644193b2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ActiveEraBF06Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ActiveEraBF06Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.WeightUnit @@ -29,6 +30,11 @@ import kotlin.math.floor import kotlin.math.max import kotlin.math.min import kotlin.math.pow +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Active Era BF-06 (aka "BS-06") GATT handler. @@ -236,7 +242,7 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { stableWeightKg = weightKg logI("Stable weight: %.3f kg".format(stableWeightKg)) sendMeasuringSnack(weightKg) - ensurePending().weight = weightKg + ensurePending()[MeasurementType.WEIGHT] = Kg(weightKg) // We can publish later once we have HR/ADC (if provided) maybePublishIfComplete() @@ -284,17 +290,17 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { val calc = LibICBIACalculatorWLA07(stableWeightKg, user.bodyHeight.toInt(), impedanceOhm, user.age, user.gender.isMale()) ensurePending().apply { val bodyFat = calc.bodyFatPercent.toFloat() - fat = bodyFat - muscle = calc.musclePercent.toFloat() - visceralFat = calc.visceralFat.toFloat() - bone = calc.boneMass.toFloat() - water = calc.moisturePercent.toFloat() - lbm = (stableWeightKg * (100.0 - bodyFat) / 100.0).toFloat() - bmr = calc.bMR.toFloat() - protein = calc.protein.toFloat() + this[MeasurementType.BODY_FAT] = Percent(bodyFat) + this[MeasurementType.MUSCLE] = Percent(calc.musclePercent.toFloat()) + this[MeasurementType.VISCERAL_FAT] = calc.visceralFat.toFloat() + this[MeasurementType.BONE] = Kg(calc.boneMass.toFloat()) + this[MeasurementType.WATER] = Percent(calc.moisturePercent.toFloat()) + this[MeasurementType.LBM] = Kg((stableWeightKg * (100.0 - bodyFat) / 100.0).toFloat()) + this[MeasurementType.BMR] = Kcal(calc.bMR.toFloat()) + this[MeasurementType.PROTEIN] = Percent(calc.protein.toFloat()) // Store the raw impedance so body composition can be recomputed later. - impedance = impedanceOhm + this[MeasurementType.IMPEDANCE] = Ohm(impedanceOhm.toFloat()) } } else -> { @@ -310,10 +316,10 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { val fatKg = (stableWeightKg - ffm).coerceAtLeast(0.0) val fatPct = if (stableWeightKg > 0) (fatKg / stableWeightKg) * 100.0 else 0.0 ensurePending().apply { - lbm = ffm.toFloat() - fat = fatPct.toFloat() + this[MeasurementType.LBM] = Kg(ffm.toFloat()) + this[MeasurementType.BODY_FAT] = Percent(fatPct.toFloat()) // Store the raw impedance so body composition can be recomputed later. - impedance = impedanceOhm + this[MeasurementType.IMPEDANCE] = Ohm(impedanceOhm.toFloat()) // Optional: rough water/muscle estimates could be added if desired } } @@ -327,7 +333,7 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { val hrBpm = pkt[0x03].toInt() and 0xFF logI("Heart rate: $hrBpm bpm") ensurePending().apply { - heartRate = hrBpm + this[MeasurementType.HEART_RATE] = Bpm(hrBpm) } maybePublishIfComplete() @@ -357,7 +363,7 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { val m = ensurePending() val needHr = supportsHR - val haveHr = m.heartRate != 0 + val haveHr = (m[MeasurementType.HEART_RATE]?.value ?: 0) != 0 if (needHr && !haveHr) return @@ -367,7 +373,7 @@ class ActiveEraBF06Handler : ScaleDeviceHandler() { dateTime = java.util.Date() // now } publish(m) - logI("published measurement (weight=${m.weight}, fat=${m.fat}, lbm=${m.lbm})") + logI("published measurement (weight=${m[MeasurementType.WEIGHT]}, fat=${m[MeasurementType.BODY_FAT]}, lbm=${m[MeasurementType.LBM]})") // Reset to avoid double-publishing in the same session pending = null } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AfuB1Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AfuB1Handler.kt index 76f314ccf..23b6e1158 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AfuB1Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AfuB1Handler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.BluetoothGattCharacteristic import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.AfuB1Lib @@ -34,6 +35,9 @@ import java.util.Locale import java.util.UUID import kotlin.math.abs import kotlin.time.Duration.Companion.milliseconds +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent class AfuB1Handler : ScaleDeviceHandler() { @@ -488,7 +492,7 @@ class AfuB1Handler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { userId = user.id dateTime = timestamp?.let { plausibleDate(it) } ?: Date() - this.weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) // Composition chain: resistance -> body-fat % -> report. Nothing derived is // published outside the reference implementation's validated input ranges. if (impedanceOhms != null && impedanceOhms > 0f) { @@ -500,14 +504,14 @@ class AfuB1Handler : ScaleDeviceHandler() { if (bodyFat != null) { val comp = AfuB1Lib.bodyComposition(sexFlag, user.age, user.bodyHeight, weightKg, bodyFat) if (comp != null) { - this.impedance = impedanceOhms.toDouble() - fat = bodyFat - water = comp.waterPercent.toFloat() - muscle = comp.skeletalMusclePercent.toFloat() - bone = comp.boneMassKg - visceralFat = comp.visceralFatIndex.toFloat() - protein = comp.proteinPercent.toFloat() - lbm = comp.fatFreeMassKg + this[MeasurementType.IMPEDANCE] = Ohm(impedanceOhms) + this[MeasurementType.BODY_FAT] = Percent(bodyFat) + this[MeasurementType.WATER] = Percent(comp.waterPercent.toFloat()) + this[MeasurementType.MUSCLE] = Percent(comp.skeletalMusclePercent.toFloat()) + this[MeasurementType.BONE] = Kg(comp.boneMassKg) + this[MeasurementType.VISCERAL_FAT] = comp.visceralFatIndex.toFloat() + this[MeasurementType.PROTEIN] = Percent(comp.proteinPercent.toFloat()) + this[MeasurementType.LBM] = Kg(comp.fatFreeMassKg) } } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerBF450Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerBF450Handler.kt index 7ef025cc1..86a1f6f2d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerBF450Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerBF450Handler.kt @@ -17,6 +17,7 @@ */ package com.health.openscale.core.bluetooth.scales import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType @@ -24,6 +25,8 @@ import com.health.openscale.core.service.ScannedDeviceInfo import com.welie.blessed.BluetoothBytesParser import java.util.GregorianCalendar import java.util.UUID +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Handler for Beurer BF450. @@ -280,14 +283,14 @@ class BeurerBF450Handler : StandardWeightProfileHandler() { val transformed = super.transformBeforePublish(m) if (pendingBodyComposition) { - transformed.fat = lastFatPct - transformed.muscle = lastMusclePct - transformed.water = (lastBodyWater / (transformed.weight.takeIf {it > 0f} ?: 1f) * 100f) + transformed[MeasurementType.BODY_FAT] = Percent(lastFatPct) + transformed[MeasurementType.MUSCLE] = Percent(lastMusclePct) + transformed[MeasurementType.WATER] = Percent((lastBodyWater / ((transformed[MeasurementType.WEIGHT]?.value ?: 0f).takeIf {it > 0f} ?: 1f) * 100f)) if (lastImpedance > 0.0) { - transformed.impedance = lastImpedance + transformed[MeasurementType.IMPEDANCE] = Ohm(lastImpedance.toFloat()) } - logD("Added values to measurement in BF450: fat=%.1f%% musc=%.1f%% water=%.1f%% impd=%.1fΩ".format(transformed.fat, transformed.muscle, transformed.water, transformed.impedance)) + logD("Added values to measurement in BF450: fat=%.1f%% musc=%.1f%% water=%.1f%% impd=%.1fΩ".format((transformed[MeasurementType.BODY_FAT]?.value ?: 0f), (transformed[MeasurementType.MUSCLE]?.value ?: 0f), (transformed[MeasurementType.WATER]?.value ?: 0f), (transformed[MeasurementType.IMPEDANCE]?.value ?: 0f))) pendingBodyComposition = false } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerSanitasHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerSanitasHandler.kt index 7c789c65a..a800abde4 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerSanitasHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerSanitasHandler.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import androidx.annotation.VisibleForTesting import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.WeightUnit @@ -32,6 +33,9 @@ import java.util.Locale import java.util.UUID import kotlin.experimental.or import kotlin.math.max +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Beurer/Sanitas handler (BF700/800/RT Libra, BF710, Sanitas SBF70/SBF75/Crane). @@ -672,13 +676,13 @@ class BeurerSanitasHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { this.userId = userId this.dateTime = Date(timestampMs) - this.weight = weight - this.fat = fat - this.water = water - this.muscle = muscle - this.bone = bone + this[MeasurementType.WEIGHT] = Kg(weight) + this[MeasurementType.BODY_FAT] = Percent(fat) + this[MeasurementType.WATER] = Percent(water) + this[MeasurementType.MUSCLE] = Percent(muscle) + this[MeasurementType.BONE] = Kg(bone) // Store the raw impedance so body composition can be recomputed later. - if (impedance > 0) this.impedance = impedance.toDouble() + if (impedance > 0) this[MeasurementType.IMPEDANCE] = Ohm(impedance.toFloat()) } publish(m) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt index 88a932ed7..4fb7bd8f4 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.ActivityLevel @@ -29,6 +30,8 @@ import java.nio.charset.StandardCharsets import java.util.Date import java.util.UUID import kotlin.random.Random +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * BodyConnectHandler @@ -339,12 +342,12 @@ class BodyConnectHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { dateTime = Date(deviceTimeToJava(lastTS!!)) - this.weight = weight + this[MeasurementType.WEIGHT] = Kg(weight) } - fat?.let { m.fat = it } - water?.let { m.water = it } - muscle?.let { m.muscle = it } - bone?.let { m.bone = it } + fat?.let { m[MeasurementType.BODY_FAT] = Percent(it) } + water?.let { m[MeasurementType.WATER] = Percent(it) } + muscle?.let { m[MeasurementType.MUSCLE] = Percent(it) } + bone?.let { m[MeasurementType.BONE] = Kg(it) } publish(m) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt index 05b538aaf..7c97fa034 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CultSmartScaleProHandler.kt @@ -18,12 +18,17 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Handler for the **Cult Smart Scale Pro** (Espressif-based BLE scale, 8-electrode BIA). @@ -258,7 +263,7 @@ class CultSmartScaleProHandler : ScaleDeviceHandler() { private fun publishMeasurement(withBodyComp: Boolean, user: ScaleUser?) { val m = ScaleMeasurement().apply { dateTime = Date() - weight = pendingWeight + this[MeasurementType.WEIGHT] = Kg(pendingWeight) } if (withBodyComp && user != null && pendingImpedance > 0.0) { @@ -270,22 +275,22 @@ class CultSmartScaleProHandler : ScaleDeviceHandler() { impedance = pendingImpedance ) - m.fat = lib.totalFatPercentage.toFloat().coerceIn(0f, 75f) - m.water = lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) - m.muscle = lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f) - m.bone = lib.boneMassKg.toFloat().coerceIn(0f, 10f) - m.lbm = lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f) - m.bmr = lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) - m.impedance = pendingImpedance + m[MeasurementType.BODY_FAT] = Percent(lib.totalFatPercentage.toFloat().coerceIn(0f, 75f)) + m[MeasurementType.WATER] = Percent(lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f)) + m[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f)) + m[MeasurementType.BONE] = Kg(lib.boneMassKg.toFloat().coerceIn(0f, 10f)) + m[MeasurementType.LBM] = Kg(lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f)) + m[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f)) + m[MeasurementType.IMPEDANCE] = Ohm(pendingImpedance.toFloat()) logI( "body comp (StandardImpedanceLib, impedance=${pendingImpedance}Ω): " + - "fat=${m.fat}% water=${m.water}% muscle=${m.muscle}% " + - "bone=${m.bone}kg lbm=${m.lbm}kg bmr=${m.bmr}kcal" + "fat=${m[MeasurementType.BODY_FAT]}% water=${m[MeasurementType.WATER]}% muscle=${m[MeasurementType.MUSCLE]}% " + + "bone=${m[MeasurementType.BONE]}kg lbm=${m[MeasurementType.LBM]}kg bmr=${m[MeasurementType.BMR]}kcal" ) } - logI("publishing → weight=${m.weight}kg fat=${m.fat}% water=${m.water}% muscle=${m.muscle}% bmr=${m.bmr}kcal impedance=${m.impedance}Ω") + logI("publishing → weight=${m[MeasurementType.WEIGHT]}kg fat=${m[MeasurementType.BODY_FAT]}% water=${m[MeasurementType.WATER]}% muscle=${m[MeasurementType.MUSCLE]}% bmr=${m[MeasurementType.BMR]}kcal impedance=${m[MeasurementType.IMPEDANCE]}Ω") publish(m) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CustomOpenScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CustomOpenScaleHandler.kt index cc85a1df9..6bb770254 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CustomOpenScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/CustomOpenScaleHandler.kt @@ -17,6 +17,7 @@ */ package com.health.openscale.core.bluetooth.scales +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -27,6 +28,8 @@ import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.floor +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent class CustomOpenScaleHandler : ScaleDeviceHandler() { @@ -194,10 +197,10 @@ class CustomOpenScaleHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { dateTime = whenDate - this.weight = weight - this.fat = fat - this.water = water - this.muscle = muscle + this[MeasurementType.WEIGHT] = Kg(weight) + this[MeasurementType.BODY_FAT] = Percent(fat) + this[MeasurementType.WATER] = Percent(water) + this[MeasurementType.MUSCLE] = Percent(muscle) } publish(m) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DigooDGSO38HHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DigooDGSO38HHandler.kt index 2f2e2081c..03e0ac300 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DigooDGSO38HHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DigooDGSO38HHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.WeightUnit @@ -25,6 +26,8 @@ import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.UUID import kotlin.math.abs +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * Digoo DG-SO38H GATT handler (legacy: BluetoothDigooDGSO38H). @@ -137,7 +140,7 @@ class DigooDGSO38HHandler : ScaleDeviceHandler() { val fat = (((frame[6].toInt() and 0xFF) shl 8) or (frame[7].toInt() and 0xFF)) / 10.0f val m = ScaleMeasurement().apply { - this.weight = weight + this[MeasurementType.WEIGHT] = Kg(weight) dateTime = Date() } @@ -154,11 +157,11 @@ class DigooDGSO38HHandler : ScaleDeviceHandler() { // bone kg [18] /10 val bone = (frame[18].toInt() and 0xFF) / 10.0f - m.fat = fat - m.water = water - m.muscle = muscle - m.bone = bone - m.visceralFat = visceral + m[MeasurementType.BODY_FAT] = Percent(fat) + m[MeasurementType.WATER] = Percent(water) + m[MeasurementType.MUSCLE] = Percent(muscle) + m[MeasurementType.BONE] = Kg(bone) + m[MeasurementType.VISCERAL_FAT] = visceral } publish(m) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DrTrustSSW532Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DrTrustSSW532Handler.kt index 2bc6a664d..193da67b9 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DrTrustSSW532Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DrTrustSSW532Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib @@ -29,6 +30,10 @@ import kotlinx.coroutines.launch import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Dr. Trust SSW532 / ICOMON FG2211WB body composition scale. @@ -131,7 +136,7 @@ class DrTrustSSW532Handler : ScaleDeviceHandler() { if (fallback > 0f) { publish(ScaleMeasurement().apply { dateTime = Date() - weight = fallback + this[MeasurementType.WEIGHT] = Kg(fallback) }) } } @@ -195,7 +200,7 @@ class DrTrustSSW532Handler : ScaleDeviceHandler() { // User stepped off without picking up handles — publish saved weight and disconnect logD("step-off detected: publishing savedWeightKg=$savedWeightKg") bodyCompPublished = true - publish(ScaleMeasurement().apply { dateTime = Date(); weight = savedWeightKg }) + publish(ScaleMeasurement().apply { dateTime = Date(); this[MeasurementType.WEIGHT] = Kg(savedWeightKg) }) savedWeightKg = 0f writeTeardownAck() requestDisconnect() @@ -250,7 +255,7 @@ class DrTrustSSW532Handler : ScaleDeviceHandler() { bodyCompPublished = true publish(ScaleMeasurement().apply { dateTime = Date() - weight = savedWeightKg + this[MeasurementType.WEIGHT] = Kg(savedWeightKg) }) savedWeightKg = 0f writeTeardownAck() @@ -291,15 +296,15 @@ class DrTrustSSW532Handler : ScaleDeviceHandler() { bodyCompPublished = true publish(ScaleMeasurement().apply { dateTime = Date() - weight = pendingWeightKg - fat = fatPct.coerceIn(0f, 75f) - water = lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) - muscle = lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 99f) - bone = lib.boneMassKg.toFloat().coerceIn(0f, 10f) - bmr = lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) - lbm = lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f) - visceralFat = estimateVisceralFat(pendingWeightKg, z3, user.age, gender) - impedance = wholeBodyZ + this[MeasurementType.WEIGHT] = Kg(pendingWeightKg) + this[MeasurementType.BODY_FAT] = Percent(fatPct.coerceIn(0f, 75f)) + this[MeasurementType.WATER] = Percent(lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f)) + this[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 99f)) + this[MeasurementType.BONE] = Kg(lib.boneMassKg.toFloat().coerceIn(0f, 10f)) + this[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f)) + this[MeasurementType.LBM] = Kg(lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f)) + this[MeasurementType.VISCERAL_FAT] = estimateVisceralFat(pendingWeightKg, z3, user.age, gender) + this[MeasurementType.IMPEDANCE] = Ohm(wholeBodyZ.toFloat()) }) writeTeardownAck() requestDisconnect() diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt index e9231136b..d8dddba15 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt @@ -1,11 +1,13 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Calendar import java.util.UUID +import com.health.openscale.core.data.Kg /** * Handler for EEBBL Body Fat Scale (P1) - 8-electrode with handle. @@ -145,7 +147,7 @@ class EEBBLHandler : ScaleDeviceHandler() { } if (!weightPublished && stableCount >= REQUIRED_STABLE_COUNT && weight in 30f..250f) { - pendingMeasurement = ScaleMeasurement().apply { this.weight = weight } + pendingMeasurement = ScaleMeasurement().apply { this[MeasurementType.WEIGHT] = Kg(weight) } logI("*** STABLE WEIGHT DETECTED: $weight kg (raw=$raw) — publishing + disconnecting immediately so measurement is tied to the correct user (fixes assisted weighing / baby mode) ***") publish(pendingMeasurement!!) weightPublished = true @@ -230,9 +232,9 @@ class EEBBLHandler : ScaleDeviceHandler() { logI("*** RICH BODY-COMP INDICATION (alternative path) weight=$weight kg at idx=$weightStartIdx ***") if (pendingMeasurement == null) { - pendingMeasurement = ScaleMeasurement().apply { this.weight = weight } + pendingMeasurement = ScaleMeasurement().apply { this[MeasurementType.WEIGHT] = Kg(weight) } } else { - pendingMeasurement!!.weight = weight + pendingMeasurement!![MeasurementType.WEIGHT] = Kg(weight) } publish(pendingMeasurement!!) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ESCS20MHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ESCS20MHandler.kt index 9815cee85..366e8241c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ESCS20MHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ESCS20MHandler.kt @@ -17,6 +17,7 @@ */ package com.health.openscale.core.bluetooth.scales +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.TrisaBodyAnalyzeLib @@ -27,6 +28,9 @@ import java.util.Calendar import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Handler for ES-CS20M scales (Renpho / Lefu lineage). @@ -441,7 +445,7 @@ class ESCS20MHandler : ScaleDeviceHandler() { return } - acc.weight = weightKg + acc[MeasurementType.WEIGHT] = Kg(weightKg) LogManager.i(TAG, "Weight: ${"%.2f".format(weightKg)}kg BIA resistance: ${biaResistance}Ω") if (biaResistance > 0) { @@ -456,36 +460,24 @@ class ESCS20MHandler : ScaleDeviceHandler() { val calc = TrisaBodyAnalyzeLib(sex, user.age, user.bodyHeight) val imp = if (biaResistance < 410) 3.0f else 0.3f * (biaResistance - 400f) val fat = calc.getFat(weightKg, imp) - acc.impedance = biaResistance.toDouble() - acc.fat = fat - acc.muscle = calc.getMuscle(weightKg, imp) - acc.water = calc.getWater(weightKg, imp) - acc.bone = calc.getBone(weightKg, imp) - acc.lbm = weightKg * (100f - fat) / 100f - LogManager.i(TAG, "Body composition: fat=${acc.fat}% muscle=${acc.muscle}%") + acc[MeasurementType.IMPEDANCE] = Ohm(biaResistance.toFloat()) + acc[MeasurementType.BODY_FAT] = Percent(fat) + acc[MeasurementType.MUSCLE] = Percent(calc.getMuscle(weightKg, imp)) + acc[MeasurementType.WATER] = Percent(calc.getWater(weightKg, imp)) + acc[MeasurementType.BONE] = Kg(calc.getBone(weightKg, imp)) + acc[MeasurementType.LBM] = Kg(weightKg * (100f - fat) / 100f) + LogManager.i(TAG, "Body composition: fat=${acc[MeasurementType.BODY_FAT]}% muscle=${acc[MeasurementType.MUSCLE]}%") } acc.userId = user.id acc.dateTime = Date() - publish(snapshot(acc)) + // Snapshot: acc is reused and reset for the next weigh-in. + publish(acc.snapshot()) resetState() hasPublished = true // set after resetState so step-off STOPs are ignored } - private fun snapshot(m: ScaleMeasurement) = ScaleMeasurement().apply { - userId = m.userId - dateTime = m.dateTime - weight = m.weight - fat = m.fat - muscle = m.muscle - water = m.water - bone = m.bone - lbm = m.lbm - visceralFat = m.visceralFat - impedance = m.impedance - } - // ── Helpers ─────────────────────────────────────────────────────────────── private fun u16be(b: ByteArray, off: Int): Int { @@ -495,8 +487,8 @@ class ESCS20MHandler : ScaleDeviceHandler() { private fun resetState() { acc.apply { - userId = -1; dateTime = null; weight = 0f; fat = 0f; muscle = 0f - water = 0f; bone = 0f; lbm = 0f; visceralFat = 0f; impedance = 0.0 + userId = -1; dateTime = null + values.clear() } connPhase = PHASE_WAIT_PROFILE_ACK biaResistance = 0 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EbelterBodyFatB2Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EbelterBodyFatB2Handler.kt index 4d78af10c..37917f051 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EbelterBodyFatB2Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EbelterBodyFatB2Handler.kt @@ -17,11 +17,15 @@ */ package com.health.openscale.core.bluetooth.scales +import android.icu.util.Measure +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent class EbelterBodyFatB2Handler : ScaleDeviceHandler() { @@ -97,11 +101,11 @@ class EbelterBodyFatB2Handler : ScaleDeviceHandler() { val m = ScaleMeasurement() m.dateTime = Date() - m.weight = weightKg + m[MeasurementType.WEIGHT] = Kg(weightKg) // grasa solo si viene distinta de 0 (cuando no está activado, suele venir 0) if (fatRaw != 0) { - m.fat = fatRaw / 10.0f + m[MeasurementType.BODY_FAT] = Percent(fatRaw / 10.0f) } // ----------------------------- diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityESF551Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityESF551Handler.kt index 164e6fc46..15cf76d0b 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityESF551Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityESF551Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib @@ -25,6 +26,10 @@ import com.health.openscale.core.data.WeightUnit import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent // Based on https://github.com/ronnnnnnnnnnnnn/etekcity_esf551_ble @@ -118,9 +123,10 @@ class EtekcityESF551Handler : ScaleDeviceHandler() { val measurement = ScaleMeasurement( userId = user.id, dateTime = Date(), - weight = weightKg.toFloat(), - impedance = impedance, - ) + ).also { m -> + m[MeasurementType.WEIGHT] = Kg(weightKg.toFloat()) + m[MeasurementType.IMPEDANCE] = Ohm(impedance.toFloat()) + } if (impedance > 0 && impedance < 1500) { val lib = StandardImpedanceLib( @@ -130,11 +136,11 @@ class EtekcityESF551Handler : ScaleDeviceHandler() { heightM = user.bodyHeight / 100.0, impedance = impedance, ) - measurement.fat = lib.totalFatPercentage.toFloat() - measurement.water = lib.totalBodyWaterPercentage.toFloat() - measurement.muscle = lib.skeletalMusclePercentage.toFloat() - measurement.bone = lib.boneMassKg.toFloat() - measurement.bmr = lib.basalMetabolicRate.toFloat() + measurement[MeasurementType.BODY_FAT] = Percent(lib.totalFatPercentage.toFloat()) + measurement[MeasurementType.WATER] = Percent(lib.totalBodyWaterPercentage.toFloat()) + measurement[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat()) + measurement[MeasurementType.BONE] = Kg(lib.boneMassKg.toFloat()) + measurement[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat()) } if (data[20] == 1.toByte() && impedance > 0) { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityFit8SHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityFit8SHandler.kt index d416faba5..74a397e4e 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityFit8SHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EtekcityFit8SHandler.kt @@ -18,12 +18,17 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent // Based on https://github.com/ronnnnnnnnnnnnn/etekcity_esf551_ble @@ -122,29 +127,29 @@ class EtekcityFit8SHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { userId = user.id dateTime = Date() - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } val impedance = (payload[13].toInt() and 0xFF) or ((payload[14].toInt() and 0xFF) shl 8) if (impedance != 0) { - m.impedance = impedance.toDouble() + m[MeasurementType.IMPEDANCE] = Ohm(impedance.toFloat()) val lib = StandardImpedanceLib( gender = user.gender, age = user.age, - weightKg = m.weight.toDouble(), + weightKg = (m[MeasurementType.WEIGHT]?.value ?: 0f).toDouble(), heightM = user.bodyHeight / 100.0, - impedance = m.impedance + impedance = (m[MeasurementType.IMPEDANCE]?.value ?: 0f).toDouble() ) - m.fat = lib.totalFatPercentage.toFloat() - m.water = lib.totalBodyWaterPercentage.toFloat() - m.muscle = lib.skeletalMusclePercentage.toFloat() - m.bone = lib.boneMassKg.toFloat() - m.bmr = lib.basalMetabolicRate.toFloat() - m.lbm = lib.fatFreeMassKg.toFloat() + m[MeasurementType.BODY_FAT] = Percent(lib.totalFatPercentage.toFloat()) + m[MeasurementType.WATER] = Percent(lib.totalBodyWaterPercentage.toFloat()) + m[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat()) + m[MeasurementType.BONE] = Kg(lib.boneMassKg.toFloat()) + m[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat()) + m[MeasurementType.LBM] = Kg(lib.fatFreeMassKg.toFloat()) } else { logD("Impedance is missing or zero. Publishing weight only") } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyC20Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyC20Handler.kt index 4aacba9ec..bdd9828b8 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyC20Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyC20Handler.kt @@ -4,6 +4,7 @@ import android.bluetooth.le.ScanResult import android.util.SparseArray import androidx.core.util.isEmpty import androidx.core.util.size +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.MiScaleLib @@ -11,6 +12,10 @@ import com.health.openscale.core.data.GenderType import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.Locale +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent class EufyC20Handler : ScaleDeviceHandler() { private val MANUFACTURER_ID = 48228 @@ -76,19 +81,19 @@ class EufyC20Handler : ScaleDeviceHandler() { if (hasWeight && payload.size >= 14) { val rawWeight = ((payload[13].toInt() and 0xFF) shl 8) or (payload[12].toInt() and 0xFF) val weightKg = rawWeight / 100.0f - if (weightKg != 0.0f) m.weight = weightKg + if (weightKg != 0.0f) m[MeasurementType.WEIGHT] = Kg(weightKg) } if (hasHeartRate && payload.size >= 16) { val hr = payload[15].toInt() and 0xFF - if (hr != 0) m.heartRate = hr + if (hr != 0) m[MeasurementType.HEART_RATE] = Bpm(hr) } if (hasImpedance && payload.size >= 19) { val rawImp = ((payload[18].toInt() and 0xFF) shl 8) or (payload[17].toInt() and 0xFF) val imp = rawImp / 10.0 - if (imp != 0.0) m.impedance = imp + if (imp != 0.0) m[MeasurementType.IMPEDANCE] = Ohm(imp.toFloat()) } - if (!m.hasWeight() && m.impedance <= 0.0 && m.heartRate <= 0) { + if (!m.hasWeight() && (m[MeasurementType.IMPEDANCE]?.value ?: 0f) <= 0.0 && (m[MeasurementType.HEART_RATE]?.value ?: 0) <= 0) { return BroadcastAction.IGNORED } @@ -100,23 +105,23 @@ class EufyC20Handler : ScaleDeviceHandler() { // merge m into global stored (use local alias s, no !!) s.mergeWith(m) - if (m.hasWeight() && (s.weight != m.weight)) s.weight = m.weight + if (m.hasWeight() && ((s[MeasurementType.WEIGHT]?.value ?: 0f) != (m[MeasurementType.WEIGHT]?.value ?: 0f))) s[MeasurementType.WEIGHT] = Kg((m[MeasurementType.WEIGHT]?.value ?: 0f)) // compute composition if possible - if (s.hasWeight() && s.impedance > 0.0) { + if (s.hasWeight() && (s[MeasurementType.IMPEDANCE]?.value ?: 0f) > 0.0) { val sex = if (user.gender == GenderType.MALE) 1 else 0 val lib = MiScaleLib(sex, user.age, user.bodyHeight) - val wf = s.weight - s.fat = lib.getBodyFat(wf, s.impedance.toFloat()) - s.water = lib.getWater(wf, s.impedance.toFloat()) - s.muscle = lib.getMuscle(wf, s.impedance.toFloat()) - s.bone = lib.getBoneMass(wf, s.impedance.toFloat()) - s.lbm = lib.getLBM(wf, s.impedance.toFloat()) - s.visceralFat = lib.getVisceralFat(wf) + val wf = (s[MeasurementType.WEIGHT]?.value ?: 0f) + s[MeasurementType.BODY_FAT] = Percent(lib.getBodyFat(wf, (s[MeasurementType.IMPEDANCE]?.value ?: 0f).toFloat())) + s[MeasurementType.WATER] = Percent(lib.getWater(wf, (s[MeasurementType.IMPEDANCE]?.value ?: 0f).toFloat())) + s[MeasurementType.MUSCLE] = Percent(lib.getMuscle(wf, (s[MeasurementType.IMPEDANCE]?.value ?: 0f).toFloat())) + s[MeasurementType.BONE] = Kg(lib.getBoneMass(wf, (s[MeasurementType.IMPEDANCE]?.value ?: 0f).toFloat())) + s[MeasurementType.LBM] = Kg(lib.getLBM(wf, (s[MeasurementType.IMPEDANCE]?.value ?: 0f).toFloat())) + s[MeasurementType.VISCERAL_FAT] = lib.getVisceralFat(wf) } // if full measurement -> clear stored and stop - return if (s.hasWeight() && s.impedance > 0.0 && s.heartRate > 0) { + return if (s.hasWeight() && (s[MeasurementType.IMPEDANCE]?.value ?: 0f) > 0.0 && (s[MeasurementType.HEART_RATE]?.value ?: 0) > 0) { s.dateTime = Date() publish(s) stored = null diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyP2Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyP2Handler.kt index ede24aa7b..9cac453df 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyP2Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EufyP2Handler.kt @@ -25,6 +25,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.EufyAuthHandler @@ -33,6 +34,8 @@ import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm /** * Eufy Smart Scale P2 (T9148) and P2 Pro (T9149). @@ -105,9 +108,9 @@ class EufyP2Handler : ScaleDeviceHandler() { val reading = EufyP2Lib.parseWeightNotification(data) ?: return publish(ScaleMeasurement().apply { dateTime = Date() - weight = reading.weightKg + this[MeasurementType.WEIGHT] = Kg(reading.weightKg) userId = user.id - if (reading.impedanceOhm > 0) impedance = reading.impedanceOhm.toDouble() + if (reading.impedanceOhm > 0) this[MeasurementType.IMPEDANCE] = Ohm(reading.impedanceOhm.toFloat()) }) } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExcelvanCF36xHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExcelvanCF36xHandler.kt index 8797424bf..1faf81144 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExcelvanCF36xHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExcelvanCF36xHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.ActivityLevel @@ -27,6 +28,8 @@ import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.utils.ConverterUtils import java.util.UUID import kotlin.math.roundToInt +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * Excelvan CF36xBLE (a.k.a. "Electronic Scale") GATT handler. @@ -187,16 +190,16 @@ class ExcelvanCF36xHandler : ScaleDeviceHandler() { val weightKg = ConverterUtils.toKilogram(weightDeviceUnit, user.scaleUnit) val m = ScaleMeasurement().apply { - weight = weightKg - this.fat = fat - this.muscle = muscle - this.water = water - this.bone = bone - this.visceralFat = visceral + this[MeasurementType.WEIGHT] = Kg(weightKg) + this[MeasurementType.BODY_FAT] = Percent(fat) + this[MeasurementType.MUSCLE] = Percent(muscle) + this[MeasurementType.WATER] = Percent(water) + this[MeasurementType.BONE] = Kg(bone) + this[MeasurementType.VISCERAL_FAT] = visceral // timestamp: device does not provide; let app fill "now" on save if needed } - logD("publish kg=${m.weight} fat=${m.fat} water=${m.water} muscle=${m.muscle} bone=${m.bone} visc=${m.visceralFat}") + logD("publish kg=${m[MeasurementType.WEIGHT]} fat=${m[MeasurementType.BODY_FAT]} water=${m[MeasurementType.WATER]} muscle=${m[MeasurementType.MUSCLE]} bone=${m[MeasurementType.BONE]} visc=${m[MeasurementType.VISCERAL_FAT]}") publish(m) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExingtechY1Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExingtechY1Handler.kt index 06aa72130..4dbd3c15d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExingtechY1Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ExingtechY1Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -25,6 +26,8 @@ import com.health.openscale.core.utils.ConverterUtils import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * Handler for Exingtech Y1 scales (often advertising as "VScale"). @@ -124,14 +127,14 @@ class ExingtechY1Handler : ScaleDeviceHandler() { return ScaleMeasurement().apply { dateTime = Date() - this.weight = weight - this.fat = fat - this.water = water - this.muscle = muscle - this.bone = bone - this.visceralFat = visceralIndex + this[MeasurementType.WEIGHT] = Kg(weight) + this[MeasurementType.BODY_FAT] = Percent(fat) + this[MeasurementType.WATER] = Percent(water) + this[MeasurementType.MUSCLE] = Percent(muscle) + this[MeasurementType.BONE] = Kg(bone) + this[MeasurementType.VISCERAL_FAT] = visceralIndex }.also { - logD("VScale result kg=${it.weight} fat=${it.fat} water=${it.water} muscle=${it.muscle} bone=${it.bone} visc=${it.visceralFat}" + logD("VScale result kg=${it[MeasurementType.WEIGHT]} fat=${it[MeasurementType.BODY_FAT]} water=${it[MeasurementType.WATER]} muscle=${it[MeasurementType.MUSCLE]} bone=${it[MeasurementType.BONE]} visc=${it[MeasurementType.VISCERAL_FAT]}" ) } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandler.kt index 63684274f..dfb481e95 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/FitTrackDaraHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -29,6 +30,8 @@ import java.util.Calendar import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * FitTrack Dara 2.0 body-composition scale. @@ -261,13 +264,13 @@ class FitTrackDaraHandler : ScaleDeviceHandler() { /** Store the dump indices that map to a [ScaleMeasurement] field (see class docs for the full set). */ private fun applyDumpIndex(idx: Int, value: Int) { when (idx) { - 0 -> pending.weight = ConverterUtils.toKilogram(value / 10.0f, currentAppUser().scaleUnit) - 2 -> pending.fat = value / 10.0f // % - 4 -> pending.visceralFat = value.toFloat() // index (no ÷10) - 5 -> pending.lbm = value / 10.0f // kg - 7 -> pending.bone = value / 10.0f // kg - 8 -> pending.water = value / 10.0f // % - 10 -> pending.protein = value / 10.0f // % + 0 -> pending[MeasurementType.WEIGHT] = Kg.from(value / 10.0f, currentAppUser().scaleUnit) + 2 -> pending[MeasurementType.BODY_FAT] = Percent(value / 10.0f) // % + 4 -> pending[MeasurementType.VISCERAL_FAT] = value.toFloat() // index (no ÷10) + 5 -> pending[MeasurementType.LBM] = Kg(value / 10.0f) // kg + 7 -> pending[MeasurementType.BONE] = Kg(value / 10.0f) // kg + 8 -> pending[MeasurementType.WATER] = Percent(value / 10.0f) // % + 10 -> pending[MeasurementType.PROTEIN] = Percent(value / 10.0f) // % } } @@ -281,15 +284,15 @@ class FitTrackDaraHandler : ScaleDeviceHandler() { */ private fun publishFinal() { if (published) return - val weightKg = if (pending.weight > 0f) pending.weight else pendingWeightKg + val weightKg = if ((pending[MeasurementType.WEIGHT]?.value ?: 0f) > 0f) (pending[MeasurementType.WEIGHT]?.value ?: 0f) else pendingWeightKg if (weightKg <= 0f) return published = true dumpJob?.cancel() dumpJob = null - pending.weight = weightKg + pending[MeasurementType.WEIGHT] = Kg(weightKg) pending.dateTime = Date() - logD("publishing weight=${pending.weight} fat=${pending.fat} water=${pending.water} " + - "lbm=${pending.lbm} bone=${pending.bone} visceral=${pending.visceralFat} protein=${pending.protein}") + logD("publishing weight=${pending[MeasurementType.WEIGHT]} fat=${pending[MeasurementType.BODY_FAT]} water=${pending[MeasurementType.WATER]} " + + "lbm=${pending[MeasurementType.LBM]} bone=${pending[MeasurementType.BONE]} visceral=${pending[MeasurementType.VISCERAL_FAT]} protein=${pending[MeasurementType.PROTEIN]}") publish(pending) requestDisconnect() } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HealthKeep280Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HealthKeep280Handler.kt index c73e72b61..e8c6879c0 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HealthKeep280Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HealthKeep280Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib @@ -25,6 +26,10 @@ import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent class HealthKeep280Handler : ScaleDeviceHandler() { @@ -109,16 +114,16 @@ class HealthKeep280Handler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { this.userId = user.id - this.weight = weight + this[MeasurementType.WEIGHT] = Kg(weight) this.dateTime = Date() if (heartRate > 0) { - this.heartRate = heartRate + this[MeasurementType.HEART_RATE] = Bpm(heartRate) } } // Calcolo automatico di grasso, acqua, muscoli e ossa tramite la libreria BIA di openScale if (impedance > 0 && user.bodyHeight > 0 && user.age > 0) { - measurement.impedance = impedance.toDouble() + measurement[MeasurementType.IMPEDANCE] = Ohm(impedance.toFloat()) val impedanceLib = StandardImpedanceLib( gender = user.gender, @@ -128,10 +133,10 @@ class HealthKeep280Handler : ScaleDeviceHandler() { impedance = impedance.toDouble() ) - measurement.fat = impedanceLib.totalFatPercentage.toFloat().coerceIn(0f, 75f) - measurement.water = impedanceLib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) - measurement.muscle = impedanceLib.skeletalMusclePercentage.toFloat().coerceIn(0f, 99f) - measurement.bone = impedanceLib.boneMassKg.toFloat().coerceIn(0f, 10f) + measurement[MeasurementType.BODY_FAT] = Percent(impedanceLib.totalFatPercentage.toFloat().coerceIn(0f, 75f)) + measurement[MeasurementType.WATER] = Percent(impedanceLib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f)) + measurement[MeasurementType.MUSCLE] = Percent(impedanceLib.skeletalMusclePercentage.toFloat().coerceIn(0f, 99f)) + measurement[MeasurementType.BONE] = Kg(impedanceLib.boneMassKg.toFloat().coerceIn(0f, 10f)) } logI("Publishing final measurement: $weight kg, Impedance: $impedance Ohm, HR: $heartRate bpm") diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt index a0997927a..f62bed06d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt @@ -18,12 +18,15 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.max +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * Handler for Hesley scales that advertise as "YunChen". @@ -88,14 +91,14 @@ class HesleyHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { dateTime = Date() - this.weight = max(0f, weight) - this.fat = fat - this.muscle = muscle - this.water = water - this.bone = bone + this[MeasurementType.WEIGHT] = Kg(max(0f, weight)) + this[MeasurementType.BODY_FAT] = Percent(fat) + this[MeasurementType.MUSCLE] = Percent(muscle) + this[MeasurementType.WATER] = Percent(water) + this[MeasurementType.BONE] = Kg(bone) } - logD( "Hesley result kg=${m.weight} fat=${m.fat} water=${m.water} muscle=${m.muscle} bone=${m.bone}") + logD( "Hesley result kg=${m[MeasurementType.WEIGHT]} fat=${m[MeasurementType.BODY_FAT]} water=${m[MeasurementType.WATER]} muscle=${m[MeasurementType.MUSCLE]} bone=${m[MeasurementType.BONE]}") publish(m) } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107Handler.kt index f6cb73292..37d9567c1 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HoffenBbs8107Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.WeightUnit @@ -28,6 +29,8 @@ import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.max +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * Handler for the Hoffen BBS-8107 and its rebrands. @@ -231,18 +234,18 @@ class HoffenBbs8107Handler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { dateTime = Date() - this.weight = max(0f, weight) + this[MeasurementType.WEIGHT] = Kg(max(0f, weight)) } // If barefoot (contact) → extra composition values when (frame[5]) { 0x00.toByte() -> { - m.fat = ConverterUtils.fromUnsignedInt16Le(frame, 6) / 10.0f - m.water = ConverterUtils.fromUnsignedInt16Le(frame, 8) / 10.0f - m.muscle = ConverterUtils.fromUnsignedInt16Le(frame, 10) / 10.0f + m[MeasurementType.BODY_FAT] = Percent(ConverterUtils.fromUnsignedInt16Le(frame, 6) / 10.0f) + m[MeasurementType.WATER] = Percent(ConverterUtils.fromUnsignedInt16Le(frame, 8) / 10.0f) + m[MeasurementType.MUSCLE] = Percent(ConverterUtils.fromUnsignedInt16Le(frame, 10) / 10.0f) // Bone mass is returned in deci-kg at index 14 (single byte) - m.bone = (frame[14].toInt() and 0xFF) / 10.0f - m.visceralFat = ConverterUtils.fromUnsignedInt16Le(frame, 17) / 10.0f + m[MeasurementType.BONE] = Kg((frame[14].toInt() and 0xFF) / 10.0f) + m[MeasurementType.VISCERAL_FAT] = ConverterUtils.fromUnsignedInt16Le(frame, 17) / 10.0f } 0x04.toByte() -> { LogManager.d(TAG, "No impedance/contact data") diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt index ac472bccc..4bf63ae42 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiAhCh100Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -32,6 +33,9 @@ import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec import kotlin.math.min +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Huawei AH100 / CH100 body-fat scale handler. @@ -333,13 +337,13 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { val sm = ScaleMeasurement().apply { this.userId = m.userId this.dateTime = m.dateTime ?: Date() - this.weight = m.weightKg - this.fat = m.fatPct + this[MeasurementType.WEIGHT] = Kg(m.weightKg) + this[MeasurementType.BODY_FAT] = Percent(m.fatPct) // The scale reports impedance but the v2.5.4 reference doesn't // derive water/muscle/bone from it; openScale's existing // StandardImpedanceLib can be wired in later for that. if (m.impedanceOhm in 1..3999) { - this.impedance = m.impedanceOhm.toDouble() + this[MeasurementType.IMPEDANCE] = Ohm(m.impedanceOhm.toFloat()) } } publish(sm) @@ -416,7 +420,7 @@ class HuaweiAhCh100Handler : ScaleDeviceHandler() { * height — wrong but physiologically sane, and replaced by the first real reading. */ private fun profileWeightKg(user: ScaleUser): Float { - lastMeasurementFor(user.id)?.weight?.takeIf { it.isFinite() && it > 0f }?.let { return it } + lastMeasurementFor(user.id)?.get(MeasurementType.WEIGHT)?.value?.takeIf { it.isFinite() && it > 0f }?.let { return it } user.initialWeight.takeIf { it.isFinite() && it > 0f }?.let { return it } val heightM = user.bodyHeight / 100f return if (heightM.isFinite() && heightM > 0.5f) 22f * heightM * heightM else 70f diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt index 28771987f..0d9d87142 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.EtekcityLib @@ -33,6 +34,10 @@ import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec import kotlin.math.min +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Honor Smart Scale CH100S body fat scale (Chipsea CST34M97 chipset). @@ -264,10 +269,10 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { this.userId = userId this.dateTime = dt - this.weight = weight - this.fat = fat + this[MeasurementType.WEIGHT] = Kg(weight) + this[MeasurementType.BODY_FAT] = Percent(fat) if (impedance in 1..3999) { - this.impedance = impedance.toDouble() + this[MeasurementType.IMPEDANCE] = Ohm(impedance.toFloat()) // Water%, muscle%, bone, BMR, visceral fat are not sent by the scale. // Compute app-side from impedance using BIA formulas (Chipsea chipset). val user = currentAppUser() @@ -278,11 +283,11 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { heightM = user.bodyHeight.toDouble() / 100.0, impedance = impedance.toDouble() ) - this.water = lib.water.toFloat() - this.muscle = lib.skeletalMusclePercentage.toFloat() - this.bone = lib.boneMass.toFloat() - this.bmr = lib.basalMetabolicRate.toFloat() - this.visceralFat = lib.visceralFat.toFloat() + this[MeasurementType.WATER] = Percent(lib.water.toFloat()) + this[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat()) + this[MeasurementType.BONE] = Kg(lib.boneMass.toFloat()) + this[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat()) + this[MeasurementType.VISCERAL_FAT] = lib.visceralFat.toFloat() } } publish(m) @@ -328,7 +333,7 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { * weight, then a BMI-22 estimate from body height. */ private fun profileWeightKg(user: ScaleUser): Float { - lastMeasurementFor(user.id)?.weight?.takeIf { it.isFinite() && it > 0f }?.let { return it } + lastMeasurementFor(user.id)?.get(MeasurementType.WEIGHT)?.value?.takeIf { it.isFinite() && it > 0f }?.let { return it } user.initialWeight.takeIf { it.isFinite() && it > 0f }?.let { return it } val heightM = user.bodyHeight / 100f return if (heightM.isFinite() && heightM > 0.5f) 22f * heightM * heightM else 70f diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt index 9de66ae55..f7c5616c7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.HuaweiHagridSecretProvider @@ -55,6 +56,11 @@ import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.roundToInt +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Huawei/Honor Hagrid WSP scale handler. @@ -589,7 +595,7 @@ class HuaweiHagridWspHandler( } private fun buildUserInfo(user: ScaleUser): HuaweiHagridWspLib.HagridUserInfo { - val lastWeight = lastMeasurementFor(user.id)?.weight + val lastWeight = lastMeasurementFor(user.id)?.get(MeasurementType.WEIGHT)?.value val configuredWeight = when { lastWeight != null && lastWeight.isFinite() && lastWeight > 0f -> lastWeight user.initialWeight.isFinite() && user.initialWeight > 0f -> user.initialWeight @@ -803,21 +809,24 @@ class HuaweiHagridWspHandler( val measurement = ScaleMeasurement( userId = user.id, dateTime = dateTimeOverride ?: parsed.timestamp ?: Date(), - weight = parsed.weightKg, - fat = composition?.bodyFatPercent - ?: parsed.fatPercent.takeIf { it > 0f && it < 80f } - ?: 0f, - water = composition?.waterPercent ?: 0f, - muscle = composition?.skeletalMusclePercent ?: 0f, - visceralFat = composition?.visceralFatLevel ?: 0f, - bone = composition?.boneMineralKg ?: 0f, - lbm = composition?.leanBodyMassKg ?: 0f, - bmr = composition?.bmrKcal ?: 0f, - heartRate = parsed.heartRateBpm ?: 0, - impedance = high, - impedanceLow = publishedLow, - protein = composition?.proteinPercent ?: 0f, - ) + ).apply { + this[MeasurementType.WEIGHT] = Kg(parsed.weightKg) + this[MeasurementType.BODY_FAT] = Percent( + composition?.bodyFatPercent + ?: parsed.fatPercent.takeIf { it > 0f && it < 80f } + ?: 0f + ) + this[MeasurementType.WATER] = Percent(composition?.waterPercent ?: 0f) + this[MeasurementType.MUSCLE] = Percent(composition?.skeletalMusclePercent ?: 0f) + this[MeasurementType.VISCERAL_FAT] = composition?.visceralFatLevel ?: 0f + this[MeasurementType.BONE] = Kg(composition?.boneMineralKg ?: 0f) + this[MeasurementType.LBM] = Kg(composition?.leanBodyMassKg ?: 0f) + this[MeasurementType.BMR] = Kcal(composition?.bmrKcal ?: 0f) + parsed.heartRateBpm?.let { this[MeasurementType.HEART_RATE] = Bpm(it) } + this[MeasurementType.IMPEDANCE] = Ohm(high.toFloat()) + this[MeasurementType.IMPEDANCE_LOW] = Ohm(publishedLow.toFloat()) + this[MeasurementType.PROTEIN] = Percent(composition?.proteinPercent ?: 0f) + } logI( "Publishing Huawei Hagrid measurement: lowCount=${parsed.lowFrequencyImpedance.size} " + @@ -838,8 +847,7 @@ class HuaweiHagridWspHandler( ScaleMeasurement( userId = user.id, dateTime = parsed.timestamp ?: Date(), - weight = weight, - ) + ).apply { this[MeasurementType.WEIGHT] = Kg(weight) } ) } @@ -857,11 +865,12 @@ class HuaweiHagridWspHandler( ScaleMeasurement( userId = user.id, dateTime = parsed.timestamp ?: Date(), - weight = parsed.weightKg ?: 0f, - fat = parsed.fatPercent?.takeIf { it > 0f && it < 80f } ?: 0f, - muscle = parsed.musclePercent?.takeIf { it > 0f && it < 100f } ?: 0f, - impedance = parsed.impedanceOhm ?: 0.0, - ) + ).apply { + this[MeasurementType.WEIGHT] = Kg(parsed.weightKg ?: 0f) + this[MeasurementType.BODY_FAT] = Percent(parsed.fatPercent?.takeIf { it > 0f && it < 80f } ?: 0f) + this[MeasurementType.MUSCLE] = Percent(parsed.musclePercent?.takeIf { it > 0f && it < 100f } ?: 0f) + this[MeasurementType.IMPEDANCE] = Ohm((parsed.impedanceOhm ?: 0.0).toFloat()) + } ) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt index dc896b4f8..5896c4de1 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HumeDara2Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib @@ -27,6 +28,10 @@ import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.utils.ConverterUtils import java.util.UUID import kotlin.math.roundToInt +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Hume Health "Dara 2.0" body-composition scale. @@ -201,7 +206,7 @@ class HumeDara2Handler : ScaleDeviceHandler() { val weightKg = ConverterUtils.toKilogram(weightKgFromFrame(frame), user.scaleUnit) val impedanceOhms = impedanceOhmsFromFrame(frame) - val m = ScaleMeasurement().apply { weight = weightKg } + val m = ScaleMeasurement().apply { this[MeasurementType.WEIGHT] = Kg(weightKg) } if (impedanceOhms in PLAUSIBLE_IMPEDANCE_OHMS && user.bodyHeight > 0f) { val bia = StandardImpedanceLib( @@ -211,16 +216,16 @@ class HumeDara2Handler : ScaleDeviceHandler() { heightM = user.bodyHeight / 100.0, impedance = impedanceOhms ) - m.impedance = impedanceOhms - m.fat = bia.totalFatPercentage.toFloat() - m.water = bia.totalBodyWaterPercentage.toFloat() - m.muscle = bia.skeletalMusclePercentage.toFloat() - m.bone = bia.boneMassKg.toFloat() - m.bmr = bia.basalMetabolicRate.toFloat() - m.lbm = bia.fatFreeMassKg.toFloat() - logD("publish kg=${m.weight} impedance=$impedanceOhms fat=${m.fat} water=${m.water} muscle=${m.muscle} bone=${m.bone} bmr=${m.bmr} lbm=${m.lbm}") + m[MeasurementType.IMPEDANCE] = Ohm(impedanceOhms.toFloat()) + m[MeasurementType.BODY_FAT] = Percent(bia.totalFatPercentage.toFloat()) + m[MeasurementType.WATER] = Percent(bia.totalBodyWaterPercentage.toFloat()) + m[MeasurementType.MUSCLE] = Percent(bia.skeletalMusclePercentage.toFloat()) + m[MeasurementType.BONE] = Kg(bia.boneMassKg.toFloat()) + m[MeasurementType.BMR] = Kcal(bia.basalMetabolicRate.toFloat()) + m[MeasurementType.LBM] = Kg(bia.fatFreeMassKg.toFloat()) + logD("publish kg=${m[MeasurementType.WEIGHT]} impedance=$impedanceOhms fat=${m[MeasurementType.BODY_FAT]} water=${m[MeasurementType.WATER]} muscle=${m[MeasurementType.MUSCLE]} bone=${m[MeasurementType.BONE]} bmr=${m[MeasurementType.BMR]} lbm=${m[MeasurementType.LBM]}") } else { - logD("publish kg=${m.weight} impedance=$impedanceOhms out of plausible range, skipping body composition") + logD("publish kg=${m[MeasurementType.WEIGHT]} impedance=$impedanceOhms out of plausible range, skipping body composition") } publish(m) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/IHealthHS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/IHealthHS3Handler.kt index 279ec6d1c..286464cca 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/IHealthHS3Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/IHealthHS3Handler.kt @@ -17,11 +17,13 @@ */ package com.health.openscale.core.bluetooth.scales +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kg /** * iHealth HS3 (HS33FA4A) classic SPP handler. @@ -149,7 +151,7 @@ class IHealthHS3Handler : ScaleDeviceHandler() { } val m = ScaleMeasurement().apply { dateTime = Date() - this.weight = weight + this[MeasurementType.WEIGHT] = Kg(weight) } logD("Parsed weight: $weight kg") publish(m) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/InlifeHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/InlifeHandler.kt index 4446e812b..16b413aaf 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/InlifeHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/InlifeHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.ActivityLevel @@ -26,6 +27,9 @@ import java.util.Locale import java.util.UUID import kotlin.math.max import kotlin.math.min +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent class InlifeHandler : ScaleDeviceHandler() { @@ -184,13 +188,13 @@ class InlifeHandler : ScaleDeviceHandler() { } val m = ScaleMeasurement().apply { - this.weight = weight - this.fat = clamp(fatPct, 5.0, 80.0) - this.water = clamp(water, 5.0, 80.0) - this.muscle = clamp(muscle, 5.0, 80.0) - this.bone = clamp(boneKg, 0.5, 8.0) // bone mass in kg (legacy kept kg here) - this.lbm = lbm - this.visceralFat = clamp(visceral, 1.0, 50.0) + this[MeasurementType.WEIGHT] = Kg(weight) + this[MeasurementType.BODY_FAT] = Percent(clamp(fatPct, 5.0, 80.0)) + this[MeasurementType.WATER] = Percent(clamp(water, 5.0, 80.0)) + this[MeasurementType.MUSCLE] = Percent(clamp(muscle, 5.0, 80.0)) + this[MeasurementType.BONE] = Kg(clamp(boneKg, 0.5, 8.0)) // bone mass in kg (legacy kept kg here) + this[MeasurementType.LBM] = Kg(lbm) + this[MeasurementType.VISCERAL_FAT] = clamp(visceral, 1.0, 50.0) } publish(m) @@ -206,8 +210,8 @@ class InlifeHandler : ScaleDeviceHandler() { // Legacy left BIA as TODO; publish weight plus the raw impedance so body // composition can be computed/recomputed later. publish(ScaleMeasurement().apply { - this.weight = weight - if (impedance > 0) this.impedance = impedance.toDouble() + this[MeasurementType.WEIGHT] = Kg(weight) + if (impedance > 0) this[MeasurementType.IMPEDANCE] = Ohm(impedance.toFloat()) }) // (Optional) Hook a BIA library here if available later. sendCommand(CMD_FINISH) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt index a647218b7..b32ff4bc0 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.KeepS3BodyComposition @@ -31,6 +32,11 @@ import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.roundToInt +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** Pure codec for the capture-verified Keep S3 application protocol. */ internal object KeepS3Protocol { @@ -583,12 +589,11 @@ class KeepS3Handler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { userId = user.id dateTime = Date() - weight = weightKg - if (heartRateBpm > 0) heartRate = heartRateBpm - // openScale has no measurement type for the vendor impedance or the phase angles, - // so they are decoded and logged but not published. Re-enabling any of these needs - // a MeasurementTypeKey, a ScaleMeasurement field, a DB migration and BleConnector - // wiring. + this[MeasurementType.WEIGHT] = Kg(weightKg) + if (heartRateBpm > 0) this[MeasurementType.HEART_RATE] = Bpm(heartRateBpm) + // The vendor impedance and phase angles are decoded and logged but not + // published. Wiring any of them up is now handler-local: declare a + // MeasurementType.deviceFloat key and write it into the measurement. // if (deviceImpedanceOhm > 0) deviceImpedance = deviceImpedanceOhm.toDouble() // phaseAngle50Degrees?.takeIf { it.isFinite() && it > 0f }?.let { phaseAngle = it } // phaseAngle100Degrees?.takeIf { it.isFinite() && it > 0f }?.let { phaseAngleHigh = it } @@ -598,8 +603,8 @@ class KeepS3Handler : ScaleDeviceHandler() { if (hasDualFrequencyImpedance) { // ScaleMeasurement's established dual-band convention is high frequency in // impedance and low frequency in impedanceLow. Keep S3 reports 100/50 kHz. - impedance = compositionImpedance100Ohm.toDouble() - impedanceLow = compositionImpedance50Ohm.toDouble() + this[MeasurementType.IMPEDANCE] = Ohm(compositionImpedance100Ohm.toFloat()) + this[MeasurementType.IMPEDANCE_LOW] = Ohm(compositionImpedance50Ohm.toFloat()) } val composition = if (hasDualFrequencyImpedance) { @@ -628,20 +633,21 @@ class KeepS3Handler : ScaleDeviceHandler() { if (composition == null) { logW("Keep S3 body composition unavailable; missing or invalid dual-frequency input") } else { - fat = composition.bodyFatPercent - water = composition.waterPercent - visceralFat = composition.visceralFatLevel.toFloat() - bone = composition.boneKg - lbm = composition.fatFreeMassKg - bmr = composition.basalMetabolicRateKcal.toFloat() - protein = composition.proteinPercent + this[MeasurementType.BODY_FAT] = Percent(composition.bodyFatPercent) + this[MeasurementType.WATER] = Percent(composition.waterPercent) + this[MeasurementType.VISCERAL_FAT] = composition.visceralFatLevel.toFloat() + this[MeasurementType.BONE] = Kg(composition.boneKg) + this[MeasurementType.LBM] = Kg(composition.fatFreeMassKg) + this[MeasurementType.BMR] = Kcal(composition.basalMetabolicRateKcal.toFloat()) + this[MeasurementType.PROTEIN] = Percent(composition.proteinPercent) // openScale evaluates MUSCLE as a skeletal-muscle percentage (plausible range // 15-60%). Keep's broader "muscle" value is FFM minus bone and can exceed that // range, so publish the separately decoded skeletal-muscle percentage here. - muscle = composition.skeletalMusclePercent + this[MeasurementType.MUSCLE] = Percent(composition.skeletalMusclePercent) // Keep's broader composition.musclePercent remains decoded by the model but is // not published because openScale has no lean-soft-tissue measurement type. - // Not published — openScale has no measurement type for these. + // Not published yet — wiring them up would be handler-local now + // (MeasurementType.deviceFloat), but stays a deliberate follow-up. // subcutaneousFat = composition.subcutaneousFatPercent // bodyAge = composition.bodyAge // bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg @@ -691,7 +697,7 @@ class KeepS3Handler : ScaleDeviceHandler() { } logI("Keep S3 previous record uses matched protocol impedance=${impedanceOhm.roundToInt()}Ω") return KeepS3Protocol.PreviousRecord( - weightKg = previous.weight, + weightKg = (previous[MeasurementType.WEIGHT]?.value ?: 0f), timestampSeconds = (previous.dateTime?.time ?: 0L) / 1000L, impedanceOhm = impedanceOhm, ) @@ -705,7 +711,7 @@ class KeepS3Handler : ScaleDeviceHandler() { val timestampSeconds = measurement.dateTime?.time?.div(1000L) ?: return val encoded = KeepS3Protocol.serializeDeviceImpedance( timestampSeconds = timestampSeconds, - weightKg = measurement.weight, + weightKg = (measurement[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm = deviceImpedanceOhm, ) ?: return settingsPutString(KeepS3Protocol.deviceImpedanceSettingKey(userId), encoded) @@ -717,7 +723,7 @@ class KeepS3Handler : ScaleDeviceHandler() { settingsGetString(KeepS3Protocol.deviceImpedanceSettingKey(userId)), ) ?: return null return stored.impedanceOhm.toDouble().takeIf { - KeepS3Protocol.deviceImpedanceMatches(stored, timestampSeconds, previous.weight) + KeepS3Protocol.deviceImpedanceMatches(stored, timestampSeconds, (previous[MeasurementType.WEIGHT]?.value ?: 0f)) } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MGBHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MGBHandler.kt index a85909062..53190934c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MGBHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MGBHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib @@ -26,6 +27,10 @@ import com.health.openscale.core.utils.LogManager import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * SWAN / Icomon / YG (aka "MGB") handler. @@ -202,13 +207,13 @@ class MGBHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { userId = user.id dateTime = Date() - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } // The scale reports only weight + raw impedance; derive body composition the same way // VitafitVT701Handler does for its weight+impedance scales. if (impedanceOhm in 1 until 1500) { - m.impedance = impedanceOhm.toDouble() + m[MeasurementType.IMPEDANCE] = Ohm(impedanceOhm.toFloat()) val lib = StandardImpedanceLib( gender = user.gender, age = user.age, @@ -216,11 +221,11 @@ class MGBHandler : ScaleDeviceHandler() { heightM = user.bodyHeight / 100.0, impedance = impedanceOhm.toDouble(), ) - m.fat = lib.totalFatPercentage.toFloat() - m.water = lib.totalBodyWaterPercentage.toFloat() - m.muscle = lib.skeletalMusclePercentage.toFloat() - m.bone = lib.boneMassKg.toFloat() - m.bmr = lib.basalMetabolicRate.toFloat() + m[MeasurementType.BODY_FAT] = Percent(lib.totalFatPercentage.toFloat()) + m[MeasurementType.WATER] = Percent(lib.totalBodyWaterPercentage.toFloat()) + m[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat()) + m[MeasurementType.BONE] = Kg(lib.boneMassKg.toFloat()) + m[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat()) } publish(m) @@ -272,19 +277,19 @@ class MGBHandler : ScaleDeviceHandler() { p += 6 // weight (uint16 LE * 0.1) - m.weight = readDeciBE(d, p); p += 2 + m[MeasurementType.WEIGHT] = Kg(readDeciBE(d, p)); p += 2 // BMI (ignored) /* val bmi = readDeciLE(d, p); */ p += 2 // fat % - m.fat = readDeciBE(d, p); p += 2 + m[MeasurementType.BODY_FAT] = Percent(readDeciBE(d, p)); p += 2 // two unknown bytes p += 2 pending = m - LogManager.d("MgbHandler", "first frame -> weight=${m.weight}, fat=${m.fat}") + LogManager.d("MgbHandler", "first frame -> weight=${m[MeasurementType.WEIGHT]}, fat=${m[MeasurementType.BODY_FAT]}") } /** @@ -297,10 +302,10 @@ class MGBHandler : ScaleDeviceHandler() { var p = 2 // after 01 00 header - m.muscle = readDeciLE(d, p); p += 2 + m[MeasurementType.MUSCLE] = Percent(readDeciLE(d, p)); p += 2 /* val bmr = readDeciLE(d, p); */ p += 2 - m.bone = readDeciLE(d, p); p += 2 - m.water = readDeciLE(d, p); p += 2 + m[MeasurementType.BONE] = Kg(readDeciLE(d, p)); p += 2 + m[MeasurementType.WATER] = Percent(readDeciLE(d, p)); p += 2 /* val age = d[p].toUByte().toInt(); */ p += 1 /* val protein = readDeciLE(d, p); */ p += 2 // Skip remaining bytes (unknown/padding) @@ -308,7 +313,7 @@ class MGBHandler : ScaleDeviceHandler() { publish(m) pending = null - LogManager.d("MgbHandler", "second frame -> muscle=${m.muscle}, bone=${m.bone}, water=${m.water} → published") + LogManager.d("MgbHandler", "second frame -> muscle=${m[MeasurementType.MUSCLE]}, bone=${m[MeasurementType.BONE]}, water=${m[MeasurementType.WATER]} → published") } /** Read uint16 little-endian and scale by 0.1f. */ diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MedisanaBs44xHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MedisanaBs44xHandler.kt index b2bb53cdc..87a98179c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MedisanaBs44xHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MedisanaBs44xHandler.kt @@ -18,12 +18,15 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.abs +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent class MedisanaBs44xHandler : ScaleDeviceHandler() { @@ -139,7 +142,7 @@ class MedisanaBs44xHandler : ScaleDeviceHandler() { val m = current ?: ScaleMeasurement().also { current = it } m.dateTime = Date(tsSec * 1000) - m.weight = weightKg + m[MeasurementType.WEIGHT] = Kg(weightKg) } // Feature frame: @@ -149,10 +152,10 @@ class MedisanaBs44xHandler : ScaleDeviceHandler() { if (d.size < 16) return val m = current ?: ScaleMeasurement().also { current = it } - m.fat = decode12bitTenth(d, 8) - m.water = decode12bitTenth(d, 10) - m.muscle = decode12bitTenth(d, 12) - m.bone = decode12bitTenth(d, 14) + m[MeasurementType.BODY_FAT] = Percent(decode12bitTenth(d, 8)) + m[MeasurementType.WATER] = Percent(decode12bitTenth(d, 10)) + m[MeasurementType.MUSCLE] = Percent(decode12bitTenth(d, 12)) + m[MeasurementType.BONE] = Kg(decode12bitTenth(d, 14)) } private fun decode12bitTenth(d: ByteArray, off: Int): Float { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt index f65b65d73..ee4a2f4ea 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.BodyMiScaleLib @@ -52,6 +53,10 @@ import java.util.Date import java.util.Locale import java.util.TimeZone import java.util.UUID +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Xiaomi Mi Scale (v1/v2) handler for the modern GATT stack. @@ -436,7 +441,7 @@ class MiScaleHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { dateTime = dt - weight = ConverterUtils.toKilogram(native, user.scaleUnit) + this[MeasurementType.WEIGHT] = Kg.from(native, user.scaleUnit) userId = user.id } @@ -444,7 +449,7 @@ class MiScaleHandler : ScaleDeviceHandler() { val imp = ((d[10].toInt() and 0xFF) shl 8) or (d[9].toInt() and 0xFF) if (imp > 0) { // Store the raw impedance so body composition can be recomputed later. - m.impedance = imp.toDouble() + m[MeasurementType.IMPEDANCE] = Ohm(imp.toFloat()) when (readBodyCompAlgorithm()) { BodyCompAlgorithm.XIAOMI -> applyXiaomiComposition(m, imp.toFloat(), user) BodyCompAlgorithm.BODYMISCALE_SCIENCE -> applyBodyMiScaleComposition(m, imp.toFloat(), user) @@ -462,12 +467,12 @@ class MiScaleHandler : ScaleDeviceHandler() { private fun applyXiaomiComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { val sex = if (user.gender == GenderType.MALE) 1 else 0 val lib = MiScaleLib(sex, user.age, user.bodyHeight) - m.water = lib.getWater(m.weight, impedance) - m.visceralFat = lib.getVisceralFat(m.weight) - m.fat = lib.getBodyFat(m.weight, impedance) - m.muscle = lib.getMuscle(m.weight, impedance) - m.lbm = lib.getLBM(m.weight, impedance) - m.bone = lib.getBoneMass(m.weight, impedance) + m[MeasurementType.WATER] = Percent(lib.getWater((m[MeasurementType.WEIGHT]?.value ?: 0f), impedance)) + m[MeasurementType.VISCERAL_FAT] = lib.getVisceralFat((m[MeasurementType.WEIGHT]?.value ?: 0f)) + m[MeasurementType.BODY_FAT] = Percent(lib.getBodyFat((m[MeasurementType.WEIGHT]?.value ?: 0f), impedance)) + m[MeasurementType.MUSCLE] = Percent(lib.getMuscle((m[MeasurementType.WEIGHT]?.value ?: 0f), impedance)) + m[MeasurementType.LBM] = Kg(lib.getLBM((m[MeasurementType.WEIGHT]?.value ?: 0f), impedance)) + m[MeasurementType.BONE] = Kg(lib.getBoneMass((m[MeasurementType.WEIGHT]?.value ?: 0f), impedance)) } /** @@ -480,19 +485,19 @@ class MiScaleHandler : ScaleDeviceHandler() { private fun applyBodyMiScaleComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { val lib = BodyMiScaleLib(user.gender, user.age, user.bodyHeight) - val lbmKg = lib.getLbm(m.weight, impedance) - val fatPct = lib.getFat(m.weight, lbmKg) + val lbmKg = lib.getLbm((m[MeasurementType.WEIGHT]?.value ?: 0f), impedance) + val fatPct = lib.getFat((m[MeasurementType.WEIGHT]?.value ?: 0f), lbmKg) val boneKg = lib.getBoneMass(lbmKg) - val muscleKg = lib.getMuscleMass(m.weight, fatPct, boneKg) - - m.fat = fatPct - m.water = lib.getWater(fatPct) - m.muscle = if (m.weight > 0f) muscleKg / m.weight * 100f else 0f - m.lbm = lbmKg - m.bone = boneKg - m.protein = lib.getProtein(m.weight, lbmKg) - m.bmr = lib.getBmr(m.weight) - m.visceralFat = lib.getVisceralFat(m.weight) + val muscleKg = lib.getMuscleMass((m[MeasurementType.WEIGHT]?.value ?: 0f), fatPct, boneKg) + + m[MeasurementType.BODY_FAT] = Percent(fatPct) + m[MeasurementType.WATER] = Percent(lib.getWater(fatPct)) + m[MeasurementType.MUSCLE] = Percent(if ((m[MeasurementType.WEIGHT]?.value ?: 0f) > 0f) muscleKg / (m[MeasurementType.WEIGHT]?.value ?: 0f) * 100f else 0f) + m[MeasurementType.LBM] = Kg(lbmKg) + m[MeasurementType.BONE] = Kg(boneKg) + m[MeasurementType.PROTEIN] = Percent(lib.getProtein((m[MeasurementType.WEIGHT]?.value ?: 0f), lbmKg)) + m[MeasurementType.BMR] = Kcal(lib.getBmr((m[MeasurementType.WEIGHT]?.value ?: 0f))) + m[MeasurementType.VISCERAL_FAT] = lib.getVisceralFat((m[MeasurementType.WEIGHT]?.value ?: 0f)) } /** History record (10 bytes): [status][weightLE(2)][yearLE(2)][mon][day][h][m][s] */ @@ -529,7 +534,7 @@ class MiScaleHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { dateTime = dt - weight = ConverterUtils.toKilogram(native, user.scaleUnit) + this[MeasurementType.WEIGHT] = Kg.from(native, user.scaleUnit) userId = user.id } publish(m) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt index 780bbca2a..eece9b789 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt @@ -42,6 +42,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.UnitType +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.BmrFormula @@ -56,6 +59,11 @@ import java.util.Date import java.util.Locale import kotlin.text.isNotEmpty import kotlin.text.lowercase +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Xiaomi Body Composition Scale S400 handler. @@ -79,6 +87,22 @@ class MiScaleS400Handler : ScaleDeviceHandler() { companion object { // Settings keys for S400 configuration + // Flat paths on purpose: the derived CSV column stays the ECW / ICW / BCM these + // quantities exported as when they were predefined. Colour, icon and unit match + // their former seeds, so nothing changes visually for anyone upgrading. + val EXTRACELLULAR_WATER = MeasurementType.devicePercent( + "ecw", R.string.measurement_type_ecw, + icon = MeasurementTypeIcon.IC_M_SCATTER_PLOT, color = 0xFF4FC3F7.toInt() + ) + val INTRACELLULAR_WATER = MeasurementType.devicePercent( + "icw", R.string.measurement_type_icw, + icon = MeasurementTypeIcon.IC_M_BUBBLE_CHART, color = 0xFF0288D1.toInt() + ) + val BODY_CELL_MASS = MeasurementType.deviceKg( + "bcm", R.string.measurement_type_bcm, + icon = MeasurementTypeIcon.IC_M_HIVE, color = 0xFF558B2F.toInt() + ) + private const val SETTINGS_KEY_BIND_KEY = "s400_bind_key" private const val SETTINGS_KEY_MAC_ADDRESS = "s400_mac_address" private const val SETTINGS_KEY_BONE_FORMULA = "s400_bone_formula" @@ -349,23 +373,27 @@ class MiScaleS400Handler : ScaleDeviceHandler() { val scaleMeasurement = ScaleMeasurement().apply { dateTime = Date() - weight = finalized.weightKg userId = user.id - heartRate = finalized.heartRate ?: 0 - impedance = finalized.impedanceHigh.toDouble() - finalized.impedanceLow?.let { impedanceLow = it.toDouble() } - - fat = composition.bfPct ?: 0f - water = composition.tbwPct ?: 0f - muscle = composition.smmPct ?: 0f - bone = composition.boneKg ?: 0f - lbm = composition.ffmKg ?: 0f - visceralFat = composition.vfi ?: 0f - bmr = composition.bmrKcal ?: 0f - ecw = composition.ecwPct ?: 0f - icw = composition.icwPct ?: 0f - protein = composition.proteinPct ?: 0f - bcm = composition.bcmKg ?: 0f + this[MeasurementType.WEIGHT] = Kg(finalized.weightKg) + finalized.heartRate?.let { this[MeasurementType.HEART_RATE] = Bpm(it) } + this[MeasurementType.IMPEDANCE] = Ohm(finalized.impedanceHigh.toFloat()) + finalized.impedanceLow?.let { this[MeasurementType.IMPEDANCE_LOW] = Ohm(it.toFloat()) } + + this[MeasurementType.BODY_FAT] = Percent(composition.bfPct ?: 0f) + this[MeasurementType.WATER] = Percent(composition.tbwPct ?: 0f) + this[MeasurementType.MUSCLE] = Percent(composition.smmPct ?: 0f) + this[MeasurementType.BONE] = Kg(composition.boneKg ?: 0f) + this[MeasurementType.LBM] = Kg(composition.ffmKg ?: 0f) + this[MeasurementType.VISCERAL_FAT] = composition.vfi ?: 0f + this[MeasurementType.BMR] = Kcal(composition.bmrKcal ?: 0f) + this[MeasurementType.PROTEIN] = Percent(composition.proteinPct ?: 0f) + + // Dual-frequency BIA outputs, contributed as device values: the S400 is the + // only scale in the catalogue measuring at two frequencies, and a future one + // reuses these identities. + composition.ecwPct?.let { this[EXTRACELLULAR_WATER] = Percent(it) } + composition.icwPct?.let { this[INTRACELLULAR_WATER] = Percent(it) } + composition.bcmKg?.let { this[BODY_CELL_MASS] = Kg(it) } } publish(scaleMeasurement) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt index 91dd57d73..fff8c34c9 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt @@ -31,7 +31,7 @@ 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.MeasurementType import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.User @@ -64,6 +64,8 @@ import java.util.Date import java.util.concurrent.ConcurrentHashMap import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent // ------------------------------------------------------------------------------------------------- // Shared tuning for BLE pacing & retry (used by GATT adapter). @@ -476,18 +478,20 @@ abstract class ModernScaleAdapter( 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 - ) - } + // Resolve the weight type row first: goals reference the row id, and + // the pre-16 code compared it against the enum's ordinal, which only + // worked because WEIGHT happened to be seeded as row 1. + val weightRow = measurementFacade.getAllMeasurementTypes().first() + .find { it.key == MeasurementType.WEIGHT } + val goalWeightGoal = weightRow?.let { row -> + userGoals.find { it.measurementTypeId == row.id } + } + if (goalWeightGoal != null && weightRow != null) { + goalWeight = ConverterUtils.convertFloatValueUnit( + value = goalWeightGoal.goalValue, + fromUnit = weightRow.unit, + toUnit = UnitType.KG + ) } val allMeasurements = measurementFacade.getMeasurementsForUser(u.id).first() @@ -495,7 +499,7 @@ abstract class ModernScaleAdapter( val oldestWeightMeasurementValue = allMeasurements .sortedBy { it.measurement.timestamp } .firstNotNullOfOrNull { measurementWithValues -> - measurementWithValues.values.find { it.type.key == MeasurementTypeKey.WEIGHT } + measurementWithValues.values.find { it.type.key == MeasurementType.WEIGHT } } if (oldestWeightMeasurementValue != null) { @@ -508,7 +512,7 @@ abstract class ModernScaleAdapter( val allTypes = measurementFacade.getAllMeasurementTypes().first() - val weightType = allTypes.find { it.key == MeasurementTypeKey.WEIGHT } + val weightType = allTypes.find { it.key == MeasurementType.WEIGHT } if (weightType != null) { scaleUnit = weightType.unit.toWeightUnit() } @@ -522,16 +526,16 @@ abstract class ModernScaleAdapter( runCatching { m.userId = mwv.measurement.userId } runCatching { m.dateTime = Date(mwv.measurement.timestamp) } - fun valueOf(key: MeasurementTypeKey): MeasurementValue? = + fun valueOf(key: com.health.openscale.core.data.MeasurementType.Key<*>): 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 } + valueOf(MeasurementType.WEIGHT)?.let { m[MeasurementType.WEIGHT] = Kg(it.floatValue ?: 0f) } + valueOf(MeasurementType.BODY_FAT)?.let { m[MeasurementType.BODY_FAT] = Percent(it.floatValue ?: 0f) } + valueOf(MeasurementType.WATER)?.let { m[MeasurementType.WATER] = Percent(it.floatValue ?: 0f) } + valueOf(MeasurementType.MUSCLE)?.let { m[MeasurementType.MUSCLE] = Percent(it.floatValue ?: 0f) } + valueOf(MeasurementType.VISCERAL_FAT)?.let { m[MeasurementType.VISCERAL_FAT] = it.floatValue ?: 0f } + valueOf(MeasurementType.LBM)?.let { m[MeasurementType.LBM] = Kg(it.floatValue ?: 0f) } + valueOf(MeasurementType.BONE)?.let { m[MeasurementType.BONE] = Kg(it.floatValue ?: 0f) } return m } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt index c3682b8ee..210d2baaf 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt @@ -19,12 +19,17 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import android.util.SparseArray +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.OkOkV2Lib import com.health.openscale.core.data.WeightUnit import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.utils.ConverterUtils +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Unified OKOK broadcast handler with dynamic display name: @@ -130,18 +135,18 @@ class OkOkHandler : ScaleDeviceHandler() { parseV20(m)?.let { (kg, imp) -> publish(ScaleMeasurement().apply { userId = user.id - weight = kg + this[MeasurementType.WEIGHT] = Kg(kg) // Store the raw impedance so body composition can be computed/recomputed later. - if (imp > 0f) impedance = imp.toDouble() + if (imp > 0f) this[MeasurementType.IMPEDANCE] = Ohm(imp) }) return BroadcastAction.CONSUMED_STOP } parseV11(m)?.let { kg -> - publish(ScaleMeasurement().apply { userId = user.id; weight = kg }) + publish(ScaleMeasurement().apply { userId = user.id; this[MeasurementType.WEIGHT] = Kg(kg) }) return BroadcastAction.CONSUMED_STOP } parseVF0(m)?.let { kg -> - publish(ScaleMeasurement().apply { userId = user.id; weight = kg }) + publish(ScaleMeasurement().apply { userId = user.id; this[MeasurementType.WEIGHT] = Kg(kg) }) return BroadcastAction.CONSUMED_STOP } @@ -149,19 +154,19 @@ class OkOkHandler : ScaleDeviceHandler() { parseC0(m)?.let { (kg, imp) -> publish(ScaleMeasurement().apply { userId = user.id - weight = kg + this[MeasurementType.WEIGHT] = Kg(kg) if (user.age <= 5 || imp == 0f) return@apply val lib = OkOkV2Lib(user.age, if (user.gender.isMale()) 1 else 0, user.bodyHeight) - water = lib.getWater(kg, imp) - visceralFat = lib.getVisceralFat(kg, imp) - fat = lib.getBodyFat(kg, imp) - muscle = lib.getMuscle(kg, imp) - lbm = lib.getLBM(kg, imp) - bone = lib.getBoneMass(kg, imp) - bmr = lib.getBMR(kg) - protein = lib.getProtein(kg, imp) - impedance = imp.toDouble() + this[MeasurementType.WATER] = Percent(lib.getWater(kg, imp)) + this[MeasurementType.VISCERAL_FAT] = lib.getVisceralFat(kg, imp) + this[MeasurementType.BODY_FAT] = Percent(lib.getBodyFat(kg, imp)) + this[MeasurementType.MUSCLE] = Percent(lib.getMuscle(kg, imp)) + this[MeasurementType.LBM] = Kg(lib.getLBM(kg, imp)) + this[MeasurementType.BONE] = Kg(lib.getBoneMass(kg, imp)) + this[MeasurementType.BMR] = Kcal(lib.getBMR(kg)) + this[MeasurementType.PROTEIN] = Percent(lib.getProtein(kg, imp)) + this[MeasurementType.IMPEDANCE] = Ohm(imp) }) return BroadcastAction.CONSUMED_STOP } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt index 454016b31..f6fcb8cad 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.OmronBodyCompositionLib @@ -32,6 +33,9 @@ import java.text.DateFormat import java.util.Locale import java.util.UUID import kotlin.math.min +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * Handler for Omron body composition monitors that speak the WLC ("Wellness Link") transfer @@ -492,12 +496,13 @@ class OmronWlcHandler : ScaleDeviceHandler() { private fun OmronBodyCompositionLib.Record.toMeasurement(userId: Int) = ScaleMeasurement( userId = userId, dateTime = timestamp, - weight = weightKg, - fat = bodyFatPercent ?: 0f, - muscle = skeletalMusclePercent ?: 0f, - visceralFat = visceralFatLevel ?: 0f, - bmr = bmrKcal?.toFloat() ?: 0f - ) + ).also { m -> + m[MeasurementType.WEIGHT] = Kg(weightKg) + m[MeasurementType.BODY_FAT] = Percent(bodyFatPercent ?: 0f) + m[MeasurementType.MUSCLE] = Percent(skeletalMusclePercent ?: 0f) + m[MeasurementType.VISCERAL_FAT] = visceralFatLevel ?: 0f + m[MeasurementType.BMR] = Kcal(bmrKcal?.toFloat() ?: 0f) + } // ---- chunked EEPROM reads ------------------------------------------------------------------ diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt index b918a46c3..e7ef37e15 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.OneByoneLib @@ -27,6 +28,9 @@ import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Calendar import java.util.UUID import kotlin.math.max +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * OneByone (classic) handler (Service 0xFFF0, notify on 0xFFF4, write cmds on 0xFFF1). @@ -214,20 +218,20 @@ class OneByoneHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { userId = user.id dateTime = if (hasTimestamp) whenCal.time else Calendar.getInstance().time - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) // Store the raw impedance so body composition can be recomputed later. - impedance = impedanceOhm.toDouble() + this[MeasurementType.IMPEDANCE] = Ohm(impedanceOhm.toFloat()) } try { // Derivations - val fatPct = lib.getBodyFat(m.weight, impedanceOhm) - m.fat = fatPct - m.water = lib.getWater(fatPct) - m.bone = lib.getBoneMass(m.weight, impedanceOhm) - m.visceralFat = lib.getVisceralFat(m.weight) - m.muscle = lib.getMuscle(m.weight, impedanceOhm) - m.lbm = lib.getLBM(m.weight, m.fat) + val fatPct = lib.getBodyFat((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm) + m[MeasurementType.BODY_FAT] = Percent(fatPct) + m[MeasurementType.WATER] = Percent(lib.getWater(fatPct)) + m[MeasurementType.BONE] = Kg(lib.getBoneMass((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) + m[MeasurementType.VISCERAL_FAT] = lib.getVisceralFat((m[MeasurementType.WEIGHT]?.value ?: 0f)) + m[MeasurementType.MUSCLE] = Percent(lib.getMuscle((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) + m[MeasurementType.LBM] = Kg(lib.getLBM((m[MeasurementType.WEIGHT]?.value ?: 0f), (m[MeasurementType.BODY_FAT]?.value ?: 0f))) publish(m) } catch (t: Throwable) { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneNewHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneNewHandler.kt index 2d544f93f..018d05486 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneNewHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneNewHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.OneByoneNewLib @@ -26,6 +27,9 @@ import com.health.openscale.core.utils.ConverterUtils import java.util.Date import java.util.UUID import kotlin.math.roundToInt +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * OneByone (new) handler @@ -116,7 +120,7 @@ class OneByoneNewHandler : ScaleDeviceHandler() { pending = ScaleMeasurement().apply { this.userId = user.id this.dateTime = Date() - this.weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } } @@ -131,7 +135,7 @@ class OneByoneNewHandler : ScaleDeviceHandler() { populateBodyComp(m, imp, currentAppUser()) // Store the raw impedance so body composition can be recomputed later. - m.impedance = imp.toDouble() + m[MeasurementType.IMPEDANCE] = Ohm(imp.toFloat()) publish(m) pending = null @@ -158,11 +162,11 @@ class OneByoneNewHandler : ScaleDeviceHandler() { val hist = ScaleMeasurement().apply { userId = user.id dateTime = ts - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } populateBodyComp(hist, imp, currentAppUser()) // Store the raw impedance so body composition can be recomputed later. - hist.impedance = imp.toDouble() + hist[MeasurementType.IMPEDANCE] = Ohm(imp.toFloat()) publish(hist) } @@ -181,12 +185,12 @@ class OneByoneNewHandler : ScaleDeviceHandler() { val lib = OneByoneNewLib(gender, u.age, heightForLib, u.activityLevel.toInt()) - m.fat = lib.getBodyFatPercentage(m.weight, impedanceOhm) - m.water = lib.getWaterPercentage(m.weight, impedanceOhm) - m.bone = lib.getBoneMass(m.weight, impedanceOhm) - m.visceralFat = lib.getVisceralFat(m.weight) - m.muscle = lib.getSkeletonMusclePercentage(m.weight, impedanceOhm) - m.lbm = lib.getLBM(m.weight, impedanceOhm) + m[MeasurementType.BODY_FAT] = Percent(lib.getBodyFatPercentage((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) + m[MeasurementType.WATER] = Percent(lib.getWaterPercentage((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) + m[MeasurementType.BONE] = Kg(lib.getBoneMass((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) + m[MeasurementType.VISCERAL_FAT] = lib.getVisceralFat((m[MeasurementType.WEIGHT]?.value ?: 0f)) + m[MeasurementType.MUSCLE] = Percent(lib.getSkeletonMusclePercentage((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) + m[MeasurementType.LBM] = Kg(lib.getLBM((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) } // ---- Outbound commands ---------------------------------------------------- @@ -237,7 +241,7 @@ class OneByoneNewHandler : ScaleDeviceHandler() { val u = users[idx] val last = lastMeasurementFor(u.id) - val weight = last?.weight ?: 0f + val weight = last?.get(MeasurementType.WEIGHT)?.value ?: 0f val imp = if (last != null) getImpedanceFromLBM(u, last) else 0 setMeasurementEntry( @@ -284,7 +288,7 @@ class OneByoneNewHandler : ScaleDeviceHandler() { // seed with current user's last known values (if any) val last = lastMeasurementFor(user.id) - val weight = last?.weight ?: 0f + val weight = last?.get(MeasurementType.WEIGHT)?.value ?: 0f val imp = if (last != null) getImpedanceFromLBM(user, last) else 0 setMeasurementEntry( @@ -401,10 +405,10 @@ class OneByoneNewHandler : ScaleDeviceHandler() { * estimate it from LBM the app already has. */ private fun getImpedanceFromLBM(user: ScaleUser, measurement: ScaleMeasurement): Int { - val finalLbm = measurement.lbm + val finalLbm = (measurement[MeasurementType.LBM]?.value ?: 0f) val postImpedanceLbm = finalLbm + user.age * 0.0542f val hM = user.bodyHeight / 100f - val preImpedanceLbm = hM * hM * 9.058f + 12.226f + measurement.weight * 0.32f + val preImpedanceLbm = hM * hM * 9.058f + 12.226f + (measurement[MeasurementType.WEIGHT]?.value ?: 0f) * 0.32f return ((preImpedanceLbm - postImpedanceLbm) / 0.0068f).roundToInt() } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt index 8783c17da..64e480423 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib @@ -30,6 +31,11 @@ import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.roundToInt +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Picooc (有品) body fat scales that speak the connection-oriented BLE protocol: @@ -320,9 +326,9 @@ class PicoocHandler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { userId = user.id dateTime = Date() - weight = pendingWeightKg - if (pendingImpedance > 0.0) impedance = pendingImpedance - if (pendingHeartRate > 0) heartRate = pendingHeartRate + this[MeasurementType.WEIGHT] = Kg(pendingWeightKg) + if (pendingImpedance > 0.0) this[MeasurementType.IMPEDANCE] = Ohm(pendingImpedance.toFloat()) + if (pendingHeartRate > 0) this[MeasurementType.HEART_RATE] = Bpm(pendingHeartRate) } val usableImpedance = pendingImpedance in MIN_IMPEDANCE..MAX_IMPEDANCE @@ -340,24 +346,24 @@ class PicoocHandler : ScaleDeviceHandler() { if (composition != null && composition.fatPercent in MIN_FAT_PERCENT..MAX_FAT_PERCENT) { // The scale did the maths for us — prefer its numbers over our estimate. - measurement.fat = composition.fatPercent - measurement.water = composition.waterPercent.coerceIn(0f, 80f) - measurement.bone = composition.boneMassKg.coerceIn(0f, 10f) - measurement.visceralFat = composition.visceralFat.toFloat() - measurement.lbm = pendingWeightKg * (1f - composition.fatPercent / 100f) + measurement[MeasurementType.BODY_FAT] = Percent(composition.fatPercent) + measurement[MeasurementType.WATER] = Percent(composition.waterPercent.coerceIn(0f, 80f)) + measurement[MeasurementType.BONE] = Kg(composition.boneMassKg.coerceIn(0f, 10f)) + measurement[MeasurementType.VISCERAL_FAT] = composition.visceralFat.toFloat() + measurement[MeasurementType.LBM] = Kg(pendingWeightKg * (1f - composition.fatPercent / 100f)) // 0x32 carries neither of these, so fall back to the estimator for them. lib?.let { - measurement.muscle = it.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f) - measurement.bmr = it.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) + measurement[MeasurementType.MUSCLE] = Percent(it.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f)) + measurement[MeasurementType.BMR] = Kcal(it.basalMetabolicRate.toFloat().coerceIn(0f, 5000f)) } logI("Picooc body composition source: 0x32 packet") } else if (lib != null) { - measurement.fat = lib.totalFatPercentage.toFloat().coerceIn(0f, 75f) - measurement.water = lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f) - measurement.muscle = lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f) - measurement.bone = lib.boneMassKg.toFloat().coerceIn(0f, 10f) - measurement.lbm = lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f) - measurement.bmr = lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f) + measurement[MeasurementType.BODY_FAT] = Percent(lib.totalFatPercentage.toFloat().coerceIn(0f, 75f)) + measurement[MeasurementType.WATER] = Percent(lib.totalBodyWaterPercentage.toFloat().coerceIn(0f, 80f)) + measurement[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat().coerceIn(0f, 100f)) + measurement[MeasurementType.BONE] = Kg(lib.boneMassKg.toFloat().coerceIn(0f, 10f)) + measurement[MeasurementType.LBM] = Kg(lib.fatFreeMassKg.toFloat().coerceIn(0f, 150f)) + measurement[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat().coerceIn(0f, 5000f)) logI("Picooc body composition source: StandardImpedanceLib (${pendingImpedance}Ω)") } else { logI("Picooc body composition unavailable: impedance=$pendingImpedance height=${user.bodyHeight}") @@ -369,10 +375,10 @@ class PicoocHandler : ScaleDeviceHandler() { composition?.bodyAge?.takeIf { it > 0 }?.let { logI("Picooc metabolic body age: $it (not stored)") } logI( - "Picooc publishing ($reason) → weight=${measurement.weight}kg fat=${measurement.fat}% " + - "water=${measurement.water}% muscle=${measurement.muscle}% bone=${measurement.bone}kg " + - "visceral=${measurement.visceralFat} lbm=${measurement.lbm}kg bmr=${measurement.bmr}kcal " + - "hr=${measurement.heartRate}bpm impedance=${measurement.impedance}Ω" + "Picooc publishing ($reason) → weight=${measurement[MeasurementType.WEIGHT]}kg fat=${measurement[MeasurementType.BODY_FAT]}% " + + "water=${measurement[MeasurementType.WATER]}% muscle=${measurement[MeasurementType.MUSCLE]}% bone=${measurement[MeasurementType.BONE]}kg " + + "visceral=${measurement[MeasurementType.VISCERAL_FAT]} lbm=${measurement[MeasurementType.LBM]}kg bmr=${measurement[MeasurementType.BMR]}kcal " + + "hr=${measurement[MeasurementType.HEART_RATE]}bpm impedance=${measurement[MeasurementType.IMPEDANCE]}Ω" ) publish(measurement) // No requestDisconnect(): the scale terminates the link itself once it is done. diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt index 5494a58e1..5c9ca926e 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.bluetooth.scales import android.os.Handler import android.os.Looper import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.TrisaBodyAnalyzeLib @@ -27,6 +28,9 @@ import com.health.openscale.core.data.WeightUnit import com.health.openscale.core.service.ScannedDeviceInfo import java.util.UUID import kotlin.experimental.and +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * QN / FITINDEX ES-26M style scales (vendor protocol on 0xFFE0/0xFFF0). @@ -526,9 +530,9 @@ class QNHandler : ScaleDeviceHandler() { private fun publishQnMeasurement(user: ScaleUser, weightKg: Float, r1: Float, source: String) { val m = ScaleMeasurement().apply { userId = user.id - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) // Store the raw resistance so body composition can be recomputed later. - impedance = r1.toDouble() + this[MeasurementType.IMPEDANCE] = Ohm(r1) } val impedance = if (r1 < 410f) 3.0f else 0.3f * (r1 - 400f) @@ -539,26 +543,14 @@ class QNHandler : ScaleDeviceHandler() { user.bodyHeight ) - m.fat = trisa.getFat(weightKg, impedance) - m.water = trisa.getWater(weightKg, impedance) - m.muscle = trisa.getMuscle(weightKg, impedance) - m.bone = trisa.getBone(weightKg, impedance) + m[MeasurementType.BODY_FAT] = Percent(trisa.getFat(weightKg, impedance)) + m[MeasurementType.WATER] = Percent(trisa.getWater(weightKg, impedance)) + m[MeasurementType.MUSCLE] = Percent(trisa.getMuscle(weightKg, impedance)) + m[MeasurementType.BONE] = Kg(trisa.getBone(weightKg, impedance)) logD("QN: publishing $source measurement weight=$weightKg kg r1=$r1 impedance=$impedance") - publish(snapshot(m)) + // Snapshot: the accumulator keeps being mutated after this publish. + publish(m.snapshot()) } - /** Make a defensive snapshot so later mutations don’t affect published data. */ - private fun snapshot(m: ScaleMeasurement) = ScaleMeasurement().also { - it.userId = m.userId - it.dateTime = m.dateTime - it.weight = m.weight - it.fat = m.fat - it.water = m.water - it.muscle = m.muscle - it.bone = m.bone - it.lbm = m.lbm - it.visceralFat = m.visceralFat - it.impedance = m.impedance - } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandlerBroadcast.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandlerBroadcast.kt index 8ff640ff7..20b1c9dac 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandlerBroadcast.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandlerBroadcast.kt @@ -18,12 +18,14 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult +import com.health.openscale.core.data.MeasurementType 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 java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kg /** * Handler for QN-lineage scales operating in non-connectable broadcast mode @@ -146,7 +148,7 @@ class QNHandlerBroadcast : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { userId = user.id - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) dateTime = Date() } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt index 2fd79d67e..812412542 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt @@ -17,6 +17,7 @@ */ package com.health.openscale.core.bluetooth.scales +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -27,6 +28,9 @@ import java.util.Timer import java.util.TimerTask import java.util.UUID import kotlin.math.roundToInt +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Handler for the Realme Smart Scale (Lifesense A6 lineage). @@ -131,12 +135,12 @@ class RealmeSmartScaleHandler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { this.userId = user.id this.dateTime = if (scaleTime > 0) Date(scaleTime * 1000L) else Date() - this.weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } // --- LOCAL BIA CALCULATION ENGINE --- if (impedance > 0) { - measurement.impedance = impedance.toDouble() + measurement[MeasurementType.IMPEDANCE] = Ohm(impedance.toFloat()) val sex = if (user.gender.isMale()) 1 else 0 val calc = com.health.openscale.core.bluetooth.libs.YunmaiLib(sex, user.bodyHeight, user.activityLevel) @@ -144,16 +148,16 @@ class RealmeSmartScaleHandler : ScaleDeviceHandler() { val fatPct = calc.getFat(user.age, weightKg, impedance) if (fatPct > 0f) { - measurement.fat = fatPct - measurement.muscle = calc.getMuscle(fatPct) / weightKg * 100.0f - measurement.water = calc.getWater(fatPct) - measurement.bone = calc.getBoneMass(measurement.muscle, weightKg) - measurement.lbm = calc.getLeanBodyMass(weightKg, fatPct) - measurement.visceralFat = calc.getVisceralFat(fatPct, user.age) + measurement[MeasurementType.BODY_FAT] = Percent(fatPct) + measurement[MeasurementType.MUSCLE] = Percent(calc.getMuscle(fatPct) / weightKg * 100.0f) + measurement[MeasurementType.WATER] = Percent(calc.getWater(fatPct)) + measurement[MeasurementType.BONE] = Kg(calc.getBoneMass((measurement[MeasurementType.MUSCLE]?.value ?: 0f), weightKg)) + measurement[MeasurementType.LBM] = Kg(calc.getLeanBodyMass(weightKg, fatPct)) + measurement[MeasurementType.VISCERAL_FAT] = calc.getVisceralFat(fatPct, user.age) } } - LogManager.i(TAG, "Measurement: Weight=$weightKg kg, Fat=${measurement.fat}%, Date=${measurement.dateTime}") + LogManager.i(TAG, "Measurement: Weight=$weightKg kg, Fat=${measurement[MeasurementType.BODY_FAT]}%, Date=${measurement.dateTime}") publish(measurement) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt index 265572dee..f2b7fbcc7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt @@ -17,6 +17,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.Wla25BodyComposition @@ -26,6 +27,10 @@ import java.util.Locale import java.util.TimeZone import java.util.UUID import kotlin.math.abs +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Relaxmedic body-composition scale (Fitdays app, icomon protocol version 107). @@ -193,27 +198,27 @@ class RelaxmedicHandler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { dateTime = Date() - weight = grams / 1000.0f - if (imps.isNotEmpty()) impedance = imps[0] + this[MeasurementType.WEIGHT] = Kg(grams / 1000.0f) + if (imps.isNotEmpty()) this[MeasurementType.IMPEDANCE] = Ohm(imps[0].toFloat()) } val result = Wla25BodyComposition.compute( heightCm = user.bodyHeight.toInt(), - rawWeightKg = measurement.weight.toDouble(), + rawWeightKg = (measurement[MeasurementType.WEIGHT]?.value ?: 0f).toDouble(), imps = imps ) if (result != null) { measurement.apply { - weight = result.weightKg - fat = result.fat - water = result.water - muscle = result.musclePercent - bone = result.boneKg - visceralFat = result.visceralFat.toFloat() - protein = result.protein - bmr = result.bmrKcal.toFloat() - lbm = result.lbmKg + this[MeasurementType.WEIGHT] = Kg(result.weightKg) + this[MeasurementType.BODY_FAT] = Percent(result.fat) + this[MeasurementType.WATER] = Percent(result.water) + this[MeasurementType.MUSCLE] = Percent(result.musclePercent) + this[MeasurementType.BONE] = Kg(result.boneKg) + this[MeasurementType.VISCERAL_FAT] = result.visceralFat.toFloat() + this[MeasurementType.PROTEIN] = Percent(result.protein) + this[MeasurementType.BMR] = Kcal(result.bmrKcal.toFloat()) + this[MeasurementType.LBM] = Kg(result.lbmKg) } } else { // The impedances failed the algorithm's validity gate; the weight is diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RenphoES26BBHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RenphoES26BBHandler.kt index d58617ad8..af3cb473b 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RenphoES26BBHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RenphoES26BBHandler.kt @@ -17,6 +17,7 @@ */ package com.health.openscale.core.bluetooth.scales +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -24,6 +25,8 @@ import com.health.openscale.core.utils.ConverterUtils import java.util.Date import java.util.UUID import kotlin.math.min +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm /** * RENPHO ES-26BB-B GATT handler (legacy: BluetoothES26BBB). @@ -166,11 +169,11 @@ class RenphoES26BBHandler : ScaleDeviceHandler() { private fun saveMeasurement(weightX100: Long, resistance: Int, timestampMs: Long?) { val m = ScaleMeasurement().apply { - weight = weightX100 / 100f + this[MeasurementType.WEIGHT] = Kg(weightX100 / 100f) if (timestampMs != null) dateTime = Date(timestampMs) // No BIA library is hooked here, but store the raw resistance so // body composition can be computed/recomputed later. - if (resistance > 0) impedance = resistance.toDouble() + if (resistance > 0) this[MeasurementType.IMPEDANCE] = Ohm(resistance.toFloat()) } publish(m) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RenphoHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RenphoHandler.kt index 678ab0e9c..6e566b514 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RenphoHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RenphoHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -25,6 +26,7 @@ import com.health.openscale.core.utils.LogManager import java.util.Calendar import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kg /** * Handler for RENPHO ES-WBE28 (GATT; mix of standard and vendor-specific behavior). @@ -191,7 +193,7 @@ class RenphoHandler : ScaleDeviceHandler() { LogManager.d(TAG, "RENPHO weight frame → raw=$raw, kg=$weightKg") - val m = ScaleMeasurement().apply { this.weight = weightKg } + val m = ScaleMeasurement().apply { this[MeasurementType.WEIGHT] = Kg(weightKg) } publish(m) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RobiS9Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RobiS9Handler.kt index 2af6e0d3d..ed26cddc3 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RobiS9Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RobiS9Handler.kt @@ -25,12 +25,14 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kg /** * Robi S9 smart scale (Fitdays app, Lefu-style FFB0 protocol). @@ -94,7 +96,7 @@ class RobiS9Handler : ScaleDeviceHandler() { val weightKg = parseFinalWeightKg(data) ?: return val m = ScaleMeasurement().apply { dateTime = Date() - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) // Byte 8 is heart rate (bpm), bytes 9..10 are impedance (u16 BE, Ohm); this // capture's A3 frame had zeros there, so body composition still isn't published. } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR5Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR5Handler.kt index 2c772584b..2a88af226 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR5Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR5Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.WeightUnit @@ -26,6 +27,7 @@ import java.util.Calendar import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kg /** * Runstar R5 handler. @@ -138,7 +140,7 @@ class RunstarR5Handler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { dateTime = Date() - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } publish(measurement) lastPublishedWeightRaw = rawWeight diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR6Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR6Handler.kt index d68561836..5869f6bda 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR6Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RunstarR6Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -25,6 +26,9 @@ import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.abs +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm /** * Runstar R6 smart scale. @@ -159,9 +163,9 @@ class RunstarR6Handler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { dateTime = Date() - weight = grams / 1000.0f - if (heartRateRaw != 0) heartRate = heartRateRaw - if (impedanceRaw != 0) impedance = impedanceRaw.toDouble() + this[MeasurementType.WEIGHT] = Kg(grams / 1000.0f) + if (heartRateRaw != 0) this[MeasurementType.HEART_RATE] = Bpm(heartRateRaw) + if (impedanceRaw != 0) this[MeasurementType.IMPEDANCE] = Ohm(impedanceRaw.toFloat()) } publish(measurement) lastPublishedWeightRaw = grams @@ -185,7 +189,7 @@ class RunstarR6Handler : ScaleDeviceHandler() { publish(ScaleMeasurement().apply { dateTime = entryDate - weight = grams / 1000.0f + this[MeasurementType.WEIGHT] = Kg(grams / 1000.0f) }) sendAck(seq) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RyFitHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RyFitHandler.kt index 61ec6c9b7..c19e12b12 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RyFitHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RyFitHandler.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.GenderType @@ -13,6 +14,9 @@ import kotlinx.coroutines.* import java.util.* import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent class RyFitHandler : ScaleDeviceHandler() { @@ -237,14 +241,14 @@ class RyFitHandler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { userId = user.id dateTime = Date() - this.weight = weight - fat = pendingFat!! - water = pendingWater!! - this.muscle = muscle - bone = boneKg - this.bmr = bmr - this.visceralFat = visceralFat - this.lbm = lbm + this[MeasurementType.WEIGHT] = Kg(weight) + this[MeasurementType.BODY_FAT] = Percent(pendingFat!!) + this[MeasurementType.WATER] = Percent(pendingWater!!) + this[MeasurementType.MUSCLE] = Percent(muscle) + this[MeasurementType.BONE] = Kg(boneKg) + this[MeasurementType.BMR] = Kcal(bmr) + this[MeasurementType.VISCERAL_FAT] = visceralFat + this[MeasurementType.LBM] = Kg(lbm) } publish(measurement) logI("Published measurement: weight=$weight, fat=$pendingFat, water=$pendingWater, muscle=$muscle") diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleupHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleupHandler.kt index 1e70aca7b..705a86254 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleupHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleupHandler.kt @@ -18,12 +18,14 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult +import com.health.openscale.core.data.MeasurementType 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 java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kg /** * ScaleupHandler @@ -132,7 +134,7 @@ class ScaleupHandler : ScaleDeviceHandler() { publish(ScaleMeasurement().apply { userId = user.id - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) dateTime = Date() }) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SenssunHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SenssunHandler.kt index 2fad868ba..5dd77efe9 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SenssunHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SenssunHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -25,6 +26,8 @@ import com.health.openscale.core.utils.LogManager import java.util.Calendar import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * SenssunHandler @@ -198,11 +201,11 @@ class SenssunHandler : ScaleDeviceHandler() { if (valuesMask == 0x0F) { val m = ScaleMeasurement().apply { - weight = lastWeight / 10.0f - fat = lastFat / 10.0f - water = lastHydration / 10.0f - bone = lastBone / 10.0f - muscle = lastMuscle / 10.0f + this[MeasurementType.WEIGHT] = Kg(lastWeight / 10.0f) + this[MeasurementType.BODY_FAT] = Percent(lastFat / 10.0f) + this[MeasurementType.WATER] = Percent(lastHydration / 10.0f) + this[MeasurementType.BONE] = Kg(lastBone / 10.0f) + this[MeasurementType.MUSCLE] = Percent(lastMuscle / 10.0f) dateTime = Date() } publish(m) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SinocareHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SinocareHandler.kt index feb911c42..6eb6bb956 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SinocareHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SinocareHandler.kt @@ -19,11 +19,13 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import android.util.SparseArray +import com.health.openscale.core.data.MeasurementType 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 kotlin.math.max +import com.health.openscale.core.data.Kg /** * SinocareHandler @@ -118,7 +120,7 @@ class SinocareHandler : ScaleDeviceHandler() { val weightKg = weightRaw / 100.0f val m = ScaleMeasurement().apply { - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) // dateTime left as "now" by default if ScaleMeasurement handles it, or set explicitly: // dateTime = Date(System.currentTimeMillis()) // Optionally attach userId if your model requires it: diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SoehnleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SoehnleHandler.kt index f2923383b..f12d613f7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SoehnleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/SoehnleHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.SoehnleLib @@ -29,6 +30,8 @@ import java.util.Calendar import java.util.GregorianCalendar import java.util.TimeZone import java.util.UUID +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * SoehnleHandler @@ -261,11 +264,11 @@ class SoehnleHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { userId = openScaleUserId - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) dateTime = cal.time - water = lib.getWater(weightKg, imp50.toFloat()) - fat = lib.getFat(weightKg, imp50.toFloat()) - muscle = lib.getMuscle(weightKg, imp50.toFloat(), imp5.toFloat()) + this[MeasurementType.WATER] = Percent(lib.getWater(weightKg, imp50.toFloat())) + this[MeasurementType.BODY_FAT] = Percent(lib.getFat(weightKg, imp50.toFloat())) + this[MeasurementType.MUSCLE] = Percent(lib.getMuscle(weightKg, imp50.toFloat(), imp5.toFloat())) } publish(m) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt index 59d8a665d..7ec99be12 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt @@ -17,6 +17,11 @@ */ package com.health.openscale.core.bluetooth.scales +import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.Percent +import com.health.openscale.core.data.MeasurementTypeIcon +import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.BeurerBf1000Lib import com.health.openscale.core.data.ActivityLevel @@ -55,10 +60,13 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { private var friendlyName: String? = null private var profile: Profile? = null + // Segmental values buffered until the measurement they belong to is published. + private val bf1000PendingSegmentals = LinkedHashMap, Percent>() + private var bf1000PendingVisceralFat = 0f + // BF1000 private measurement characteristics on the Beurer FFFF service: // 0006 reports measurement-complete status, 0009 carries visceral/segmental - // fat, and 000A carries segmental muscle. Segmental values are decoded and - // logged only until openScale has a generic/custom BLE measurement path. + // fat, and 000A carries segmental muscle. private val bf1000MeasurementStatus = uuid16(0x0006) private val bf1000SegmentalFatMeasurement = uuid16(0x0009) private val bf1000SegmentalMuscleMeasurement = uuid16(0x000A) @@ -253,11 +261,21 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { } logD( - "BF1000 segmental fat decoded, not persisted: visceral=${decoded.visceralFat} " + + "BF1000 segmental fat decoded: visceral=${decoded.visceralFat} " + "leftArm=${decoded.leftArm}% rightArm=${decoded.rightArm}% " + "torso=${decoded.torso}% leftLeg=${decoded.leftLeg}% " + "rightLeg=${decoded.rightLeg}%" ) + + // Visceral fat has a predefined key of its own, so it rides the regular + // field rather than a device-specific one. + if (decoded.visceralFat > 0f) bf1000PendingVisceralFat = decoded.visceralFat + + bufferSegmental(SEGMENTAL_FAT_LEFT_ARM, decoded.leftArm) + bufferSegmental(SEGMENTAL_FAT_RIGHT_ARM, decoded.rightArm) + bufferSegmental(SEGMENTAL_FAT_TORSO, decoded.torso) + bufferSegmental(SEGMENTAL_FAT_LEFT_LEG, decoded.leftLeg) + bufferSegmental(SEGMENTAL_FAT_RIGHT_LEG, decoded.rightLeg) } characteristic == bf1000SegmentalMuscleMeasurement -> { val decoded = BeurerBf1000Lib.parseSegmentalMuscleMeasurement(data) @@ -267,10 +285,16 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { } logD( - "BF1000 segmental muscle decoded, not persisted: leftArm=${decoded.leftArm}% " + + "BF1000 segmental muscle decoded: leftArm=${decoded.leftArm}% " + "rightArm=${decoded.rightArm}% torso=${decoded.torso}% " + "leftLeg=${decoded.leftLeg}% rightLeg=${decoded.rightLeg}%" ) + + bufferSegmental(SEGMENTAL_MUSCLE_LEFT_ARM, decoded.leftArm) + bufferSegmental(SEGMENTAL_MUSCLE_RIGHT_ARM, decoded.rightArm) + bufferSegmental(SEGMENTAL_MUSCLE_TORSO, decoded.torso) + bufferSegmental(SEGMENTAL_MUSCLE_LEFT_LEG, decoded.leftLeg) + bufferSegmental(SEGMENTAL_MUSCLE_RIGHT_LEG, decoded.rightLeg) } } @@ -283,6 +307,63 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { private fun isBf1000CustomMeasurementCharacteristic(characteristic: UUID): Boolean = characteristic in bf1000CustomMeasurementChars + private fun bufferSegmental(key: MeasurementType.Key, percent: Float) { + if (percent > 0f && percent.isFinite()) bf1000PendingSegmentals[key] = Percent(percent) + } + + /** + * Attaches the values from the BF1000's private characteristics to the measurement + * built from the standard weight/body-composition packets. Runs on every publish path, + * including the disconnect flush, and clears the buffer so the next weigh-in starts + * clean. + */ + override fun transformBeforePublish(m: ScaleMeasurement): ScaleMeasurement { + val transformed = super.transformBeforePublish(m) + + if (activeModel != Model.BEURER_BF1000) return transformed + + if (MeasurementType.VISCERAL_FAT !in transformed && bf1000PendingVisceralFat > 0f) { + transformed[MeasurementType.VISCERAL_FAT] = bf1000PendingVisceralFat + } + bf1000PendingSegmentals.forEach { (key, percent) -> transformed[key] = percent } + + if (bf1000PendingSegmentals.isNotEmpty()) { + logD("Attached ${bf1000PendingSegmentals.size} BF1000 segmental values") + } + + bf1000PendingSegmentals.clear() + bf1000PendingVisceralFat = 0f + + return transformed + } + + private companion object { + // Semantic, vendor-neutral identities: a future scale reporting the same + // quantities writes into the same columns, so the user's history survives a scale + // change. The icon says *where* on the body, the colour says *what* is measured: + // fat segments are shades of the predefined BODY_FAT red, muscle segments shades + // of the MUSCLE green, darkening from the arms down to the legs. The anchor shades + // themselves stay reserved for the whole-body totals. + val SEGMENTAL_FAT_LEFT_ARM = fat("left_arm", R.string.measurement_type_segmental_fat_left_arm, MeasurementTypeIcon.IC_BICEPS, 0xFFE57373) + val SEGMENTAL_FAT_RIGHT_ARM = fat("right_arm", R.string.measurement_type_segmental_fat_right_arm, MeasurementTypeIcon.IC_BICEPS, 0xFFF44336) + val SEGMENTAL_FAT_TORSO = fat("torso", R.string.measurement_type_segmental_fat_torso, MeasurementTypeIcon.IC_CHEST, 0xFFE53935) + val SEGMENTAL_FAT_LEFT_LEG = fat("left_leg", R.string.measurement_type_segmental_fat_left_leg, MeasurementTypeIcon.IC_THIGH, 0xFFD32F2F) + val SEGMENTAL_FAT_RIGHT_LEG = fat("right_leg", R.string.measurement_type_segmental_fat_right_leg, MeasurementTypeIcon.IC_THIGH, 0xFFC62828) + + val SEGMENTAL_MUSCLE_LEFT_ARM = muscle("left_arm", R.string.measurement_type_segmental_muscle_left_arm, MeasurementTypeIcon.IC_BICEPS, 0xFF81C784) + val SEGMENTAL_MUSCLE_RIGHT_ARM = muscle("right_arm", R.string.measurement_type_segmental_muscle_right_arm, MeasurementTypeIcon.IC_BICEPS, 0xFF4CAF50) + val SEGMENTAL_MUSCLE_TORSO = muscle("torso", R.string.measurement_type_segmental_muscle_torso, MeasurementTypeIcon.IC_CHEST, 0xFF43A047) + val SEGMENTAL_MUSCLE_LEFT_LEG = muscle("left_leg", R.string.measurement_type_segmental_muscle_left_leg, MeasurementTypeIcon.IC_THIGH, 0xFF388E3C) + val SEGMENTAL_MUSCLE_RIGHT_LEG = muscle("right_leg", R.string.measurement_type_segmental_muscle_right_leg, MeasurementTypeIcon.IC_THIGH, 0xFF2E7D32) + + /** Both packets report tenths of a percent, so every segment is a PERCENT float. */ + private fun fat(part: String, nameResId: Int, icon: MeasurementTypeIcon, color: Long) = + MeasurementType.devicePercent("segmental.fat.$part", nameResId, icon = icon, color = color.toInt()) + + private fun muscle(part: String, nameResId: Int, icon: MeasurementTypeIcon, color: Long) = + MeasurementType.devicePercent("segmental.muscle.$part", nameResId, icon = icon, color = color.toInt()) + } + private fun handleUserList(data: ByteArray, user : ScaleUser) { val parser = BluetoothBytesParser(data) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardWeightProfileHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardWeightProfileHandler.kt index c00a74c8f..390ff1240 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardWeightProfileHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardWeightProfileHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -29,6 +30,9 @@ import java.util.Locale import java.util.UUID import kotlin.math.min import kotlin.random.Random +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Handler for **Bluetooth Standard Weight Profile** devices (GATT 181D/181B/181C/1805/180F). @@ -176,12 +180,12 @@ open class StandardWeightProfileHandler : ScaleDeviceHandler() { protected open fun transformBeforePublish(m: ScaleMeasurement): ScaleMeasurement { logD("transformBeforePublish called for measurement: $m") - val w = m.weight.takeIf { it > 0f } ?: 1f + val w = (m[MeasurementType.WEIGHT]?.value ?: 0f).takeIf { it > 0f } ?: 1f - m.water = (m.water / w) * 100f + m[MeasurementType.WATER]?.let { waterMassKg -> m[MeasurementType.WATER] = Percent((waterMassKg.value / w) * 100f) } - logD("transformed values before publish: weight=${m.weight}kg, lbm=${m.lbm}kg, bone=${m.bone}kg, " + - "fat=${m.fat}%, muscle=${m.muscle}%, water=${m.water}%") + logD("transformed values before publish: weight=${m[MeasurementType.WEIGHT]}kg, lbm=${m[MeasurementType.LBM]}kg, bone=${m[MeasurementType.BONE]}kg, " + + "fat=${m[MeasurementType.BODY_FAT]}%, muscle=${m[MeasurementType.MUSCLE]}%, water=${m[MeasurementType.WATER]}%") return m } @@ -342,7 +346,7 @@ open class StandardWeightProfileHandler : ScaleDeviceHandler() { val weightRaw = u16le(value, offset); offset += 2 val m = ScaleMeasurement() - m.weight = weightRaw * multiplier + m[MeasurementType.WEIGHT] = Kg(weightRaw * multiplier) if (tsPresent) { val cal = Calendar.getInstance() @@ -361,7 +365,7 @@ open class StandardWeightProfileHandler : ScaleDeviceHandler() { val scaleUserIndex = u8(value, offset); offset += 1 val appId = loadUserIdForScaleIndex(scaleUserIndex) if (appId != -1) m.userId = appId - logD("Weight: flags=0x${flags.toString(16)} idx=$scaleUserIndex mappedAppId=$appId kg=$isKg value=${m.weight}") + logD("Weight: flags=0x${flags.toString(16)} idx=$scaleUserIndex mappedAppId=$appId kg=$isKg value=${m[MeasurementType.WEIGHT]}") } if (bmiHeight) { @@ -397,7 +401,7 @@ open class StandardWeightProfileHandler : ScaleDeviceHandler() { val m = ScaleMeasurement() val bodyFatPct = u16le(value, offset) * 0.1f; offset += 2 - m.fat = bodyFatPct + m[MeasurementType.BODY_FAT] = Percent(bodyFatPct) if (tsPresent) { val cal = Calendar.getInstance() @@ -427,7 +431,7 @@ open class StandardWeightProfileHandler : ScaleDeviceHandler() { if (musclePctPresent) { val musclePct = u16le(value, offset) * 0.1f; offset += 2 - m.muscle = musclePct + m[MeasurementType.MUSCLE] = Percent(musclePct) } var softLean = 0.0f @@ -448,21 +452,21 @@ open class StandardWeightProfileHandler : ScaleDeviceHandler() { if (waterMassPresent) { val bodyWaterMass = u16le(value, offset) * massMultiplier; offset += 2 - m.water = bodyWaterMass + m[MeasurementType.WATER] = Percent(bodyWaterMass) } if (impedancePresent) { val z = u16le(value, offset) * 0.1f; offset += 2 logD("Impedance=$z Ω") // Store the raw impedance so body composition can be recomputed later. - if (z > 0f) m.impedance = z.toDouble() + if (z > 0f) m[MeasurementType.IMPEDANCE] = Ohm(z) } if (weightPresent) { val w = u16le(value, offset) * massMultiplier; offset += 2 - m.weight = w + m[MeasurementType.WEIGHT] = Kg(w) } else { - pendingMeasurement?.weight?.takeIf { it > 0f }?.let { m.weight = it } + pendingMeasurement?.get(MeasurementType.WEIGHT)?.value?.takeIf { it > 0f }?.let { m[MeasurementType.WEIGHT] = Kg(it) } } if (heightPresent) { @@ -473,13 +477,13 @@ open class StandardWeightProfileHandler : ScaleDeviceHandler() { if (multiPacket) logW("Body Composition: multi-packet measurement not supported") // Derive LBM & bone if we have soft-lean and weight - val w2 = m.weight + val w2 = (m[MeasurementType.WEIGHT]?.value ?: 0f) if (w2 > 0f && softLeanPresent) { - val fatMass = w2 * (m.fat / 100f) + val fatMass = w2 * ((m[MeasurementType.BODY_FAT]?.value ?: 0f) / 100f) val leanBodyMass = w2 - fatMass val boneMass = leanBodyMass - softLean - m.lbm = leanBodyMass - m.bone = boneMass + m[MeasurementType.LBM] = Kg(leanBodyMass) + m[MeasurementType.BONE] = Kg(boneMass) } return m diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandler.kt index eafc2a573..9745c73e8 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -28,6 +29,7 @@ import java.util.Calendar import java.util.Date import java.util.Locale import java.util.UUID +import com.health.openscale.core.data.Kg /** * Taylor 5331891 BIA (Body Composition) bathroom scale. @@ -185,7 +187,7 @@ class TaylorBIAHandler : ScaleDeviceHandler() { published = true publish(ScaleMeasurement().apply { dateTime = Date() - weight = pendingWeightKg + this[MeasurementType.WEIGHT] = Kg(pendingWeightKg) }) } } @@ -243,7 +245,7 @@ class TaylorBIAHandler : ScaleDeviceHandler() { // Weight-only: the scale transmits no impedance/body-composition data over BLE. val measurement = ScaleMeasurement().apply { dateTime = Date() - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } publish(measurement) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TrisaBodyAnalyzeHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TrisaBodyAnalyzeHandler.kt index afe19dfb1..7a372b60e 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TrisaBodyAnalyzeHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TrisaBodyAnalyzeHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.TrisaBodyAnalyzeLib @@ -25,6 +26,9 @@ import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.utils.ConverterUtils import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * TrisaBodyAnalyzeHandler @@ -192,7 +196,7 @@ class TrisaBodyAnalyzeHandler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { dateTime = Date(convertDeviceTimestampToJava(deviceTs)) - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } // Only resistance2 is used for derived composition fields @@ -202,12 +206,12 @@ class TrisaBodyAnalyzeHandler : ScaleDeviceHandler() { val impedance = if (resistance2 < 410f) 3.0f else 0.3f * (resistance2 - 400f) val sexFlag = if (user!!.gender.isMale()) 1 else 0 val lib = TrisaBodyAnalyzeLib(sexFlag, user.age, user.bodyHeight) - measurement.fat = lib.getFat(weightKg, impedance) - measurement.water = lib.getWater(weightKg, impedance) - measurement.muscle = lib.getMuscle(weightKg, impedance) - measurement.bone = lib.getBone(weightKg, impedance) + measurement[MeasurementType.BODY_FAT] = Percent(lib.getFat(weightKg, impedance)) + measurement[MeasurementType.WATER] = Percent(lib.getWater(weightKg, impedance)) + measurement[MeasurementType.MUSCLE] = Percent(lib.getMuscle(weightKg, impedance)) + measurement[MeasurementType.BONE] = Kg(lib.getBone(weightKg, impedance)) // Store the raw resistance so body composition can be recomputed later. - measurement.impedance = resistance2.toDouble() + measurement[MeasurementType.IMPEDANCE] = Ohm(resistance2) } return measurement } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/VitafitVT701Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/VitafitVT701Handler.kt index fae97153a..89a7a63b5 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/VitafitVT701Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/VitafitVT701Handler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib @@ -27,6 +28,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Date import java.util.UUID +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Vitafit "Body Fat" smart scale (VT701), sold with the Vitafit / Fitdays app. @@ -128,14 +133,14 @@ class VitafitVT701Handler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { userId = user.id dateTime = Date() - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } // The scale reports only weight + raw impedance; derive body composition the same way the // other impedance scales in this package do. The guard mirrors StandardImpedanceLib's note // that its formulas only hold for whole-body impedance in a sane range. if (impedanceOhm in 1 until 1500) { - measurement.impedance = impedanceOhm.toDouble() + measurement[MeasurementType.IMPEDANCE] = Ohm(impedanceOhm.toFloat()) val lib = StandardImpedanceLib( gender = user.gender, age = user.age, @@ -143,11 +148,11 @@ class VitafitVT701Handler : ScaleDeviceHandler() { heightM = user.bodyHeight / 100.0, impedance = impedanceOhm.toDouble(), ) - measurement.fat = lib.totalFatPercentage.toFloat() - measurement.water = lib.totalBodyWaterPercentage.toFloat() - measurement.muscle = lib.skeletalMusclePercentage.toFloat() - measurement.bone = lib.boneMassKg.toFloat() - measurement.bmr = lib.basalMetabolicRate.toFloat() + measurement[MeasurementType.BODY_FAT] = Percent(lib.totalFatPercentage.toFloat()) + measurement[MeasurementType.WATER] = Percent(lib.totalBodyWaterPercentage.toFloat()) + measurement[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat()) + measurement[MeasurementType.BONE] = Kg(lib.boneMassKg.toFloat()) + measurement[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat()) } publish(measurement) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3Handler.kt index a885024ea..3b9e54734 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/WeightGurusA3Handler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -29,6 +30,10 @@ import java.util.Date import java.util.TimeZone import java.util.UUID import kotlin.random.Random +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Weight Gurus Bluetooth Smart Scale 0376 (Greater Goods / DMD Brands, Transtek A3). @@ -417,8 +422,8 @@ class WeightGurusA3Handler : ScaleDeviceHandler() { val measurement = ScaleMeasurement().apply { dateTime = Date(timestamp?.let(::deviceTimeToJava) ?: System.currentTimeMillis()) - this.weight = weight - impedance?.let { this.impedance = it.toDouble() } + this[MeasurementType.WEIGHT] = Kg(weight) + impedance?.let { this[MeasurementType.IMPEDANCE] = Ohm(it.toFloat()) } } logD("Weight frame → ${weight}kg at ${measurement.dateTime}, impedance=$impedance, append=$hasAppendFrame") @@ -468,33 +473,33 @@ class WeightGurusA3Handler : ScaleDeviceHandler() { if (flags and 0x01 != 0) off += 1 // scale-side user slot if (flags and 0x02 != 0) { if (off + 2 > data.size) return - measurement.bmr = ConverterUtils.fromUnsignedInt16Le(data, off).toFloat() + measurement[MeasurementType.BMR] = Kcal(ConverterUtils.fromUnsignedInt16Le(data, off).toFloat()) off += 2 } if (flags and 0x04 != 0) { if (off + 2 > data.size) return - measurement.fat = sfloatFrom16(data, off) + measurement[MeasurementType.BODY_FAT] = Percent(sfloatFrom16(data, off)) off += 2 } if (flags and 0x08 != 0) { if (off + 2 > data.size) return - measurement.water = sfloatFrom16(data, off) + measurement[MeasurementType.WATER] = Percent(sfloatFrom16(data, off)) off += 2 } if (flags and 0x10 != 0) { if (off + 2 > data.size) return - measurement.visceralFat = sfloatFrom16(data, off) + measurement[MeasurementType.VISCERAL_FAT] = sfloatFrom16(data, off) off += 2 } if (flags and 0x20 != 0) { if (off + 2 > data.size) return - measurement.muscle = sfloatFrom16(data, off) + measurement[MeasurementType.MUSCLE] = Percent(sfloatFrom16(data, off)) off += 2 } if (flags and 0x40 != 0) { if (off + 2 > data.size) return // The scale sends bone as a percentage, but openScale stores it as a mass in kg. - measurement.bone = bonePercentToKg(sfloatFrom16(data, off), measurement.weight) + measurement[MeasurementType.BONE] = Kg(bonePercentToKg(sfloatFrom16(data, off), (measurement[MeasurementType.WEIGHT]?.value ?: 0f))) off += 2 } @@ -530,9 +535,9 @@ class WeightGurusA3Handler : ScaleDeviceHandler() { return } logD( - "Decoded ${measurement.dateTime}: weight=${measurement.weight}kg fat=${measurement.fat}% " + - "water=${measurement.water}% muscle=${measurement.muscle}% bone=${measurement.bone}kg " + - "visceral=${measurement.visceralFat} bmr=${measurement.bmr}kcal" + "Decoded ${measurement.dateTime}: weight=${measurement[MeasurementType.WEIGHT]}kg fat=${measurement[MeasurementType.BODY_FAT]}% " + + "water=${measurement[MeasurementType.WATER]}% muscle=${measurement[MeasurementType.MUSCLE]}% bone=${measurement[MeasurementType.BONE]}kg " + + "visceral=${measurement[MeasurementType.VISCERAL_FAT]} bmr=${measurement[MeasurementType.BMR]}kcal" ) publish(measurement) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/XiaomiS800Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/XiaomiS800Handler.kt index 4a3f75703..75e566b9c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/XiaomiS800Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/XiaomiS800Handler.kt @@ -47,12 +47,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.XiaomiS800Lib import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date import java.util.Locale +import com.health.openscale.core.data.Kg /** * Xiaomi Mijia 8-electrode Body Composition Scale S800 (xiaomi.scales.ms116). @@ -123,7 +125,7 @@ class XiaomiS800Handler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { dateTime = Date() - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) userId = user.id } publish(m) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt index 1448830ae..d28625542 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.YunmaiLib @@ -29,6 +30,8 @@ import java.util.Date import java.util.Locale import java.util.UUID import kotlin.math.abs +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent /** * Yunmai (SE/Mini) – minimal, event-driven implementation. @@ -142,13 +145,13 @@ class YunmaiHandler( } if (lastMeasurement != null && isDuplicateMeasurement(measurement, lastMeasurement!!)) { - logI("Duplicate measurement skipped: weight=${measurement.weight}kg, fat=${measurement.fat}") + logI("Duplicate measurement skipped: weight=${measurement[MeasurementType.WEIGHT]}kg, fat=${measurement[MeasurementType.BODY_FAT]}") return } publish(measurement) // base will take it from here lastMeasurement = measurement - logI("Measurement published: weight=${measurement.weight} kg, fat=${measurement.fat}") + logI("Measurement published: weight=${measurement[MeasurementType.WEIGHT]} kg, fat=${measurement[MeasurementType.BODY_FAT]}") } // --- Packet builders ------------------------------------------------------ @@ -216,7 +219,7 @@ class YunmaiHandler( val m = ScaleMeasurement().apply { dateTime = Date(tsMillis) - weight = weightKg + this[MeasurementType.WEIGHT] = Kg(weightKg) } if (isMini) { @@ -243,12 +246,12 @@ class YunmaiHandler( } if (fatPct > 0f && fatPct.isFinite()) { - m.fat = fatPct - m.muscle = yunmai.getMuscle(fatPct) - m.water = yunmai.getWater(fatPct) - m.bone = yunmai.getBoneMass(m.muscle, weightKg) - m.lbm = yunmai.getLeanBodyMass(weightKg, fatPct) - m.visceralFat = yunmai.getVisceralFat(fatPct, user.age) + m[MeasurementType.BODY_FAT] = Percent(fatPct) + m[MeasurementType.MUSCLE] = Percent(yunmai.getMuscle(fatPct)) + m[MeasurementType.WATER] = Percent(yunmai.getWater(fatPct)) + m[MeasurementType.BONE] = Kg(yunmai.getBoneMass((m[MeasurementType.MUSCLE]?.value ?: 0f), weightKg)) + m[MeasurementType.LBM] = Kg(yunmai.getLeanBodyMass(weightKg, fatPct)) + m[MeasurementType.VISCERAL_FAT] = yunmai.getVisceralFat(fatPct, user.age) } else { logW("Body fat is zero/invalid (prot=$protocolVer, R=$resistance)") } @@ -274,12 +277,12 @@ class YunmaiHandler( val timeDiff = abs(newTime - existingTime) if (timeDiff > timeThresholdMs) return false - if (abs(new.weight - existing.weight) > valueTolerance) return false - if (abs(new.fat - existing.fat) > valueTolerance) return false - if (abs(new.water - existing.water) > valueTolerance) return false - if (abs(new.muscle - existing.muscle) > valueTolerance) return false - if (abs(new.bone - existing.bone) > valueTolerance) return false - if (abs(new.visceralFat - existing.visceralFat) > valueTolerance) return false + if (abs((new[MeasurementType.WEIGHT]?.value ?: 0f) - (existing[MeasurementType.WEIGHT]?.value ?: 0f)) > valueTolerance) return false + if (abs((new[MeasurementType.BODY_FAT]?.value ?: 0f) - (existing[MeasurementType.BODY_FAT]?.value ?: 0f)) > valueTolerance) return false + if (abs((new[MeasurementType.WATER]?.value ?: 0f) - (existing[MeasurementType.WATER]?.value ?: 0f)) > valueTolerance) return false + if (abs((new[MeasurementType.MUSCLE]?.value ?: 0f) - (existing[MeasurementType.MUSCLE]?.value ?: 0f)) > valueTolerance) return false + if (abs((new[MeasurementType.BONE]?.value ?: 0f) - (existing[MeasurementType.BONE]?.value ?: 0f)) > valueTolerance) return false + if (abs((new[MeasurementType.VISCERAL_FAT] ?: 0f) - (existing[MeasurementType.VISCERAL_FAT] ?: 0f)) > valueTolerance) return false return true } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt index 7d56a5e3a..652ea2d61 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt @@ -18,11 +18,15 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.YunmaiLib import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent /** * Advertisement parser for the Yunmai X (YMBS-M268) broadcast protocol. @@ -178,23 +182,23 @@ class YunmaiXHandler : ScaleDeviceHandler() { val m = ScaleMeasurement().apply { userId = user.id dateTime = Date() - weight = f.weightKg + this[MeasurementType.WEIGHT] = Kg(f.weightKg) } if (f.impedanceOhm > 0) { - m.impedance = f.impedanceOhm.toDouble() + m[MeasurementType.IMPEDANCE] = Ohm(f.impedanceOhm.toFloat()) val sexInt = if (user.gender.isMale()) 1 else 0 val yunmai = YunmaiLib(sexInt, user.bodyHeight, user.activityLevel) val fatPct = yunmai.getFat(user.age, f.weightKg, f.impedanceOhm) if (fatPct > 0f && fatPct.isFinite()) { - m.fat = fatPct - m.muscle = yunmai.getMuscle(fatPct) - m.water = yunmai.getWater(fatPct) - m.bone = yunmai.getBoneMass(m.muscle, f.weightKg) - m.lbm = yunmai.getLeanBodyMass(f.weightKg, fatPct) - m.visceralFat = yunmai.getVisceralFat(fatPct, user.age) + m[MeasurementType.BODY_FAT] = Percent(fatPct) + m[MeasurementType.MUSCLE] = Percent(yunmai.getMuscle(fatPct)) + m[MeasurementType.WATER] = Percent(yunmai.getWater(fatPct)) + m[MeasurementType.BONE] = Kg(yunmai.getBoneMass((m[MeasurementType.MUSCLE]?.value ?: 0f), f.weightKg)) + m[MeasurementType.LBM] = Kg(yunmai.getLeanBodyMass(f.weightKg, fatPct)) + m[MeasurementType.VISCERAL_FAT] = yunmai.getVisceralFat(fatPct, user.age) } else { logW("Body fat is zero/invalid (R=${f.impedanceOhm})") } diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index 983338f2c..0c73e73a0 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -366,48 +366,6 @@ enum class MeasurementTypeIcon(val resource: IconResource) { IC_M_HIVE(IconResource.VectorResource(Icons.Filled.Hive)); } -enum class MeasurementTypeKey( - val id: Int, - @param:StringRes val localizedNameResId: Int, - val allowedUnitTypes: List, - val allowedInputType: List -) { - WEIGHT(1, R.string.measurement_type_weight, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - BMI(2, R.string.measurement_type_bmi, listOf(UnitType.NONE), listOf(InputFieldType.FLOAT)), - BODY_FAT(3, R.string.measurement_type_body_fat, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - WATER(4, R.string.measurement_type_water, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - MUSCLE(5, R.string.measurement_type_muscle, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - LBM(6, R.string.measurement_type_lbm, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - BONE(7, R.string.measurement_type_bone, listOf(UnitType.KG, UnitType.LB), listOf(InputFieldType.FLOAT)), - WAIST(8, R.string.measurement_type_waist, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - WHR(9, R.string.measurement_type_whr, listOf(UnitType.NONE), listOf(InputFieldType.FLOAT)), - WHTR(10, R.string.measurement_type_whtr, listOf(UnitType.NONE), listOf(InputFieldType.FLOAT)), - HIPS(11, R.string.measurement_type_hips, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - VISCERAL_FAT(12, R.string.measurement_type_visceral_fat, listOf(UnitType.NONE), listOf(InputFieldType.FLOAT)), - CHEST(13, R.string.measurement_type_chest, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - THIGH(14, R.string.measurement_type_thigh, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - BICEPS(15, R.string.measurement_type_biceps, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - NECK(16, R.string.measurement_type_neck, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - CALIPER_1(17, R.string.measurement_type_caliper1, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - CALIPER_2(18, R.string.measurement_type_caliper2, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - CALIPER_3(19, R.string.measurement_type_caliper3, listOf(UnitType.CM, UnitType.INCH), listOf(InputFieldType.FLOAT)), - CALIPER(20, R.string.measurement_type_fat_caliper, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), - BMR(21, R.string.measurement_type_bmr, listOf(UnitType.KCAL), listOf(InputFieldType.FLOAT)), - TDEE(22, R.string.measurement_type_tdee, listOf(UnitType.KCAL), listOf(InputFieldType.FLOAT)), - HEART_RATE(23, R.string.measurement_type_heart_rate, listOf(UnitType.BPM), listOf(InputFieldType.INT)), - CALORIES(24, R.string.measurement_type_calories, listOf(UnitType.KCAL), listOf(InputFieldType.FLOAT)), - DATE(25, R.string.measurement_type_date, listOf(UnitType.NONE), listOf(InputFieldType.DATE)), - TIME(26, R.string.measurement_type_time, listOf(UnitType.NONE), listOf(InputFieldType.TIME)), - COMMENT(27, R.string.measurement_type_comment, listOf(UnitType.NONE), listOf(InputFieldType.TEXT)), - USER(28, R.string.measurement_type_user, listOf(UnitType.NONE), listOf(InputFieldType.USER)), - IMPEDANCE(29, R.string.measurement_type_impedance, listOf(UnitType.OHM), listOf(InputFieldType.FLOAT)), - IMPEDANCE_LOW(30, R.string.measurement_type_impedance_low, listOf(UnitType.OHM), listOf(InputFieldType.FLOAT)), - ECW(31, R.string.measurement_type_ecw, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - ICW(32, R.string.measurement_type_icw, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - PROTEIN(33, R.string.measurement_type_protein, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - BCM(34, R.string.measurement_type_bcm, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - CUSTOM(99, R.string.measurement_type_custom_default_name, UnitType.entries.toList(), listOf(InputFieldType.FLOAT, InputFieldType.INT, InputFieldType.TEXT, InputFieldType.DATE, InputFieldType.TIME)); -} enum class UnitType(val displayName: String) { @@ -426,6 +384,17 @@ enum class UnitType(val displayName: String) { return this == KG || this == LB || this == ST } + /** + * The units a value in this unit can be converted into without changing what it means. + * PERCENT is alone in its family on purpose: turning a percentage into a mass needs to + * know what it is a percentage *of*. + */ + fun convertibleUnits(): List = when { + isWeightUnit() -> listOf(KG, LB, ST) + this == CM || this == INCH -> listOf(CM, INCH) + else -> listOf(this) + } + fun toWeightUnit(): WeightUnit { return when (this) { LB -> WeightUnit.LB @@ -435,6 +404,72 @@ enum class UnitType(val displayName: String) { } } +/** + * A raw scale value whose unit is part of its type. Every float-valued builtin + * [MeasurementType.Key] binds one of these ([Kg], [Percent], [Cm], [Kcal], [Ohm]), so a + * handler cannot set a value without stating the unit it is in — `m[WEIGHT] = Kg(72.5f)` + * compiles, `m[WEIGHT] = 72.5f` and `m[WEIGHT] = Percent(20f)` do not. The BLE connector + * unwraps centrally and converts to the unit the user configured. + * + * The companions carry the conversions a handler needs when its scale reports something + * else (`Kg.from(native, user.scaleUnit)`, `Cm.fromInch(...)`), so the right way is also + * the discoverable way. `toString()` prints just the number to keep log lines readable. + */ +sealed interface UnitValue { + val value: Float +} + +/** A mass in kilograms — the delivery unit for WEIGHT, LBM, BONE and `deviceKg` keys. */ +@JvmInline +value class Kg(override val value: Float) : UnitValue { + override fun toString(): String = value.toString() + + companion object { + /** Converts from the unit the scale natively reports in (kg/lb/st). */ + fun from(value: Float, unit: WeightUnit): Kg = + Kg(com.health.openscale.core.utils.ConverterUtils.toKilogram(value, unit)) + + fun fromLb(lb: Float): Kg = from(lb, WeightUnit.LB) + + /** Chinese catty (市斤), used natively by some Asian scales. */ + fun fromJin(jin: Float): Kg = Kg(jin * 0.5f) + } +} + +/** A share of body weight in percent — BODY_FAT, WATER, MUSCLE, PROTEIN, `devicePercent`. */ +@JvmInline +value class Percent(override val value: Float) : UnitValue { + override fun toString(): String = value.toString() +} + +/** A length in centimeters — the circumference and caliper keys. */ +@JvmInline +value class Cm(override val value: Float) : UnitValue { + override fun toString(): String = value.toString() + + companion object { + fun fromInch(inch: Float): Cm = Cm(inch * 2.54f) + } +} + +/** An energy in kilocalories — BMR (and CALORIES). */ +@JvmInline +value class Kcal(override val value: Float) : UnitValue { + override fun toString(): String = value.toString() +} + +/** An electrical resistance in ohms — the impedance keys. */ +@JvmInline +value class Ohm(override val value: Float) : UnitValue { + override fun toString(): String = value.toString() +} + +/** A heart rate in whole beats per minute. */ +@JvmInline +value class Bpm(val value: Int) { + override fun toString(): String = value.toString() +} + enum class InputFieldType { FLOAT, INT, diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt b/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt index b7aad9167..287dc4aa0 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/MeasurementType.kt @@ -18,49 +18,446 @@ package com.health.openscale.core.data import android.content.Context +import androidx.annotation.StringRes import androidx.room.Entity import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey +import com.health.openscale.R +/** + * A measurement type: one row per quantity the app tracks. + * + * Two halves, one concept. The **row** (this entity) is user state — the chosen unit, + * colour, icon and flags, all editable. The **[Key]** is vocabulary and defaults — what a + * quantity is called, which units are allowed and how a fresh row starts. Predefined keys + * live as constants on the companion ([WEIGHT], [BODY_FAT], …); scale handlers declare + * their own via [deviceFloat] & friends; user-created types have no [Key] at all, only a + * row. + * + * Both meet in [identity], the stable namespaced string this row is unique by: + * + * - `builtin.weight` — predefined, resolvable back to its [Key] via [keyOf] + * - `ble.segmental.fat.left_arm` — contributed by a scale handler + * - `user.schritte` — created by the user + * + * The prefixes are applied by the factories, never typed by hand, so a handler cannot + * claim the builtin or user namespace by accident. An identity is frozen once assigned — + * renames, language switches and unit edits never change it. + */ @Entity( - indices = [Index(value = ["key"], unique = false)] // unique false because of custom types + // Unique: the identity IS what makes a measurement type itself. SQLite enforces what + // the pre-16 schema could not — a second `builtin.weight` row cannot exist. + indices = [Index(value = ["identity"], unique = true)] ) data class MeasurementType( @PrimaryKey(autoGenerate = true) val id: Int = 0, - val key: MeasurementTypeKey = MeasurementTypeKey.CUSTOM, + /** + * Stable namespaced identity, see class doc. Blank means "not assigned yet": the type + * editor constructs rows without one and MeasurementTypeCrudUseCases assigns the + * `user.*` identity on save. + */ + val identity: String = "", val name: String? = null, val color: Int = 0, - val icon : MeasurementTypeIcon = MeasurementTypeIcon.IC_DEFAULT, + val icon: MeasurementTypeIcon = MeasurementTypeIcon.IC_DEFAULT, val unit: UnitType = UnitType.NONE, val inputType: InputFieldType = InputFieldType.FLOAT, val displayOrder: Int = 0, val isDerived: Boolean = false, - val isEnabled : Boolean = true, - val isPinned : Boolean = false, - val isOnRightYAxis : Boolean = false, + val isEnabled: Boolean = true, + val isPinned: Boolean = false, + val isOnRightYAxis: Boolean = false, val isInternal: Boolean = false -){ +) { + /** The predefined key behind this row, or null for `ble.*`/`user.*` rows. */ + val key: Key<*>? + get() = keyOf(identity) + + @Ignore + fun isBuiltIn(): Boolean = identity.startsWith(BUILTIN_PREFIX) + + /** True for a type a scale handler contributed. Its protocol owns unit and input type. */ + @Ignore + fun isDeviceOwned(): Boolean = identity.startsWith(DEVICE_PREFIX) + + /** True for a type the user created themselves. */ + @Ignore + fun isUserOwned(): Boolean = identity.startsWith(USER_PREFIX) + /** - * Gets the appropriate display name for UI purposes. - * If the key points to a predefined type with a localized resource ID, that resource is used - * to ensure the name is displayed in the current device language. - * Otherwise (e.g., for CUSTOM types or if no specific resource ID is set for the key), - * the stored 'name' property is returned. - * - * @param context The context needed to resolve string resources. - * @return The display name for this measurement type. + * Display name: predefined types resolve their localized string resource (the stored + * [name] stays null and is ignored), all other types show the stored [name]. */ - @Ignore // Room should not try to map this helper function to a DB column + @Ignore fun getDisplayName(context: Context): String { - return if (key == MeasurementTypeKey.CUSTOM) { - if (!name.isNullOrBlank()) { - name - } else { - context.getString(key.localizedNameResId) + val predefined = key + return when { + predefined != null -> context.getString(predefined.nameResId) + !name.isNullOrBlank() -> name + else -> context.getString(R.string.measurement_type_custom_default_name) + } + } + + /** + * One vocabulary entry — a quantity the app can track, identified by a namespaced + * identity string. [T] is bound to [inputType] by the factories, so the value a + * handler writes into a ScaleMeasurement and the column it lands in cannot disagree: + * `m[aTextKey] = 1.5f` does not compile. + */ + // The constructor is internal out of necessity, not intent: Kotlin cannot restrict a + // nested class's constructor to the outer companion. The namespace guarantee is + // enforced where rows are created instead — resolveOrCreate materializes only ble.* + // keys, and the crud use case accepts only user.* or registry identities. + class Key internal constructor( + val identity: String, + @param:StringRes val nameResId: Int, + val inputType: InputFieldType, + /** + * The unit handler-supplied raw values arrive in — for typed keys the value class + * (Kg, Percent, ...) enforces it at compile time, and this names the same unit for + * the connector, which converts from it to the unit the user chose. + */ + val wireUnit: UnitType, + val allowedUnitTypes: List, + val allowedInputTypes: List, + // Seed defaults — how a fresh row for this key starts out. + val defaultUnit: UnitType, + val defaultColor: Int, + val defaultIcon: MeasurementTypeIcon, + val isDerived: Boolean, + val defaultEnabled: Boolean, + val defaultPinned: Boolean, + val defaultOnRightYAxis: Boolean, + val isInternal: Boolean + ) { + override fun equals(other: Any?): Boolean = + this === other || (other is Key<*> && other.identity == identity) + + override fun hashCode(): Int = identity.hashCode() + + override fun toString(): String = "Key($identity)" + } + + /** + * The predefined vocabulary — replaces both the former MeasurementTypeKey enum and + * the former seed list in OpenScaleApp, which had to be kept in sync by hand. + * + * INVARIANT (guarded by a test): every builtin id is the lowercased historical enum + * name (`body_fat`, `caliper_1`). That single rule keeps two contracts correct at + * once — the 15→16 migration rewrites `'builtin.' || lower(key)`, and the CSV export + * keeps its historical `BODY_FAT` headers. + * + * Declaration order is the canonical display order (migrations re-stamp displayOrder + * from it), which is why it follows the former seed list, not the former enum. + */ + companion object { + const val BUILTIN_PREFIX = "builtin." + const val DEVICE_PREFIX = "ble." + const val USER_PREFIX = "user." + /** The pre-16 `key` column sentinel every custom row shared; only the 15→16 + * migration still needs it to recognise legacy rows. */ + const val LEGACY_CUSTOM_KEY = "CUSTOM" + + private const val DEVICE_DEFAULT_COLOR = 0xFFFFA726.toInt() + + private val ordered = mutableListOf>() + + private fun reg(key: Key): Key { + ordered += key + return key + } + + private val WEIGHT_UNITS = listOf(UnitType.KG, UnitType.LB, UnitType.ST) + private val COMPOSITION_UNITS = listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST) + private val LENGTH_UNITS = listOf(UnitType.CM, UnitType.INCH) + private val NONE_UNIT = listOf(UnitType.NONE) + + // T is the unit value class the key's raw values arrive as (Kg, Percent, ...); + // Float only for dimensionless quantities. wire must name the same unit. + private fun builtinFloat( + id: String, @StringRes nameRes: Int, units: List, color: Long, + icon: MeasurementTypeIcon, wire: UnitType = units.first(), + derived: Boolean = false, enabled: Boolean = true, pinned: Boolean = false, + rightAxis: Boolean = false, isInternal: Boolean = false + ): Key = reg( + Key( + BUILTIN_PREFIX + id, nameRes, InputFieldType.FLOAT, wire, units, + listOf(InputFieldType.FLOAT), units.first(), color.toInt(), icon, + derived, enabled, pinned, rightAxis, isInternal + ) + ) + + /** Body weight — raw values arrive as [Kg]. */ + val WEIGHT: Key = builtinFloat("weight", R.string.measurement_type_weight, WEIGHT_UNITS, 0xFF7E57C2, MeasurementTypeIcon.IC_WEIGHT, pinned = true, rightAxis = true) + /** Body mass index — dimensionless; usually derived by the app, set it only if the scale reports its own. */ + val BMI: Key = builtinFloat("bmi", R.string.measurement_type_bmi, NONE_UNIT, 0xFFFFCA28, MeasurementTypeIcon.IC_BMI, derived = true, pinned = true) + /** Body fat — raw values arrive as [Percent] of body weight. */ + val BODY_FAT: Key = builtinFloat("body_fat", R.string.measurement_type_body_fat, COMPOSITION_UNITS, 0xFFEF5350, MeasurementTypeIcon.IC_BODY_FAT, pinned = true) + /** Body water — raw values arrive as [Percent] of body weight. */ + val WATER: Key = builtinFloat("water", R.string.measurement_type_water, COMPOSITION_UNITS, 0xFF29B6F6, MeasurementTypeIcon.IC_WATER, pinned = true) + /** Muscle mass — raw values arrive as [Percent] of body weight. */ + val MUSCLE: Key = builtinFloat("muscle", R.string.measurement_type_muscle, COMPOSITION_UNITS, 0xFF66BB6A, MeasurementTypeIcon.IC_MUSCLE, pinned = true) + /** Lean body mass — raw values arrive as [Kg]. */ + val LBM: Key = builtinFloat("lbm", R.string.measurement_type_lbm, WEIGHT_UNITS, 0xFF4DBAC0, MeasurementTypeIcon.IC_LBM) + /** Bone mass — raw values arrive as [Kg]. */ + val BONE: Key = builtinFloat("bone", R.string.measurement_type_bone, listOf(UnitType.KG, UnitType.LB), 0xFFBDBDBD, MeasurementTypeIcon.IC_BONE) + /** Waist circumference — raw values arrive as [Cm]. */ + val WAIST: Key = builtinFloat("waist", R.string.measurement_type_waist, LENGTH_UNITS, 0xFF78909C, MeasurementTypeIcon.IC_WAIST) + /** Waist-to-hip ratio — dimensionless, derived by the app. */ + val WHR: Key = builtinFloat("whr", R.string.measurement_type_whr, NONE_UNIT, 0xFFFFA726, MeasurementTypeIcon.IC_WHR, derived = true) + /** Waist-to-height ratio — dimensionless, derived by the app. */ + val WHTR: Key = builtinFloat("whtr", R.string.measurement_type_whtr, NONE_UNIT, 0xFFFF7043, MeasurementTypeIcon.IC_WHTR, derived = true) + /** Hip circumference — raw values arrive as [Cm]. */ + val HIPS: Key = builtinFloat("hips", R.string.measurement_type_hips, LENGTH_UNITS, 0xFF5C6BC0, MeasurementTypeIcon.IC_HIPS) + /** Visceral fat rating — dimensionless vendor scale (typically 1..59). */ + val VISCERAL_FAT: Key = builtinFloat("visceral_fat", R.string.measurement_type_visceral_fat, NONE_UNIT, 0xFFD84315, MeasurementTypeIcon.IC_VISCERAL_FAT) + /** Chest circumference — raw values arrive as [Cm]. */ + val CHEST: Key = builtinFloat("chest", R.string.measurement_type_chest, LENGTH_UNITS, 0xFF8E24AA, MeasurementTypeIcon.IC_CHEST) + /** Thigh circumference — raw values arrive as [Cm]. */ + val THIGH: Key = builtinFloat("thigh", R.string.measurement_type_thigh, LENGTH_UNITS, 0xFFA1887F, MeasurementTypeIcon.IC_THIGH) + /** Biceps circumference — raw values arrive as [Cm]. */ + val BICEPS: Key = builtinFloat("biceps", R.string.measurement_type_biceps, LENGTH_UNITS, 0xFFEC407A, MeasurementTypeIcon.IC_BICEPS) + /** Neck circumference — raw values arrive as [Cm]. */ + val NECK: Key = builtinFloat("neck", R.string.measurement_type_neck, LENGTH_UNITS, 0xFFB0BEC5, MeasurementTypeIcon.IC_NECK) + /** Caliper skinfold site 1 — raw values arrive as [Cm]. */ + val CALIPER_1: Key = builtinFloat("caliper_1", R.string.measurement_type_caliper1, LENGTH_UNITS, 0xFFFFF59D, MeasurementTypeIcon.IC_CALIPER1) + /** Caliper skinfold site 2 — raw values arrive as [Cm]. */ + val CALIPER_2: Key = builtinFloat("caliper_2", R.string.measurement_type_caliper2, LENGTH_UNITS, 0xFFFFE082, MeasurementTypeIcon.IC_CALIPER2) + /** Caliper skinfold site 3 — raw values arrive as [Cm]. */ + val CALIPER_3: Key = builtinFloat("caliper_3", R.string.measurement_type_caliper3, LENGTH_UNITS, 0xFFFFCC80, MeasurementTypeIcon.IC_CALIPER3) + /** Caliper body fat — **%**, derived by the app from the three sites. */ + val CALIPER: Key = builtinFloat("caliper", R.string.measurement_type_fat_caliper, listOf(UnitType.PERCENT), 0xFFFB8C00, MeasurementTypeIcon.IC_FAT_CALIPER, derived = true) + /** Basal metabolic rate — raw values arrive as [Kcal] per day (set it only if the scale reports its own; the app derives it otherwise). */ + val BMR: Key = builtinFloat("bmr", R.string.measurement_type_bmr, listOf(UnitType.KCAL), 0xFFAB47BC, MeasurementTypeIcon.IC_BMR, derived = true) + /** Total daily energy expenditure — **kcal/day**, derived by the app. */ + val TDEE: Key = builtinFloat("tdee", R.string.measurement_type_tdee, listOf(UnitType.KCAL), 0xFF26A69A, MeasurementTypeIcon.IC_TDEE, derived = true) + /** Heart rate — raw values arrive as [Bpm]. */ + val HEART_RATE: Key = reg( + Key( + BUILTIN_PREFIX + "heart_rate", R.string.measurement_type_heart_rate, + InputFieldType.INT, UnitType.BPM, listOf(UnitType.BPM), listOf(InputFieldType.INT), + UnitType.BPM, 0xFFE91E63.toInt(), MeasurementTypeIcon.IC_M_HEART_RATE, + false, true, false, false, false + ) + ) + /** Bioimpedance (high/single frequency) — raw values arrive as [Ohm]. */ + val IMPEDANCE: Key = builtinFloat("impedance", R.string.measurement_type_impedance, listOf(UnitType.OHM), 0xFF607D8B, MeasurementTypeIcon.IC_DEFAULT, enabled = false, isInternal = true) + /** Bioimpedance (low frequency) — raw values arrive as [Ohm]. */ + val IMPEDANCE_LOW: Key = builtinFloat("impedance_low", R.string.measurement_type_impedance_low, listOf(UnitType.OHM), 0xFF455A64, MeasurementTypeIcon.IC_DEFAULT, enabled = false, isInternal = true) + // ECW, ICW and BCM are deliberately absent: only a dual-frequency BIA scale + // reports them, so they live in the ble.* namespace, declared by the S400 handler. + // MIGRATION_15_16 retires the rows older installs carry. + /** Protein — raw values arrive as [Percent] of body weight. */ + val PROTEIN: Key = builtinFloat("protein", R.string.measurement_type_protein, COMPOSITION_UNITS, 0xFF9CCC65, MeasurementTypeIcon.IC_M_PROTEIN) + /** Calorie intake — **kcal**, entered by the user, not by scales. */ + val CALORIES: Key = builtinFloat("calories", R.string.measurement_type_calories, listOf(UnitType.KCAL), 0xFF4CAF50, MeasurementTypeIcon.IC_CALORIES) + /** Free-text note attached to a measurement. */ + val COMMENT: Key = reg( + Key( + BUILTIN_PREFIX + "comment", R.string.measurement_type_comment, + InputFieldType.TEXT, UnitType.NONE, NONE_UNIT, listOf(InputFieldType.TEXT), + UnitType.NONE, 0xFFE0E0E0.toInt(), MeasurementTypeIcon.IC_COMMENT, + false, true, true, false, false + ) + ) + /** Table column for the measurement date — not a value handlers set (use [ScaleMeasurement.dateTime]). */ + val DATE: Key = reg( + Key( + BUILTIN_PREFIX + "date", R.string.measurement_type_date, + InputFieldType.DATE, UnitType.NONE, NONE_UNIT, listOf(InputFieldType.DATE), + UnitType.NONE, 0xFF9E9E9E.toInt(), MeasurementTypeIcon.IC_DATE, + false, true, false, false, false + ) + ) + /** Table column for the measurement time — not a value handlers set (use [ScaleMeasurement.dateTime]). */ + val TIME: Key = reg( + Key( + BUILTIN_PREFIX + "time", R.string.measurement_type_time, + InputFieldType.TIME, UnitType.NONE, NONE_UNIT, listOf(InputFieldType.TIME), + UnitType.NONE, 0xFF757575.toInt(), MeasurementTypeIcon.IC_TIME, + false, true, false, false, false + ) + ) + /** Table column for the user name — carries no value. */ + val USER: Key = reg( + Key( + BUILTIN_PREFIX + "user", R.string.measurement_type_user, + InputFieldType.USER, UnitType.NONE, NONE_UNIT, listOf(InputFieldType.USER), + UnitType.NONE, 0xFF90A4AE.toInt(), MeasurementTypeIcon.IC_USER, + false, true, false, false, false + ) + ) + + /** All predefined keys, in canonical display order. */ + val allKeys: List> = ordered.toList() + + private val index: Map> = allKeys.associateBy { it.identity } + + /** The predefined key behind [identity], or null for `ble.*`/`user.*` identities. */ + fun keyOf(identity: String): Key<*>? = index[identity] + + /** + * A quantity a scale handler contributes, e.g. a segmental body-fat percentage. + * Declare it as a constant in the handler's companion object; prefer a semantic + * path a second scale reporting the same quantity can reuse + * (`"segmental.fat.left_arm"`) over a vendor-bound one, so the user's history + * survives switching scales. Everything besides the identity only seeds the type + * on first use — the user may change all of it afterwards. + * + * The factory picks the delivery unit: [devicePercent] keys take [Percent] values, + * [deviceKg] takes [Kg], [deviceFloat] a plain dimensionless [Float]. + */ + fun devicePercent( + path: String, + @StringRes nameResId: Int, + icon: MeasurementTypeIcon = MeasurementTypeIcon.IC_DEFAULT, + color: Int = DEVICE_DEFAULT_COLOR, + enabled: Boolean = true, + pinned: Boolean = false, + rightAxis: Boolean = false, + isInternal: Boolean = false + ): Key = deviceFloatKey(path, nameResId, UnitType.PERCENT, icon, color, enabled, pinned, rightAxis, isInternal) + + /** See [devicePercent]; raw values arrive as [Kg]. */ + fun deviceKg( + path: String, + @StringRes nameResId: Int, + icon: MeasurementTypeIcon = MeasurementTypeIcon.IC_DEFAULT, + color: Int = DEVICE_DEFAULT_COLOR, + enabled: Boolean = true, + pinned: Boolean = false, + rightAxis: Boolean = false, + isInternal: Boolean = false + ): Key = deviceFloatKey(path, nameResId, UnitType.KG, icon, color, enabled, pinned, rightAxis, isInternal) + + /** See [devicePercent]; a dimensionless decimal (score, index, ratio). */ + fun deviceFloat( + path: String, + @StringRes nameResId: Int, + icon: MeasurementTypeIcon = MeasurementTypeIcon.IC_DEFAULT, + color: Int = DEVICE_DEFAULT_COLOR, + enabled: Boolean = true, + pinned: Boolean = false, + rightAxis: Boolean = false, + isInternal: Boolean = false + ): Key = deviceFloatKey(path, nameResId, UnitType.NONE, icon, color, enabled, pinned, rightAxis, isInternal) + + private fun deviceFloatKey( + path: String, @StringRes nameResId: Int, wireUnit: UnitType, + icon: MeasurementTypeIcon, color: Int, enabled: Boolean, pinned: Boolean, + rightAxis: Boolean, isInternal: Boolean + ): Key = Key( + DEVICE_PREFIX + path, nameResId, InputFieldType.FLOAT, wireUnit, + wireUnit.convertibleUnits(), listOf(InputFieldType.FLOAT), + wireUnit, color, icon, false, enabled, pinned, rightAxis, isInternal + ) + + /** A whole number a scale handler contributes, e.g. a count or a score. */ + fun deviceInt( + path: String, + @StringRes nameResId: Int, + wireUnit: UnitType = UnitType.NONE, + icon: MeasurementTypeIcon = MeasurementTypeIcon.IC_DEFAULT, + color: Int = DEVICE_DEFAULT_COLOR, + enabled: Boolean = true, + pinned: Boolean = false, + isInternal: Boolean = false + ): Key = Key( + DEVICE_PREFIX + path, nameResId, InputFieldType.INT, wireUnit, + listOf(wireUnit), listOf(InputFieldType.INT), + wireUnit, color, icon, false, enabled, pinned, false, isInternal + ) + + /** Free text a scale handler contributes, e.g. a body-type classification code. */ + fun deviceText( + path: String, + @StringRes nameResId: Int, + icon: MeasurementTypeIcon = MeasurementTypeIcon.IC_DEFAULT, + color: Int = DEVICE_DEFAULT_COLOR, + enabled: Boolean = true, + pinned: Boolean = false, + isInternal: Boolean = false + ): Key = Key( + DEVICE_PREFIX + path, nameResId, InputFieldType.TEXT, UnitType.NONE, + listOf(UnitType.NONE), listOf(InputFieldType.TEXT), + UnitType.NONE, color, icon, false, enabled, pinned, false, isInternal + ) + + /** Fresh rows for every predefined key, in canonical display order — the seed set. */ + fun seedRows(): List = allKeys.map { key -> + MeasurementType( + identity = key.identity, + name = null, + color = key.defaultColor, + icon = key.defaultIcon, + unit = key.defaultUnit, + inputType = key.inputType, + isDerived = key.isDerived, + isEnabled = key.defaultEnabled, + isPinned = key.defaultPinned, + isOnRightYAxis = key.defaultOnRightYAxis, + isInternal = key.isInternal + ) + } + + /** Strips the namespace prefix and renders the rest as an UPPER_SNAKE column key. */ + fun identityColumnKey(identity: String): String = + identity.substringAfter('.').replace('.', '_').uppercase() + + /** Reserved column headers a user-created type must never shadow. */ + private val BUILT_IN_COLUMN_KEYS: Set = + allKeys.map { identityColumnKey(it.identity) }.toSet() + + /** + * Builds the `user.*` identity for a user-created type from its display name. + * Uniqueness is checked on the *derived column key*, so a type called "Weight" + * cannot end up sharing the predefined `WEIGHT` column, and "Schritte" / + * "schritte" cannot collide either; a taken key gets a `_2`, `_3`, … suffix. + * + * @param takenColumnKeys column keys of every other type, compared case-insensitively. + */ + fun userIdentityFor(name: String?, takenColumnKeys: Set): String { + val slug = slugify(name) + val reserved = BUILT_IN_COLUMN_KEYS + takenColumnKeys.map { it.uppercase() } + + var candidate = slug + var suffix = 1 + while (identityColumnKey(USER_PREFIX + candidate) in reserved) { + suffix++ + candidate = "${slug}_$suffix" } - } else { - context.getString(key.localizedNameResId) + return USER_PREFIX + candidate + } + + /** + * The `user.*` identity for a CSV column the user typed in themselves, or null + * when they may not have it — blank, unslugifiable, or already taken. Unlike + * [userIdentityFor] nothing is numbered away: the user asked for that exact + * column, so the editor shows an error instead of silently working around it. + * + * @param takenColumnKeys every other type's column key; the edited type's own + * excluded, so it can be left as it is. + */ + fun userIdentityFrom(columnKey: String, takenColumnKeys: Set): String? { + val slug = slugify(columnKey) + if (columnKey.isBlank() || slug == UNNAMED_SLUG) return null + + val identity = USER_PREFIX + slug + val reserved = BUILT_IN_COLUMN_KEYS + takenColumnKeys.map { it.uppercase() } + return identity.takeIf { identityColumnKey(it) !in reserved } } + + /** Not "custom": that would derive the CUSTOM column key. */ + private const val UNNAMED_SLUG = "unnamed" + + /** lowercase, non-alphanumeric to '_', collapsed and trimmed. Unicode aware. */ + private fun slugify(text: String?): String = text.orEmpty() + .lowercase() + .map { if (it.isLetterOrDigit()) it else '_' } + .joinToString("") + .replace(Regex("_+"), "_") + .trim('_') + .ifBlank { UNNAMED_SLUG } } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt index 304abe315..ad0119c84 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt @@ -26,12 +26,10 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.User import com.health.openscale.core.data.UserIcon import com.health.openscale.core.data.UserGoals -import com.health.openscale.getDefaultMeasurementTypes import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -47,7 +45,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase = Room.databaseBuilder(ctx, AppDatabase::class.java, AppDatabase.Companion.DATABASE_NAME) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16) .build() @Provides @@ -74,7 +72,7 @@ object DatabaseModule { MeasurementValue::class, MeasurementType::class, ], - version = 15, + version = 16, exportSchema = true ) @TypeConverters(DatabaseConverters::class) @@ -91,6 +89,16 @@ abstract class AppDatabase : RoomDatabase() { } } +/** + * The pre-16 `key` column value of a predefined key (`WEIGHT`, `BODY_FAT`). + * + * The migrations below run at chain positions where the schema still stores enum names, so + * they must keep writing the old format; 15→16 converts afterwards. Derived from the + * identity instead of the removed enum — behaviour is byte-identical. + */ +private val MeasurementType.Key<*>.legacyKeyName: String + get() = MeasurementType.identityColumnKey(identity) + // ----------------------------------------------------------------------------- // Legacy v2.x migrations (schema versions 1..6). // @@ -325,8 +333,12 @@ val MIGRATION_6_7 = object : Migration(6, 7) { db.execSQL("CREATE INDEX IF NOT EXISTS `index_MeasurementValue_measurementId` ON `MeasurementValue` (`measurementId`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_MeasurementValue_typeId` ON `MeasurementValue` (`typeId`)") - // --- Create measurement type idempotent (INSERT OR IGNORE, used UNIQUE-Index) --- - fun ensureType(db: SupportSQLiteDatabase, type: MeasurementType, displayOrder: Int) { + // --- Create measurement types idempotently (INSERT OR IGNORE over the UNIQUE + // index created above). The OR IGNORE only works because this migration just + // created a *unique* index on `key`; MIGRATION_12_13 later drops it, so anything + // running after that cannot rely on this pattern — see the notes there and in + // 13_14 / 14_15. + fun ensureType(db: SupportSQLiteDatabase, key: MeasurementType.Key<*>, displayOrder: Int) { db.execSQL( """ INSERT OR IGNORE INTO MeasurementType @@ -335,24 +347,24 @@ val MIGRATION_6_7 = object : Migration(6, 7) { VALUES (?,?,?,?,?,?,?,?,?,?,?) """.trimIndent(), arrayOf( - type.key.name, + key.legacyKeyName, null, - type.color, - type.icon.name, - type.unit.name, - type.inputType.name, + key.defaultColor, + key.defaultIcon.name, + key.defaultUnit.name, + key.inputType.name, displayOrder, - if (type.isDerived) 1 else 0, - if (type.isEnabled) 1 else 0, - if (type.isPinned) 1 else 0, - if (type.isOnRightYAxis) 1 else 0 + if (key.isDerived) 1 else 0, + if (key.defaultEnabled) 1 else 0, + if (key.defaultPinned) 1 else 0, + if (key.defaultOnRightYAxis) 1 else 0 ) ) } var order = 1 - for (type in getDefaultMeasurementTypes()) { - ensureType(db, type, order++) + for (key in MeasurementType.allKeys) { + ensureType(db, key, order++) } // --- Migrate users --- @@ -510,43 +522,45 @@ val MIGRATION_12_13 = object : Migration(12, 13) { val MIGRATION_13_14 = object : Migration(13, 14) { override fun migrate(db: SupportSQLiteDatabase) { // Step 1: Add HEART_RATE if it's missing, using INSERT OR IGNORE. - // We find the definition from the default list to get its properties (color, icon, etc.). - val heartRateType = getDefaultMeasurementTypes().find { it.key == MeasurementTypeKey.HEART_RATE } - - if (heartRateType != null) { - db.execSQL( - """ - INSERT OR IGNORE INTO MeasurementType - (`key`, `name`, `color`, `icon`, `unit`, `inputType`, `displayOrder`, - `isDerived`, `isEnabled`, `isPinned`, `isOnRightYAxis`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """.trimIndent(), - arrayOf( - heartRateType.key.name, - null, - heartRateType.color, - heartRateType.icon.name, - heartRateType.unit.name, - heartRateType.inputType.name, - -1, // Use a temporary displayOrder to avoid conflicts - if (heartRateType.isDerived) 1 else 0, - if (heartRateType.isEnabled) 1 else 0, - if (heartRateType.isPinned) 1 else 0, - if (heartRateType.isOnRightYAxis) 1 else 0 - ) + // + // Caveat, kept as-is because the migration has shipped: the OR IGNORE has no + // conflict target here — MIGRATION_12_13 dropped the unique index on `key`, so + // this insert is in fact unconditional. A database that came up through + // MIGRATION_6_7 already holds HEART_RATE (6_7 seeds the whole registry) and ends + // up with it twice. MIGRATION_15_16 merges such duplicates back onto their + // oldest row. + val heartRate = MeasurementType.HEART_RATE + db.execSQL( + """ + INSERT OR IGNORE INTO MeasurementType + (`key`, `name`, `color`, `icon`, `unit`, `inputType`, `displayOrder`, + `isDerived`, `isEnabled`, `isPinned`, `isOnRightYAxis`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + arrayOf( + heartRate.legacyKeyName, + null, + heartRate.defaultColor, + heartRate.defaultIcon.name, + heartRate.defaultUnit.name, + heartRate.inputType.name, + -1, // Use a temporary displayOrder to avoid conflicts + if (heartRate.isDerived) 1 else 0, + if (heartRate.defaultEnabled) 1 else 0, + if (heartRate.defaultPinned) 1 else 0, + if (heartRate.defaultOnRightYAxis) 1 else 0 ) - } + ) - // Step 2: Re-order ALL existing types to match the getDefaultMeasurementTypes() list. + // Step 2: Re-order ALL existing types to match the canonical registry order. // This ensures the order is identical for new installs and migrated users. - val defaultTypesInOrder = getDefaultMeasurementTypes() db.beginTransaction() try { - defaultTypesInOrder.forEachIndexed { index, measurementType -> + MeasurementType.allKeys.forEachIndexed { index, key -> val displayOrder = index + 1 // Room/SQL indices are often 1-based db.execSQL( "UPDATE MeasurementType SET displayOrder = ? WHERE `key` = ?", - arrayOf(displayOrder, measurementType.key.name) + arrayOf(displayOrder, key.legacyKeyName) ) } db.setTransactionSuccessful() @@ -566,21 +580,18 @@ val MIGRATION_14_15 = object : Migration(14, 15) { "ADD COLUMN isInternal INTEGER NOT NULL DEFAULT 0" ) - // Seed the six new MeasurementTypes introduced for S400 dual-frequency - // body composition: IMPEDANCE, IMPEDANCE_LOW (raw band readings, kept - // internal) and ECW, ICW, PROTEIN, BCM (enabled). Flags are taken from - // getDefaultMeasurementTypes(), so upgraded installs end up with the - // same defaults as fresh ones. - val newKeys = setOf( - MeasurementTypeKey.IMPEDANCE, - MeasurementTypeKey.IMPEDANCE_LOW, - MeasurementTypeKey.ECW, - MeasurementTypeKey.ICW, - MeasurementTypeKey.PROTEIN, - MeasurementTypeKey.BCM, + // Seed the MeasurementTypes introduced for S400 dual-frequency body composition. + // ECW, ICW and BCM are no longer predefined (they moved to the ble.* namespace, + // see MIGRATION_15_16), so this migration stops seeding them: a ≤14 database never + // gets the rows and the S400 handler creates them on first use instead. + // + // Same caveat as MIGRATION_13_14: without the unique index on `key` (dropped in + // 12_13) the OR IGNORE never fires, and databases that came through 6_7 get these + // a second time. MIGRATION_15_16 merges the duplicates. + val newKeys = listOf( + MeasurementType.IMPEDANCE, MeasurementType.IMPEDANCE_LOW, MeasurementType.PROTEIN, ) - val newTypes = getDefaultMeasurementTypes().filter { it.key in newKeys } - newTypes.forEach { type -> + newKeys.forEach { key -> db.execSQL( """ INSERT OR IGNORE INTO MeasurementType @@ -589,31 +600,30 @@ val MIGRATION_14_15 = object : Migration(14, 15) { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), arrayOf( - type.key.name, + key.legacyKeyName, null, - type.color, - type.icon.name, - type.unit.name, - type.inputType.name, + key.defaultColor, + key.defaultIcon.name, + key.defaultUnit.name, + key.inputType.name, -1, - if (type.isDerived) 1 else 0, - if (type.isEnabled) 1 else 0, - if (type.isPinned) 1 else 0, - if (type.isOnRightYAxis) 1 else 0, - if (type.isInternal) 1 else 0 + if (key.isDerived) 1 else 0, + if (key.defaultEnabled) 1 else 0, + if (key.defaultPinned) 1 else 0, + if (key.defaultOnRightYAxis) 1 else 0, + if (key.isInternal) 1 else 0 ) ) } // Re-apply displayOrder to keep new + existing types aligned with the - // canonical order from getDefaultMeasurementTypes(). - val defaultTypesInOrder = getDefaultMeasurementTypes() + // canonical registry order. db.beginTransaction() try { - defaultTypesInOrder.forEachIndexed { index, measurementType -> + MeasurementType.allKeys.forEachIndexed { index, key -> db.execSQL( "UPDATE MeasurementType SET displayOrder = ? WHERE `key` = ?", - arrayOf(index + 1, measurementType.key.name) + arrayOf(index + 1, key.legacyKeyName) ) } db.setTransactionSuccessful() @@ -622,3 +632,165 @@ val MIGRATION_14_15 = object : Migration(14, 15) { } } } + +val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + // One identity column for every measurement type: `builtin.weight` derived from the + // former enum name, `ble.*` for values a scale handler contributes, `user.*` for + // user-created types. A unique index over it then guarantees what the schema has + // not enforced since MIGRATION_12_13 dropped the unique index on `key`. + db.execSQL("DROP INDEX IF EXISTS `index_MeasurementType_key`") + db.execSQL("ALTER TABLE `MeasurementType` RENAME COLUMN `key` TO `identity`") + + // --- Step 1: collapse predefined types that exist more than once ------------------ + // + // Databases that came up through MIGRATION_6_7 carry seven duplicates: 6_7 seeds + // the whole registry, and 13_14 / 14_15 then insert HEART_RATE, IMPEDANCE, + // IMPEDANCE_LOW, ECW, ICW, PROTEIN and BCM a second time, because their INSERT OR + // IGNORE lost its conflict target with 12_13. + // + // The duplicates are not empty: BleConnector resolved types by key and kept the + // *last* row, so measurements taken since that upgrade were written to the + // duplicate. Values and goals are therefore moved to the surviving row rather than + // deleted, and only what the survivor already holds for the same measurement (or + // user) is dropped. Deletions are explicit instead of relying on ON DELETE + // CASCADE, whose enforcement during a migration depends on the connection's + // foreign-key pragma. + val idsByLegacyKey = LinkedHashMap>() + db.query( + "SELECT id, identity FROM MeasurementType WHERE identity != ? ORDER BY id ASC", + arrayOf(MeasurementType.LEGACY_CUSTOM_KEY) + ).use { cursor -> + while (cursor.moveToNext()) { + idsByLegacyKey.getOrPut(cursor.getString(1)) { mutableListOf() } + .add(cursor.getInt(0)) + } + } + + idsByLegacyKey.values.filter { it.size > 1 }.forEach { ids -> + val survivor = ids.first() + + ids.drop(1).forEach { duplicate -> + db.execSQL( + "UPDATE MeasurementValue SET typeId = ? WHERE typeId = ? AND measurementId " + + "NOT IN (SELECT measurementId FROM MeasurementValue WHERE typeId = ?)", + arrayOf(survivor, duplicate, survivor) + ) + db.execSQL( + "UPDATE user_goals SET measurementTypeId = ? WHERE measurementTypeId = ? " + + "AND userId NOT IN (SELECT userId FROM user_goals WHERE measurementTypeId = ?)", + arrayOf(survivor, duplicate, survivor) + ) + + db.execSQL("DELETE FROM MeasurementValue WHERE typeId = ?", arrayOf(duplicate)) + db.execSQL("DELETE FROM user_goals WHERE measurementTypeId = ?", arrayOf(duplicate)) + db.execSQL("DELETE FROM MeasurementType WHERE id = ?", arrayOf(duplicate)) + } + } + + // --- Step 2: predefined rows get their namespaced identity ------------------------ + // 'BODY_FAT' -> 'builtin.body_fat'. Correct because every registry id is, by + // guarded invariant, the lowercased historical enum name. + db.execSQL( + "UPDATE MeasurementType SET identity = 'builtin.' || lower(identity) " + + "WHERE identity != ?", + arrayOf(MeasurementType.LEGACY_CUSTOM_KEY) + ) + + // --- Step 3: retire the keys only a dual-frequency BIA scale ever filled ---------- + // + // ECW, ICW and BCM leave the predefined vocabulary: the Xiaomi S400 was the sole + // handler writing them, and any future dual-frequency scale reuses the same ble.* + // identities. The rows are rewritten in place, so their ids — and every value + // pointing at them — survive untouched. The paths are deliberately flat, so the + // derived CSV column stays the ECW / ICW / BCM these types always exported as. + // + // The name must be written here: once the row is no longer predefined its display + // name comes from storage, and a migration has no Context to localise with. Fresh + // creations through the handler get the translated name instead. + listOf( + Triple("builtin.ecw", "ble.ecw", "Extracellular water"), + Triple("builtin.icw", "ble.icw", "Intracellular water"), + Triple("builtin.bcm", "ble.bcm", "Body cell mass"), + ).forEach { (retired, identity, name) -> + db.execSQL( + "UPDATE MeasurementType SET identity = ?, name = ? WHERE identity = ?", + arrayOf(identity, name, retired) + ) + } + + // --- Step 4: user-created rows get user.* identities ------------------------------- + // Pre-16 has neither a constraint nor any validation on custom names, so a real + // database can hold two types called "Schritte"; userIdentityFor numbers those + // apart. Creating the index first would abort the migration on such a database and + // leave the app unable to open it at all. + val assignments = mutableListOf>() + + // Reserve the columns of every already-assigned row — including the ble.* ones + // step 3 just minted, which are no longer in the registry's reserved set. Without + // this, a v15 custom type named "ECW" would claim user.ecw and collide with + // ble.ecw on the CSV column. + val takenColumnKeys = mutableSetOf() + db.query( + "SELECT identity FROM MeasurementType WHERE identity != ?", + arrayOf(MeasurementType.LEGACY_CUSTOM_KEY) + ).use { cursor -> + while (cursor.moveToNext()) { + takenColumnKeys.add(MeasurementType.identityColumnKey(cursor.getString(0))) + } + } + + db.query( + "SELECT id, name FROM MeasurementType WHERE identity = ? ORDER BY id ASC", + arrayOf(MeasurementType.LEGACY_CUSTOM_KEY) + ).use { cursor -> + while (cursor.moveToNext()) { + val name = if (cursor.isNull(1)) null else cursor.getString(1) + val identity = MeasurementType.userIdentityFor(name, takenColumnKeys) + takenColumnKeys.add(MeasurementType.identityColumnKey(identity)) + assignments.add(cursor.getInt(0) to identity) + } + } + + assignments.forEach { (id, identity) -> + db.execSQL( + "UPDATE MeasurementType SET identity = ? WHERE id = ?", + arrayOf(identity, id) + ) + } + + // --- Step 5: re-apply the canonical order, as 13_14 and 14_15 do ------------------- + // Needed here because the merged duplicates left two rows sharing a displayOrder. + MeasurementType.allKeys.forEachIndexed { index, key -> + db.execSQL( + "UPDATE MeasurementType SET displayOrder = ? WHERE identity = ?", + arrayOf(index + 1, key.identity) + ) + } + + // Non-predefined rows (the retired ble.* ones and user customs) are renumbered + // behind the predefined block, keeping their relative order. The retired rows + // would otherwise keep the displayOrder they had as builtins and tie with the + // freshly re-stamped values, making the list order unstable. + val trailing = mutableListOf() + db.query( + "SELECT id FROM MeasurementType WHERE identity NOT LIKE 'builtin.%' " + + "ORDER BY displayOrder ASC, id ASC" + ).use { cursor -> + while (cursor.moveToNext()) trailing.add(cursor.getInt(0)) + } + trailing.forEachIndexed { index, id -> + db.execSQL( + "UPDATE MeasurementType SET displayOrder = ? WHERE id = ?", + arrayOf(MeasurementType.allKeys.size + 1 + index, id) + ) + } + + // Only now, with duplicates merged and every row assigned: the identity becomes an + // enforced invariant. + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_MeasurementType_identity` " + + "ON `MeasurementType`(`identity`)" + ) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseConverters.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseConverters.kt index 52aad8c46..0ff497604 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseConverters.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseConverters.kt @@ -22,19 +22,12 @@ import com.health.openscale.core.data.AmputationPart import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Limb -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType import kotlin.collections.joinToString import kotlin.collections.map class DatabaseConverters { - @TypeConverter - fun fromTypeKey(value: MeasurementTypeKey): String = value.name - - @TypeConverter - fun toTypeKey(value: String): MeasurementTypeKey = MeasurementTypeKey.valueOf(value) - // UnitType @TypeConverter fun fromUnitType(value: UnitType): String = value.name diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt index 64c5b6cd5..b3024b681 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt @@ -27,7 +27,6 @@ import android.net.Uri import com.health.openscale.BuildConfig import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -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.facade.SettingsFacade @@ -245,12 +244,12 @@ class DatabaseProvider : ContentProvider() { runBlocking { val allMeasurementTypes = databaseRepository.getAllMeasurementTypes().first() - val weightType = allMeasurementTypes.find { it.key == MeasurementTypeKey.WEIGHT } - val fatType = allMeasurementTypes.find { it.key == MeasurementTypeKey.BODY_FAT } - val waterType = allMeasurementTypes.find { it.key == MeasurementTypeKey.WATER } - val muscleType = allMeasurementTypes.find { it.key == MeasurementTypeKey.MUSCLE } + val weightType = allMeasurementTypes.find { it.key == MeasurementType.WEIGHT } + val fatType = allMeasurementTypes.find { it.key == MeasurementType.BODY_FAT } + val waterType = allMeasurementTypes.find { it.key == MeasurementType.WATER } + val muscleType = allMeasurementTypes.find { it.key == MeasurementType.MUSCLE } val typeIdMap = allMeasurementTypes.associate { it.key to it.id } - weightTypeIdFound = typeIdMap[MeasurementTypeKey.WEIGHT] + weightTypeIdFound = typeIdMap[MeasurementType.WEIGHT] if (weightType != null) { val targetWeightValue = ConverterUtils.convertFloatValueUnit(weightFromProviderInKg, UnitType.KG, weightType.unit) @@ -285,10 +284,8 @@ class DatabaseProvider : ContentProvider() { // supplied as a "values_json" payload are written too (canonical → user unit). val valuesJson = values.getAsString(MeasurementColumns.VALUES_JSON) if (valuesJson != null) { - val typesByKey = allMeasurementTypes.associateBy { it.key.name } - val typesById = allMeasurementTypes.associateBy { it.id } val existingTypeIds = measurementValuesToInsert.mapTo(HashSet()) { it.typeId } - GenericValueJson.parse(valuesJson, typesByKey, typesById).forEach { (type, parsedValue) -> + GenericValueJson.parse(valuesJson, allMeasurementTypes).forEach { (type, parsedValue) -> if (type.id !in existingTypeIds) { measurementValuesToInsert.add(parsedValue) existingTypeIds.add(type.id) @@ -298,7 +295,7 @@ class DatabaseProvider : ContentProvider() { } if (weightTypeIdFound == null) { // Double check if weight type ID was resolved - LogManager.e(TAG, "Weight MeasurementTypeKey system configuration issue. Cannot insert essential weight value.") + LogManager.e(TAG, "Weight measurement type missing. Cannot insert essential weight value.") return null } @@ -398,7 +395,7 @@ class DatabaseProvider : ContentProvider() { // Helper to update a value, converting from base unit to user's configured unit. suspend fun processValueUpdate( newValueFromProvider: Float?, // Value in base unit (kg or %) - typeKey: MeasurementTypeKey, + typeKey: MeasurementType.Key<*>, cvKey: String ) { if (!values.containsKey(cvKey)) return // This value is not being updated. @@ -414,7 +411,7 @@ class DatabaseProvider : ContentProvider() { if (newValueFromProvider != null) { // A new value is provided (insert or update) // --- START: Unit Conversion --- val targetValue = when { - typeKey == MeasurementTypeKey.WEIGHT -> { + typeKey == MeasurementType.WEIGHT -> { // Convert incoming KG to user's weight unit. ConverterUtils.convertFloatValueUnit(newValueFromProvider, UnitType.KG, targetType.unit) } @@ -422,8 +419,8 @@ class DatabaseProvider : ContentProvider() { // For composition, convert incoming % to absolute weight in user's unit. // The base weight for calculation must be the new weight being provided. val baseWeightInKg = weightFromProviderInKg ?: existingMeasurementWithValues.values - .find { it.type.key == MeasurementTypeKey.WEIGHT }?.value?.floatValue?.let { - typeMap[MeasurementTypeKey.WEIGHT]?.unit?.let { unit -> + .find { it.type.key == MeasurementType.WEIGHT }?.value?.floatValue?.let { + typeMap[MeasurementType.WEIGHT]?.unit?.let { unit -> ConverterUtils.convertFloatValueUnit(it, unit, UnitType.KG) } } @@ -465,10 +462,10 @@ class DatabaseProvider : ContentProvider() { } // Process updates for all relevant measurement types - processValueUpdate(weightFromProviderInKg, MeasurementTypeKey.WEIGHT, MeasurementColumns.WEIGHT) - processValueUpdate(fatFromProviderPercent, MeasurementTypeKey.BODY_FAT, MeasurementColumns.BODY_FAT) - processValueUpdate(waterFromProviderPercent, MeasurementTypeKey.WATER, MeasurementColumns.WATER) - processValueUpdate(muscleFromProviderPercent, MeasurementTypeKey.MUSCLE, MeasurementColumns.MUSCLE) + processValueUpdate(weightFromProviderInKg, MeasurementType.WEIGHT, MeasurementColumns.WEIGHT) + processValueUpdate(fatFromProviderPercent, MeasurementType.BODY_FAT, MeasurementColumns.BODY_FAT) + processValueUpdate(waterFromProviderPercent, MeasurementType.WATER, MeasurementColumns.WATER) + processValueUpdate(muscleFromProviderPercent, MeasurementType.MUSCLE, MeasurementColumns.MUSCLE) // Generic inbound values (all types incl. custom) supplied as a // "values_json" payload — mirrors the insert path. Lets an external app @@ -476,17 +473,15 @@ class DatabaseProvider : ContentProvider() { // Values arrive canonical and are converted to the user's unit by parse(). val valuesJson = values.getAsString(MeasurementColumns.VALUES_JSON) if (valuesJson != null) { - val typesByKey = allMeasurementTypes.associateBy { it.key.name } - val typesById = allMeasurementTypes.associateBy { it.id } // Skip types already handled by the explicit weight/fat/water/muscle // columns above, so a duplicate in the JSON can't overwrite them. val handledTypeIds = buildSet { - if (values.containsKey(MeasurementColumns.WEIGHT)) typeMap[MeasurementTypeKey.WEIGHT]?.id?.let(::add) - if (values.containsKey(MeasurementColumns.BODY_FAT)) typeMap[MeasurementTypeKey.BODY_FAT]?.id?.let(::add) - if (values.containsKey(MeasurementColumns.WATER)) typeMap[MeasurementTypeKey.WATER]?.id?.let(::add) - if (values.containsKey(MeasurementColumns.MUSCLE)) typeMap[MeasurementTypeKey.MUSCLE]?.id?.let(::add) + if (values.containsKey(MeasurementColumns.WEIGHT)) typeMap[MeasurementType.WEIGHT]?.id?.let(::add) + if (values.containsKey(MeasurementColumns.BODY_FAT)) typeMap[MeasurementType.BODY_FAT]?.id?.let(::add) + if (values.containsKey(MeasurementColumns.WATER)) typeMap[MeasurementType.WATER]?.id?.let(::add) + if (values.containsKey(MeasurementColumns.MUSCLE)) typeMap[MeasurementType.MUSCLE]?.id?.let(::add) } - GenericValueJson.parse(valuesJson, typesByKey, typesById).forEach { (type, parsedValue) -> + GenericValueJson.parse(valuesJson, allMeasurementTypes).forEach { (type, parsedValue) -> if (type.id in handledTypeIds) return@forEach val existingValue = existingMeasurementWithValues.values.find { it.type.id == type.id } if (existingValue != null) { @@ -588,7 +583,14 @@ class DatabaseProvider : ContentProvider() { private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH) // v2: sync Intents carry userId on delete/clear (multi-user routing). openScale-sync // requires >= 2 and warns the user to update openScale otherwise. - private const val API_VERSION = 2 + /** + * ContentProvider API version, served via the meta URI. + * v2 — sync intents carry the userId on delete/clear. + * v3 — generic value JSON identifies types by `identity` (namespaced, stable across + * installations); the legacy `key`/`typeId` fields are gone from the wire in + * both directions — inbound entries without an identity are dropped. + */ + private const val API_VERSION = 3 val AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" private const val MATCH_TYPE_META = 1 diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt index a6c7d9df6..60b27f022 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseRepository.kt @@ -19,7 +19,6 @@ package com.health.openscale.core.database import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.User import com.health.openscale.core.data.UserGoals @@ -222,36 +221,40 @@ class DatabaseRepository @Inject constructor( // --- Measurement Type Operations --- suspend fun insertAllMeasurementTypes(types: List) { - val existingTypes = measurementTypeDao.getAll().first() - val existingKeys = existingTypes.map { it.key }.toSet() + val existingIdentities = measurementTypeDao.getAll().first().map { it.identity }.toSet() - val typesToInsert = types.filter { type -> - // Allow insertion if the key is CUSTOM or if the key does not already exist in the database. - type.key == MeasurementTypeKey.CUSTOM || type.key !in existingKeys - } + // Plain identity check, no special cases — the unique index on `identity` is the + // actual guarantee; this only avoids pointless constraint violations on re-seeding. + val typesToInsert = types.filter { it.identity !in existingIdentities } if (typesToInsert.isNotEmpty()) { LogManager.i(TAG, "Found ${typesToInsert.size} new measurement types to insert.") measurementTypeDao.insertAll(typesToInsert) } else { - LogManager.d(TAG, "No new measurement types to insert. All provided non-custom types already exist.") + LogManager.d(TAG, "No new measurement types to insert.") } } fun getAllMeasurementTypes(): Flow> = measurementTypeDao.getAll() + suspend fun getMeasurementTypeById(id: Int): MeasurementType? = + measurementTypeDao.getById(id) + + suspend fun getMeasurementTypeByIdentity(identity: String): MeasurementType? = + measurementTypeDao.getByIdentity(identity) + suspend fun insertMeasurementType(type: MeasurementType): Long { - LogManager.d(TAG, "Inserting measurement type: ${type.key}") // Logging the key + LogManager.d(TAG, "Inserting measurement type: ${type.identity}") return measurementTypeDao.insert(type) } suspend fun deleteMeasurementType(type: MeasurementType) { - LogManager.d(TAG, "Deleting measurement type with id: ${type.id}, key: ${type.key}") + LogManager.d(TAG, "Deleting measurement type with id: ${type.id}, identity: ${type.identity}") measurementTypeDao.delete(type) } suspend fun updateMeasurementType(type: MeasurementType) { - LogManager.d(TAG, "Updating measurement type with id: ${type.id}, key: ${type.key}") + LogManager.d(TAG, "Updating measurement type with id: ${type.id}, identity: ${type.identity}") measurementTypeDao.update(type) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementTypeDao.kt b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementTypeDao.kt index b47c27327..89f98e96d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementTypeDao.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/MeasurementTypeDao.kt @@ -42,4 +42,11 @@ interface MeasurementTypeDao { @Query("SELECT * FROM MeasurementType ORDER BY displayOrder ASC") fun getAll(): Flow> + + @Query("SELECT * FROM MeasurementType WHERE id = :id LIMIT 1") + suspend fun getById(id: Int): MeasurementType? + + /** Looks a type up by its stable identity — how handler-contributed types are resolved. */ + @Query("SELECT * FROM MeasurementType WHERE identity = :identity LIMIT 1") + suspend fun getByIdentity(identity: String): MeasurementType? } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt index 5a4ca6483..0a39df767 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt @@ -21,7 +21,6 @@ import android.R.attr.level import com.health.openscale.core.data.AggregationLevel import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.SmoothingAlgorithm import com.health.openscale.core.data.User @@ -293,6 +292,13 @@ class MeasurementFacade @Inject constructor( suspend fun addMeasurementType(type: MeasurementType): Result = typeCrud.add(type) + /** + * Resolves the measurement type a scale handler contributed, creating it on first use; + * null when it could neither be found nor created. + */ + suspend fun resolveOrCreateMeasurementType(key: MeasurementType.Key<*>): MeasurementType? = + typeCrud.resolveOrCreate(key) + suspend fun updateMeasurementType(type: MeasurementType): Result = typeCrud.update(type) @@ -323,8 +329,8 @@ class MeasurementFacade @Inject constructor( measuredAtMillis: Long, ) = evaluationUseCases.evaluate(type, value, userEvaluationContext, measuredAtMillis) - fun plausiblePercentRangeFor(typeKey: MeasurementTypeKey) = - evaluationUseCases.plausiblePercentRangeFor(typeKey) + fun plausiblePercentRangeFor(key: MeasurementType.Key<*>?) = + evaluationUseCases.plausiblePercentRangeFor(key) // ------------------------------------------------------------------------- // Selection helper diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt index df7530a45..858bd0346 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt @@ -26,9 +26,11 @@ import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.ConnectionStatus import com.health.openscale.core.data.Measurement -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.InputFieldType +import com.health.openscale.core.data.UnitValue +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementValue -import com.health.openscale.core.data.UnitType import com.health.openscale.core.facade.MeasurementFacade import com.health.openscale.core.utils.LogManager import com.health.openscale.ui.shared.SnackbarEvent @@ -349,7 +351,7 @@ class BleConnector( } is BluetoothEvent.MeasurementReceived -> { - LogManager.i(TAG, "Event: Measurement received from $deviceDisplayName: Weight ${event.measurement.weight}") + LogManager.i(TAG, "Event: Measurement received from $deviceDisplayName: Weight ${event.measurement[MeasurementType.WEIGHT]}") saveMeasurementFromEvent(event.measurement, deviceDisplayName) } @@ -452,7 +454,8 @@ class BleConnector( timestamp = measurementData.dateTime?.time ?: System.currentTimeMillis() ) - // Load all measurement types to (a) map keys -> IDs and (b) read target units for conversion. + // Load all measurement types once: predefined ones are resolved from this + // snapshot, device-contributed ones are created on first use. val types = measurementFacade.getAllMeasurementTypes().firstOrNull() ?: run { LogManager.e(TAG, "Could not load MeasurementTypes from DB for $deviceName.") @@ -463,110 +466,57 @@ class BleConnector( ) return@launch } - - val typeKeyToIdMap = types.associate { it.key to it.id } - val typeKeyToUnitMap = types.associate { it.key to it.unit } - - fun getTypeId(key: MeasurementTypeKey) = typeKeyToIdMap[key] - fun getTargetUnit(key: MeasurementTypeKey) = typeKeyToUnitMap[key] ?: UnitType.NONE - - // Declare raw units provided by ScaleMeasurement for each key. - // Percent-based values will "convert" to themselves (converter returns unchanged value). - val rawUnitByKey: Map = mapOf( - MeasurementTypeKey.WEIGHT to UnitType.KG, - MeasurementTypeKey.BODY_FAT to UnitType.PERCENT, - MeasurementTypeKey.WATER to UnitType.PERCENT, - MeasurementTypeKey.MUSCLE to UnitType.PERCENT, - MeasurementTypeKey.VISCERAL_FAT to UnitType.PERCENT, - MeasurementTypeKey.BONE to UnitType.KG, - MeasurementTypeKey.LBM to UnitType.KG, - MeasurementTypeKey.HEART_RATE to UnitType.BPM, - MeasurementTypeKey.IMPEDANCE to UnitType.OHM, - MeasurementTypeKey.IMPEDANCE_LOW to UnitType.OHM, - MeasurementTypeKey.ECW to UnitType.PERCENT, - MeasurementTypeKey.ICW to UnitType.PERCENT, - MeasurementTypeKey.PROTEIN to UnitType.PERCENT, - MeasurementTypeKey.BCM to UnitType.KG, - MeasurementTypeKey.BMR to UnitType.KCAL - ) - + val typesByIdentity = types.associateBy { it.identity } + + // One loop for everything the handler reported. The key declares which + // column its value lands in and which unit it arrived in; the row knows the + // unit the user wants. ScaleMeasurement.set already dropped values that never + // describe a real measurement, so what is in the map is worth persisting — + // which is also why resolving (and thus creating) a device type only happens + // for values that survived: a broken packet must not leave an empty type + // behind. + // + // BMR goes in first so its presence guards the per-value recalc that runs + // after WEIGHT lands; otherwise DerivedValuesProcess would write its + // Mifflin-St Jeor BMR before the device's BIA-based BMR arrives, leaving two + // rows for the same typeId. val values = mutableListOf() - - /** - * Adds a converted float value for the given key if present & valid. - * - Reads the raw unit for the key (what the device/handler provided). - * - Looks up the target unit from MeasurementType. - * - Converts using existing ConverterUtils.convertFloatValueUnit. - */ - fun addConvertedIfValid( - value: Float?, - key: MeasurementTypeKey, - isValid: (Float) -> Boolean = { it.isFinite() && it > 0f } - ) { - val v = value ?: return - if (!isValid(v)) return - - val rawUnit = rawUnitByKey[key] ?: UnitType.NONE - val target = getTargetUnit(key) - - val converted = com.health.openscale.core.utils.ConverterUtils.convertFloatValueUnit( - v, rawUnit, target - ) - - getTypeId(key)?.let { typeId -> - values.add( - MeasurementValue( - measurementId = 0, - typeId = typeId, - floatValue = converted - ) + val reported = measurementData.values.entries + .sortedByDescending { it.key == MeasurementType.BMR } + + for ((key, raw) in reported) { + val type = typesByIdentity[key.identity] + ?: measurementFacade.resolveOrCreateMeasurementType(key) + ?: run { + LogManager.w(TAG, "($deviceName): Skipping ${key.identity}, type unavailable.") + null + } ?: continue + + values += when (key.inputType) { + InputFieldType.FLOAT -> MeasurementValue( + measurementId = 0, + typeId = type.id, + // The user may have configured a different unit than the wire one. + floatValue = com.health.openscale.core.utils.ConverterUtils + .convertFloatValueUnit( + if (raw is UnitValue) raw.value else raw as Float, + key.wireUnit, type.unit + ) ) - } - } - - /** - * Adds an integer value for the given key if present & valid. - * Used for heart rate which is stored as an Int. - */ - fun addConvertedIfValid( - value: Int?, - key: MeasurementTypeKey - ) { - val v = value ?: return - if (v <= 0) return - getTypeId(key)?.let { typeId -> - values.add( - MeasurementValue( - measurementId = 0, - typeId = typeId, - intValue = v - ) + InputFieldType.INT -> MeasurementValue( + measurementId = 0, typeId = type.id, + intValue = if (raw is Bpm) raw.value else raw as Int ) + InputFieldType.TEXT -> MeasurementValue( + measurementId = 0, typeId = type.id, textValue = raw as String + ) + InputFieldType.DATE, InputFieldType.TIME -> MeasurementValue( + measurementId = 0, typeId = type.id, dateValue = (raw as java.util.Date).time + ) + InputFieldType.USER -> continue } } - // BMR goes in first so its presence guards the per-value recalc that - // runs after WEIGHT lands; otherwise DerivedValuesProcess would write - // its Mifflin-St Jeor BMR before the device's BIA-based BMR arrives, - // leaving two rows for the same typeId. - addConvertedIfValid(measurementData.bmr, MeasurementTypeKey.BMR) - - // Collect all supported values from ScaleMeasurement, converting as needed. - addConvertedIfValid(measurementData.weight, MeasurementTypeKey.WEIGHT) - addConvertedIfValid(measurementData.fat, MeasurementTypeKey.BODY_FAT) - addConvertedIfValid(measurementData.water, MeasurementTypeKey.WATER) - addConvertedIfValid(measurementData.muscle, MeasurementTypeKey.MUSCLE) - addConvertedIfValid(measurementData.visceralFat, MeasurementTypeKey.VISCERAL_FAT) - addConvertedIfValid(measurementData.bone, MeasurementTypeKey.BONE) - addConvertedIfValid(measurementData.lbm, MeasurementTypeKey.LBM) - addConvertedIfValid(measurementData.heartRate, MeasurementTypeKey.HEART_RATE) - addConvertedIfValid(measurementData.impedance.toFloat(), MeasurementTypeKey.IMPEDANCE) - addConvertedIfValid(measurementData.impedanceLow.toFloat(), MeasurementTypeKey.IMPEDANCE_LOW) - addConvertedIfValid(measurementData.ecw, MeasurementTypeKey.ECW) - addConvertedIfValid(measurementData.icw, MeasurementTypeKey.ICW) - addConvertedIfValid(measurementData.protein, MeasurementTypeKey.PROTEIN) - addConvertedIfValid(measurementData.bcm, MeasurementTypeKey.BCM) - if (values.isEmpty()) { LogManager.w(TAG, "No valid values from measurement of $deviceName to save.") _snackbarEvents.tryEmit( @@ -585,7 +535,7 @@ class BleConnector( "Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${values.size}" ) pendingSavedCount.incrementAndGet() - lastSavedArgs = listOf(measurementData.weight, deviceName) + lastSavedArgs = listOf(measurementData[MeasurementType.WEIGHT] ?: 0f, deviceName) savedBurstSignal.tryEmit(Unit) } catch (e: Exception) { LogManager.e(TAG, "Error saving measurement from $deviceName.", e) diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/DerivedValuesCalculator.kt b/android_app/app/src/main/java/com/health/openscale/core/service/DerivedValuesCalculator.kt index e4519fa4b..bf58dc5a5 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/DerivedValuesCalculator.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/DerivedValuesCalculator.kt @@ -21,7 +21,7 @@ import androidx.annotation.VisibleForTesting import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.MeasureUnit -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.WeightUnit @@ -73,7 +73,7 @@ class DerivedValuesCalculator @Inject constructor( // "${allGlobalTypes.size} global types, and user '${user.name}' for measurement $measurementId.") // Helper to find a raw value and its unit from the persisted MeasurementValues and MeasurementTypes - val findValueAndUnit = { key: MeasurementTypeKey -> + val findValueAndUnit = { key: MeasurementType.Key<*> -> val measurementTypeObject = allGlobalTypes.find { it.key == key } if (measurementTypeObject == null) { LogManager.w(DERIVED_VALUES_TAG, "MeasurementType for key '$key' not found in global types list.") @@ -82,13 +82,12 @@ class DerivedValuesCalculator @Inject constructor( val valueObject = currentMeasurementValues.find { it.typeId == measurementTypeObject.id } val value = valueObject?.floatValue val unit = measurementTypeObject.unit // The unit is defined in the MeasurementType object - // LogManager.v(DERIVED_VALUES_TAG, "findValueAndUnit for $key (typeId: ${measurementTypeObject.id}, unit: $unit): ${value ?: "not found"}") Pair(value, unit) } } // Helper to save or update a derived measurement value - val saveOrUpdateDerivedValue: suspend (value: Float?, typeKey: MeasurementTypeKey) -> Unit = + val saveOrUpdateDerivedValue: suspend (value: Float?, typeKey: MeasurementType.Key<*>) -> Unit = save@{ derivedValue, derivedValueTypeKey -> val derivedTypeObject = allGlobalTypes.find { it.key == derivedValueTypeKey } @@ -105,7 +104,6 @@ class DerivedValuesCalculator @Inject constructor( measurementValueDao.deleteById(existingDerivedValueObject.id) //LogManager.d(DERIVED_VALUES_TAG, "Derived value for key ${derivedTypeObject.key} is null. Deleted existing value (ID: ${existingDerivedValueObject.id}).") } else { - //LogManager.v(DERIVED_VALUES_TAG, "Derived value for key ${derivedTypeObject.key} is null. No existing value to delete.") } } else { // If derived value is not null, insert or update it @@ -115,7 +113,6 @@ class DerivedValuesCalculator @Inject constructor( measurementValueDao.update(existingDerivedValueObject.copy(floatValue = roundedValue)) // LogManager.d(DERIVED_VALUES_TAG, "Derived value for key ${derivedTypeObject.key} updated from ${existingDerivedValueObject.floatValue} to $roundedValue.") } else { - //LogManager.v(DERIVED_VALUES_TAG, "Derived value for key ${derivedTypeObject.key} is $roundedValue (unchanged). No update needed.") } } else { measurementValueDao.insert( @@ -131,13 +128,13 @@ class DerivedValuesCalculator @Inject constructor( } // Fetch raw values and their original units - val (weightValue, weightUnitType) = findValueAndUnit(MeasurementTypeKey.WEIGHT) - val (bodyFatValue, _) = findValueAndUnit(MeasurementTypeKey.BODY_FAT) // Unit usually % (UnitType.PERCENT) - val (waistValue, waistUnitType) = findValueAndUnit(MeasurementTypeKey.WAIST) - val (hipsValue, hipsUnitType) = findValueAndUnit(MeasurementTypeKey.HIPS) - val (caliper1Value, caliper1UnitType) = findValueAndUnit(MeasurementTypeKey.CALIPER_1) - val (caliper2Value, caliper2UnitType) = findValueAndUnit(MeasurementTypeKey.CALIPER_2) - val (caliper3Value, caliper3UnitType) = findValueAndUnit(MeasurementTypeKey.CALIPER_3) + val (weightValue, weightUnitType) = findValueAndUnit(MeasurementType.WEIGHT) + val (bodyFatValue, _) = findValueAndUnit(MeasurementType.BODY_FAT) // Unit usually % (UnitType.PERCENT) + val (waistValue, waistUnitType) = findValueAndUnit(MeasurementType.WAIST) + val (hipsValue, hipsUnitType) = findValueAndUnit(MeasurementType.HIPS) + val (caliper1Value, caliper1UnitType) = findValueAndUnit(MeasurementType.CALIPER_1) + val (caliper2Value, caliper2UnitType) = findValueAndUnit(MeasurementType.CALIPER_2) + val (caliper3Value, caliper3UnitType) = findValueAndUnit(MeasurementType.CALIPER_3) // --- CONVERT VALUES TO REQUIRED UNITS FOR CALCULATIONS --- @@ -148,7 +145,7 @@ class DerivedValuesCalculator @Inject constructor( UnitType.LB -> ConverterUtils.toKilogram(weightValue, WeightUnit.LB) UnitType.ST -> ConverterUtils.toKilogram(weightValue, WeightUnit.ST) else -> { - LogManager.w(DERIVED_VALUES_TAG, "Unsupported unit $weightUnitType for weight conversion. Assuming KG if value present for ${MeasurementTypeKey.WEIGHT}.") + LogManager.w(DERIVED_VALUES_TAG, "Unsupported unit $weightUnitType for weight conversion. Assuming KG if value present for ${MeasurementType.WEIGHT}.") weightValue // Fallback or handle error appropriately } } @@ -160,7 +157,7 @@ class DerivedValuesCalculator @Inject constructor( UnitType.CM -> waistValue UnitType.INCH -> ConverterUtils.toCentimeter(waistValue, MeasureUnit.INCH) else -> { - LogManager.w(DERIVED_VALUES_TAG, "Unsupported unit $waistUnitType for waist conversion. Assuming CM if value present for ${MeasurementTypeKey.WAIST}.") + LogManager.w(DERIVED_VALUES_TAG, "Unsupported unit $waistUnitType for waist conversion. Assuming CM if value present for ${MeasurementType.WAIST}.") waistValue } } @@ -172,7 +169,7 @@ class DerivedValuesCalculator @Inject constructor( UnitType.CM -> hipsValue UnitType.INCH -> ConverterUtils.toCentimeter(hipsValue, MeasureUnit.INCH) else -> { - LogManager.w(DERIVED_VALUES_TAG, "Unsupported unit $hipsUnitType for hips conversion. Assuming CM if value present for ${MeasurementTypeKey.HIPS}.") + LogManager.w(DERIVED_VALUES_TAG, "Unsupported unit $hipsUnitType for hips conversion. Assuming CM if value present for ${MeasurementType.HIPS}.") hipsValue } } @@ -212,14 +209,14 @@ class DerivedValuesCalculator @Inject constructor( // --- PERFORM DERIVED VALUE CALCULATIONS --- // Pass the converted values (e.g., weightKg, waistCm) to the processing functions - processBmiCalculation(weightKg, userHeightCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.BMI) } - processWhrCalculation(waistCm, hipsCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHR) } - processWhtrCalculation(waistCm, userHeightCm).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.WHTR) } + processBmiCalculation(weightKg, userHeightCm).also { saveOrUpdateDerivedValue(it, MeasurementType.BMI) } + processWhrCalculation(waistCm, hipsCm).also { saveOrUpdateDerivedValue(it, MeasurementType.WHR) } + processWhtrCalculation(waistCm, userHeightCm).also { saveOrUpdateDerivedValue(it, MeasurementType.WHTR) } // BMR: when the source device already supplied one (e.g. S400 BIA-based BMR // via FFM), keep it. Mifflin-St Jeor is an anthropometric fallback used // only when no BMR landed on the row. TDEE always derives from whichever // BMR is actually persisted. - val bmrTypeId = allGlobalTypes.find { it.key == MeasurementTypeKey.BMR }?.id + val bmrTypeId = allGlobalTypes.find { it.key == MeasurementType.BMR }?.id val existingBmr = bmrTypeId?.let { id -> currentMeasurementValues.find { it.typeId == id }?.floatValue } @@ -228,8 +225,8 @@ class DerivedValuesCalculator @Inject constructor( heightCm = user.heightCm, ageYears = ageAtMeasurementYears, gender = user.gender - )?.also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.BMR) } - processTDEECalculation(bmr, user.activityLevel).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.TDEE) } + )?.also { saveOrUpdateDerivedValue(it, MeasurementType.BMR) } + processTDEECalculation(bmr, user.activityLevel).also { saveOrUpdateDerivedValue(it, MeasurementType.TDEE) } processFatCaliperCalculation( caliper1Cm = caliper1Cm, @@ -237,7 +234,7 @@ class DerivedValuesCalculator @Inject constructor( caliper3Cm = caliper3Cm, ageYears = ageAtMeasurementYears, gender = user.gender - ).also { saveOrUpdateDerivedValue(it, MeasurementTypeKey.CALIPER) } + ).also { saveOrUpdateDerivedValue(it, MeasurementType.CALIPER) } val endTime = System.nanoTime() val durationMillis = (endTime - startTime) / 1_000_000 diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt index 5733221e6..2ed6e7cad 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt @@ -24,7 +24,6 @@ import com.github.doyaaaaaken.kotlincsv.dsl.csvWriter import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.model.MeasurementWithValues @@ -54,6 +53,8 @@ data class ImportReport( val linesSkippedMissingDate: Int, val linesSkippedDateParseError: Int, val valuesSkippedParseError: Int, + /** Headers that matched no measurement type; their whole column was skipped. */ + val skippedColumns: List = emptyList(), ) /** @@ -105,21 +106,19 @@ class ImportExportUseCases @Inject constructor( val allAppTypes: List = repository.getAllMeasurementTypes().first() val exportableValueTypes = allAppTypes.filter { - it.key != MeasurementTypeKey.DATE && - it.key != MeasurementTypeKey.TIME && - it.key != MeasurementTypeKey.USER + it.key != MeasurementType.DATE && + it.key != MeasurementType.TIME && + it.key != MeasurementType.USER } - val valueColumnKeys = exportableValueTypes.map { - if (it.key == MeasurementTypeKey.CUSTOM) { - it.name ?: it.key.name - } else { - it.key.name - } - }.distinct() + // One header per type, guaranteed unique. Identities make that automatic; only + // rows without one (inserted straight through the DAO) fall back to their display + // name and get numbered instead of silently collapsing into one column. + val columnKeyByTypeId = uniqueColumnKeysByTypeId(exportableValueTypes) + val valueColumnKeys = columnKeyByTypeId.values.toList() - val dateColumnKey = MeasurementTypeKey.DATE.name - val timeColumnKey = MeasurementTypeKey.TIME.name + val dateColumnKey = MeasurementType.identityColumnKey(MeasurementType.DATE.identity) + val timeColumnKey = MeasurementType.identityColumnKey(MeasurementType.TIME.identity) val allCsvColumnKeys = buildList { add(dateColumnKey) @@ -152,14 +151,10 @@ class ImportExportUseCases @Inject constructor( val type = mwvSingle.type val value = mwvSingle.value - val currentColumnKey = if (type.key == MeasurementTypeKey.CUSTOM) { - type.name ?: type.key.name - } else { - type.key.name - } + val currentColumnKey = columnKeyByTypeId[type.id] - if (type.key != MeasurementTypeKey.DATE && type.key != MeasurementTypeKey.TIME && - valueColumnKeys.contains(currentColumnKey) + if (currentColumnKey != null && + type.key != MeasurementType.DATE && type.key != MeasurementType.TIME ) { val s = when (type.inputType) { InputFieldType.TEXT -> value.textValue @@ -198,6 +193,34 @@ class ImportExportUseCases @Inject constructor( } + /** + * The CSV column header for one type — one style for the whole file: the identity, + * uppercased (`WEIGHT`, `SEGMENTAL_FAT_LEFT_ARM`, `SCHRITTE`). Identities are frozen + * and unique, so the header is stable across renames and languages. Only a row that + * somehow has no identity — one inserted straight through the DAO — falls back to its + * display name. Lives here because the column key is CSV vocabulary, not part of the + * type's identity mechanics. + */ + private fun MeasurementType.csvColumnKey(): String = + if (identity.isNotBlank()) MeasurementType.identityColumnKey(identity) + else name?.takeIf { it.isNotBlank() } ?: "CUSTOM" + + private fun uniqueColumnKeysByTypeId(types: List): Map { + val used = mutableSetOf() + val result = LinkedHashMap() + types.forEach { type -> + val base = type.csvColumnKey() + var candidate = base + var suffix = 1 + while (!used.add(candidate.uppercase())) { + suffix++ + candidate = "${base}_$suffix" + } + result[type.id] = candidate + } + return result + } + /** * Import measurements for a user from a CSV file at [uri]. * The CSV format matches the exporter (first row is header). @@ -212,14 +235,15 @@ class ImportExportUseCases @Inject constructor( val allAppTypes: List = repository.getAllMeasurementTypes().first() - val dateColumnKey = MeasurementTypeKey.DATE.name - val timeColumnKey = MeasurementTypeKey.TIME.name + val dateColumnKey = MeasurementType.identityColumnKey(MeasurementType.DATE.identity) + val timeColumnKey = MeasurementType.identityColumnKey(MeasurementType.TIME.identity) val dateTimeColumnKey = "dateTime" val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") var linesSkippedMissingDate = 0 var linesSkippedDateParseError = 0 var valuesSkippedParseError = 0 + val skippedColumns = mutableListOf() var importedMeasurementsCount = 0 var ignoredMeasurementsCount = 0 @@ -253,24 +277,36 @@ class ImportExportUseCases @Inject constructor( row.forEachIndexed { colIdx, colName -> if (colIdx == dateIdx || colIdx == timeIdx || colIdx == dateTimeIdx) return@forEachIndexed - // 1) map by MeasurementTypeKey.name + // 1) the identity-derived column key — the same derivation the + // exporter uses. List order (displayOrder, predefined first) + // keeps the historical precedence: a user type named + // "COMMENT" cannot hijack the predefined column. var matched = allAppTypes.find { t -> - t.key.name.equals(colName, ignoreCase = true) && - t.key != MeasurementTypeKey.DATE && - t.key != MeasurementTypeKey.TIME + t.key != MeasurementType.DATE && + t.key != MeasurementType.TIME && + t.csvColumnKey().equals(colName, ignoreCase = true) } - // 2) fallback: custom name for CUSTOM types + // 2) fallback for files written before identity headers: user + // types used to export under their display name. if (matched == null) { matched = allAppTypes.find { t -> - t.key == MeasurementTypeKey.CUSTOM && - (t.name?.equals(colName, ignoreCase = true) == true) + t.isUserOwned() && + (t.name?.equals(colName, ignoreCase = true) == true) } } // Internal raw inputs (e.g. impedance bands) are disabled by // default but must still be importable for re-derivation. if (matched != null && (matched.isEnabled || matched.isInternal)) { valueColumnMap[colIdx] = matched - LogManager.d(TAG, "Header map: '$colName' -> ${matched.key} (id=${matched.id})") + LogManager.d(TAG, "Header map: '$colName' -> ${matched.identity} (id=${matched.id})") + } else { + // Columns used to vanish in complete silence here. + skippedColumns.add(colName) + LogManager.w( + TAG, + "CSV column '$colName' matches no enabled measurement type; " + + "the whole column is skipped." + ) } } return@forEachIndexed @@ -413,7 +449,8 @@ class ImportExportUseCases @Inject constructor( ignoredMeasurementsCount = ignoredMeasurementsCount, linesSkippedMissingDate = linesSkippedMissingDate, linesSkippedDateParseError = linesSkippedDateParseError, - valuesSkippedParseError = valuesSkippedParseError + valuesSkippedParseError = valuesSkippedParseError, + skippedColumns = skippedColumns.toList() ) } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementDemoUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementDemoUseCase.kt index 5300fee85..77a6a9340 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementDemoUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementDemoUseCase.kt @@ -6,7 +6,6 @@ import com.health.openscale.R import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -313,7 +312,7 @@ class MeasurementDemoUseCase @Inject constructor( private suspend fun generateSparseMetrics(userId: Int, count: Int, daysBack: Int) { // Deliberately use only weight type to simulate incomplete scale data val weightType = query.getAllMeasurementTypes().first() - .firstOrNull { it.key == MeasurementTypeKey.WEIGHT && it.isEnabled } + .firstOrNull { it.key == MeasurementType.WEIGHT && it.isEnabled } ?: return val now = System.currentTimeMillis() @@ -382,25 +381,25 @@ class MeasurementDemoUseCase @Inject constructor( } InputFieldType.INT -> { val v = when (type.key) { - MeasurementTypeKey.HEART_RATE -> (60..85).random() + MeasurementType.HEART_RATE -> (60..85).random() else -> (10..100).random() } MeasurementValue(measurementId = 0, typeId = type.id, intValue = v) } InputFieldType.FLOAT -> { val v = when (type.key) { - MeasurementTypeKey.WEIGHT -> w - MeasurementTypeKey.BODY_FAT -> f.coerceIn(3f, 50f) - MeasurementTypeKey.MUSCLE -> m.coerceIn(10f, 75f) - MeasurementTypeKey.WATER -> (100f - f - noise(1f, 4f)).coerceIn(40f, 75f) - MeasurementTypeKey.BONE -> (w * 0.04f + noise(-0.2f, 0.2f)).coerceIn(1f, 10f) - MeasurementTypeKey.VISCERAL_FAT -> (f / 3f + noise(-1f, 1f)).coerceIn(1f, 20f) - MeasurementTypeKey.WAIST, - MeasurementTypeKey.HIPS, - MeasurementTypeKey.CHEST, - MeasurementTypeKey.THIGH, - MeasurementTypeKey.BICEPS, - MeasurementTypeKey.NECK -> (w * 1.1f + noise(-2f, 2f)).coerceIn(20f, 150f) + MeasurementType.WEIGHT -> w + MeasurementType.BODY_FAT -> f.coerceIn(3f, 50f) + MeasurementType.MUSCLE -> m.coerceIn(10f, 75f) + MeasurementType.WATER -> (100f - f - noise(1f, 4f)).coerceIn(40f, 75f) + MeasurementType.BONE -> (w * 0.04f + noise(-0.2f, 0.2f)).coerceIn(1f, 10f) + MeasurementType.VISCERAL_FAT -> (f / 3f + noise(-1f, 1f)).coerceIn(1f, 20f) + MeasurementType.WAIST, + MeasurementType.HIPS, + MeasurementType.CHEST, + MeasurementType.THIGH, + MeasurementType.BICEPS, + MeasurementType.NECK -> (w * 1.1f + noise(-2f, 2f)).coerceIn(20f, 150f) else -> w * 0.5f } MeasurementValue(measurementId = 0, typeId = type.id, floatValue = v) diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementEvaluationUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementEvaluationUseCases.kt index aa07ddc23..689ab0a84 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementEvaluationUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementEvaluationUseCases.kt @@ -17,10 +17,9 @@ */ package com.health.openscale.core.usecase -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.model.UserEvaluationContext import com.health.openscale.core.data.EvaluationState -import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.UnitType import com.health.openscale.core.service.MeasurementEvaluator.evalBmi import com.health.openscale.core.service.MeasurementEvaluator.evalBodyFat @@ -69,11 +68,11 @@ class MeasurementEvaluationUseCases @Inject constructor() { userEvaluationContext.birthDateMillis ) - val typeKey = type.key + val key = type.key val unit = type.unit - return when (typeKey) { - MeasurementTypeKey.WEIGHT -> { + return when (key) { + MeasurementType.WEIGHT -> { val weightKg = ConverterUtils.convertFloatValueUnit(value, unit, UnitType.KG) evalWeightAgainstTargetRange( weightKg = weightKg, @@ -82,30 +81,30 @@ class MeasurementEvaluationUseCases @Inject constructor() { gender = userEvaluationContext.gender ) } - MeasurementTypeKey.BODY_FAT -> { + MeasurementType.BODY_FAT -> { val fatPercent = ConverterUtils.convertFloatValueUnit(value, unit, UnitType.PERCENT) evalBodyFat(fatPercent, ageYears, userEvaluationContext.gender) } - MeasurementTypeKey.WATER -> { + MeasurementType.WATER -> { val waterPercent = ConverterUtils.convertFloatValueUnit(value, unit, UnitType.PERCENT) evalWater(waterPercent, ageYears, userEvaluationContext.gender) } - MeasurementTypeKey.MUSCLE -> { + MeasurementType.MUSCLE -> { val musclePercent = ConverterUtils.convertFloatValueUnit(value, unit, UnitType.PERCENT) evalMuscle(musclePercent, ageYears, userEvaluationContext.gender) } - MeasurementTypeKey.LBM -> { + MeasurementType.LBM -> { val weightKg = ConverterUtils.convertFloatValueUnit(value, unit, UnitType.KG) evalLBM(weightKg, ageYears, userEvaluationContext.gender) } - MeasurementTypeKey.WAIST -> { + MeasurementType.WAIST -> { val waistCm = ConverterUtils.convertFloatValueUnit(value, unit, UnitType.CM) evalWaistCm(waistCm, ageYears, userEvaluationContext.gender) } - MeasurementTypeKey.BMI -> evalBmi(value, ageYears, userEvaluationContext.gender) - MeasurementTypeKey.WHTR -> evalWHtR(value, ageYears) - MeasurementTypeKey.WHR -> evalWHR(value, ageYears, userEvaluationContext.gender) - MeasurementTypeKey.VISCERAL_FAT -> evalVisceralFat(value, ageYears) + MeasurementType.BMI -> evalBmi(value, ageYears, userEvaluationContext.gender) + MeasurementType.WHTR -> evalWHtR(value, ageYears) + MeasurementType.WHR -> evalWHR(value, ageYears, userEvaluationContext.gender) + MeasurementType.VISCERAL_FAT -> evalVisceralFat(value, ageYears) else -> null } } @@ -117,15 +116,15 @@ class MeasurementEvaluationUseCases @Inject constructor() { * incorrect values (e.g., sensor glitches, unit mix-ups) before attempting * a proper evaluation. The ranges are intentionally wide. * - * @param typeKey The measurement type to check. - * @return A closed percent range [min .. max] if the metric is percentage-based and supported, + * @param key The measurement type to check. + * @return A closed percent range [min .. max] if the type is percentage-based and supported, * or `null` if no generic plausibility range is defined for this type. */ - fun plausiblePercentRangeFor(typeKey: MeasurementTypeKey): ClosedFloatingPointRange? = - when (typeKey) { - MeasurementTypeKey.WATER -> 35f..75f - MeasurementTypeKey.BODY_FAT -> 3f..70f - MeasurementTypeKey.MUSCLE -> 15f..60f + fun plausiblePercentRangeFor(key: MeasurementType.Key<*>?): ClosedFloatingPointRange? = + when (key) { + MeasurementType.WATER -> 35f..75f + MeasurementType.BODY_FAT -> 3f..70f + MeasurementType.MUSCLE -> 15f..60f else -> null } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementInsightsUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementInsightsUseCase.kt index bb66e86ed..32f44b18a 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementInsightsUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementInsightsUseCase.kt @@ -19,7 +19,6 @@ package com.health.openscale.core.usecase import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.model.BodyCompositionPattern import com.health.openscale.core.model.CompositionPatternType import com.health.openscale.core.model.InsightConfidence @@ -252,16 +251,19 @@ class MeasurementInsightsUseCase @Inject constructor() { private fun computeBodyCompositionPattern( sorted: List, ): BodyCompositionPattern? { - val typesByKey: Map = sorted + // Only predefined types take part in the pattern; ble.*/user.* rows have no Key + // and are skipped — before the identity refactor they fell out via the CUSTOM + // branch, now they fall out here. + val typesByKey: Map, MeasurementType> = sorted .flatMap { it.values } .filter { isNumeric(it.type) } - .associateBy { it.type.key } - .mapValues { it.value.type } + .mapNotNull { v -> v.type.key?.let { it to v.type } } + .toMap() - val weightType = typesByKey[MeasurementTypeKey.WEIGHT] ?: return null - val fatType = typesByKey[MeasurementTypeKey.BODY_FAT] ?: return null - val muscleType = typesByKey[MeasurementTypeKey.MUSCLE] ?: return null - val waterType = typesByKey[MeasurementTypeKey.WATER] ?: return null + val weightType = typesByKey[MeasurementType.WEIGHT] ?: return null + val fatType = typesByKey[MeasurementType.BODY_FAT] ?: return null + val muscleType = typesByKey[MeasurementType.MUSCLE] ?: return null + val waterType = typesByKey[MeasurementType.WATER] ?: return null data class QuadPoint( val date: LocalDate, diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt index 0efad7632..2156ebe2c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTransformationUseCase.kt @@ -24,7 +24,6 @@ import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.LbmFormulaOption import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -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 @@ -53,6 +52,14 @@ class MeasurementTransformationUseCase @Inject constructor( private val query: MeasurementQueryUseCases ) { + /** + * The row id of the WEIGHT type. Resolved from the database — the pre-16 code compared + * MeasurementValue.typeId against the enum's ordinal, which only worked because WEIGHT + * happened to be seeded as row 1. + */ + private suspend fun weightTypeId(): Int? = + query.getAllMeasurementTypes().first().find { it.key == MeasurementType.WEIGHT }?.id + /** * Transforms a measurement by applying "Assisted Weighing" logic. * It calculates the weight difference and returns the new list of values. @@ -64,13 +71,14 @@ class MeasurementTransformationUseCase @Inject constructor( totalWeightValues: List, referenceUser: User ): List { - val totalWeight = totalWeightValues.find { it.typeId == MeasurementTypeKey.WEIGHT.id }?.floatValue ?: 0f + val weightTypeId = weightTypeId() ?: return totalWeightValues + val totalWeight = totalWeightValues.find { it.typeId == weightTypeId }?.floatValue ?: 0f val lastReferenceWeight = query.getMeasurementsForUser(referenceUser.id).first() - .firstNotNullOfOrNull { m -> m.values.find { v -> v.type.key == MeasurementTypeKey.WEIGHT }?.value?.floatValue } ?: 0f + .firstNotNullOfOrNull { m -> m.values.find { v -> v.type.key == MeasurementType.WEIGHT }?.value?.floatValue } ?: 0f val diffWeight = totalWeight - lastReferenceWeight val diffWeightMeasurementValue = MeasurementValue( - typeId = MeasurementTypeKey.WEIGHT.id, + typeId = weightTypeId, floatValue = diffWeight, measurementId = measurement.id // placeholder ) @@ -94,7 +102,7 @@ class MeasurementTransformationUseCase @Inject constructor( return measurement } - val newWeight = values.find { it.typeId == MeasurementTypeKey.WEIGHT.id }?.floatValue + val newWeight = weightTypeId()?.let { id -> values.find { it.typeId == id } }?.floatValue if (newWeight == null) { return measurement // No weight to assign, save as is } @@ -103,7 +111,7 @@ class MeasurementTransformationUseCase @Inject constructor( val allUsers = userUseCases.observeAllUsers().first() val userDiffs = allUsers.mapNotNull { user -> val lastWeight = query.getMeasurementsForUser(user.id).first() - .firstNotNullOfOrNull { m -> m.values.find { v -> v.type.key == MeasurementTypeKey.WEIGHT }?.value?.floatValue } + .firstNotNullOfOrNull { m -> m.values.find { v -> v.type.key == MeasurementType.WEIGHT }?.value?.floatValue } if (lastWeight != null && lastWeight > 0) { val diffPercent = (abs(newWeight - lastWeight) / lastWeight) * 100 @@ -170,7 +178,7 @@ class MeasurementTransformationUseCase @Inject constructor( val byKey = types.associateBy { it.key } // Anchor: weight (in kg) - val weightType = byKey[MeasurementTypeKey.WEIGHT] ?: return values + val weightType = byKey[MeasurementType.WEIGHT] ?: return values val weightVal = values.find { it.typeId == weightType.id }?.floatValue ?: return values val weightKg = when (weightType.unit) { UnitType.KG -> weightVal @@ -184,7 +192,7 @@ class MeasurementTransformationUseCase @Inject constructor( val isMale = user.gender == GenderType.MALE // BMI (prefer provided, otherwise compute) - val bmiType = byKey[MeasurementTypeKey.BMI] + val bmiType = byKey[MeasurementType.BMI] val bmiProvided = bmiType?.let { t -> values.find { it.typeId == t.id }?.floatValue } val bmi = bmiProvided?.toDouble() ?: run { val hM = heightCm / 100.0 @@ -213,18 +221,18 @@ class MeasurementTransformationUseCase @Inject constructor( } // --- BODY FAT (%) - byKey[MeasurementTypeKey.BODY_FAT]?.let { type -> + byKey[MeasurementType.BODY_FAT]?.let { type -> if (bfOpt != BodyFatFormulaOption.OFF) { val bf = when (bfOpt) { BodyFatFormulaOption.US_NAVY -> { - val waistIn = values.find { it.typeId == byKey[MeasurementTypeKey.WAIST]?.id }?.let { + val waistIn = values.find { it.typeId == byKey[MeasurementType.WAIST]?.id }?.let { it.floatValue?.let { value -> - ConverterUtils.convertFloatValueUnit(value, byKey[MeasurementTypeKey.WAIST]!!.unit, UnitType.INCH) + ConverterUtils.convertFloatValueUnit(value, byKey[MeasurementType.WAIST]!!.unit, UnitType.INCH) } } - val neckIn = values.find { it.typeId == byKey[MeasurementTypeKey.NECK]?.id }?.let { + val neckIn = values.find { it.typeId == byKey[MeasurementType.NECK]?.id }?.let { it.floatValue?.let { value -> - ConverterUtils.convertFloatValueUnit(value, byKey[MeasurementTypeKey.NECK]!!.unit, UnitType.INCH) + ConverterUtils.convertFloatValueUnit(value, byKey[MeasurementType.NECK]!!.unit, UnitType.INCH) } } val heightIn = (heightCm / 2.54).toFloat() @@ -236,9 +244,9 @@ class MeasurementTransformationUseCase @Inject constructor( 86.010f * log10(diff) - 70.041f * log10(heightIn) + 36.76f } else null } else { - val hipsIn = values.find { it.typeId == byKey[MeasurementTypeKey.HIPS]?.id }?.let { + val hipsIn = values.find { it.typeId == byKey[MeasurementType.HIPS]?.id }?.let { it.floatValue?.let { value -> - ConverterUtils.convertFloatValueUnit(value, byKey[MeasurementTypeKey.HIPS]!!.unit, UnitType.INCH) + ConverterUtils.convertFloatValueUnit(value, byKey[MeasurementType.HIPS]!!.unit, UnitType.INCH) } } if (hipsIn != null && hipsIn > 0f) { @@ -276,7 +284,7 @@ class MeasurementTransformationUseCase @Inject constructor( } // --- BODY WATER (stored either as %, or mass depending on type.unit) - byKey[MeasurementTypeKey.WATER]?.let { type -> + byKey[MeasurementType.WATER]?.let { type -> if (bwOpt != BodyWaterFormulaOption.OFF) { val liters = when (bwOpt) { BodyWaterFormulaOption.BEHNKE_1963 -> @@ -318,11 +326,11 @@ class MeasurementTransformationUseCase @Inject constructor( } // --- LBM (mass) - byKey[MeasurementTypeKey.LBM]?.let { type -> + byKey[MeasurementType.LBM]?.let { type -> if (lbmOpt != LbmFormulaOption.OFF) { val lbmKg = when (lbmOpt) { LbmFormulaOption.WEIGHT_MINUS_BODY_FAT -> { - val bfType = byKey[MeasurementTypeKey.BODY_FAT] + val bfType = byKey[MeasurementType.BODY_FAT] // Prefer freshly computed BF in 'out', fallback to original list val bfPercent = out.firstOrNull { it.typeId == bfType?.id }?.floatValue ?: values.firstOrNull { it.typeId == bfType?.id }?.floatValue @@ -446,7 +454,8 @@ class MeasurementTransformationUseCase @Inject constructor( return values } - val weightIndex = values.indexOfFirst { it.typeId == MeasurementTypeKey.WEIGHT.id } + val weightTypeId = weightTypeId() ?: return values + val weightIndex = values.indexOfFirst { it.typeId == weightTypeId } if (weightIndex == -1) { return values } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt index 369a2e6db..f65d21cba 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt @@ -19,10 +19,11 @@ package com.health.openscale.core.usecase import com.health.openscale.core.data.InputFieldType 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.WeightUnit +import android.content.Context import com.health.openscale.core.database.DatabaseRepository +import dagger.hilt.android.qualifiers.ApplicationContext import com.health.openscale.core.utils.ConverterUtils import com.health.openscale.core.utils.LogManager import kotlinx.coroutines.flow.first @@ -39,18 +40,120 @@ import javax.inject.Singleton */ @Singleton class MeasurementTypeCrudUseCases @Inject constructor( - private val repository: DatabaseRepository + private val repository: DatabaseRepository, + @param:ApplicationContext private val appContext: Context ) { private val TAG = "MeasurementTypeCrudUseCase" - /** Adds a new measurement type. */ + /** + * Adds a new measurement type. A user-created type (the editor constructs rows with a + * blank identity) gets its frozen `user.*` identity assigned here. + */ suspend fun add(type: MeasurementType): Result = runCatching { - repository.insertMeasurementType(type) + repository.insertMeasurementType(withIdentity(type, existing = null)) } - /** Updates a measurement type without touching existing values. */ + /** + * Updates a measurement type without touching existing values. + * + * The editor rebuilds the entity from scratch on save, so the fields it does not show + * — the identity and isInternal — are restored from the stored row here; without this, + * every edit would silently clear them. + */ suspend fun update(type: MeasurementType): Result = runCatching { - repository.updateMeasurementType(type) + val existing = repository.getMeasurementTypeById(type.id) + repository.updateMeasurementType( + withIdentity(type, existing).copy(isInternal = existing?.isInternal ?: type.isInternal) + ) + } + + /** + * Returns the measurement type backing a handler-declared [key], creating it on + * first use. Called while a measurement is being saved, so it never fails the + * measurement: on any problem it returns null and the caller drops just that value. + * + * The lookup goes through the identity, never the display name, so the type is still + * found after the user renamed it, switched the app language or changed its unit. + */ + suspend fun resolveOrCreate(key: MeasurementType.Key<*>): MeasurementType? = runCatching { + repository.getMeasurementTypeByIdentity(key.identity) + ?.let { return@runCatching it } + + // Create only in the ble.* namespace. Predefined rows come from seeding and the + // migrations; creating one here would mint a half-defined builtin (localized name + // stored, end-of-list displayOrder — or, for an identity the registry does not + // know, an unrenamable, undeletable ghost). The Key constructor cannot be sealed + // against the module, so this is the enforcement, not the factories. + if (!key.identity.startsWith(MeasurementType.DEVICE_PREFIX)) { + LogManager.e( + TAG, + "Refusing to create '${key.identity}': only ble.* keys materialize on first use." + ) + return@runCatching null + } + + val displayOrder = repository.getAllMeasurementTypes().first().size + 1 + val newType = MeasurementType( + identity = key.identity, + name = appContext.getString(key.nameResId), + color = key.defaultColor, + icon = key.defaultIcon, + unit = key.defaultUnit, + inputType = key.inputType, + displayOrder = displayOrder, + isDerived = key.isDerived, + isEnabled = key.defaultEnabled, + isPinned = key.defaultPinned, + isOnRightYAxis = key.defaultOnRightYAxis, + isInternal = key.isInternal + ) + LogManager.i(TAG, "Creating measurement type for ${key.identity}") + newType.copy(id = repository.insertMeasurementType(newType).toInt()) + }.recoverCatching { error -> + // Lost the race on the unique index against a concurrent insert: take the winner. + repository.getMeasurementTypeByIdentity(key.identity) ?: throw error + }.getOrElse { error -> + LogManager.e(TAG, "Could not resolve key ${key.identity}", error) + null + } + + /** + * The identity a row must carry, as a ranked list of rules. + * + * Identities are frozen: a builtin or ble.* one is never changed — the ble.* identity + * is the handler's only link to its own values, and moving it would create a second + * type at the next weigh-in. A user.* identity is frozen too, but the editor may + * replace it outright: that is the CSV column field, and the user changing it + * deliberately is very different from a rename silently invalidating earlier exports. + * Only a row with no identity at all gets one derived from its display name. + */ + private suspend fun withIdentity( + incoming: MeasurementType, + existing: MeasurementType? + ): MeasurementType { + val current = existing?.identity?.takeIf { it.isNotBlank() } + val identity = when { + current != null && !existing.isUserOwned() -> current + incoming.identity.startsWith(MeasurementType.USER_PREFIX) && + (current == null || existing.isUserOwned()) -> incoming.identity + current != null -> current + // An explicit identity is honoured only when it is a user.* one or belongs to + // the registry (re-seeding a predefined row). Anything else — a smuggled ble.* + // or an unknown builtin.* — is ignored and the type gets a fresh user identity, + // so no caller can claim a foreign namespace through the editor path. + incoming.identity.isNotBlank() && + (incoming.identity.startsWith(MeasurementType.USER_PREFIX) || + MeasurementType.keyOf(incoming.identity) != null) -> incoming.identity + else -> { + val taken = repository.getAllMeasurementTypes().first() + .filter { it.id != incoming.id } + .filter { it.identity.isNotBlank() } + .map { MeasurementType.identityColumnKey(it.identity) } + .toSet() + MeasurementType.userIdentityFor(incoming.name, taken) + } + } + return incoming.copy(identity = identity) } /** All measurement types (predefined + custom). Used by the sync layer to build the @@ -91,8 +194,11 @@ class MeasurementTypeCrudUseCases @Inject constructor( val oldUnit = originalType.unit val newUnit = updatedType.unit - // Update the type definition first. + // Update the type definition first — through the same identity rules as update(), + // so an edited CSV column survives a simultaneous unit change. + val identity = withIdentity(updatedType, originalType).identity val finalType = originalType.copy( + identity = identity, name = updatedType.name, color = updatedType.color, icon = updatedType.icon, @@ -122,7 +228,7 @@ class MeasurementTypeCrudUseCases @Inject constructor( } // Resolve the global WEIGHT type (needed for percent<->absolute conversions) - val weightType = repository.getAllMeasurementTypes().first().find { it.key == MeasurementTypeKey.WEIGHT } + val weightType = repository.getAllMeasurementTypes().first().find { it.identity == MeasurementType.WEIGHT.identity } var updatedCount = 0 for (mv in allValuesForType) { @@ -130,9 +236,9 @@ class MeasurementTypeCrudUseCases @Inject constructor( var converted: Float? // Percent <-> absolute conversions for composition-like metrics - if (typeKey == MeasurementTypeKey.BODY_FAT || - typeKey == MeasurementTypeKey.WATER || - typeKey == MeasurementTypeKey.MUSCLE + if (typeKey == MeasurementType.BODY_FAT || + typeKey == MeasurementType.WATER || + typeKey == MeasurementType.MUSCLE ) { if (weightType == null) { // No weight type found; cannot compute percent-based conversions. diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/ReminderUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/ReminderUseCase.kt index 07a0f706d..0e8168e02 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/ReminderUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/ReminderUseCase.kt @@ -23,7 +23,7 @@ import androidx.annotation.VisibleForTesting import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.facade.SettingsFacade import com.health.openscale.core.worker.ReminderWorker import dagger.Module @@ -84,7 +84,7 @@ class ReminderUseCase @Inject constructor( if (!isSameDay) return true - val hasWeightValue = closestMatchResult.second.values.any { it.type.key == MeasurementTypeKey.WEIGHT } + val hasWeightValue = closestMatchResult.second.values.any { it.type.key == MeasurementType.WEIGHT } return !hasWeightValue } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt index 01bb20822..52608935c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt @@ -227,7 +227,7 @@ class SyncUseCases @Inject constructor( * Adds the full, self-describing generic value set as a JSON "values" extra (Phase 2): every * MeasurementValue with its type metadata (key/name/unit/inputType/isDerived), the numeric value * already converted to the dimension's canonical base unit, unit as a UCUM code. Custom types - * ride along (key=="CUSTOM", distinguished by typeId). Lets the sync app forward all 34 types + + * ride along (key=="CUSTOM", distinguished by typeId). Lets the sync app forward all predefined types + * custom without knowing openScale's enums. */ private suspend fun Intent.putGenericValues(values: List) { @@ -250,11 +250,10 @@ class SyncUseCases @Inject constructor( * Builds/parses the self-describing **generic value set** shared between the sync Intent * ([SyncUseCases]) and the ContentProvider ([com.health.openscale.core.database.DatabaseProvider]). * - * Each value carries its type metadata (`typeId`, `key` = MeasurementTypeKey enum name, `name`, - * `unit` as a UCUM code, `inputType`, `isDerived`); the numeric value is in the **canonical base - * unit** of its dimension. Custom types ride along (key == "CUSTOM", distinguished by `typeId`). - * HL7 FHIR Quantity / UCUM-inspired — the sync app forwards all types incl. custom without knowing - * openScale's enums. + * Each value carries its type metadata (`identity` — the namespaced, cross-installation + * identifier, `name`, `unit` as a UCUM code, `inputType`, `isDerived`); the numeric value is + * in the **canonical base unit** of its dimension. HL7 FHIR Quantity / UCUM-inspired — the + * sync app forwards all types incl. device and user ones without knowing openScale's internals. */ object GenericValueJson { @@ -263,9 +262,8 @@ object GenericValueJson { for (v in values) { val type = typesById[v.typeId] ?: continue val obj = JSONObject() - obj.put("typeId", type.id) - obj.put("key", type.key.name) - obj.put("name", type.name ?: type.key.name) + obj.put("identity", type.identity) + obj.put("name", type.name ?: MeasurementType.identityColumnKey(type.identity)) obj.put("unit", ucumCode(type.unit)) obj.put("inputType", type.inputType.name) obj.put("isDerived", type.isDerived) @@ -288,7 +286,10 @@ object GenericValueJson { /** * Reverse of [build] (inbound): parse a generic value JSON into (type, value) pairs, the value * already in the user's unit and in the field the type's [MeasurementType.inputType] requires. - * Predefined types are matched by [typesByKey] (enum name), custom by [typesById] (typeId). + * + * Matching is by `identity` — the canonical, cross-installation identifier of API v3. + * Entries without one (pre-v3 peers) are skipped; the MIN_API_VERSION gate on the sync + * side makes such payloads a misconfiguration, not a supported input. * * The returned [MeasurementValue]s carry `measurementId = 0` — the caller owns that. * @@ -298,16 +299,16 @@ object GenericValueJson { */ fun parse( json: String, - typesByKey: Map, - typesById: Map + types: List ): List> { + val typesByIdentity = types.associateBy { it.identity } + val out = mutableListOf>() runCatching { val arr = JSONArray(json) for (i in 0 until arr.length()) { val o = arr.getJSONObject(i) - val key = o.optString("key", "") - val type = if (key == "CUSTOM") typesById[o.optInt("typeId", -1)] else typesByKey[key] + val type = typesByIdentity[o.optString("identity", "")] if (type == null) continue val empty = MeasurementValue(measurementId = 0, typeId = type.id) val parsed: MeasurementValue? = when (type.inputType) { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt index dc0f87d9c..00e66e545 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChart.kt @@ -62,7 +62,6 @@ import com.health.openscale.R import com.health.openscale.core.data.AggregationLevel import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.SmoothingAlgorithm import com.health.openscale.core.facade.SettingsPreferenceKeys import com.health.openscale.core.model.EnrichedMeasurement @@ -147,19 +146,19 @@ fun MeasurementChart( val allAvailableMeasurementTypes by sharedViewModel.measurementTypes.collectAsState() - // Resolved via MeasurementTypeKey, never via MeasurementTypeKey.id: the row id of a + // Resolved via the predefined Key constants, never via MeasurementType.id: the row id of a // MeasurementType is auto-generated on insert and does not match the enum id. val defaultSelectedTypesValue = remember(targetMeasurementTypeId, allAvailableMeasurementTypes) { if (targetMeasurementTypeId != null) { setOf(targetMeasurementTypeId.toString()) } else { val defaultKeys = setOf( - MeasurementTypeKey.WEIGHT, - MeasurementTypeKey.BMI, - MeasurementTypeKey.BODY_FAT, - MeasurementTypeKey.WATER, - MeasurementTypeKey.MUSCLE, - MeasurementTypeKey.COMMENT, + MeasurementType.WEIGHT, + MeasurementType.BMI, + MeasurementType.BODY_FAT, + MeasurementType.WATER, + MeasurementType.MUSCLE, + MeasurementType.COMMENT, ) allAvailableMeasurementTypes .filter { it.key in defaultKeys } @@ -365,7 +364,7 @@ fun MeasurementChart( .find { it.id == targetMeasurementTypeId } ?.let { listOf(it.id) } ?: emptyList() } else { - val defaultKeys = setOf(MeasurementTypeKey.WEIGHT, MeasurementTypeKey.BODY_FAT) + val defaultKeys = setOf(MeasurementType.WEIGHT, MeasurementType.BODY_FAT) selectableFilteredTypes .filter { it.key in defaultKeys } .map { it.id } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt index 4cf2b2ebc..da711fe70 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/graph/GraphScreen.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.health.openscale.R -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.Trend import com.health.openscale.core.facade.SettingsPreferenceKeys import com.health.openscale.core.model.ValueWithDifference @@ -135,7 +135,7 @@ fun GraphScreen( if (showDeleteDialog && sheetEnrichedMeasurement != null) { val enrichedItem = sheetEnrichedMeasurement val weightValue = enrichedItem.valuesWithTrend.find { - it.currentValue.type.key == MeasurementTypeKey.WEIGHT + it.currentValue.type.key == MeasurementType.WEIGHT } val weightString = weightValue?.currentValue?.let { LocaleUtils.formatValueForDisplay(it.value.floatValue.toString(), it.type.unit) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt index d936c9992..6d5acc231 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/MeasurementDetailScreen.kt @@ -67,7 +67,6 @@ import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementTypeIcon -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.utils.LocaleUtils @@ -529,7 +528,7 @@ fun MeasurementDetailScreen( .format(Date(measurementTimestampState)) } - val weightType = allMeasurementTypes.find { it.key == MeasurementTypeKey.WEIGHT } + val weightType = allMeasurementTypes.find { it.key == MeasurementType.WEIGHT } val weightValue = weightType?.let { type -> valuesState[type.id]?.let { value -> LocaleUtils.formatValueForDisplay(value, type.unit) @@ -573,7 +572,7 @@ fun MeasurementDetailScreen( }.timeInMillis } - val triggeringType = allMeasurementTypes.find { it.key == MeasurementTypeKey.DATE } + val triggeringType = allMeasurementTypes.find { it.key == MeasurementType.DATE } val dateDialogTitle = stringResource( R.string.dialog_title_change_value, triggeringType?.getDisplayName(context) ?: stringResource(R.string.label_date) @@ -614,7 +613,7 @@ fun MeasurementDetailScreen( } if (showTimePickerForMainTimestamp) { - val triggeringType = allMeasurementTypes.find { it.key == MeasurementTypeKey.TIME } + val triggeringType = allMeasurementTypes.find { it.key == MeasurementType.TIME } val timeDialogTitle = stringResource( R.string.dialog_title_change_value, triggeringType?.getDisplayName(context) ?: stringResource(R.string.label_time) @@ -642,7 +641,7 @@ fun MeasurementDetailScreen( } if (showUserPicker) { - val triggeringType = allMeasurementTypes.find { it.key == MeasurementTypeKey.USER } + val triggeringType = allMeasurementTypes.find { it.key == MeasurementType.USER } val userDialogTitle = stringResource(R.string.dialog_title_change_value, triggeringType?.getDisplayName(context) ?: stringResource(R.string.measurement_type_user)) UserInputDialog( diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index d45d6dd5e..1a0402e3d 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -106,7 +106,6 @@ import com.health.openscale.core.data.AggregationLevel import com.health.openscale.core.data.EvaluationState import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.Trend import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.UserGoals @@ -270,7 +269,7 @@ fun OverviewScreen( var measurementToDelete by remember { mutableStateOf(null) } measurementToDelete?.let { aggItem -> val enrichedItem = aggItem.enriched - val weightValue = enrichedItem.valuesWithTrend.find { it.currentValue.type.key == MeasurementTypeKey.WEIGHT } + val weightValue = enrichedItem.valuesWithTrend.find { it.currentValue.type.key == MeasurementType.WEIGHT } val weightString = weightValue?.currentValue?.let { LocaleUtils.formatValueForDisplay(it.value.floatValue.toString(), it.type.unit) } ?: "" @@ -1206,8 +1205,8 @@ fun MeasurementRowExpandable( val (displayValue, displayLow, displayHigh) = remember(evalResult, type.unit) { val targetUnit = type.unit val baseUnit = when (type.key) { - MeasurementTypeKey.WEIGHT, MeasurementTypeKey.LBM -> UnitType.KG - MeasurementTypeKey.WAIST -> UnitType.CM + MeasurementType.WEIGHT, MeasurementType.LBM -> UnitType.KG + MeasurementType.WAIST -> UnitType.CM else -> UnitType.PERCENT } // The noAgeBand branch above already handled negative limits, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt index 3edc42038..69fa6c900 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeDetailScreen.kt @@ -37,6 +37,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.outlined.Info +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults @@ -80,7 +84,6 @@ import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.LbmFormulaOption import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementTypeIcon -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType import com.health.openscale.ui.components.MeasurementIcon import com.health.openscale.ui.shared.SharedViewModel @@ -114,12 +117,55 @@ fun MeasurementTypeDetailScreen( } val isEdit = typeId != -1 - val currentMeasurementTypeKey = remember(originalExistingType, isEdit) { - if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM else MeasurementTypeKey.CUSTOM + // The predefined key behind the edited row — null for a new type and for every + // ble.*/user.* row. It constrains units and input types; a custom type is free. + val editedKey: MeasurementType.Key<*>? = remember(originalExistingType, isEdit) { + if (isEdit) originalExistingType?.key else null } - val allowedUnitsForKey = remember(currentMeasurementTypeKey) { currentMeasurementTypeKey.allowedUnitTypes } - val allowedInputTypesForKey = remember(currentMeasurementTypeKey) { currentMeasurementTypeKey.allowedInputType } + // A handler-contributed type stays inside the unit family its protocol declared + // (kg/lb/st remains switchable, a percentage cannot become a mass) and keeps the input + // type its protocol writes — switching it would make every future reading load into a + // column nothing reads. Expressed as allowed lists, which the existing plumbing below + // already renders as hidden dropdowns once there is nothing left to choose. + val allowedUnitsForKey = remember(editedKey, originalExistingType) { + when { + editedKey != null -> editedKey.allowedUnitTypes + originalExistingType?.isDeviceOwned() == true -> originalExistingType.unit.convertibleUnits() + else -> UnitType.entries.toList() + } + } + val allowedInputTypesForKey = remember(editedKey, originalExistingType) { + when { + editedKey != null -> editedKey.allowedInputTypes + originalExistingType?.isDeviceOwned() == true -> listOf(originalExistingType.inputType) + else -> listOf( + InputFieldType.FLOAT, InputFieldType.INT, InputFieldType.TEXT, + InputFieldType.DATE, InputFieldType.TIME + ) + } + } + + // The CSV column is the user-facing side of a user-created type's identity. One value + // answers both questions: null means the column is not free, and when it is, this is + // exactly the identity that gets saved. + val isUserOwnedType = originalExistingType?.isUserOwned() == true + var csvColumnKey by remember(originalExistingType) { + mutableStateOf( + originalExistingType?.identity + ?.takeIf { it.isNotBlank() } + ?.let { MeasurementType.identityColumnKey(it) } + .orEmpty() + ) + } + val takenColumnKeys = remember(measurementTypes, typeId) { + measurementTypes.filter { it.id != typeId && it.identity.isNotBlank() } + .map { MeasurementType.identityColumnKey(it.identity) } + .toSet() + } + val claimedIdentity = remember(csvColumnKey, takenColumnKeys) { + MeasurementType.userIdentityFrom(csvColumnKey, takenColumnKeys) + } var name by remember { mutableStateOf(originalExistingType?.getDisplayName(context).orEmpty()) } var selectedUnit by remember { @@ -180,12 +226,15 @@ fun MeasurementTypeDetailScreen( return@TopBarAction } - val finalKey = if (isEdit) originalExistingType?.key ?: MeasurementTypeKey.CUSTOM else MeasurementTypeKey.CUSTOM + if (isUserOwnedType && claimedIdentity == null) { + Toast.makeText(context, R.string.measurement_type_csv_column_taken, Toast.LENGTH_SHORT).show() + return@TopBarAction + } - val derivedForType = when (currentMeasurementTypeKey) { - MeasurementTypeKey.BODY_FAT -> bodyFatFormula != BodyFatFormulaOption.OFF - MeasurementTypeKey.WATER -> bodyWaterFormula != BodyWaterFormulaOption.OFF - MeasurementTypeKey.LBM -> lbmFormula != LbmFormulaOption.OFF + val derivedForType = when (editedKey) { + MeasurementType.BODY_FAT -> bodyFatFormula != BodyFatFormulaOption.OFF + MeasurementType.WATER -> bodyWaterFormula != BodyWaterFormulaOption.OFF + MeasurementType.LBM -> lbmFormula != LbmFormulaOption.OFF else -> originalExistingType?.isDerived ?: false } @@ -199,16 +248,21 @@ fun MeasurementTypeDetailScreen( displayOrder = originalExistingType?.displayOrder ?: measurementTypes.size, isEnabled = isEnabled, isPinned = isPinned, - key = finalKey, + // A user-owned type carries the (possibly edited) CSV column back as + // its identity; everything else travels untouched — a brand-new type + // leaves it blank and MeasurementTypeCrudUseCases assigns the user.* + // one, predefined and ble.* identities are frozen in the use case. + identity = if (isUserOwnedType) claimedIdentity.orEmpty() + else originalExistingType?.identity.orEmpty(), isDerived = derivedForType, isOnRightYAxis = isOnRightYAxis ) scope.launch { - when (currentMeasurementTypeKey) { - MeasurementTypeKey.BODY_FAT -> if (bodyFatFormula != bodyFatFormulaOption) sharedViewModel.setSelectedBodyFatFormula(bodyFatFormula) - MeasurementTypeKey.WATER -> if (bodyWaterFormula != bodyWaterFormulaOption) sharedViewModel.setSelectedBodyWaterFormula(bodyWaterFormula) - MeasurementTypeKey.LBM -> if (lbmFormula != lbmFormulaOption) sharedViewModel.setSelectedLbmFormula(lbmFormula) + when (editedKey) { + MeasurementType.BODY_FAT -> if (bodyFatFormula != bodyFatFormulaOption) sharedViewModel.setSelectedBodyFatFormula(bodyFatFormula) + MeasurementType.WATER -> if (bodyWaterFormula != bodyWaterFormulaOption) sharedViewModel.setSelectedBodyWaterFormula(bodyWaterFormula) + MeasurementType.LBM -> if (lbmFormula != lbmFormulaOption) sharedViewModel.setSelectedLbmFormula(lbmFormula) else -> Unit } } @@ -351,11 +405,38 @@ fun MeasurementTypeDetailScreen( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Marks the whole type rather than any single field, so it sits above the + // controls: you learn what you are looking at before you start changing things. + // Predefined and user-created types carry no such marker. + if (originalExistingType?.isDeviceOwned() == true) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Bluetooth, + contentDescription = null, // the label right next to it carries the meaning + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource(R.string.measurement_type_from_scale), + style = MaterialTheme.typography.labelMedium + ) + } + } + } + OutlinedSettingRow(label = stringResource(R.string.measurement_type_label_enabled)) { Switch(checked = isEnabled, onCheckedChange = { isEnabled = it }) } - if (!isEdit || (originalExistingType?.key == MeasurementTypeKey.CUSTOM)) { + if (!isEdit || originalExistingType?.isBuiltIn() == false) { OutlinedTextField( value = name, onValueChange = { name = it }, @@ -401,8 +482,8 @@ fun MeasurementTypeDetailScreen( ) // Formula pickers use local state - when (currentMeasurementTypeKey) { - MeasurementTypeKey.BODY_FAT -> { + when (editedKey) { + MeasurementType.BODY_FAT -> { FormulaPickerRow( label = stringResource(R.string.formula_label_body_fat), currentText = bodyFatFormula.displayName(context), @@ -416,7 +497,7 @@ fun MeasurementTypeDetailScreen( onSelect = { requestFormulaChange( it) } ) } - MeasurementTypeKey.WATER -> { + MeasurementType.WATER -> { FormulaPickerRow( label = stringResource(R.string.formula_label_body_water), currentText = bodyWaterFormula.displayName(context), @@ -430,7 +511,7 @@ fun MeasurementTypeDetailScreen( onSelect = { requestFormulaChange(it) } ) } - MeasurementTypeKey.LBM -> { + MeasurementType.LBM -> { FormulaPickerRow( label = stringResource(R.string.formula_label_lbm), currentText = lbmFormula.displayName(context), @@ -531,6 +612,50 @@ fun MeasurementTypeDetailScreen( Switch(checked = isOnRightYAxis, onCheckedChange = { isOnRightYAxis = it }) } } + + // Reference rather than a control, hence last: the header this type exports under. + // Editable only for a self-created type — a predefined column is the shared + // vocabulary that makes an export readable elsewhere, and a ble.* identity is the + // handler's link to its own values. Those two stay visible with a padlock: this + // row is the one place that answers "why does my export say BODY_FAT?". + if (originalExistingType != null && originalExistingType.key != MeasurementType.USER) { + if (isUserOwnedType) { + OutlinedTextField( + value = csvColumnKey, + onValueChange = { csvColumnKey = it }, + label = { Text(stringResource(R.string.measurement_type_label_csv_column)) }, + singleLine = true, + isError = claimedIdentity == null, + supportingText = { + if (claimedIdentity == null) { + Text(stringResource(R.string.measurement_type_csv_column_taken)) + } + }, + textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth() + ) + } else { + OutlinedSettingRow(label = stringResource(R.string.measurement_type_label_csv_column)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = MeasurementType.identityColumnKey(originalExistingType.identity), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } } if (showColorPicker) { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeSettingsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeSettingsScreen.kt index 058090c36..3aeb56a80 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeSettingsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/MeasurementTypeSettingsScreen.kt @@ -75,7 +75,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.health.openscale.R import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.ui.components.RoundMeasurementIcon import com.health.openscale.ui.screen.dialog.DeleteConfirmationDialog import com.health.openscale.ui.shared.SharedViewModel @@ -408,7 +407,7 @@ fun MeasurementTypeSettingsScreen( } // Delete button is only shown for custom types - if (type.key == MeasurementTypeKey.CUSTOM) { + if (!type.isBuiltIn()) { IconButton(onClick = { typeToDelete = type }) { Icon( Icons.Default.Delete, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt index 9eb963e4e..526d0bc50 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/SettingsViewModel.kt @@ -328,6 +328,9 @@ class SettingsViewModel @Inject constructor( if (report.valuesSkippedParseError > 0) { parts.add(context.getString(R.string.import_summary_values_skipped_parse_errors, report.valuesSkippedParseError).removeSuffix(".")) } + if (report.skippedColumns.isNotEmpty()) { + parts.add(context.getString(R.string.import_summary_skipped_unknown_columns, report.skippedColumns.size).removeSuffix(".")) + } if (parts.isNotEmpty()) { append(" (") diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt index 274c2f744..9d37b3545 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/table/TableScreen.kt @@ -90,7 +90,7 @@ import com.health.openscale.core.data.AggregationLevel import com.health.openscale.core.data.EvaluationState import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementTypeIcon -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.Trend import com.health.openscale.core.data.UnitType import com.health.openscale.core.facade.SettingsPreferenceKeys @@ -192,9 +192,9 @@ fun TableScreen( val defaultIds = allAvailableTypesFromVM .filter { it.isEnabled && - it.key != MeasurementTypeKey.DATE && - it.key != MeasurementTypeKey.TIME && - it.key != MeasurementTypeKey.USER + it.key != MeasurementType.DATE && + it.key != MeasurementType.TIME && + it.key != MeasurementType.USER } .map { it.id } selectedColumnIdsFromFilter.addAll(defaultIds) @@ -685,21 +685,21 @@ fun TableScreen( filterLogic = { allTypes -> allTypes.filter { it.isEnabled && - it.key != MeasurementTypeKey.DATE && - it.key != MeasurementTypeKey.TIME && - it.key != MeasurementTypeKey.USER + it.key != MeasurementType.DATE && + it.key != MeasurementType.TIME && + it.key != MeasurementType.USER } }, defaultSelectionLogic = { availableFilteredTypes -> - // Matched on MeasurementTypeKey, not on MeasurementTypeKey.id: the row id of a + // Matched on the predefined Key constants, not on MeasurementType.id: the row id of a // MeasurementType is auto-generated on insert and does not match the enum id. val defaultKeys = setOf( - MeasurementTypeKey.WEIGHT, - MeasurementTypeKey.BMI, - MeasurementTypeKey.BODY_FAT, - MeasurementTypeKey.WATER, - MeasurementTypeKey.MUSCLE, - MeasurementTypeKey.COMMENT, + MeasurementType.WEIGHT, + MeasurementType.BMI, + MeasurementType.BODY_FAT, + MeasurementType.WATER, + MeasurementType.MUSCLE, + MeasurementType.COMMENT, ) availableFilteredTypes .filter { it.key in defaultKeys && it.isEnabled } @@ -778,7 +778,7 @@ fun TableScreen( ) Row(Modifier.weight(1f).horizontalScroll(horizontalScrollState)) { displayedTypes.forEach { type -> - val width = if (type.key == MeasurementTypeKey.COMMENT) commentWidth else colWidth + val width = if (type.key == MeasurementType.COMMENT) commentWidth else colWidth TableHeaderCellInternal( text = type.getDisplayName(LocalContext.current), modifier = Modifier @@ -862,11 +862,11 @@ fun TableScreen( ) { displayedTypes.forEach { colType -> val cellData = rowData.values[colType.id] - val width = if (colType.key == MeasurementTypeKey.COMMENT) commentWidth else colWidth + val width = if (colType.key == MeasurementType.COMMENT) commentWidth else colWidth TableDataCellInternal( cellData = cellData, modifier = Modifier.width(width).fillMaxHeight(), - alignment = if (colType.key == MeasurementTypeKey.COMMENT) + alignment = if (colType.key == MeasurementType.COMMENT) TextAlign.Start else TextAlign.End, isAggregated = rowData.isAggregated, ) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt index f1241a1c2..c26779060 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt @@ -28,7 +28,6 @@ import com.health.openscale.core.data.AggregationLevel import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.SmoothingAlgorithm import com.health.openscale.core.data.TimeRangeFilter @@ -841,8 +840,8 @@ class SharedViewModel @Inject constructor( measuredAtMillis: Long, ) = measurementFacade.evaluate(type, value, userEvaluationContext, measuredAtMillis) - fun getPlausiblePercentRange(typeKey: MeasurementTypeKey) = - measurementFacade.plausiblePercentRangeFor(typeKey) + fun getPlausiblePercentRange(key: MeasurementType.Key<*>?) = + measurementFacade.plausiblePercentRangeFor(key) fun performCsvExport( userId: Int, @@ -898,7 +897,7 @@ class SharedViewModel @Inject constructor( return@launch } - val bmiType = types.firstOrNull { it.key == MeasurementTypeKey.BMI } ?: run { + val bmiType = types.firstOrNull { it.key == MeasurementType.BMI } ?: run { LogManager.w(TAG, "Backfill skip: BMI type not found.") return@launch } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidget.kt b/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidget.kt index 64517eff1..7dda1f892 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidget.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidget.kt @@ -38,7 +38,7 @@ import com.health.openscale.core.data.EvaluationState import com.health.openscale.core.data.IconResource import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementTypeIcon -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.facade.MeasurementFacade import com.health.openscale.core.facade.UserFacade import dagger.hilt.EntryPoint @@ -223,7 +223,7 @@ private suspend fun loadDisplayData( // Resolve target type: explicit selection → weight → first enabled numeric val targetType = selectedTypeId?.let { sel -> allTypes.firstOrNull { it.id == sel } } - ?: allTypes.firstOrNull { it.key == MeasurementTypeKey.WEIGHT } + ?: allTypes.firstOrNull { it.key == MeasurementType.WEIGHT } ?: allTypes.firstOrNull { it.isEnabled && (it.inputType == InputFieldType.FLOAT || it.inputType == InputFieldType.INT) } ?: return null diff --git a/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidgetConfigActivity.kt b/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidgetConfigActivity.kt index 8660b0c1e..831041e5d 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidgetConfigActivity.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/widget/MeasurementWidgetConfigActivity.kt @@ -48,7 +48,6 @@ import androidx.lifecycle.viewModelScope import com.health.openscale.R import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.usecase.MeasurementQueryUseCases import com.health.openscale.ui.components.RoundMeasurementIcon import dagger.hilt.android.AndroidEntryPoint @@ -178,7 +177,7 @@ private fun WidgetConfigScreen( // Default selection: WEIGHT if available, else first entry LaunchedEffect(types) { - selectedId = types.firstOrNull { it.key == MeasurementTypeKey.WEIGHT }?.id + selectedId = types.firstOrNull { it.key == MeasurementType.WEIGHT }?.id ?: types.firstOrNull()?.id selectedTheme = WidgetTheme.LIGHT } diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index cdf2bf7ac..ed9e92cfd 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -191,6 +191,19 @@ Intracellular water Protein Body cell mass + Added by a scale + CSV column + This CSV column is already in use. + Body fat (left arm) + Body fat (right arm) + Body fat (torso) + Body fat (left leg) + Body fat (right leg) + Muscle (left arm) + Muscle (right arm) + Muscle (torso) + Muscle (left leg) + Muscle (right leg) Bone formula Impedance-based regression (Legacy) Heymsfield (anthropometric) @@ -805,6 +818,7 @@ "%1$d rows skipped due to missing dates." "%1$d rows skipped due to date parsing errors." "%1$d individual values could not be parsed." + "%1$d columns did not match any measurement and were skipped." No valid data found in the CSV file, or all rows contained errors. Import failed: The mandatory column date is missing in the CSV header. Import failed: Could not read the file. diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/data/ScaleMeasurementTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/data/ScaleMeasurementTest.kt new file mode 100644 index 000000000..d2be207cf --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/data/ScaleMeasurementTest.kt @@ -0,0 +1,89 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.google.common.truth.Truth.assertThat +import com.health.openscale.R +import com.health.openscale.core.data.MeasurementType +import org.junit.Test +import com.health.openscale.core.data.Bpm +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Percent + +/** The envelope-plus-map behaviour every multi-packet protocol leans on. */ +class ScaleMeasurementTest { + + private val deviceKey = MeasurementType.devicePercent( + "segmental.fat.left_arm", R.string.measurement_type_weight + ) + + @Test + fun `absence is absence - values that never describe a measurement are dropped`() { + val m = ScaleMeasurement() + m[MeasurementType.WEIGHT] = Kg(0f) // the old sentinel for "not reported" + m[MeasurementType.BODY_FAT] = Percent(Float.NaN) + m[MeasurementType.HEART_RATE] = Bpm(0) + m[MeasurementType.COMMENT] = " " + + assertThat(m.values).isEmpty() + assertThat(m.hasWeight()).isFalse() + + m[MeasurementType.WEIGHT] = Kg(72.5f) + assertThat(m.hasWeight()).isTrue() + assertThat(m[MeasurementType.WEIGHT]?.value).isEqualTo(72.5f) + } + + @Test + fun `predefined and device keys ride the same map`() { + val m = ScaleMeasurement() + m[MeasurementType.WEIGHT] = Kg(72.5f) + m[deviceKey] = Percent(12.4f) + m[MeasurementType.HEART_RATE] = Bpm(62) + + assertThat(m.values).hasSize(3) + assertThat(m[deviceKey]?.value).isEqualTo(12.4f) + assertThat(m[MeasurementType.HEART_RATE]?.value).isEqualTo(62) + } + + @Test + fun `merge fills gaps without overwriting what was collected first`() { + val target = ScaleMeasurement(userId = 0xFF).apply { this[MeasurementType.WEIGHT] = Kg(72.5f) } + val incoming = ScaleMeasurement(userId = 3).apply { + this[MeasurementType.WEIGHT] = Kg(99f) + this[MeasurementType.BODY_FAT] = Percent(21.3f) + } + + target.mergeWith(incoming) + + assertThat(target[MeasurementType.WEIGHT]?.value).isEqualTo(72.5f) + assertThat(target[MeasurementType.BODY_FAT]?.value).isEqualTo(21.3f) + assertThat(target.userId).isEqualTo(3) + } + + @Test + fun `snapshot detaches the map so later mutations do not leak into published data`() { + val acc = ScaleMeasurement().apply { this[MeasurementType.WEIGHT] = Kg(72.5f) } + + val published = acc.snapshot() + acc[MeasurementType.BODY_FAT] = Percent(21.3f) + acc.values.clear() + + assertThat(published[MeasurementType.WEIGHT]?.value).isEqualTo(72.5f) + assertThat(published.values).hasSize(1) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandlerTest.kt index 57a67bcc3..24f26e1cd 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandlerTest.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.MeasurementType import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.bluetooth.data.ScaleMeasurement @@ -444,11 +445,11 @@ class BodyConnectHandlerTest { assertThat(setup.callbacks.published).hasSize(1) val measurement = setup.callbacks.published.single() - assertThat(measurement.weight).isWithin(1e-5f).of(80.2f) - assertThat(measurement.fat).isWithin(1e-5f).of(17.6f) - assertThat(measurement.water).isWithin(1e-5f).of(61.6f) - assertThat(measurement.muscle).isWithin(1e-5f).of(42.7f) - assertThat(measurement.bone).isWithin(1e-5f).of(3.6f) + assertThat(measurement[MeasurementType.WEIGHT]?.value).isWithin(1e-5f).of(80.2f) + assertThat(measurement[MeasurementType.BODY_FAT]?.value).isWithin(1e-5f).of(17.6f) + assertThat(measurement[MeasurementType.WATER]?.value).isWithin(1e-5f).of(61.6f) + assertThat(measurement[MeasurementType.MUSCLE]?.value).isWithin(1e-5f).of(42.7f) + assertThat(measurement[MeasurementType.BONE]?.value).isWithin(1e-5f).of(3.6f) } @Test @@ -588,7 +589,7 @@ class BodyConnectHandlerTest { val requestedUserInteractions = mutableListOf>() override fun onPublish(measurement: ScaleMeasurement) { - published += measurement.copy() + published += measurement.snapshot() } override fun onUserInteractionRequired(interactionType: UserInteractionType, data: Any?) { @Suppress("UNCHECKED_CAST") diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt index dfaf679dd..3b050b765 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import android.util.SparseArray import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.MeasurementType import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser @@ -99,7 +100,7 @@ class HuaweiHagridWspHandlerTest { assertThat(callbacks.published).hasSize(1) assertThat(callbacks.published.single().userId).isEqualTo(7) - assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.5f) + assertThat(callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(77.5f) } @Test @@ -181,7 +182,7 @@ class HuaweiHagridWspHandlerTest { sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) assertThat(callbacks.published).hasSize(1) - assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.32f) + assertThat(callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(77.32f) assertThat(callbacks.infos.map { it.resId }) .contains(R.string.bluetooth_scale_info_measuring_weight) } @@ -217,12 +218,12 @@ class HuaweiHagridWspHandlerTest { impedanceOhm = 350.0, ) assertThat(expectedComposition).isNotNull() - assertThat(measurement.weight).isWithin(0.0001f).of(77.32f) - assertThat(measurement.fat).isWithin(0.0001f).of(expectedComposition!!.bodyFatPercent) + assertThat(measurement[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(77.32f) + assertThat(measurement[MeasurementType.BODY_FAT]?.value).isWithin(0.0001f).of(expectedComposition!!.bodyFatPercent) // The stored low-frequency impedance is the very value the composition model was fed, // so a measurement can still be interpreted from what is in the database. - assertThat(measurement.impedanceLow).isWithin(0.0001).of(350.0) - assertThat(measurement.impedance).isWithin(0.0001).of(600.0) + assertThat(measurement[MeasurementType.IMPEDANCE_LOW]?.value).isWithin(0.0001f).of(350f) + assertThat(measurement[MeasurementType.IMPEDANCE]?.value).isWithin(0.0001f).of(600f) // Subsequent realtime notifications in the same cycle are ignored for persistence. sendWspNotification(handler, CHR_REALTIME_WEIGHT, scale3CompositionRealtimePayload()) @@ -247,7 +248,7 @@ class HuaweiHagridWspHandlerTest { // Realtime arrives first and is published immediately. sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) assertThat(callbacks.published).hasSize(1) - assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.32f) + assertThat(callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(77.32f) assertThat(callbacks.infos.map { it.resId }) .contains(R.string.bluetooth_scale_info_measuring_weight) @@ -277,13 +278,13 @@ class HuaweiHagridWspHandlerTest { assertThat(callbacks.published).hasSize(1) val measurement = callbacks.published.single() - assertThat(measurement.weight).isWithin(0.0001f).of(77.32f) - assertThat(measurement.fat).isWithin(0.0001f).of(18.5f) - assertThat(measurement.heartRate).isEqualTo(72) - assertThat(measurement.impedanceLow).isWithin(0.0001).of(500.0) - assertThat(measurement.impedance).isWithin(0.0001).of(600.0) - assertThat(measurement.lbm).isEqualTo(0f) - assertThat(measurement.bmr).isEqualTo(0f) + assertThat(measurement[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(77.32f) + assertThat(measurement[MeasurementType.BODY_FAT]?.value).isWithin(0.0001f).of(18.5f) + assertThat(measurement[MeasurementType.HEART_RATE]?.value).isEqualTo(72) + assertThat(measurement[MeasurementType.IMPEDANCE_LOW]?.value).isWithin(0.0001f).of(500f) + assertThat(measurement[MeasurementType.IMPEDANCE]?.value).isWithin(0.0001f).of(600f) + assertThat(measurement[MeasurementType.LBM]).isNull() + assertThat(measurement[MeasurementType.BMR]).isNull() assertThat(transport.reassembleWriteMessagesTo(CHR_HISTORY_WEIGHT).last()) .isEqualTo(byteArrayOf(0x00)) } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt index 81f30d883..2de526b92 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiUserRecordWireTest.kt @@ -18,6 +18,8 @@ package com.health.openscale.core.bluetooth.scales import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.service.ScannedDeviceInfo @@ -127,8 +129,7 @@ class HuaweiUserRecordWireTest { previous = ScaleMeasurement( userId = TEST_USER_ID, dateTime = Date(0L), - weight = 80.0f, - ), + ).apply { this[MeasurementType.WEIGHT] = Kg(80.0f) }, ) val plain = decodeAhCh100Record(driveAhCh100ToUserInfo(setup)) diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt index 15802a249..a95753717 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.KeepS3BodyComposition @@ -37,6 +38,8 @@ import java.util.Date import java.util.UUID import kotlin.coroutines.EmptyCoroutineContext import kotlin.math.roundToInt +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Ohm @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -191,19 +194,19 @@ class KeepS3HandlerTest { setup.handler.handleNotification(notifyCharacteristic, record) assertThat(setup.callbacks.published).hasSize(1) - assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(85.10f) - assertThat(setup.callbacks.published.single().impedance).isEqualTo(478.0) - assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(506.0) - assertThat(setup.callbacks.published.single().heartRate).isEqualTo(107) - assertThat(setup.callbacks.published.single().fat).isEqualTo(29.5f) - assertThat(setup.callbacks.published.single().water).isEqualTo(50.2f) - assertThat(setup.callbacks.published.single().muscle) + assertThat(setup.callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(85.10f) + assertThat(setup.callbacks.published.single()[MeasurementType.IMPEDANCE]?.value).isEqualTo(478f) + assertThat(setup.callbacks.published.single()[MeasurementType.IMPEDANCE_LOW]?.value).isEqualTo(506f) + assertThat(setup.callbacks.published.single()[MeasurementType.HEART_RATE]?.value).isEqualTo(107) + assertThat(setup.callbacks.published.single()[MeasurementType.BODY_FAT]?.value).isEqualTo(29.5f) + assertThat(setup.callbacks.published.single()[MeasurementType.WATER]?.value).isEqualTo(50.2f) + assertThat(setup.callbacks.published.single()[MeasurementType.MUSCLE]?.value) .isWithin(0.001f).of(38.42538f) - assertThat(setup.callbacks.published.single().visceralFat).isEqualTo(12f) - assertThat(setup.callbacks.published.single().protein).isEqualTo(12.7f) - assertThat(setup.callbacks.published.single().bone).isEqualTo(3.0f) - assertThat(setup.callbacks.published.single().lbm).isEqualTo(60.0f) - assertThat(setup.callbacks.published.single().bmr).isEqualTo(1791f) + assertThat(setup.callbacks.published.single()[MeasurementType.VISCERAL_FAT]).isEqualTo(12f) + assertThat(setup.callbacks.published.single()[MeasurementType.PROTEIN]?.value).isEqualTo(12.7f) + assertThat(setup.callbacks.published.single()[MeasurementType.BONE]?.value).isEqualTo(3.0f) + assertThat(setup.callbacks.published.single()[MeasurementType.LBM]?.value).isEqualTo(60.0f) + assertThat(setup.callbacks.published.single()[MeasurementType.BMR]?.value).isEqualTo(1791f) val storedDeviceImpedance = KeepS3Protocol.parseDeviceImpedance( setup.settings.strings[KeepS3Protocol.deviceImpedanceSettingKey(setup.user.id)], @@ -256,11 +259,11 @@ class KeepS3HandlerTest { assertThat(setup.callbacks.published).hasSize(1) assertThat(setup.callbacks.published.single().userId).isEqualTo(setup.user.id) - assertThat(setup.callbacks.published.single().impedance).isEqualTo(475.0) - assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(502.0) - assertThat(setup.callbacks.published.single().fat).isEqualTo(29.4f) - assertThat(setup.callbacks.published.single().water).isEqualTo(50.3f) - assertThat(setup.callbacks.published.single().muscle) + assertThat(setup.callbacks.published.single()[MeasurementType.IMPEDANCE]?.value).isEqualTo(475f) + assertThat(setup.callbacks.published.single()[MeasurementType.IMPEDANCE_LOW]?.value).isEqualTo(502f) + assertThat(setup.callbacks.published.single()[MeasurementType.BODY_FAT]?.value).isEqualTo(29.4f) + assertThat(setup.callbacks.published.single()[MeasurementType.WATER]?.value).isEqualTo(50.3f) + assertThat(setup.callbacks.published.single()[MeasurementType.MUSCLE]?.value) .isWithin(0.001f).of(38.47059f) assertThat(setup.transport.writes.count { it.payload.contentEquals(KeepS3Protocol.buildAck(0x57)) @@ -288,10 +291,10 @@ class KeepS3HandlerTest { runCurrent() assertThat(setup.callbacks.published).hasSize(1) - assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(85.00f) - assertThat(setup.callbacks.published.single().impedance).isEqualTo(0.0) - assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(0.0) - assertThat(setup.callbacks.published.single().heartRate).isEqualTo(97) + assertThat(setup.callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(85.00f) + assertThat(setup.callbacks.published.single()[MeasurementType.IMPEDANCE]).isNull() + assertThat(setup.callbacks.published.single()[MeasurementType.IMPEDANCE_LOW]).isNull() + assertThat(setup.callbacks.published.single()[MeasurementType.HEART_RATE]?.value).isEqualTo(97) } @Test @@ -314,11 +317,11 @@ class KeepS3HandlerTest { ) assertThat(setup.callbacks.published).hasSize(1) - assertThat(setup.callbacks.published.single().weight).isEqualTo(85.1f) - assertThat(setup.callbacks.published.single().heartRate).isEqualTo(107) - assertThat(setup.callbacks.published.single().fat).isEqualTo(0f) - assertThat(setup.callbacks.published.single().water).isEqualTo(0f) - assertThat(setup.callbacks.published.single().lbm).isEqualTo(0f) + assertThat(setup.callbacks.published.single()[MeasurementType.WEIGHT]?.value).isEqualTo(85.1f) + assertThat(setup.callbacks.published.single()[MeasurementType.HEART_RATE]?.value).isEqualTo(107) + assertThat(setup.callbacks.published.single()[MeasurementType.BODY_FAT]).isNull() + assertThat(setup.callbacks.published.single()[MeasurementType.WATER]).isNull() + assertThat(setup.callbacks.published.single()[MeasurementType.LBM]).isNull() } @Test @@ -352,11 +355,11 @@ class KeepS3HandlerTest { ), )!! val actual = setup.callbacks.published.single() - assertThat(actual.fat).isEqualTo(expected.bodyFatPercent) - assertThat(actual.water).isEqualTo(expected.waterPercent) - assertThat(actual.muscle).isEqualTo(expected.skeletalMusclePercent) - assertThat(actual.bone).isEqualTo(expected.boneKg) - assertThat(actual.lbm).isEqualTo(expected.fatFreeMassKg) + assertThat(actual[MeasurementType.BODY_FAT]?.value).isEqualTo(expected.bodyFatPercent) + assertThat(actual[MeasurementType.WATER]?.value).isEqualTo(expected.waterPercent) + assertThat(actual[MeasurementType.MUSCLE]?.value).isEqualTo(expected.skeletalMusclePercent) + assertThat(actual[MeasurementType.BONE]?.value).isEqualTo(expected.boneKg) + assertThat(actual[MeasurementType.LBM]?.value).isEqualTo(expected.fatFreeMassKg) } @Test @@ -564,8 +567,8 @@ class KeepS3HandlerTest { ), ) val previous = first.callbacks.published.single() - assertThat(previous.impedance).isEqualTo(475.0) - assertThat(previous.impedanceLow).isEqualTo(502.0) + assertThat(previous[MeasurementType.IMPEDANCE]?.value).isEqualTo(475f) + assertThat(previous[MeasurementType.IMPEDANCE_LOW]?.value).isEqualTo(502f) val setup = attachedHandler(previous = previous, settings = settings, scope = this) @@ -584,10 +587,11 @@ class KeepS3HandlerTest { val previous = ScaleMeasurement( userId = 7, dateTime = Date(0x1234_5678L * 1000L), - weight = 85.10f, - impedance = 478.0, - impedanceLow = 506.0, - ) + ).apply { + this[MeasurementType.WEIGHT] = Kg(85.10f) + this[MeasurementType.IMPEDANCE] = Ohm(478f) + this[MeasurementType.IMPEDANCE_LOW] = Ohm(506f) + } val settings = InMemorySettings().apply { putString( KeepS3Protocol.deviceImpedanceSettingKey(previous.userId), @@ -648,9 +652,10 @@ class KeepS3HandlerTest { previous: ScaleMeasurement? = ScaleMeasurement( userId = user.id, dateTime = Date(0x1234_5678L * 1000L), - weight = 85.10f, - impedance = 301.0, - ), + ).apply { + this[MeasurementType.WEIGHT] = Kg(85.10f) + this[MeasurementType.IMPEDANCE] = Ohm(301f) + }, ): Setup { val handler = KeepS3Handler() val transport = CapturingTransport() @@ -810,7 +815,7 @@ class KeepS3HandlerTest { val published = mutableListOf() override fun onPublish(measurement: ScaleMeasurement) { - published += measurement.copy() + published += measurement.snapshot() } override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocHandlerTest.kt index 9a77d0c04..6c9643256 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocHandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocHandlerTest.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.data.GenderType @@ -374,17 +375,17 @@ class PicoocHandlerTest { val measurement = setup.callbacks.published.single() assertThat(measurement.userId).isEqualTo(setup.user.id) - assertThat(measurement.weight).isWithin(0.0001f).of(70.0f) - assertThat(measurement.fat).isWithin(0.0001f).of(18.5f) - assertThat(measurement.water).isWithin(0.0001f).of(55.2f) - assertThat(measurement.bone).isWithin(0.0001f).of(3.0f) - assertThat(measurement.visceralFat).isWithin(0.0001f).of(8.0f) - assertThat(measurement.lbm).isWithin(0.001f).of(57.05f) - assertThat(measurement.heartRate).isEqualTo(72) - assertThat(measurement.impedance).isWithin(0.0001).of(500.0) + assertThat(measurement[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(70.0f) + assertThat(measurement[MeasurementType.BODY_FAT]?.value).isWithin(0.0001f).of(18.5f) + assertThat(measurement[MeasurementType.WATER]?.value).isWithin(0.0001f).of(55.2f) + assertThat(measurement[MeasurementType.BONE]?.value).isWithin(0.0001f).of(3.0f) + assertThat(measurement[MeasurementType.VISCERAL_FAT]).isWithin(0.0001f).of(8.0f) + assertThat(measurement[MeasurementType.LBM]?.value).isWithin(0.001f).of(57.05f) + assertThat(measurement[MeasurementType.HEART_RATE]?.value).isEqualTo(72) + assertThat(measurement[MeasurementType.IMPEDANCE]?.value).isWithin(0.0001f).of(500f) // 0x32 carries neither of these, so they come from the impedance estimator. - assertThat(measurement.muscle).isGreaterThan(0f) - assertThat(measurement.bmr).isGreaterThan(0f) + assertThat(measurement[MeasurementType.MUSCLE]?.value).isGreaterThan(0f) + assertThat(measurement[MeasurementType.BMR]?.value).isGreaterThan(0f) } @Test @@ -398,7 +399,7 @@ class PicoocHandlerTest { advanceTimeBy(PicoocHandler.SETTLE_WAIT_MS) runCurrent() - assertThat(setup.callbacks.published.single().heartRate).isEqualTo(72) + assertThat(setup.callbacks.published.single()[MeasurementType.HEART_RATE]?.value).isEqualTo(72) } @Test @@ -411,12 +412,12 @@ class PicoocHandlerTest { runCurrent() val measurement = setup.callbacks.published.single() - assertThat(measurement.weight).isWithin(0.0001f).of(70.0f) - assertThat(measurement.impedance).isWithin(0.0001).of(500.0) - assertThat(measurement.fat).isGreaterThan(0f) - assertThat(measurement.water).isGreaterThan(0f) - assertThat(measurement.muscle).isGreaterThan(0f) - assertThat(measurement.bmr).isGreaterThan(0f) + assertThat(measurement[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(70.0f) + assertThat(measurement[MeasurementType.IMPEDANCE]?.value).isWithin(0.0001f).of(500f) + assertThat(measurement[MeasurementType.BODY_FAT]?.value).isGreaterThan(0f) + assertThat(measurement[MeasurementType.WATER]?.value).isGreaterThan(0f) + assertThat(measurement[MeasurementType.MUSCLE]?.value).isGreaterThan(0f) + assertThat(measurement[MeasurementType.BMR]?.value).isGreaterThan(0f) } @Test @@ -430,7 +431,7 @@ class PicoocHandlerTest { setup.handler.handleDisconnected() assertThat(setup.callbacks.published).hasSize(1) - assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(70.0f) + assertThat(setup.callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(70.0f) } @Test @@ -480,11 +481,11 @@ class PicoocHandlerTest { setup.handler.handleDisconnected() val measurement = setup.callbacks.published.single() - assertThat(measurement.weight).isWithin(0.0001f).of(72.1f) - assertThat(measurement.impedance).isWithin(0.0001).of(505.0) - assertThat(measurement.heartRate).isEqualTo(68) - assertThat(measurement.fat).isGreaterThan(0f) - assertThat(measurement.bmr).isGreaterThan(0f) + assertThat(measurement[MeasurementType.WEIGHT]?.value).isWithin(0.0001f).of(72.1f) + assertThat(measurement[MeasurementType.IMPEDANCE]?.value).isWithin(0.0001f).of(505f) + assertThat(measurement[MeasurementType.HEART_RATE]?.value).isEqualTo(68) + assertThat(measurement[MeasurementType.BODY_FAT]?.value).isGreaterThan(0f) + assertThat(measurement[MeasurementType.BMR]?.value).isGreaterThan(0f) } @Test @@ -644,7 +645,7 @@ class PicoocHandlerTest { val published = mutableListOf() override fun onPublish(measurement: ScaleMeasurement) { - published += measurement.copy() + published += measurement.snapshot() } override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandlerTest.kt new file mode 100644 index 000000000..188b75df8 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandlerTest.kt @@ -0,0 +1,246 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon +import com.health.openscale.core.data.Percent +import com.health.openscale.core.data.UnitType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Calendar +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext + +/** + * The BF1000 reports segmental fat and muscle on Beurer-private characteristics, separate + * from the standard weight/body-composition packets. These tests drive both halves through + * the handler and assert that the private values reach the published measurement. + * + * Packet payloads are the vectors already asserted in BeurerBf1000LibTest. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StandardBeurerSanitasHandlerTest { + + private val chrWeightMeasurement = uuid16(0x2A9D) + private val chrSegmentalFat = uuid16(0x0009) + private val chrSegmentalMuscle = uuid16(0x000A) + + @Test + fun `attaches BF1000 segmental values to the published measurement`() { + val setup = attachedHandler("Beurer BF1000") + + setup.handler.handleNotification(chrSegmentalFat, segmentalFatPacket()) + setup.handler.handleNotification(chrSegmentalMuscle, segmentalMusclePacket()) + setup.handler.handleNotification(chrWeightMeasurement, weightPacket()) + setup.handler.handleDisconnected() + + val published = setup.callbacks.published.single() + assertThat(published[MeasurementType.WEIGHT]!!.value).isWithin(1e-3f).of(72.5f) + + val segmentals = published.values.keys + .filter { it.identity.startsWith("ble.segmental.") } + .associate { it.identity to (published.values[it] as Percent).value } + + val expected = mapOf( + "ble.segmental.fat.left_arm" to 12.4f, + "ble.segmental.fat.right_arm" to 12.9f, + "ble.segmental.fat.torso" to 21.3f, + "ble.segmental.fat.left_leg" to 18.7f, + "ble.segmental.fat.right_leg" to 18.9f, + "ble.segmental.muscle.left_arm" to 33.1f, + "ble.segmental.muscle.right_arm" to 33.6f, + "ble.segmental.muscle.torso" to 45.2f, + "ble.segmental.muscle.left_leg" to 41.8f, + "ble.segmental.muscle.right_leg" to 42.0f, + ) + assertThat(segmentals.keys).containsExactlyElementsIn(expected.keys) + expected.forEach { (identity, percent) -> + assertThat(segmentals.getValue(identity)).isWithin(1e-3f).of(percent) + } + } + + @Test + fun `visceral fat rides the predefined key, not a device-specific one`() { + val setup = attachedHandler("Beurer BF1000") + + setup.handler.handleNotification(chrSegmentalFat, segmentalFatPacket()) + setup.handler.handleNotification(chrWeightMeasurement, weightPacket()) + setup.handler.handleDisconnected() + + val published = setup.callbacks.published.single() + assertThat(published[MeasurementType.VISCERAL_FAT]!!).isWithin(1e-3f).of(0.8f) + assertThat(published.values.keys.map { it.identity }) + .doesNotContain("ble.segmental.visceral_fat") + } + + @Test + fun `the buffer is cleared, so the next weigh-in does not inherit stale values`() { + val setup = attachedHandler("Beurer BF1000") + + setup.handler.handleNotification(chrSegmentalFat, segmentalFatPacket()) + setup.handler.handleNotification(chrWeightMeasurement, weightPacket()) + setup.handler.handleDisconnected() + setup.handler.handleNotification(chrWeightMeasurement, weightPacket()) + setup.handler.handleDisconnected() + + assertThat(setup.callbacks.published).hasSize(2) + assertThat(setup.callbacks.published[0].values.keys.any { it.identity.startsWith("ble.") }).isTrue() + assertThat(setup.callbacks.published[1].values.keys.any { it.identity.startsWith("ble.") }).isFalse() + } + + @Test + fun `other Beurer models are untouched by the BF1000 path`() { + val setup = attachedHandler("Beurer BF105") + + setup.handler.handleNotification(chrWeightMeasurement, weightPacket()) + setup.handler.handleDisconnected() + + val published = setup.callbacks.published.single() + assertThat(published[MeasurementType.WEIGHT]!!.value).isWithin(1e-3f).of(72.5f) + assertThat(published.values.keys.none { it.identity.startsWith("ble.") }).isTrue() + } + + @Test + fun `each segment seeds with a distinguishable icon and colour`() { + val setup = attachedHandler("Beurer BF1000") + setup.handler.handleNotification(chrSegmentalFat, segmentalFatPacket()) + setup.handler.handleNotification(chrSegmentalMuscle, segmentalMusclePacket()) + setup.handler.handleNotification(chrWeightMeasurement, weightPacket()) + setup.handler.handleDisconnected() + + val keys = setup.callbacks.published.single().values.keys + .filter { it.identity.startsWith("ble.segmental.") } + + val fat = keys.filter { it.identity.startsWith("ble.segmental.fat.") } + val muscle = keys.filter { it.identity.startsWith("ble.segmental.muscle.") } + // Colour separates the groups and the segments; the whole-body anchors stay reserved. + assertThat(fat.map { it.defaultColor }).containsNoDuplicates() + assertThat(muscle.map { it.defaultColor }).containsNoDuplicates() + assertThat(fat.map { it.defaultColor }.intersect(muscle.map { it.defaultColor }.toSet())).isEmpty() + assertThat(keys.map { it.defaultColor }) + .containsNoneOf(MeasurementType.BODY_FAT.defaultColor, MeasurementType.MUSCLE.defaultColor) + // The icon carries the body part; unit is what both packets report. + assertThat(keys.none { it.defaultIcon == MeasurementTypeIcon.IC_DEFAULT }).isTrue() + assertThat(keys.all { it.wireUnit == UnitType.PERCENT }).isTrue() + assertThat(keys.none { it.defaultPinned }).isTrue() + } + + // --- helpers --------------------------------------------------------------- + + /** Standard Weight Scale 2A9D: flags 0x00 (kg, nothing optional) + 72.5 kg / 0.005. */ + private fun weightPacket(): ByteArray { + val raw = (72.5f / 0.005f).toInt() + return byteArrayOf(0x00, (raw and 0xFF).toByte(), ((raw shr 8) and 0xFF).toByte()) + } + + /** 0x7E marker, visceral in tenths, then five fat percentages in tenths, LE. */ + private fun segmentalFatPacket(): ByteArray = + byteArrayOf(0x7E, 8) + tenths(12.4f, 12.9f, 21.3f, 18.7f, 18.9f) + + /** 0x3E marker, then five muscle percentages in tenths, LE. */ + private fun segmentalMusclePacket(): ByteArray = + byteArrayOf(0x3E) + tenths(33.1f, 33.6f, 45.2f, 41.8f, 42.0f) + + private fun tenths(vararg values: Float): ByteArray { + val out = ByteArray(values.size * 2) + values.forEachIndexed { i, v -> + val raw = Math.round(v * 10f) + out[i * 2] = (raw and 0xFF).toByte() + out[i * 2 + 1] = ((raw shr 8) and 0xFF).toByte() + } + return out + } + + private fun attachedHandler(deviceName: String): Setup { + val handler = StandardBeurerSanitasHandler() + assertThat(handler.supportFor(device(deviceName))).isNotNull() + + val callbacks = CapturingCallbacks() + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(user()), + scope = CoroutineScope(EmptyCoroutineContext), + ) + return Setup(handler, callbacks) + } + + private fun user(): ScaleUser { + val birthday = Calendar.getInstance().apply { add(Calendar.YEAR, -30) }.time + return ScaleUser(id = 7, birthday = birthday, bodyHeight = 180f, gender = GenderType.MALE) + } + + private fun device(name: String) = ScannedDeviceInfo( + name = name, + address = "00:11:22:33:44:55", + rssi = -50, + serviceUuids = emptyList(), + manufacturerData = null, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private class Setup( + val handler: StandardBeurerSanitasHandler, + val callbacks: CapturingCallbacks, + ) + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + override fun onPublish(measurement: ScaleMeasurement) { + published += measurement.snapshot() + } + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private class NoopTransport : ScaleDeviceHandler.Transport { + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) = Unit + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = true + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + private val strings = mutableMapOf() + private val ints = mutableMapOf() + override fun getInt(key: String, default: Int): Int = ints[key] ?: default + override fun putInt(key: String, value: Int) { ints[key] = value } + override fun getString(key: String, default: String?): String? = strings[key] ?: default + override fun putString(key: String, value: String) { strings[key] = value } + override fun remove(key: String) { strings.remove(key); ints.remove(key) } + } + + private class FixedDataProvider(private val user: ScaleUser) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = null + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/data/MeasurementTypeIdentityTest.kt b/android_app/app/src/test/java/com/health/openscale/core/data/MeasurementTypeIdentityTest.kt new file mode 100644 index 000000000..fb06338c1 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/data/MeasurementTypeIdentityTest.kt @@ -0,0 +1,174 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.data + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.R +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The identity rules everything else leans on: the registry invariant that keeps the + * migration, the CSV headers and the sync wire format correct at once, and the slug rules + * that keep the 15→16 migration from tripping over its own unique index. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MeasurementTypeIdentityTest { + + // --- the registry invariant ------------------------------------------------- + + /** The `key` column values shipped by every schema up to 15, in seed order — minus + * ECW, ICW and BCM, which MIGRATION_15_16 retires into the ble.* namespace. */ + private val historicalEnumNames = listOf( + "WEIGHT", "BMI", "BODY_FAT", "WATER", "MUSCLE", "LBM", "BONE", "WAIST", "WHR", + "WHTR", "HIPS", "VISCERAL_FAT", "CHEST", "THIGH", "BICEPS", "NECK", "CALIPER_1", + "CALIPER_2", "CALIPER_3", "CALIPER", "BMR", "TDEE", "HEART_RATE", "IMPEDANCE", + "IMPEDANCE_LOW", "PROTEIN", "CALORIES", "COMMENT", "DATE", "TIME", "USER" + ) + + @Test + fun `every predefined identity is the lowercased historical enum name`() { + // MIGRATION_15_16 rewrites `'builtin.' || lower(key)`; this is what makes that + // SQL land exactly on the registry. + assertThat(MeasurementType.allKeys.map { it.identity }) + .containsExactlyElementsIn(historicalEnumNames.map { "builtin." + it.lowercase() }) + .inOrder() + } + + @Test + fun `identity column keys are byte-identical to the pre-16 enum names`() { + // CSV headers derive from the identity, so files written by older versions keep matching. + val rows = MeasurementType.seedRows() + assertThat(rows.map { MeasurementType.identityColumnKey(it.identity) }) + .containsExactlyElementsIn(historicalEnumNames).inOrder() + } + + @Test + fun `seed rows carry their key's defaults`() { + val weight = MeasurementType.seedRows().first() + assertThat(weight.identity).isEqualTo("builtin.weight") + assertThat(weight.unit).isEqualTo(UnitType.KG) + assertThat(weight.isPinned).isTrue() + assertThat(weight.name).isNull() // display name comes from resources, not the row + + val impedance = MeasurementType.seedRows().first { it.identity == "builtin.impedance" } + assertThat(impedance.isInternal).isTrue() + assertThat(impedance.isEnabled).isFalse() + } + + // --- origin and lookup ------------------------------------------------------ + + @Test + fun `origin is read off the namespace prefix`() { + val builtIn = MeasurementType(identity = MeasurementType.WEIGHT.identity) + val device = MeasurementType(identity = "ble.segmental.fat.torso", name = "x") + val user = MeasurementType(identity = "user.schritte", name = "Schritte") + + assertThat(builtIn.isBuiltIn()).isTrue() + assertThat(builtIn.key).isEqualTo(MeasurementType.WEIGHT) + assertThat(device.isDeviceOwned()).isTrue() + assertThat(device.key).isNull() + assertThat(user.isUserOwned()).isTrue() + assertThat(user.key).isNull() + } + + @Test + fun `device factories mint the ble namespace, nobody types it`() { + val key = MeasurementType.deviceFloat("segmental.fat.left_arm", R.string.measurement_type_weight) + assertThat(key.identity).isEqualTo("ble.segmental.fat.left_arm") + // Equality is on identity alone: two declarations of one key can never be two columns. + assertThat(key).isEqualTo( + MeasurementType.deviceFloat("segmental.fat.left_arm", R.string.measurement_type_bmi, color = 1) + ) + } + + // --- user identities -------------------------------------------------------- + + @Test + fun `user identities are slugs, numbered instead of colliding`() { + // This is the case that would abort MIGRATION_15_16 on the unique index: + // pre-16 allows two custom types to share a name. + val first = MeasurementType.userIdentityFor("Schritte", emptySet()) + val second = MeasurementType.userIdentityFor("Schritte", setOf("SCHRITTE")) + assertThat(listOf(first, second)).containsExactly("user.schritte", "user.schritte_2").inOrder() + + assertThat(MeasurementType.userIdentityFor(" Blood pressure (sys) ", emptySet())) + .isEqualTo("user.blood_pressure_sys") + assertThat(MeasurementType.userIdentityFor("Körperfett", emptySet())) + .isEqualTo("user.körperfett") // unicode aware, no ASCII folding + assertThat(MeasurementType.userIdentityFor("---", emptySet())).isEqualTo("user.unnamed") + } + + @Test + fun `a user type can never shadow a predefined column`() { + assertThat(MeasurementType.userIdentityFor("Weight", emptySet())).isEqualTo("user.weight_2") + assertThat(MeasurementType.userIdentityFor("body fat", emptySet())).isEqualTo("user.body_fat_2") + } + + // --- display name ----------------------------------------------------------- + + @Test + fun `predefined names are localized, custom names are stored`() { + val context = androidx.test.core.app.ApplicationProvider.getApplicationContext() + val weight = MeasurementType(identity = MeasurementType.WEIGHT.identity, name = "ignored") + val user = MeasurementType(identity = "user.schritte", name = "Schritte") + + assertThat(weight.getDisplayName(context)).isEqualTo(context.getString(R.string.measurement_type_weight)) + assertThat(user.getDisplayName(context)).isEqualTo("Schritte") + } + + // --- the editable CSV column ----------------------------------------------- + + @Test + fun `a typed-in column is normalised and handed back as an identity`() { + assertThat(MeasurementType.userIdentityFrom("STEPS", emptySet())).isEqualTo("user.steps") + assertThat(MeasurementType.userIdentityFrom("Blood pressure (sys)", emptySet())) + .isEqualTo("user.blood_pressure_sys") + } + + @Test + fun `a typed-in column that is not free comes back as null`() { + val taken = setOf("SCHRITTE", "SEGMENTAL_FAT_LEFT_ARM") + + assertThat(MeasurementType.userIdentityFrom("STEPS", taken)).isEqualTo("user.steps") + // Nothing is numbered away, unlike userIdentityFor: the user asked for that exact + // column, so the editor has to say no. + assertThat(MeasurementType.userIdentityFrom("Schritte", taken)).isNull() + assertThat(MeasurementType.userIdentityFrom("segmental_fat_left_arm", taken)).isNull() + assertThat(MeasurementType.userIdentityFrom("Weight", emptySet())).isNull() + assertThat(MeasurementType.userIdentityFrom("", emptySet())).isNull() + assertThat(MeasurementType.userIdentityFrom("---", emptySet())).isNull() + } + + @Test + fun `unit families - a percentage stands alone, weights convert among themselves`() { + assertThat(UnitType.PERCENT.convertibleUnits()).containsExactly(UnitType.PERCENT) + assertThat(UnitType.KG.convertibleUnits()) + .containsExactly(UnitType.KG, UnitType.LB, UnitType.ST) + assertThat(UnitType.CM.convertibleUnits()).containsExactly(UnitType.CM, UnitType.INCH) + // Closed under conversion: staying inside the family always means a real conversion. + UnitType.entries.forEach { unit -> + unit.convertibleUnits().forEach { target -> + assertThat(target.convertibleUnits()).contains(unit) + } + } + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/database/IdentityMigrationTest.kt b/android_app/app/src/test/java/com/health/openscale/core/database/IdentityMigrationTest.kt new file mode 100644 index 000000000..c3f613124 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/database/IdentityMigrationTest.kt @@ -0,0 +1,437 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.database + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * [MIGRATION_15_16] against a hand-built v15 `MeasurementType` table. + * + * v15 has neither a unique constraint nor any validation on custom type names, and + * databases that came up through MIGRATION_6_7 carry seven duplicated predefined keys. + * Backfilling identities and only then creating the unique index — with the duplicates + * merged first — is what keeps the migration from aborting and leaving the app unable to + * open its own database. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class IdentityMigrationTest { + + private lateinit var dbFile: File + private lateinit var helper: SupportSQLiteOpenHelper + private lateinit var db: SupportSQLiteDatabase + + /** The tables exactly as schemas/15.json declares the parts this migration touches. */ + private val createV15 = listOf( + """ + CREATE TABLE IF NOT EXISTS `MeasurementType` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `key` TEXT NOT NULL, `name` TEXT, `color` INTEGER NOT NULL, `icon` TEXT NOT NULL, + `unit` TEXT NOT NULL, `inputType` TEXT NOT NULL, `displayOrder` INTEGER NOT NULL, + `isDerived` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, + `isOnRightYAxis` INTEGER NOT NULL, `isInternal` INTEGER NOT NULL + ) + """.trimIndent(), + "CREATE INDEX IF NOT EXISTS `index_MeasurementType_key` ON `MeasurementType`(`key`)", + """ + CREATE TABLE IF NOT EXISTS `MeasurementValue` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `measurementId` INTEGER NOT NULL, + `typeId` INTEGER NOT NULL, `floatValue` REAL, `intValue` INTEGER, + `textValue` TEXT, `dateValue` INTEGER + ) + """.trimIndent(), + """ + CREATE TABLE IF NOT EXISTS `user_goals` ( + `userId` INTEGER NOT NULL, `measurementTypeId` INTEGER NOT NULL, + `goalValue` REAL NOT NULL, PRIMARY KEY(`userId`, `measurementTypeId`) + ) + """.trimIndent(), + ) + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + dbFile = File(context.cacheDir, "v15-${System.nanoTime()}.db") + helper = FrameworkSQLiteOpenHelperFactory().create( + SupportSQLiteOpenHelper.Configuration.builder(context) + .name(dbFile.absolutePath) + .callback(object : SupportSQLiteOpenHelper.Callback(15) { + override fun onCreate(db: SupportSQLiteDatabase) = createV15.forEach(db::execSQL) + override fun onUpgrade(db: SupportSQLiteDatabase, o: Int, n: Int) = Unit + }) + .build() + ) + db = helper.writableDatabase + } + + @After + fun tearDown() { + helper.close() + dbFile.delete() + } + + @Test + fun `predefined rows are rewritten in place to builtin identities`() { + insertType("WEIGHT") + insertType("BODY_FAT") + + MIGRATION_15_16.migrate(db) + + assertThat(identities()).containsExactly("builtin.weight", "builtin.body_fat").inOrder() + } + + @Test + fun `duplicated predefined rows are merged onto their oldest row`() { + val survivor = insertType("HEART_RATE") + insertType("HEART_RATE") + + MIGRATION_15_16.migrate(db) + + assertThat(ids()).containsExactly(survivor) + assertThat(identities()).containsExactly("builtin.heart_rate") + } + + @Test + fun `values written to the duplicate move to the survivor, none are lost`() { + val survivor = insertType("HEART_RATE") + val duplicate = insertType("HEART_RATE") + // BleConnector resolved by key and kept the last row, so measurements taken after + // the faulty 2.x upgrade hang off the duplicate. + insertValue(measurementId = 1, typeId = duplicate, value = 62f) + insertValue(measurementId = 2, typeId = duplicate, value = 71f) + insertValue(measurementId = 3, typeId = survivor, value = 58f) + + MIGRATION_15_16.migrate(db) + + assertThat(valuesFor(survivor)).containsExactly(62f, 71f, 58f) + } + + @Test + fun `a measurement holding both rows keeps the older row's value`() { + val survivor = insertType("HEART_RATE") + val duplicate = insertType("HEART_RATE") + insertValue(measurementId = 1, typeId = survivor, value = 60f) + insertValue(measurementId = 1, typeId = duplicate, value = 99f) + + MIGRATION_15_16.migrate(db) + + assertThat(valuesFor(survivor)).containsExactly(60f) + } + + @Test + fun `goals pointing at the duplicate move too`() { + val survivor = insertType("WEIGHT") + val duplicate = insertType("WEIGHT") + insertGoal(userId = 1, typeId = duplicate, value = 70f) + insertGoal(userId = 2, typeId = survivor, value = 65f) + insertGoal(userId = 2, typeId = duplicate, value = 99f) + + MIGRATION_15_16.migrate(db) + + assertThat(goalsFor(survivor)).containsExactly(1 to 70f, 2 to 65f) + } + + @Test + fun `duplicate custom names are numbered instead of aborting the migration`() { + insertType("CUSTOM", name = "Schritte") + insertType("CUSTOM", name = "Schritte") + insertType("CUSTOM", name = "schritte") + + MIGRATION_15_16.migrate(db) + + assertThat(identities()) + .containsExactly("user.schritte", "user.schritte_2", "user.schritte_3").inOrder() + } + + @Test + fun `a custom type named like a predefined one cannot take its column`() { + insertType("WEIGHT") + insertType("CUSTOM", name = "Weight") + + MIGRATION_15_16.migrate(db) + + assertThat(identities()).containsExactly("builtin.weight", "user.weight_2").inOrder() + } + + @Test + fun `the unique index exists afterwards and actually bites`() { + insertType("CUSTOM", name = "Schritte") + + MIGRATION_15_16.migrate(db) + + val duplicate = runCatching { + db.execSQL( + "INSERT INTO MeasurementType (`identity`,`name`,`color`,`icon`,`unit`,`inputType`," + + "`displayOrder`,`isDerived`,`isEnabled`,`isPinned`,`isOnRightYAxis`,`isInternal`) " + + "VALUES ('user.schritte','x',0,'IC_DEFAULT','NONE','FLOAT',0,0,1,0,0,0)" + ) + } + assertThat(duplicate.isFailure).isTrue() + } + + // --- retiring the dual-frequency BIA keys ---------------------------------- + + @Test + fun `ECW, ICW and BCM become device types without moving a single value`() { + val ecw = insertType("ECW") + val icw = insertType("ICW") + val bcm = insertType("BCM") + insertValue(measurementId = 1, typeId = ecw, value = 24.6f) + insertValue(measurementId = 2, typeId = bcm, value = 31.4f) + + MIGRATION_15_16.migrate(db) + + // Rewritten in place: ids and the values hanging off them survive untouched. + assertThat(ids()).containsExactly(ecw, icw, bcm).inOrder() + assertThat(identities()).containsExactly("ble.ecw", "ble.icw", "ble.bcm").inOrder() + assertThat(valuesFor(ecw)).containsExactly(24.6f) + assertThat(valuesFor(bcm)).containsExactly(31.4f) + } + + @Test + fun `the retired rows get a stored name, their localized key is gone`() { + insertType("ICW") + + MIGRATION_15_16.migrate(db) + + assertThat(names().single()).isEqualTo("Intracellular water") + } + + @Test + fun `retired duplicates from a 2x upgrade are merged first`() { + val survivor = insertType("ECW") + val duplicate = insertType("ECW") + insertValue(measurementId = 1, typeId = duplicate, value = 24.6f) + + MIGRATION_15_16.migrate(db) + + assertThat(ids()).containsExactly(survivor) + assertThat(identities()).containsExactly("ble.ecw") + assertThat(valuesFor(survivor)).containsExactly(24.6f) + } + + @Test + fun `the retired CSV columns stay what they always were`() { + insertType("ECW") + + MIGRATION_15_16.migrate(db) + + assertThat(com.health.openscale.core.data.MeasurementType.identityColumnKey(identities().single())) + .isEqualTo("ECW") + } + + @Test + fun `a custom type named ECW cannot claim the freshly retired ble column`() { + // The combined case the isolated tests miss: a real v15 install has the ECW row + // AND may have a same-named custom type. The retired ble.ecw column must be + // reserved before user slugs are assigned. + insertType("ECW") + insertType("CUSTOM", name = "ECW") + + MIGRATION_15_16.migrate(db) + + assertThat(identities()).containsExactly("ble.ecw", "user.ecw_2").inOrder() + } + + @Test + fun `retired and custom rows are renumbered behind the predefined block`() { + // The retired rows kept the displayOrder 14_15 gave them as builtins; left alone + // they would tie with the freshly re-stamped predefined values and make the list + // order unstable. + insertType("WEIGHT") + insertType("ECW") + insertType("CUSTOM", name = "Schritte") + + MIGRATION_15_16.migrate(db) + + val orders = mutableMapOf() + db.query("SELECT identity, displayOrder FROM MeasurementType").use { c -> + while (c.moveToNext()) orders[c.getString(0)] = c.getInt(1) + } + assertThat(orders.values.toList()).containsNoDuplicates() + val builtinMax = com.health.openscale.core.data.MeasurementType.allKeys.size + assertThat(orders["builtin.weight"]).isAtMost(builtinMax) + assertThat(orders["ble.ecw"]).isGreaterThan(builtinMax) + assertThat(orders["user.schritte"]).isGreaterThan(builtinMax) + } + + // --- helpers --------------------------------------------------------------- + + @Test + fun `a full master dataset survives the migration without losing a value`() { + // The v15 vocabulary as the 2.x upgrade chain really leaves it: all 34 predefined + // keys, of which seven exist twice (6_7 seeds everything, 13_14/14_15 insert them + // again), plus custom types including a duplicated name. + val v15Keys = listOf( + "WEIGHT", "BMI", "BODY_FAT", "WATER", "MUSCLE", "LBM", "BONE", "WAIST", "WHR", + "WHTR", "HIPS", "VISCERAL_FAT", "CHEST", "THIGH", "BICEPS", "NECK", "CALIPER_1", + "CALIPER_2", "CALIPER_3", "CALIPER", "BMR", "TDEE", "HEART_RATE", "IMPEDANCE", + "IMPEDANCE_LOW", "ECW", "ICW", "BCM", "PROTEIN", "CALORIES", "COMMENT", "DATE", + "TIME", "USER" + ) + val idByKey = v15Keys.associateWith { insertType(it) } + val duplicated = listOf("HEART_RATE", "IMPEDANCE", "IMPEDANCE_LOW", "ECW", "ICW", "PROTEIN", "BCM") + val dupIdByKey = duplicated.associateWith { insertType(it) } + val schritte = insertType("CUSTOM", "Schritte") + val schritte2 = insertType("CUSTOM", "Schritte") + val umlaut = insertType("CUSTOM", "Körperumfang") + + // One unique value per (measurement, type) so any loss or mix-up is visible. + var next = 1f + val planned = mutableMapOf>() + fun value(measurementId: Int, typeId: Int) { + insertValue(measurementId, typeId, next) + planned.getOrPut(typeId) { mutableListOf() } += next + next += 1f + } + val numericKeys = v15Keys - listOf("COMMENT", "DATE", "TIME", "USER") + numericKeys.forEach { value(1, idByKey.getValue(it)) } + // Weigh-ins taken after the 2.x upgrade landed on the duplicate rows: + duplicated.forEach { value(2, dupIdByKey.getValue(it)) } + value(3, schritte); value(3, schritte2); value(3, umlaut) + // The single intended deletion: measurement 1 already holds PROTEIN on the + // survivor, so the duplicate's copy for the same measurement is a true duplicate. + insertValue(1, dupIdByKey.getValue("PROTEIN"), 999f) + + insertGoal(1, idByKey.getValue("WEIGHT"), 75f) + insertGoal(1, dupIdByKey.getValue("ECW"), 40f) // must move to the surviving ECW row + insertGoal(2, schritte, 10_000f) + + fun count(table: String): Int { + db.query("SELECT COUNT(*) FROM $table").use { c -> c.moveToNext(); return c.getInt(0) } + } + val valuesBefore = count("MeasurementValue") + + MIGRATION_15_16.migrate(db) + + // Exactly one row gone — the same-measurement duplicate; nothing else. + assertThat(count("MeasurementValue")).isEqualTo(valuesBefore - 1) + assertThat(count("user_goals")).isEqualTo(3) + + // Merged duplicates: the survivor now holds its own and the duplicate's values. + duplicated.forEach { key -> + val expected = planned.getValue(idByKey.getValue(key)) + + planned.getValue(dupIdByKey.getValue(key)) + assertThat(valuesFor(idByKey.getValue(key))).containsExactlyElementsIn(expected) + } + // Every other row was rewritten in place — same id, same values, new identity. + (numericKeys - duplicated).forEach { key -> + assertThat(valuesFor(idByKey.getValue(key))) + .containsExactlyElementsIn(planned.getValue(idByKey.getValue(key))) + } + listOf(schritte, schritte2, umlaut).forEach { id -> + assertThat(valuesFor(id)).containsExactlyElementsIn(planned.getValue(id)) + } + + // Identities landed where the vocabulary says: ble.* for the retired trio, + // user.* for customs, builtin.* for the rest — all unique, none blank. + fun identityOf(id: Int): String { + db.query("SELECT identity FROM MeasurementType WHERE id = ?", arrayOf(id)) + .use { c -> c.moveToNext(); return c.getString(0) } + } + assertThat(identityOf(idByKey.getValue("ECW"))).isEqualTo("ble.ecw") + assertThat(identityOf(idByKey.getValue("WEIGHT"))).isEqualTo("builtin.weight") + assertThat(identityOf(schritte)).startsWith("user.") + assertThat(identityOf(schritte2)).startsWith("user.") + assertThat(identityOf(umlaut)).startsWith("user.") + val all = identities() + assertThat(all).containsNoDuplicates() + assertThat(all).doesNotContain("") + + // Goals: the duplicate ECW goal followed its values to the surviving row. + val goalTargets = mutableListOf() + db.query("SELECT measurementTypeId FROM user_goals ORDER BY userId ASC, measurementTypeId ASC") + .use { c -> while (c.moveToNext()) goalTargets += c.getInt(0) } + assertThat(goalTargets).containsExactly( + idByKey.getValue("WEIGHT"), idByKey.getValue("ECW"), schritte + ) + } + + private fun insertType(key: String, name: String? = null): Int { + db.execSQL( + "INSERT INTO MeasurementType (`key`,`name`,`color`,`icon`,`unit`,`inputType`," + + "`displayOrder`,`isDerived`,`isEnabled`,`isPinned`,`isOnRightYAxis`,`isInternal`) " + + "VALUES (?,?,0,'IC_DEFAULT','NONE','FLOAT',0,0,1,0,0,0)", + arrayOf(key, name) + ) + var id = -1 + db.query("SELECT last_insert_rowid()").use { c -> if (c.moveToNext()) id = c.getInt(0) } + return id + } + + private fun insertValue(measurementId: Int, typeId: Int, value: Float) { + db.execSQL( + "INSERT INTO MeasurementValue (`measurementId`,`typeId`,`floatValue`) VALUES (?,?,?)", + arrayOf(measurementId, typeId, value) + ) + } + + private fun insertGoal(userId: Int, typeId: Int, value: Float) { + db.execSQL( + "INSERT INTO user_goals (`userId`,`measurementTypeId`,`goalValue`) VALUES (?,?,?)", + arrayOf(userId, typeId, value) + ) + } + + private fun identities(): List = readColumn("SELECT identity FROM MeasurementType ORDER BY id ASC") + + private fun ids(): List { + val out = mutableListOf() + db.query("SELECT id FROM MeasurementType ORDER BY id ASC") + .use { c -> while (c.moveToNext()) out += c.getInt(0) } + return out + } + + private fun valuesFor(typeId: Int): List { + val out = mutableListOf() + db.query("SELECT floatValue FROM MeasurementValue WHERE typeId = ? ORDER BY id ASC", arrayOf(typeId)) + .use { c -> while (c.moveToNext()) out += c.getFloat(0) } + return out + } + + private fun goalsFor(typeId: Int): List> { + val out = mutableListOf>() + db.query("SELECT userId, goalValue FROM user_goals WHERE measurementTypeId = ? ORDER BY userId ASC", arrayOf(typeId)) + .use { c -> while (c.moveToNext()) out += c.getInt(0) to c.getFloat(1) } + return out + } + + private fun readColumn(sql: String): List { + val out = mutableListOf() + db.query(sql).use { c -> while (c.moveToNext()) out += c.getString(0) } + return out + } + + private fun names(): List { + val out = mutableListOf() + db.query("SELECT name FROM MeasurementType ORDER BY id ASC") + .use { c -> while (c.moveToNext()) out += if (c.isNull(0)) null else c.getString(0) } + return out + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/database/MeasurementDaoTest.kt b/android_app/app/src/test/java/com/health/openscale/core/database/MeasurementDaoTest.kt index 7fd737657..83d334d26 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/database/MeasurementDaoTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/database/MeasurementDaoTest.kt @@ -25,7 +25,6 @@ import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.User import kotlinx.coroutines.flow.first @@ -87,7 +86,7 @@ class MeasurementDaoTest { @Test fun deleteUser_cascadesToMeasurementsAndValues() = runBlocking { val typeId = db.measurementTypeDao().insert( - MeasurementType(key = MeasurementTypeKey.WEIGHT, inputType = InputFieldType.FLOAT) + MeasurementType(identity = MeasurementType.WEIGHT.identity, inputType = InputFieldType.FLOAT) ).toInt() val measurementId = db.measurementDao().insert( Measurement(userId = userId, timestamp = 2_000L) diff --git a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt index b62f31d50..8748d75c7 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt @@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.MeasurementType import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -79,7 +80,16 @@ class MigrationTest { assertThat(weight).isNotNull() // The rewrite seeds the default measurement types. - assertThat(repo.getAllMeasurementTypes().first()).isNotEmpty() + val types = repo.getAllMeasurementTypes().first() + assertThat(types).isNotEmpty() + + // Regression: 13_14 / 14_15 used to re-insert seven keys that 6_7 had already + // seeded (their INSERT OR IGNORE lost its conflict target when 12_13 dropped the + // unique index), leaving upgraded databases with 41 types instead of 34. + // MIGRATION_15_16 merges the duplicates and gives every row its identity. + assertThat(types).hasSize(MeasurementType.allKeys.size) + assertThat(types.map { it.identity }).containsNoDuplicates() + assertThat(types.all { it.isBuiltIn() }).isTrue() } /** diff --git a/android_app/app/src/test/java/com/health/openscale/core/database/UserGoalsDaoTest.kt b/android_app/app/src/test/java/com/health/openscale/core/database/UserGoalsDaoTest.kt index d182e6f79..25254fd5d 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/database/UserGoalsDaoTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/database/UserGoalsDaoTest.kt @@ -22,7 +22,6 @@ import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.User import com.health.openscale.core.data.UserGoals import kotlinx.coroutines.flow.first @@ -55,7 +54,7 @@ class UserGoalsDaoTest { activityLevel = ActivityLevel.MODERATE, useAssistedWeighing = false, ) ).toInt() - typeId = db.measurementTypeDao().insert(MeasurementType(key = MeasurementTypeKey.WEIGHT)).toInt() + typeId = db.measurementTypeDao().insert(MeasurementType(identity = MeasurementType.WEIGHT.identity)).toInt() } @After diff --git a/android_app/app/src/test/java/com/health/openscale/core/service/DerivedValuesCalculatorIntegrationTest.kt b/android_app/app/src/test/java/com/health/openscale/core/service/DerivedValuesCalculatorIntegrationTest.kt index ae01c5f51..aeecefa7d 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/service/DerivedValuesCalculatorIntegrationTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/service/DerivedValuesCalculatorIntegrationTest.kt @@ -23,11 +23,10 @@ import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.Measurement -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.User import com.health.openscale.core.database.AppDatabase -import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -57,10 +56,10 @@ class DerivedValuesCalculatorIntegrationTest { @Before fun setUp() = runBlocking { db = RoomTestSupport.inMemory(context) - db.measurementTypeDao().insertAll(getDefaultMeasurementTypes()) + db.measurementTypeDao().insertAll(MeasurementType.seedRows()) val types = db.measurementTypeDao().getAll().first() - weightTypeId = types.first { it.key == MeasurementTypeKey.WEIGHT }.id - bmiTypeId = types.first { it.key == MeasurementTypeKey.BMI }.id + weightTypeId = types.first { it.key == MeasurementType.WEIGHT }.id + bmiTypeId = types.first { it.key == MeasurementType.BMI }.id userId = db.userDao().insert( User( diff --git a/android_app/app/src/test/java/com/health/openscale/core/service/MeasurementEnricherTest.kt b/android_app/app/src/test/java/com/health/openscale/core/service/MeasurementEnricherTest.kt index f2fc55513..14058e73f 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/service/MeasurementEnricherTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/service/MeasurementEnricherTest.kt @@ -20,7 +20,7 @@ package com.health.openscale.core.service import android.content.Context import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.Trend import com.health.openscale.testutil.Fixtures import com.health.openscale.testutil.RoomTestSupport @@ -54,7 +54,7 @@ class MeasurementEnricherTest { @Test fun enrichWithDifferences_computesDeltaAndTrend_vsPreviousMeasurement() { - val weight = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT) + val weight = Fixtures.type(id = 1, identity = MeasurementType.WEIGHT.identity) val newest = Fixtures.mwv(2, Fixtures.ts(2025, 1, 2), listOf(Fixtures.valueWithType(weight, 72f, 2))) val older = Fixtures.mwv(1, Fixtures.ts(2025, 1, 1), listOf(Fixtures.valueWithType(weight, 70f, 1))) @@ -72,7 +72,7 @@ class MeasurementEnricherTest { @Test fun enrichWithDifferences_skipsDisabledTypes() { - val disabled = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT, enabled = false) + val disabled = Fixtures.type(id = 1, identity = MeasurementType.WEIGHT.identity, enabled = false) val mwv = Fixtures.mwv(1, Fixtures.ts(2025, 1, 1), listOf(Fixtures.valueWithType(disabled, 70f, 1))) val result = enricher().enrichWithDifferences(listOf(mwv), listOf(disabled)) @@ -82,8 +82,8 @@ class MeasurementEnricherTest { @Test fun enrichWithDifferences_sortsByDisplayOrder() { - val late = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT, displayOrder = 5) - val early = Fixtures.type(id = 2, key = MeasurementTypeKey.BODY_FAT, displayOrder = 1) + val late = Fixtures.type(id = 1, identity = MeasurementType.WEIGHT.identity, displayOrder = 5) + val early = Fixtures.type(id = 2, identity = MeasurementType.BODY_FAT.identity, displayOrder = 1) val mwv = Fixtures.mwv( 1, Fixtures.ts(2025, 1, 1), listOf(Fixtures.valueWithType(late, 70f, 1), Fixtures.valueWithType(early, 20f, 1)), diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt index 2809aad6a..f57dcd21d 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt @@ -104,7 +104,7 @@ class BackupRestoreUseCasesTest { produceFile = { File(sandboxRoot, "settings.preferences_pb") } ) val settings = SettingsFacadeImpl(dataStore) - val sync = SyncUseCases(baseContext as Application, MeasurementTypeCrudUseCases(repository)) + val sync = SyncUseCases(baseContext as Application, MeasurementTypeCrudUseCases(repository, ApplicationProvider.getApplicationContext())) useCases = BackupRestoreUseCases(sandboxContext, repository, settings, sync) repository.insertUser( diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt index 34388dcb3..f2a7eb71a 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/GenericValueJsonTest.kt @@ -20,7 +20,6 @@ package com.health.openscale.core.usecase import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.UnitType import org.junit.Test @@ -45,24 +44,24 @@ class GenericValueJsonTest { const val EPS = 1e-3f // Predefined types kept in their canonical unit (no conversion on the wire). - val weightKg = MeasurementType(id = 1, key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG) - val fatPercent = MeasurementType(id = 2, key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT) + val weightKg = MeasurementType(id = 1, identity = MeasurementType.WEIGHT.identity, unit = UnitType.KG) + val fatPercent = MeasurementType(id = 2, identity = MeasurementType.BODY_FAT.identity, unit = UnitType.PERCENT) // Predefined type in a non-canonical unit -> exercises inch<->cm conversion on the wire. - val waistInch = MeasurementType(id = 3, key = MeasurementTypeKey.WAIST, unit = UnitType.INCH) + val waistInch = MeasurementType(id = 3, identity = MeasurementType.WAIST.identity, unit = UnitType.INCH) // Custom types are matched by typeId (key == "CUSTOM") on parse. - val chestCustomCm = MeasurementType(id = 50, key = MeasurementTypeKey.CUSTOM, name = "Chest tape", unit = UnitType.CM) + val chestCustomCm = MeasurementType(id = 50, identity = "user.chest_tape", name = "Chest tape", unit = UnitType.CM) val noteCustomText = MeasurementType( - id = 60, key = MeasurementTypeKey.CUSTOM, name = "Note", unit = UnitType.NONE, inputType = InputFieldType.TEXT + id = 60, identity = "user.note", name = "Note", unit = UnitType.NONE, inputType = InputFieldType.TEXT ) // Non-FLOAT input types: each lives in a different column of MeasurementValue. val heartRateBpm = MeasurementType( - id = 4, key = MeasurementTypeKey.HEART_RATE, unit = UnitType.BPM, inputType = InputFieldType.INT + id = 4, identity = MeasurementType.HEART_RATE.identity, unit = UnitType.BPM, inputType = InputFieldType.INT ) val dateCustom = MeasurementType( - id = 61, key = MeasurementTypeKey.CUSTOM, name = "Cycle start", unit = UnitType.NONE, inputType = InputFieldType.DATE + id = 61, identity = "user.cycle_start", name = "Cycle start", unit = UnitType.NONE, inputType = InputFieldType.DATE ) val timeCustom = MeasurementType( - id = 62, key = MeasurementTypeKey.CUSTOM, name = "Bedtime", unit = UnitType.NONE, inputType = InputFieldType.TIME + id = 62, identity = "user.bedtime", name = "Bedtime", unit = UnitType.NONE, inputType = InputFieldType.TIME ) val allTypes = listOf( @@ -70,7 +69,6 @@ class GenericValueJsonTest { heartRateBpm, dateCustom, timeCustom, ) val typesById = allTypes.associateBy { it.id } - val typesByKey = allTypes.associateBy { it.key.name } } private fun value(typeId: Int, float: Float? = null, text: String? = null) = @@ -78,7 +76,7 @@ class GenericValueJsonTest { /** parse() returns the type itself; these assertions only care about which type it was. */ private fun roundTrip(values: List): Map = - GenericValueJson.parse(GenericValueJson.build(values, typesById), typesByKey, typesById) + GenericValueJson.parse(GenericValueJson.build(values, typesById), allTypes) .associate { (type, parsed) -> type.id to parsed } private fun roundTripFloats(values: List): Map = @@ -138,9 +136,9 @@ class GenericValueJsonTest { fun parse_skipsAnEntryThatCarriesNothingItsTypeCanHold() { // build() never emits "value" for a TEXT type, but a foreign producer might. There is no // column for it, so the entry is dropped rather than written to floatValue. - val json = """[{"typeId":${noteCustomText.id},"key":"CUSTOM","unit":"","value":42.0}]""" + val json = """[{"identity":"${noteCustomText.identity}","unit":"","value":42.0}]""" - val parsed = GenericValueJson.parse(json, typesByKey, typesById) + val parsed = GenericValueJson.parse(json, allTypes) assertThat(parsed).isEmpty() } @@ -153,13 +151,47 @@ class GenericValueJsonTest { listOf(value(weightKg.id, 70f), value(waistInch.id, 34f)), typesById, ) - val reducedById = typesById.filterKeys { it != waistInch.id } - val reducedByKey = typesByKey.filterKeys { it != waistInch.key.name } - - val parsed = GenericValueJson.parse(json, reducedByKey, reducedById) + val parsed = GenericValueJson.parse(json, allTypes.filter { it.id != waistInch.id }) .associate { (type, value) -> type.id to value } assertThat(parsed).containsKey(weightKg.id) assertThat(parsed).doesNotContainKey(waistInch.id) } + + @Test + fun `identity is the only wire identifier - stray legacy fields are ignored`() { + // Unknown/legacy fields in the payload must not influence matching. + val json = """[{"identity":"${weightKg.identity}","typeId":9999,"key":"CUSTOM","unit":"kg","value":70.0}]""" + + val parsed = GenericValueJson.parse(json, allTypes) + + assertThat(parsed.single().first.id).isEqualTo(weightKg.id) + } + + @Test + fun `custom types match across installations via identity, where typeId cannot`() { + // The sender's typeId means nothing here; the identity does. + val json = """[{"identity":"${chestCustomCm.identity}","typeId":12345,"key":"CUSTOM","unit":"cm","value":100.0}]""" + + val parsed = GenericValueJson.parse(json, allTypes) + + assertThat(parsed.single().first.id).isEqualTo(chestCustomCm.id) + } + + @Test + fun `entries without an identity are dropped - pre-v3 payloads are not a supported input`() { + val json = """[{"typeId":${chestCustomCm.id},"key":"CUSTOM","unit":"cm","value":100.0},""" + + """{"key":"WEIGHT","unit":"kg","value":70.0}]""" + + assertThat(GenericValueJson.parse(json, allTypes)).isEmpty() + } + + @Test + fun `outbound payloads are identity-only - API v3 emits no legacy pair`() { + val json = GenericValueJson.build(listOf(value(weightKg.id, 70f)), typesById) + + assertThat(json).contains("\"identity\":\"${weightKg.identity}\"") + assertThat(json).doesNotContain("\"key\"") + assertThat(json).doesNotContain("\"typeId\"") + } } diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt index 0892b8913..860c25c4b 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt @@ -28,14 +28,12 @@ import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -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.database.AppDatabase import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.utils.LogManager -import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -72,8 +70,8 @@ class ImportExportUseCasesTest { LogManager.init(context, false) db = RoomTestSupport.inMemory(context) repo = RoomTestSupport.repositoryFor(db) - repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) - val sync = SyncUseCases(context as Application, MeasurementTypeCrudUseCases(repo)) + repo.insertAllMeasurementTypes(MeasurementType.seedRows()) + val sync = SyncUseCases(context as Application, MeasurementTypeCrudUseCases(repo, ApplicationProvider.getApplicationContext())) useCases = ImportExportUseCases(repo, sync) userId = db.userDao().insert( @@ -88,7 +86,7 @@ class ImportExportUseCasesTest { ).toInt() val weightId = repo.getAllMeasurementTypes().first() - .first { it.key == MeasurementTypeKey.WEIGHT }.id + .first { it.key == MeasurementType.WEIGHT }.id listOf(1_000L to 70f, 2_000L to 71f).forEach { (ts, w) -> val mId = db.measurementDao().insert(Measurement(userId = userId, timestamp = ts)).toInt() db.measurementValueDao().insert( @@ -246,7 +244,7 @@ class ImportExportUseCasesTest { @Test fun import_internalImpedanceColumn_isImportedDespiteDisabledType() = runBlocking { val impedanceType = repo.getAllMeasurementTypes().first() - .first { it.key == MeasurementTypeKey.IMPEDANCE } + .first { it.key == MeasurementType.IMPEDANCE } // Impedance is a raw input: internal and disabled by default, yet still importable. assertThat(impedanceType.isInternal).isTrue() assertThat(impedanceType.isEnabled).isFalse() @@ -266,8 +264,8 @@ class ImportExportUseCasesTest { @Test fun csvRoundTrip_internalImpedance_isExportedAndReimported() = runBlocking { val types = repo.getAllMeasurementTypes().first() - val weightId = types.first { it.key == MeasurementTypeKey.WEIGHT }.id - val impedanceId = types.first { it.key == MeasurementTypeKey.IMPEDANCE }.id + val weightId = types.first { it.key == MeasurementType.WEIGHT }.id + val impedanceId = types.first { it.key == MeasurementType.IMPEDANCE }.id // Add a measurement carrying a raw impedance value. val mId = db.measurementDao().insert(Measurement(userId = userId, timestamp = 3_000L)).toInt() @@ -285,7 +283,7 @@ class ImportExportUseCasesTest { // The internal impedance column and value must be present in the CSV. val csv = file.readText() - assertThat(csv).contains(MeasurementTypeKey.IMPEDANCE.name) + assertThat(csv).contains(MeasurementType.identityColumnKey(MeasurementType.IMPEDANCE.identity)) assertThat(csv).contains("500.0") repo.deleteAllMeasurementsForUser(userId) @@ -304,7 +302,6 @@ class ImportExportUseCasesTest { fun import_customTypeMappedByName() = runBlocking { val customTypeId = db.measurementTypeDao().insert( MeasurementType( - key = MeasurementTypeKey.CUSTOM, name = "MyMetric", inputType = InputFieldType.FLOAT, unit = UnitType.NONE, @@ -323,4 +320,112 @@ class ImportExportUseCasesTest { assertThat(value).isNotNull() assertThat(value!!.value.floatValue).isWithin(1e-3f).of(3.5f) } + + @Test + fun csvRoundTrip_deviceType_headerIsIdentityDerivedAndSurvivesARename() = runBlocking { + val crud = MeasurementTypeCrudUseCases(repo, context) + val leftArm = crud.resolveOrCreate( + MeasurementType.deviceFloat( + "segmental.fat.left_arm", + com.health.openscale.R.string.measurement_type_segmental_fat_left_arm + ) + )!! + + val mId = db.measurementDao().insert(Measurement(userId = userId, timestamp = 5_000L)).toInt() + db.measurementValueDao().insert( + MeasurementValue(measurementId = mId, typeId = leftArm.id, floatValue = 12.4f) + ) + + val file = File(context.cacheDir, "seg-${System.nanoTime()}.csv") + val cr = context.contentResolver + useCases.exportUserToCsv(userId, Uri.fromFile(file), cr).getOrThrow() + + // Identity-derived, so the column is stable across renames and languages. + assertThat(file.readLines().first()).contains("SEGMENTAL_FAT_LEFT_ARM") + + crud.update(leftArm.copy(name = "Fett Arm links")).getOrThrow() + repo.deleteAllMeasurementsForUser(userId) + useCases.importUserFromCsv(userId, Uri.fromFile(file), cr).getOrThrow() + + val reimported = repo.getMeasurementsWithValuesForUser(userId).first() + .flatMap { it.values } + .firstOrNull { it.value.typeId == leftArm.id } + assertThat(reimported).isNotNull() + assertThat(reimported!!.value.floatValue).isWithin(1e-3f).of(12.4f) + } + + @Test + fun csvExport_userTypesExportUnderTheirIdentityColumn() = runBlocking { + val crud = MeasurementTypeCrudUseCases(repo, context) + val steps = crud.add(MeasurementType(name = "Schritte")).getOrThrow().toInt() + + val mId = db.measurementDao().insert(Measurement(userId = userId, timestamp = 6_000L)).toInt() + db.measurementValueDao().insert( + MeasurementValue(measurementId = mId, typeId = steps, floatValue = 8000f) + ) + + val file = File(context.cacheDir, "steps-${System.nanoTime()}.csv") + useCases.exportUserToCsv(userId, Uri.fromFile(file), context.contentResolver).getOrThrow() + + // One header style for the whole file: identity-derived, not the display name. + assertThat(file.readLines().first()).contains("SCHRITTE") + } + + @Test + fun csvImport_oldFilesWithDisplayNameHeadersStillMatch() = runBlocking { + val crud = MeasurementTypeCrudUseCases(repo, context) + val steps = crud.add(MeasurementType(name = "Schritte")).getOrThrow().toInt() + + // A file written before identity headers existed used the display name. + val report = useCases.importUserFromCsv( + userId, csvUri("DATE,Schritte\n2025-04-11,8000\n"), context.contentResolver + ).getOrThrow() + + assertThat(report.importedMeasurementsCount).isEqualTo(1) + val value = repo.getMeasurementsWithValuesForUser(userId).first() + .flatMap { it.values }.firstOrNull { it.value.typeId == steps } + assertThat(value!!.value.floatValue).isWithin(1e-3f).of(8000f) + } + + @Test + fun csvRoundTrip_twoSameNamedUserTypes_stayTwoColumns() = runBlocking { + // Duplicate names are allowed; the frozen identities keep the columns apart — + // the old exporter collapsed both into one column and lost a value. + val crud = MeasurementTypeCrudUseCases(repo, context) + val first = crud.add(MeasurementType(name = "Schritte")).getOrThrow().toInt() + val second = crud.add(MeasurementType(name = "Schritte")).getOrThrow().toInt() + + val mId = db.measurementDao().insert(Measurement(userId = userId, timestamp = 7_000L)).toInt() + db.measurementValueDao().insert(MeasurementValue(measurementId = mId, typeId = first, floatValue = 1111f)) + db.measurementValueDao().insert(MeasurementValue(measurementId = mId, typeId = second, floatValue = 2222f)) + + val file = File(context.cacheDir, "dup-${System.nanoTime()}.csv") + val cr = context.contentResolver + useCases.exportUserToCsv(userId, Uri.fromFile(file), cr).getOrThrow() + + val header = file.readLines().first() + assertThat(header).contains("SCHRITTE") + assertThat(header).contains("SCHRITTE_2") + + repo.deleteAllMeasurementsForUser(userId) + useCases.importUserFromCsv(userId, Uri.fromFile(file), cr).getOrThrow() + + val byType = repo.getMeasurementsWithValuesForUser(userId).first() + .flatMap { it.values }.associate { it.value.typeId to it.value.floatValue } + assertThat(byType[first]).isWithin(1e-3f).of(1111f) + assertThat(byType[second]).isWithin(1e-3f).of(2222f) + } + + @Test + fun csvImport_reportsColumnsItCouldNotMap() = runBlocking { + val report = useCases.importUserFromCsv( + userId, + csvUri("DATE,TIME,WEIGHT,SOMETHING_ELSE\n2025-04-07,08:30,72.5,3.5\n"), + context.contentResolver + ).getOrThrow() + + // Unmatched columns used to be dropped in complete silence. + assertThat(report.skippedColumns).containsExactly("SOMETHING_ELSE") + assertThat(report.importedMeasurementsCount).isEqualTo(1) + } } diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt index e852ff4b0..b211a6be7 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementAggregationUseCaseTest.kt @@ -20,7 +20,7 @@ package com.health.openscale.core.usecase import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.AggregationLevel import com.health.openscale.core.data.InputFieldType -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.model.EnrichedMeasurement import com.health.openscale.testutil.Fixtures import org.junit.Test @@ -31,7 +31,7 @@ import java.util.Locale class MeasurementAggregationUseCaseTest { private val useCase = MeasurementAggregationUseCase() - private val weight = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT) + private val weight = Fixtures.type(id = 1, identity = MeasurementType.WEIGHT.identity) private fun em(measurementId: Int, timestamp: Long, weightValue: Float): EnrichedMeasurement = Fixtures.enriched( @@ -93,7 +93,7 @@ class MeasurementAggregationUseCaseTest { private val heartRate = Fixtures.type( id = 2, - key = MeasurementTypeKey.HEART_RATE, + identity = MeasurementType.HEART_RATE.identity, inputType = InputFieldType.INT, ) diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementCrudUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementCrudUseCasesTest.kt index d1982bdcd..0ca88632b 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementCrudUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementCrudUseCasesTest.kt @@ -23,12 +23,11 @@ import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.Measurement -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.User import com.health.openscale.core.database.AppDatabase import com.health.openscale.core.database.DatabaseRepository -import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -65,7 +64,7 @@ class MeasurementCrudUseCasesTest { fun setUp() = runBlocking { db = RoomTestSupport.inMemory(app) repo = RoomTestSupport.repositoryFor(db) - repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + repo.insertAllMeasurementTypes(MeasurementType.seedRows()) val settings = RoomTestSupport.settingsFacadeFor( CoroutineScope(SupervisorJob() + Dispatchers.IO), File(app.cacheDir, "crud-${System.nanoTime()}.preferences_pb"), @@ -73,9 +72,9 @@ class MeasurementCrudUseCasesTest { crud = RoomTestSupport.measurementCrudFor(app, repo, settings) val types = repo.getAllMeasurementTypes().first() - weightId = types.first { it.key == MeasurementTypeKey.WEIGHT }.id - waistId = types.first { it.key == MeasurementTypeKey.WAIST }.id - neckId = types.first { it.key == MeasurementTypeKey.NECK }.id + weightId = types.first { it.key == MeasurementType.WEIGHT }.id + waistId = types.first { it.key == MeasurementType.WAIST }.id + neckId = types.first { it.key == MeasurementType.NECK }.id userId = repo.insertUser( User( @@ -150,7 +149,7 @@ class MeasurementCrudUseCasesTest { @Test fun saveMeasurement_editWeight_recalculatesDerivedWithoutChurningIds() = runBlocking { - val bmiId = repo.getAllMeasurementTypes().first().first { it.key == MeasurementTypeKey.BMI }.id + val bmiId = repo.getAllMeasurementTypes().first().first { it.key == MeasurementType.BMI }.id val id = crud.saveMeasurement( Measurement(userId = userId, timestamp = 3_000L), diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementFilterUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementFilterUseCasesTest.kt index b48df6ff7..3779fb54d 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementFilterUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementFilterUseCasesTest.kt @@ -19,7 +19,6 @@ package com.health.openscale.core.usecase import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.model.EnrichedMeasurement import com.health.openscale.testutil.Fixtures import org.junit.Test @@ -27,8 +26,8 @@ import org.junit.Test class MeasurementFilterUseCasesTest { private val useCase = MeasurementFilterUseCases() - private val weight = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT) - private val fat = Fixtures.type(id = 2, key = MeasurementTypeKey.BODY_FAT) + private val weight = Fixtures.type(id = 1, identity = MeasurementType.WEIGHT.identity) + private val fat = Fixtures.type(id = 2, identity = MeasurementType.BODY_FAT.identity) private fun emWith(measurementId: Int, vararg types: MeasurementType): EnrichedMeasurement = Fixtures.enriched( diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementInsightsUseCaseTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementInsightsUseCaseTest.kt index faf220011..572e673ea 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementInsightsUseCaseTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementInsightsUseCaseTest.kt @@ -18,7 +18,7 @@ package com.health.openscale.core.usecase import com.google.common.truth.Truth.assertThat -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.model.TrendDirection import com.health.openscale.testutil.Fixtures @@ -32,7 +32,7 @@ import org.junit.Test class MeasurementInsightsUseCaseTest { private val useCase = MeasurementInsightsUseCase() - private val weight = Fixtures.type(id = 1, key = MeasurementTypeKey.WEIGHT) + private val weight = Fixtures.type(id = 1, identity = MeasurementType.WEIGHT.identity) private fun m(day: Int, value: Float): MeasurementWithValues = Fixtures.mwv( @@ -79,4 +79,20 @@ class MeasurementInsightsUseCaseTest { assertThat(analysis.longTermTrend).isEqualTo(TrendDirection.UP) assertThat(insight.basedOnCount).isEqualTo(6) } + + @Test + fun compute_survivesNumericValuesOfTypesWithoutAPredefinedKey() { + // Regression: the body-composition pattern used to force-unwrap type.key, which is + // null for every ble.*/user.* row — crashing exactly for the users the identity + // refactor was built for. + val leftArm = Fixtures.type(id = 99, identity = "ble.segmental.fat.left_arm") + val series = risingSeries().mapIndexed { i, mwv -> + mwv.copy(values = mwv.values + Fixtures.valueWithType(leftArm, 12f + i, 50 + i)) + } + + val insight = useCase.compute(series, primaryTypeId = weight.id) + + assertThat(insight.basedOnCount).isEqualTo(series.size) + assertThat(insight.measurementAnalysis).isNotNull() + } } diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt index dd582b60f..d70b6df3c 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt @@ -22,14 +22,14 @@ import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.Measurement +import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementTypeIcon 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.database.AppDatabase import com.health.openscale.core.database.DatabaseRepository -import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -58,8 +58,8 @@ class MeasurementTypeCrudUseCasesTest { fun setUp() = runBlocking { db = RoomTestSupport.inMemory(ApplicationProvider.getApplicationContext()) repo = RoomTestSupport.repositoryFor(db) - repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) - useCase = MeasurementTypeCrudUseCases(repo) + repo.insertAllMeasurementTypes(MeasurementType.seedRows()) + useCase = MeasurementTypeCrudUseCases(repo, ApplicationProvider.getApplicationContext()) userId = repo.insertUser( User( name = "u", birthDate = 0L, gender = GenderType.MALE, heightCm = 175f, @@ -71,7 +71,7 @@ class MeasurementTypeCrudUseCasesTest { @After fun tearDown() = db.close() - private suspend fun type(key: MeasurementTypeKey): MeasurementType = + private suspend fun type(key: MeasurementType.Key<*>): MeasurementType = repo.getAllMeasurementTypes().first().first { it.key == key } private suspend fun newMeasurement(timestamp: Long): Int = @@ -82,7 +82,7 @@ class MeasurementTypeCrudUseCasesTest { @Test fun sameUnit_updatesDefinitionWithoutConverting() = runBlocking { - val waist = type(MeasurementTypeKey.WAIST) + val waist = type(MeasurementType.WAIST) val mId = newMeasurement(1_000L) repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = waist.id, floatValue = 90f)) @@ -90,12 +90,12 @@ class MeasurementTypeCrudUseCasesTest { assertThat(report.attempted).isFalse() assertThat(valueOf(waist.id)).isWithin(1e-3f).of(90f) - assertThat(type(MeasurementTypeKey.WAIST).name).isEqualTo("Bauch") + assertThat(type(MeasurementType.WAIST).name).isEqualTo("Bauch") } @Test fun genericConversion_centimeterToInch() = runBlocking { - val waist = type(MeasurementTypeKey.WAIST) // unit CM by default + val waist = type(MeasurementType.WAIST) // unit CM by default val mId = newMeasurement(1_000L) repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = waist.id, floatValue = 100f)) @@ -108,8 +108,8 @@ class MeasurementTypeCrudUseCasesTest { @Test fun compositionConversion_percentToKg_usesPerMeasurementWeight() = runBlocking { - val weight = type(MeasurementTypeKey.WEIGHT) // KG - val bodyFat = type(MeasurementTypeKey.BODY_FAT) // PERCENT + val weight = type(MeasurementType.WEIGHT) // KG + val bodyFat = type(MeasurementType.BODY_FAT) // PERCENT val mId = newMeasurement(1_000L) repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = weight.id, floatValue = 80f)) repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = bodyFat.id, floatValue = 20f)) @@ -122,7 +122,7 @@ class MeasurementTypeCrudUseCasesTest { @Test fun compositionConversion_percentToKg_skipsRowsWithoutWeight() = runBlocking { - val bodyFat = type(MeasurementTypeKey.BODY_FAT) + val bodyFat = type(MeasurementType.BODY_FAT) val mId = newMeasurement(1_000L) repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = bodyFat.id, floatValue = 20f)) @@ -132,4 +132,165 @@ class MeasurementTypeCrudUseCasesTest { assertThat(report.updatedCount).isEqualTo(0) assertThat(valueOf(bodyFat.id)).isWithin(1e-3f).of(20f) // unchanged } + + // --- device-contributed types ---------------------------------------------- + + private val leftArmFat = MeasurementType.deviceFloat( + "segmental.fat.left_arm", com.health.openscale.R.string.measurement_type_segmental_fat_left_arm + ) + + @Test + fun resolveOrCreate_createsOnceAndReusesAfterwards() = runBlocking { + val first = useCase.resolveOrCreate(leftArmFat) + val second = useCase.resolveOrCreate(leftArmFat) + + assertThat(first).isNotNull() + assertThat(second!!.id).isEqualTo(first!!.id) + assertThat(first.identity).isEqualTo("ble.segmental.fat.left_arm") + assertThat(first.isEnabled).isTrue() + assertThat(first.isPinned).isFalse() + assertThat(allTypes().count { it.identity == leftArmFat.identity }).isEqualTo(1) + } + + @Test + fun resolveOrCreate_stillFindsTheTypeAfterTheUserRenamedIt() = runBlocking { + val created = useCase.resolveOrCreate(leftArmFat)!! + + // The editor rebuilds the entity from scratch, dropping identity and isInternal. + useCase.update(created.copy(identity = "", name = "Fett Arm links")).getOrThrow() + + val resolved = useCase.resolveOrCreate(leftArmFat) + assertThat(resolved!!.id).isEqualTo(created.id) + assertThat(resolved.name).isEqualTo("Fett Arm links") + } + + @Test + fun resolveOrCreate_recreatesATypeTheUserDeleted() = runBlocking { + val created = useCase.resolveOrCreate(leftArmFat)!! + useCase.delete(created).getOrThrow() + + val recreated = useCase.resolveOrCreate(leftArmFat) + assertThat(recreated).isNotNull() + assertThat(recreated!!.id).isNotEqualTo(created.id) + } + + // --- identities on add/update ---------------------------------------------- + + @Test + fun add_assignsAUserIdentityWithoutTheEditorKnowing() = runBlocking { + val id = useCase.add(MeasurementType(name = "Schritte")).getOrThrow().toInt() + assertThat(typeById(id).identity).isEqualTo("user.schritte") + } + + @Test + fun add_numbersASecondTypeWithTheSameName() = runBlocking { + useCase.add(MeasurementType(name = "Schritte")).getOrThrow() + val second = useCase.add(MeasurementType(name = "Schritte")).getOrThrow().toInt() + assertThat(typeById(second).identity).isEqualTo("user.schritte_2") + } + + @Test + fun add_rejectsASecondRowForAPredefinedType() = runBlocking { + // The point of the unique identity index: since MIGRATION_12_13 removed the unique + // key index, nothing had stopped a second WEIGHT row from existing. + val duplicate = useCase.add(MeasurementType(identity = MeasurementType.WEIGHT.identity)) + assertThat(duplicate.isFailure).isTrue() + assertThat(allTypes().count { it.identity == MeasurementType.WEIGHT.identity }).isEqualTo(1) + } + + @Test + fun update_restoresTheIdentityAndInternalFlagTheEditorDropped() = runBlocking { + val impedance = type(MeasurementType.IMPEDANCE) + assertThat(impedance.isInternal).isTrue() + + useCase.update(impedance.copy(identity = "", isInternal = false, color = 123)).getOrThrow() + + val after = typeById(impedance.id) + assertThat(after.identity).isEqualTo(MeasurementType.IMPEDANCE.identity) + assertThat(after.isInternal).isTrue() + assertThat(after.color).isEqualTo(123) + } + + private suspend fun allTypes(): List = repo.getAllMeasurementTypes().first() + + private suspend fun typeById(id: Int): MeasurementType = allTypes().first { it.id == id } + + @Test + fun update_takesTheCsvColumnTheUserTypedIn() = runBlocking { + val id = useCase.add(MeasurementType(name = "Schritte")).getOrThrow().toInt() + + // What the editor sends when the CSV column field was edited. + useCase.update(typeById(id).copy(identity = "user.steps")).getOrThrow() + + assertThat(typeById(id).identity).isEqualTo("user.steps") + assertThat(MeasurementType.identityColumnKey(typeById(id).identity)).isEqualTo("STEPS") + } + + @Test + fun update_keepsAUserIdentityWhenOnlyTheNameChanges() = runBlocking { + val id = useCase.add(MeasurementType(name = "Schritte")).getOrThrow().toInt() + + // Frozen: a rename must not invalidate earlier exports. + useCase.update(typeById(id).copy(name = "Tagesschritte", identity = "")).getOrThrow() + + assertThat(typeById(id).identity).isEqualTo("user.schritte") + assertThat(typeById(id).name).isEqualTo("Tagesschritte") + } + + @Test + fun update_ignoresAClaimedIdentityOnTypesThatDoNotOwnOne() = runBlocking { + val fromScale = useCase.resolveOrCreate(leftArmFat)!! + val builtIn = type(MeasurementType.BMI) + + useCase.update(fromScale.copy(identity = "user.hijacked")).getOrThrow() + useCase.update(builtIn.copy(identity = "user.hijacked")).getOrThrow() + + assertThat(typeById(fromScale.id).identity).isEqualTo("ble.segmental.fat.left_arm") + assertThat(typeById(builtIn.id).identity).isEqualTo(MeasurementType.BMI.identity) + } + + // --- namespace enforcement at the creation seams ---------------------------- + + @Test + fun resolveOrCreate_materializesOnlyBleKeys() = runBlocking { + // The Key constructor cannot be sealed against the module, so a handler could mint + // a key in a foreign namespace. The creation seam is the enforcement: anything that + // is not ble.* resolves but never creates. + val minted = MeasurementType.Key( + "builtin.smuggled", com.health.openscale.R.string.measurement_type_weight, + InputFieldType.FLOAT, UnitType.PERCENT, listOf(UnitType.PERCENT), + listOf(InputFieldType.FLOAT), UnitType.PERCENT, 0, + MeasurementTypeIcon.IC_DEFAULT, false, true, false, false, false + ) + + assertThat(useCase.resolveOrCreate(minted)).isNull() + assertThat(allTypes().none { it.identity == "builtin.smuggled" }).isTrue() + } + + @Test + fun resolveOrCreate_resolvesButNeverRecreatesAPredefinedRow() = runBlocking { + // A predefined key resolves to its seeded row… + val resolved = useCase.resolveOrCreate(MeasurementType.WEIGHT) + assertThat(resolved!!.identity).isEqualTo(MeasurementType.WEIGHT.identity) + + // …and if that row were ever missing, it is not half-healed here: seeding and the + // migrations own predefined rows. + useCase.delete(resolved.copy()).getOrThrow() + assertThat(useCase.resolveOrCreate(MeasurementType.WEIGHT)).isNull() + assertThat(allTypes().none { it.identity == MeasurementType.WEIGHT.identity }).isTrue() + } + + @Test + fun add_ignoresASmuggledForeignNamespace() = runBlocking { + val bleId = useCase.add( + MeasurementType(identity = "ble.smuggled", name = "Schritte") + ).getOrThrow().toInt() + val ghostId = useCase.add( + MeasurementType(identity = "builtin.ghost", name = "Geist") + ).getOrThrow().toInt() + + // Both land in the user namespace, derived from the display name. + assertThat(typeById(bleId).identity).isEqualTo("user.schritte") + assertThat(typeById(ghostId).identity).isEqualTo("user.geist") + } } diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementValueInheritanceTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementValueInheritanceTest.kt index 2ec0a2f23..1b51f3d6d 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementValueInheritanceTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementValueInheritanceTest.kt @@ -25,13 +25,11 @@ import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType -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.database.AppDatabase import com.health.openscale.core.database.DatabaseRepository -import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -73,26 +71,26 @@ class MeasurementValueInheritanceTest { fun setUp() = runBlocking { db = RoomTestSupport.inMemory(app) repo = RoomTestSupport.repositoryFor(db) - repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + repo.insertAllMeasurementTypes(MeasurementType.seedRows()) val settings = RoomTestSupport.settingsFacadeFor( CoroutineScope(SupervisorJob() + Dispatchers.IO), File(app.cacheDir, "inheritance-${System.nanoTime()}.preferences_pb"), ) val query = MeasurementQueryUseCases(repo) - val sync = SyncUseCases(app, MeasurementTypeCrudUseCases(repo)) + val sync = SyncUseCases(app, MeasurementTypeCrudUseCases(repo, ApplicationProvider.getApplicationContext())) transformation = MeasurementTransformationUseCase( settings, UserUseCases(repo, settings, sync), query ) val types = repo.getAllMeasurementTypes().first() - weightId = types.first { it.key == MeasurementTypeKey.WEIGHT }.id - waistId = types.first { it.key == MeasurementTypeKey.WAIST }.id - hipsId = types.first { it.key == MeasurementTypeKey.HIPS }.id - heartRateId = types.first { it.key == MeasurementTypeKey.HEART_RATE }.id - commentId = types.first { it.key == MeasurementTypeKey.COMMENT }.id - bmiId = types.first { it.key == MeasurementTypeKey.BMI }.id - impedanceId = types.first { it.key == MeasurementTypeKey.IMPEDANCE }.id + weightId = types.first { it.key == MeasurementType.WEIGHT }.id + waistId = types.first { it.key == MeasurementType.WAIST }.id + hipsId = types.first { it.key == MeasurementType.HIPS }.id + heartRateId = types.first { it.key == MeasurementType.HEART_RATE }.id + commentId = types.first { it.key == MeasurementType.COMMENT }.id + bmiId = types.first { it.key == MeasurementType.BMI }.id + impedanceId = types.first { it.key == MeasurementType.IMPEDANCE }.id userId = repo.insertUser( User( @@ -214,7 +212,7 @@ class MeasurementValueInheritanceTest { ) // The insert triggers the derived recalculation, so BMI exists on the predecessor. assertThat(repo.getMeasurementsWithValuesForUser(userId).first() - .single().values.any { it.type.key == MeasurementTypeKey.BMI }).isTrue() + .single().values.any { it.type.key == MeasurementType.BMI }).isTrue() val result = transformation.applyValueInheritance( incoming(2_000L), listOf(float(weightId, 71f)) @@ -231,23 +229,23 @@ class MeasurementValueInheritanceTest { @Test fun inherits_coversNumericCustomTypesButNoOthers() = runBlocking { val customFloatId = repo.insertMeasurementType( - MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Blood pressure", + MeasurementType(identity = "user.blood_pressure", name = "Blood pressure", unit = UnitType.NONE, inputType = InputFieldType.FLOAT) ).toInt() val customIntId = repo.insertMeasurementType( - MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Steps", + MeasurementType(identity = "user.steps", name = "Steps", unit = UnitType.NONE, inputType = InputFieldType.INT) ).toInt() val customTextId = repo.insertMeasurementType( - MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Mood", + MeasurementType(identity = "user.mood", name = "Mood", unit = UnitType.NONE, inputType = InputFieldType.TEXT) ).toInt() val customDateId = repo.insertMeasurementType( - MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Last blood test", + MeasurementType(identity = "user.last_blood_test", name = "Last blood test", unit = UnitType.NONE, inputType = InputFieldType.DATE) ).toInt() val disabledCustomId = repo.insertMeasurementType( - MeasurementType(key = MeasurementTypeKey.CUSTOM, name = "Retired", + MeasurementType(identity = "user.retired", name = "Retired", unit = UnitType.NONE, inputType = InputFieldType.FLOAT, isEnabled = false) ).toInt() diff --git a/android_app/app/src/test/java/com/health/openscale/testutil/Fixtures.kt b/android_app/app/src/test/java/com/health/openscale/testutil/Fixtures.kt index 5d17514a8..de76dec6a 100644 --- a/android_app/app/src/test/java/com/health/openscale/testutil/Fixtures.kt +++ b/android_app/app/src/test/java/com/health/openscale/testutil/Fixtures.kt @@ -20,7 +20,6 @@ package com.health.openscale.testutil import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementType -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.model.EnrichedMeasurement @@ -43,13 +42,13 @@ object Fixtures { fun type( id: Int, - key: MeasurementTypeKey = MeasurementTypeKey.CUSTOM, + identity: String = "user.fixture_type_$id", inputType: InputFieldType = InputFieldType.FLOAT, displayOrder: Int = id, enabled: Boolean = true, ): MeasurementType = MeasurementType( id = id, - key = key, + identity = identity, unit = UnitType.NONE, inputType = inputType, displayOrder = displayOrder, diff --git a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt index bce056eb5..ab051d6be 100644 --- a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt +++ b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt @@ -19,6 +19,7 @@ package com.health.openscale.testutil import android.app.Application import android.content.Context +import androidx.test.core.app.ApplicationProvider import android.database.sqlite.SQLiteDatabase import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.room.Room @@ -31,6 +32,7 @@ import com.health.openscale.core.database.MIGRATION_11_12 import com.health.openscale.core.database.MIGRATION_12_13 import com.health.openscale.core.database.MIGRATION_13_14 import com.health.openscale.core.database.MIGRATION_14_15 +import com.health.openscale.core.database.MIGRATION_15_16 import com.health.openscale.core.database.MIGRATION_1_2 import com.health.openscale.core.database.MIGRATION_2_3 import com.health.openscale.core.database.MIGRATION_3_4 @@ -82,6 +84,7 @@ object RoomTestSupport { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_15_16, ) /** On-disk database at the real [AppDatabase.DATABASE_NAME] path, with all migrations applied. */ @@ -262,7 +265,7 @@ object RoomTestSupport { ) fun facadesFor(app: Application, repo: DatabaseRepository, settings: SettingsFacade): Facades { - val typeCrud = MeasurementTypeCrudUseCases(repo) + val typeCrud = MeasurementTypeCrudUseCases(repo, ApplicationProvider.getApplicationContext()) val sync = SyncUseCases(app, typeCrud) val userUseCases = UserUseCases(repo, settings, sync) val userFacade = UserFacade(userUseCases, UserGoalsUseCases(repo)) @@ -298,7 +301,7 @@ object RoomTestSupport { /** Wires the real [MeasurementCrudUseCases] graph (same constructors as production) over [repo]. */ fun measurementCrudFor(app: Application, repo: DatabaseRepository, settings: SettingsFacade): MeasurementCrudUseCases { - val sync = SyncUseCases(app, MeasurementTypeCrudUseCases(repo)) + val sync = SyncUseCases(app, MeasurementTypeCrudUseCases(repo, ApplicationProvider.getApplicationContext())) val userUseCases = UserUseCases(repo, settings, sync) val query = MeasurementQueryUseCases(repo) val transformation = MeasurementTransformationUseCase(settings, userUseCases, query) diff --git a/android_app/app/src/test/java/com/health/openscale/ui/screen/settings/SettingsViewModelTest.kt b/android_app/app/src/test/java/com/health/openscale/ui/screen/settings/SettingsViewModelTest.kt index f8e46fd8f..e1109b97b 100644 --- a/android_app/app/src/test/java/com/health/openscale/ui/screen/settings/SettingsViewModelTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/ui/screen/settings/SettingsViewModelTest.kt @@ -24,13 +24,11 @@ import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.InputFieldType 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.data.UserGoals import com.health.openscale.core.database.AppDatabase import com.health.openscale.core.database.DatabaseRepository -import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.MainDispatcherRule import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.CoroutineScope @@ -77,7 +75,7 @@ class SettingsViewModelTest { fun setUp() = runBlocking { db = RoomTestSupport.inMemory(app) repo = RoomTestSupport.repositoryFor(db) - repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + repo.insertAllMeasurementTypes(MeasurementType.seedRows()) val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val settings = RoomTestSupport.settingsFacadeFor( scope, File(app.cacheDir, "settings-${System.nanoTime()}.preferences_pb") @@ -118,7 +116,7 @@ class SettingsViewModelTest { @Test fun createUserWithGoals_persistsUserAndGoals() = runBlocking { - repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + repo.insertAllMeasurementTypes(MeasurementType.seedRows()) val typeId = repo.getAllMeasurementTypes().first().first().id vm.createUserWithGoals( @@ -136,7 +134,7 @@ class SettingsViewModelTest { @Test fun updateUserWithGoals_reconcilesGoals() = runBlocking { - repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + repo.insertAllMeasurementTypes(MeasurementType.seedRows()) val types = repo.getAllMeasurementTypes().first() val keptType = types[0].id val removedType = types[1].id @@ -172,7 +170,6 @@ class SettingsViewModelTest { vm.addMeasurementType( MeasurementType( - key = MeasurementTypeKey.CUSTOM, name = "MyMetric", inputType = InputFieldType.FLOAT, unit = UnitType.NONE, diff --git a/android_app/app/src/test/java/com/health/openscale/ui/shared/SharedViewModelTest.kt b/android_app/app/src/test/java/com/health/openscale/ui/shared/SharedViewModelTest.kt index f9dee13a1..b1aaca139 100644 --- a/android_app/app/src/test/java/com/health/openscale/ui/shared/SharedViewModelTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/ui/shared/SharedViewModelTest.kt @@ -23,14 +23,13 @@ import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.Measurement -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.User import com.health.openscale.core.database.AppDatabase import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.usecase.MeasurementTypeCrudUseCases import com.health.openscale.core.usecase.SyncUseCases -import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.MainDispatcherRule import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.CoroutineScope @@ -82,7 +81,7 @@ class SharedViewModelTest { scope, File(app.cacheDir, "shared-${System.nanoTime()}.preferences_pb") ) val facades = RoomTestSupport.facadesFor(app, repo, settings) - val sync = SyncUseCases(app, MeasurementTypeCrudUseCases(repo)) + val sync = SyncUseCases(app, MeasurementTypeCrudUseCases(repo, ApplicationProvider.getApplicationContext())) vm = SharedViewModel( facades.userFacade, facades.measurementFacade, facades.dataManagementFacade, settings, sync, ) @@ -121,10 +120,10 @@ class SharedViewModelTest { @Test fun screenFlow_emitsSuccessWithAggregatedData_forSelectedUser() { runBlocking { - repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) + repo.insertAllMeasurementTypes(MeasurementType.seedRows()) val uid = repo.insertUser(user("Alice")).toInt() val weightTypeId = repo.getAllMeasurementTypes().first() - .first { it.key == MeasurementTypeKey.WEIGHT }.id + .first { it.key == MeasurementType.WEIGHT }.id val measurementId = repo.insertMeasurement( Measurement(userId = uid, timestamp = System.currentTimeMillis()) ).toInt() From 54c06aa0e71f97c76493037f489211ea950878b0 Mon Sep 17 00:00:00 2001 From: "samie a." Date: Wed, 2 Sep 2026 11:54:32 +0500 Subject: [PATCH 61/72] fix(beurer): mask bit 31 when decoding measurement timestamps (#1504) The Beurer BF800-W sets bit 31 of the 4-byte timestamp on live CMD_MEASUREMENT frames while sending the same field with the bit cleared in CMD_SAVED_MEASUREMENT frames. Decoded unmasked, a live measurement lands roughly 68 years in the future, so measurements were saved dated 2094 (#1500). Unix timestamps do not need bit 31 until 2038, so the flag bit is safe to mask off. Adds decodeTimestampSeconds() and uses it for both the stored timestamp and the duplicate-suppression comparison. --- .../bluetooth/scales/BeurerSanitasHandler.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerSanitasHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerSanitasHandler.kt index a800abde4..83f603ae7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerSanitasHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BeurerSanitasHandler.kt @@ -641,8 +641,8 @@ class BeurerSanitasHandler : ScaleDeviceHandler() { // If we had deferred data and it matches the same timestamp/user, drop it storedMeasurement.measurementData?.let { pending -> if (ru.remoteUserId == storedMeasurement.storedUid) { - val tsA = ConverterUtils.fromUnsignedInt32Be(merged, 0) - val tsB = ConverterUtils.fromUnsignedInt32Be(pending, 0) + val tsA = decodeTimestampSeconds(merged, 0) + val tsB = decodeTimestampSeconds(pending, 0) if (tsA == tsB) { logD("Dropping deferred data (duplicate of saved data)") storedMeasurement.measurementData = null @@ -664,7 +664,7 @@ class BeurerSanitasHandler : ScaleDeviceHandler() { } private fun addMeasurement(buf: ByteArray, userId: Int) { - val timestampMs = ConverterUtils.fromUnsignedInt32Be(buf, 0) * 1000L + val timestampMs = decodeTimestampSeconds(buf, 0) * 1000L val weight = getKiloGram(buf, 4) val impedance = ConverterUtils.fromUnsignedInt16Be(buf, 6) val fat = getPercent(buf, 8) @@ -687,6 +687,19 @@ class BeurerSanitasHandler : ScaleDeviceHandler() { publish(m) } + /** + * Decode a 4-byte big-endian unix timestamp from a measurement payload. + * + * Some firmwares set bit 31 of the timestamp in live [CMD_MEASUREMENT] frames while + * sending the same field with the bit cleared in [CMD_SAVED_MEASUREMENT] frames. + * Observed on a Beurer BF800-W (issue #1500): SET_TIME wrote 0x6A9290AD and the live + * measurement 21 s later carried 0xEA9290C2 = (0x6A9290AD + 21) or 0x80000000. + * Decoded unmasked, such a measurement lands ~68 years in the future (year 2094). + * Unix timestamps do not need bit 31 until 2038, so the flag bit is safe to mask off. + */ + private fun decodeTimestampSeconds(data: ByteArray, offset: Int): Long = + ConverterUtils.fromUnsignedInt32Be(data, offset) and 0x7FFF_FFFFL + private fun getKiloGram(data: ByteArray, offset: Int): Float { // Unit is 50 g return ConverterUtils.fromUnsignedInt16Be(data, offset) * 50.0f / 1000.0f From a494f90db5bd5e1f0a1e492dee651afbd99914b4 Mon Sep 17 00:00:00 2001 From: Fhatuwani Sikhwari Date: Wed, 2 Sep 2026 09:03:05 +0200 Subject: [PATCH 62/72] Fix RyFit age and EEBBL capability status (#1498) * fix: use ScaleUser age in RyFit handler * fix: correct EEBBL capability status --- .../core/bluetooth/scales/EEBBLHandler.kt | 3 +- .../core/bluetooth/scales/RyFitHandler.kt | 17 +-- .../core/bluetooth/data/ScaleUserTest.kt | 39 ++++++ .../core/bluetooth/scales/EEBBLHandlerTest.kt | 41 ++++++ .../core/bluetooth/scales/RyFitHandlerTest.kt | 123 ++++++++++++++++++ 5 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/data/ScaleUserTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/EEBBLHandlerTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/RyFitHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt index d8dddba15..6faf8429a 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/EEBBLHandler.kt @@ -52,8 +52,7 @@ class EEBBLHandler : ScaleDeviceHandler() { DeviceCapability.LIVE_WEIGHT_STREAM, DeviceCapability.TIME_SYNC, DeviceCapability.USER_SYNC, - DeviceCapability.UNIT_CONFIG, - DeviceCapability.BODY_COMPOSITION // partial: weight reliable; full metrics need parse impl + good measurement log + DeviceCapability.UNIT_CONFIG ), linkMode = LinkMode.CONNECT_GATT ) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RyFitHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RyFitHandler.kt index c19e12b12..5edb66cf1 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RyFitHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RyFitHandler.kt @@ -67,7 +67,7 @@ class RyFitHandler : ScaleDeviceHandler() { override fun onConnected(user: ScaleUser) { heightCm = user.bodyHeight.toInt().coerceIn(50, 250) - age = user.getAge() + age = user.age.coerceIn(10, 100) gender = if (user.gender == GenderType.MALE) "男" else "女" setNotifyOn(SERVICE_UUID, CHAR_UUID) @@ -127,7 +127,7 @@ class RyFitHandler : ScaleDeviceHandler() { private fun refreshUserFromApp(): Boolean { val appUser = currentAppUser() heightCm = appUser.bodyHeight.toInt().coerceIn(50, 250) - age = appUser.getAge() + age = appUser.age.coerceIn(10, 100) gender = if (appUser.gender == GenderType.MALE) "男" else "女" return true } @@ -285,15 +285,4 @@ class RyFitHandler : ScaleDeviceHandler() { } } - // --------------------------------------------------------------- - // 辅助方法 - // --------------------------------------------------------------- - - private fun ScaleUser.getAge(): Int { - val calNow = Calendar.getInstance() - val calBirth = Calendar.getInstance().apply { time = this@getAge.birthday } - var age = calNow.get(Calendar.YEAR) - calBirth.get(Calendar.YEAR) - if (calNow.get(Calendar.DAY_OF_YEAR) < calBirth.get(Calendar.DAY_OF_YEAR)) age-- - return age.coerceIn(10, 100) - } -} \ No newline at end of file +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/data/ScaleUserTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/data/ScaleUserTest.kt new file mode 100644 index 000000000..81fcfa236 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/data/ScaleUserTest.kt @@ -0,0 +1,39 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Calendar +import java.util.Date + +class ScaleUserTest { + + @Test + fun `age handles birthdays after leap day in non-leap years`() { + val user = ScaleUser(birthday = date(2000, Calendar.MARCH, 1)) + + assertThat(user.getAge(date(2023, Calendar.MARCH, 1))).isEqualTo(23) + } + + private fun date(year: Int, month: Int, day: Int): Date = + Calendar.getInstance().apply { + clear() + set(year, month, day) + }.time +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/EEBBLHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/EEBBLHandlerTest.kt new file mode 100644 index 000000000..7682a92c8 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/EEBBLHandlerTest.kt @@ -0,0 +1,41 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.service.ScannedDeviceInfo +import org.junit.Test + +class EEBBLHandlerTest { + + @Test + fun `body composition remains declared but not implemented`() { + val support = EEBBLHandler().supportFor(device("EEBBL"))!! + + assertThat(support.capabilities).contains(DeviceCapability.BODY_COMPOSITION) + assertThat(support.implemented).doesNotContain(DeviceCapability.BODY_COMPOSITION) + } + + private fun device(name: String) = ScannedDeviceInfo( + name = name, + address = "00:11:22:33:44:55", + rssi = -50, + serviceUuids = emptyList(), + manufacturerData = null, + ) +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/RyFitHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/RyFitHandlerTest.kt new file mode 100644 index 000000000..b9764df41 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/RyFitHandlerTest.kt @@ -0,0 +1,123 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.GenderType +import kotlinx.coroutines.CoroutineScope +import org.junit.Test +import java.util.Calendar +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext + +class RyFitHandlerTest { + + @Test + fun `profile age is clamped when connected`() { + val setup = attachedHandler(appUser = userBornYearsAgo(30)) + val connectedUser = userBornYearsAgo(9) + + setup.handler.handleConnected(connectedUser) + + assertThat(setup.handler.profileAge()).isEqualTo(10) + } + + @Test + fun `c0 command uses current app user age with protocol clamp`() { + val setup = attachedHandler(appUser = userBornYearsAgo(101)) + + setup.handler.handleConnected(userBornYearsAgo(30)) + setup.transport.clearWrites() + setup.handler.handleNotification(RyFitHandler.CHAR_UUID, byteArrayOf(0xFB.toByte(), 0xF8.toByte())) + + val c0 = setup.transport.writes.single { it.payload.first() == 0xC0.toByte() }.payload + assertThat(c0[3].toInt() and 0xFF).isEqualTo(100) + } + + private fun attachedHandler(appUser: ScaleUser): Setup { + val handler = RyFitHandler() + val transport = CapturingTransport() + handler.attach( + transport = transport, + callbacks = SilentCallbacks(), + settings = InMemorySettings(), + data = FixedDataProvider(appUser), + scope = CoroutineScope(EmptyCoroutineContext), + ) + return Setup(handler, transport) + } + + private fun RyFitHandler.profileAge(): Int { + val field = RyFitHandler::class.java.getDeclaredField("age") + field.isAccessible = true + return field.getInt(this) + } + + private fun userBornYearsAgo(years: Int): ScaleUser = + ScaleUser( + birthday = Calendar.getInstance().apply { add(Calendar.YEAR, -years) }.time, + bodyHeight = 175f, + gender = GenderType.MALE, + ) + + private data class Setup( + val handler: RyFitHandler, + val transport: CapturingTransport, + ) + + private data class Write( + val service: UUID, + val characteristic: UUID, + val payload: ByteArray, + val withResponse: Boolean, + ) + + private class CapturingTransport : ScaleDeviceHandler.Transport { + val writes = mutableListOf() + + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) { + writes += Write(service, characteristic, payload.copyOf(), withResponse) + } + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID) = true + fun clearWrites() = writes.clear() + } + + private class SilentCallbacks : ScaleDeviceHandler.Callbacks { + override fun onPublish(measurement: ScaleMeasurement) = Unit + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + override fun getInt(key: String, default: Int): Int = default + override fun putInt(key: String, value: Int) = Unit + override fun getString(key: String, default: String?): String? = default + override fun putString(key: String, value: String) = Unit + override fun remove(key: String) = Unit + } + + private class FixedDataProvider(private val user: ScaleUser) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = null + } +} From 5999051b56e89113a476f05d3fef3f3761782ec5 Mon Sep 17 00:00:00 2001 From: tarouca <36767065+manueltarouca@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:12:00 +0100 Subject: [PATCH 63/72] feat: Add support for AiLink broadcast body fat scales (#1495) Adds a handler for the AiLink/eLink broadcast body-fat scale family, driven by the vendor "AiLink" app (com.pingwang.elink) and sold under many private labels. The unit this was developed against advertises as "EL1" and is retailed in Portugal as "BALANCA BODY SMART C/ APP". These scales are non-connectable: they never accept a GATT link and instead repeat the whole measurement inside an encrypted manufacturer record, so the handler runs in BROADCAST_ONLY mode. Advertisement layout (service 0xF0A0). AiLink abuses the manufacturer field: the two bytes Android parses as a company id are really the vendor's CID and VID, so ScanRecord hands the record back already stripped of them. companyId = VID << 8 | CID [0] PID [1..6] MAC, little-endian [7] checksum: sum of [8..17] & 0xFF [8..17] 10-byte payload, TEA-encrypted The payload is 32-round TEA over only the first 8 bytes, with a 128-bit key built from the device ids (k0 = pid + 0x41493000, k1 = vid + 0x4C327900, k2 = cid + 0x31783100, k3 = 0x306C6531). Decrypted it yields a rolling sequence counter, a status byte, the weight with its unit and decimal count, and the impedance. Only the status 0xFF frame is published, and only once per session: the scale streams live weight while the user settles, then latches the final frame and repeats it for as long as it stays awake. The impedance field is stale until the scale's BIA phase finishes, so it is only ever read from the completed frame, and 0 is treated as "BIA failed" -> publish weight without body composition. Verified on hardware: barefoot the scale completes with 500 ohms, while standing on it through footwear it runs the same BIA phase, detects the broken circuit and completes with status 3 and 0 ohms. Body composition itself is left to StandardImpedanceLib rather than porting the vendor's formulas. Protocol parsing lives in AiLinkLib, free of Android dependencies and covered by unit tests. Its fixtures are synthetic - built by running the protocol's own TEA encryption over invented readings - so they exercise the real cipher, key derivation and checksum without committing anyone's health data. Co-authored-by: oliexdev --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../core/bluetooth/libs/AiLinkLib.kt | 320 ++++++++++++++++++ .../scales/AiLinkBroadcastHandler.kt | 208 ++++++++++++ .../openscale/core/bluetooth/ScaleCatalog.kt | 26 ++ .../core/bluetooth/libs/AiLinkLibTest.kt | 184 ++++++++++ .../scales/AiLinkBroadcastHandlerTest.kt | 135 ++++++++ .../test/resources/scale_catalog_remarks.txt | 1 + 7 files changed, 876 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/AiLinkLib.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AiLinkBroadcastHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/AiLinkLibTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/AiLinkBroadcastHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 82ed7a68a..65116afc9 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -24,6 +24,7 @@ import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler import com.health.openscale.core.bluetooth.scales.AAAxHandler import com.health.openscale.core.bluetooth.scales.AfuB1Handler +import com.health.openscale.core.bluetooth.scales.AiLinkBroadcastHandler import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler import com.health.openscale.core.bluetooth.scales.ScaleupHandler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler @@ -155,6 +156,7 @@ class ScaleFactory @Inject constructor( SinocareHandler(), SenssunHandler(), RenphoHandler(), + AiLinkBroadcastHandler(), QNHandlerBroadcast(), QNHandler(), OneByoneHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/AiLinkLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/AiLinkLib.kt new file mode 100644 index 000000000..81775d717 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/AiLinkLib.kt @@ -0,0 +1,320 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +/** + * Decoder for the **AiLink / eLink broadcast body-fat scale** protocol + * (vendor apps `com.pingwang.elink` / "AiLink", chipsets branded Aicare, Pingwang, eLink). + * + * These scales are *non-connectable*: they never accept a GATT link and instead repeat the whole + * measurement inside the manufacturer-specific data of an `ADV_NONCONN_IND` advertisement, with + * the interesting part encrypted. + * + * ## Provenance + * The protocol described below was determined for interoperability, by observing live + * advertisements from an "EL1" scale and by studying the behaviour of the vendor's own + * implementation (`BleValueBean.init`, `MainModel.BroadCast`, `BroadcastScaleDeviceData.dataCheck` + * and the native `libAILinkBle-lib.so`). What was recovered are facts about the wire format — + * field offsets, the checksum, the cipher and its key schedule — not source text. The + * implementation here is original: TEA itself is a published algorithm, and every routine below + * was written from the documented layout rather than transcribed from any decompiler output. + * + * ## Advertisement layout + * The scale advertises service `0000F0A0` and one manufacturer record. AiLink abuses the + * manufacturer field: the two bytes Android parses as the *company id* are really the vendor's + * CID and VID, so `ScanRecord.getManufacturerSpecificData(companyId)` hands back the record + * already stripped of them. Indices below are into that stripped array: + * + * ``` + * companyId = VID << 8 | CID (0x0301 -> CID 0x01, VID 0x03) + * [0] PID + * [1..6] MAC address, little-endian (reverse for display) + * [7] checksum: sum of [8..17] & 0xFF + * [8..17] 10-byte payload, TEA-encrypted + * ``` + * + * ## Payload encryption + * `AiLinkPwdUtil.decryptBroadcast` is plain 32-round [TEA](https://en.wikipedia.org/wiki/Tiny_Encryption_Algorithm) + * over **only the first 8 bytes** — bytes [8..9] of the payload are passed through untouched, and + * this decoder keeps that quirk so frames decode the same way the scale meant them. The 128-bit key + * is built inline from the device ids rather than from `get_ailink_key`'s MD5 path + * (see [broadcastKey]). + * + * ## Decrypted payload + * ``` + * [0] rolling sequence counter + * [1] status (see STATUS_* below) + * [2] flags: bits 3..5 = weight unit, bits 1..2 = decimal places + * [3..4] weight, big-endian; bit 7 of [3] is a negative-value flag + * [5..6] impedance in ohms, big-endian + * [7] body-composition algorithm id + * [8..9] not decrypted, meaning unverified — deliberately not decoded here + * ``` + * + * Only the fields above were observed on real hardware. Body composition is intentionally *not* + * computed: the raw impedance is reported and openScale derives the rest. + */ +object AiLinkLib { + + // --- Status byte (payload[1]), named after the vendor app's own UI branches --------------- + + /** Live weight while the user is still settling; also sent when the scale is idle. */ + const val STATUS_MEASURING = 0x00 + + /** Impedance measurement running ("data analyzing" in the vendor UI). */ + const val STATUS_ANALYSING = 0x01 + + /** Impedance result available. */ + const val STATUS_IMPEDANCE_READY = 0x02 + + /** Impedance measurement failed; the vendor app zeroes the impedance in this case. */ + const val STATUS_IMPEDANCE_FAILED = 0x03 + + /** Vendor "data mode 4" frame. Branched on by the vendor app but never observed here. */ + const val STATUS_DATA_MODE = 0x04 + + /** Measurement finished — final weight and impedance. This is the frame worth recording. */ + const val STATUS_COMPLETE = 0xFF + + /** + * Every status the vendor app branches on. + * + * The advertisement's checksum covers the *still-encrypted* payload, so it proves the record + * arrived intact but not that it is AiLink at all — an all-zero record from an unrelated + * vendor checksums perfectly. Requiring the decrypted status byte to be one of these is the + * cheapest way to tell "decrypted with the right key" from "decrypted into noise". + */ + private val KNOWN_STATUSES = setOf( + STATUS_MEASURING, + STATUS_ANALYSING, + STATUS_IMPEDANCE_READY, + STATUS_IMPEDANCE_FAILED, + STATUS_DATA_MODE, + STATUS_COMPLETE, + ) + + // --- Weight unit (payload[2], bits 3..5) -------------------------------------------------- + + /** Kilograms. The only unit seen on real hardware, so the only one [Broadcast.weightKg] trusts. */ + const val UNIT_KG = 0 + + /** Chinese jin, per the vendor's `UnitUtil.weightUnitToString`. Never observed; not decoded. */ + const val UNIT_JIN = 1 + + /** Pounds, per the vendor's `UnitUtil.weightUnitToString`. Never observed; not decoded. */ + const val UNIT_LB = 6 + + // --- Frame geometry ----------------------------------------------------------------------- + + /** Manufacturer record length once Android has stripped the two company-id bytes. */ + private const val RECORD_LENGTH = 18 + private const val MAC_OFFSET = 1 + private const val MAC_LENGTH = 6 + private const val CHECKSUM_INDEX = 7 + private const val PAYLOAD_OFFSET = 8 + private const val PAYLOAD_LENGTH = 10 + + /** TEA covers only the first 8 bytes of the payload; see the class docs. */ + private const val ENCRYPTED_LENGTH = 8 + + // --- TEA parameters ----------------------------------------------------------------------- + + private const val TEA_ROUNDS = 32 + + /** TEA's golden-ratio delta, 0x9E3779B9 as a signed [Int]. */ + private val DELTA = 0x9E3779B9.toInt() + + /** Decryption starts from delta * 32, i.e. 0xC6EF3720. */ + private val SUM_INITIAL = 0xC6EF3720.toInt() + + /** + * Per-word constants the native library adds to the device ids to form the TEA key. + * Their bytes spell fragments of "AILink"/"1x1"/"1el0", which is presumably the point. + */ + private const val KEY_BASE_PID = 0x41493000 + private const val KEY_BASE_VID = 0x4C327900 + private const val KEY_BASE_CID = 0x31783100 + private const val KEY_WORD_3 = 0x306C6531 + + /** Device ids at or above this are folded back down before being mixed into the key. */ + private const val ID_FOLD_THRESHOLD = 0x10000 + private const val ID_FOLD_AMOUNT = 0xFFFF + + /** + * One decoded advertisement. + * + * @property macAddress the scale's MAC in display order, e.g. `0A:1B:2C:3D:4E:5F`. + * @property sequence rolling counter; the vendor app uses it with [status] to drop repeats. + * @property status one of the `STATUS_*` constants. + * @property weightUnit raw unit code; see the `UNIT_*` constants. + * @property decimals number of implied decimal places in [rawWeight]. + * @property isNegative the scale flagged the reading as negative (the vendor app rejects those). + * @property rawWeight weight before the decimal point is applied. + * @property weightKg [rawWeight] scaled to kilograms, or `null` when [weightUnit] is not + * [UNIT_KG] — the other unit codes have never been observed, so this + * library refuses to guess rather than record a wrong weight. + * @property impedance body impedance in ohms; 0 when the scale reports none. + * @property algorithm vendor body-composition algorithm id (informational). + */ + data class Broadcast( + val macAddress: String, + val sequence: Int, + val status: Int, + val weightUnit: Int, + val decimals: Int, + val isNegative: Boolean, + val rawWeight: Int, + val weightKg: Float?, + val impedance: Int, + val algorithm: Int, + ) { + /** True once the scale has finished weighing *and* measuring impedance. */ + val isComplete: Boolean get() = status == STATUS_COMPLETE + + /** + * The weight in kg, but only when the reading passes the vendor app's own plausibility + * gate: positive, non-zero, and in a unit we understand. `null` otherwise. + * + * Single source of truth for "is there a weight worth recording here", so callers unwrap + * once instead of testing [isUsable] and then re-checking [weightKg] for null. + */ + val usableWeightKg: Float? + get() = weightKg?.takeIf { !isNegative && rawWeight != 0 } + + /** Whether this frame carries a weight worth recording; see [usableWeightKg]. */ + val isUsable: Boolean get() = usableWeightKg != null + } + + /** + * Decodes one manufacturer record, or returns `null` if it is not a well-formed AiLink + * broadcast: too short, checksum mismatch over the encrypted payload, or a decrypted status + * outside [KNOWN_STATUSES] (which is what rejects another vendor's record that happens to + * satisfy the checksum). + * + * @param companyId the id Android keyed the record by; really `VID << 8 | CID`. + * @param data the record with the company-id bytes already stripped. + */ + fun parse(companyId: Int, data: ByteArray): Broadcast? { + if (data.size < RECORD_LENGTH) return null + + val cid = companyId and 0xFF + val vid = (companyId shr 8) and 0xFF + val pid = data[0].toInt() and 0xFF + + val encrypted = data.copyOfRange(PAYLOAD_OFFSET, PAYLOAD_OFFSET + PAYLOAD_LENGTH) + if (checksum8(encrypted) != (data[CHECKSUM_INDEX].toInt() and 0xFF)) return null + + val p = decryptBroadcast(encrypted, cid, vid, pid) + + val status = p[1].toInt() and 0xFF + if (status !in KNOWN_STATUSES) return null + + val flags = p[2].toInt() and 0xFF + val unit = (flags shr 3) and 0x07 + val decimals = (flags shr 1) and 0x03 + val rawWeight = ((p[3].toInt() and 0x7F) shl 8) or (p[4].toInt() and 0xFF) + + return Broadcast( + macAddress = macOf(data), + sequence = p[0].toInt() and 0xFF, + status = status, + weightUnit = unit, + decimals = decimals, + isNegative = (p[3].toInt() and 0x80) != 0, + rawWeight = rawWeight, + weightKg = if (unit == UNIT_KG) applyDecimals(rawWeight, decimals) else null, + impedance = ((p[5].toInt() and 0xFF) shl 8) or (p[6].toInt() and 0xFF), + algorithm = p[7].toInt() and 0xFF, + ) + } + + /** + * Builds the 128-bit TEA key the broadcast payload is encrypted with. + * + * Derived from the observed behaviour of + * `Java_com_pinwang_ailinkble_AiLinkPwdUtil_decryptBroadcast`, which builds the key inline + * from the device ids instead of going through `get_ailink_key`'s MD5 path. Ids at or above + * 0x10000 are first reduced by 0xFFFF, which is how the vendor's device *types* (65537, + * 65557, …) collapse into small numbers. + */ + fun broadcastKey(cid: Int, vid: Int, pid: Int): IntArray = intArrayOf( + fold(pid) + KEY_BASE_PID, + fold(vid) + KEY_BASE_VID, + fold(cid) + KEY_BASE_CID, + KEY_WORD_3, + ) + + /** + * TEA-decrypts the first 8 bytes of [payload] in a copy, leaving any trailing bytes as-is + * (the native implementation does the same). The two 32-bit halves are read little-endian. + */ + fun decryptBroadcast(payload: ByteArray, cid: Int, vid: Int, pid: Int): ByteArray { + val out = payload.copyOf() + if (out.size < ENCRYPTED_LENGTH) return out + + val k = broadcastKey(cid, vid, pid) + var v0 = readLe32(out, 0) + var v1 = readLe32(out, 4) + var sum = SUM_INITIAL + + repeat(TEA_ROUNDS) { + v1 -= ((v0 shl 4) + k[2]) xor (v0 + sum) xor ((v0 ushr 5) + k[3]) + v0 -= ((v1 shl 4) + k[0]) xor (v1 + sum) xor ((v1 ushr 5) + k[1]) + sum -= DELTA + } + + writeLe32(out, 0, v0) + writeLe32(out, 4, v1) + return out + } + + // --- Helpers ------------------------------------------------------------------------------ + + private fun fold(id: Int) = if (id >= ID_FOLD_THRESHOLD) id - ID_FOLD_AMOUNT else id + + private fun checksum8(bytes: ByteArray): Int = + bytes.fold(0) { acc, b -> acc + (b.toInt() and 0xFF) } and 0xFF + + /** Weight is transmitted as an integer with [decimals] implied decimal places. */ + private fun applyDecimals(raw: Int, decimals: Int): Float = when (decimals) { + 1 -> raw / 10f + 2 -> raw / 100f + 3 -> raw / 1000f + else -> raw.toFloat() + } + + /** The MAC sits little-endian in the advertisement; reverse it for display. */ + private fun macOf(data: ByteArray): String = + (MAC_OFFSET until MAC_OFFSET + MAC_LENGTH) + .map { data[it] } + .reversed() + .joinToString(":") { "%02X".format(it.toInt() and 0xFF) } + + private fun readLe32(b: ByteArray, offset: Int): Int = + (b[offset].toInt() and 0xFF) or + ((b[offset + 1].toInt() and 0xFF) shl 8) or + ((b[offset + 2].toInt() and 0xFF) shl 16) or + ((b[offset + 3].toInt() and 0xFF) shl 24) + + private fun writeLe32(b: ByteArray, offset: Int, value: Int) { + b[offset] = value.toByte() + b[offset + 1] = (value shr 8).toByte() + b[offset + 2] = (value shr 16).toByte() + b[offset + 3] = (value shr 24).toByte() + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AiLinkBroadcastHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AiLinkBroadcastHandler.kt new file mode 100644 index 000000000..12e489d29 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AiLinkBroadcastHandler.kt @@ -0,0 +1,208 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.util.SparseArray +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.AiLinkLib +import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import java.util.UUID + +/** + * AiLink / eLink **broadcast** body-fat scales. + * + * Sold under many private labels driven by the "AiLink" app (`com.pingwang.elink`); the unit this + * was developed against advertises as `EL1` and is retailed in Portugal as "BALANÇA BODY SMART + * C/ APP". These scales are non-connectable: they never accept a GATT link and instead repeat the + * complete measurement inside an encrypted manufacturer record, so this handler runs in + * [LinkMode.BROADCAST_ONLY] and all GATT hooks are deliberate no-ops. + * + * The wire format, the TEA key derivation and the payload layout are documented on [AiLinkLib], + * which does the decoding; this class only decides *which* decoded frame is worth recording. + * + * ## Which frame gets published + * The scale streams `status = 0` frames with a live weight while the user settles, then latches a + * single `status = 0xFF` frame that carries the final weight **and** the impedance, and repeats + * that frame for as long as it stays awake. Only the completed frame is published, and only once + * per session — mirroring the vendor app, which likewise ignores everything until the impedance + * measurement has finished. + * + * Body composition is derived by openScale's own [StandardImpedanceLib] from the reported + * impedance rather than by porting the vendor's formulas, consistent with the other + * impedance-reporting handlers. + * + * ## Why only the completed frame's impedance is trusted + * The impedance field is **stale until the scale's BIA phase finishes**: frames sent while the + * user is still settling repeat the *previous* measurement's value, which makes an unchanging + * impedance during `status 0` look deceptively like a hardcoded constant. It is not — verified + * on hardware by a controlled experiment: barefoot, the scale completed reporting 500 ohms; + * standing on it through footwear, the same BIA phase ran, detected the broken circuit and + * completed with `status 3` and 0 ohms. + * + * Two rules follow, both implemented in [onAdvertisement]: only ever read impedance from the + * `status 0xFF` frame, and treat 0 as "the scale's BIA pass failed" — publish the weight but no + * body composition, rather than feeding a bogus 0 into [StandardImpedanceLib]. + */ +class AiLinkBroadcastHandler : ScaleDeviceHandler() { + + companion object { + /** + * Advertised service that marks the AiLink *broadcast* profile + * (`BleConfig.UUID_SERVER_BROADCAST_AILINK` in the vendor SDK). Connectable AiLink + * devices use other services, so this is what keeps the handler to broadcast scales. + */ + private const val SERVICE_BROADCAST_SHORT = 0xF0A0 + + /** Guard against nonsense decodes; the hardware is a ~180 kg consumer scale. */ + private const val WEIGHT_MIN_KG = 1.0f + private const val WEIGHT_MAX_KG = 300.0f + } + + private val serviceBroadcast: UUID = uuid16(SERVICE_BROADCAST_SHORT) + + /** Publishing is single-shot per session; the scale repeats the final frame indefinitely. */ + private var hasPublished = false + + // --- Device identification ---------------------------------------------------------------- + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + // The advertised name ("EL1") is a meaningless vendor default that other eLink products + // share, so match on the broadcast service plus a manufacturer record that actually + // checksums as an AiLink frame. That pair is specific and, unlike the name, verifiable. + if (!device.serviceUuids.contains(serviceBroadcast)) return null + + val mfr = device.manufacturerData ?: return null + if (firstAiLinkFrame(mfr) == null) return null + + return DeviceSupport( + displayName = "AiLink Body Fat Scale", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + linkMode = LinkMode.BROADCAST_ONLY, + ) + } + + // --- GATT hooks (intentional no-ops — the device is non-connectable) ----------------------- + + override fun onConnected(user: ScaleUser) = Unit + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) = Unit + + override fun onDisconnected() { + hasPublished = false + } + + // --- Broadcast reception ------------------------------------------------------------------ + + override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction { + if (hasPublished) return BroadcastAction.IGNORED + + val mfr = result.scanRecord?.manufacturerSpecificData ?: return BroadcastAction.IGNORED + val frame = firstAiLinkFrame(mfr) ?: return BroadcastAction.IGNORED + + if (!frame.isComplete) { + // Live weight while the user settles, or an idle frame with no load at all. + logD("status=0x%02X seq=%d raw=%d — waiting for the scale to finish" + .format(frame.status, frame.sequence, frame.rawWeight)) + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + // The vendor app rejects completed frames that are negative, zero, or in a unit we have + // never observed; do the same rather than record a wrong measurement. + val weightKg = frame.usableWeightKg + if (weightKg == null) { + logW("completed frame is not usable (unit=${frame.weightUnit} raw=${frame.rawWeight} " + + "negative=${frame.isNegative}); ignoring") + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + if (weightKg < WEIGHT_MIN_KG || weightKg > WEIGHT_MAX_KG) { + logW("implausible weight ${weightKg}kg; ignoring") + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + val measurement = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + this[MeasurementType.WEIGHT] = Kg(weightKg) + } + + // The scale zeroes the impedance when its BIA pass fails (status 0x03 in the vendor app), + // so only derive body composition when it actually reported one. Height and age are + // required inputs: ScaleUser defaults bodyHeight to -1, and the equations still return + // positive, plausible-looking numbers from it. + if (frame.impedance > 0 && user.bodyHeight > 0f && user.age > 0) { + measurement[MeasurementType.IMPEDANCE] = Ohm(frame.impedance.toFloat()) + + val lib = StandardImpedanceLib( + gender = user.gender, + age = user.age, + weightKg = weightKg.toDouble(), + heightM = user.bodyHeight / 100.0, + impedance = frame.impedance.toDouble(), + ) + + measurement[MeasurementType.BODY_FAT] = Percent(lib.totalFatPercentage.toFloat()) + measurement[MeasurementType.WATER] = Percent(lib.totalBodyWaterPercentage.toFloat()) + measurement[MeasurementType.MUSCLE] = Percent(lib.skeletalMusclePercentage.toFloat()) + measurement[MeasurementType.BONE] = Kg(lib.boneMassKg.toFloat()) + measurement[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat()) + measurement[MeasurementType.LBM] = Kg(lib.fatFreeMassKg.toFloat()) + } else { + logD("scale reported no impedance; publishing weight only") + } + + logI("publishing weight=${weightKg}kg impedance=${frame.impedance} seq=${frame.sequence}") + hasPublished = true + publish(measurement) + return BroadcastAction.CONSUMED_STOP + } + + // --- Helpers ------------------------------------------------------------------------------ + + /** + * Returns the first manufacturer record in [mfr] that decodes as an AiLink frame. + * + * An advertisement may legitimately carry records from several vendors, and AiLink's + * "company id" is really its CID/VID pair rather than a registered identifier — so rather + * than trusting any single id, every record is offered to [AiLinkLib.parse] and the checksum + * decides. + */ + private fun firstAiLinkFrame(mfr: SparseArray): AiLinkLib.Broadcast? { + for (i in 0 until mfr.size()) { + val record = mfr.valueAt(i) ?: continue + AiLinkLib.parse(mfr.keyAt(i), record)?.let { return it } + } + return null + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index d3d1e6a55..807db3ebf 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.bluetooth import android.util.SparseArray import com.health.openscale.core.bluetooth.scales.AAAxHandler import com.health.openscale.core.bluetooth.scales.HealthKeep280Handler +import com.health.openscale.core.bluetooth.scales.AiLinkBroadcastHandler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler import com.health.openscale.core.bluetooth.scales.AfuB1Handler import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler @@ -151,6 +152,24 @@ object ScaleCatalog { /** Etekcity's company id; with service 0xFFD0 the only fingerprint of the nameless Fit 8S. */ private const val ETEKCITY_COMPANY_ID = 0x06D0 + /** Not a registered company id at all: AiLink packs its CID (0x01) and VID (0x03) here. */ + private const val AILINK_COMPANY_ID = 0x0301 + + /** + * An AiLink advertisement carrying a completed measurement. Its payload is encrypted and + * checksummed, so unlike most fixtures here it cannot be a zero-filled placeholder — the + * handler only claims records that actually decrypt. Synthesised with the protocol's own + * cipher over an invented reading; see `AiLinkLibTest`. + */ + private val AILINK_RECORD = byteArrayOf( + 0x01, // PID + 0x5F, 0x4E, 0x3D, 0x2C, 0x1B, 0x0A, // MAC, little-endian + 0x8F.toByte(), // checksum of the payload + 0x10, 0xE6.toByte(), 0x9C.toByte(), 0xF7.toByte(), // TEA-encrypted payload + 0xCB.toByte(), 0x45, 0x2C, 0xCC.toByte(), + 0xFF.toByte(), 0xFF.toByte(), + ) + private infix fun ScannedDeviceInfo.claimedBy(handler: Class) = Fixture(this, handler) @@ -276,6 +295,13 @@ object ScaleCatalog { // Scaleup: any manufacturer record whose company-id low byte is 0xD0 (measuring) or 0xE0. advertisement(manufacturerData = listOf(0x1DD0 to ByteArray(9))) claimedBy ScaleupHandler::class.java, + // AiLink broadcast body-fat scale: service 0xF0A0 plus an encrypted, checksummed record + // whose "company id" is really the vendor's CID/VID pair. Real capture from an "EL1". + advertisement( + name = "EL1", + services = listOf(uuid16(0xF0A0)), + manufacturerData = listOf(AILINK_COMPANY_ID to AILINK_RECORD), + ) claimedBy AiLinkBroadcastHandler::class.java, ) // --- Registry queries ------------------------------------------------------------------- diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/AiLinkLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/AiLinkLibTest.kt new file mode 100644 index 000000000..61437a1a5 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/AiLinkLibTest.kt @@ -0,0 +1,184 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [AiLinkLib]. + * + * [AiLinkLib] was reverse-engineered from an AiLink "EL1" broadcast scale (vendor app + * `com.pingwang.elink`) and verified against live advertisements from that hardware. The + * fixtures below are **synthetic**: each one was produced by running the protocol's own TEA + * encryption over a chosen payload, so they still exercise the real key derivation, the real + * cipher and the real checksum — but they carry invented weights and an invented MAC rather than + * anyone's health data. + * + * Every fixture is laid out exactly as `ScanRecord.getManufacturerSpecificData(0x0301)` returns + * it, i.e. with the two company-id bytes already stripped by Android. + */ +class AiLinkLibTest { + + /** Company id as Android reports it; really AiLink's CID (0x01) and VID (0x03). */ + private val COMPANY_ID = 0x0301 + + private fun adv(hex: String) = hex.replace(" ", "").chunked(2) + .map { it.toInt(16).toByte() }.toByteArray() + + /** status 0xFF (complete), 70.0 kg, impedance 500 — the frame the scale latches and repeats. */ + private val COMPLETED = adv("01 5f4e3d2c1b0a 8f 10e69cf7cb452cccffff") + + /** status 0xFF (complete) from an earlier step-on, 68.4 kg. */ + private val COMPLETED_684 = adv("01 5f4e3d2c1b0a 7e 945ab8103c0b750effff") + + /** status 0x00 (measuring) while a 70.0 kg reading is still live. */ + private val MEASURING = adv("01 5f4e3d2c1b0a 33 31c5f84954a1b455ffff") + + /** status 0x00 with no load: raw weight 0 and the flags' low bit clear. */ + private val IDLE = adv("01 5f4e3d2c1b0a 81 cd9244ffe6049c5bffff") + + /** Complete, but the scale's impedance pass failed and it reported 0 ohms. */ + private val NO_IMPEDANCE = adv("01 5f4e3d2c1b0a f9 95fd662927ee4283ffff") + + // --- Key derivation -------------------------------------------------------- + + @Test + fun derives_the_broadcast_tea_key_from_cid_vid_pid() { + // Recovered from Java_com_pinwang_ailinkble_AiLinkPwdUtil_decryptBroadcast: + // k0 = pid + 0x41493000, k1 = vid + 0x4C327900, k2 = cid + 0x31783100, k3 = 0x306C6531 + assertThat(AiLinkLib.broadcastKey(cid = 1, vid = 3, pid = 1).toList()) + .containsExactly(0x41493001, 0x4C327903, 0x31783101, 0x306C6531) + .inOrder() + } + + @Test + fun subtracts_0xffff_from_ids_at_or_above_0x10000() { + // The native code folds the app's 0x1xxxx device types back into a small number + // (e.g. type 65537 -> 2) before adding the constants. + assertThat(AiLinkLib.broadcastKey(cid = 65537, vid = 3, pid = 1)[2]) + .isEqualTo(0x31783100 + 2) + } + + // --- Decoding -------------------------------------------------------------- + + @Test + fun decodes_the_completed_measurement() { + val b = AiLinkLib.parse(COMPANY_ID, COMPLETED)!! + + assertThat(b.macAddress).isEqualTo("0A:1B:2C:3D:4E:5F") + assertThat(b.status).isEqualTo(AiLinkLib.STATUS_COMPLETE) + assertThat(b.sequence).isEqualTo(42) + assertThat(b.weightUnit).isEqualTo(AiLinkLib.UNIT_KG) + assertThat(b.decimals).isEqualTo(1) + assertThat(b.isNegative).isFalse() + assertThat(b.rawWeight).isEqualTo(700) + assertThat(b.weightKg).isWithin(0.001f).of(70.0f) + assertThat(b.impedance).isEqualTo(500) + assertThat(b.algorithm).isEqualTo(1) + assertThat(b.isComplete).isTrue() + assertThat(b.isUsable).isTrue() + } + + @Test + fun decodes_an_earlier_completed_measurement() { + val b = AiLinkLib.parse(COMPANY_ID, COMPLETED_684)!! + + assertThat(b.status).isEqualTo(AiLinkLib.STATUS_COMPLETE) + assertThat(b.sequence).isEqualTo(12) + assertThat(b.weightKg).isWithin(0.001f).of(68.4f) + } + + @Test + fun decodes_a_live_measuring_frame_but_does_not_call_it_complete() { + val b = AiLinkLib.parse(COMPANY_ID, MEASURING)!! + + assertThat(b.status).isEqualTo(AiLinkLib.STATUS_MEASURING) + assertThat(b.sequence).isEqualTo(26) + assertThat(b.weightKg).isWithin(0.001f).of(70.0f) + assertThat(b.isComplete).isFalse() + } + + @Test + fun treats_an_idle_frame_as_unusable() { + val b = AiLinkLib.parse(COMPANY_ID, IDLE)!! + + assertThat(b.status).isEqualTo(AiLinkLib.STATUS_MEASURING) + assertThat(b.rawWeight).isEqualTo(0) + assertThat(b.weightKg).isWithin(0.001f).of(0f) + assertThat(b.isUsable).isFalse() + } + + /** A completed frame stays usable when the impedance pass failed; only the ohms are missing. */ + @Test + fun decodes_a_completed_frame_that_reports_no_impedance() { + val b = AiLinkLib.parse(COMPANY_ID, NO_IMPEDANCE)!! + + assertThat(b.isComplete).isTrue() + assertThat(b.isUsable).isTrue() + assertThat(b.impedance).isEqualTo(0) + assertThat(b.weightKg).isWithin(0.001f).of(70.0f) + } + + /** Each frame carries a rolling counter, so consecutive readings are distinguishable. */ + @Test + fun sequence_differs_between_frames() { + val seen = listOf(COMPLETED, COMPLETED_684, MEASURING, IDLE) + .map { AiLinkLib.parse(COMPANY_ID, it)!!.sequence } + + assertThat(seen).containsNoDuplicates() + } + + // --- Rejection ------------------------------------------------------------- + + @Test + fun rejects_a_frame_whose_checksum_does_not_match() { + val corrupted = COMPLETED.copyOf().also { it[7] = (it[7] + 1).toByte() } + + assertThat(AiLinkLib.parse(COMPANY_ID, corrupted)).isNull() + } + + @Test + fun rejects_a_frame_that_is_too_short() { + assertThat(AiLinkLib.parse(COMPANY_ID, COMPLETED.copyOf(17))).isNull() + } + + /** The checksum covers the still-encrypted payload, so a flipped payload byte is caught. */ + @Test + fun rejects_a_frame_with_a_corrupted_payload() { + val corrupted = COMPLETED.copyOf().also { it[8] = (it[8] + 1).toByte() } + + assertThat(AiLinkLib.parse(COMPANY_ID, corrupted)).isNull() + } + + /** + * An all-zero record satisfies the checksum trivially (0 == 0), so the checksum alone cannot + * prove a record is AiLink. Decrypting it yields status 0x55, which is not a status the scale + * ever sends — that is what rejects another vendor's record. + */ + @Test + fun rejects_an_all_zero_record_from_another_vendor() { + assertThat(AiLinkLib.parse(0x00FF, ByteArray(18))).isNull() + } + + /** A frame decrypted with the wrong key lands on a nonsense status and must be rejected. */ + @Test + fun rejects_a_frame_decrypted_with_the_wrong_device_ids() { + assertThat(AiLinkLib.parse(0x0405, COMPLETED)).isNull() + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/AiLinkBroadcastHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/AiLinkBroadcastHandlerTest.kt new file mode 100644 index 000000000..8b8d75f9a --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/AiLinkBroadcastHandlerTest.kt @@ -0,0 +1,135 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.util.SparseArray +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.service.ScannedDeviceInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +/** + * Device-matching tests for [AiLinkBroadcastHandler]. + * + * Because the payload is encrypted, the only honest fingerprint is the F0A0 broadcast service + * plus a manufacturer record that actually decrypts — so these tests pin both halves of that + * fingerprint, and in particular that the handler stays quiet for devices it cannot decode. + * + * The fixture is synthetic: it was built by running the protocol's own TEA encryption over an + * invented 70.0 kg reading, so it is byte-for-byte valid without containing real health data. + * + * Robolectric supplies a working [SparseArray]; the decoding itself is covered by `AiLinkLibTest`. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AiLinkBroadcastHandlerTest { + + private val AILINK_SERVICE: UUID = + UUID.fromString("0000f0a0-0000-1000-8000-00805f9b34fb") + + /** Really CID 0x01 + VID 0x03, which Android reports as a company id. */ + private val COMPANY_ID = 0x0301 + + /** A completed measurement (status 0xFF, 70.0 kg, impedance 500). */ + private val RECORD = hex("01 5f4e3d2c1b0a 8f 10e69cf7cb452cccffff") + + private fun hex(s: String) = s.replace(" ", "").chunked(2) + .map { it.toInt(16).toByte() }.toByteArray() + + private fun advertisement( + services: List = listOf(AILINK_SERVICE), + manufacturerData: List> = listOf(COMPANY_ID to RECORD), + ) = ScannedDeviceInfo( + name = "EL1", + address = "0A:1B:2C:3D:4E:5F", + rssi = -85, + serviceUuids = services, + manufacturerData = SparseArray().apply { + manufacturerData.forEach { (id, data) -> put(id, data) } + }, + ) + + @Test + fun claims_a_real_ailink_broadcast_advertisement() { + assertThat(AiLinkBroadcastHandler().supportFor(advertisement())).isNotNull() + } + + @Test + fun reports_the_device_as_broadcast_only() { + val support = AiLinkBroadcastHandler().supportFor(advertisement())!! + + assertThat(support.linkMode).isEqualTo(LinkMode.BROADCAST_ONLY) + assertThat(support.implemented).contains(DeviceCapability.LIVE_WEIGHT_STREAM) + } + + /** + * The scale measures impedance for real, so body composition is derived from it. + * + * Confirmed on hardware with a controlled experiment: barefoot the scale completed reporting + * 500 ohms, while standing on it through footwear it ran the same BIA phase, detected the + * broken circuit and completed with `status 3 (impedance failed)` and 0 ohms. A device + * emitting a hardcoded constant could not distinguish those two cases. + */ + @Test + fun claims_body_composition_because_impedance_is_really_measured() { + val support = AiLinkBroadcastHandler().supportFor(advertisement())!! + + assertThat(support.implemented).contains(DeviceCapability.BODY_COMPOSITION) + } + + @Test + fun ignores_a_device_without_the_ailink_broadcast_service() { + // Same payload, but no F0A0: not an AiLink broadcast scale. + assertThat(AiLinkBroadcastHandler().supportFor(advertisement(services = emptyList()))) + .isNull() + } + + @Test + fun ignores_a_record_whose_checksum_does_not_validate() { + val corrupted = RECORD.copyOf().also { it[7] = (it[7] + 1).toByte() } + + assertThat( + AiLinkBroadcastHandler() + .supportFor(advertisement(manufacturerData = listOf(COMPANY_ID to corrupted))) + ).isNull() + } + + @Test + fun ignores_an_advertisement_with_no_manufacturer_data() { + val noMfr = ScannedDeviceInfo( + name = "EL1", + address = "0A:1B:2C:3D:4E:5F", + rssi = -85, + serviceUuids = listOf(AILINK_SERVICE), + manufacturerData = null, + ) + + assertThat(AiLinkBroadcastHandler().supportFor(noMfr)).isNull() + } + + @Test + fun ignores_an_unrelated_vendors_record_on_the_same_service() { + assertThat( + AiLinkBroadcastHandler() + .supportFor(advertisement(manufacturerData = listOf(0x00FF to ByteArray(18)))) + ).isNull() + } +} diff --git a/android_app/app/src/test/resources/scale_catalog_remarks.txt b/android_app/app/src/test/resources/scale_catalog_remarks.txt index 116f9362b..8c88a56ac 100644 --- a/android_app/app/src/test/resources/scale_catalog_remarks.txt +++ b/android_app/app/src/test/resources/scale_catalog_remarks.txt @@ -17,3 +17,4 @@ Custom openScale = See [here](Custom-Bluetooth-Scale) for a tutorial Trisa Body Analyze 4.0 = See [Trisa Body Analyze](Trisa-Body-Analyze) for the protocol ProfiCare PC-PW 3008 BT = Same Chipsea "WeChat scale" firmware as the Hoffen BBS-8107, so it is driven by the same handler HuaweiHagridWspHandler = The scale only answers after a Huawei Hagrid handshake, so CAK, C1 and C2 (32 hex characters each) have to be entered in the device settings first +AiLink Body Fat Scale = Non-connectable: the whole measurement is TEA-encrypted inside the advertisement, so no pairing is needed and the vendor "AiLink" app can be removed. Covers the AiLink/eLink broadcast family (advertises service 0xF0A0, often named "EL1") From ac5753b062112e4d74772e6b50b084cd68c59175 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Wed, 2 Sep 2026 11:51:19 +0200 Subject: [PATCH 64/72] A custom range required both ends, so a fixed starting point froze the window and new measurements dropped out of it. The end date is now optional and means "always up to today". MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve bounds in one place (TimeRangeFilter.resolveBounds); the UI and the data pipeline had drifted apart. - Snap bounds to local calendar days — the picker reports UTC midnight, and rolling ranges shifted by an hour across a DST switch. - Show the range picker as a Material 3 full-screen dialog, seeded from the stored range. - Optionally apply time range and aggregation to all screens, toggled per section. Linking only swaps the settings prefix, so unlinking restores each screen exactly. --- .../com/health/openscale/core/data/Enums.kt | 42 +++ .../core/facade/MeasurementFacade.kt | 2 +- .../openscale/core/facade/SettingsFacade.kt | 20 ++ .../components/MeasurementChartFilter.kt | 302 +++++++++++++++--- .../components/MeasurementChartSettings.kt | 67 ++-- .../openscale/ui/shared/SharedViewModel.kt | 75 ++--- .../app/src/main/res/values/strings.xml | 9 + .../core/data/TimeRangeFilterTest.kt | 172 ++++++++++ .../usecase/MeasurementFilterUseCasesTest.kt | 44 +++ 9 files changed, 612 insertions(+), 121 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/data/TimeRangeFilterTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index 0c73e73a0..9bb0bd638 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt @@ -79,6 +79,7 @@ import androidx.compose.ui.res.stringResource import com.health.openscale.R import com.health.openscale.core.utils.LocaleUtils import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -493,8 +494,49 @@ enum class TimeRangeFilter(@param:StringRes val displayNameResId: Int) { fun getDisplayName(context: Context): String { return context.getString(displayNameResId) } + + /** + * Resolves this filter into the inclusive epoch-millisecond bounds the measurement pipeline + * filters on. `null` on either side means that side is unbounded. + * + * Single source of truth for the conversion: both the data pipeline (SharedViewModel) and the + * UI (filter title, period chart) call it, so the label a chart carries cannot drift from the + * data the chart shows. + * + * [customStartMillis] and [customEndMillis] are local start-of-day millis as written by the + * date range picker, `0L` meaning "not set". A [CUSTOM] range with a start but no end stays + * open-ended and keeps picking up new measurements as they arrive - that is what makes a fixed + * starting point (the day a diet began, say) useful over time. + * + * The rolling ranges snap their start to local midnight and leave the end open, so a + * measurement dated slightly in the future stays visible. + */ + fun resolveBounds( + customStartMillis: Long = 0L, + customEndMillis: Long = 0L, + zone: ZoneId = ZoneId.systemDefault(), + today: LocalDate = LocalDate.now(zone), + ): Pair = when (this) { + ALL_DAYS -> null to null + LAST_7_DAYS -> today.minusDays(7).startOfDayMillis(zone) to null + LAST_30_DAYS -> today.minusDays(30).startOfDayMillis(zone) to null + LAST_365_DAYS -> today.minusDays(365).startOfDayMillis(zone) to null + CUSTOM -> { + val start = customStartMillis.takeIf { it > 0L } + // An unset end is a deliberate choice, not missing input: the range runs up to today + // and stays open for whatever is measured after it. + val end = customEndMillis.takeIf { it > 0L }?.let { endDayStart -> + Instant.ofEpochMilli(endDayStart).atZone(zone).toLocalDate() + .plusDays(1).startOfDayMillis(zone) - 1 + } + start to end + } + } } +private fun LocalDate.startOfDayMillis(zone: ZoneId): Long = + atStartOfDay(zone).toInstant().toEpochMilli() + enum class AggregationLevel(@param:StringRes val displayNameResId: Int) { NONE(R.string.aggregation_level_none), DAY(R.string.aggregation_level_day), diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt index 0a39df767..faedab55a 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt @@ -137,7 +137,7 @@ class MeasurementFacade @Inject constructor( * @param userId Database id of the user. * @param measurementTypesFlow Global type catalogue. * @param startTimeMillisFlow Start of the time range (inclusive), null = no bound. - * @param endTimeMillisFlow End of the time range (exclusive), null = no bound. + * @param endTimeMillisFlow End of the time range (inclusive), null = no bound. * @param typesToSmoothFlow Type ids to apply smoothing to. * @param algorithmFlow Selected smoothing algorithm. * @param alphaFlow Alpha for exponential smoothing (0..1). diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt index 5a443d0c2..bf322731a 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt @@ -132,6 +132,8 @@ object SettingsPreferenceKeys { const val TABLE_SCREEN_CONTEXT = "table_screen" const val STATISTICS_SCREEN_CONTEXT = "statistics_screen" const val INSIGHTS_SCREEN_CONTEXT = "insights_screen" + const val SHARED_FILTER_CONTEXT = "shared_screen" + const val LINK_ACROSS_SCREENS_PREFIX = "link_across_screens" } @Module @@ -225,6 +227,9 @@ interface SettingsFacade { val chartSmoothingMaxGapDays: Flow suspend fun setChartSmoothingMaxGapDays(days: Int) + fun linkedAcrossScreens(settingSuffix: String): Flow + suspend fun setLinkedAcrossScreens(settingSuffix: String, linked: Boolean) + val showChartGoalLines: Flow suspend fun setShowChartGoalLines(show: Boolean) @@ -750,6 +755,21 @@ class SettingsFacadeImpl @Inject constructor( saveSetting(SettingsPreferenceKeys.CHART_SMOOTHING_MAX_GAP_DAYS.name, days) } + override fun linkedAcrossScreens(settingSuffix: String): Flow = observeSetting( + "${SettingsPreferenceKeys.LINK_ACROSS_SCREENS_PREFIX}$settingSuffix", + // Off by default: every screen keeps its own settings until the user links them from + // the filter menu, so neither an update nor a fresh install changes anything unasked. + false + ).catch { exception -> + LogManager.e(TAG, "Error observing linkedAcrossScreens for $settingSuffix", exception) + emit(false) + } + + override suspend fun setLinkedAcrossScreens(settingSuffix: String, linked: Boolean) { + LogManager.d(TAG, "Setting $settingSuffix linked across screens to: $linked") + saveSetting("${SettingsPreferenceKeys.LINK_ACROSS_SCREENS_PREFIX}$settingSuffix", linked) + } + override val showChartGoalLines: Flow = observeSetting( SettingsPreferenceKeys.CHART_SHOW_GOAL_LINES.name, false diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt index 34bab490e..80a7b7973 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt @@ -17,25 +17,47 @@ */ package com.health.openscale.ui.screen.components +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material3.DatePickerDialog +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.DatePickerDefaults import androidx.compose.material3.DateRangePicker import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDateRangePickerState +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,10 +65,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat import com.health.openscale.R import com.health.openscale.core.data.AggregationLevel import com.health.openscale.core.data.TimeRangeFilter @@ -59,6 +90,7 @@ import kotlinx.coroutines.launch import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import java.time.temporal.TemporalAdjusters @@ -85,7 +117,13 @@ fun provideFilterTopBarAction( val context = LocalContext.current // --- Time range state --- - val targetTimeRangeKeyName = "${screenContextName}${TIME_RANGE_SUFFIX}" + val settingsContext by sharedViewModel.filterContext(screenContextName, TIME_RANGE_SUFFIX) + .collectAsState(initial = screenContextName) + val isTimeRangeLinked by sharedViewModel.linkedAcrossScreens(TIME_RANGE_SUFFIX) + .collectAsState(initial = false) + val isAggregationLinked by sharedViewModel.linkedAcrossScreens(AGGREGATION_LEVEL_SUFFIX) + .collectAsState(initial = false) + val targetTimeRangeKeyName = "${settingsContext}${TIME_RANGE_SUFFIX}" val currentPersistedTimeRangeName by sharedViewModel .observeSetting(targetTimeRangeKeyName, TimeRangeFilter.ALL_DAYS.name) .collectAsState(initial = TimeRangeFilter.ALL_DAYS.name) @@ -114,42 +152,148 @@ fun provideFilterTopBarAction( var showMenuState by rememberSaveable { mutableStateOf(false) } var showDateRangePicker by remember { mutableStateOf(false) } + // Read back so reopening the dialog shows the range that is actually in effect. + val customStartMillis by sharedViewModel + .observeSetting("${settingsContext}${CUSTOM_START_DATE_MILLIS_SUFFIX}", 0L) + .collectAsState(initial = 0L) + val customEndMillis by sharedViewModel + .observeSetting("${settingsContext}${CUSTOM_END_DATE_MILLIS_SUFFIX}", 0L) + .collectAsState(initial = 0L) + if (showDateRangePicker) { - val dateRangePickerState = rememberDateRangePickerState() - DatePickerDialog( + val dateRangePickerState = rememberDateRangePickerState( + initialSelectedStartDateMillis = customStartMillis.takeIf { it > 0L } + ?.let(::localDayStartToPickerMillis), + initialSelectedEndDateMillis = customEndMillis.takeIf { it > 0L } + ?.let(::localDayStartToPickerMillis), + ) + val selectedStart = dateRangePickerState.selectedStartDateMillis + val selectedEnd = dateRangePickerState.selectedEndDateMillis + + // Persists the picked range and switches the filter over to it. A null [endMillis] is + // stored as 0L, which resolveBounds reads as "open end". + val applyRange: (startMillis: Long, endMillis: Long?) -> Unit = { startMillis, endMillis -> + showDateRangePicker = false + scope.launch { + sharedViewModel.saveSetting( + "${settingsContext}${CUSTOM_START_DATE_MILLIS_SUFFIX}", + pickerMillisToLocalDayStart(startMillis) + ) + sharedViewModel.saveSetting( + "${settingsContext}${CUSTOM_END_DATE_MILLIS_SUFFIX}", + endMillis?.let(::pickerMillisToLocalDayStart) ?: 0L + ) + sharedViewModel.saveSetting( + targetTimeRangeKeyName, + TimeRangeFilter.CUSTOM.name + ) + } + } + + // Material 3 presents range selection as a full-screen dialog. The range picker has no + // month arrows - it is one continuously scrolling list of months - so inside a standard + // dialog only a single month is visible at a time and it reads as if it were stuck. + Dialog( onDismissRequest = { showDateRangePicker = false }, - confirmButton = { - TextButton( - onClick = { - showDateRangePicker = false - val startMillis = dateRangePickerState.selectedStartDateMillis - val endMillis = dateRangePickerState.selectedEndDateMillis - if (startMillis != null && endMillis != null) { - scope.launch { - sharedViewModel.saveSetting( - "${screenContextName}${CUSTOM_START_DATE_MILLIS_SUFFIX}", - startMillis + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + // The dialog gets its own window, so the activity's edge-to-edge setup does not reach + // it and the status bar icons would keep their default light tint on a light sheet. + // Deriving the tint from the sheet's own luminance stays right whichever way the theme + // was resolved - system, manual override or high contrast. + val containerColor = DatePickerDefaults.colors().containerColor + val view = LocalView.current + SideEffect { + (view.parent as? DialogWindowProvider)?.window?.let { window -> + WindowCompat.getInsetsController(window, view) + .isAppearanceLightStatusBars = containerColor.luminance() > 0.5f + } + } + + // One continuous sheet: the app bar takes the date picker's own container colour so + // there is no seam between the bar and the picker header below it. + Surface( + modifier = Modifier.fillMaxSize(), + color = containerColor + ) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + ) { + TopAppBar( + title = { Text(stringResource(R.string.time_range_custom_dialog_title)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ), + navigationIcon = { + IconButton(onClick = { showDateRangePicker = false }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cancel_button) ) - sharedViewModel.saveSetting( - "${screenContextName}${CUSTOM_END_DATE_MILLIS_SUFFIX}", - endMillis + } + }, + actions = { + if (selectedEnd != null) { + TextButton( + onClick = { + dateRangePickerState.setSelection(selectedStart, null) + } + ) { + Text(stringResource(R.string.time_range_custom_clear_end)) + } + } + TextButton( + onClick = { + selectedStart?.let { applyRange(it, selectedEnd) } + }, + // A start on its own is a complete choice: "from this day onwards". + enabled = selectedStart != null + ) { Text(stringResource(R.string.dialog_ok)) } + } + ) + + DateRangePicker( + state = dateRangePickerState, + modifier = Modifier.weight(1f), + // The title slot sits above the headline and carries the one thing the + // picker itself cannot express: what an empty end date means, and how to + // get back to it once a date has been chosen. + title = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(18.dp) ) - sharedViewModel.saveSetting( - targetTimeRangeKeyName, - TimeRangeFilter.CUSTOM.name + Spacer(Modifier.width(10.dp)) + Text( + text = stringResource( + if (selectedEnd == null) R.string.time_range_custom_hint_open_end + else R.string.time_range_custom_hint_fixed_end + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer ) } } - }, - enabled = dateRangePickerState.selectedEndDateMillis != null - ) { Text(stringResource(R.string.dialog_ok)) } - }, - dismissButton = { - TextButton(onClick = { showDateRangePicker = false }) { - Text(stringResource(R.string.cancel_button)) + ) } } - ) { DateRangePicker(state = dateRangePickerState) } + } } return TopBarAction( @@ -160,11 +304,14 @@ fun provideFilterTopBarAction( DropdownMenu(expanded = showMenuState, onDismissRequest = { showMenuState = false }) { // --- Section 1: Time range --- - Text( - text = stringResource(R.string.filter_section_time_range), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + FilterSectionHeader( + title = stringResource(R.string.filter_section_time_range), + linked = isTimeRangeLinked, + linkedDescription = stringResource(R.string.content_description_time_range_linked), + unlinkedDescription = stringResource(R.string.content_description_time_range_unlinked), + onLinkedChange = { linked -> + scope.launch { sharedViewModel.setLinkedAcrossScreens(TIME_RANGE_SUFFIX, linked) } + } ) TimeRangeFilter.entries.forEach { timeRange -> DropdownMenuItem( @@ -198,11 +345,14 @@ fun provideFilterTopBarAction( // --- Section 2: Aggregation (only for Graph, Table, Statistics) --- if (showAggregation) { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - Text( - text = stringResource(R.string.filter_section_aggregation), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + FilterSectionHeader( + title = stringResource(R.string.filter_section_aggregation), + linked = isAggregationLinked, + linkedDescription = stringResource(R.string.content_description_aggregation_linked), + unlinkedDescription = stringResource(R.string.content_description_aggregation_unlinked), + onLinkedChange = { linked -> + scope.launch { sharedViewModel.setLinkedAcrossScreens(AGGREGATION_LEVEL_SUFFIX, linked) } + } ) AggregationLevel.entries.forEach { level -> DropdownMenuItem( @@ -263,6 +413,53 @@ fun provideFilterTopBarAction( } } +/** + * A section heading in the filter menu, carrying the toggle that decides whether the choices + * below it apply to this screen alone or to every screen. + * + * The toggle belongs on the heading rather than in the list: it sets the scope of the entries, + * it is not one more entry to pick. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FilterSectionHeader( + title: String, + linked: Boolean, + linkedDescription: String, + unlinkedDescription: String, + onLinkedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + val description = if (linked) linkedDescription else unlinkedDescription + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(description) } }, + state = rememberTooltipState() + ) { + IconToggleButton(checked = linked, onCheckedChange = onLinkedChange) { + Icon( + imageVector = if (linked) Icons.Default.Link else Icons.Default.LinkOff, + contentDescription = description, + tint = if (linked) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + /** * Remembers and calculates the data needed for the [PeriodChart]. * Groups measurements by a dynamic time unit (day, week, month, year) @@ -366,4 +563,29 @@ internal fun rememberPeriodChartData( ) } } -} \ No newline at end of file +} + +/** + * Converts a [DateRangePicker] selection into the start of that calendar day in the local zone. + * + * The picker reports UTC midnight of the day the user tapped. Stored unchanged, that is 22:00 of + * the previous day at UTC+2 - so an evening measurement from the day before the chosen start would + * be swept into the range. Interpreting the value as a bare calendar date and re-anchoring it + * locally keeps the boundary on the day the user actually picked. + */ +private fun pickerMillisToLocalDayStart(pickerMillis: Long): Long = + Instant.ofEpochMilli(pickerMillis) + .atZone(ZoneOffset.UTC) + .toLocalDate() + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + +/** Inverse of [pickerMillisToLocalDayStart], for seeding the picker from a stored range. */ +private fun localDayStartToPickerMillis(localMillis: Long): Long = + Instant.ofEpochMilli(localMillis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartSettings.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartSettings.kt index f382c793e..f51bc2d1d 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartSettings.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartSettings.kt @@ -52,7 +52,9 @@ internal fun rememberResolvedTimeRangeState( sharedViewModel: SharedViewModel, defaultFilter: TimeRangeFilter = TimeRangeFilter.ALL_DAYS ): State> { - val timeRangeKey = remember(screenContextName) { "${screenContextName}${TIME_RANGE_SUFFIX}" } + val context by sharedViewModel.filterContext(screenContextName, TIME_RANGE_SUFFIX) + .collectAsState(initial = screenContextName) + val timeRangeKey = remember(context) { "${context}${TIME_RANGE_SUFFIX}" } val persistedTimeRangeName by sharedViewModel .observeSetting(timeRangeKey, defaultFilter.name) .collectAsState(initial = defaultFilter.name) @@ -61,40 +63,15 @@ internal fun rememberResolvedTimeRangeState( TimeRangeFilter.entries.find { it.name == persistedTimeRangeName } ?: defaultFilter } - val customStartKey = remember(screenContextName) { "${screenContextName}${CUSTOM_START_DATE_MILLIS_SUFFIX}" } - val customStartMillis by sharedViewModel.observeSetting(customStartKey, -1L).collectAsState(initial = -1L) + val customStartKey = remember(context) { "${context}${CUSTOM_START_DATE_MILLIS_SUFFIX}" } + val customStartMillis by sharedViewModel.observeSetting(customStartKey, 0L).collectAsState(initial = 0L) - val customEndKey = remember(screenContextName) { "${screenContextName}${CUSTOM_END_DATE_MILLIS_SUFFIX}" } - val customEndMillis by sharedViewModel.observeSetting(customEndKey, -1L).collectAsState(initial = -1L) + val customEndKey = remember(context) { "${context}${CUSTOM_END_DATE_MILLIS_SUFFIX}" } + val customEndMillis by sharedViewModel.observeSetting(customEndKey, 0L).collectAsState(initial = 0L) return remember(activeTimeRange, customStartMillis, customEndMillis) { - mutableStateOf( - run { - val (start, end) = when (activeTimeRange) { - TimeRangeFilter.ALL_DAYS -> null to null - TimeRangeFilter.CUSTOM -> { - val customStart = if (customStartMillis != -1L) customStartMillis else null - val customEnd = if (customEndMillis != -1L) customEndMillis else null - customStart to customEnd - } - else -> { - val cal = java.util.Calendar.getInstance() - val endTime = cal.timeInMillis - when (activeTimeRange) { - TimeRangeFilter.LAST_7_DAYS -> cal.add(java.util.Calendar.DAY_OF_YEAR, -7) - TimeRangeFilter.LAST_30_DAYS -> cal.add(java.util.Calendar.DAY_OF_YEAR, -30) - TimeRangeFilter.LAST_365_DAYS -> cal.add(java.util.Calendar.DAY_OF_YEAR, -365) - } - cal.set(java.util.Calendar.HOUR_OF_DAY, 0) - cal.set(java.util.Calendar.MINUTE, 0) - cal.set(java.util.Calendar.SECOND, 0) - cal.set(java.util.Calendar.MILLISECOND, 0) - cal.timeInMillis to endTime - } - } - Triple(activeTimeRange, start, end) - } - ) + val (start, end) = activeTimeRange.resolveBounds(customStartMillis, customEndMillis) + mutableStateOf(Triple(activeTimeRange, start, end)) } } @@ -104,7 +81,9 @@ internal fun rememberResolvedAggregationLevel( sharedViewModel: SharedViewModel, defaultLevel: AggregationLevel = AggregationLevel.NONE ): State { - val key = remember(screenContextName) { "${screenContextName}${AGGREGATION_LEVEL_SUFFIX}" } + val context by sharedViewModel.filterContext(screenContextName, AGGREGATION_LEVEL_SUFFIX) + .collectAsState(initial = screenContextName) + val key = remember(context) { "${context}${AGGREGATION_LEVEL_SUFFIX}" } val persisted by sharedViewModel .observeSetting(key, defaultLevel.name) .collectAsState(initial = defaultLevel.name) @@ -129,12 +108,22 @@ internal fun rememberFilterTitle( val dateFormat = remember { DateFormat.getDateFormat(context) } return when { - activeFilter == TimeRangeFilter.CUSTOM && startTimeMillis != null && endTimeMillis != null -> { - val startDate = dateFormat.format(Date(startTimeMillis)) - val endDate = dateFormat.format(Date(endTimeMillis)) - stringResource(R.string.time_range_custom_from_to, startDate, endDate) - } - else -> activeFilter.getDisplayName(context) + activeFilter != TimeRangeFilter.CUSTOM || startTimeMillis == null -> + activeFilter.getDisplayName(context) + + // An open end is the point of a fixed starting date, so name it rather than falling back + // to the generic "Custom Days". + endTimeMillis == null -> + stringResource( + R.string.time_range_custom_since, + dateFormat.format(Date(startTimeMillis)) + ) + + else -> stringResource( + R.string.time_range_custom_from_to, + dateFormat.format(Date(startTimeMillis)), + dateFormat.format(Date(endTimeMillis)) + ) } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt index c26779060..9736f9577 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt @@ -36,7 +36,6 @@ import com.health.openscale.core.data.UserGoals import com.health.openscale.core.facade.DataManagementFacade import com.health.openscale.core.facade.MeasurementFacade import com.health.openscale.core.facade.SettingsFacade -import com.health.openscale.core.facade.SettingsPreferenceKeys import com.health.openscale.core.facade.UserFacade import com.health.openscale.core.model.AggregatedMeasurement import com.health.openscale.core.model.MeasurementInsight @@ -45,6 +44,7 @@ import com.health.openscale.core.model.UserEvaluationContext import com.health.openscale.core.usecase.MeasurementDemoUseCase import com.health.openscale.core.usecase.SyncUseCases import com.health.openscale.core.utils.LogManager +import com.health.openscale.core.facade.SettingsPreferenceKeys import com.health.openscale.ui.screen.components.AGGREGATION_LEVEL_SUFFIX import com.health.openscale.ui.screen.components.CUSTOM_END_DATE_MILLIS_SUFFIX import com.health.openscale.ui.screen.components.CUSTOM_START_DATE_MILLIS_SUFFIX @@ -303,17 +303,32 @@ class SharedViewModel @Inject constructor( // Aggregation level (persisted per screen context) // ------------------------------------------------------------------------- - fun observeAggregationLevel(screenContextName: String): Flow { - val key = "${screenContextName}${AGGREGATION_LEVEL_SUFFIX}" - return observeSetting(key, AggregationLevel.NONE.name) + /** + * Where the setting named by [settingSuffix] is stored for [screenContextName]: the screen's + * own context, or one shared context while that setting is linked across screens. + * + * The per-screen entries stay in DataStore untouched while linked, so unlinking hands each + * screen back exactly what it had before - no migration, no lost settings. Time range and + * aggregation are linked independently, so sharing one starting date across the app while the + * table still groups by month is possible. + */ + fun filterContext(screenContextName: String, settingSuffix: String): Flow = + linkedAcrossScreens(settingSuffix).map { linked -> + if (linked) SettingsPreferenceKeys.SHARED_FILTER_CONTEXT else screenContextName + } + + fun observeAggregationLevel(screenContextName: String): Flow = + filterContext(screenContextName, AGGREGATION_LEVEL_SUFFIX) + .flatMapLatest { context -> + observeSetting("${context}${AGGREGATION_LEVEL_SUFFIX}", AggregationLevel.NONE.name) + } .map { name -> AggregationLevel.entries.find { it.name == name } ?: AggregationLevel.NONE } - } suspend fun saveAggregationLevel(screenContextName: String, level: AggregationLevel) { - val key = "${screenContextName}${AGGREGATION_LEVEL_SUFFIX}" - saveSetting(key, level.name) + val context = filterContext(screenContextName, AGGREGATION_LEVEL_SUFFIX).first() + saveSetting("${context}${AGGREGATION_LEVEL_SUFFIX}", level.name) } // ------------------------------------------------------------------------- @@ -359,15 +374,18 @@ class SharedViewModel @Inject constructor( useSmoothing: Boolean, ): StateFlow>> { // Combine the three persisted settings into a single resolved time-range pair - val timeRangeFlow: Flow> = combine( - observeSetting("${screenContextName}${TIME_RANGE_SUFFIX}", TimeRangeFilter.ALL_DAYS.name), - observeSetting("${screenContextName}${CUSTOM_START_DATE_MILLIS_SUFFIX}", 0L), - observeSetting("${screenContextName}${CUSTOM_END_DATE_MILLIS_SUFFIX}", 0L), - ) { rangeName, customStart, customEnd -> - val range = TimeRangeFilter.entries.find { it.name == rangeName } - ?: TimeRangeFilter.ALL_DAYS - resolveTimeRange(range, customStart, customEnd) - } + val timeRangeFlow: Flow> = filterContext(screenContextName, TIME_RANGE_SUFFIX) + .flatMapLatest { context -> + combine( + observeSetting("${context}${TIME_RANGE_SUFFIX}", TimeRangeFilter.ALL_DAYS.name), + observeSetting("${context}${CUSTOM_START_DATE_MILLIS_SUFFIX}", 0L), + observeSetting("${context}${CUSTOM_END_DATE_MILLIS_SUFFIX}", 0L), + ) { rangeName, customStart, customEnd -> + val range = TimeRangeFilter.entries.find { it.name == rangeName } + ?: TimeRangeFilter.ALL_DAYS + range.resolveBounds(customStart, customEnd) + } + } val aggregationFlow: Flow = observeAggregationLevel(screenContextName) @@ -503,31 +521,6 @@ class SharedViewModel @Inject constructor( } } - /** - * Resolves a [TimeRangeFilter] into concrete start/end epoch-millisecond bounds. - * Returns `null` for an open bound (no filter applied on that side). - * - * Previously this logic was spread across rememberResolvedTimeRangeState in - * multiple Composables. Keeping it here makes it testable without Android instrumentation. - */ - private fun resolveTimeRange( - filter: TimeRangeFilter, - customStartMillis: Long, - customEndMillis: Long, - ): Pair { - val now = System.currentTimeMillis() - return when (filter) { - TimeRangeFilter.ALL_DAYS -> null to null - TimeRangeFilter.LAST_7_DAYS -> (now - 7L * 86_400_000) to null - TimeRangeFilter.LAST_30_DAYS -> (now - 30L * 86_400_000) to null - TimeRangeFilter.LAST_365_DAYS -> (now - 365L * 86_400_000) to null - TimeRangeFilter.CUSTOM -> - if (customStartMillis > 0L && customEndMillis > 0L) - customStartMillis to customEndMillis - else - null to null - } - } // ------------------------------------------------------------------------- // Drill-down flow — raw, non-aggregated, fixed time window diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index ed9e92cfd..eda76477e 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -496,8 +496,17 @@ Last 365 Days Custom Days From %1$s to %2$s + Since %1$s + Select date range + No end date — always up to today. + Clear the end date to always include today. + Clear end Time range Aggregation + Time range applies to all screens + Time range applies to this screen only + Aggregation applies to all screens + Aggregation applies to this screen only %1$s selected No Aggregation Day diff --git a/android_app/app/src/test/java/com/health/openscale/core/data/TimeRangeFilterTest.kt b/android_app/app/src/test/java/com/health/openscale/core/data/TimeRangeFilterTest.kt new file mode 100644 index 000000000..baa00ced5 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/data/TimeRangeFilterTest.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 . + */ +package com.health.openscale.core.data + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId + +/** Tests the conversion from a selected time range to the bounds the measurement pipeline filters on. */ +class TimeRangeFilterTest { + + private val berlin: ZoneId = ZoneId.of("Europe/Berlin") + private val today: LocalDate = LocalDate.of(2025, 4, 20) + + private fun dayStart(date: LocalDate, zone: ZoneId = berlin): Long = + date.atStartOfDay(zone).toInstant().toEpochMilli() + + private fun resolve( + filter: TimeRangeFilter, + start: Long = 0L, + end: Long = 0L, + zone: ZoneId = berlin, + ) = filter.resolveBounds(start, end, zone, today) + + @Test + fun allDays_isUnbounded() { + assertThat(resolve(TimeRangeFilter.ALL_DAYS)).isEqualTo(null to null) + } + + @Test + fun rollingRange_startsAtLocalMidnight() { + val (start, _) = resolve(TimeRangeFilter.LAST_30_DAYS) + assertThat(start).isEqualTo(dayStart(LocalDate.of(2025, 3, 21))) + } + + @Test + fun rollingRange_leavesEndOpenSoFutureDatedEntriesStayVisible() { + listOf( + TimeRangeFilter.LAST_7_DAYS, + TimeRangeFilter.LAST_30_DAYS, + TimeRangeFilter.LAST_365_DAYS, + ).forEach { filter -> + assertThat(resolve(filter).second).isNull() + } + } + + @Test + fun rollingRanges_spanTheirNamedNumberOfDays() { + assertThat(resolve(TimeRangeFilter.LAST_7_DAYS).first) + .isEqualTo(dayStart(LocalDate.of(2025, 4, 13))) + assertThat(resolve(TimeRangeFilter.LAST_365_DAYS).first) + .isEqualTo(dayStart(LocalDate.of(2024, 4, 20))) + } + + @Test + fun custom_endIsInclusiveToTheLastMillisecondOfThatDay() { + val (start, end) = resolve( + TimeRangeFilter.CUSTOM, + start = dayStart(LocalDate.of(2025, 3, 12)), + end = dayStart(LocalDate.of(2025, 4, 10)), + ) + assertThat(start).isEqualTo(dayStart(LocalDate.of(2025, 3, 12))) + // 2025-04-10 23:59:59.999 local, not the start of that day - a measurement taken that + // evening belongs to the range the user picked. + assertThat(end).isEqualTo(dayStart(LocalDate.of(2025, 4, 11)) - 1) + } + + @Test + fun custom_startWithoutEnd_staysOpenEnded() { + val diaryStart = dayStart(LocalDate.of(2025, 3, 12)) + assertThat(resolve(TimeRangeFilter.CUSTOM, start = diaryStart, end = 0L)) + .isEqualTo(diaryStart to null) + } + + @Test + fun custom_withoutStart_isUnbounded() { + assertThat(resolve(TimeRangeFilter.CUSTOM, start = 0L, end = 0L)) + .isEqualTo(null to null) + } + + @Test + fun custom_endWithoutStart_stillBoundsTheEnd() { + val (start, end) = resolve( + TimeRangeFilter.CUSTOM, + start = 0L, + end = dayStart(LocalDate.of(2025, 4, 10)), + ) + assertThat(start).isNull() + assertThat(end).isEqualTo(dayStart(LocalDate.of(2025, 4, 11)) - 1) + } + + @Test + fun custom_endIsInclusive_atNegativeUtcOffsetsToo() { + val newYork = ZoneId.of("America/New_York") + val (start, end) = resolve( + TimeRangeFilter.CUSTOM, + start = dayStart(LocalDate.of(2025, 3, 12), newYork), + end = dayStart(LocalDate.of(2025, 4, 10), newYork), + zone = newYork, + ) + assertThat(start).isEqualTo(dayStart(LocalDate.of(2025, 3, 12), newYork)) + assertThat(end).isEqualTo(dayStart(LocalDate.of(2025, 4, 11), newYork) - 1) + } + + @Test + fun custom_endOfDay_survivesADayThatIsOnly23HoursLong() { + // US DST starts 2025-03-09 at 02:00, so that calendar day has 23 hours. Anchoring the end + // on the next day's midnight keeps the bound on the right day either way. + val newYork = ZoneId.of("America/New_York") + val dstDay = LocalDate.of(2025, 3, 9) + val (_, end) = resolve( + TimeRangeFilter.CUSTOM, + start = dayStart(dstDay, newYork), + end = dayStart(dstDay, newYork), + zone = newYork, + ) + assertThat(end).isEqualTo(dayStart(dstDay.plusDays(1), newYork) - 1) + assertThat(end!! - dayStart(dstDay, newYork) + 1).isEqualTo(23 * 60 * 60 * 1000L) + } + + @Test + fun rollingRange_countsCalendarDaysNotFixed24HourBlocks() { + // 2025-03-30 is the European DST switch, so the seven days before 03-31 span 167 hours. + // Subtracting 7 * 86_400_000 would land an hour into the wrong day. + val end = LocalDate.of(2025, 3, 31) + val start = TimeRangeFilter.LAST_7_DAYS.resolveBounds(zone = berlin, today = end).first + assertThat(start).isEqualTo(dayStart(LocalDate.of(2025, 3, 24))) + assertThat(dayStart(end) - start!!).isEqualTo(7 * 24 * 60 * 60 * 1000L - 60 * 60 * 1000L) + } + + @Test + fun bounds_holdOnSubHourUtcOffsets() { + val kathmandu = ZoneId.of("Asia/Kathmandu") // UTC+05:45 + val day = LocalDate.of(2025, 4, 10) + val (start, end) = resolve( + TimeRangeFilter.CUSTOM, + start = dayStart(day, kathmandu), + end = dayStart(day, kathmandu), + zone = kathmandu, + ) + assertThat(start).isEqualTo(dayStart(day, kathmandu)) + assertThat(end).isEqualTo(dayStart(day.plusDays(1), kathmandu) - 1) + } + + @Test + fun bounds_followTheGivenZoneRatherThanUtc() { + val utc = ZoneId.of("UTC") + val berlinStart = resolve(TimeRangeFilter.LAST_7_DAYS, zone = berlin).first + val utcStart = resolve(TimeRangeFilter.LAST_7_DAYS, zone = utc).first + + assertThat(berlinStart).isEqualTo(dayStart(LocalDate.of(2025, 4, 13), berlin)) + assertThat(utcStart).isEqualTo(dayStart(LocalDate.of(2025, 4, 13), utc)) + // Same calendar day, different instant - CEST is two hours ahead of UTC in April. + assertThat(utcStart!! - berlinStart!!).isEqualTo(2 * 60 * 60 * 1000L) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementFilterUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementFilterUseCasesTest.kt index 3779fb54d..0ed0c46e9 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementFilterUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementFilterUseCasesTest.kt @@ -21,6 +21,9 @@ import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.model.EnrichedMeasurement import com.health.openscale.testutil.Fixtures +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest import org.junit.Test class MeasurementFilterUseCasesTest { @@ -57,4 +60,45 @@ class MeasurementFilterUseCasesTest { assertThat(out).hasSize(1) assertThat(out[0].measurementWithValues.measurement.id).isEqualTo(2) } + + /** Days 1..5 of April 2025, at noon each. */ + private val fiveDays = (1..5).map { emWith(it, weight) } + + private suspend fun timeFilteredIds(start: Long?, end: Long?): List = + useCase.getTimeFiltered(flowOf(fiveDays), start, end) + .first() + .map { it.measurementWithValues.measurement.id } + + @Test + fun getTimeFiltered_bothBoundsNull_returnsAll() = runTest { + assertThat(timeFilteredIds(null, null)).containsExactly(1, 2, 3, 4, 5).inOrder() + } + + @Test + fun getTimeFiltered_openEnd_keepsEverythingFromTheStartOnwards() = runTest { + assertThat(timeFilteredIds(Fixtures.ts(2025, 4, 3, hour = 0), null)) + .containsExactly(3, 4, 5).inOrder() + } + + @Test + fun getTimeFiltered_openStart_keepsEverythingUpToTheEnd() = runTest { + assertThat(timeFilteredIds(null, Fixtures.ts(2025, 4, 3, hour = 23))) + .containsExactly(1, 2, 3).inOrder() + } + + @Test + fun getTimeFiltered_bothBounds_keepsTheEnclosedRange() = runTest { + assertThat( + timeFilteredIds( + Fixtures.ts(2025, 4, 2, hour = 0), + Fixtures.ts(2025, 4, 4, hour = 23), + ) + ).containsExactly(2, 3, 4).inOrder() + } + + @Test + fun getTimeFiltered_boundsAreInclusive() = runTest { + val exactly = Fixtures.ts(2025, 4, 3) + assertThat(timeFilteredIds(exactly, exactly)).containsExactly(3) + } } From f5d03d915f7115cbbafabac694ecd8aca4c56da3 Mon Sep 17 00:00:00 2001 From: Sebastian Ziegler Date: Sat, 5 Sep 2026 08:34:25 +0200 Subject: [PATCH 65/72] Fix crash on every successful weigh-in: unwrap Kg in saved-measurement (#1507) * Fix crash on every successful weigh-in: unwrap Kg in saved-measurement snackbar 39a0c5c ("BLE handlers can contribute custom measurement types", #1481/#1484) changed BleConnector's "measurement saved" snackbar args from the old Float measurementData.weight to measurementData[MeasurementType.WEIGHT] ?: 0f. That subscript returns Kg? (the Key type), not Float?, so whenever a measurement actually has a weight (i.e. on every successful weigh-in on every scale), the snackbar's format arg list carries a Kg object instead of a raw Float. Resources.getString then throws formatting it against the "%1$.1f kg" specifier in bluetooth_connector_measurement_saved: java.util.IllegalFormatConversionException: f != com.health.openscale.core.data.Kg at ... Resources.getString at ... AppNavigationKt$AppNavigation$2$1$1$2.emit Fix: unwrap .value to get the Float the format string expects. Added a regression test exercising the exact Resources.getString call that threw (constructing the full BleConnector needs a live ScaleCommunicator and DB facades, so the test mirrors just the argument-building step instead). Verified it fails against the pre-fix expression and passes against the fix. * Delete android_app/app/src/test/java/com/health/openscale/core/service/BleConnectorSnackbarTest.kt --------- Co-authored-by: OliE --- .../main/java/com/health/openscale/core/service/BleConnector.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt index 858bd0346..f44da62b8 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt @@ -535,7 +535,7 @@ class BleConnector( "Measurement from $deviceName for User $currentAppUserId saved (ID: $measurementId). Values: ${values.size}" ) pendingSavedCount.incrementAndGet() - lastSavedArgs = listOf(measurementData[MeasurementType.WEIGHT] ?: 0f, deviceName) + lastSavedArgs = listOf(measurementData[MeasurementType.WEIGHT]?.value ?: 0f, deviceName) savedBurstSignal.tryEmit(Unit) } catch (e: Exception) { LogManager.e(TAG, "Error saving measurement from $deviceName.", e) From accbf819392686a6f4d9ddedab00410914639048 Mon Sep 17 00:00:00 2001 From: mrblowntobits <118385405+mrblowntobits@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:07:10 +0200 Subject: [PATCH 66/72] Adds support for AnD UC352BLE (#1505) * Adds support for AnD UC352BLE * Delete android_app/gradle/gradle-daemon-jvm.properties * Update AndUC352BLEHandlerTest.kt * Update ScaleFactory.kt * Update AndUC352BLEHandler.kt --------- Co-authored-by: blowntobits Co-authored-by: OliE --- .../openscale/core/bluetooth/ScaleFactory.kt | 3 +- .../bluetooth/scales/AndUC352BLEHandler.kt | 51 +++++++ .../openscale/core/bluetooth/ScaleCatalog.kt | 2 + .../scales/AndUC352BLEHandlerTest.kt | 144 ++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AndUC352BLEHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/AndUC352BLEHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 65116afc9..b36a22ab2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -28,6 +28,7 @@ import com.health.openscale.core.bluetooth.scales.AiLinkBroadcastHandler import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler import com.health.openscale.core.bluetooth.scales.ScaleupHandler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler +import com.health.openscale.core.bluetooth.scales.AndUC352BLEHandler import com.health.openscale.core.bluetooth.scales.CultSmartScaleProHandler import com.health.openscale.core.bluetooth.scales.BeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.BroadcastScaleAdapter @@ -134,7 +135,7 @@ class ScaleFactory @Inject constructor( */ @VisibleForTesting internal fun createHandlers(): List = listOf( - // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). + AndUC352BLEHandler(), HealthKeep280Handler(), PicoocHandler(), ActiveEraBF06Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AndUC352BLEHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AndUC352BLEHandler.kt new file mode 100644 index 000000000..857206bf7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/AndUC352BLEHandler.kt @@ -0,0 +1,51 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Locale + +/** + * Handler for the A&D UC-352BLE. + * + * The scale uses the Bluetooth SIG Weight Scale Profile: measurements are indicated on 2A9D and + * its clock is written through Current Time (2A2B). [StandardWeightProfileHandler] implements both + * parts, so this class only supplies model-specific discovery and capability metadata. + */ +class AndUC352BLEHandler : StandardWeightProfileHandler() { + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + if (!device.name.uppercase(Locale.US).startsWith(DEVICE_NAME_PREFIX)) return null + + val capabilities = setOf( + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.BATTERY_LEVEL, + ) + return DeviceSupport( + displayName = "A&D UC-352BLE", + capabilities = capabilities, + implemented = capabilities, + linkMode = LinkMode.CONNECT_GATT, + ) + } + + private companion object { + const val DEVICE_NAME_PREFIX = "A&D_UC-352BLE_" + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 807db3ebf..cf2f30eeb 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -22,6 +22,7 @@ import com.health.openscale.core.bluetooth.scales.AAAxHandler import com.health.openscale.core.bluetooth.scales.HealthKeep280Handler import com.health.openscale.core.bluetooth.scales.AiLinkBroadcastHandler import com.health.openscale.core.bluetooth.scales.ActiveEraBF06Handler +import com.health.openscale.core.bluetooth.scales.AndUC352BLEHandler import com.health.openscale.core.bluetooth.scales.AfuB1Handler import com.health.openscale.core.bluetooth.scales.BeurerBF450Handler import com.health.openscale.core.bluetooth.scales.BeurerSanitasHandler @@ -180,6 +181,7 @@ object ScaleCatalog { */ val fixtures: List = listOf( // --- Matched by advertised name --- + device("A&D_UC-352BLE_123456", uuid16(0x181D)) claimedBy AndUC352BLEHandler::class.java, device("HEALTHKEEP 280") claimedBy HealthKeep280Handler::class.java, device("AE BS-06") claimedBy ActiveEraBF06Handler::class.java, device("AFU-BH-TZ-B1", uuid16(0xFC50)) claimedBy AfuB1Handler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/AndUC352BLEHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/AndUC352BLEHandlerTest.kt new file mode 100644 index 000000000..c5bfdb3c2 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/AndUC352BLEHandlerTest.kt @@ -0,0 +1,144 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Calendar +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AndUC352BLEHandlerTest { + + @Test + fun `recognises the captured advertisement without depending on its serial suffix`() { + val support = AndUC352BLEHandler().supportFor(device("A&D_UC-352BLE_01166D", 0x181D)) + + assertThat(support).isNotNull() + assertThat(support!!.displayName).isEqualTo("A&D UC-352BLE") + assertThat(support.capabilities).containsExactly( + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.BATTERY_LEVEL, + ) + assertThat(support.implemented).isEqualTo(support.capabilities) + assertThat(support.linkMode).isEqualTo(LinkMode.CONNECT_GATT) + } + + @Test + fun `matching is case insensitive and rejects other devices`() { + val handler = AndUC352BLEHandler() + + assertThat(handler.supportFor(device("a&d_uc-352ble_abcdef", 0x181D))).isNotNull() + assertThat(handler.supportFor(device("A&D_UC-355BLE_01166D", 0x181D))).isNull() + assertThat(handler.supportFor(device("UC-352BLE", 0x181D))).isNull() + assertThat(handler.supportFor(device("A&D_UC-352BLE_01166D"))).isNotNull() + } + + @Test + fun `decodes captured standard weight indications`() { + val captured = listOf( + // flags, weight/0.005 kg LE, year LE, month, day, hour, minute, second + "025433ea070816082a24" to ExpectedMeasurement(65.7f, 8, 42, 36), + "024033ea070816082b0b" to ExpectedMeasurement(65.6f, 8, 43, 11), + "027c33ea070816082b28" to ExpectedMeasurement(65.9f, 8, 43, 40), + ) + + for ((payload, expected) in captured) { + val callbacks = CapturingCallbacks() + val handler = attachedHandler(callbacks) + + handler.handleNotification(uuid16(0x2A9D), payload.hexToBytes()) + handler.handleDisconnected() + + assertThat(callbacks.published).hasSize(1) + val measurement = callbacks.published.single() + assertThat(measurement[MeasurementType.WEIGHT]?.value).isWithin(0.001f).of(expected.weight) + val calendar = Calendar.getInstance().apply { time = requireNotNull(measurement.dateTime) } + assertThat(calendar.get(Calendar.YEAR)).isEqualTo(2026) + assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.AUGUST) + assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(22) + assertThat(calendar.get(Calendar.HOUR_OF_DAY)).isEqualTo(expected.hour) + assertThat(calendar.get(Calendar.MINUTE)).isEqualTo(expected.minute) + assertThat(calendar.get(Calendar.SECOND)).isEqualTo(expected.second) + } + } + + private data class ExpectedMeasurement(val weight: Float, val hour: Int, val minute: Int, val second: Int) + + private fun attachedHandler(callbacks: CapturingCallbacks) = AndUC352BLEHandler().apply { + attach( + NoopTransport(), callbacks, InMemorySettings(), FixedDataProvider(), + CoroutineScope(Dispatchers.Unconfined), + ) + } + + private class NoopTransport : ScaleDeviceHandler.Transport { + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) = Unit + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID) = true + } + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + override fun onPublish(measurement: ScaleMeasurement) { published += measurement.snapshot() } + override fun resolveString(resId: Int, vararg args: Any) = "res:$resId" + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + private val ints = mutableMapOf() + private val strings = mutableMapOf() + override fun getInt(key: String, default: Int) = ints[key] ?: default + override fun putInt(key: String, value: Int) { ints[key] = value } + override fun getString(key: String, default: String?) = strings[key] ?: default + override fun putString(key: String, value: String) { strings[key] = value } + override fun remove(key: String) { ints.remove(key); strings.remove(key) } + } + + private class FixedDataProvider : ScaleDeviceHandler.DataProvider { + private val user = ScaleUser(id = 1, userName = "Test User") + override fun currentUser() = user + override fun usersForDevice() = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = null + } + + private fun device(name: String, vararg services: Int) = ScannedDeviceInfo( + name = name, + address = "00:11:22:33:44:55", + rssi = -50, + serviceUuids = services.map(::uuid16), + manufacturerData = null, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} From 18648fb38dfe2e4680c4ee216ef490f9604df7eb Mon Sep 17 00:00:00 2001 From: Sebastian Ziegler Date: Sat, 5 Sep 2026 09:21:29 +0200 Subject: [PATCH 67/72] Fix: QNHandler for GE CS 10 G "Fit Plus": pairing, weight parsing, config (#1502) * Fix QNHandler for GE CS 10 G "Fit Plus": pairing, weight parsing, config Three independent bugs, all confirmed against a BTSnoop capture of the official "Fit Profile" app driving a real Fit Plus unit (a GE-branded Qing Niu / QN-Scale device): 1. supportFor() never claimed the device. Its pre-connect advertisement carries only the FFE0 service UUID -- AE00 and FFF0 are real GATT services but only visible after connecting, so neither the QN-family name list nor the AE00 relaxation matched "Fit Plus". Added "fit plus" as a recognized name. 2. Live-weight (0x10) frames were never recognized as valid. This unit reports weightScaleFactor=100 via the 0x12 handshake frame but still uses the ES-30M byte layout ([4]=state, [5,6]=weight), which was previously gated on weightScaleFactor==10 only. Added isLongFrameVariant, derived from the 0x12 frame's own structure (length byte == frame length) rather than the scale factor, and used it as an additional trigger for ES-30M-style parsing. 3. State==1 ("stabilizing") was treated as stable alongside state==2, but state==1 frames always carry R1=R2=0 in the capture -- treating it as final would publish a measurement with fabricated impedance and then discard the real state==2 reading via the hasPublishedForThisSession guard. Tightened to state==2 only. Also, cross-referenced against the decompiled vendor Android SDK (CmdBuilder/QNDecoderImpl): - Added the 0x1F stable-reading acknowledgment ([0x1F, 0x05, protocolType, 0x10, checksum]), which the vendor app sends unconditionally and openScale never sent at all. - The 0x13 config command now carries the real user's height/age/gender instead of zeros, matching CmdBuilder.buildCmd(0x13, scaleType, [unit, lightInterval, height, age, gender]). QNHandlerProtocolTest.kt is new and includes a regression test built from byte-for-byte frames taken from the BTSnoop capture (device address anonymized) covering bugs 2 and 3 together. QNHandlerMatchTest gains a matching test for the FFE0-only advertisement and has its now-incorrect "Fit Plus should not match" negative case corrected to a generic device name. Verified end-to-end on the physical device: openScale now pairs and publishes real weight/impedance measurements. * Address review: drop broken length-based heuristic, scope fixes, fix impedance units - Remove isLongFrameVariant. byte[1] is the frame's own length for every well-formed frame in this protocol, so "byte[1] == frame length" is true almost always, not just for the 18-byte 0x12 variant -- it doesn't discriminate anything. Worse, it silently widened the ES-30M format gate for every classic factor-100 QN/Yolanda/Renpho device: any weigh-in whose low weight byte is <= 2 (~1/85 readings) would misparse the stable flag and impedance as weight. weightScaleFactor == 10.0f alone already selects the right layout for the captured Fit Plus family, since its 0x12 frame's byte[10] happens to fail the existing (pre-existing, unchanged) `== 1` check the same way a classic factor-10 device's would. - Scope the state==2-only tightening, the 0x1F ack, and the real height/age/gender in the 0x13 config to protocolType == 0xFF (the captured Fit Plus family). Every other protocolType keeps its original, longer-serving behavior (state 1 or 2 treated as stable, no 0x1F ack, zeroed config bytes) until a capture justifies extending these there too. - Fix impedance units for the same family: the vendor SDK's own two-byte combine (MeasureDecoder.h -> twoByte2Int) applies no scaling, so raw r1 (e.g. 4350) is left as reported. But TrisaBodyAnalyzeLib's formula is documented against impedance in the ~300-1000 Ohm range, matching r1/10, not raw r1 (which is landing ~10x above that and was being fed through an ad-hoc 0.3*(r1-400) transform whose constants only make sense against a ~300-1000 input to begin with). Stores and uses r1/10 for this family; every other protocolType keeps the existing raw-r1 + ad-hoc-transform behavior untouched. Flagged in comments as inferred, not confirmed against the vendor's own (unavailable) body-composition algorithm. - Merge master (typed MeasurementType/Kg/Ohm/Percent value map replacing direct ScaleMeasurement field access). Tests: two new companions per gated behavior proving classic protocolTypes keep their original behavior, plus a direct regression test for the low-weight-byte misparse the length heuristic would have caused. All QNHandler tests and the full suite pass. --- .../core/bluetooth/scales/QNHandler.kt | 87 +++- .../bluetooth/scales/QNHandlerMatchTest.kt | 25 +- .../bluetooth/scales/QNHandlerProtocolTest.kt | 372 ++++++++++++++++++ 3 files changed, 468 insertions(+), 16 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/QNHandlerProtocolTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt index 5c9ca926e..ba19dcded 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/QNHandler.kt @@ -81,6 +81,16 @@ class QNHandler : ScaleDeviceHandler() { /** Last seen protocol type from a vendor packet (byte[2]), echoed back in our replies. */ private var seenProtocolType: Byte = 0x00.toByte() + /** + * True for the GE CS 10 G "Fit Plus" family, which always reports protocolType 0xFF and is + * the only sub-family this handler's behavior has been verified against via a real BTSnoop + * capture (weight, impedance, the 0x1F ack, and real height/age/gender in the 0x13 config). + * Everything gated on this flag falls back to the pre-existing, longer-serving behavior for + * every other protocolType, so verified fixes for this one family can't regress the rest. + */ + private val isCapturedUniversalVariant: Boolean + get() = seenProtocolType == 0xFF.toByte() + /** Flag to track if we've received the protocol type from 0x12 frame. */ private var hasReceivedProtocolType = false @@ -105,7 +115,11 @@ class QNHandler : ScaleDeviceHandler() { nameLc.contains("renpho-scale") || // SEB/Tefal Goodvibes variants advertise under SEB branding // while keeping the QN/Yolanda service layout. - nameLc.contains("seb-scale") + nameLc.contains("seb-scale") || + // GE CS 10 G "Fit Plus": confirmed via BTSnoop capture to advertise + // FFE0 pre-connect with name "Fit Plus" (not AE00 — that's only visible + // after GATT connect), so the AE00 relaxation below never fires for it. + nameLc == "fit plus" // AE00 is a QN-only vendor service (newer QN firmware) that is never shared with the // fff0 Inlife/1byone/Eufy cluster, so it positively identifies a QN scale even when the @@ -293,7 +307,7 @@ class QNHandler : ScaleDeviceHandler() { logD( "QN: raw notify: ${data.toHexPreview(24)}") // Detect format by checking if byte[4] looks like a stable flag (0x00, 0x01, 0x02) - // vs weight data (typically > 0x10) + // vs weight data (typically > 0x10). val byte4Value = data[4].toInt() and 0xFF val isES30MFormat = byte4Value <= 0x02 && weightScaleFactor == 10.0f @@ -306,7 +320,12 @@ class QNHandler : ScaleDeviceHandler() { // ES-30M format: byte[4]=stable, bytes[5,6]=weight if (data.size < 11) return val stableFlag = byte4Value - stable = stableFlag == 0x02 || stableFlag == 0x01 + // Only state 2 carries real impedance on the captured Fit Plus family — state 1 + // ("stabilizing") always reports R1=R2=0 there, so treating it as stable would + // publish a measurement with fabricated impedance and then never see the real one + // (guarded by hasPublishedForThisSession). Not verified for other protocolTypes, + // so they keep the original (state 1 or 2) behavior. + stable = if (isCapturedUniversalVariant) stableFlag == 0x02 else (stableFlag == 0x02 || stableFlag == 0x01) raw = u16be(data[5], data[6]) r1 = u16be(data[7], data[8]) r2 = u16be(data[9], data[10]) @@ -321,7 +340,25 @@ class QNHandler : ScaleDeviceHandler() { logD("QN: using original format") } - if (!stable || hasPublishedForThisSession) return + if (!stable) return + + // Acknowledge the stable reading with 0x1F, per the vendor SDK + // (CmdBuilder.buildOverCmd -> QNDecoderImpl opcode 0x10/state 0x02 handler), which + // always answers a stable frame with [0x1F, 0x05, protocolType, 0x10, checksum] + // regardless of anything else. Verified against a real capture of the protocolType + // 0xFF ("Fit Plus") family only; scoped there until we have evidence other QN devices + // also expect it. + if (isCapturedUniversalVariant) { + val ack = byteArrayOf(0x1F, 0x05, seenProtocolType, 0x10, 0x00) + ack[ack.lastIndex] = checksum(ack, 0, ack.lastIndex - 1) + if (hasCharacteristic(SVC_T2, CHR_T2_WRITE_SHARED)) { + writeTo(SVC_T2, CHR_T2_WRITE_SHARED, ack, true) + } else if (hasCharacteristic(SVC_T1, CHR_T1_WRITE_CONFIG)) { + writeTo(SVC_T1, CHR_T1_WRITE_CONFIG, ack, true) + } + } + + if (hasPublishedForThisSession) return var weightKg = raw / weightScaleFactor @@ -445,7 +482,7 @@ class QNHandler : ScaleDeviceHandler() { */ private fun handleScaleInfoFrame(data: ByteArray) { if (data.size <= 10) return - + weightScaleFactor = if (data[10].toInt() == 1) 100.0f else 10.0f logD("QN: set weightScaleFactor=$weightScaleFactor from opcode 0x12") @@ -472,11 +509,23 @@ class QNHandler : ScaleDeviceHandler() { else -> 0x01.toByte() // KG } - logD("QN: sending config with seenProtocolType=$seenProtocolType unitByte=$unitByte") + // Height/age/gender, per the vendor SDK's CmdBuilder.buildCmd(0x13, scaleType, + // [unit, lightInterval, height, age, gender]) (QNDecoderImpl.setScaleConfig). + // Clamped to the vendor's own [60,220]cm / [6,80]yr bounds (buildUserInfoCmd) so an + // out-of-range profile can't produce a byte the scale's firmware doesn't expect. + // Gender wire byte is inverted from the vendor's internal enum: 0 = male, 1 = female. + // Verified against a real capture of the protocolType 0xFF ("Fit Plus") family only; + // other protocolTypes keep sending zeros (the pre-existing behavior) until we have + // evidence they also expect real values here. + val heightByte = if (isCapturedUniversalVariant) user.bodyHeight.toInt().coerceIn(60, 220).toByte() else 0x00.toByte() + val ageByte = if (isCapturedUniversalVariant) user.age.coerceIn(6, 80).toByte() else 0x00.toByte() + val genderByte = if (!isCapturedUniversalVariant) 0x00.toByte() else if (user.gender.isMale()) 0x00.toByte() else 0x01.toByte() + + logD("QN: sending config with seenProtocolType=$seenProtocolType unitByte=$unitByte height=$heightByte age=$ageByte gender=$genderByte") // Configure unit on both flavors (the non-matching write will be ignored by the stack). val cfg = byteArrayOf( - 0x13, 0x09, seenProtocolType, unitByte, 0x10, 0x00, 0x00, 0x00, 0x00 + 0x13, 0x09, seenProtocolType, unitByte, 0x10, heightByte, ageByte, genderByte, 0x00 ) cfg[cfg.lastIndex] = checksum(cfg, 0, cfg.lastIndex) // last byte = checksum @@ -528,14 +577,30 @@ class QNHandler : ScaleDeviceHandler() { ((d.toLong() and 0xFFL) shl 24) private fun publishQnMeasurement(user: ScaleUser, weightKg: Float, r1: Float, source: String) { + // On the captured "Fit Plus" family, r1 is raw ADC counts in tenths of an ohm (e.g. + // 4350 -> 435.0 Ohm), landing squarely in the ~300-1000 Ohm range the Trisa formula + // below is documented against; taken as whole ohms it's roughly 10x too large. The + // vendor SDK's own two-byte combine (MeasureDecoder.h/twoByte2Int) applies no scaling, + // so this is specific to how this family's firmware reports the value, not a universal + // QN constant -- scoped accordingly rather than changed for every protocolType. + val impedanceOhm = if (isCapturedUniversalVariant) r1 / 10f else r1 + val m = ScaleMeasurement().apply { userId = user.id this[MeasurementType.WEIGHT] = Kg(weightKg) - // Store the raw resistance so body composition can be recomputed later. - this[MeasurementType.IMPEDANCE] = Ohm(r1) + // Store the (possibly rescaled) resistance so body composition can be recomputed later. + this[MeasurementType.IMPEDANCE] = Ohm(impedanceOhm) } - val impedance = if (r1 < 410f) 3.0f else 0.3f * (r1 - 400f) + // Pre-existing ad-hoc calibration for the other protocolTypes this handler already + // serves; left untouched since we have no capture evidence to correct it for them. + val impedance = if (isCapturedUniversalVariant) { + impedanceOhm + } else if (impedanceOhm < 410f) { + 3.0f + } else { + 0.3f * (impedanceOhm - 400f) + } val trisa = TrisaBodyAnalyzeLib( if (user.gender.isMale()) 1 else 0, @@ -548,7 +613,7 @@ class QNHandler : ScaleDeviceHandler() { m[MeasurementType.MUSCLE] = Percent(trisa.getMuscle(weightKg, impedance)) m[MeasurementType.BONE] = Kg(trisa.getBone(weightKg, impedance)) - logD("QN: publishing $source measurement weight=$weightKg kg r1=$r1 impedance=$impedance") + logD("QN: publishing $source measurement weight=$weightKg kg r1=$r1 impedanceOhm=$impedanceOhm impedance=$impedance") // Snapshot: the accumulator keeps being mutated after this publish. publish(m.snapshot()) } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/QNHandlerMatchTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/QNHandlerMatchTest.kt index 13aa4cc9b..a2a470e48 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/QNHandlerMatchTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/QNHandlerMatchTest.kt @@ -26,10 +26,16 @@ import org.robolectric.annotation.Config import java.util.UUID /** - * Tests for [QNHandler.supportFor] device matching, in particular the AE00 vendor-service - * path ported from ble-scale-sync 6a12687 (#235): a QN scale that advertises the QN-only - * ae00 service under a non-QN name (e.g. GE CS 10 G "Fit Plus") must be claimed, while the - * ae00 service alone (no fff0/ffe0 channel this handler can drive) must NOT be claimed. + * Tests for [QNHandler.supportFor] device matching. + * + * Two independent paths matter here: + * - The AE00 vendor-service path ported from ble-scale-sync 6a12687 (#235): a QN scale that + * advertises the QN-only ae00 service under a non-QN name must be claimed, while ae00 alone + * (no fff0/ffe0 channel this handler can drive) must NOT be claimed. + * - The "fit plus" name match: a real BTSnoop capture of GE CS 10 G "Fit Plus" showed its + * pre-connect advertisement carries ONLY FFE0 — no AE00, no FFF0 (those are real GATT + * services but only discoverable post-connection) — so the AE00 relaxation above never + * fires for it in practice, and the name match is what actually claims the device. * * Robolectric is required because QNHandler constructs a main-looper Handler. */ @@ -57,7 +63,16 @@ class QNHandlerMatchTest { @Test fun `does not claim a bare fff0 device with a non-QN name`() { // No ae00 and no QN-family name -> not ours (leave to other fff0 handlers). - assertThat(QNHandler().supportFor(device("Fit Plus", 0xFFF0))).isNull() + assertThat(QNHandler().supportFor(device("Unrelated Scale", 0xFFF0))).isNull() + } + + @Test + fun `claims Fit Plus advertising only FFE0 (name match, no AE00 needed)`() { + // GE CS 10 G "Fit Plus": confirmed via BTSnoop capture to advertise pre-connect with + // ONLY FFE0 in its 16-bit service UUID list — AE00 and FFF0 are real GATT services but + // only visible after connecting, so the AE00 relaxation never fires here. "fit plus" is + // therefore matched directly as a QN-family name (#Fit-Plus-not-supported). + assertThat(QNHandler().supportFor(device("Fit Plus", 0xFFE0))).isNotNull() } @Test diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/QNHandlerProtocolTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/QNHandlerProtocolTest.kt new file mode 100644 index 000000000..4330cfe0f --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/QNHandlerProtocolTest.kt @@ -0,0 +1,372 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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 com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Date +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Ground-truth regression tests for [QNHandler]'s Type-2 (0xFFF0) protocol handling, verified + * against the decompiled Qing Niu vendor SDK (QNDecoderImpl.decodeData / CmdBuilder) and a real + * BTSnoop capture of the official "Fit Profile" app talking to a GE CS 10 G "Fit Plus" + * (2026-08-31, device address anonymized in the frames below). + * + * Four behaviors are verified against that capture, but only hold for the *captured* family — + * which always reports protocolType 0xFF (see [QNHandler.isCapturedUniversalVariant]) — and are + * gated accordingly so they can't change behavior for the many other QN/Yolanda/Renpho devices + * this handler already served before this capture existed: + * + * - The 0x13 config command carries the real user height/age/gender + * (CmdBuilder.buildCmd(0x13, scaleType, [unit, lightInterval, height, age, gender])) instead + * of zeros. + * - Every stable 0x10 weight frame is acknowledged with + * [0x1F, 0x05, protocolType, 0x10, checksum] (CmdBuilder.buildOverCmd). + * - In the ES-30M byte layout, only state==2 (not state==1) is treated as stable — state==1 + * always carried R1=R2=0 in the capture. + * - r1/r2 are stored/used as tenths of an ohm (divided by 10) for impedance, since raw values + * landed ~10x above the ~300-1000 Ohm range TrisaBodyAnalyzeLib's formula is documented + * against, and the vendor SDK's own two-byte combine (MeasureDecoder.h) applies no scaling of + * its own — see `publishQnMeasurement`'s comment for the caveat that this is inferred, not + * confirmed against the vendor's own (unavailable) body-composition algorithm. + * + * Each gated test has a companion proving the *other* protocolTypes keep their original, + * longer-serving behavior — including [classic factor-100 frame with weight low byte 0x00 is not + * misclassified as ES-30M], which is a direct regression test for a real bug: an earlier version + * of this fix used a length-byte-based "long frame" heuristic that turned out to be true for + * essentially every well-formed frame (byte[1] is the frame's own length in this protocol), which + * would have silently corrupted roughly 1 in 85 weigh-ins on every classic QN/Yolanda/Renpho + * device by misreading the weight's low byte as an ES-30M state/impedance field. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class QNHandlerProtocolTest { + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private val SVC_T2 = uuid16(0xFFF0) + private val CHR_T2_NOTIFY = uuid16(0xFFF1) + private val CHR_T2_WRITE = uuid16(0xFFF2) + + private fun checksum(buf: ByteArray, from: Int, toInclusive: Int): Byte { + var s = 0 + for (i in from..toInclusive) s = (s + (buf[i].toInt() and 0xFF)) and 0xFF + return s.toByte() + } + + /** MM birthday chosen so `age` comes out to a known, mid-range value. */ + private fun testUser(): ScaleUser = ScaleUser( + id = 1, + birthday = Date(0), // 1970-01-01: comfortably inside the vendor's [6,80] clamp range + bodyHeight = 181f, + gender = GenderType.MALE, + ) + + private fun newHandler( + user: ScaleUser, + available: Set> = setOf(SVC_T2 to CHR_T2_NOTIFY, SVC_T2 to CHR_T2_WRITE), + ): Triple { + val transport = CapturingTransport(available) + val callbacks = CapturingCallbacks() + val handler = QNHandler() + handler.attach( + transport = transport, + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(user), + scope = CoroutineScope(EmptyCoroutineContext), + ) + handler.handleConnected(user) + return Triple(handler, transport, callbacks) + } + + /** Classic (short) 0x12 scale-info frame: [op, len, protocolType, ..., byte10=weightScaleFlag]. */ + private fun classicScaleInfoFrame(protocolType: Byte, weightScaleFlag: Byte): ByteArray { + val f = ByteArray(15) + f[0] = 0x12 + f[1] = 15 + f[2] = protocolType + f[10] = weightScaleFlag + return f + } + + @Test + fun `0x13 config command carries real height, age and gender for the captured protocolType 0xFF family`() { + val user = testUser() + val (handler, transport, _) = newHandler(user) + + handler.handleNotification(CHR_T2_NOTIFY, classicScaleInfoFrame(protocolType = 0xFF.toByte(), weightScaleFlag = 1)) + + val cfg = transport.writes.map { it.payload }.firstOrNull { it.size == 9 && it[0] == 0x13.toByte() } + assertThat(cfg).isNotNull() + cfg!! + + val expectedChecksum = checksum(cfg, 0, cfg.lastIndex - 1) + assertThat(cfg[8]).isEqualTo(expectedChecksum) + assertThat(cfg[2]).isEqualTo(0xFF.toByte()) // echoed protocol type + assertThat(cfg[4]).isEqualTo(0x10.toByte()) // lightInterval default + assertThat(cfg[5]).isEqualTo(181.toByte()) // height (unsigned 181 == signed -75 in two's complement) + assertThat(cfg[6]).isEqualTo(user.age.toByte()) + assertThat(cfg[7]).isEqualTo(0x00.toByte()) // male == 0 per vendor wire encoding + } + + @Test + fun `0x13 config command still sends zeroed height, age and gender for a classic protocolType`() { + // Regression guard: only the captured protocolType 0xFF family is verified to want real + // profile bytes here. Every other QN device keeps the pre-existing (zeroed) behavior. + val user = testUser() + val (handler, transport, _) = newHandler(user) + + handler.handleNotification(CHR_T2_NOTIFY, classicScaleInfoFrame(protocolType = 0x07, weightScaleFlag = 1)) + + val cfg = transport.writes.map { it.payload }.firstOrNull { it.size == 9 && it[0] == 0x13.toByte() } + assertThat(cfg).isNotNull() + cfg!! + assertThat(cfg[5]).isEqualTo(0x00.toByte()) // height + assertThat(cfg[6]).isEqualTo(0x00.toByte()) // age + assertThat(cfg[7]).isEqualTo(0x00.toByte()) // gender + } + + @Test + fun `stable weight frame is acknowledged with 0x1F for the captured protocolType 0xFF family`() { + val user = testUser() + val (handler, transport, callbacks) = newHandler(user) + + // Original 0x10 format: [op, len, protoType, weightHi, weightLo, stable, r1Hi, r1Lo, r2Hi, r2Lo] + // weightScaleFactor defaults to 100 -> raw 7000 == 70.00kg. + val weightFrame = byteArrayOf( + 0x10, 0x0A, 0xFF.toByte(), + ((7000 shr 8) and 0xFF).toByte(), (7000 and 0xFF).toByte(), + 0x01, // stable + 0x01, 0xF4.toByte(), // r1 = 500 + 0x01, 0xF4.toByte(), // r2 = 500 + ) + handler.handleNotification(CHR_T2_NOTIFY, weightFrame) + + val expectedAck = byteArrayOf(0x1F, 0x05, 0xFF.toByte(), 0x10, 0x00) + expectedAck[4] = checksum(expectedAck, 0, 3) + // ByteArray has no structural equals, so compare via List (or contentEquals) + // rather than raw contains(), which would only match by reference identity. + assertThat(transport.writes.map { it.payload.toList() }).contains(expectedAck.toList()) + + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.01f).of(70.0f) + } + + @Test + fun `stable weight frame is NOT acknowledged with 0x1F for a classic protocolType, but still publishes`() { + // Regression guard: only the captured protocolType 0xFF family is verified to want this + // ack. Every other QN device keeps the pre-existing (no-ack) behavior and must still + // publish the weight regardless. + val user = testUser() + val (handler, transport, callbacks) = newHandler(user) + + val weightFrame = byteArrayOf( + 0x10, 0x0A, 0x07, + ((7000 shr 8) and 0xFF).toByte(), (7000 and 0xFF).toByte(), + 0x01, // stable + 0x01, 0xF4.toByte(), + 0x01, 0xF4.toByte(), + ) + handler.handleNotification(CHR_T2_NOTIFY, weightFrame) + + assertThat(transport.writes.map { it.payload[0] }).doesNotContain(0x1F.toByte()) + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.01f).of(70.0f) + } + + @Test + fun `classic factor-100 frame with weight low byte 0x00 is not misclassified as ES-30M`() { + // Direct regression test for the bug caught in PR review: a "long frame" heuristic based + // on byte[1] == frame length is true for every well-formed frame in this protocol (that's + // just how the length field works), so it can't distinguish anything. Using it as an + // additional OR-condition alongside weightScaleFactor==10 effectively removed that guard, + // which would misparse any classic-format weight whose low byte happens to be <= 2 (about + // 1 in 85 real weigh-ins) as an ES-30M frame instead: e.g. 71.68kg (raw 7168 = 0x1C00) has + // low byte 0x00, matching the ES-30M "state" field's valid range. + val user = testUser() + val (handler, _, callbacks) = newHandler(user) + + // Classic (non-Fit-Plus) device: protocolType 0x07, weightScaleFactor 100 (byte10 == 1). + handler.handleNotification(CHR_T2_NOTIFY, classicScaleInfoFrame(protocolType = 0x07, weightScaleFlag = 1)) + + // Original format frame for 71.68kg: weightHi=0x1C, weightLo=0x00 (the low byte that would + // wrongly look like an ES-30M "measuring" state if the format detection were broken). + val weightFrame = byteArrayOf( + 0x10, 0x0A, 0x07, + 0x1C, 0x00, // weight = 7168 raw -> 71.68 kg + 0x01, // stable + 0x01, 0xF4.toByte(), // r1 = 500 + 0x01, 0xF4.toByte(), // r2 = 500 + ) + handler.handleNotification(CHR_T2_NOTIFY, weightFrame) + + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single()[MeasurementType.WEIGHT]?.value).isWithin(0.01f).of(71.68f) + } + + @Test + fun `classic ES-30M device (protocolType other than 0xFF) still treats state 1 as stable`() { + // The state==2-only tightening is scoped to the captured protocolType 0xFF family; + // every other ES-30M-layout device keeps the original (state 1 OR 2) behavior. + val user = testUser() + val (handler, _, callbacks) = newHandler(user) + + // weightScaleFactor 10 (byte10 != 1) selects the ES-30M layout for a classic protocolType. + handler.handleNotification(CHR_T2_NOTIFY, classicScaleInfoFrame(protocolType = 0x07, weightScaleFlag = 0)) + + val state1Frame = byteArrayOf( + 0x10, 0x0E, 0x07, + 0xFE.toByte(), 0x01, // [3]=filler, [4]=state 1 + 0x21, 0x57, // weight + 0x00, 0x00, 0x00, 0x00, // r1 = r2 = 0 + 0x00, 0x00, 0x00, + ) + state1Frame[state1Frame.lastIndex] = checksum(state1Frame, 0, state1Frame.lastIndex - 1) + handler.handleNotification(CHR_T2_NOTIFY, state1Frame) + + assertThat(callbacks.published).hasSize(1) + } + + @Test + fun `real Fit Plus advertisement (FFE0 name-only, no AE00 or FFF0) is claimed`() { + // Captured pre-connect advertisement: 16-bit service UUIDs = [FFE0] only (address + // anonymized). AE00/FFF0 are real GATT services but only appear post-connection, so + // the AE00 relaxation in supportFor() never sees them here — the name match is load-bearing. + val device = ScannedDeviceInfo( + name = "Fit Plus", + address = "00:11:22:33:44:55", + rssi = -60, + serviceUuids = listOf(uuid16(0xFFE0)), + manufacturerData = null, + ) + assertThat(QNHandler().supportFor(device)).isNotNull() + } + + @Test + fun `real hardware frames - state 1 is not published, state 2 publishes correct weight and impedance`() { + // Byte-for-byte from a BTSnoop capture of the official app driving a real + // GE CS 10 G "Fit Plus" (protocolType 0xFF, weightScaleFactor 100, long-frame 0x12), + // except bytes[3..8] (the device's BD_ADDR, embedded in this frame per the vendor + // protocol) are replaced with a placeholder and the checksum recomputed accordingly. + val scaleInfoFrame = byteArrayOf( + 0x12, 0x12, 0xFF.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x13, 0x07, 0x10, 0x00, 0x01, 0x05, 0x9F.toByte(), 0x32, 0x89.toByte(), + ) + val state0 = byteArrayOf( + 0x10, 0x0F, 0xFF.toByte(), 0xFE.toByte(), 0x00, 0x21, 0x57, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94.toByte(), + ) + val state1 = byteArrayOf( + 0x10, 0x0F, 0xFF.toByte(), 0xFE.toByte(), 0x01, 0x21, 0x57, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x95.toByte(), + ) + val state2 = byteArrayOf( + 0x10, 0x0F, 0xFF.toByte(), 0xFE.toByte(), 0x02, 0x21, 0x57, + 0x10, 0xFE.toByte(), 0x10, 0xFC.toByte(), 0x00, 0xAC.toByte(), 0x00, 0x5C, + ) + + val user = testUser() + val (handler, transport, callbacks) = newHandler(user) + + handler.handleNotification(CHR_T2_NOTIFY, scaleInfoFrame) + handler.handleNotification(CHR_T2_NOTIFY, state0) + assertThat(callbacks.published).isEmpty() + + handler.handleNotification(CHR_T2_NOTIFY, state1) + assertThat(callbacks.published).isEmpty() // state 1 has R1=R2=0 -> must not publish yet + + handler.handleNotification(CHR_T2_NOTIFY, state2) + assertThat(callbacks.published).hasSize(1) + val published = callbacks.published.single() + assertThat(published[MeasurementType.WEIGHT]?.value).isWithin(0.01f).of(85.35f) + // Raw r1 = 4350; the captured protocolType 0xFF family reports resistance in tenths of an + // ohm (see the class doc comment), so the stored/derived impedance is 435.0, not 4350.0. + assertThat(published[MeasurementType.IMPEDANCE]?.value).isWithin(0.01f).of(435.0f) + + val expectedAck = byteArrayOf(0x1F, 0x05, 0xFF.toByte(), 0x10, 0x00) + expectedAck[4] = checksum(expectedAck, 0, 3) + assertThat(transport.writes.map { it.payload.toList() }).contains(expectedAck.toList()) + } + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + override fun onPublish(measurement: ScaleMeasurement) { + published += measurement + } + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private data class WriteRecord( + val service: UUID, + val characteristic: UUID, + val payload: ByteArray, + val withResponse: Boolean + ) + + private class CapturingTransport( + private val available: Set> + ) : ScaleDeviceHandler.Transport { + val writes = mutableListOf() + + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) { + writes += WriteRecord(service, characteristic, payload, withResponse) + } + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = + available.contains(service to characteristic) + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + private val values = mutableMapOf() + override fun getInt(key: String, default: Int): Int = values[key]?.toIntOrNull() ?: default + override fun putInt(key: String, value: Int) { + values[key] = value.toString() + } + override fun getString(key: String, default: String?): String? = values[key] ?: default + override fun putString(key: String, value: String) { + values[key] = value + } + override fun remove(key: String) { + values.remove(key) + } + } + + private class FixedDataProvider( + private val user: ScaleUser, + private val users: List = listOf(user) + ) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = users + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = null + } +} From f341d242528a06a9e5942d06f0d5e7d6a2c971bc Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 5 Sep 2026 09:49:42 +0200 Subject: [PATCH 68/72] Add time range filter to Insights, issue #1506 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insights always computed over a user's whole history, so several years of data drowned out anything recent. It was the only data screen without the filter menu. - Wire the filter action into InsightsScreen, as in StatisticsScreen. - Extract timeRangeBoundsFlow out of buildScreenFlow so insightsFlow reuses the same persisted-range resolution. - insightsForUser takes optional start/end millis and filters before computing. - Skip the type-filter-row toggle for Insights, as for Statistics — its row always shows, so the entry would do nothing. --- .../core/facade/MeasurementFacade.kt | 23 +++++++++++-- .../components/MeasurementChartFilter.kt | 10 ++++-- .../ui/screen/insights/InsightsScreen.kt | 8 ++++- .../openscale/ui/shared/SharedViewModel.kt | 33 ++++++++++++++----- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt index faedab55a..061f74ed3 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt @@ -208,13 +208,32 @@ class MeasurementFacade @Inject constructor( * @param primaryTypeId Optional explicit primary type ID for weekday and seasonal * pattern computation. If null, the use case selects the type * with the most measurements automatically. + * @param startMillis Inclusive start of the time range; `null` = unbounded. + * @param endMillis Inclusive end of the time range; `null` = unbounded. Same + * semantics as the pipeline's time filter, so a range picked in + * the filter menu narrows the insights the same way it narrows a + * chart. */ - fun insightsForUser(userId: Int, primaryTypeId: Int? = null): Flow = + fun insightsForUser( + userId: Int, + primaryTypeId: Int? = null, + startMillis: Long? = null, + endMillis: Long? = null, + ): Flow = getMeasurementsForUser(userId) .distinctUntilChanged() .map { measurements -> withContext(Dispatchers.Default) { - insights.compute(measurements, primaryTypeId) + val inRange = if (startMillis == null && endMillis == null) { + measurements + } else { + measurements.filter { mwv -> + val ts = mwv.measurement.timestamp + (startMillis == null || ts >= startMillis) && + (endMillis == null || ts <= endMillis) + } + } + insights.compute(inRange, primaryTypeId) } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt index 80a7b7973..eeb9fe658 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/components/MeasurementChartFilter.kt @@ -380,8 +380,14 @@ fun provideFilterTopBarAction( } } - // --- Section 3: Type filter row toggle (not for Statistics) --- - if (screenContextName != SettingsPreferenceKeys.STATISTICS_SCREEN_CONTEXT) { + // --- Section 3: Type filter row toggle (not for Statistics and Insights) --- + // Insights renders its row unconditionally — it is the only way to pick the type the + // whole screen is about — so the toggle would be a dead entry there. + if (screenContextName !in listOf( + SettingsPreferenceKeys.STATISTICS_SCREEN_CONTEXT, + SettingsPreferenceKeys.INSIGHTS_SCREEN_CONTEXT, + ) + ) { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) DropdownMenuItem( text = { Text(stringResource(R.string.menu_item_measurement_filter)) }, diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/insights/InsightsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/insights/InsightsScreen.kt index e9342ee35..32f509362 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/insights/InsightsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/insights/InsightsScreen.kt @@ -120,6 +120,7 @@ import com.health.openscale.core.utils.LocaleUtils import com.health.openscale.ui.navigation.Routes import com.health.openscale.ui.screen.components.MeasurementTypeFilterRow import com.health.openscale.ui.screen.components.SELECTED_TYPES_SUFFIX +import com.health.openscale.ui.screen.components.provideFilterTopBarAction import com.health.openscale.ui.screen.components.rememberAddMeasurementActionButton import com.health.openscale.ui.screen.components.rememberBluetoothActionButton import com.health.openscale.ui.screen.components.rememberContextualSelectedTypeIds @@ -176,11 +177,16 @@ fun InsightsScreen( val bluetoothAction = rememberBluetoothActionButton(bluetoothViewModel, sharedViewModel, navController) val addMeasurementAction = rememberAddMeasurementActionButton(sharedViewModel, navController) + val filterAction = provideFilterTopBarAction( + sharedViewModel = sharedViewModel, + screenContextName = INSIGHTS_SCREEN_CONTEXT, + ) val title = stringResource(R.string.route_title_insights) + // Use Unit as key — filterAction is recreated on every recomposition LaunchedEffect(Unit) { sharedViewModel.setTopBarTitle(title) - sharedViewModel.setTopBarActions(listOfNotNull(bluetoothAction, addMeasurementAction)) + sharedViewModel.setTopBarActions(listOfNotNull(bluetoothAction, addMeasurementAction, filterAction)) } Column(modifier = Modifier.fillMaxSize()) { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt index 9736f9577..4c6ddb035 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt @@ -369,12 +369,15 @@ class SharedViewModel @Inject constructor( buildScreenFlow(screenContextName, useSmoothing) } - private fun buildScreenFlow( - screenContextName: String, - useSmoothing: Boolean, - ): StateFlow>> { - // Combine the three persisted settings into a single resolved time-range pair - val timeRangeFlow: Flow> = filterContext(screenContextName, TIME_RANGE_SUFFIX) + /** + * The persisted time range of [screenContextName], resolved into the inclusive epoch-millisecond + * bounds the data layer filters on (`null` on a side = unbounded). + * + * Shared by every screen that carries the filter menu, so the range a screen shows and the range + * it computes on cannot drift apart. + */ + private fun timeRangeBoundsFlow(screenContextName: String): Flow> = + filterContext(screenContextName, TIME_RANGE_SUFFIX) .flatMapLatest { context -> combine( observeSetting("${context}${TIME_RANGE_SUFFIX}", TimeRangeFilter.ALL_DAYS.name), @@ -387,6 +390,12 @@ class SharedViewModel @Inject constructor( } } + private fun buildScreenFlow( + screenContextName: String, + useSmoothing: Boolean, + ): StateFlow>> { + val timeRangeFlow: Flow> = timeRangeBoundsFlow(screenContextName) + val aggregationFlow: Flow = observeAggregationLevel(screenContextName) return _isInitialUserLoadComplete @@ -468,11 +477,17 @@ class SharedViewModel @Inject constructor( private val insightsFlowCache = mutableMapOf>>() fun insightsFlow(primaryTypeId: Int?): StateFlow> = + // The cache key stays the type alone: the flow reads the time range from settings and + // re-emits on its own when the filter menu changes it. insightsFlowCache.getOrPut(primaryTypeId) { - selectedUserId - .flatMapLatest { uid -> + combine( + selectedUserId, + timeRangeBoundsFlow(SettingsPreferenceKeys.INSIGHTS_SCREEN_CONTEXT), + ) { uid, timeRange -> uid to timeRange } + .flatMapLatest { (uid, timeRange) -> if (uid == null) return@flatMapLatest flowOf(UiState.Loading) - measurementFacade.insightsForUser(uid, primaryTypeId) + val (startMs, endMs) = timeRange + measurementFacade.insightsForUser(uid, primaryTypeId, startMs, endMs) .map> { UiState.Success(it) } .onStart { emit(UiState.Loading) } .catch { emit(UiState.Error(it.message)) } From 20b7dc8074cf0ec0b52b78b4354ce7b6b368c39d Mon Sep 17 00:00:00 2001 From: Fhatuwani Sikhwari Date: Sat, 5 Sep 2026 15:41:52 +0200 Subject: [PATCH 69/72] Fix misleading plateau and volatility insights (#1510) Signed-off-by: Fhatuwani Sikhwari --- .../usecase/MeasurementInsightsUseCase.kt | 47 ++++++- .../ui/screen/insights/InsightsScreen.kt | 14 +- .../usecase/MeasurementInsightsUseCaseTest.kt | 131 ++++++++++++++++++ .../ui/screen/insights/InsightsScreenTest.kt | 75 ++++++++++ 4 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 android_app/app/src/test/java/com/health/openscale/ui/screen/insights/InsightsScreenTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementInsightsUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementInsightsUseCase.kt index 32f44b18a..3b75b8a4b 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementInsightsUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementInsightsUseCase.kt @@ -54,6 +54,8 @@ class MeasurementInsightsUseCase @Inject constructor() { private const val SHORT_TERM_TREND_DAYS = 30L private const val VOLATILITY_STABLE_THRESHOLD = 0.01f private const val VOLATILITY_MODERATE_THRESHOLD = 0.03f + private const val PLATEAU_TOTAL_CHANGE_THRESHOLD = 0.0025f + private const val MIN_RELATIVE_DENOMINATOR = 1e-6f const val CORRELATION_WINDOW_DAYS = 90L const val CORRELATION_MIN_MEASUREMENTS = 4 const val PATTERN_HISTORY_WINDOWS = 5 @@ -138,11 +140,11 @@ class MeasurementInsightsUseCase @Inject constructor() { val minPoint = dataPoints.minBy { it.second } val maxPoint = dataPoints.maxBy { it.second } - // Volatility via standard deviation relative to the mean + // Volatility via detrended residual standard deviation relative to the mean val rawValues = dataPoints.map { it.second } val mean = rawValues.average().toFloat() - val stdDev = stdDev(rawValues, mean) - val relStdDev = if (mean != 0f) stdDev / mean else 0f + val stdDev = detrendedStdDev(dataPoints) + val relStdDev = if (abs(mean) > MIN_RELATIVE_DENOMINATOR) stdDev / abs(mean) else 0f val volatility = when { relStdDev < VOLATILITY_STABLE_THRESHOLD -> Volatility.STABLE relStdDev < VOLATILITY_MODERATE_THRESHOLD -> Volatility.MODERATE @@ -174,14 +176,18 @@ class MeasurementInsightsUseCase @Inject constructor() { ) / 30.44f val ratePerMonth = if (totalMonths > 0f) deltaAbs / totalMonths else 0f - // Plateau detection at the tail of the series using half the stdDev as threshold + // Plateau detection at the tail of the series using total change within the window. val (plateauDays, plateauStartDate) = run { if (dataPoints.size < 3) return@run null to null - val threshold = stdDev * 0.5f + val threshold = abs(mean) * PLATEAU_TOTAL_CHANGE_THRESHOLD if (threshold < 1e-6f) return@run null to null var plateauStartIndex = dataPoints.size - 1 + var windowMin = dataPoints.last().second + var windowMax = dataPoints.last().second for (i in dataPoints.indices.reversed().drop(1)) { - if (abs(dataPoints[i + 1].second - dataPoints[i].second) > threshold) break + windowMin = minOf(windowMin, dataPoints[i].second) + windowMax = maxOf(windowMax, dataPoints[i].second) + if (windowMax - windowMin > threshold) break plateauStartIndex = i } if (plateauStartIndex == dataPoints.size - 1) return@run null to null @@ -553,4 +559,31 @@ class MeasurementInsightsUseCase @Inject constructor() { val variance = values.sumOf { ((it - mean) * (it - mean)).toDouble() } / values.size return sqrt(variance).toFloat() } -} \ No newline at end of file + + private fun detrendedStdDev(dataPoints: List>): Float { + if (dataPoints.size < 2) return 0f + + val firstDate = dataPoints.first().first + val xs = dataPoints.map { + ChronoUnit.DAYS.between(firstDate, it.first).toFloat() + } + val xMean = xs.average().toFloat() + val yMean = dataPoints.map { it.second }.average().toFloat() + val denominator = xs.sumOf { ((it - xMean) * (it - xMean)).toDouble() }.toFloat() + + if (denominator < MIN_RELATIVE_DENOMINATOR) { + return stdDev(dataPoints.map { it.second }, yMean) + } + + val numerator = dataPoints.indices.sumOf { i -> + ((xs[i] - xMean) * (dataPoints[i].second - yMean)).toDouble() + }.toFloat() + val slope = numerator / denominator + val intercept = yMean - slope * xMean + val residuals = dataPoints.indices.map { i -> + dataPoints[i].second - (intercept + slope * xs[i]) + } + val residualMean = residuals.average().toFloat() + return stdDev(residuals, residualMean) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/insights/InsightsScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/insights/InsightsScreen.kt index 32f509362..0e0038b9c 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/insights/InsightsScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/insights/InsightsScreen.kt @@ -442,10 +442,8 @@ private fun MeasurementAnalysisCard(analysis: MeasurementAnalysis) { // ── Summary ─────────────────────────────────────────────────── val summary: String? = when { - analysis.plateauDays != null && - analysis.plateauDays > 14 && - java.time.temporal.ChronoUnit.DAYS.between(analysis.lastMeasuredOn, LocalDate.now()) <= 7 -> - stringResource(R.string.insights_summary_plateau, analysis.plateauDays) + analysis.shouldShowPlateauSummary() -> + stringResource(R.string.insights_summary_plateau, analysis.plateauDays ?: 0) analysis.shortTermTrend != analysis.longTermTrend && analysis.longTermTrend != TrendDirection.STABLE -> when (analysis.shortTermTrend) { TrendDirection.DOWN -> stringResource(R.string.insights_summary_trend_change_down) @@ -466,6 +464,12 @@ private fun MeasurementAnalysisCard(analysis: MeasurementAnalysis) { } } +internal fun MeasurementAnalysis.shouldShowPlateauSummary(today: LocalDate = LocalDate.now()): Boolean = + plateauDays != null && + plateauDays > 14 && + longTermTrend == TrendDirection.STABLE && + java.time.temporal.ChronoUnit.DAYS.between(lastMeasuredOn, today) <= 7 + // --------------------------------------------------------------------------- // ShiftStatTile // --------------------------------------------------------------------------- @@ -1332,4 +1336,4 @@ private fun TrendDirection.toSentiment(): InsightChipSentiment = when (this) { TrendDirection.DOWN -> InsightChipSentiment.GOOD TrendDirection.UP -> InsightChipSentiment.BAD TrendDirection.STABLE -> InsightChipSentiment.NEUTRAL -} \ No newline at end of file +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementInsightsUseCaseTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementInsightsUseCaseTest.kt index 572e673ea..7e00a5ab6 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementInsightsUseCaseTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementInsightsUseCaseTest.kt @@ -21,7 +21,10 @@ import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.model.TrendDirection +import com.health.openscale.core.model.Volatility import com.health.openscale.testutil.Fixtures +import java.time.LocalDate +import java.time.ZoneId import org.junit.Test /** @@ -44,6 +47,25 @@ class MeasurementInsightsUseCaseTest { private fun risingSeries(): List = listOf(m(1, 70f), m(2, 71f), m(3, 72f), m(4, 73f), m(5, 74f), m(6, 75f)) + private fun m(date: LocalDate, measurementId: Int, value: Float): MeasurementWithValues = + Fixtures.mwv( + measurementId = measurementId, + timestamp = date.atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), + values = listOf(Fixtures.valueWithType(weight, value, measurementId)), + ) + + private fun dailySeries( + start: LocalDate, + count: Int, + valueForDay: (Int) -> Float, + ): List = + (0 until count).map { day -> + m(start.plusDays(day.toLong()), day + 1, valueForDay(day)) + } + + private fun analysisFor(measurements: List) = + useCase.compute(measurements, primaryTypeId = weight.id).measurementAnalysis!! + @Test fun compute_emptyInput_returnsEmptyInsight() { val insight = useCase.compute(emptyList(), primaryTypeId = weight.id) @@ -95,4 +117,113 @@ class MeasurementInsightsUseCaseTest { assertThat(insight.basedOnCount).isEqualTo(series.size) assertThat(insight.measurementAnalysis).isNotNull() } + + @Test + fun compute_smoothStrongDownwardTrend_doesNotReportLongPlateauOrHighVolatility() { + val start = LocalDate.of(2025, 1, 1) + val measurements = dailySeries(start, count = 181) { day -> + 100f - 22f * day / 180f + } + + val analysis = analysisFor(measurements) + + assertThat(analysis.longTermTrend).isEqualTo(TrendDirection.DOWN) + assertThat(analysis.plateauDays ?: 0).isLessThan(14) + assertThat(analysis.volatility).isEqualTo(Volatility.STABLE) + } + + @Test + fun compute_smoothStrongUpwardTrend_doesNotReportHighVolatilityFromTrendSpan() { + val start = LocalDate.of(2025, 1, 1) + val measurements = dailySeries(start, count = 181) { day -> + 78f + 22f * day / 180f + } + + val analysis = analysisFor(measurements) + + assertThat(analysis.longTermTrend).isEqualTo(TrendDirection.UP) + assertThat(analysis.plateauDays ?: 0).isLessThan(14) + assertThat(analysis.volatility).isEqualTo(Volatility.STABLE) + } + + @Test + fun compute_stableSeries_stillDetectsPlateau() { + val start = LocalDate.of(2025, 2, 1) + val measurements = dailySeries(start, count = 30) { day -> + if (day % 2 == 0) 80f else 80.05f + } + + val analysis = analysisFor(measurements) + + assertThat(analysis.longTermTrend).isEqualTo(TrendDirection.STABLE) + assertThat(analysis.plateauDays).isAtLeast(28) + assertThat(analysis.plateauStartDate).isEqualTo(start) + } + + @Test + fun compute_flatNoisySeries_reportsHighVolatility() { + val start = LocalDate.of(2025, 3, 1) + val measurements = dailySeries(start, count = 40) { day -> + if (day % 2 == 0) 77f else 83f + } + + val analysis = analysisFor(measurements) + + assertThat(analysis.longTermTrend).isEqualTo(TrendDirection.STABLE) + assertThat(analysis.volatility).isEqualTo(Volatility.HIGH) + } + + @Test + fun compute_trendingNoisySeries_reportsResidualVolatilityFromScatter() { + val start = LocalDate.of(2025, 3, 1) + val measurements = dailySeries(start, count = 60) { day -> + 100f - 0.1f * day + if (day % 2 == 0) -1.5f else 1.5f + } + + val analysis = analysisFor(measurements) + + assertThat(analysis.longTermTrend).isEqualTo(TrendDirection.DOWN) + assertThat(analysis.volatility).isEqualTo(Volatility.MODERATE) + } + + @Test + fun compute_slowPersistentDrift_doesNotBecomeLongPlateau() { + val measurements = dailySeries(LocalDate.of(2025, 4, 1), count = 60) { day -> + 80f - 0.02f * day + } + + val analysis = analysisFor(measurements) + + assertThat(analysis.longTermTrend).isEqualTo(TrendDirection.DOWN) + assertThat(analysis.plateauDays ?: 0).isLessThan(14) + } + + @Test + fun compute_plateauWindowUsesTotalChangeThresholdBoundary() { + val start = LocalDate.of(2025, 5, 1) + val withinThreshold = dailySeries(start, count = 20) { day -> + if (day == 0) 100f else 100.24f + } + val outsideThreshold = dailySeries(start, count = 20) { day -> + if (day == 0) 99.7f else 100f + } + + val inside = analysisFor(withinThreshold) + val outside = analysisFor(outsideThreshold) + + assertThat(inside.plateauStartDate).isEqualTo(start) + assertThat(outside.plateauStartDate).isEqualTo(start.plusDays(1)) + } + + @Test + fun compute_shortDatasetReturnsEmptyAnalysis() { + val measurements = dailySeries(LocalDate.of(2025, 6, 1), count = 2) { day -> + 80f + day + } + + val insight = useCase.compute(measurements, primaryTypeId = weight.id) + + assertThat(insight.measurementAnalysis).isNull() + assertThat(insight.basedOnCount).isEqualTo(2) + } } diff --git a/android_app/app/src/test/java/com/health/openscale/ui/screen/insights/InsightsScreenTest.kt b/android_app/app/src/test/java/com/health/openscale/ui/screen/insights/InsightsScreenTest.kt new file mode 100644 index 000000000..ca958153b --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/ui/screen/insights/InsightsScreenTest.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.ui.screen.insights + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.model.InsightConfidence +import com.health.openscale.core.model.MeasurementAnalysis +import com.health.openscale.core.model.TrendDirection +import com.health.openscale.core.model.Volatility +import com.health.openscale.testutil.Fixtures +import java.time.LocalDate +import org.junit.Test + +class InsightsScreenTest { + + private val weight = Fixtures.type(id = 1, identity = MeasurementType.WEIGHT.identity) + + private fun analysis( + longTermTrend: TrendDirection, + plateauDays: Int? = 21, + lastMeasuredOn: LocalDate = LocalDate.of(2025, 6, 30), + ) = MeasurementAnalysis( + type = weight, + firstValue = 80f, + lastValue = 80f, + deltaAbsolute = 0f, + deltaPercent = 0f, + minValue = 80f, + minValueDate = lastMeasuredOn.minusDays(plateauDays?.toLong() ?: 0L), + maxValue = 80f, + maxValueDate = lastMeasuredOn, + volatility = Volatility.STABLE, + shortTermTrend = TrendDirection.STABLE, + longTermTrend = longTermTrend, + ratePerMonth = 0f, + plateauDays = plateauDays, + plateauStartDate = plateauDays?.let { lastMeasuredOn.minusDays(it.toLong()) }, + bestPeriodStart = null, + bestPeriodDelta = null, + firstMeasuredOn = lastMeasuredOn.minusDays(plateauDays?.toLong() ?: 0L), + lastMeasuredOn = lastMeasuredOn, + confidence = InsightConfidence.HIGH, + ) + + @Test + fun shouldShowPlateauSummary_suppressesWhenLongTermTrendIsNotStable() { + val today = LocalDate.of(2025, 7, 1) + + assertThat(analysis(TrendDirection.DOWN).shouldShowPlateauSummary(today)).isFalse() + assertThat(analysis(TrendDirection.UP).shouldShowPlateauSummary(today)).isFalse() + } + + @Test + fun shouldShowPlateauSummary_allowsRecentStablePlateau() { + val today = LocalDate.of(2025, 7, 1) + + assertThat(analysis(TrendDirection.STABLE).shouldShowPlateauSummary(today)).isTrue() + } +} From fa8f768309b3480d2a5368f40ea0e39749d929f4 Mon Sep 17 00:00:00 2001 From: dnsfdv Date: Sun, 6 Sep 2026 11:24:12 +0500 Subject: [PATCH 70/72] Add support for PICOOC Mini Lite (PICOOC-L broadcast scale) (#1512) * feat: Add support for PICOOC Mini Lite broadcast scale Non-connectable PICOOC-L advertisements carry weight and impedance; body composition is a clean-room port of the vendor's 4.3.0 White/Caucasian BIA path, with an online learner for the vendor's anchor_weight/anchor_beta profile state. Plumbs the existing useAssistedWeighing flag (9436dcfe) into ScaleUser so the handler can skip composition while assisted weighing is active. * Wire up metabolic age across handlers, trim PICOOC PR --------- Co-authored-by: dfadeev <1289810+dnsfdv@users.noreply.github.com> Co-authored-by: oliexdev --- .../openscale/core/bluetooth/ScaleFactory.kt | 3 + .../core/bluetooth/libs/OkOkV2Lib.kt | 5 +- .../libs/PicoocWhiteBodyComposition.kt | 639 ++++++++++++++++++ .../bluetooth/libs/Wla25BodyComposition.kt | 9 +- .../core/bluetooth/scales/HesleyHandler.kt | 15 +- .../bluetooth/scales/HuaweiCH100SHandler.kt | 14 + .../core/bluetooth/scales/KeepS3Handler.kt | 26 +- .../core/bluetooth/scales/OkOkHandler.kt | 15 + .../core/bluetooth/scales/OmronWlcHandler.kt | 12 + .../scales/PicoocBroadcastHandler.kt | 445 ++++++++++++ .../core/bluetooth/scales/PicoocHandler.kt | 16 +- .../bluetooth/scales/RelaxmedicHandler.kt | 19 + .../app/src/main/res/values/strings.xml | 8 + .../openscale/core/bluetooth/ScaleCatalog.kt | 2 + .../core/bluetooth/ScaleFactoryTest.kt | 23 + .../bluetooth/libs/PicoocAnchorLearnerTest.kt | 69 ++ .../libs/PicoocWhiteBodyCompositionTest.kt | 109 +++ .../scales/PicoocBroadcastHandlerTest.kt | 185 +++++ .../test/resources/scale_catalog_remarks.txt | 1 + 19 files changed, 1600 insertions(+), 15 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyComposition.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocAnchorLearnerTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyCompositionTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index b36a22ab2..347d020ce 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -64,6 +64,7 @@ import com.health.openscale.core.bluetooth.scales.BodyConnectHandler import com.health.openscale.core.bluetooth.scales.OkOkHandler import com.health.openscale.core.bluetooth.scales.OmronWlcHandler import com.health.openscale.core.bluetooth.scales.PicoocHandler +import com.health.openscale.core.bluetooth.scales.PicoocBroadcastHandler import com.health.openscale.core.bluetooth.scales.OneByoneHandler import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler import com.health.openscale.core.bluetooth.scales.QNHandler @@ -138,6 +139,8 @@ class ScaleFactory @Inject constructor( AndUC352BLEHandler(), HealthKeep280Handler(), PicoocHandler(), + // Exact PICOOC-L match must precede ScaleupHandler's generic 0xD0 manufacturer match. + PicoocBroadcastHandler(), ActiveEraBF06Handler(), AfuB1Handler(), KeepS3Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt index 6ec5125b2..db115800f 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OkOkV2Lib.kt @@ -128,7 +128,10 @@ class OkOkV2Lib( val bodyAge = if (sex == MALE) (height * -0.7471f) + (weight * 0.9161f) + (age * 0.4184f) + (impedance * 0.0517f) + 54.2267f else (height * -1.1165f) + (weight * 1.5784f) + (age * 0.4615f) + (impedance * 0.0415f) + 83.2548f - return min(18, max(bodyAge.toInt(), 80)) + // The vendor's own clamp reads min(18, max(age, 80)), which can only ever return 18. + // The value was never published, so the slip stayed invisible; keep the intended + // 18..80 window now that it is. + return bodyAge.toInt().coerceIn(18, 80) } private fun getIdealWeight(): Float = diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyComposition.kt new file mode 100644 index 000000000..305da59b7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyComposition.kt @@ -0,0 +1,639 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.bluetooth.libs + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Kotlin implementation of PICOOC 4.3.0's reverse-engineered Caucasian/White BIA calculations. + * + * The vendor names the two pieces of persistent profile state `anchor_weight` and + * `anchor_bata` (sic). They are not scale calibration constants: the app derives them from a + * group of nearby historical weigh-ins. Keeping them explicit makes the calculation testable + * and lets a handler persist them without shipping PICOOC's proprietary native library. + */ +internal object PicoocWhiteBodyComposition { + data class Input( + val male: Boolean, + val heightCm: Float, + val age: Int, + val weightKg: Float, + val correctedImpedanceOhm: Int, + val anchorWeightKg: Int, + val anchorBeta: Int, + val hour: Int = 0, + val previousMeasurementAnchor: Int = 0, + ) + + data class Result( + val bodyFatPercent: Float, + /** PICOOC's broad muscle metric: lean body mass minus bone mass, as % of weight. */ + val totalMusclePercent: Float, + val boneMassKg: Float, + val waterPercent: Float, + val basalMetabolicRateKcal: Int, + val proteinPercent: Float, + val bmi: Float, + val visceralFatLevel: Int, + val leanBodyMassKg: Float, + val metabolicAge: Int, + val skeletalMusclePercent: Float, + val fatReferencePercent: Float, + val anchorBeta: Int, + val measurementAnchor: Int, + ) + + fun calculate(input: Input): Result? { + val h = if (input.heightCm < 3f) input.heightCm * 100f else input.heightCm + val w = input.weightKg + val r = input.correctedImpedanceOhm + if (h <= 0f || w <= 0f || r < 50 || input.age < 16 || input.anchorWeightKg <= 0) { + return null + } + + val sex = if (input.male) 1 else 0 + val reference = fatReference(h, w, sex, input.age, r, input.anchorWeightKg) + val beta = if (input.anchorBeta >= 19) { + input.anchorBeta + } else { + initialAnchorBeta( + sex = sex, + age = input.age, + bmiTimesTen = (w / square(h / 100f) * 10f).roundToInt(), + referenceFatTimesTen = (reference * 10f).roundToInt(), + impedance = r, + hour = input.hour, + ) + } + + val initialCoefficient = if (input.male) 0.495f else 0.515f + var fat = fatFormula(h, w, initialCoefficient, sex, input.age, r, input.anchorWeightKg, beta) + var anchorPart = 0 + + // PICOOC has a small continuity correction only at exceptionally low fat values. The + // previous measurement stores beta*10 + this one-digit band number. + val previousPart = if (input.previousMeasurementAnchor > 100) { + input.previousMeasurementAnchor % 10 + } else { + 0 + } + val thresholds = if (input.male) { + shiftedThresholds(floatArrayOf(5f, 6f, 7f, 8f, 9f), previousPart) + } else { + shiftedThresholds(floatArrayOf(6f, 7f, 8f, 9f, 10f, 11f), previousPart) + } + val coefficients = if (input.male) { + floatArrayOf(0.435f, 0.445f, 0.455f, 0.465f, 0.475f) + } else { + floatArrayOf(0.475f, 0.4825f, 0.49f, 0.495f, 0.5f, 0.505f) + } + for (index in thresholds.indices) { + if (fat <= thresholds[index]) { + anchorPart = index + 1 + fat = fatFormula(h, w, coefficients[index], sex, input.age, r, input.anchorWeightKg, beta) + break + } + } + + val lean = w * (1f - fat / 100f) + val water = water(h, w, fat, sex, input.age, r) + val bone = if (input.male) lean * 0.05f + w * 0.00237f else lean * 0.0868f - w * 0.02085f + val totalMuscle = 100f - fat - bone / w * 100f + val protein = 100f - fat - water - bone / w * 100f + val bmr = bmr(h, w, lean, fat, sex, input.age) + val bmi = w / square(h / 100f) + val visceral = visceralFat(h, w, input.age, r) + val rawBodyAge = rawBodyAge(w, h, fat, sex, input.age) + val metabolicAge = adjustedBodyAge(input.age, sex, bmr, w, rawBodyAge) + val skeletal = skeletalMuscle(h, w, sex, input.age, r) + + return Result( + bodyFatPercent = fat, + totalMusclePercent = totalMuscle, + boneMassKg = bone, + waterPercent = water, + basalMetabolicRateKcal = bmr, + proteinPercent = protein, + bmi = bmi, + visceralFatLevel = visceral, + leanBodyMassKg = lean, + metabolicAge = metabolicAge, + skeletalMusclePercent = skeletal, + fatReferencePercent = reference, + anchorBeta = beta, + measurementAnchor = beta * 10 + anchorPart, + ) + } + + /** Vendor impedance stabilisation: reuse a recent correction, otherwise round to 10 ohms. */ + fun correctedImpedance( + rawOhm: Int, + weightKg: Float, + timestampMs: Long, + previousRawOhm: Int?, + previousWeightKg: Float?, + previousTimestampMs: Long?, + previousCorrectedOhm: Int?, + ): Int { + val canReuse = previousRawOhm != null && previousWeightKg != null && + previousTimestampMs != null && timestampMs - previousTimestampMs in 0L..600_000L && + abs(weightKg - previousWeightKg) <= 1f && abs(rawOhm - previousRawOhm) < 10 + if (canReuse && previousCorrectedOhm != null && previousCorrectedOhm > 0) { + return previousCorrectedOhm + } + val source = if (canReuse) previousRawOhm else rawOhm + return ((source / 10.0) + 0.5).toInt() * 10 + } + + /** Vendor anchor bucket: keep it over [-1,+2) kg, otherwise reset to truncated weight. */ + fun anchorWeight(weightKg: Float, previousAnchorWeightKg: Int?): Int { + val previous = previousAnchorWeightKg?.takeIf { it > 0 } ?: return weightKg.toInt() + val tenths = (weightKg * 10f).roundToInt() + return if (tenths >= (previous + 2) * 10 || tenths < (previous - 1) * 10) { + weightKg.toInt() + } else { + previous + } + } + + private fun fatReference(h: Float, w: Float, sex: Int, age: Int, r: Int, anchorWeight: Int): Float { + fun youngerLean(): Double { + val intercept = if (sex == 1) 9.28 else 10.985 + val offset = if (sex == 1) 0.7335 else 0.7785 + val coefficient = if (sex == 1) 0.495 else 0.515 + val fatRatio = (h * -0.0767 + intercept + age * 0.0635) / anchorWeight + offset - + coefficient * h * h / (r * w) + r * 0.00001732 - r * 0.01 / w + return w * (1.0 - fatRatio) + } + + val leanKg = when { + age < 51 -> youngerLean() + else -> { + val olderLean = if (sex == 1) { + h * 0.499 * h / r + 15.229 + w * 0.134 + } else { + h * 0.45954 * h / r - 2.66775 + w * 0.204 + h * 0.05113 + + r * 0.00667 - age * 0.04233 + } + if (age < 61) (60 - age) * youngerLean() / 10.0 + (age - 50) * olderLean / 10.0 else olderLean + } + } + return boundedFat(((w - leanKg) / w * 100.0).toFloat(), sex) + } + + private fun fatFormula( + h: Float, + w: Float, + coefficient: Float, + sex: Int, + age: Int, + r: Int, + anchorWeight: Int, + beta: Int, + ): Float { + val intercept = if (sex == 1) 9.28 else 10.985 + val offset = if (sex == 1) 0.7335 else 0.7785 + val ratio = (h * -0.0767 + intercept + age * 0.0635) / anchorWeight + offset - + coefficient * h * h / (r * w) + r * 0.00001732 - beta / 100_000_000.0 * r * r + return boundedFat((ratio * 100.0).toFloat(), sex) + } + + private fun boundedFat(value: Float, sex: Int): Float = if (sex == 1) { + value.coerceIn(5f, 62.2f) + } else { + value.coerceIn(5f, 66.7f) + } + + private fun initialAnchorBeta( + sex: Int, + age: Int, + bmiTimesTen: Int, + referenceFatTimesTen: Int, + impedance: Int, + hour: Int, + ): Int { + val lowBmi = bmiTimesTen < 250 + val lowReferenceFat = referenceFatTimesTen < 290 + val daytime = hour in 4..11 + val band = if (lowBmi) { + when { + impedance < 500 -> 0 + impedance < 550 -> 1 + impedance < 600 -> 2 + impedance < 650 -> 3 + impedance < 700 -> 4 + else -> 5 + } + } else { + when { + impedance < 400 -> 0 + impedance < 500 -> 1 + impedance < 550 -> 2 + impedance < 600 -> 3 + impedance < 650 -> 4 + impedance < 700 -> 5 + else -> 6 + } + } + + val values = when { + age < 51 && sex == 1 && lowReferenceFat && lowBmi -> + pair(intArrayOf(35, 33, 31, 30, 29, 28), intArrayOf(35, 35, 33, 31, 30, 29)) + age < 51 && sex == 1 && lowReferenceFat -> + pair(intArrayOf(35, 34, 32, 30, 29, 28, 27), intArrayOf(35, 35, 34, 32, 30, 29, 28)) + age < 51 && sex == 1 && lowBmi -> + pair(intArrayOf(25, 24, 23, 22, 21, 20), intArrayOf(25, 25, 24, 23, 22, 21)) + age < 51 && sex == 1 -> + pair(intArrayOf(25, 24, 23, 22, 21, 20, 19), intArrayOf(25, 25, 24, 23, 22, 21, 20)) + age < 51 && lowReferenceFat && lowBmi -> + pair(intArrayOf(38, 36, 35, 34, 33, 32), intArrayOf(38, 38, 36, 35, 34, 33)) + age < 51 && lowReferenceFat -> + pair(intArrayOf(38, 37, 35, 34, 33, 32, 31), intArrayOf(38, 38, 37, 35, 34, 33, 32)) + age < 51 && lowBmi -> + pair(intArrayOf(31, 30, 29, 28, 27, 26), intArrayOf(31, 31, 30, 29, 28, 27)) + age < 51 -> + pair(intArrayOf(31, 30, 29, 28, 27, 26, 25), intArrayOf(31, 31, 30, 29, 28, 27, 26)) + sex == 1 && lowReferenceFat && lowBmi -> + pair(intArrayOf(36, 35, 34, 33, 31, 29), intArrayOf(36, 36, 35, 34, 33, 31)) + sex == 1 && lowReferenceFat -> + pair(intArrayOf(36, 35, 34, 33, 32, 30, 28), intArrayOf(36, 36, 35, 34, 33, 32, 30)) + sex == 1 && lowBmi -> + pair(intArrayOf(25, 24, 23, 22, 21, 20), intArrayOf(25, 25, 24, 23, 22, 21)) + sex == 1 -> + pair(intArrayOf(25, 24, 23, 22, 21, 20, 19), intArrayOf(25, 25, 24, 23, 22, 21, 20)) + lowReferenceFat && lowBmi -> + pair(intArrayOf(41, 39, 37, 35, 34, 33), intArrayOf(41, 41, 39, 37, 35, 34)) + lowReferenceFat -> + pair(intArrayOf(41, 38, 37, 36, 34, 33, 32), intArrayOf(41, 41, 38, 37, 36, 34, 33)) + lowBmi -> + pair(intArrayOf(30, 29, 28, 27, 26, 25), intArrayOf(30, 30, 29, 28, 27, 26)) + else -> + pair(intArrayOf(30, 29, 28, 27, 26, 25, 24), intArrayOf(30, 30, 29, 28, 27, 26, 25)) + } + return values[if (daytime) 1 else 0][band] + } + + private fun pair(normal: IntArray, daytime: IntArray): Array = arrayOf(normal, daytime) + + private fun shiftedThresholds(base: FloatArray, previousPart: Int): FloatArray = + base.copyOf().also { values -> + if (previousPart in 1..values.size) values[previousPart - 1] += 1f + } + + private fun water(h: Float, w: Float, fat: Float, sex: Int, age: Int, r: Int): Float { + val value = ((h * 0.3674 * h / r + 6.53 + w * 0.17531 - age * 0.11 + sex * 2.83) / w * 100.0).toFloat() + return value.coerceAtMost(if (sex == 1) 73.8f else 72.8f) + } + + private fun bmr(h: Float, w: Float, lean: Float, fat: Float, sex: Int, age: Int): Int { + val equationOne = if (sex == 1) { + when { + age < 4 -> w * 28.2 + h * 8.59 - 371.0 + age < 11 -> w * 15.1 + h * 3.13 + 306.0 + age < 18 -> w * 15.6 + h * 2.66 + 299.0 + age < 31 -> w * 14.4 + h * 3.13 + 113.0 + age < 61 -> w * 11.4 + h * 5.41 - 137.0 + else -> w * 11.4 + h * 5.41 - 256.0 + } + } else { + when { + age < 4 -> w * 30.4 + h * 7.03 - 287.0 + age < 11 -> w * 15.9 + h * 2.1 + 349.0 + age < 18 -> w * 9.4 + h * 2.49 + 462.0 + age < 31 -> w * 10.4 + h * 6.15 - 282.0 + age < 61 -> w * 8.18 + h * 5.02 - 11.6 + else -> w * 8.52 + h * 4.21 + 10.7 + } + } + val fatKg = w * fat / 100f + val bmi = w / square(h / 100f) + val equationTwo = when { + bmi <= 18.5f -> (lean * 0.08961 + fatKg * 0.05662 + 0.667) * 1000 / 4.184 + bmi <= 25f -> (lean * 0.0455 + fatKg * 0.0278 - age * 0.01291 + + if (sex == 1) 4.513 else 3.634) * 1000 / 4.184 + bmi <= 30f -> (lean * 0.03776 + fatKg * 0.03013 - age * 0.01196 + + if (sex == 1) 4.858 else 3.928) * 1000 / 4.814 + else -> (lean * 0.05685 + fatKg * 0.04022 - age * 0.01402 + + if (sex == 1) 3.626 else 2.818) * 1000 / 4.814 + } + val averaged = ((equationOne + equationTwo) / 2.0).roundToInt() + val leanFloor = (lean * 21.6 + 370.0).toInt() + return maxOf(averaged, leanFloor) + } + + private fun visceralFat(h: Float, w: Float, age: Int, r: Int): Int { + // The mangled signature is (int, float, float, int): age is in x0 and resistance in + // x1. Ghidra lists the float registers first, which can misleadingly resemble sex/age. + val raw = ((r * 31f + w * 1_000_000f * 10f / (h * 100f * h) * 940f + + age * 1049f - 210_772f) / 1000f).toInt().coerceIn(0, 0xffff) + return (raw / 10 + 1).coerceIn(1, 30) + } + + private fun skeletalMuscle(h: Float, w: Float, sex: Int, age: Int, r: Int): Float { + val value = if (sex == 1) { + (h * 0.3315 * h / r + w * 0.119 - age * 0.0355 + 4.4509) / w * 100.0 + } else { + (h * 0.3475 * h / r + w * 0.0778 - age * 0.0355 + 3.1369) / w * 100.0 + } + return value.toFloat().coerceIn(if (sex == 1) 20.1f else 15.1f, if (sex == 1) 70.1f else 68.1f) + } + + private fun rawBodyAge(w: Float, h: Float, fat: Float, sex: Int, age: Int): Int { + if (age < 18) return 0 + val weightIndex = ((w * 10f).toInt() - 50) / 10 + val heightIndex = ((h * 10f).toInt() - 1000) / 5 + val weightFactor = WEIGHT_TABLE[weightIndex.coerceIn(0, WEIGHT_TABLE.lastIndex)] + val heightFactor = if (heightIndex < HEIGHT_TABLE_SHORT.size) { + HEIGHT_TABLE_SHORT[heightIndex.coerceAtLeast(0)] + } else { + HEIGHT_TABLE_TALL[(heightIndex - HEIGHT_TABLE_SHORT.size).coerceIn(0, HEIGHT_TABLE_TALL.lastIndex)] + } + val impedanceLike = ((1000f - fat * 10f) * w * 10f * 100_000f / + (weightFactor * heightFactor) / 8883f) + val signal = if (sex == 0) { + fat * 10f * 10.3f + 6716f - impedanceLike * 313f + } else { + fat * 10f * 3.86f + 3052f - impedanceLike * 100f + } + val estimate = if (sex == 0) age * 9f + abs(signal) * 0.05f else age * 8f + abs(signal) * 0.1f + return (estimate / 10f + 0.5f).toInt().coerceIn(18, 80) + } + + private fun adjustedBodyAge(age: Int, sex: Int, bmr: Int, weight: Float, rawAge: Int): Int { + var adjusted = age + if (age > 17) { + val ideal = idealBmr(weight, sex, age) + adjusted = if (age < 26) { + val lowRatio = if (sex == 1) 0.9f else 0.95f + when { + bmr >= ideal -> age + bmr < ideal * 0.5f -> age + if (sex == 1) 5 else 8 + bmr < ideal * lowRatio -> age + interpolate(bmr, ideal * 0.5f, ideal * lowRatio, 7, 3) + else -> age + interpolate(bmr, ideal * lowRatio, ideal.toFloat(), 3, 1) + } + } else if (sex == 1) { + when { + bmr >= ideal * 1.15f -> age - 3 + bmr >= ideal * 1.10f -> age - interpolate(bmr, ideal * 1.10f, ideal * 1.15f, 1, 2) + bmr >= ideal -> age + bmr >= ideal * 0.9f -> age + interpolate(bmr, ideal * 0.9f, ideal.toFloat(), 4, 1) + bmr >= ideal * 0.5f -> age + interpolate(bmr, ideal * 0.5f, ideal * 0.9f, 8, 4) + else -> age + 9 + } + } else { + when { + bmr >= ideal * 1.10f -> age - 3 + bmr >= ideal * 1.05f -> age - interpolate(bmr, ideal * 1.05f, ideal * 1.10f, 1, 2) + bmr >= ideal -> age + bmr >= ideal * 0.95f -> age + interpolate(bmr, ideal * 0.95f, ideal.toFloat(), 4, 1) + bmr >= ideal * 0.5f -> age + interpolate(bmr, ideal * 0.5f, ideal * 0.95f, 8, 4) + else -> age + 9 + } + } + } + adjusted += ((rawAge - adjusted) / 2f).toInt() + return adjusted + } + + private fun idealBmr(weight: Float, sex: Int, age: Int): Int { + val value = when { + age < 10 -> weight * if (sex == 1) 42.48 else 40.176 + age < 13 -> weight * if (sex == 1) 35.136 else 33.264 + age < 16 -> weight * if (sex == 1) 29.52 else 27.936 + age < 20 -> if (sex == 1 && weight > 73.5f) weight * 17.5 + 651 else weight * if (sex == 1) 26.352 else 24.192 + age < 25 -> if (sex == 1 && weight > 77.6f) weight * 15.3 + 679 else weight * if (sex == 1) 24.048 else 23.328 + age < 30 -> if (sex == 1 && weight > 89.4f) weight * 15.3 + 679 else weight * if (sex == 1) 22.896 else 22.032 + age < 35 -> if (sex == 1 && weight > 77.8f) weight * 11.6 + 879 else weight * if (sex == 1) 22.896 else 22.032 + age < 55 -> if (sex == 1 && weight > 83.1f) weight * 11.6 + 879 else weight * if (sex == 1) 22.176 else 21.168 + age < 70 -> if (sex == 1 && weight > 86.7f) weight * 11.6 + 879 else weight * if (sex == 1) 21.744 else 20.736 + else -> weight * if (sex == 1) 20.88 else 20.736 + } + return (value + 0.5).toInt() + } + + private fun interpolate(value: Int, low: Float, high: Float, lowResult: Int, highResult: Int): Int { + val clamped = value.toFloat().coerceIn(low, high) + return (lowResult + (clamped - low) / (high - low) * (highResult - lowResult) + 0.5f).toInt() + } + + private fun square(value: Float): Float = value * value + + private val WEIGHT_TABLE = intArrayOf( + 20,22,24,25,27,28,29,30,31,32,33,34,35,36,37,38,39,39,40,41,42,42,43,44,45,45,46,47,47,48,48,49, + 50,50,51,51,52,53,53,54,54,55,55,56,56,57,57,58,58,59,59,60,60,61,61,62,62,62,63,63,64,64,65,65, + 66,66,66,67,67,68,68,68,69,69,70,70,70,71,71,72,72,72,73,73,73,74,74,74,75,75,76,76,76,77,77,77, + 78,78,78,79,79,79,80,80,80,81,81,81,82,82,82,83,83,83,83,84,84,84,85,85,85,86,86,86,87,87,87,87, + 88,88,88,89,89,89,89,90,90,90,91,91,91,91,92,92,92,93,93,93,93,94,94,94,94,95,95,95,95,95,95, + ) + + private val HEIGHT_TABLE_SHORT = intArrayOf( + 2118,2125,2132,2139,2146,2153,2160,2167,2174,2181,2188,2195,2202,2209,2216,2222,2229,2236,2243,2250, + 2257,2263,2270,2277,2284,2290,2297,2304,2311,2317,2324,2331,2337,2344,2351,2357,2364,2371,2377,2384, + 2391,2397,2404,2410,2417,2423,2430,2437,2443,2450,2456,2463,2469,2476,2482,2489,2495,2502,2508,2514, + 2521,2527,2534,2540,2546,2553,2559,2566,2572,2578,2585,2591,2597,2604,2610,2616,2623,2629,2635,2642, + 2648,2654,2660,2667,2673,2679,2685,2691,2698,2704,2710,2716,2722,2729,2735,2741,2747,2753,2759,2766, + 2772,2778,2784,2790,2796,2802,2808,2814,2821,2827,2833,2839,2845,2851,2857,2863,2869,2875,2881,2887,2893, + ) + + private val HEIGHT_TABLE_TALL = intArrayOf( + 2899,2905,2911,2917,2923,2929,2935,2941,2947,2953,2958,2964,2970,2976,2982,2988,2994,3000,3006,3012, + 3017,3023,3029,3035,3041,3047,3052,3058,3064,3070,3076,3082,3087,3093,3099,3105,3111,3116,3122,3128, + 3134,3139,3145,3151,3157,3162,3168,3174,3179,3185,3191,3197,3202,3208,3214,3219,3225,3231,3236,3242, + 3248,3253,3259,3265,3270,3276,3281,3287,3293,3298,3304,3310,3315,3321,3326,3332,3337,3343,3349,3354, + 3360,3365,3371,3376,3382,3387,3393,3398,3404,3410,3415,3421,3426,3432,3437,3443,3448,3453,3459,3464, + 3470,3475,3481,3486,3492,3497,3503,3508,3513,3519,3524,3530,3535,3541,3546,3551,3557,3562,3568,3573, + ) +} + +/** + * Online equivalent of PICOOC 4.3.0's chronological history replay. + * + * The vendor creates weight clusters while a profile beta is unset and promotes a cluster's beta + * after its fourth BIA record. Keeping only the aggregates below is equivalent to retaining the + * full records for chronological live measurements: the original grouping code only reads count, + * average/min/max weight, last weight and last raw resistance. + */ +internal object PicoocAnchorLearner { + private const val MAX_CLUSTER_DISTANCE_GRAMS = 3_000 + private const val SECOND_RECORD_DISTANCE_GRAMS = 4_000 + private const val SECOND_RECORD_MAX_R_DIFF_OHM = 60 + const val REQUIRED_MEASUREMENTS = 4 + + data class Cluster( + val beta: Int, + val count: Int, + val sumWeightGrams: Long, + val minWeightGrams: Int, + val maxWeightGrams: Int, + val lastWeightGrams: Int, + val lastRawOhm: Int, + ) { + val averageWeightGrams: Double get() = sumWeightGrams.toDouble() / count + } + + data class State( + val processedCount: Int = 0, + val clusters: List = emptyList(), + ) { + val progress: Int + get() = clusters.maxOfOrNull { it.count }?.coerceAtMost(REQUIRED_MEASUREMENTS) ?: 0 + } + + /** A beta of zero asks the body-composition implementation to run its native cold-start tree. */ + data class Decision( + val beta: Int, + val clusterIndex: Int?, + ) + + data class Update( + val state: State, + val fixedBeta: Int?, + val progress: Int, + ) + + fun decide(state: State, weightKg: Float, rawOhm: Int): Decision { + val weightGrams = (weightKg * 1_000f).roundToInt() + val groups = state.clusters + if (groups.isEmpty()) return Decision(beta = 0, clusterIndex = null) + + val index = when { + state.processedCount == 1 -> { + val first = groups.first() + val weightDiff = abs(weightGrams - first.lastWeightGrams) + if (weightDiff <= MAX_CLUSTER_DISTANCE_GRAMS || + (weightDiff <= SECOND_RECORD_DISTANCE_GRAMS && + abs(rawOhm - first.lastRawOhm) <= SECOND_RECORD_MAX_R_DIFF_OHM) + ) 0 else null + } + + state.processedCount == 2 && groups.size == 1 -> { + val first = groups.first() + if (weightGrams >= first.minWeightGrams - MAX_CLUSTER_DISTANCE_GRAMS && + weightGrams <= first.maxWeightGrams + MAX_CLUSTER_DISTANCE_GRAMS + ) 0 else null + } + + else -> closestCluster(groups, weightGrams) + } + return if (index == null) Decision(beta = 0, clusterIndex = null) + else Decision(beta = groups[index].beta, clusterIndex = index) + } + + fun accept( + state: State, + decision: Decision, + weightKg: Float, + rawOhm: Int, + calculatedBeta: Int, + ): Update { + // Every entry of the reverse-engineered beta tables is >= 19, so this cannot normally + // happen. Should a table ever be corrected into a smaller value, count the measurement + // as processed and keep it out of the clusters rather than throwing: this runs inside + // the BLE advertisement callback, where an exception costs the whole measurement. + if (calculatedBeta < 19) { + return Update(state = skip(state), fixedBeta = null, progress = state.progress) + } + val weightGrams = (weightKg * 1_000f).roundToInt() + val groups = state.clusters.toMutableList() + val updated: Cluster + if (decision.clusterIndex == null) { + updated = Cluster( + beta = calculatedBeta, + count = 1, + sumWeightGrams = weightGrams.toLong(), + minWeightGrams = weightGrams, + maxWeightGrams = weightGrams, + lastWeightGrams = weightGrams, + lastRawOhm = rawOhm, + ) + groups += updated + } else { + val old = groups[decision.clusterIndex] + updated = old.copy( + count = old.count + 1, + sumWeightGrams = old.sumWeightGrams + weightGrams, + minWeightGrams = minOf(old.minWeightGrams, weightGrams), + maxWeightGrams = maxOf(old.maxWeightGrams, weightGrams), + lastWeightGrams = weightGrams, + lastRawOhm = rawOhm, + ) + groups[decision.clusterIndex] = updated + } + + val next = State(processedCount = state.processedCount + 1, clusters = groups) + return Update( + state = next, + fixedBeta = calculatedBeta.takeIf { updated.count >= REQUIRED_MEASUREMENTS }, + progress = next.progress, + ) + } + + /** Low-resistance measurements are calculated but excluded from beta-cluster learning. */ + fun skip(state: State): State = state.copy(processedCount = state.processedCount + 1) + + fun encode(state: State): String = buildString { + append(state.processedCount) + append('|') + state.clusters.forEachIndexed { index, group -> + if (index > 0) append(';') + append(group.beta).append(',') + append(group.count).append(',') + append(group.sumWeightGrams).append(',') + append(group.minWeightGrams).append(',') + append(group.maxWeightGrams).append(',') + append(group.lastWeightGrams).append(',') + append(group.lastRawOhm) + } + } + + fun decode(value: String?): State { + if (value.isNullOrBlank()) return State() + return runCatching { + val pieces = value.split('|', limit = 2) + val processed = pieces[0].toInt().coerceAtLeast(0) + val groups = pieces.getOrNull(1).orEmpty() + .split(';') + .filter { it.isNotBlank() } + .map { encoded -> + val fields = encoded.split(',') + require(fields.size == 7) + Cluster( + beta = fields[0].toInt(), + count = fields[1].toInt(), + sumWeightGrams = fields[2].toLong(), + minWeightGrams = fields[3].toInt(), + maxWeightGrams = fields[4].toInt(), + lastWeightGrams = fields[5].toInt(), + lastRawOhm = fields[6].toInt(), + ).also { + require(it.beta >= 19 && it.count > 0 && it.sumWeightGrams > 0) + } + } + State(processedCount = maxOf(processed, groups.sumOf { it.count }), clusters = groups) + }.getOrDefault(State()) + } + + private fun closestCluster(groups: List, weightGrams: Int): Int? { + var bestIndex: Int? = null + var bestDistance = Double.POSITIVE_INFINITY + var bestCount = -1 + groups.forEachIndexed { index, group -> + val distance = abs(weightGrams - group.averageWeightGrams) + if (distance > MAX_CLUSTER_DISTANCE_GRAMS) return@forEachIndexed + if (distance < bestDistance || (distance == bestDistance && group.count > bestCount)) { + bestIndex = index + bestDistance = distance + bestCount = group.count + } + } + return bestIndex + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt index cb92b0102..00f598097 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/Wla25BodyComposition.kt @@ -191,12 +191,9 @@ object Wla25BodyComposition { /** * Metabolic age: the user's age nudged by a per-sex body-fat band. * - * Currently unused — [com.health.openscale.core.bluetooth.data.ScaleMeasurement] - * has no field for it, so there is nowhere to publish it. `EtekcityLib` and - * `HesleyHandler` hit the same wall: one computes metabolic age and the - * other reads it off the wire, and both discard it. Kept here because it is - * part of the algorithm and is verified against the vendor library; wiring - * it up is a data-model change, not a driver one. + * Published by `RelaxmedicHandler` on the handler-local `ble.metabolic_age` type, the same + * identity the other drivers that report a body age use, so the column stays continuous + * across scales. Verified against the vendor library. * * The offsets skip zero — the healthy band steps straight from -1 to +1. * The female band at [45, 46) returning +0 while >=46 gives +5 is not a diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt index f62bed06d..c985bf43d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HesleyHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.service.ScannedDeviceInfo import java.util.Date @@ -38,6 +39,17 @@ import com.health.openscale.core.data.Percent */ class HesleyHandler : ScaleDeviceHandler() { + companion object { + /** + * Metabolic age the scale reports in byte 17. The identity stays vendor-neutral so a + * user switching brands keeps one continuous column. + */ + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + } + private val SERVICE: UUID = uuid16(0xFFF0) private val CHAR_CMD: UUID = uuid16(0xFFF1) // write-only private val CHAR_NOTIFY: UUID = uuid16(0xFFF4) // notify (+read on some firmwares) @@ -86,7 +98,7 @@ class HesleyHandler : ScaleDeviceHandler() { val water = (((frame[8].toInt() and 0xFF) shl 8) or (frame[9].toInt() and 0xFF)) / 10.0f val muscle = (((frame[10].toInt() and 0xFF) shl 8) or (frame[11].toInt() and 0xFF)) / 10.0f val bone = (((frame[12].toInt() and 0xFF) shl 8) or (frame[13].toInt() and 0xFF)) / 10.0f - // val bodyAge = frame[17].toInt() and 0xFF // 10..99 (unused) + val bodyAge = frame[17].toInt() and 0xFF // 10..99 // val kcal = (((frame[14].toInt() and 0xFF) shl 8) or (frame[15].toInt() and 0xFF)) // not stored val m = ScaleMeasurement().apply { @@ -96,6 +108,7 @@ class HesleyHandler : ScaleDeviceHandler() { this[MeasurementType.MUSCLE] = Percent(muscle) this[MeasurementType.WATER] = Percent(water) this[MeasurementType.BONE] = Kg(bone) + bodyAge.takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it } } logD( "Hesley result kg=${m[MeasurementType.WEIGHT]} fat=${m[MeasurementType.BODY_FAT]} water=${m[MeasurementType.WATER]} muscle=${m[MeasurementType.MUSCLE]} bone=${m[MeasurementType.BONE]}") diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt index 0d9d87142..b6a9a89fd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiCH100SHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.EtekcityLib @@ -55,6 +56,18 @@ import com.health.openscale.core.data.Percent */ class HuaweiCH100SHandler : ScaleDeviceHandler() { + companion object { + /** + * Metabolic age the Chipsea BIA model derives together with the other composition + * values. The identity stays vendor-neutral so a user switching brands keeps one + * continuous column. + */ + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + } + // --- BLE identifiers ------------------------------------------------------ private val SERVICE = uuid16(0xFAA0) @@ -288,6 +301,7 @@ class HuaweiCH100SHandler : ScaleDeviceHandler() { this[MeasurementType.BONE] = Kg(lib.boneMass.toFloat()) this[MeasurementType.BMR] = Kcal(lib.basalMetabolicRate.toFloat()) this[MeasurementType.VISCERAL_FAT] = lib.visceralFat.toFloat() + lib.metabolicAge.takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it } } } publish(m) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt index b32ff4bc0..a7a6c5f14 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.KeepS3BodyComposition @@ -644,12 +645,12 @@ class KeepS3Handler : ScaleDeviceHandler() { // 15-60%). Keep's broader "muscle" value is FFM minus bone and can exceed that // range, so publish the separately decoded skeletal-muscle percentage here. this[MeasurementType.MUSCLE] = Percent(composition.skeletalMusclePercent) - // Keep's broader composition.musclePercent remains decoded by the model but is - // not published because openScale has no lean-soft-tissue measurement type. + // Keep's broader "muscle" (FFM minus bone) goes to its own type instead. + this[TOTAL_MUSCLE] = Percent(composition.musclePercent) + composition.bodyAge.takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it } // Not published yet — wiring them up would be handler-local now // (MeasurementType.deviceFloat), but stays a deliberate follow-up. // subcutaneousFat = composition.subcutaneousFatPercent - // bodyAge = composition.bodyAge // bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg logI("Keep S3 body composition calculated with offline BHKeep SDK-compatible model") } @@ -786,6 +787,25 @@ class KeepS3Handler : ScaleDeviceHandler() { } companion object { + /** + * Metabolic age the offline BHKeep model computes. The identity stays vendor-neutral so + * a user switching brands keeps one continuous column. + */ + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + + /** + * Keep's broad "muscle" figure: fat-free mass minus bone, as a percentage of weight. + * [MeasurementType.MUSCLE] is evaluated as skeletal muscle (plausible 15-60 %), which + * this value regularly exceeds, so it needs a type of its own. + */ + val TOTAL_MUSCLE = MeasurementType.devicePercent( + "total_muscle", R.string.measurement_type_total_muscle, + icon = MeasurementTypeIcon.IC_M_WORKOUT, color = 0xFF2E7D32.toInt() + ) + private const val DEVICE_NAME = "Keep_S3" private const val FINAL_RECORD_WAIT_MS = 2_000L private const val DISCONNECT_DELAY_MS = 6_000L diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt index 210d2baaf..cffe1899c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OkOkHandler.kt @@ -19,7 +19,9 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import android.util.SparseArray +import com.health.openscale.R import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.OkOkV2Lib @@ -42,6 +44,18 @@ import com.health.openscale.core.data.Percent */ class OkOkHandler : ScaleDeviceHandler() { + companion object { + /** + * Metabolic age from the V2 vendor model, published on the 0xC0 path where the + * composition is computed here. The identity stays vendor-neutral so a user switching + * brands keeps one continuous column. + */ + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + } + // Known manufacturer ids private val MANUF_V20 = 0x20ca private val MANUF_V11 = 0x11ca @@ -167,6 +181,7 @@ class OkOkHandler : ScaleDeviceHandler() { this[MeasurementType.BMR] = Kcal(lib.getBMR(kg)) this[MeasurementType.PROTEIN] = Percent(lib.getProtein(kg, imp)) this[MeasurementType.IMPEDANCE] = Ohm(imp) + lib.getBodyAge(kg, imp).takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it } }) return BroadcastAction.CONSUMED_STOP } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt index f6fcb8cad..d21767921 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OmronWlcHandler.kt @@ -20,6 +20,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.OmronBodyCompositionLib @@ -57,6 +58,16 @@ import com.health.openscale.core.data.Percent class OmronWlcHandler : ScaleDeviceHandler() { companion object { + /** + * Body age the scale stores alongside a record, on the models whose profile has the + * field. The identity stays vendor-neutral so a user switching brands keeps one + * continuous column. + */ + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + val SVC_OMRON_WLP: UUID = UUID.fromString("ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b") val CHR_UNLOCK: UUID = UUID.fromString("b305b680-aee7-11e1-a730-0002a5d5c51b") @@ -502,6 +513,7 @@ class OmronWlcHandler : ScaleDeviceHandler() { m[MeasurementType.MUSCLE] = Percent(skeletalMusclePercent ?: 0f) m[MeasurementType.VISCERAL_FAT] = visceralFatLevel ?: 0f m[MeasurementType.BMR] = Kcal(bmrKcal?.toFloat() ?: 0f) + bodyAgeYears?.takeIf { it in 10..99 }?.let { m[METABOLIC_AGE] = it } } // ---- chunked EEPROM reads ------------------------------------------------------------------ diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandler.kt new file mode 100644 index 000000000..15328b5f7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandler.kt @@ -0,0 +1,445 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.PicoocAnchorLearner +import com.health.openscale.core.bluetooth.libs.PicoocWhiteBodyComposition +import com.health.openscale.core.data.Kg +import com.health.openscale.core.data.Kcal +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon +import com.health.openscale.core.data.Ohm +import com.health.openscale.core.data.Percent +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import kotlin.math.roundToInt + +/** + * Parser for the PICOOC Mini Lite broadcast advertisement. + * + * The layout was recovered from PICOOC 4.3.0's `BTBleForBroadcasDevice` and checked against + * three captures from a physical Mini Lite. The full 16-byte manufacturer value is: + * + * [0..5] device MAC, in normal display order + * [6] result state; 0x39 is the completed measurement accepted by the vendor app + * [7] protocol byte (0x1F in the captures; the vendor parser does not inspect it) + * [8..9] weight, unsigned big-endian, 0.05 kg units + * [10..11] impedance, unsigned big-endian, 0.1 ohm units; 0xFFFF means unavailable + * [12..13] secondary weight, unsigned big-endian with bit 15 masked, 0.1 lb units + * [14] display-unit byte (passed through by the vendor app) + * [15] one's-complement checksum of bytes [0..14] + * + * Android treats bytes [0..1] as the little-endian Bluetooth company id. Consequently [parse] + * receives them separately as [manufacturerId], followed by the remaining 14-byte [payload]. + * The id is part of the embedded MAC, not a fixed PICOOC company id, and must never be hardcoded. + */ +internal object PicoocMiniLiteAdv { + const val COMPLETE_STATE = 0x39 + private const val PAYLOAD_SIZE = 14 + private const val MFR_SIZE = 16 + + data class Frame( + val state: Int, + val protocol: Int, + val weightKg: Float, + val impedanceOhm: Float?, + val displayUnit: Int, + ) { + val complete: Boolean get() = state == COMPLETE_STATE + } + + /** Parse one entry exactly as Android exposes it through ManufacturerSpecificData. */ + fun parse(manufacturerId: Int, payload: ByteArray?): Frame? { + if (payload == null || payload.size != PAYLOAD_SIZE) return null + + val mfr = ByteArray(MFR_SIZE) + mfr[0] = manufacturerId.toByte() + mfr[1] = (manufacturerId ushr 8).toByte() + payload.copyInto(mfr, destinationOffset = 2) + + // The vendor checksum is ~(sum(bytes 0..14) & 0xFF). Equivalently, including the + // checksum byte makes the low byte of the total exactly 0xFF. + val checksumTotal = mfr.sumOf { it.toInt() and 0xFF } and 0xFF + if (checksumTotal != 0xFF) return null + + val state = mfr[6].toInt() and 0xFF + val protocol = mfr[7].toInt() and 0xFF + val weightRaw = u16be(mfr, 8) + val impedanceRaw = u16be(mfr, 10) + return Frame( + state = state, + protocol = protocol, + weightKg = weightRaw / 20.0f, + impedanceOhm = if (impedanceRaw == 0xFFFF) null else impedanceRaw / 10.0f, + displayUnit = mfr[14].toInt() and 0xFF, + ) + } + + private fun u16be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) +} + +/** Broadcast-only support for the PICOOC Mini Lite, advertised as `PICOOC-L`. */ +class PicoocBroadcastHandler : ScaleDeviceHandler() { + + companion object { + private const val ADVERTISED_NAME = "PICOOC-L" + private const val WEIGHT_MIN_KG = 0.5f + private const val WEIGHT_MAX_KG = 300.0f + + private const val KEY_ANCHOR_WEIGHT = "anchorWeight" + private const val KEY_ANCHOR_BETA = "anchorBeta" + private const val KEY_LEARNER_STATE = "learnerState" + private const val KEY_PROFILE_FINGERPRINT = "profileFingerprint" + private const val KEY_MEASUREMENT_ANCHOR = "measurementAnchor" + private const val KEY_PREVIOUS_RAW_R = "previousRawR" + private const val KEY_PREVIOUS_CORRECTED_R = "previousCorrectedR" + private const val KEY_PREVIOUS_WEIGHT_GRAMS = "previousWeightGrams" + private const val KEY_PREVIOUS_TIMESTAMP = "previousTimestamp" + private const val KEY_UI_LAST_USER_ID = "ui/lastUserId" + private const val KEY_UI_LAST_USER_NAME = "ui/lastUserName" + private const val KEY_UI_PROGRESS = "ui/progress" + private const val KEY_UI_BETA = "ui/beta" + private const val KEY_UI_ANCHOR_WEIGHT = "ui/anchorWeight" + private const val KEY_UI_FIXED = "ui/fixed" + + // Generic paths on purpose: both quantities are reported by scales of several vendors + // (total muscle = lean mass minus bone, metabolic age), so a user switching brands + // keeps one continuous history instead of a second vendor-bound column. + val TOTAL_MUSCLE = MeasurementType.devicePercent( + "total_muscle", R.string.measurement_type_total_muscle, + icon = MeasurementTypeIcon.IC_M_WORKOUT, color = 0xFF2E7D32.toInt() + ) + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + } + + private val deviceSupport = DeviceSupport( + displayName = "PICOOC Mini Lite", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + ), + linkMode = LinkMode.BROADCAST_ONLY, + ) + + /** The scale repeats its final advertisement; publish it at most once per scan session. */ + private var armed = true + + @Composable + override fun DeviceConfigurationUi() { + var lastUserId by remember { mutableIntStateOf(settingsGetInt(KEY_UI_LAST_USER_ID, -1)) } + var lastUserName by remember { mutableStateOf(settingsGetString(KEY_UI_LAST_USER_NAME).orEmpty()) } + var progress by remember { mutableIntStateOf(settingsGetInt(KEY_UI_PROGRESS, 0)) } + var beta by remember { mutableIntStateOf(settingsGetInt(KEY_UI_BETA, 0)) } + var anchorWeight by remember { mutableIntStateOf(settingsGetInt(KEY_UI_ANCHOR_WEIGHT, 0)) } + var fixed by remember { mutableStateOf(settingsGetInt(KEY_UI_FIXED, 0) == 1) } + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.picooc_calibration_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when { + lastUserId < 0 || progress == 0 -> Text(stringResource(R.string.picooc_calibration_no_measurements)) + fixed -> Text( + stringResource( + R.string.picooc_calibration_fixed, + lastUserName, + beta, + anchorWeight, + ) + ) + else -> Text( + stringResource( + R.string.picooc_calibration_learning, + lastUserName, + progress, + beta, + anchorWeight, + ) + ) + } + Button( + onClick = { + resetCalibration(lastUserId) + progress = 0 + beta = 0 + anchorWeight = 0 + fixed = false + }, + enabled = lastUserId >= 0, + ) { + Text( + if (lastUserName.isBlank()) stringResource(R.string.picooc_calibration_reset_generic) + else stringResource(R.string.picooc_calibration_reset, lastUserName) + ) + } + } + } + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? = + deviceSupport.takeIf { device.name.trim().equals(ADVERTISED_NAME, ignoreCase = true) } + + override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction { + val manufacturerData = result.scanRecord?.manufacturerSpecificData + ?: return BroadcastAction.IGNORED + + var frame: PicoocMiniLiteAdv.Frame? = null + for (i in 0 until manufacturerData.size()) { + frame = PicoocMiniLiteAdv.parse( + manufacturerData.keyAt(i), + manufacturerData.valueAt(i), + ) + if (frame != null) break + } + val parsed = frame ?: return BroadcastAction.IGNORED + + if (!parsed.complete) { + // Any valid non-final frame belongs to an active measurement and re-arms the guard. + armed = true + logD("measurement in progress (state=0x%02X)".format(parsed.state)) + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + if (!armed) return BroadcastAction.CONSUMED_STOP + if (parsed.weightKg !in WEIGHT_MIN_KG..WEIGHT_MAX_KG) { + logW("completed frame has implausible weight ${parsed.weightKg} kg; ignoring") + return BroadcastAction.CONSUMED_KEEP_SCANNING + } + + val now = System.currentTimeMillis() + val measurement = ScaleMeasurement().apply { + userId = user.id + dateTime = Date(now) + this[MeasurementType.WEIGHT] = Kg(parsed.weightKg) + parsed.impedanceOhm + ?.takeIf { it > 0.0f } + ?.let { this[MeasurementType.IMPEDANCE] = Ohm(it) } + } + + parsed.impedanceOhm + ?.takeIf { it >= 50f } + ?.let { rawImpedance -> + populateBodyComposition(measurement, user, parsed.weightKg, rawImpedance, now) + } + + armed = false + publish(measurement) + logI( + "measurement published: weight=${parsed.weightKg} kg, " + + "impedance=${parsed.impedanceOhm ?: "unavailable"} ohm, " + + "protocol=0x%02X unit=%d".format(parsed.protocol, parsed.displayUnit) + ) + return BroadcastAction.CONSUMED_STOP + } + + override fun onDisconnected() { + armed = true + } + + private fun populateBodyComposition( + measurement: ScaleMeasurement, + user: ScaleUser, + weightKg: Float, + rawImpedance: Float, + now: Long, + ) { + val age = user.getAge(Date(now)) + if (age < 16 || user.bodyHeight <= 0f) { + logW("body composition requires age >= 16 and a valid height") + return + } + + ensureCalibrationMatchesProfile(user) + + val rawR = rawImpedance.roundToInt() + val previousRawR = settingsGetInt(userKey(KEY_PREVIOUS_RAW_R, user.id), -1).takeIf { it > 0 } + val previousCorrectedR = settingsGetInt(userKey(KEY_PREVIOUS_CORRECTED_R, user.id), -1).takeIf { it > 0 } + val previousWeight = settingsGetInt(userKey(KEY_PREVIOUS_WEIGHT_GRAMS, user.id), -1) + .takeIf { it > 0 } + ?.div(1000f) + val previousTimestamp = settingsGetString(userKey(KEY_PREVIOUS_TIMESTAMP, user.id))?.toLongOrNull() + val correctedR = PicoocWhiteBodyComposition.correctedImpedance( + rawOhm = rawR, + weightKg = weightKg, + timestampMs = now, + previousRawOhm = previousRawR, + previousWeightKg = previousWeight, + previousTimestampMs = previousTimestamp, + previousCorrectedOhm = previousCorrectedR, + ) + + val anchorWeightKey = userKey(KEY_ANCHOR_WEIGHT, user.id) + val anchorBetaKey = userKey(KEY_ANCHOR_BETA, user.id) + val previousAnchorWeight = settingsGetInt(anchorWeightKey, 0).takeIf { it > 0 } + val anchorWeight = PicoocWhiteBodyComposition.anchorWeight(weightKg, previousAnchorWeight) + val fixedBeta = settingsGetInt(anchorBetaKey, 0).takeIf { it >= 19 } + val learnerKey = userKey(KEY_LEARNER_STATE, user.id) + val learnerState = PicoocAnchorLearner.decode(settingsGetString(learnerKey)) + val learnable = fixedBeta == null && correctedR >= 300 + val decision = if (learnable) { + PicoocAnchorLearner.decide(learnerState, weightKg, rawR) + } else { + null + } + val betaForCalculation = fixedBeta ?: decision?.beta ?: 0 + val recentMeasurementAnchor = previousTimestamp + ?.takeIf { now - it in 0L..1_800_000L } + ?.let { settingsGetInt(userKey(KEY_MEASUREMENT_ANCHOR, user.id), 0) } + ?: 0 + + val result = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = user.gender.isMale(), + heightCm = user.bodyHeight, + age = age, + weightKg = weightKg, + correctedImpedanceOhm = correctedR, + anchorWeightKg = anchorWeight, + anchorBeta = betaForCalculation, + hour = java.util.Calendar.getInstance().apply { timeInMillis = now }.get(java.util.Calendar.HOUR_OF_DAY), + previousMeasurementAnchor = recentMeasurementAnchor, + ) + ) ?: return + + val learnerUpdate = when { + fixedBeta != null -> null + decision != null -> PicoocAnchorLearner.accept( + state = learnerState, + decision = decision, + weightKg = weightKg, + rawOhm = rawR, + calculatedBeta = result.anchorBeta, + ) + else -> { + settingsPutString(learnerKey, PicoocAnchorLearner.encode(PicoocAnchorLearner.skip(learnerState))) + null + } + } + learnerUpdate?.let { settingsPutString(learnerKey, PicoocAnchorLearner.encode(it.state)) } + val promotedBeta = learnerUpdate?.fixedBeta + if (promotedBeta != null) settingsPutInt(anchorBetaKey, promotedBeta) + + applyBodyCompositionMeasurements(measurement, result) + + settingsPutInt(anchorWeightKey, anchorWeight) + settingsPutInt(userKey(KEY_MEASUREMENT_ANCHOR, user.id), result.measurementAnchor) + settingsPutInt(userKey(KEY_PREVIOUS_RAW_R, user.id), rawR) + settingsPutInt(userKey(KEY_PREVIOUS_CORRECTED_R, user.id), correctedR) + settingsPutInt(userKey(KEY_PREVIOUS_WEIGHT_GRAMS, user.id), (weightKg * 1000f).roundToInt()) + settingsPutString(userKey(KEY_PREVIOUS_TIMESTAMP, user.id), now.toString()) + + val progress = if (fixedBeta != null || promotedBeta != null) { + PicoocAnchorLearner.REQUIRED_MEASUREMENTS + } else { + learnerUpdate?.progress ?: learnerState.progress + } + settingsPutInt(KEY_UI_LAST_USER_ID, user.id) + settingsPutString(KEY_UI_LAST_USER_NAME, user.userName.ifBlank { user.id.toString() }) + settingsPutInt(KEY_UI_PROGRESS, progress) + settingsPutInt(KEY_UI_BETA, result.anchorBeta) + settingsPutInt(KEY_UI_ANCHOR_WEIGHT, anchorWeight) + settingsPutInt(KEY_UI_FIXED, if (fixedBeta != null || promotedBeta != null) 1 else 0) + + logI( + "PICOOC body composition: fat=${result.bodyFatPercent}, " + + "totalMuscle=${result.totalMusclePercent}, skeletalMuscle=${result.skeletalMusclePercent}, " + + "water=${result.waterPercent}, bone=${result.boneMassKg}, bmr=${result.basalMetabolicRateKcal}, " + + "anchor=$anchorWeight/${result.anchorBeta}/${result.measurementAnchor} " + + "(${if (fixedBeta != null || promotedBeta != null) "fixed" else "learning $progress/4"}), " + + "R=$rawR->$correctedR" + ) + } + + private fun ensureCalibrationMatchesProfile(user: ScaleUser) { + val key = userKey(KEY_PROFILE_FINGERPRINT, user.id) + val fingerprint = "white-v1:${user.gender}:${user.bodyHeight.toBits()}:${user.birthday.time}" + if (settingsGetString(key) == fingerprint) return + + resetCalibration(user.id) + settingsPutString(key, fingerprint) + logI("PICOOC calibration reset because profile inputs changed for user ${user.id}") + } + + private fun resetCalibration(userId: Int) { + settingsPutInt(userKey(KEY_ANCHOR_WEIGHT, userId), 0) + settingsPutInt(userKey(KEY_ANCHOR_BETA, userId), 0) + settingsPutString(userKey(KEY_LEARNER_STATE, userId), "") + settingsPutInt(userKey(KEY_MEASUREMENT_ANCHOR, userId), 0) + settingsPutInt(userKey(KEY_PREVIOUS_RAW_R, userId), 0) + settingsPutInt(userKey(KEY_PREVIOUS_CORRECTED_R, userId), 0) + settingsPutInt(userKey(KEY_PREVIOUS_WEIGHT_GRAMS, userId), 0) + settingsPutString(userKey(KEY_PREVIOUS_TIMESTAMP, userId), "") + if (settingsGetInt(KEY_UI_LAST_USER_ID, -1) == userId) { + settingsPutInt(KEY_UI_PROGRESS, 0) + settingsPutInt(KEY_UI_BETA, 0) + settingsPutInt(KEY_UI_ANCHOR_WEIGHT, 0) + settingsPutInt(KEY_UI_FIXED, 0) + } + } + + /** + * openScale's built-in MUSCLE reference ranges describe skeletal muscle. PICOOC's primary + * "muscle" value is broader (lean mass minus bone) and commonly exceeds openScale's 60% + * plausibility ceiling, so it lands on the separate [TOTAL_MUSCLE] type instead. + */ + internal fun applyBodyCompositionMeasurements( + measurement: ScaleMeasurement, + result: PicoocWhiteBodyComposition.Result, + ) { + measurement[MeasurementType.BODY_FAT] = Percent(result.bodyFatPercent) + measurement[MeasurementType.MUSCLE] = Percent(result.skeletalMusclePercent) + measurement[MeasurementType.WATER] = Percent(result.waterPercent) + measurement[MeasurementType.BONE] = Kg(result.boneMassKg) + measurement[MeasurementType.BMR] = Kcal(result.basalMetabolicRateKcal.toFloat()) + measurement[MeasurementType.PROTEIN] = Percent(result.proteinPercent) + measurement[MeasurementType.VISCERAL_FAT] = result.visceralFatLevel.toFloat() + measurement[MeasurementType.LBM] = Kg(result.leanBodyMassKg) + measurement[TOTAL_MUSCLE] = Percent(result.totalMusclePercent) + result.metabolicAge.takeIf { it in 10..99 }?.let { measurement[METABOLIC_AGE] = it } + } + + private fun userKey(base: String, userId: Int): String = "$base/$userId" +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt index 64e480423..416aee749 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/PicoocHandler.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.StandardImpedanceLib @@ -369,10 +370,8 @@ class PicoocHandler : ScaleDeviceHandler() { logI("Picooc body composition unavailable: impedance=$pendingImpedance height=${user.bodyHeight}") } - // openScale has no measurement type for the metabolic body age the 0x32 packet reports, - // nor for the phase angle in the live frames. Re-enabling either needs a - // MeasurementTypeKey, a ScaleMeasurement field, a DB migration and BleConnector wiring. - composition?.bodyAge?.takeIf { it > 0 }?.let { logI("Picooc metabolic body age: $it (not stored)") } + // The phase angle in the live frames still has no measurement type and stays unpublished. + composition?.bodyAge?.takeIf { it in 10..99 }?.let { measurement[METABOLIC_AGE] = it } logI( "Picooc publishing ($reason) → weight=${measurement[MeasurementType.WEIGHT]}kg fat=${measurement[MeasurementType.BODY_FAT]}% " + @@ -442,6 +441,15 @@ class PicoocHandler : ScaleDeviceHandler() { ) companion object { + /** + * Metabolic body age the 0x32 packet reports. The identity stays vendor-neutral so a + * user switching brands keeps one continuous column. + */ + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + // App→scale frame prefixes. const val PREFIX_LATIN = 0xF1 const val PREFIX_MODERN = 0xA1 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt index f2b7fbcc7..466ae8d90 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RelaxmedicHandler.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.bluetooth.scales import com.health.openscale.R import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.data.MeasurementTypeIcon import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.libs.Wla25BodyComposition @@ -219,6 +220,15 @@ class RelaxmedicHandler : ScaleDeviceHandler() { this[MeasurementType.PROTEIN] = Percent(result.protein) this[MeasurementType.BMR] = Kcal(result.bmrKcal.toFloat()) this[MeasurementType.LBM] = Kg(result.lbmKg) + Wla25BodyComposition.bodyAge( + age = user.age, + fatPercent = result.fat.toDouble(), + sex = if (user.gender.isMale()) { + Wla25BodyComposition.SEX_MALE + } else { + Wla25BodyComposition.SEX_FEMALE + }, + ).takeIf { it in 10..99 }?.let { this[METABOLIC_AGE] = it } } } else { // The impedances failed the algorithm's validity gate; the weight is @@ -347,6 +357,15 @@ class RelaxmedicHandler : ScaleDeviceHandler() { } companion object { + /** + * Metabolic age from [Wla25BodyComposition.bodyAge]. The identity stays vendor-neutral + * so a user switching brands keeps one continuous column. + */ + val METABOLIC_AGE = MeasurementType.deviceInt( + "metabolic_age", R.string.measurement_type_metabolic_age, + icon = MeasurementTypeIcon.IC_M_TIMER, color = 0xFF795548.toInt() + ) + private const val FRAME_SIZE = 20 private const val TYPE_ACK_IN = 0xA0 diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index eda76477e..9ddbfb0c6 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -190,6 +190,14 @@ Extracellular water Intracellular water Protein + Total muscle + Metabolic age + PICOOC learns a stable body-composition anchor from four nearby BIA measurements. + Calibration has not started yet. + %1$s: learning %2$d of 4, preliminary beta %3$d, anchor %4$d kg + %1$s: calibrated, beta %2$d, anchor %3$d kg + Reset calibration for %1$s + Reset PICOOC calibration Body cell mass Added by a scale CSV column diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index cf2f30eeb..75a0f52cf 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -59,6 +59,7 @@ import com.health.openscale.core.bluetooth.scales.MiScaleS400Handler import com.health.openscale.core.bluetooth.scales.OkOkHandler import com.health.openscale.core.bluetooth.scales.OmronWlcHandler import com.health.openscale.core.bluetooth.scales.PicoocHandler +import com.health.openscale.core.bluetooth.scales.PicoocBroadcastHandler import com.health.openscale.core.bluetooth.scales.OneByoneHandler import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler import com.health.openscale.core.bluetooth.scales.QNHandler @@ -189,6 +190,7 @@ object ScaleCatalog { // The S3 Lite V2.0 advertises as PICOOC-CQ; the Latin series carries no vendor prefix. device("PICOOC-CQ") claimedBy PicoocHandler::class.java, device("Latin-S") claimedBy PicoocHandler::class.java, + device("PICOOC-L") claimedBy PicoocBroadcastHandler::class.java, device("Beurer BF450") claimedBy BeurerBF450Handler::class.java, device("BIA SCALE", SERVICE_FFB0) claimedBy TaylorBIAHandler::class.java, device("RYFIT") claimedBy RyFitHandler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt index bc3b46b5e..ede353bba 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleFactoryTest.kt @@ -29,6 +29,7 @@ import com.health.openscale.core.bluetooth.scales.FitTrackDaraHandler import com.health.openscale.core.bluetooth.scales.HumeDara2Handler import com.health.openscale.core.bluetooth.scales.MGBHandler import com.health.openscale.core.bluetooth.scales.OkOkHandler +import com.health.openscale.core.bluetooth.scales.PicoocBroadcastHandler import com.health.openscale.core.bluetooth.scales.QNHandlerBroadcast import com.health.openscale.core.bluetooth.scales.ScaleupHandler import com.health.openscale.core.bluetooth.scales.SinocareHandler @@ -322,6 +323,28 @@ class ScaleFactoryTest { assertThat(EtekcityFit8SHandler().supportFor(scaleup)).isNull() } + /** PICOOC-L's embedded MAC starts with 0xD0, which also triggers ScaleupHandler's broad match. */ + @Test + fun `PICOOC Mini Lite is matched ahead of the generic Scaleup handler`() { + val picooc = advertisement( + name = "PICOOC-L", + manufacturerData = listOf( + 0x49D0 to byteArrayOf( + 0x00, 0x4B, 0x4F, 0x1E, 0x39, 0x1F, 0x06, 0x76, + 0x13, 0x60, 0x87.toByte(), 0x20, 0x00, 0x40, + ) + ), + ) + + assertClaimedBy(picooc, PicoocBroadcastHandler::class.java) + assertThat(claimants(picooc).map { it.javaClass.simpleName }) + .containsExactly("PicoocBroadcastHandler", "ScaleupHandler").inOrder() + + val order = ScaleFactory.createHandlers().map { it.javaClass.simpleName } + assertThat(order.indexOf("PicoocBroadcastHandler")) + .isLessThan(order.indexOf("ScaleupHandler")) + } + /** * The connectable Etekcity ESF551 is the Fit 8S's closest neighbour — same vendor, so possibly * the same company id. It identifies itself by name and must keep winning: the Fit 8S handler diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocAnchorLearnerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocAnchorLearnerTest.kt new file mode 100644 index 000000000..c403517a1 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocAnchorLearnerTest.kt @@ -0,0 +1,69 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PicoocAnchorLearnerTest { + + @Test + fun `promotes a nearby weight cluster only on its fourth measurement`() { + var state = PicoocAnchorLearner.State() + val samples = listOf(81.5f to 492, 81.6f to 498, 82.7f to 496, 82.6f to 494) + + samples.forEachIndexed { index, (weight, rawR) -> + val decision = PicoocAnchorLearner.decide(state, weight, rawR) + assertThat(decision.beta).isEqualTo(if (index == 0) 0 else 34) + val update = PicoocAnchorLearner.accept(state, decision, weight, rawR, 34) + assertThat(update.progress).isEqualTo(index + 1) + if (index < 3) assertThat(update.fixedBeta).isNull() + else assertThat(update.fixedBeta).isEqualTo(34) + state = update.state + } + } + + @Test + fun `second record uses the vendor four-kilo and sixty-ohm exception`() { + val first = PicoocAnchorLearner.accept( + PicoocAnchorLearner.State(), + PicoocAnchorLearner.Decision(0, null), + 80f, + 500, + 30, + ).state + + assertThat(PicoocAnchorLearner.decide(first, 83.5f, 560).beta).isEqualTo(30) + assertThat(PicoocAnchorLearner.decide(first, 83.5f, 561).beta).isEqualTo(0) + assertThat(PicoocAnchorLearner.decide(first, 84.1f, 500).beta).isEqualTo(0) + } + + @Test + fun `serialized cluster state round trips and malformed state resets safely`() { + var state = PicoocAnchorLearner.State() + state = PicoocAnchorLearner.accept( + state, + PicoocAnchorLearner.decide(state, 81.5f, 492), + 81.5f, + 492, + 34, + ).state + state = PicoocAnchorLearner.accept( + state, + PicoocAnchorLearner.decide(state, 81.6f, 498), + 81.6f, + 498, + 34, + ).state + + assertThat(PicoocAnchorLearner.decode(PicoocAnchorLearner.encode(state))).isEqualTo(state) + assertThat(PicoocAnchorLearner.decode("broken")).isEqualTo(PicoocAnchorLearner.State()) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyCompositionTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyCompositionTest.kt new file mode 100644 index 000000000..fe8168a95 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/PicoocWhiteBodyCompositionTest.kt @@ -0,0 +1,109 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PicoocWhiteBodyCompositionTest { + + @Test + fun `matches the supplied official PICOOC result`() { + val result = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = true, + heightCm = 176f, + age = 39, + weightKg = 82.7f, + correctedImpedanceOhm = 500, + anchorWeightKg = 81, + anchorBeta = 34, + ) + )!! + + assertThat(result.bodyFatPercent).isWithin(0.05f).of(26.5f) + assertThat(result.totalMusclePercent).isWithin(0.05f).of(69.6f) + assertThat(result.waterPercent).isWithin(0.05f).of(51.2f) + assertThat(result.skeletalMusclePercent).isWithin(0.05f).of(40.4f) + assertThat(result.proteinPercent).isWithin(0.05f).of(18.4f) + assertThat(result.boneMassKg).isWithin(0.05f).of(3.2f) + assertThat(result.basalMetabolicRateKcal).isEqualTo(1683) + assertThat(result.bmi).isWithin(0.05f).of(26.7f) + assertThat(result.visceralFatLevel).isEqualTo(10) + assertThat(result.metabolicAge).isEqualTo(42) + assertThat(result.anchorBeta).isEqualTo(34) + assertThat(result.measurementAnchor).isEqualTo(340) + } + + @Test + fun `rounds new impedance and reuses a sufficiently recent stable correction`() { + val first = PicoocWhiteBodyComposition.correctedImpedance( + rawOhm = 496, + weightKg = 82.7f, + timestampMs = 1_000_000L, + previousRawOhm = null, + previousWeightKg = null, + previousTimestampMs = null, + previousCorrectedOhm = null, + ) + val reused = PicoocWhiteBodyComposition.correctedImpedance( + rawOhm = 492, + weightKg = 82.5f, + timestampMs = 1_300_000L, + previousRawOhm = 496, + previousWeightKg = 82.7f, + previousTimestampMs = 1_000_000L, + previousCorrectedOhm = first, + ) + + assertThat(first).isEqualTo(500) + assertThat(reused).isEqualTo(500) + } + + @Test + fun `keeps and resets the vendor weight anchor at its exact bucket boundaries`() { + assertThat(PicoocWhiteBodyComposition.anchorWeight(82.7f, 81)).isEqualTo(81) + assertThat(PicoocWhiteBodyComposition.anchorWeight(83.0f, 81)).isEqualTo(83) + assertThat(PicoocWhiteBodyComposition.anchorWeight(79.9f, 81)).isEqualTo(79) + } + + @Test + fun `cold start derives profile-neutral anchors from the current measurement`() { + val night = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = true, + heightCm = 176f, + age = 39, + weightKg = 82.7f, + correctedImpedanceOhm = 500, + anchorWeightKg = PicoocWhiteBodyComposition.anchorWeight(82.7f, null), + anchorBeta = 0, + hour = 0, + ) + )!! + val morning = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = true, + heightCm = 176f, + age = 39, + weightKg = 82.7f, + correctedImpedanceOhm = 500, + anchorWeightKg = PicoocWhiteBodyComposition.anchorWeight(82.7f, null), + anchorBeta = 0, + hour = 8, + ) + )!! + + assertThat(night.anchorBeta).isEqualTo(23) + assertThat(night.bodyFatPercent).isWithin(0.05f).of(29.3f) + assertThat(morning.anchorBeta).isEqualTo(24) + assertThat(morning.bodyFatPercent).isWithin(0.05f).of(29.0f) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandlerTest.kt new file mode 100644 index 000000000..5a33e9f50 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/PicoocBroadcastHandlerTest.kt @@ -0,0 +1,185 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.util.SparseArray +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.libs.PicoocWhiteBodyComposition +import com.health.openscale.core.data.MeasurementType +import com.health.openscale.core.service.ScannedDeviceInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Parser regression tests using advertisements captured from a physical PICOOC Mini Lite. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PicoocBroadcastHandlerTest { + + private val packetA = hex("02010609095049434F4F432D4C11FFD049004B4F1E391F065E13388704009C") + private val packetB = hex("02010609095049434F4F432D4C11FFD049004B4F1E391F066013748706005C") + private val packetC = hex("02010609095049434F4F432D4C11FFD049004B4F1E391F0676136087200040") + + private fun hex(value: String): ByteArray = value.removePrefix("0x").chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + + /** Extract the type-0xFF AD value and split it the same way Android's ScanRecord does. */ + private fun manufacturerEntry(advertisement: ByteArray): Pair { + var offset = 0 + while (offset < advertisement.size) { + val length = advertisement[offset].toInt() and 0xFF + require(length > 0 && offset + length < advertisement.size) + val type = advertisement[offset + 1].toInt() and 0xFF + if (type == 0xFF) { + val id = (advertisement[offset + 2].toInt() and 0xFF) or + ((advertisement[offset + 3].toInt() and 0xFF) shl 8) + return id to advertisement.copyOfRange(offset + 4, offset + length + 1) + } + offset += length + 1 + } + error("manufacturer data not found") + } + + private fun parse(packet: ByteArray): PicoocMiniLiteAdv.Frame? { + val (id, payload) = manufacturerEntry(packet) + return PicoocMiniLiteAdv.parse(id, payload) + } + + private fun device(name: String, packet: ByteArray? = packetC): ScannedDeviceInfo { + val manufacturerData = packet?.let { + val (id, payload) = manufacturerEntry(it) + SparseArray().apply { put(id, payload) } + } + return ScannedDeviceInfo( + name = name, + address = "D0:49:00:4B:4F:1E", + rssi = -50, + serviceUuids = emptyList(), + manufacturerData = manufacturerData, + ) + } + + @Test + fun `parses all captured measurements`() { + val a = parse(packetA)!! + val b = parse(packetB)!! + val c = parse(packetC)!! + + assertThat(a.weightKg).isWithin(1e-3f).of(81.5f) + assertThat(a.impedanceOhm).isEqualTo(492.0f) + assertThat(b.weightKg).isWithin(1e-3f).of(81.6f) + assertThat(b.impedanceOhm).isEqualTo(498.0f) + assertThat(c.weightKg).isWithin(1e-3f).of(82.7f) + assertThat(c.impedanceOhm).isEqualTo(496.0f) + } + + @Test + fun `recognises the vendor apps completed-state marker`() { + val frame = parse(packetC)!! + + assertThat(frame.state).isEqualTo(0x39) + assertThat(frame.protocol).isEqualTo(0x1F) + assertThat(frame.complete).isTrue() + assertThat(frame.displayUnit).isEqualTo(0) + } + + @Test + fun `a checksummed non-final state remains in progress`() { + val changed = packetC.copyOf() + changed[21] = 0x38 + changed[30] = checksum(changed.copyOfRange(15, 30)).toByte() + + val frame = parse(changed)!! + assertThat(frame.complete).isFalse() + } + + @Test + fun `rejects an incorrect checksum`() { + val corrupted = packetC.copyOf().also { it[30] = (it[30] + 1).toByte() } + assertThat(parse(corrupted)).isNull() + } + + @Test + fun `rejects a truncated manufacturer record`() { + val (id, payload) = manufacturerEntry(packetC) + assertThat(PicoocMiniLiteAdv.parse(id, payload.copyOf(payload.size - 1))).isNull() + } + + @Test + fun `rejects corrupted weight when the checksum is stale`() { + val corrupted = packetC.copyOf().also { it[24] = 0x77 } + assertThat(parse(corrupted)).isNull() + } + + @Test + fun `does not hardcode the captured manufacturer id or MAC`() { + val changed = packetC.copyOf() + changed[15] = 0x12 + changed[16] = 0x34 + changed[17] = 0x56 + changed[18] = 0x78 + changed[19] = 0x11 + changed[20] = 0x22 + changed[30] = checksum(changed.copyOfRange(15, 30)).toByte() + + assertThat(parse(changed)).isNotNull() + } + + @Test + fun `claims only the exact Mini Lite advertised name`() { + val handler = PicoocBroadcastHandler() + + assertThat(handler.supportFor(device("PICOOC-L"))).isNotNull() + assertThat(handler.supportFor(device("picooc-l"))).isNotNull() + assertThat(handler.supportFor(device("PICOOC-L1"))).isNull() + assertThat(handler.supportFor(device("Scale Up"))).isNull() + assertThat(handler.supportFor(device("", packetC))).isNull() + } + + @Test + fun `saved device snapshot remains identifiable without manufacturer data`() { + assertThat(PicoocBroadcastHandler().supportFor(device("PICOOC-L", packet = null))).isNotNull() + } + + @Test + fun `maps skeletal muscle to openScale muscle and keeps PICOOC total muscle separate`() { + val result = PicoocWhiteBodyComposition.calculate( + PicoocWhiteBodyComposition.Input( + male = true, + heightCm = 176f, + age = 39, + weightKg = 82.7f, + correctedImpedanceOhm = 500, + anchorWeightKg = 81, + anchorBeta = 34, + ) + )!! + val measurement = ScaleMeasurement() + + PicoocBroadcastHandler().applyBodyCompositionMeasurements(measurement, result) + + assertThat(measurement[MeasurementType.MUSCLE]?.value).isWithin(0.05f).of(40.4f) + assertThat(measurement[PicoocBroadcastHandler.TOTAL_MUSCLE]?.value).isWithin(0.05f).of(69.6f) + } + + private fun checksum(bytes: ByteArray): Int = + (bytes.sumOf { it.toInt() and 0xFF } and 0xFF).inv() and 0xFF +} diff --git a/android_app/app/src/test/resources/scale_catalog_remarks.txt b/android_app/app/src/test/resources/scale_catalog_remarks.txt index 8c88a56ac..96054829c 100644 --- a/android_app/app/src/test/resources/scale_catalog_remarks.txt +++ b/android_app/app/src/test/resources/scale_catalog_remarks.txt @@ -18,3 +18,4 @@ Trisa Body Analyze 4.0 = See [Trisa Body Analyze](Trisa-Body-Analyze) for the pr ProfiCare PC-PW 3008 BT = Same Chipsea "WeChat scale" firmware as the Hoffen BBS-8107, so it is driven by the same handler HuaweiHagridWspHandler = The scale only answers after a Huawei Hagrid handshake, so CAK, C1 and C2 (32 hex characters each) have to be entered in the device settings first AiLink Body Fat Scale = Non-connectable: the whole measurement is TEA-encrypted inside the advertisement, so no pairing is needed and the vendor "AiLink" app can be removed. Covers the AiLink/eLink broadcast family (advertises service 0xF0A0, often named "EL1") +PICOOC Mini Lite = Tested with the PICOOC-L broadcast protocol. Weight and impedance are recorded, and the adult Caucasian/White body-composition path is a clean-room port of the PICOOC 4.3.0 ARM64 implementation. From eb8493866b5790e6ee8469b61b12f8ed6b556113 Mon Sep 17 00:00:00 2001 From: OliE Date: Sun, 6 Sep 2026 08:53:03 +0200 Subject: [PATCH 71/72] Update Weblate translation (#1513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (811 of 811 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/pt_BR/ * Translated using Weblate (Polish) Currently translated at 32.6% (265 of 811 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/pl/ * Translated using Weblate (Catalan) Currently translated at 42.4% (355 of 837 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/ca/ * Translated using Weblate (Catalan) Currently translated at 100.0% (837 of 837 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/ca/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 98.0% (821 of 837 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/zh_Hans/ * Translated using Weblate (Catalan) Currently translated at 100.0% (837 of 837 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/ca/ * Translated using Weblate (Hebrew) Currently translated at 19.1% (160 of 837 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/he/ * Translated using Weblate (Hebrew) Currently translated at 21.1% (177 of 838 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/he/ * Translated using Weblate (Catalan) Currently translated at 100.0% (838 of 838 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/ca/ * Translated using Weblate (Catalan) Currently translated at 100.0% (861 of 861 strings) Translation: openScale/Strings Translate-URL: https://hosted.weblate.org/projects/openscale/strings/ca/ --------- Co-authored-by: Ddelaqua Co-authored-by: Michał Gątkowski Co-authored-by: Eduard Ereza Martínez Co-authored-by: Rin Co-authored-by: Yaron Shahrabani --- .../app/src/main/res/values-ca/strings.xml | 792 +++++++++++++++++- .../app/src/main/res/values-iw/strings.xml | 23 +- .../app/src/main/res/values-pl/strings.xml | 15 + .../src/main/res/values-pt-rBR/strings.xml | 14 +- .../src/main/res/values-zh-rCN/strings.xml | 128 ++- 5 files changed, 956 insertions(+), 16 deletions(-) diff --git a/android_app/app/src/main/res/values-ca/strings.xml b/android_app/app/src/main/res/values-ca/strings.xml index 55574b833..cf8b842ec 100644 --- a/android_app/app/src/main/res/values-ca/strings.xml +++ b/android_app/app/src/main/res/values-ca/strings.xml @@ -32,7 +32,7 @@ No s’ha trobat cap mesura. No hi ha cap element disponible. Visió general - Gràfiques + Gràfics Taula Estadístiques Anàlisis @@ -42,4 +42,794 @@ Nova mesura Edita la mesura Quant a + Edita el tipus + Afegeix un tipus + Tipus de mesures + Edita l’usuari + Afegeix un usuari + Usuaris + Gestiona els usuaris + Canvia d’usuari. Seleccionat: %1$s + Indicador d’usuari seleccionat + Afegeix un usuari + No s’ha pogut seleccionar l’usuari. + No hi ha cap usuari seleccionat. + Primer seleccioneu un usuari. + No hi ha cap usuari actiu + Creeu un usuari nou o seleccioneu-ne un d’existent per a veure i gestionar les vostres mesures. + S’ha suprimit correctament l’usuari %1$s. + S’ha produït un error en suprimir l’usuari %1$s. + S’ha actualitzat correctament l’usuari %1$s. + S’ha afegit correctament l’usuari %1$s. + S’ha produït un error en actualitzar l’usuari %1$s. + Nom + Alçada + Sexe + Home + Dona + Correcció d’amputacions + Cap + + Avantbraç + Braç sencer + Peu + Part inferior de la cama + Cama sencera + Seleccioneu les extremitats afetades i especifiqueu el nivell d’amputació de cadascuna. + Braç esquerre + Braç dret + Cama esquerra + Cama dreta + Nivell d’activitat + Pesatge assistit + Seleccioneu la persona de referència per a %1$s + No hi ha disponible cap persona de referència adequada. Creeu un usuari que no utilitzi el pesatge assistit. + Cal seleccionar una persona de referència per al pesatge assistit. + Data de naixement + Introduïu dades vàlides + %.1f cm + Edat: %1$d, Sexe: %2$s + Edita l’usuari + Suprimeix l’usuari + Afegeix un usuari nou + Canvia d’unitat + S’ha actualitzat la mesura (%1$s) + S’ha desat la mesura (%1$s) + No s’ha pogut desar la mesura. Torneu-ho a provar. + No s’ha desat — ja existeix una mesura a %1$s. + S’ha suprimit la mesura (%1$s) + No s’ha pogut suprimir la mesura. Torneu-ho a provar. + Desfés + Arrossegueu per a canvair la mida del gràfic o toqueu per a contraure’l + Toqueu per a mostrar el gràfic + Voleu descartar els canvis? + No heu desat els canvis i es perdran. + Descarta + Continua editant + Desa la mesura + Afegeix una mesura + Afegeix una mesura nova + Edita la mesura de %1$s + Suprimeix la mesura de %1$s + Aquesta data i hora coincideix amb la vostra darrera mesura. Trieu una data i hora diferents. + Encara no hi ha cap mesura + Afegiu la primera mesura per a començar a fer un seguiment del vostre progrés. + No hi ha cap valor de mesura actiu per a aquesta entrada. + Nombre no vàlid per a %1$s: «%2$s» + Enter no vàlid per a %1$s: «%2$s» + Nombre no vàlid per a %1$s + Enter no vàlid per a %1$s + Introduïu dades vàlides. + Edita %1$s + Canvia %1$s + Seleccioneu un color + Seleccioneu una icona + Introduïu un valor + Introduïu text + Hora + Minut + Data + Hora + Augmenta %1$s + Disminueix %1$s + Edita %1$s + Mostra’n menys + Mostra’n més + Valor augmentat + Valor disminuït + Valor estable o sense canvis + Icona per a %1$s + Icona per a un tipus de mesura desconegut: %1$s + Tipus desconegut: %1$s + Pes + IMC + Greix corporal + Aigua + Múscul + Massa magra + Os + Cintura + ICM + ICA + Malucs + Greix visceral + Pit + Cuixa + Bíceps + Coll + Plec 1 + Plec 2 + Plec 3 + Plec de greix + TMB + Freqüència cardíaca + Impedància (alta) + Impedància (baixa) + Aigua extracel·lular + Aigua intracel·lular + Proteïna + Massa cel·lular corporal + Fórmula òssia + Regressió basada en impedància (antiga) + Heymsfield (antropomètrica) + Fórmula TMB + Cunningham 1991 + Cunningham 1980 + DEDT + Calories + Comentari + Data + Hora + Usuari + Tipus personalitzat + Activat + Nom + Color + Icona + Unitat + Objectiu + Data de l’objectiu + Seleccioneu una data + Els meus objectius + Objectius de %d + Sense objectius + Suprimeix + Edita l’objectiu: %1$s + Defineix un objectiu: %1$s + No hi ha cap tipus disponible per a definir objectius. + Seleccioneu un tipus + Valor objectiu per a %1$s + El valor objectiu no pot ser buit per a un objectiu nou. + Primer seleccioneu un tipus de mesura. + S’ha suprimit l’objectiu + Tipus d’entrada + Fixat + A l’eix Y de la dreta + Previsualització de la icona seleccionada + Ordena + Edita + Suprimeix + S’ha afegit correctament el tipus de mesura «%1$s». + S’ha produït un error en afegir el tipus de mesura «%1$s». + S’ha suprimit correctament el tipus de mesura «%1$s». + S’ha produït un error en suprimir el tipus de mesura «%1$s». + S’ha produït un error en actualitzar el tipus de mesura «%1$s». + S’ha actualitzat correctament el tipus de mesura «%1$s». + S’ha actualitzat el tipus «%1$s» i s’han convertit %2$s valors correctament. + S’ha produït un error en convertir els valors del tipus «%1$s». La definició del tipus no s’ha actualitzat. + S’ha actualitzat el tipus «%1$s». La unitat ha canviat, però no hi havia cap valor existent que requerís conversió. + Confirmeu el canvi d’unitat + Si canvieu la unitat per a «%1$s» de %2$s a %3$s, es convertiran tots els punts de dades existents. Aquesta acció por tardar una estona i no es pot desfer fàcilment. Voleu continuar? + Fórmula del greix corporal + Fórmula de l’aigua corporal + Fórmula de la massa magra + Càlcul de la fórmula + Aquest valor esdevé de només lectura i calculat automàticament. S’actualitza quan canvien els valors base. S’aplica a mesures noves oe ditades; les existents es mantenen sense canvis. + Desactivat + Deurenberg (1991) + Deurenberg (1992) + Eddy et al. (1976) + Gallagher (2000) – No asiàtics + Gallagher (2000) – Asiàtics + Behnke et al. (1963) + Delwaide i Crenier (1973) + Hume i Weyers (1971) + Lee, Song i Kim (2001) + Boer (1984) + Hume (1966) + Pes − Greix corporal + Marina dels EUA + Sense càlcul automàtic + Aquest valor no es calcula automàticament. Podeu introduir-hi valors manualment. + Regressió clàssica per a adults + Una fórmula poblacional del 1991 molt utilitzada. Adequada per a poblacions d’adults en general; pot ser menys precisa en persones molt magres o molt musculars. + Ajustada per a adolescents i adults + Variant que s’adapta a usuaris més joves. És una estimació de propòsit general; els físics atlètics o atípics se’n poden desviar. + Model primerenc específic per sexe + Regressió antica amb coeficients separats per a homes i dones. Simple i ràpida, però no adaptada a poblacions especials. + Gallagher (no asiàtics) + Model de referència del 2000 per a persones no asiàtiques. Base útil; la precisió varia segons el tipus de cos. + Gallagher (asiàtics) + Model complementari ajustat a les persones asiàtiques. Utilitzeu-lo quan coincideixi millor amb el perfil de l’usuari. + Estimat d’ACT de Behnke + Estimació d’aigua corporal total clàssica. És una aproximació simple; no està individualitzada per a atletes o casos extrems. + Delwaide i Crenier + Fórmula d’ACT centrada en la massa corporal. És una estimació general raonable; la precisió varia segons el físic. + ACT de Hume–Weyers + Model d’ACT comú que utilitza l’alçada i el pes, amb coeficients específics per sexe. + Lee–Song–Kim + Estimació d’ACT posterior calibrada en cohorts coreanes. Considereu-la si coincideix amb l’usuari. + Massa magra de Boer (clínica) + Estimació de massa magra populars en entorns clínics. Regressió específica per sexe a partir de l’alçada i el pes. + Massa magra de Hume + Model primerenc de massa magra. Simple i utilitzat àmpliament; no adaptat a complexions molt atlètiques. + Pes − Greix corporal + Calcula la massa magra com al pes total menys la massa del greix derivada de la fórmula de greix corporal seleccionada. La precisió depèn de l’estimació del greix corporal. + Mètode de la Marina dels EUA + Calcula el percentatge de greix corporal a partir de les circumferències de la cintura, el coll i (en dones) els malucs segons l’estàndard de la Marina dels EUA. + %1$d: %2$s + Voleu confirmar %1$s? + Segur que voleu %2$s %1$d elements? + fixar + deixar de fixar + activar + desactivar + moure a l’eix esquerre + moure a l’eix dret + Fixat + No fixat + Activat + Desactivat + Mogut a l’eix dret + Mogut a l’eix esquerre + No s’ha pogut connectar amb la bàscula. Assegureu-vos que estigui engegada. + bàscula desada + S’està processant el Bluetooth + Configura una bàscula Bluetooth + Desconnecta’t de %1$s + Torna a provar de connectar a %1$s + S’ha produït un error de Bluetooth, reviseu la configuració + Connecta’t a %1$s + Revisa la configuració del Bluetooth + S’està connectant a %1$s… + S’està desconnectant de %1$s… + S’està processant amb %1$s… + No hi ha cap bàscula Bluetooth desada. Afegiu una bàscula a la configuració. + S’està tornant a provar de connectar a %1$s… + S’ha produït un error de Bluetooth. Reviseu la configuració. + S’està provant de connectar a %1$s… + Reviseu la configuració del Bluetooth. + Activeu el Bluetooth per a començar a escanejar. + Calen permisos de Bluetooth per a escanejar. + El Bluetooth està activat, però encara manquen els permisos. + El Bluetooth ha d’estar activat per a cercar bàscules. + Bàscula desada: + Desconegut + Atura l’escaneig + Cerca bàscules + Botó de cerca de bàscules + Dispositius trobats: + S’ha desat «%1$s» com a bàscula preferida. + %1$s actualment no és compatible. + No s’ha trobat cap dispositiu. Inicieu un escaneig nou. + Indicador de bàscula desada + Dispositiu compatible + Dispositiu no compatible + Compatible + No compatible + %1$d dBm + Desa com a preferida + Desa com a mode de desenvolupador + S’ha eliminat el dispositiu desat. + S’ha activat el mode de desenvolupament. Activeu el fitxer de registre: Configuració → General → Activa el fitxer de registre. + S’ha activat el mode de desenvolupament en aquest dispositiu. Activeu el fitxer de registre: Configuració → General → Activa el fitxer de registre. + El mode de desenvolupador està actiu — no es desarà cap mesura. + Activeu el fitxer de registre: Configuració → General → Activa el fitxer de registre. + Voleu activar el mode de desenvolupador? + L’openScale només registrarà el trànsit Bluetooth de %1$s per a fer-ne un diagnòstic. Mentre el mode de desenvolupador estigui actiu, no es desarà cap mesura. Podeu desactivar-lo a la pantalla Bluetooth o a la configuració del dispositiu. + %1$s esdevé la vostra bàscula desada i substitueix %2$s. + Activa + Desactiva + El mode de desenvolupador està activat + Capacitats + Composició corporal + Sincronització horària + Sincronització d’usuaris + Lectura de l’històric + Pes en temps real + Configuració d’unitats + Bateria + Implementat: %1$s + Compatible (no implementat): %1$s + Més opcions + Configuració de %1$s + Ajustaments del Bluetooth + Perfil d’ajustaments + Mesura per Bluetooth + Assignació intel·ligent d’usuari + Tolerància + Ignora les mesures incertes + Si una mesura és fora de la tolerància per a tots els usuaris, no es desarà. + Accions del dispositiu + Mode de desenvolupador + Elimina el dispositiu desat + Perfil d’ajustaments del Bluetooth + Ajustament del Bluetooth: %1$s + Equilibrat + Conservador + Agressiu + Desviació màxima del pes per a assignar automàticament a un usuari. + Desenvolupador + Zona de perill + Connecta automàticament en iniciar + Configuració de la bàscula + No hi ha cap configuració especial addicional disponible per a aquest dispositiu. + Algoritme de composició corporal + Xiaomi (aplicació original) + Científic + Trieu com es deriva la composició corporal a partir de la impedància. «Xiaomi» reprodueix l’aplicació Mi Fit / Zepp Life original. «Científic» utilitza fórmules revisades per experts (Siri, Pace, Wang, Schofield) i també informa de les proteïnes i de la TMB. S’aplica a les mesures noves d’aquesta bàscula. + Clau d’enllaç del BLE + Clau hexadecimal de 32 caràcters + La bàscula Xiaomi S400 requereix una clau d’enllaç del BLE per al desxifratge. Extraieu aquesta clau del Xiaomi Cloud utilitzar l’eina Xiaomi Cloud Tokens Extractor. + La clau d’enllaç ha de tenir exactament 32 caràcters hexadecimals + S400: No s’ha configurat la clau d’enllaç. Aneu a la configuració del Bluetooth per a introduir la clau del BLE. + Secrets del WSP de Huawei Hagrid + Configureu CAK, C1 i C2 amb 32 caràcters hexadecimal cadascun. Els valors es mantenen a la configuració de l’aplicació per controlador. + S’esperaven 32 caràcters hexadecimals + Desat + S’ha connectat a %1$s + No s’ha pogut connectar a %1$s: %2$s + S’ha produït un error amb %1$s: %2$s + %1$s no és compatible. + No s’ha trobat el controlador de %1$s o s’ha produït un error intern. + La mesura de %1$s no s’ha pogut assignar a un usuari. + Error: No s’han carregat els tipus de mesura. + No s’ha rebut cap valor de mesura vàlid de %1$s. + S’ha desat la mesura (%1$.1f kg) de %2$s. + S’ha produït un error en desar la mesura de %1$s. + %1$s: %2$s + S’està escoltant %1$s… + S’ha rebut una mesura de %1$s. + Icona de «calen permisos» + Calen permisos + Per a cercar bàscules, cal poder accedir al Bluetooth. + Concedeix els permisos + Icona de Bluetooth desactivat + Bluetooth desactivat + Activeu el Bluetooth per a cercar bàscules. + Activa el Bluetooth + Icona d’error + Avís important + L’openScale és un projecte de codi obert i no disposo de totes les bàscules. Tingueu en compte que no es pot garantir la compatibilitat en tots els casos, ja que els protocols Bluetooth poden canviar o no estar documentats.\n\nCom a projecte de codi obert, són molt benvingudes les contribucions i peticions de baixada per a millorar la compatibilitat amb les bàscules! + Accepta i desa + Vés al lloc web del projecte + Procés d’inicialització... + S’ha perdut la connexió + S’ha desconnectat la connexió + No s’ha trobat el dispositiu. %s + S’ha produït un error inesperat: %s + S’ha rebut un tipus de dades inesperat: %s + Missatge de la bàscula (id.: %1$d, valor: %2$s) + Cal seleccionar un usuari + Usuari núm. %1$d (edat: %2$d, pes: %3$d) + Cal el consentiment per a l’usuari %1$d (índex %2$d). + Estat o missatge desconegut del controlador: %s + L’adaptador ja està ocupat amb un altre dispositiu (%s). + S’ha produït un error en iniciar el procés de connexió a %1$s: %2$s + No connectat per a la petició de mesura. + La mesura s’hauria d’iniciar automàticament (controlador antic). + Error: Manquen dades de l’usuari per a la selecció. + Error: S’han rebut dades no vàlides per al consentiment. + Error: Manquen dades necessàries per al consentiment. + Error: S’ha rebut una càrrega de dades no vàlida per a la interacció de l’usuari. + Icona d’interval de temps + Filtra les dades del gràfic + %s seleccionat + El filtre de mesures és visible + El filtre de mesures està ocult + %1$s (%2$d entrades) + Tots els dies + Els darrers 7 dies + Els darrers 30 dies + Els darrers 365 dies + Dies personalitzats + Del %1$s al %2$s + Interval de temps + Agregació + %1$s seleccionat + Sense agregació + Dia + Setmana + Mesos + Any + No hi ha disponible cap tipus de gràfic traçable. + No hi ha dades per a mostrar. + No hi ha dades per a %1$s en l’interval seleccionat. + aquest tipus + Seleccioneu els tipus de mesura per al gràfic. + No hi ha dades per a mostrar ni tipus per a seleccionar. + No hi ha dades disponibles per als tipus seleccionats. + Mostra/amaga el filtre de mesures + Data + No hi ha cap columna seleccionada o no hi ha cap mesura disponible. + No hi ha cap columna seleccionada per a mostrar. + No hi ha dades disponibles per a la selecció de columnes actual. + Tendeix a l’alça + Tendeix a la baixa + Seleccioneu elements + %1$d seleccionats + Cancel·la la selecció + Canvia l’usuari per als elements seleccionats + Exporta els elements seleccionats + Suprimeix els elements seleccionats + Voleu suprimir l’usuari %1$s? + Totes les dades d’aquest usuari se suprimiran permanentment. Aquesta acció no es pot desfer. + Voleu suprimir l’objectiu de %1$s? + S’eliminarà l’objectiu. L’històric de mesures no es veurà afectat. + Voleu suprimir la mesura? + Segur que voleu suprimir la mesura de %1$s (%2$s)? Aquesta acció no es pot desfer. + Voleu suprimir els elements? + Segur que voleu suprimir l’element seleccionat? Aquesta acció no es pot desfer. + Segur que voleu suprimir els %1$d elements seleccionats? Aquesta acció no es pot desfer. + Voleu suprimir el tipus? + Segur que voleu suprimir el tipus personalitzat «%1$s»? Totes les mesures associades també se suprimiran permanentment. Aquesta acció no es pot desfer. + S’han suprimit %1$d mesures + No s’han pogut suprimir %1$d de %2$d mesures. + Seleccioneu l’usuari a qui assignar + No hi ha cap altre usuari disponible per a canviar-hi. + S’han mogut %1$d mesures + No s’han pogut moure %1$d de %2$d mesures (ja existeixen per a aquell usuari). + Dades limitades + La vostra anàlisi de %1$s + Recomposició corporal + Volum + Pèrdua mixta + Augment de greix + Calen almenys %1$d mesures per a analitzar com ha canviat el vostre cos amb el temps. + Calen almenys %1$d mesures per dia de la setmana per a detectar hàbits setmanals. + Calen almenys %1$d anys de dades per a detectar patrons estacionals. + Calen almenys %1$d mesures per a detectar valors inusuals. + Millor mes + Augment + Disminució + Estable + Ritme / mes + Volatilitat + Estable + Moderada + Alta + Període estable + %1$d dies + Els vostres hàbits setmanals + Tendiu a registrar valors més alts el %1$s i més baixos el %2$s + Patró preliminar — mesureu més sovint per a resultats més fiables + El vostre any en perspectiva + Els vostres valors tendeixen a tenir el punt màxim el %1$s i el mínim el %2$s + Només hi ha un any de dades — continueu prenent mesures per a descobrir patrons estacionals + Mesures inusuals + No s’ha detectat cap mesura inusual. + %1$s respecte dels %2$s esperats + %1$s + No hi ha cap canvi significatiu durant %1$d dies — pot ser que el vostre cos s’estigui adaptant. + Tendència a la baixa recent — canvi positiu en comparació a la vostra direcció a llarg termini. + Tendència a l’alça recent — tingueu present la direcció a llarg termini. + Alta fluctuació detectada — això és normal i pot reflectir canvis naturals del cos, com ara la retenció d’aigua. + Els vostres valors són molt consistents — base de dades fiable. + La vostra composició corporal + Calen almenys %1$d mesures amb les quadres mètriques (pes, greix corporal, múscul i aigua) durant els darrers %2$d dies per a calcular aquesta anàlisi. + El pes i el greix estan baixant mentre que la massa muscular es manté o fins i tot augmenta lleugerament — esteu perdent principalment massa de greix. + El vostre cos s’està recomposant eficientment: esteu perdent greix mentre que esteu desenvolupant massa muscular significativa. + Esteu guanyant massa muscular mentre que el greix corporal es manté estable o disminueix — una tendència positiva en la composició corporal. + Advertiment: Esteu perdent pes, però la vostra massa muscular també està disminuint. + La massa de greix està augmentant. Si no és una fase de volum deliberada, considereu revisar els vostres nivells d’activitat. + Tant el greix com el múscul estan augmentant — sembla una fase de volum. Si el vostre objectiu és augmentar la massa muscular, considereu si l’augment de greix és dins del vostre interval objectiu. + Totes les dades són estables — esteu en una fase de manteniment. + Encara no s’ha detectat cap patró clar — calen més mesures que incloguin el pes, el greix corporal, el múscul i l’aigua. + Senyals oposats en %1$s — les tendències del pes, el greix i el múscul no mostren encara un patró clar. + El greix està disminuint mentre que el múscul està augmentant — el millor resultat possible. + Tant el greix com el múscul estan augmentant. + Tant el greix com el múscul estan augmentant. El pes està baixant, però també s’està perdent massa muscular. + El greix està augmentant mentre que el múscul està disminuint o estable — el resultat menys favorable. + Inici + Ara + Ritme / mes + de mitjana + estable + moderada + volàtil + %1$d dies de replà + curt termini + llarg termini + No hi ha cap tipus de mesura rellevant disponible o configurat per a les estadístiques. + Mín. + Màx. + Mit. + Augment + Disminució + Sense canvis + General + Usuari + Tipus de mesura + Bluetooth + Gràfic + Gestió de dades + Quant a + Configuració general + Llengua + Recordatori + Funció de recordatori + Text del recordatori + Introduïu aquí el text del recordatori + Hora + S’ha activat el recordatori + S’ha desactivat el recordatori + Hora de pesar-se + Recordatoris + openScale + Dies + S’ha denegat el permís de notificacions + Aparença + Colors dinàmics + Contrast alt + dl. + dt. + dc. + dj. + dv. + ds. + dg. + Tots + Retroacció + Vibració quan hi ha una nova mesura + S’ha activat la vibració quan hi ha una mesura + S’ha desactivat al vibració quan hi ha una mesura + Mostra els punts de dades + Mostra les línies d’objectiu + Algoritme de suavitzat + Alfa + Mida de la finestra + Escletxa màxima per a suavitzar (dies) + Sense suavitzat + Mitjana mòbil + Suavitzat exponencial + Mostra la projecció + Base per a la projecció (dies) + Projecta durant (dies) + Model de la projecció + Lineal + Quadràtic + Cúbic + Versió: %1$s (%2$s) + Informació del projecte + Mantenidor + Icona de mantenidor + Projecte al GitHub + Pàgina oficial del projecte + Icona de pàgina d’inici + Detalls de la llicència del programari + Icona de llicència + Diagnòstic + Icona de registre al fitxer + Registre al fitxer + Voleu activar el registre al fitxer? + Quan està activat, l’aplicació desa registres detallats de manera local al vostre dispositiu. Són útils per a resoldre problemes. Els fitxers poden contenir informació sensible. Compartiu-los només amb persones en qui confieu. + En activar el registre, es començarà immediatament una sessió nova al registre. Es crearà un fitxer de registre nou i l’antic se suprimirà. + S’ha activat el registre al fitxer + S’ha desactivat el registre al fitxer + S’ha exportat correctament el fitxer de registre. + S’ha produït un error en exportar el fitxer de registre. + No s’ha trobat cap fitxer de registre a exportar. + No hi ha cap fitxer de registre disponible per a exportar. + S’ha cancel·lat l’exportació. + No s’ha trobat cap aplicació adequada per a exportar el fitxer. + Exporta el fitxer de registre + Exporta les mesures (CSV) + Importa les mesures (CSV) + Fes una còpia de seguretat de la base de dades + Restaura la base de dades + Suprimeix totes les dades de mesures + Suprimeix la base de dades sencera + Zona de perill + S’ha produït un error desconegut + Còpies de seguretat automàtiques + Activa les còpies de seguretat automàtiques + Commuta les còpies de seguretat automàtiques + Estat de la còpia de seguretat + Informació de l’estat de la còpia de seguretat + Ubicació de la còpia de seguretat + Configuració de la ubicació de la còpia de seguretat + Obre la ubicació de la còpia de seguretat + Canvia la ubicació de la còpia de seguretat + Interval de la còpia de seguretat + Configuració de l’interval de la còpia de seguretat + Canvia l’interval de la còpia de seguretat + Comportament de la còpia de seguretat + Configuració del comportament de la còpia de seguretat + Darrera còpia de seguretat: %1$s + Les còpies de seguretat automàtiques estan desactivades + Crea sempre un fitxer de còpia de seguretat nou + Sobreescriu el fitxer de còpia de seguretat existent + Per defecte: Carpeta específica de l’aplicació + Simulat: L’usuari ha triat una altra carpeta + Seleccioneu l’intrval de còpia de seguretat + No s’ha configurat la ubicació de la còpia de seguretat + No s’ha configurat la ubicació. Les còpies de seguretat automàtiques estan pausades. + S’ha produït un error en accedir a la ubicació de la còpia de seguretat + Icona de supressió + Seleccioneu el directori de la còpia de seguretat + Seleccioneu la ubicació de la còpia de seguretat + Carpeta seleccionada + No s’ha trobat cap aplicació per a obrir la carpeta. + No s’ha pogut obrir la ubicació de la còpia de seguretat. + S’ha establert la ubicació de la còpia de seguretat a: %1$s + S’ha cancel·lat la selecció de la carpeta. La còpia de seguretat automàtica no està activada. + Darrera còpia de seguretat: %1$s + Darrera còpia de seguretat: Mai + La darrera còpia de seguretat va fallar el %1$s: %2$s + Següent còpia de seguretat: %1$s + Següent còpia de seguretat: no programada + La carpeta de còpia de seguretat ja no és accessible. Torneu a seleccionar-la. + No s’ha pogut crear el fitxer de còpia de seguretat a la carpeta seleccionada. + No s’ha pogut escriure la base de dades al fitxer de còpia de seguretat. + Cada dia + Cada setmana + Cada mes + Exporta: Seleccioneu l’usuari + Importa: Seleccioneu l’usuari + Suprimeix: Seleccioneu l’usuari + Voleu suprimir-lo permanentment? + ATENCIÓ! Voleu suprimir la base de dades sencera? + Voleu restaurar la base de dades? + Confirmeu la supressió de la base de dades + Segur que voleu suprimir permanentment TOTES les dades de mesures de l’usuari «%1$s»? Aquesta acció no es pot desfer. + SÍ, SUPRIMEIX-HO TOT + Això suprimirà TOTES les dades d’aquesta aplicació DE MANERA IRREVERSIBLE, incloent-hi tots els usuaris, la configuració i les dades de mesures. Aquesta acció NO es pot desfer. Segur que voleu continuar?\n\nCaldrà reiniciar l’aplicació després d’aquesta acció. + Segur que voleu sobreescriure la base de dades actual amb una còpia de seguretat? Totes les dades actuals no desades es perdran. És possible que calgui reiniciar l’aplicació després de la restauració. + SÍ, RESTAURA-LA + Segur que voleu suprimir la base de dades sencera? Se suprimiran TOTS els usuaris i TOTES les dades i no es podrà desfer. + S’han exportat correctament les dades. + L’exportació no és possible: No s’ha definit cap camp de dades específiques per a l’exportació. + No hi ha cap mesura disponible per a aquest usuari per a exportar. + No s’ha trobat cap valor de mesura exportable per a aquest usuari. + L’exportació ha fallat: No s’ha pogut crear el fitxer. + S’ha produït un error durant l’exportació: %1$s + No hi ha cap usuari disponible per a exportar. + S’han importat correctament %1$d registres de mesures. + S’han importat correctament %1$d registres%2$s. + S’han importat correctament %1$d registres de mesures. + S’han ignorat %1$d files (data i hora duplicada). + S’han omès %1$d files perquè hi mancaven dates. + S’han omès %1$d files perquè s’hi han produït errors d’anàlisi de dates. + No s’han pogut analitzar %1$d valors individuals. + No s’han trobat dades vàlides al fitxer CSV o bé totes les files contenien errors. + La importació ha fallat: Manca la columna obligatòria «date» a la capçalera del CSV. + La importació ha fallat: No s’ha pogut llegir el fitxer. + S’ha produït un error durant la importació: %1$s + No hi ha cap usuari disponible per a importar. + No hi ha dades d’usuari disponibles per a suprimir. + S’han suprimit totes les dades de mesures de %1$s. + No s’han trobat dades de mesures de %1$s. + S’ha produït un error en suprimir les dades de %1$s. + No s’ha pogut iniciar el procés de supressió: No s’ha seleccionat cap usuari. + Error: No s’ha pogut obtenir el nom de la base de dades. + Error: No s’ha trobat el fitxer «%1$s» de la base de dades principal. + La còpia de seguretat ha fallat: No s’ha pogut crear el fitxer de còpia de seguretat a la ubicació seleccionada. + S’ha produït une rror durant la còpia de seguretat de la base de dades: %1$s + S’ha fet una còpia de seguretat de la base de dades correctament. + La restauració ha fallat: No s’ha pogut llegir el fitxer de còpia de seguretat. + La restauració ha fallat: El fitxer de còpia de seguretat no és un arxiu ZIP vàlid o està corrupte. + La restauració ha fallat: No s’han trobat els fitxers essencials de la base de dades a la còpia de seguretat. + S’ha produït un error durant la restauració de la base de dades: %1$s + S’ha restaurat correctament la base de dades. Reinicieu l’aplicació. + S’ha suprimit la base de dades sencera. + S’ha produït un error en suprimir la base de dades sencera. + No és possible l’avaluació: No hi ha cap rang d’edat coincident al moment de la mesura. + Valor inusual: fora de l’interval plausible (%1$.0f–%2$.0f%%). + Aquesta bàscula no s’ha emparellat.\n\nMantingueu premut el botó de la part inferior de la bàscula per a posar-la en mode d’emparellament, i llavors torneu-vos-hi a connectar per a obtenir la contrasenya del dispositiu. + S’ha completat l’emparellament.\n\nTorneu-vos-hi a connectar per a obtenir les dades de mesura. + buit + Usuari %1$d - %2$s + Usuari %1$d - %2$s (coincideix amb el vostre nom) + Creeu un usuari nou a la bàscula. + Nivell de bateria baix (%d%%), recarregueu la bateria o canvieu les piles de la bàscula + Pugeu a la bàscula amb els peus nus per a les mesures de referència + Pes mesurat: %.2f + S’ha arribat al nombre màxim d’usuaris concurrents de la bàscula + llegir i escriure dades de l’openScale, incloent-hi informació d’usuari i totes les mesures desades + Llegir i escriure dades de l’openScale + Seleccioneu un usuari a la bàscula + Seleccioneu el vostre usuari de la llista o creeu-ne un de nou. + Selecció d’usuari + Introduïu el codi de confirmació + Introduïu el codi que es mostra a la vostra bàscula. + Introduïu el codi + Codi de confirmació + La llista d’usuaris és buida o corrupta. + Error: No s’han pogut carregar correctament les dades dels usuaris. + Seleccioneu un usuari. + Introduïu un codi vàlid. + S’han desat %1$d mesures + Pugeu a la bàscula amb els peus nus + S’utilitza l’assignació existent: usuari %1$d de l’aplicació ↔ espai %2$d de la bàscula. + Introduïu el codi de consentiment per a l’espai %1$d. + No hi ha disponible cap codi de consentiment. Es poden llegir les mesures, però no es pot canviar la configuració de l’usuari. + Per a accedir a totes les funcionalitats, creeu un usuari nou a la bàscula. + S’està creant un usuari nou a la bàscula… + No s’ha trobat cap assignació d’usuaris; s’està creant un usuari nou a la bàscula… + S’està esperant la mesura mitjançant les notificacions. + S’ha enllaçat l’usuari %1$d de l’aplicació a l’espai %2$d de la bàscula. + Mantingueu premut el botó Bluetooth de la bàscula fins que mostri l’indicador parpellejant d’emparellament. + La bàscula s’ha emparellat amb l’openScale. + S’han llegit %1$d mesures noves de la bàscula. + No hi ha cap mesura nova emmagatzemada a la bàscula. + Usuari %1$d de la bàscula — %2$.1f kg el %3$s + Usuari %1$d de la bàscula — no hi ha mesures + Model «%1$s» d’Omron no compatible; no se’n poden descodificar els registres de mesures. + Aquest dispositiu no exposa els ervei de transferència d’Omron. + La bàscula no està en mode d’emparellament. Mantingueu premut el botó de Bluetooth fins que l’indicador d’emparellament parpellegi i torneu-ho a provar. + No s’ha pogut emmagatzemar la clau d’emparellament a la bàscula. + La bàscula ha rebutjat iniciar una transferència de dades. + No s’han pogut llegir les mesures emmagatzemades de la bàscula. + La bàscula ha deixat de respondre; s’ha cancel·lat la transferència. + La bàscula té la bateria baixa (%1$d%%). + No s’ha pogut crear l’usuari a la bàscula (codi %1$d). + La bàscula no té espais d’usuari lliures. Suprimiu un usuari del dispositiu i torneu-ho a provar. + No s’han pogut activar les notificacions per a %1$s. + No s’ha pogut escriure a %1$s. + No s’ha pogut canviar l’estat de notificació per a %1$s. + El codi de consentiment no ha estat acceptat. Reviseu-lo i torneu-ho a provar. + El codi de consentiment no ha estat acceptat. Reviseu-lo i torneu-ho a provar. + S’ha produït un error en lliurar la retroacció a l’usuari: %1$s + S’està reconnectant… (intent %1$d/%2$d) + S’està esperant la mesura mitjançant les notificacions. + No hi ha cap perifèric per a setNotifyOn (%1$s) + No s’ha trobat la característica %1$s + No hi ha cap perifèric per a write(%1$s) + No hi ha cap perifèric per a read(%1$s) + No s’ha pogut escriure a %1$s: %2$s + No s’ha pogut notificat l’estat per a %1$s: %2$s + No hi ha cap usuari seleccionat + Error + El gestor ha fallat en connectar-se: %1$s + S’ha produït un error d’anàlisi al gestor per a %1$s: %2$s + No s’ha detectat cap adaptador de Bluetooth en aquest dispositiu. + No s’ha trobat el dispositiu. Assegureu-vos que estigui engegat i dins de l’abast. + S’utilitza l’assignació existent de l’usuari %1$s a l’espai %2$s de la bàscula. S’està provant d’obtenir el consentiment automàtic. + Format de PIN no vàlid. Introduïu un número vàlid. + No s’ha pogut registrar l’usuari a la bàscula. Error: %1$s + No s’ha pogut confirmar l’usuari a la bàscula. Error: %1$s + No s’ha pogut obtenir la llista d’usuaris de la bàscula. Error: %1$s + No s’ha pogut configurar l’usuari. No s’ha pogut obtenir la llista d’usuaris de la bàscula. + Creeu un usuari nou a la bàscula (espai assignat per UDS) + Espai P%1$d: %2$s (usuari actual de l’aplicació) + Espai P%1$d: Utilitzat per %2$s (Voleu seleccionar-lo per a assignar-vos-el?) + Espai P%1$d: Ocupat (no assignat) + P%1$d: %2$s (actual) + P%1$d: Utilitzat per %2$s (Voleu assignar-vos-el?) + P%1$d: Ocupat (no enllaçat) + P%1$d: Crea «%2$s» aquí + La bàscula té la bateria baixa. + Afegit per una bàscula + Columna del CSV + Aquesta columna del CSV ja s’està utilitzant. + Greix corporal (braç esquerre) + Greix corporal (braç dret) + Greix corporal (tronc) + Greix corporal (cama esquerra) + Greix corporal (cama dreta) + Múscul (braç esquerre) + Múscul (braç dret) + Múscul (tronc) + Múscul (cama esquerra) + Múscul (cama dreta) + De %1$s ençà + Seleccioneu un interval de dates + Sense data de fi — sempre fins a avui. + Esborreu la data de fi per a incloure-hi sempre avui. + Esborra la fi + L’interval de temps s’aplica a totes les pantalles + L’interval de temps s’aplica només a aquesta pantalla + L’agregació s’aplica a totes les pantalles + L’agregació només s’aplica a aquesta pantalla + %1$d columnes no coincidien amb cap mesura i s’han omès. diff --git a/android_app/app/src/main/res/values-iw/strings.xml b/android_app/app/src/main/res/values-iw/strings.xml index b5a498e57..6d158389e 100644 --- a/android_app/app/src/main/res/values-iw/strings.xml +++ b/android_app/app/src/main/res/values-iw/strings.xml @@ -89,11 +89,11 @@ מחיקת משתמש הוספת משתמש חדש החלפת יחידה - המדידה עודכנה בהצלחה - המדידה נשמרה בהצלחה - שגיאה בשמירת המדידה - המדידה %1$s נמחקה בהצלחה - שגיאה במחיקת המדידה + המדידה עודכנה (%1$s) + המדידה נשמרה (%1$s) + לא ניתן לשמור את המדידה. נא לנסות שוב. + מדידה נמחקה (%1$s) + לא ניתן למחוק מדידה. נא לנסות שוב. שמירת מדידה הוספת מדידה הוספת מדידה חדשה @@ -135,4 +135,17 @@ ירכיים שומן בטני חזה + פתיחה + השינוי האחרון לא סונכרן. נא לפתוח את openScale-sync ולהפעיל סנכרון ידני. + תובנות + המשתמש/ת %1$s נוספו בהצלחה. + לא נשמר - כבר קיימת מדידה ב־%1$s. + הסגה + נגיעה תציג את התרשים + לסלק את השינויים? + השינויים שלך לא נשמרו והם ילכו לאיבוד. + סילוק + להמשיך לערוך + שינוי %1$s + הגדלת %1$s diff --git a/android_app/app/src/main/res/values-pl/strings.xml b/android_app/app/src/main/res/values-pl/strings.xml index 46137eac3..87504fe9d 100644 --- a/android_app/app/src/main/res/values-pl/strings.xml +++ b/android_app/app/src/main/res/values-pl/strings.xml @@ -226,4 +226,19 @@ Deurenberg (1991) Deurenberg (1992) Eddy et al. (1976) + Ostatnia zmiana nie została zsynchronizowana. Otwórz openScale-sync i uruchom ręczną synchronizację. + Otwórz + Użytkownik %1$s dodany pomyślnie. + Cofnij + Język + Wygląd + Dynamiczne kolory + Wysoki kontrast + Wibruj przy pomiarze + Włączono wibracje przy pomiarze + Wyłączono wibracje przy pomiarze + Nieznany błąd + Usuń bazę danych + Automatyczna kopia zapasowa + Włącz automatyczną kopię zapasową diff --git a/android_app/app/src/main/res/values-pt-rBR/strings.xml b/android_app/app/src/main/res/values-pt-rBR/strings.xml index 4a07f962d..ab8388885 100644 --- a/android_app/app/src/main/res/values-pt-rBR/strings.xml +++ b/android_app/app/src/main/res/values-pt-rBR/strings.xml @@ -542,7 +542,7 @@ Backups Automáticos Ativar backups automáticos Alternar backups automáticos - Status do último backup + Status do backup Informações do status do backup Diretório de backup Configuração do diretório de backup @@ -770,6 +770,16 @@ Continuar editando Não foi possível excluir %1$d de %2$d medições. Não foi possível mover %1$d de %2$d medições (já existem para esse usuário). - A última mudança não foi sincronizada. Por favor abra openScale-sync e execute a sincronização manual + A última alteração não foi sincronizada. Por favor, abra o openScale-sync e execute uma sincronização manual. Abrir + Algoritmo de composição corporal + Xiaomi (app original) + Científico + Escolha como a composição corporal é derivada da impedância. A opção \"Xiaomi\" reproduz o aplicativo original Mi Fit / Zepp Life. A opção \"Científico\" utiliza fórmulas revisadas por pares (Siri, Pace, Wang, Schofield) e também informa os níveis de proteína e a TMB. Aplica-se a novas medições a partir desta balança. + O último backup falhou em %1$s: %2$s + Próximo backup: %1$s + Próximo backup: não agendado + A pasta de backup não está mais acessível. Por favor, selecione-a novamente. + Não foi possível criar o arquivo de backup na pasta selecionada. + Não foi possível gravar o banco de dados no arquivo de backup. diff --git a/android_app/app/src/main/res/values-zh-rCN/strings.xml b/android_app/app/src/main/res/values-zh-rCN/strings.xml index ef25677a5..4777ec6a2 100644 --- a/android_app/app/src/main/res/values-zh-rCN/strings.xml +++ b/android_app/app/src/main/res/values-zh-rCN/strings.xml @@ -357,7 +357,7 @@ 自动备份 启用自动备份 切换自动备份 - 最后备份状态 + 备份状态 备份状态信息 备份位置 备份位置设置 @@ -477,11 +477,11 @@ 无合适的参考人员可用。请创建一个不使用辅助称重的用户。 必须为辅助称重选择参考人员。 更改单位 - 测量数据已成功更新 - 测量数据已成功保存 - 保存测量数据时出错 - 测量数据 %1$s 已成功删除 - 删除测量数据时出错 + 已更新测量记录(%1$s) + 测量数据已成功保存 (%1$s) + 无法保存测量数据。请再试一次。 + 测量数据 %1$s 已删除 + 无法删除测量数据。请再试一次。 保存测量数据 添加测量数据 添加新测量数据 @@ -609,8 +609,8 @@ 您确定要删除 %1$s (%2$s) 的测量数据吗?此操作无法撤消。 您确定要删除选定的 %1$d 项吗?此操作无法撤消。 您确定要删除自定义类型“%1$s”吗?所有相关的测量数据也将被永久删除。此操作无法撤消。 - 已成功删除 %1$d 项。 - 已成功为 %1$d 项更改用户。 + 已删除 %1$d 项测量记录 + 已移动%1$d项测量记录 版本: %1$s (%2$s) 上次备份: %1$s 备份位置设置为: %1$s @@ -680,4 +680,116 @@ + 未保存——已有测量值于 %1$s。 + 撤销 + 拖曳可调整图表大小,点击可折叠 + 点击显示图表 + 丢弃修改? + 您的更改尚未保存。 + 丢弃 + 继续编辑 + 阻抗(高) + 阻抗(低) + 蛋白质 + 基础代谢率公式 + 启用开发者模式? + 启用 + 禁用 + 开发者模式已激活 + 启动时自动连接 + 身体成分算法 + 上次更改未同步。请打开 openScale-sync 并手动运行同步。 + 打开 + 用户 %1$s 添加成功。 + 细胞外水分 + 细胞内水分 + 身体细胞质量 + 骨量公式 + 基于阻抗的回归(旧版) + Heymsfield(人体测量法) + openScale 只会记录 %1$s 的蓝牙流量以进行诊断。只要开发者模式处于激活状态,就不会保存任何测量数据。您可以在蓝牙设置界面或设备设置中将其关闭。 + 科学公式 + 选择如何通过阻抗计算身体成分。“小米”模式复刻了原版小米运动/Zepp Life应用程序。采用经同行评审的科学公式(Siri、Pace、Wang、Schofield),并同时报告蛋白质和基础代谢率。适用于此体重秤的新测量结果。 + 已保存 + %2$d项测量记录中有%1$d项无法删除。 + %2$d 项测量记录中有 %1$d 项无法移动(该用户已有相同记录)。 + 有限的数据 + 您的 %1$s 分析 + 重组 + 增肌 + 洞察 + %1$s 成为您保存的秤,并替换 %2$s。 + 混合减重 + 脂肪增加 + 至少需要%1$d测量,以分析贵重机构如何随时间变化. + 每周至少需要%1$d测量,以检测每周的习惯. + 至少需要%1$d年的数据才能发现季节性模式. + 至少需要%1$d测量,以发现异常值. + 最佳月份 + 上升 + 下降 + 稳定 + 每月变化率 + 波动程度 + 稳定 + 中等 + + 稳定期 + %1$d天 + 你的每周习惯 + 您的测量值通常在%1$s最高,在%2$s最低 + 初步模式—请更规律地测量,得出可靠结果 + 年度回顾 + 您的数值通常在%1$s达到最高点,在%2$s降至最低点 + 目前只有一年的数据 — 请持续测量,以找到季节性模式 + 异常测量纪录 + 未检测到异常测量结果。 + 与预期值%2$s 相差%1$s + %1$s + 连续 %1$d 天没有明显变化——你的身体可能正在适应。 + 近期呈下降趋势——与长期发展方向相比,这是一个积极的转变。 + 近期呈上升趋势——但要关注长期走势。 + 检测到高波动——这是常见现象,可以反映身体的自然变化,例如水分滞留。 + 你的身体成分 + 最近 %2$d 天內至少需要 %1$d 项同时包含四项指标(体重、体脂肪、消耗量、体水分)的测量记录,才能计算出这个洞察。 + 体重和脂肪减少,肌肉量得以保持甚至略有增长——你减掉的主要是脂肪。 + 你的身体正在高效地进行重组:你正在减脂的同时,也增加了大量的肌肉。 + 你的肌肉量在增加,而体脂保持稳定或减少——这是一个积极的身体成分趋势。 + 警告:你的体重正在减轻,但你的肌肉量也在减少。 + 脂肪量正在增加。如果这不是刻意增肌,请考虑重新评估您的运动量。 + 脂肪和肌肉都在增加——这看起来像是增肌期。如果你的目标是增肌,请考虑脂肪增加量是否在你的目标范围内。 + 所有指标均稳定——您正处于维持期。 + 目前尚未发现明显规律——需要进行更多测量,包括体重、体脂、肌肉和水分。 + %1$s 中的信号很复杂——体重、脂肪和肌肉趋势尚未呈现出清晰的模式。 + 脂肪减少,肌肉增加——这是最理想的结果。 + 脂肪和肌肉都在增加。 + 脂肪和肌肉都在减少。体重在下降,但肌肉量也在流失。 + 脂肪增加而肌肉减少或保持不变——这是最不利的结果。 + 开始 + 现在 + 每月变化率 + 平均 + 稳定 + 中等 + 波动大 + 稳定 %1$d 天 + 短期 + 长期 + 外观 + 动态色彩 + 高度对比 + 上次备份失败于 %1$s:%2$s + 下一次备份:%1$s + 下次备份:未安排 + 备份文件夹已无法访问。请重新选择。 + 无法在选定的文件夹中建立备份文件. + 数据库无法写入备份文件。 + 空白 + 用户%1$d - %2$s + 用户 %1$d - %2$s(与您的姓名匹配) + 按住体重秤上的蓝牙按钮,直到配对指示灯闪烁。 + 体重秤已和 openScale 配对。 + 从秤上读取 %1$d 个新的测量值。 + 秤上未存储任何新的测量数据。 + 用户 %1$d — 无测量数据 From 7ebb55bea94836cd070a9c2eabed6d4bc2afc73b Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 6 Sep 2026 09:05:07 +0200 Subject: [PATCH 72/72] Bump application version to 3.1.3 and code to 77 --- android_app/app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-GB/changelogs/77.txt | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-GB/changelogs/77.txt diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index 29ac76b73..fef5573a3 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -29,8 +29,8 @@ android { applicationId = "com.health.openscale" minSdk = 31 targetSdk = 37 - versionCode = 76 - versionName = "3.1.2" + versionCode = 77 + versionName = "3.1.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders["appName"] = "openScale" diff --git a/fastlane/metadata/android/en-GB/changelogs/77.txt b/fastlane/metadata/android/en-GB/changelogs/77.txt new file mode 100644 index 000000000..71dff71d2 --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/77.txt @@ -0,0 +1,6 @@ +* New scale support: PICOOC, Huawei Scale 3, Omron HBF-702T, Hume Dara 2.0, Terraillon X-LINE, Beurer BF1000, Etekcity Fit 8S, A&D UC-352BLE and more. +* Segmental body fat and muscle per arm, leg and torso, plus metabolic age. +* Scales can add their own measurement types, each with its own CSV column. +* Insights can now be limited to a time range. +* Time range and aggregation can be linked across screens or kept per screen. +* New sync API: openScale-sync 0.6.3 or newer is required.