From 4c911ff9d258113e1d7c2343974ada1318e38226 Mon Sep 17 00:00:00 2001 From: OliE Date: Sat, 18 Jul 2026 08:49:37 +0200 Subject: [PATCH 01/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] =?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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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(), + ) + } +}