From 435cd5f3ffffbe8e5c97057a649d7db50fac8629 Mon Sep 17 00:00:00 2001 From: Leko Date: Sun, 2 Aug 2026 10:07:16 +0800 Subject: [PATCH 1/5] Add Keep S3 scale support --- .../16.json | 417 +++++++++ .../java/com/health/openscale/OpenScaleApp.kt | 10 +- .../openscale/core/bluetooth/ScaleFactory.kt | 3 + .../core/bluetooth/data/ScaleMeasurement.kt | 20 +- .../bluetooth/libs/KeepS3BodyComposition.kt | 251 ++++++ .../core/bluetooth/scales/GattScaleAdapter.kt | 12 +- .../core/bluetooth/scales/KeepS3Handler.kt | 749 ++++++++++++++++ .../bluetooth/scales/ModernScaleAdapter.kt | 43 +- .../bluetooth/scales/ScaleDeviceHandler.kt | 7 + .../com/health/openscale/core/data/Enums.kt | 9 + .../openscale/core/database/AppDatabase.kt | 79 +- .../openscale/core/service/BleConnector.kt | 52 +- .../usecase/MeasurementTypeCrudUseCases.kt | 82 +- .../openscale/core/usecase/SyncUseCases.kt | 1 + .../openscale/core/utils/ConverterUtils.kt | 62 +- .../openscale/core/utils/LocaleUtils.kt | 1 + .../src/main/res/values-zh-rCN/strings.xml | 8 + .../src/main/res/values-zh-rTW/strings.xml | 8 + .../app/src/main/res/values/strings.xml | 10 +- .../libs/KeepS3BodyCompositionTest.kt | 209 +++++ .../bluetooth/scales/KeepS3HandlerTest.kt | 823 ++++++++++++++++++ .../openscale/core/database/MigrationTest.kt | 31 +- .../MeasurementTypeCrudUseCasesTest.kt | 39 + .../core/utils/ConverterUtilsTest.kt | 97 +++ .../openscale/testutil/RoomTestSupport.kt | 2 + 25 files changed, 2949 insertions(+), 76 deletions(-) create mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json 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/schemas/com.health.openscale.core.database.AppDatabase/16.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json new file mode 100644 index 000000000..5e2a8bc4b --- /dev/null +++ b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json @@ -0,0 +1,417 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "394cbd60aafb83b8d4beeb00b58404ea", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `birthDate` INTEGER NOT NULL, `gender` TEXT NOT NULL, `heightCm` REAL NOT NULL, `activityLevel` TEXT NOT NULL, `useAssistedWeighing` INTEGER NOT NULL, `amputations` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthDate", + "columnName": "birthDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gender", + "columnName": "gender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "heightCm", + "columnName": "heightCm", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "activityLevel", + "columnName": "activityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useAssistedWeighing", + "columnName": "useAssistedWeighing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amputations", + "columnName": "amputations", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "user_goals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `measurementTypeId` INTEGER NOT NULL, `goalValue` REAL NOT NULL, `goalTargetDate` INTEGER, PRIMARY KEY(`userId`, `measurementTypeId`), FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`measurementTypeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "measurementTypeId", + "columnName": "measurementTypeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "goalValue", + "columnName": "goalValue", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "goalTargetDate", + "columnName": "goalTargetDate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "measurementTypeId" + ] + }, + "indices": [ + { + "name": "index_user_goals_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_goals_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_user_goals_measurementTypeId", + "unique": false, + "columnNames": [ + "measurementTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_goals_measurementTypeId` ON `${TABLE_NAME}` (`measurementTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MeasurementType", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "measurementTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Measurement", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Measurement_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Measurement_userId` ON `${TABLE_NAME}` (`userId`)" + }, + { + "name": "index_Measurement_userId_timestamp", + "unique": true, + "columnNames": [ + "userId", + "timestamp" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Measurement_userId_timestamp` ON `${TABLE_NAME}` (`userId`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MeasurementValue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `measurementId` INTEGER NOT NULL, `typeId` INTEGER NOT NULL, `floatValue` REAL, `intValue` INTEGER, `textValue` TEXT, `dateValue` INTEGER, FOREIGN KEY(`measurementId`) REFERENCES `Measurement`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`typeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "measurementId", + "columnName": "measurementId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "typeId", + "columnName": "typeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "floatValue", + "columnName": "floatValue", + "affinity": "REAL" + }, + { + "fieldPath": "intValue", + "columnName": "intValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "textValue", + "columnName": "textValue", + "affinity": "TEXT" + }, + { + "fieldPath": "dateValue", + "columnName": "dateValue", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MeasurementValue_measurementId", + "unique": false, + "columnNames": [ + "measurementId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementValue_measurementId` ON `${TABLE_NAME}` (`measurementId`)" + }, + { + "name": "index_MeasurementValue_typeId", + "unique": false, + "columnNames": [ + "typeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementValue_typeId` ON `${TABLE_NAME}` (`typeId`)" + } + ], + "foreignKeys": [ + { + "table": "Measurement", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "measurementId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MeasurementType", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "typeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MeasurementType", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key` TEXT NOT NULL, `name` TEXT, `color` INTEGER NOT NULL, `icon` TEXT NOT NULL, `unit` TEXT NOT NULL, `inputType` TEXT NOT NULL, `displayOrder` INTEGER NOT NULL, `isDerived` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isOnRightYAxis` INTEGER NOT NULL, `isInternal` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputType", + "columnName": "inputType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayOrder", + "columnName": "displayOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDerived", + "columnName": "isDerived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOnRightYAxis", + "columnName": "isOnRightYAxis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInternal", + "columnName": "isInternal", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MeasurementType_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementType_key` ON `${TABLE_NAME}` (`key`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '394cbd60aafb83b8d4beeb00b58404ea')" + ] + } +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt b/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt index de4906f72..81316d39a 100644 --- a/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt +++ b/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt @@ -52,8 +52,13 @@ fun getDefaultMeasurementTypes(): List { MeasurementType(key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT, color = 0xFFEF5350.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isPinned = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WATER, unit = UnitType.PERCENT, color = 0xFF29B6F6.toInt(), icon = MeasurementTypeIcon.IC_WATER, isPinned = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.MUSCLE, unit = UnitType.PERCENT, color = 0xFF66BB6A.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isPinned = true, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.SKELETAL_MUSCLE, unit = UnitType.PERCENT, color = 0xFF43A047.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.LEAN_SOFT_TISSUE, unit = UnitType.KG, color = 0xFF7CB342.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), MeasurementType(key = MeasurementTypeKey.LBM, unit = UnitType.KG, color = 0xFF4DBAC0.toInt(), icon = MeasurementTypeIcon.IC_LBM, isEnabled = true), MeasurementType(key = MeasurementTypeKey.BONE, unit = UnitType.KG, color = 0xFFBDBDBD.toInt(), icon = MeasurementTypeIcon.IC_BONE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.SUBCUTANEOUS_FAT, unit = UnitType.PERCENT, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BODY_AGE, inputType = InputFieldType.INT, unit = UnitType.NONE, color = 0xFF7B1FA2.toInt(), icon = MeasurementTypeIcon.IC_M_PERSON, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT, unit = UnitType.KG, color = 0xFF5E35B1.toInt(), icon = MeasurementTypeIcon.IC_WEIGHT, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WAIST, unit = UnitType.CM, color = 0xFF78909C.toInt(), icon = MeasurementTypeIcon.IC_WAIST, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WHR, unit = UnitType.NONE, color = 0xFFFFA726.toInt(), icon = MeasurementTypeIcon.IC_WHR, isDerived = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WHTR, unit = UnitType.NONE, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_WHTR, isDerived = true, isEnabled = true), @@ -72,6 +77,9 @@ fun getDefaultMeasurementTypes(): List { MeasurementType(key = MeasurementTypeKey.HEART_RATE, inputType = InputFieldType.INT, unit = UnitType.BPM, color = 0xFFE91E63.toInt(), icon = MeasurementTypeIcon.IC_M_HEART_RATE, isEnabled = true), MeasurementType(key = MeasurementTypeKey.IMPEDANCE, unit = UnitType.OHM, color = 0xFF607D8B.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), MeasurementType(key = MeasurementTypeKey.IMPEDANCE_LOW, unit = UnitType.OHM, color = 0xFF455A64.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), + MeasurementType(key = MeasurementTypeKey.DEVICE_IMPEDANCE, unit = UnitType.OHM, color = 0xFF546E7A.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), + MeasurementType(key = MeasurementTypeKey.PHASE_ANGLE, unit = UnitType.DEGREE, color = 0xFF00897B.toInt(), icon = MeasurementTypeIcon.IC_M_SCATTER_PLOT, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.PHASE_ANGLE_HIGH, unit = UnitType.DEGREE, color = 0xFF00695C.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), MeasurementType(key = MeasurementTypeKey.ECW, unit = UnitType.PERCENT, color = 0xFF4FC3F7.toInt(), icon = MeasurementTypeIcon.IC_M_SCATTER_PLOT, isEnabled = true), MeasurementType(key = MeasurementTypeKey.ICW, unit = UnitType.PERCENT, color = 0xFF0288D1.toInt(), icon = MeasurementTypeIcon.IC_M_BUBBLE_CHART, isEnabled = true), MeasurementType(key = MeasurementTypeKey.PROTEIN, unit = UnitType.PERCENT, color = 0xFF9CCC65.toInt(), icon = MeasurementTypeIcon.IC_M_PROTEIN, isEnabled = true), @@ -145,4 +153,4 @@ class OpenScaleApp : Application(), Configuration.Provider { .setWorkerFactory(workerFactory) .build() } -} \ No newline at end of file +} 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/data/ScaleMeasurement.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt index cc5d96275..638091d52 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt @@ -30,17 +30,25 @@ data class ScaleMeasurement( var fat: Float = 0.0f, // must be in percentage var water: Float = 0.0f, // must be in percentage var muscle: Float = 0.0f, // must be in percentage - var visceralFat: Float = 0.0f, // must be in percentage + var visceralFat: Float = 0.0f, // vendor-defined visceral-fat level/index var bone: Float = 0.0f, // must be in kg var lbm : Float = 0.0f, // must be in kg var bmr: Float = 0.0f, // Basal Metabolic Rate in kcal var heartRate: Int = 0, // must be bpm var impedance: Double = 0.0, // Ohms — high-frequency band when the scale is dual-band var impedanceLow: Double = 0.0, // Ohms — low-frequency band; 0 when not reported + var deviceImpedance: Double = 0.0, // Ohms — vendor/protocol value with no verified frequency + var phaseAngle: Float = 0.0f, // degrees — primary/50 kHz phase angle + var phaseAngleHigh: Float = 0.0f, // degrees — 100 kHz phase angle; 0 when not reported var ecw: Float = 0.0f, // Extracellular water, % of body weight var icw: Float = 0.0f, // Intracellular water, % of body weight var protein: Float = 0.0f, // Protein, % of body weight var bcm: Float = 0.0f, // Body cell mass, kg + var skeletalMuscle: Float = 0.0f, // Skeletal muscle, % of body weight + var leanSoftTissue: Float = 0.0f, // Fat-free mass minus bone mass, kg + var subcutaneousFat: Float = 0.0f, // Subcutaneous fat, % of body weight + var bodyAge: Int = 0, // Estimated metabolic/body age, years + var bmi22ReferenceWeight: Float = 0.0f, // kg; height-based BMI 22 reference, not a personalized target ) { // --- Utility methods --- @@ -59,10 +67,20 @@ data class ScaleMeasurement( if (other.heartRate > 0f && this.heartRate <= 0f) this.heartRate = other.heartRate if (other.impedance > 0.0 && this.impedance <= 0.0) this.impedance = other.impedance if (other.impedanceLow > 0.0 && this.impedanceLow <= 0.0) this.impedanceLow = other.impedanceLow + if (other.deviceImpedance > 0.0 && this.deviceImpedance <= 0.0) this.deviceImpedance = other.deviceImpedance + if (other.phaseAngle > 0f && this.phaseAngle <= 0f) this.phaseAngle = other.phaseAngle + if (other.phaseAngleHigh > 0f && this.phaseAngleHigh <= 0f) this.phaseAngleHigh = other.phaseAngleHigh if (other.ecw > 0f && this.ecw <= 0f) this.ecw = other.ecw if (other.icw > 0f && this.icw <= 0f) this.icw = other.icw if (other.protein > 0f && this.protein <= 0f) this.protein = other.protein if (other.bcm > 0f && this.bcm <= 0f) this.bcm = other.bcm + if (other.skeletalMuscle > 0f && this.skeletalMuscle <= 0f) this.skeletalMuscle = other.skeletalMuscle + if (other.leanSoftTissue > 0f && this.leanSoftTissue <= 0f) this.leanSoftTissue = other.leanSoftTissue + if (other.subcutaneousFat > 0f && this.subcutaneousFat <= 0f) this.subcutaneousFat = other.subcutaneousFat + if (other.bodyAge > 0 && this.bodyAge <= 0) this.bodyAge = other.bodyAge + if (other.bmi22ReferenceWeight > 0f && this.bmi22ReferenceWeight <= 0f) { + this.bmi22ReferenceWeight = other.bmi22ReferenceWeight + } if (other.userId != 0xFF && (this.userId == 0xFF || this.userId == -1)) { // -1 was common init value 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/GattScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt index 471ac2494..e71d28089 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt @@ -365,6 +365,16 @@ class GattScaleAdapter( } } + override suspend fun awaitPendingOperations() { + val barrier = CompletableDeferred() + val queued = opQueue.trySend { barrier.complete(Unit) } + if (queued.isFailure) { + LogManager.w(TAG, "Unable to enqueue BLE operation barrier") + return + } + barrier.await() + } + override fun disconnect() { currentPeripheral?.let { central.cancelConnection(it) } } @@ -425,4 +435,4 @@ class GattScaleAdapter( runCatching { if (::central.isInitialized) central.close() } super.close() } -} \ No newline at end of file +} 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..7b2bc1208 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -0,0 +1,749 @@ +/* + * 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, + ) + + /** 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 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 (deviceImpedanceOhm > 0) deviceImpedance = deviceImpedanceOhm.toDouble() + if (heartRateBpm > 0) heartRate = heartRateBpm + 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 + skeletalMuscle = composition.skeletalMusclePercent + // The vendor calls this "muscle", but its verified formula is FFM minus bone. + // Store it as lean soft tissue instead of openScale's skeletal-muscle metric. + leanSoftTissue = composition.muscleKg + subcutaneousFat = composition.subcutaneousFatPercent + bodyAge = composition.bodyAge + bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg + logI("Keep S3 body composition calculated with offline BHKeep SDK-compatible model") + } + } + publish(measurement) + 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 { + // A Keep S3 session can leave several 0x57 ACKs queued. Wait for those ACKs, + // the 0x58 ACK, and both stop commands before starting the disconnect delay. + awaitPendingTransportOperations() + delay(DISCONNECT_DELAY_MS) + // Include any duplicate events acknowledged during the quiet period. + awaitPendingTransportOperations() + 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 + } + return KeepS3Protocol.PreviousRecord( + weightKg = previous.weight, + timestampSeconds = (previous.dateTime?.time ?: 0L) / 1000L, + impedanceOhm = previousDeviceImpedance(previous), + ) + } + + private fun previousDeviceImpedance(previous: ScaleMeasurement): Double { + if (previous.deviceImpedance.isFinite() && previous.deviceImpedance > 0.0) { + return previous.deviceImpedance + } + + // Compatibility with measurements saved by earlier Keep S3 test builds, which placed + // the vendor/protocol impedance in the generic high-frequency field before the three + // distinct impedance values were understood. + if (previous.impedance.isFinite() && previous.impedance > 0.0) { + logW("Previous Keep S3 measurement has no device impedance; using legacy impedance value") + return previous.impedance + } + return 0.0 + } + + 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 = 800L + + 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/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt index 91dd57d73..253757ae2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt @@ -63,6 +63,7 @@ import kotlinx.coroutines.withTimeoutOrNull import java.util.Date import java.util.concurrent.ConcurrentHashMap import kotlin.math.min +import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds // ------------------------------------------------------------------------------------------------- @@ -525,13 +526,51 @@ abstract class ModernScaleAdapter( fun valueOf(key: MeasurementTypeKey): MeasurementValue? = mwv.values.firstOrNull { it.type.key == key }?.value - valueOf(MeasurementTypeKey.WEIGHT)?.let { m.weight = it.floatValue ?: 0f } + mwv.values.firstOrNull { it.type.key == MeasurementTypeKey.WEIGHT }?.let { weight -> + m.weight = ConverterUtils.convertFloatValueUnit( + value = weight.value.floatValue ?: 0f, + fromUnit = weight.type.unit, + toUnit = UnitType.KG, + ) + } valueOf(MeasurementTypeKey.BODY_FAT)?.let { m.fat = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.WATER)?.let { m.water = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.MUSCLE)?.let { m.muscle = it.floatValue ?: 0f } + mwv.values.firstOrNull { it.type.key == MeasurementTypeKey.LEAN_SOFT_TISSUE }?.let { + m.leanSoftTissue = ConverterUtils.convertFloatValueUnit( + value = it.value.floatValue ?: 0f, + fromUnit = it.type.unit, + toUnit = UnitType.KG, + ) + } + mwv.values.firstOrNull { it.type.key == MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT }?.let { + m.bmi22ReferenceWeight = ConverterUtils.convertFloatValueUnit( + value = it.value.floatValue ?: 0f, + fromUnit = it.type.unit, + toUnit = UnitType.KG, + ) + } valueOf(MeasurementTypeKey.VISCERAL_FAT)?.let { m.visceralFat = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.LBM)?.let { m.lbm = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.BONE)?.let { m.bone = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.HEART_RATE)?.let { + m.heartRate = it.intValue ?: it.floatValue?.roundToInt() ?: 0 + } + valueOf(MeasurementTypeKey.IMPEDANCE)?.let { + m.impedance = (it.floatValue ?: it.intValue?.toFloat() ?: 0f).toDouble() + } + valueOf(MeasurementTypeKey.IMPEDANCE_LOW)?.let { + m.impedanceLow = (it.floatValue ?: it.intValue?.toFloat() ?: 0f).toDouble() + } + valueOf(MeasurementTypeKey.DEVICE_IMPEDANCE)?.let { + m.deviceImpedance = (it.floatValue ?: it.intValue?.toFloat() ?: 0f).toDouble() + } + valueOf(MeasurementTypeKey.PHASE_ANGLE)?.let { + m.phaseAngle = it.floatValue ?: it.intValue?.toFloat() ?: 0f + } + valueOf(MeasurementTypeKey.PHASE_ANGLE_HIGH)?.let { + m.phaseAngleHigh = it.floatValue ?: it.intValue?.toFloat() ?: 0f + } return m } @@ -549,4 +588,4 @@ abstract class ModernScaleAdapter( sb.append(']') return sb.toString() } -} \ No newline at end of file +} 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..04bde3191 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 @@ -238,6 +238,12 @@ abstract class ScaleDeviceHandler { ?: logW("writeTo called without transport") } + /** Suspend until transport operations queued before this call have completed. */ + protected suspend fun awaitPendingTransportOperations() { + transport?.awaitPendingOperations() + ?: logW("awaitPendingTransportOperations called without transport") + } + /** Read a characteristic (rare for scales; most data comes via NOTIFY). */ protected fun readFrom(service: UUID, characteristic: UUID) { transport?.read(service, characteristic) @@ -339,6 +345,7 @@ abstract class ScaleDeviceHandler { fun setNotifyOn(service: UUID, characteristic: UUID) fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean = true) fun read(service: UUID, characteristic: UUID) + suspend fun awaitPendingOperations() = Unit fun disconnect() fun getPeripheral(): BluetoothPeripheral? = null fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean 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..4a2880fa7 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 @@ -405,6 +405,14 @@ enum class MeasurementTypeKey( ICW(32, R.string.measurement_type_icw, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), PROTEIN(33, R.string.measurement_type_protein, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), BCM(34, R.string.measurement_type_bcm, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), + PHASE_ANGLE(35, R.string.measurement_type_phase_angle, listOf(UnitType.DEGREE), listOf(InputFieldType.FLOAT)), + PHASE_ANGLE_HIGH(36, R.string.measurement_type_phase_angle_high, listOf(UnitType.DEGREE), listOf(InputFieldType.FLOAT)), + SKELETAL_MUSCLE(37, R.string.measurement_type_skeletal_muscle, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), + SUBCUTANEOUS_FAT(38, R.string.measurement_type_subcutaneous_fat, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), + BODY_AGE(39, R.string.measurement_type_body_age, listOf(UnitType.NONE), listOf(InputFieldType.INT)), + BMI_22_REFERENCE_WEIGHT(40, R.string.measurement_type_bmi_22_reference_weight, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), + LEAN_SOFT_TISSUE(41, R.string.measurement_type_lean_soft_tissue, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), + DEVICE_IMPEDANCE(42, R.string.measurement_type_device_impedance, listOf(UnitType.OHM), listOf(InputFieldType.FLOAT)), CUSTOM(99, R.string.measurement_type_custom_default_name, UnitType.entries.toList(), listOf(InputFieldType.FLOAT, InputFieldType.INT, InputFieldType.TEXT, InputFieldType.DATE, InputFieldType.TIME)); } @@ -419,6 +427,7 @@ enum class UnitType(val displayName: String) { KCAL("kcal"), BPM("bpm"), OHM("Ω"), + DEGREE("°"), NONE(""); fun isWeightUnit(): Boolean { 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..0501f8180 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_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16) .build() @Provides @@ -74,7 +74,7 @@ object DatabaseModule { MeasurementValue::class, MeasurementType::class, ], - version = 15, + version = 16, exportSchema = true ) @TypeConverters(DatabaseConverters::class) @@ -621,3 +621,78 @@ val MIGRATION_14_15 = object : Migration(14, 15) { } } } + +val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + val newKeys = setOf( + MeasurementTypeKey.PHASE_ANGLE, + MeasurementTypeKey.PHASE_ANGLE_HIGH, + MeasurementTypeKey.SKELETAL_MUSCLE, + MeasurementTypeKey.LEAN_SOFT_TISSUE, + MeasurementTypeKey.SUBCUTANEOUS_FAT, + MeasurementTypeKey.BODY_AGE, + MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT, + MeasurementTypeKey.DEVICE_IMPEDANCE, + ) + getDefaultMeasurementTypes().filter { it.key in newKeys }.forEach { type -> + db.execSQL( + """ + INSERT INTO MeasurementType + (`key`, `name`, `color`, `icon`, `unit`, `inputType`, `displayOrder`, + `isDerived`, `isEnabled`, `isPinned`, `isOnRightYAxis`, `isInternal`) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM MeasurementType WHERE `key` = ? + ) + """.trimIndent(), + arrayOf( + type.key.name, + null, + type.color, + type.icon.name, + type.unit.name, + type.inputType.name, + -1, + if (type.isDerived) 1 else 0, + if (type.isEnabled) 1 else 0, + if (type.isPinned) 1 else 0, + if (type.isOnRightYAxis) 1 else 0, + if (type.isInternal) 1 else 0, + type.key.name, + ), + ) + + // Very old databases are rebuilt by MIGRATION_6_7 using the current default list. + // In a full-chain migration that can create these keys before isInternal exists, so + // normalize their v16 metadata even when the idempotent insert found an existing row. + db.execSQL( + """ + UPDATE MeasurementType + SET `color` = ?, `icon` = ?, `unit` = ?, `inputType` = ?, + `isDerived` = ?, `isEnabled` = ?, `isPinned` = ?, + `isOnRightYAxis` = ?, `isInternal` = ? + WHERE `key` = ? + """.trimIndent(), + arrayOf( + type.color, + type.icon.name, + type.unit.name, + type.inputType.name, + if (type.isDerived) 1 else 0, + if (type.isEnabled) 1 else 0, + if (type.isPinned) 1 else 0, + if (type.isOnRightYAxis) 1 else 0, + if (type.isInternal) 1 else 0, + type.key.name, + ), + ) + } + + getDefaultMeasurementTypes().forEachIndexed { index, measurementType -> + db.execSQL( + "UPDATE MeasurementType SET displayOrder = ? WHERE `key` = ?", + arrayOf(index + 1, measurementType.key.name), + ) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt index df7530a45..e90e845ee 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt @@ -30,6 +30,7 @@ import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.UnitType import com.health.openscale.core.facade.MeasurementFacade +import com.health.openscale.core.utils.ConverterUtils import com.health.openscale.core.utils.LogManager import com.health.openscale.ui.shared.SnackbarEvent import kotlinx.coroutines.CoroutineScope @@ -420,8 +421,10 @@ class BleConnector( * defined by each `MeasurementType`'s `unit` field before persisting. * * ### Raw units assumed for ScaleMeasurement - * - WEIGHT, BONE, LBM → **KG** - * - BODY_FAT, WATER, MUSCLE, VISCERAL_FAT → **PERCENT** + * - WEIGHT, BONE, LBM, LEAN_SOFT_TISSUE → **KG** + * - BODY_FAT, WATER, MUSCLE, ECW, ICW, PROTEIN, SKELETAL_MUSCLE, + * SUBCUTANEOUS_FAT → **PERCENT** + * - VISCERAL_FAT → vendor-defined unitless level * * Other fields in `ScaleMeasurement` (if added later) should be appended here with the correct raw unit. * @@ -471,22 +474,28 @@ class BleConnector( fun getTargetUnit(key: MeasurementTypeKey) = typeKeyToUnitMap[key] ?: UnitType.NONE // Declare raw units provided by ScaleMeasurement for each key. - // Percent-based values will "convert" to themselves (converter returns unchanged value). val rawUnitByKey: Map = mapOf( MeasurementTypeKey.WEIGHT to UnitType.KG, MeasurementTypeKey.BODY_FAT to UnitType.PERCENT, MeasurementTypeKey.WATER to UnitType.PERCENT, MeasurementTypeKey.MUSCLE to UnitType.PERCENT, - MeasurementTypeKey.VISCERAL_FAT to UnitType.PERCENT, + MeasurementTypeKey.VISCERAL_FAT to UnitType.NONE, MeasurementTypeKey.BONE to UnitType.KG, MeasurementTypeKey.LBM to UnitType.KG, MeasurementTypeKey.HEART_RATE to UnitType.BPM, MeasurementTypeKey.IMPEDANCE to UnitType.OHM, MeasurementTypeKey.IMPEDANCE_LOW to UnitType.OHM, + MeasurementTypeKey.DEVICE_IMPEDANCE to UnitType.OHM, + MeasurementTypeKey.PHASE_ANGLE to UnitType.DEGREE, + MeasurementTypeKey.PHASE_ANGLE_HIGH to UnitType.DEGREE, MeasurementTypeKey.ECW to UnitType.PERCENT, MeasurementTypeKey.ICW to UnitType.PERCENT, MeasurementTypeKey.PROTEIN to UnitType.PERCENT, MeasurementTypeKey.BCM to UnitType.KG, + MeasurementTypeKey.SKELETAL_MUSCLE to UnitType.PERCENT, + MeasurementTypeKey.LEAN_SOFT_TISSUE to UnitType.KG, + MeasurementTypeKey.SUBCUTANEOUS_FAT to UnitType.PERCENT, + MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT to UnitType.KG, MeasurementTypeKey.BMR to UnitType.KCAL ) @@ -496,7 +505,8 @@ class BleConnector( * Adds a converted float value for the given key if present & valid. * - Reads the raw unit for the key (what the device/handler provided). * - Looks up the target unit from MeasurementType. - * - Converts using existing ConverterUtils.convertFloatValueUnit. + * - Converts percentage-or-mass composition values using the same measurement's + * body weight; all other values use the regular unit converter. */ fun addConvertedIfValid( value: Float?, @@ -509,9 +519,22 @@ class BleConnector( val rawUnit = rawUnitByKey[key] ?: UnitType.NONE val target = getTargetUnit(key) - val converted = com.health.openscale.core.utils.ConverterUtils.convertFloatValueUnit( - v, rawUnit, target - ) + val converted = if (ConverterUtils.isPercentageOrMassComposition(key)) { + ConverterUtils.convertPercentageOrMassCompositionUnit( + value = v, + fromUnit = rawUnit, + toUnit = target, + bodyWeightKg = measurementData.weight, + ) ?: run { + LogManager.w( + TAG, + "Skipping $key: cannot convert $rawUnit to $target without a valid body weight." + ) + return + } + } else { + ConverterUtils.convertFloatValueUnit(v, rawUnit, target) + } getTypeId(key)?.let { typeId -> values.add( @@ -562,10 +585,21 @@ class BleConnector( addConvertedIfValid(measurementData.heartRate, MeasurementTypeKey.HEART_RATE) addConvertedIfValid(measurementData.impedance.toFloat(), MeasurementTypeKey.IMPEDANCE) addConvertedIfValid(measurementData.impedanceLow.toFloat(), MeasurementTypeKey.IMPEDANCE_LOW) + addConvertedIfValid(measurementData.deviceImpedance.toFloat(), MeasurementTypeKey.DEVICE_IMPEDANCE) + addConvertedIfValid(measurementData.phaseAngle, MeasurementTypeKey.PHASE_ANGLE) + addConvertedIfValid(measurementData.phaseAngleHigh, MeasurementTypeKey.PHASE_ANGLE_HIGH) addConvertedIfValid(measurementData.ecw, MeasurementTypeKey.ECW) addConvertedIfValid(measurementData.icw, MeasurementTypeKey.ICW) addConvertedIfValid(measurementData.protein, MeasurementTypeKey.PROTEIN) addConvertedIfValid(measurementData.bcm, MeasurementTypeKey.BCM) + addConvertedIfValid(measurementData.skeletalMuscle, MeasurementTypeKey.SKELETAL_MUSCLE) + addConvertedIfValid(measurementData.leanSoftTissue, MeasurementTypeKey.LEAN_SOFT_TISSUE) + addConvertedIfValid(measurementData.subcutaneousFat, MeasurementTypeKey.SUBCUTANEOUS_FAT) + addConvertedIfValid(measurementData.bodyAge, MeasurementTypeKey.BODY_AGE) + addConvertedIfValid( + measurementData.bmi22ReferenceWeight, + MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT, + ) if (values.isEmpty()) { LogManager.w(TAG, "No valid values from measurement of $deviceName to save.") @@ -694,4 +728,4 @@ class BleConnector( } LogManager.i(TAG, "BluetoothConnectionManager closed.") } -} \ No newline at end of file +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt index 369a2e6db..22f7a7d67 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt @@ -21,7 +21,6 @@ import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType -import com.health.openscale.core.data.WeightUnit import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.utils.ConverterUtils import com.health.openscale.core.utils.LogManager @@ -76,9 +75,9 @@ class MeasurementTypeCrudUseCases @Inject constructor( * Updates a type (e.g., name, flags, **unit**) and, if its unit changed, converts * all existing values of that type to the new unit. * - * Special handling: BODY_FAT, WATER, MUSCLE may switch between PERCENT and absolute - * weight units (KG/LB/ST). Conversion uses the WEIGHT value from the *same measurement*. - * If the required weight value is missing for a row, that row is skipped. + * Composition values that support both PERCENT and absolute weight units (KG/LB/ST) use + * the WEIGHT value from the *same measurement*. If the required weight value is missing + * for a row, that row is skipped. * * Note: repository.updateMeasurementValue(...) is assumed to trigger derived-value * recalculation. If not, add explicit recalculation here after updates. @@ -130,57 +129,34 @@ class MeasurementTypeCrudUseCases @Inject constructor( var converted: Float? // Percent <-> absolute conversions for composition-like metrics - if (typeKey == MeasurementTypeKey.BODY_FAT || - typeKey == MeasurementTypeKey.WATER || - typeKey == MeasurementTypeKey.MUSCLE - ) { - if (weightType == null) { - // No weight type found; cannot compute percent-based conversions. - continue + if (ConverterUtils.isPercentageOrMassComposition(typeKey)) { + val needsBodyWeight = oldUnit == UnitType.PERCENT || newUnit == UnitType.PERCENT + val weightInKg = if (needsBodyWeight) { + val resolvedWeightType = weightType ?: continue + if (!resolvedWeightType.unit.isWeightUnit()) continue + + val weightOnThisMeasurement = repository + .getValuesForMeasurement(mv.measurementId) + .first() + .find { it.typeId == resolvedWeightType.id } + ?.floatValue + ?: continue + + ConverterUtils.convertFloatValueUnit( + weightOnThisMeasurement, + resolvedWeightType.unit, + UnitType.KG, + ) + } else { + null } - val weightOnThisMeasurement = repository - .getValuesForMeasurement(mv.measurementId) - .first() - .find { it.typeId == weightType.id }?.floatValue - - if (weightOnThisMeasurement == null) { - // Missing WEIGHT value for this measurement row; skip. - continue - } - - // Normalize the total weight to KG for math, then convert to target at the end - val weightInKg = when (weightType.unit) { - UnitType.KG -> weightOnThisMeasurement - UnitType.LB -> ConverterUtils.toKilogram(weightOnThisMeasurement, WeightUnit.LB) - UnitType.ST -> ConverterUtils.toKilogram(weightOnThisMeasurement, WeightUnit.ST) - else -> null - } ?: continue - - when { - // PERCENT -> absolute (kg/lb/st) - oldUnit == UnitType.PERCENT && newUnit.isWeightUnit() -> { - val absoluteInKg = (current / 100f) * weightInKg - converted = ConverterUtils.convertFloatValueUnit(absoluteInKg, UnitType.KG, newUnit) - } - // absolute (kg/lb/st) -> PERCENT - oldUnit.isWeightUnit() && newUnit == UnitType.PERCENT -> { - val currentInKg = ConverterUtils.convertFloatValueUnit(current, oldUnit, UnitType.KG) - if (weightInKg != 0f) { - converted = currentInKg / weightInKg * 100f - } else { - converted = 0f - } - } - // absolute <-> absolute - oldUnit.isWeightUnit() && newUnit.isWeightUnit() -> { - converted = ConverterUtils.convertFloatValueUnit(current, oldUnit, newUnit) - } - else -> { - // Unsupported path, keep original - converted = current - } - } + converted = ConverterUtils.convertPercentageOrMassCompositionUnit( + value = current, + fromUnit = oldUnit, + toUnit = newUnit, + bodyWeightKg = weightInKg, + ) ?: continue } else { // Generic unit conversion converted = ConverterUtils.convertFloatValueUnit(current, oldUnit, newUnit) 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..48ea6b5f9 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 @@ -318,6 +318,7 @@ object GenericValueJson { UnitType.KCAL -> "kcal" UnitType.BPM -> "/min" UnitType.OHM -> "Ohm" + UnitType.DEGREE -> "deg" UnitType.NONE -> "" } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt index 8e9d01ed5..c1622430c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt @@ -18,6 +18,7 @@ package com.health.openscale.core.utils import com.health.openscale.core.data.MeasureUnit +import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.WeightUnit import kotlin.math.floor @@ -31,6 +32,17 @@ object ConverterUtils { private const val LB_PER_ST_DOUBLE: Double = 14.0 + private val percentageOrMassCompositionKeys = setOf( + MeasurementTypeKey.BODY_FAT, + MeasurementTypeKey.WATER, + MeasurementTypeKey.MUSCLE, + MeasurementTypeKey.ECW, + MeasurementTypeKey.ICW, + MeasurementTypeKey.PROTEIN, + MeasurementTypeKey.SKELETAL_MUSCLE, + MeasurementTypeKey.SUBCUTANEOUS_FAT, + ) + @JvmStatic fun toKilogram(value: Float, unit: WeightUnit): Float { when (unit) { @@ -252,6 +264,54 @@ object ConverterUtils { return value } + /** + * Whether [key] represents a body-composition value that may be stored either as a + * percentage of body weight or as an absolute mass. + * + * Mass-only values such as lean soft tissue are intentionally excluded. + */ + @JvmStatic + fun isPercentageOrMassComposition(key: MeasurementTypeKey): Boolean = + key in percentageOrMassCompositionKeys + + /** + * Converts a body-composition value between percent and mass units. + * + * Unlike [convertFloatValueUnit], percent-to-mass conversions require the body weight from + * the same measurement, normalized to kilograms. Unsupported conversions, or conversions + * that need a missing/invalid body weight, return `null` instead of silently returning the + * unconverted value. + */ + @JvmStatic + fun convertPercentageOrMassCompositionUnit( + value: Float, + fromUnit: UnitType, + toUnit: UnitType, + bodyWeightKg: Float?, + ): Float? { + if (!value.isFinite()) return null + if (fromUnit == toUnit) return value + + if (fromUnit.isWeightUnit() && toUnit.isWeightUnit()) { + return convertFloatValueUnit(value, fromUnit, toUnit) + } + + val validBodyWeightKg = bodyWeightKg?.takeIf { it.isFinite() && it > 0f } + ?: return null + + return when { + fromUnit == UnitType.PERCENT && toUnit.isWeightUnit() -> { + val massKg = value / 100f * validBodyWeightKg + convertFloatValueUnit(massKg, UnitType.KG, toUnit) + } + fromUnit.isWeightUnit() && toUnit == UnitType.PERCENT -> { + val massKg = convertFloatValueUnit(value, fromUnit, UnitType.KG) + massKg / validBodyWeightKg * 100f + } + else -> null + } + } + /** * Removes all non-digit characters from [input] and truncates the result to [maxLen] characters. * @@ -263,4 +323,4 @@ object ConverterUtils { fun sanitizeDigits(input: String, maxLen: Int): String = input.filter { it.isDigit() }.take(maxLen) -} \ No newline at end of file +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt index 58afa3d59..c5e057fea 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt @@ -164,6 +164,7 @@ object LocaleUtils { UnitType.KCAL-> "$signPrefix${formatNumber(absVal, maxFraction = 0, locale)} kcal" UnitType.BPM -> "$signPrefix${formatNumber(absVal, maxFraction = 0, locale)} bpm" UnitType.OHM -> "$signPrefix${formatNumber(absVal, maxFraction = 1, locale)} Ω" + UnitType.DEGREE -> "$signPrefix${formatNumber(absVal, maxFraction = 1, locale)}°" UnitType.NONE-> signPrefix + formatNumber(absVal, maxFraction = 1, locale) } } 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..4e4358713 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 @@ -47,6 +47,14 @@ 体脂率 水分 肌肉 + 相位角 + 相位角(100 kHz) + 骨骼肌率 + 去骨瘦体重 + 设备阻抗 + 皮下脂肪率 + 估算身体年龄 + BMI 22 参考体重 我的目标 %d目标 没有目标 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..4e527a332 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 @@ -34,6 +34,14 @@ 編輯類型 增加類型 測量類型 + 相位角 + 相位角(100 kHz) + 骨骼肌率 + 去骨瘦體重 + 裝置阻抗 + 皮下脂肪率 + 估算身體年齡 + BMI 22 參考體重 編輯使用者 新增使用者 使用者 diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 7adea8155..606a10de6 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + openScale Last change wasn\'t synchronized. Please open openScale-sync and run a manual sync. @@ -187,6 +187,14 @@ Heart Rate Impedance (high) Impedance (low) + Phase angle + Phase angle (100 kHz) + Skeletal muscle + Lean soft tissue + Device impedance + Subcutaneous fat + Estimated body age + BMI 22 reference weight Extracellular water Intracellular water Protein 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..2cb4707e6 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt @@ -0,0 +1,823 @@ +/* + * 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.CompletableDeferred +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() + setup.transport.blockPendingOperations() + + // 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().deviceImpedance).isEqualTo(301.0) + 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().phaseAngle).isWithin(0.0001f).of(7.6f) + assertThat(setup.callbacks.published.single().phaseAngleHigh).isWithin(0.0001f).of(7.6f) + assertThat(setup.callbacks.published.single().fat).isEqualTo(29.5f) + assertThat(setup.callbacks.published.single().water).isEqualTo(50.2f) + assertThat(setup.callbacks.published.single().muscle).isEqualTo(0f) + assertThat(setup.callbacks.published.single().leanSoftTissue).isEqualTo(57.0f) + assertThat(setup.callbacks.published.single().skeletalMuscle) + .isWithin(0.001f).of(38.42538f) + assertThat(setup.callbacks.published.single().subcutaneousFat).isEqualTo(25.7f) + assertThat(setup.callbacks.published.single().visceralFat).isEqualTo(12f) + assertThat(setup.callbacks.published.single().protein).isEqualTo(12.7f) + assertThat(setup.callbacks.published.single().bone).isEqualTo(3.0f) + assertThat(setup.callbacks.published.single().lbm).isEqualTo(60.0f) + assertThat(setup.callbacks.published.single().bmr).isEqualTo(1791f) + assertThat(setup.callbacks.published.single().bodyAge).isEqualTo(28) + assertThat(setup.callbacks.published.single().bmi22ReferenceWeight).isEqualTo(62.0f) + + 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) + + runCurrent() + assertThat(setup.transport.awaitPendingCount).isEqualTo(1) + advanceTimeBy(800) + runCurrent() + assertThat(setup.transport.disconnectCount).isEqualTo(0) + + setup.transport.releasePendingOperations() + runCurrent() + advanceTimeBy(800) + runCurrent() + assertThat(setup.transport.awaitPendingCount).isEqualTo(2) + 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().deviceImpedance).isEqualTo(300.0) + assertThat(setup.callbacks.published.single().impedance).isEqualTo(475.0) + assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(502.0) + assertThat(setup.callbacks.published.single().phaseAngle).isWithin(0.0001f).of(7.7f) + assertThat(setup.callbacks.published.single().phaseAngleHigh).isWithin(0.0001f).of(7.7f) + assertThat(setup.callbacks.published.single().fat).isEqualTo(29.4f) + assertThat(setup.callbacks.published.single().water).isEqualTo(50.3f) + assertThat(setup.callbacks.published.single().muscle).isEqualTo(0f) + assertThat(setup.callbacks.published.single().leanSoftTissue).isEqualTo(57.1f) + assertThat(setup.callbacks.published.single().skeletalMuscle) + .isWithin(0.001f).of(38.47059f) + assertThat(setup.callbacks.published.single().subcutaneousFat).isEqualTo(25.5f) + assertThat(setup.callbacks.published.single().bodyAge).isEqualTo(28) + 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().deviceImpedance).isEqualTo(300.0) + assertThat(setup.callbacks.published.single().impedance).isEqualTo(0.0) + assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(0.0) + assertThat(setup.callbacks.published.single().heartRate).isEqualTo(97) + assertThat(setup.callbacks.published.single().phaseAngle).isEqualTo(0f) + } + + @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().bodyAge).isEqualTo(0) + } + + @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.skeletalMuscle).isEqualTo(expected.skeletalMusclePercent) + assertThat(actual.bodyAge).isEqualTo(expected.bodyAge) + } + + @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 `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 prefers device impedance for previous Keep S3 record`() { + val previous = ScaleMeasurement( + userId = 7, + dateTime = Date(0x1234_5678L * 1000L), + weight = 85.10f, + impedance = 999.0, + deviceImpedance = 301.0, + ) + val setup = attachedHandler(previous = previous) + + driveInitializationThroughProfile(setup) + + val profileRequest = setup.transport.writes.single { requestOpcode(it.payload) == 0x32 }.payload + assertThat(KeepS3Protocol.decodeU16BE(profileRequest, 5 + 54)).isEqualTo(301) + } + + @Test + fun `profile falls back to legacy impedance when device impedance is absent`() { + val previous = ScaleMeasurement( + userId = 7, + dateTime = Date(0x1234_5678L * 1000L), + weight = 85.10f, + impedance = 301.0, + ) + val setup = attachedHandler(previous = previous) + + driveInitializationThroughProfile(setup) + + val profileRequest = setup.transport.writes.single { requestOpcode(it.payload) == 0x32 }.payload + assertThat(KeepS3Protocol.decodeU16BE(profileRequest, 5 + 54)).isEqualTo(301) + } + + @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, + deviceImpedance = 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 + var awaitPendingCount = 0 + private var pendingOperationsGate: CompletableDeferred? = null + + 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 suspend fun awaitPendingOperations() { + awaitPendingCount++ + pendingOperationsGate?.await() + } + + override fun disconnect() { + disconnectCount++ + } + + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = true + + fun clearWrites() = writes.clear() + + fun blockPendingOperations() { + check(pendingOperationsGate == null) + pendingOperationsGate = CompletableDeferred() + } + + fun releasePendingOperations() { + pendingOperationsGate?.complete(Unit) + pendingOperationsGate = null + } + } + + 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 } + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt index b62f31d50..a98e9d800 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt @@ -22,6 +22,8 @@ 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.MeasurementTypeKey +import com.health.openscale.core.data.UnitType import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -35,7 +37,7 @@ import kotlin.math.abs /** * Exercises the full migration chain end-to-end on the JVM (Robolectric): a hand-built * legacy schema-v6 database is opened with all migrations, running MIGRATION_6_7 (the risky - * legacy rewrite) through MIGRATION_14_15 in sequence. Verifies the enum mapping and that + * legacy rewrite) through MIGRATION_15_16 in sequence. Verifies the enum mapping and that * data survives — the highest data-loss risk in the app. */ @RunWith(RobolectricTestRunner::class) @@ -59,7 +61,7 @@ class MigrationTest { RoomTestSupport.writeLegacyV6Database(dbFile) - // Opening with the full migration chain runs MIGRATION_6_7 .. MIGRATION_14_15 in order. + // Opening with the full migration chain runs MIGRATION_6_7 .. MIGRATION_15_16 in order. val opened = RoomTestSupport.onDisk(context).also { db = it } val repo = RoomTestSupport.repositoryFor(opened) @@ -78,8 +80,27 @@ class MigrationTest { .firstOrNull { abs(it - 72.5f) < 0.1f } assertThat(weight).isNotNull() - // The rewrite seeds the default measurement types. - assertThat(repo.getAllMeasurementTypes().first()).isNotEmpty() + val types = repo.getAllMeasurementTypes().first() + assertThat(types).isNotEmpty() + assertThat(types.single { it.key == MeasurementTypeKey.PHASE_ANGLE }.unit) + .isEqualTo(UnitType.DEGREE) + assertThat(types.single { it.key == MeasurementTypeKey.PHASE_ANGLE_HIGH }.isInternal) + .isTrue() + assertThat(types.single { it.key == MeasurementTypeKey.SKELETAL_MUSCLE }.unit) + .isEqualTo(UnitType.PERCENT) + assertThat(types.single { it.key == MeasurementTypeKey.LEAN_SOFT_TISSUE }.unit) + .isEqualTo(UnitType.KG) + assertThat(types.single { it.key == MeasurementTypeKey.SUBCUTANEOUS_FAT }.unit) + .isEqualTo(UnitType.PERCENT) + assertThat(types.single { it.key == MeasurementTypeKey.BODY_AGE }.unit) + .isEqualTo(UnitType.NONE) + assertThat(types.single { it.key == MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT }.unit) + .isEqualTo(UnitType.KG) + assertThat(MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT.id).isEqualTo(40) + val deviceImpedance = types.single { it.key == MeasurementTypeKey.DEVICE_IMPEDANCE } + assertThat(deviceImpedance.unit).isEqualTo(UnitType.OHM) + assertThat(deviceImpedance.isEnabled).isFalse() + assertThat(deviceImpedance.isInternal).isTrue() } /** @@ -96,7 +117,7 @@ class MigrationTest { RoomTestSupport.writeLegacyV1Database(dbFile) - // Opening with the full migration chain runs MIGRATION_1_2 .. MIGRATION_14_15 in order. + // Opening with the full migration chain runs MIGRATION_1_2 .. MIGRATION_15_16 in order. val opened = RoomTestSupport.onDisk(context).also { db = it } val repo = RoomTestSupport.repositoryFor(opened) diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt index dd582b60f..b2cab84d9 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt @@ -29,6 +29,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.ConverterUtils import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first @@ -120,6 +121,44 @@ class MeasurementTypeCrudUseCasesTest { assertThat(valueOf(bodyFat.id)).isWithin(1e-2f).of(16f) // 20% of 80kg } + @Test + fun compositionConversion_proteinPercentToKg_usesPerMeasurementWeight() = runBlocking { + val weight = type(MeasurementTypeKey.WEIGHT) + val protein = type(MeasurementTypeKey.PROTEIN) + val mId = newMeasurement(1_000L) + repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = weight.id, floatValue = 85.55f)) + repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = protein.id, floatValue = 12.8f)) + + val report = useCase.updateTypeAndConvertValues( + protein, + protein.copy(unit = UnitType.KG), + ).getOrThrow() + + assertThat(report.updatedCount).isEqualTo(1) + assertThat(valueOf(protein.id)).isWithin(1e-4f).of(10.9504f) + } + + @Test + fun compositionConversion_skeletalMuscleKgToPercent_normalizesLbBodyWeight() = runBlocking { + val weight = type(MeasurementTypeKey.WEIGHT) + val skeletalMuscle = type(MeasurementTypeKey.SKELETAL_MUSCLE) + repo.updateMeasurementType(weight.copy(unit = UnitType.LB)) + repo.updateMeasurementType(skeletalMuscle.copy(unit = UnitType.KG)) + + val mId = newMeasurement(1_000L) + val weightLb = ConverterUtils.convertFloatValueUnit(85.55f, UnitType.KG, UnitType.LB) + repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = weight.id, floatValue = weightLb)) + repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = skeletalMuscle.id, floatValue = 33.0223f)) + + val report = useCase.updateTypeAndConvertValues( + skeletalMuscle.copy(unit = UnitType.KG), + skeletalMuscle.copy(unit = UnitType.PERCENT), + ).getOrThrow() + + assertThat(report.updatedCount).isEqualTo(1) + assertThat(valueOf(skeletalMuscle.id)).isWithin(1e-3f).of(38.6f) + } + @Test fun compositionConversion_percentToKg_skipsRowsWithoutWeight() = runBlocking { val bodyFat = type(MeasurementTypeKey.BODY_FAT) diff --git a/android_app/app/src/test/java/com/health/openscale/core/utils/ConverterUtilsTest.kt b/android_app/app/src/test/java/com/health/openscale/core/utils/ConverterUtilsTest.kt index 6907b9f60..2b09bea78 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/utils/ConverterUtilsTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/utils/ConverterUtilsTest.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.utils import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.MeasureUnit +import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.WeightUnit import org.junit.Test @@ -100,6 +101,102 @@ class ConverterUtilsTest { assertThat(ConverterUtils.convertFloatValueUnit(42f, UnitType.PERCENT, UnitType.KG)).isEqualTo(42f) } + // ---- percentage-or-mass body composition --------------------------------------------------- + + @Test + fun compositionConversion_proteinPercentToKg_usesBodyWeight() { + val converted = ConverterUtils.convertPercentageOrMassCompositionUnit( + value = 12.8f, + fromUnit = UnitType.PERCENT, + toUnit = UnitType.KG, + bodyWeightKg = 85.55f, + ) + + assertThat(converted).isNotNull() + assertThat(converted!!).isWithin(1e-4f).of(10.9504f) + } + + @Test + fun compositionConversion_skeletalMusclePercentRoundTripsThroughKg() { + val massKg = ConverterUtils.convertPercentageOrMassCompositionUnit( + value = 38.6f, + fromUnit = UnitType.PERCENT, + toUnit = UnitType.KG, + bodyWeightKg = 85.55f, + ) + val percent = ConverterUtils.convertPercentageOrMassCompositionUnit( + value = massKg!!, + fromUnit = UnitType.KG, + toUnit = UnitType.PERCENT, + bodyWeightKg = 85.55f, + ) + + assertThat(massKg).isWithin(1e-4f).of(33.0223f) + assertThat(percent).isNotNull() + assertThat(percent!!).isWithin(1e-4f).of(38.6f) + } + + @Test + fun compositionConversion_percentToLbAndStone_matchesKgConversion() { + val massKg = 10.9504f + val massLb = ConverterUtils.convertPercentageOrMassCompositionUnit( + 12.8f, UnitType.PERCENT, UnitType.LB, 85.55f + ) + val massSt = ConverterUtils.convertPercentageOrMassCompositionUnit( + 12.8f, UnitType.PERCENT, UnitType.ST, 85.55f + ) + + assertThat(massLb).isNotNull() + assertThat(massLb!!).isWithin(1e-4f) + .of(ConverterUtils.convertFloatValueUnit(massKg, UnitType.KG, UnitType.LB)) + assertThat(massSt).isNotNull() + assertThat(massSt!!).isWithin(1e-4f) + .of(ConverterUtils.convertFloatValueUnit(massKg, UnitType.KG, UnitType.ST)) + } + + @Test + fun compositionConversion_rejectsMissingWeightAndUnsupportedUnits() { + assertThat( + ConverterUtils.convertPercentageOrMassCompositionUnit( + 12.8f, UnitType.PERCENT, UnitType.KG, null + ) + ).isNull() + assertThat( + ConverterUtils.convertPercentageOrMassCompositionUnit( + 12.8f, UnitType.PERCENT, UnitType.CM, 85.55f + ) + ).isNull() + } + + @Test + fun compositionConversion_samePercentUnitDoesNotRequireBodyWeight() { + assertThat( + ConverterUtils.convertPercentageOrMassCompositionUnit( + 12.8f, UnitType.PERCENT, UnitType.PERCENT, null + ) + ).isEqualTo(12.8f) + } + + @Test + fun percentageOrMassCompositionKeys_includeExtendedMetricsButNotLeanSoftTissue() { + val expected = listOf( + MeasurementTypeKey.BODY_FAT, + MeasurementTypeKey.WATER, + MeasurementTypeKey.MUSCLE, + MeasurementTypeKey.ECW, + MeasurementTypeKey.ICW, + MeasurementTypeKey.PROTEIN, + MeasurementTypeKey.SKELETAL_MUSCLE, + MeasurementTypeKey.SUBCUTANEOUS_FAT, + ) + + expected.forEach { + assertThat(ConverterUtils.isPercentageOrMassComposition(it)).isTrue() + } + assertThat(ConverterUtils.isPercentageOrMassComposition(MeasurementTypeKey.LEAN_SOFT_TISSUE)) + .isFalse() + } + // ---- sanitizeDigits ------------------------------------------------------------------------- @Test diff --git a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt index bce056eb5..71e0c08ce 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,7 @@ import com.health.openscale.core.database.MIGRATION_11_12 import com.health.openscale.core.database.MIGRATION_12_13 import com.health.openscale.core.database.MIGRATION_13_14 import com.health.openscale.core.database.MIGRATION_14_15 +import com.health.openscale.core.database.MIGRATION_15_16 import com.health.openscale.core.database.MIGRATION_1_2 import com.health.openscale.core.database.MIGRATION_2_3 import com.health.openscale.core.database.MIGRATION_3_4 @@ -82,6 +83,7 @@ object RoomTestSupport { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_15_16, ) /** On-disk database at the real [AppDatabase.DATABASE_NAME] path, with all migrations applied. */ From 63fd4efdd9a270adb645cf71bfdcd12d3a933519 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 2 Aug 2026 15:35:14 +0200 Subject: [PATCH 2/5] 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. --- .../16.json | 417 ------------------ .../java/com/health/openscale/OpenScaleApp.kt | 10 +- .../core/bluetooth/data/ScaleMeasurement.kt | 20 +- .../core/bluetooth/scales/GattScaleAdapter.kt | 12 +- .../core/bluetooth/scales/KeepS3Handler.kt | 46 +- .../bluetooth/scales/ModernScaleAdapter.kt | 43 +- .../bluetooth/scales/ScaleDeviceHandler.kt | 7 - .../com/health/openscale/core/data/Enums.kt | 9 - .../openscale/core/database/AppDatabase.kt | 79 +--- .../openscale/core/service/BleConnector.kt | 52 +-- .../usecase/MeasurementTypeCrudUseCases.kt | 82 ++-- .../openscale/core/usecase/SyncUseCases.kt | 1 - .../openscale/core/utils/ConverterUtils.kt | 62 +-- .../openscale/core/utils/LocaleUtils.kt | 1 - .../src/main/res/values-zh-rCN/strings.xml | 8 - .../src/main/res/values-zh-rTW/strings.xml | 8 - .../app/src/main/res/values/strings.xml | 10 +- .../bluetooth/scales/KeepS3HandlerTest.kt | 74 +--- .../openscale/core/database/MigrationTest.kt | 31 +- .../MeasurementTypeCrudUseCasesTest.kt | 39 -- .../core/utils/ConverterUtilsTest.kt | 97 ---- .../openscale/testutil/RoomTestSupport.kt | 2 - 22 files changed, 107 insertions(+), 1003 deletions(-) delete mode 100644 android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json diff --git a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json b/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json deleted file mode 100644 index 5e2a8bc4b..000000000 --- a/android_app/app/schemas/com.health.openscale.core.database.AppDatabase/16.json +++ /dev/null @@ -1,417 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 16, - "identityHash": "394cbd60aafb83b8d4beeb00b58404ea", - "entities": [ - { - "tableName": "User", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `birthDate` INTEGER NOT NULL, `gender` TEXT NOT NULL, `heightCm` REAL NOT NULL, `activityLevel` TEXT NOT NULL, `useAssistedWeighing` INTEGER NOT NULL, `amputations` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "icon", - "columnName": "icon", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "birthDate", - "columnName": "birthDate", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "gender", - "columnName": "gender", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "heightCm", - "columnName": "heightCm", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "activityLevel", - "columnName": "activityLevel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "useAssistedWeighing", - "columnName": "useAssistedWeighing", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "amputations", - "columnName": "amputations", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "user_goals", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `measurementTypeId` INTEGER NOT NULL, `goalValue` REAL NOT NULL, `goalTargetDate` INTEGER, PRIMARY KEY(`userId`, `measurementTypeId`), FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`measurementTypeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "measurementTypeId", - "columnName": "measurementTypeId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "goalValue", - "columnName": "goalValue", - "affinity": "REAL", - "notNull": true - }, - { - "fieldPath": "goalTargetDate", - "columnName": "goalTargetDate", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "measurementTypeId" - ] - }, - "indices": [ - { - "name": "index_user_goals_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_user_goals_userId` ON `${TABLE_NAME}` (`userId`)" - }, - { - "name": "index_user_goals_measurementTypeId", - "unique": false, - "columnNames": [ - "measurementTypeId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_user_goals_measurementTypeId` ON `${TABLE_NAME}` (`measurementTypeId`)" - } - ], - "foreignKeys": [ - { - "table": "User", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "MeasurementType", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "measurementTypeId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "Measurement", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `User`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_Measurement_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_Measurement_userId` ON `${TABLE_NAME}` (`userId`)" - }, - { - "name": "index_Measurement_userId_timestamp", - "unique": true, - "columnNames": [ - "userId", - "timestamp" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Measurement_userId_timestamp` ON `${TABLE_NAME}` (`userId`, `timestamp`)" - } - ], - "foreignKeys": [ - { - "table": "User", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "MeasurementValue", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `measurementId` INTEGER NOT NULL, `typeId` INTEGER NOT NULL, `floatValue` REAL, `intValue` INTEGER, `textValue` TEXT, `dateValue` INTEGER, FOREIGN KEY(`measurementId`) REFERENCES `Measurement`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`typeId`) REFERENCES `MeasurementType`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "measurementId", - "columnName": "measurementId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "typeId", - "columnName": "typeId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "floatValue", - "columnName": "floatValue", - "affinity": "REAL" - }, - { - "fieldPath": "intValue", - "columnName": "intValue", - "affinity": "INTEGER" - }, - { - "fieldPath": "textValue", - "columnName": "textValue", - "affinity": "TEXT" - }, - { - "fieldPath": "dateValue", - "columnName": "dateValue", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_MeasurementValue_measurementId", - "unique": false, - "columnNames": [ - "measurementId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementValue_measurementId` ON `${TABLE_NAME}` (`measurementId`)" - }, - { - "name": "index_MeasurementValue_typeId", - "unique": false, - "columnNames": [ - "typeId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementValue_typeId` ON `${TABLE_NAME}` (`typeId`)" - } - ], - "foreignKeys": [ - { - "table": "Measurement", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "measurementId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "MeasurementType", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "typeId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "MeasurementType", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key` TEXT NOT NULL, `name` TEXT, `color` INTEGER NOT NULL, `icon` TEXT NOT NULL, `unit` TEXT NOT NULL, `inputType` TEXT NOT NULL, `displayOrder` INTEGER NOT NULL, `isDerived` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isOnRightYAxis` INTEGER NOT NULL, `isInternal` INTEGER NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "key", - "columnName": "key", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "icon", - "columnName": "icon", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "unit", - "columnName": "unit", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputType", - "columnName": "inputType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "displayOrder", - "columnName": "displayOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isDerived", - "columnName": "isDerived", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isEnabled", - "columnName": "isEnabled", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPinned", - "columnName": "isPinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isOnRightYAxis", - "columnName": "isOnRightYAxis", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isInternal", - "columnName": "isInternal", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_MeasurementType_key", - "unique": false, - "columnNames": [ - "key" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_MeasurementType_key` ON `${TABLE_NAME}` (`key`)" - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '394cbd60aafb83b8d4beeb00b58404ea')" - ] - } -} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt b/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt index 81316d39a..de4906f72 100644 --- a/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt +++ b/android_app/app/src/main/java/com/health/openscale/OpenScaleApp.kt @@ -52,13 +52,8 @@ fun getDefaultMeasurementTypes(): List { MeasurementType(key = MeasurementTypeKey.BODY_FAT, unit = UnitType.PERCENT, color = 0xFFEF5350.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isPinned = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WATER, unit = UnitType.PERCENT, color = 0xFF29B6F6.toInt(), icon = MeasurementTypeIcon.IC_WATER, isPinned = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.MUSCLE, unit = UnitType.PERCENT, color = 0xFF66BB6A.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isPinned = true, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.SKELETAL_MUSCLE, unit = UnitType.PERCENT, color = 0xFF43A047.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.LEAN_SOFT_TISSUE, unit = UnitType.KG, color = 0xFF7CB342.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), MeasurementType(key = MeasurementTypeKey.LBM, unit = UnitType.KG, color = 0xFF4DBAC0.toInt(), icon = MeasurementTypeIcon.IC_LBM, isEnabled = true), MeasurementType(key = MeasurementTypeKey.BONE, unit = UnitType.KG, color = 0xFFBDBDBD.toInt(), icon = MeasurementTypeIcon.IC_BONE, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.SUBCUTANEOUS_FAT, unit = UnitType.PERCENT, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BODY_AGE, inputType = InputFieldType.INT, unit = UnitType.NONE, color = 0xFF7B1FA2.toInt(), icon = MeasurementTypeIcon.IC_M_PERSON, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT, unit = UnitType.KG, color = 0xFF5E35B1.toInt(), icon = MeasurementTypeIcon.IC_WEIGHT, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WAIST, unit = UnitType.CM, color = 0xFF78909C.toInt(), icon = MeasurementTypeIcon.IC_WAIST, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WHR, unit = UnitType.NONE, color = 0xFFFFA726.toInt(), icon = MeasurementTypeIcon.IC_WHR, isDerived = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.WHTR, unit = UnitType.NONE, color = 0xFFFF7043.toInt(), icon = MeasurementTypeIcon.IC_WHTR, isDerived = true, isEnabled = true), @@ -77,9 +72,6 @@ fun getDefaultMeasurementTypes(): List { MeasurementType(key = MeasurementTypeKey.HEART_RATE, inputType = InputFieldType.INT, unit = UnitType.BPM, color = 0xFFE91E63.toInt(), icon = MeasurementTypeIcon.IC_M_HEART_RATE, isEnabled = true), MeasurementType(key = MeasurementTypeKey.IMPEDANCE, unit = UnitType.OHM, color = 0xFF607D8B.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), MeasurementType(key = MeasurementTypeKey.IMPEDANCE_LOW, unit = UnitType.OHM, color = 0xFF455A64.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), - MeasurementType(key = MeasurementTypeKey.DEVICE_IMPEDANCE, unit = UnitType.OHM, color = 0xFF546E7A.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), - MeasurementType(key = MeasurementTypeKey.PHASE_ANGLE, unit = UnitType.DEGREE, color = 0xFF00897B.toInt(), icon = MeasurementTypeIcon.IC_M_SCATTER_PLOT, isEnabled = true), - MeasurementType(key = MeasurementTypeKey.PHASE_ANGLE_HIGH, unit = UnitType.DEGREE, color = 0xFF00695C.toInt(), icon = MeasurementTypeIcon.IC_DEFAULT, isEnabled = false, isInternal = true), MeasurementType(key = MeasurementTypeKey.ECW, unit = UnitType.PERCENT, color = 0xFF4FC3F7.toInt(), icon = MeasurementTypeIcon.IC_M_SCATTER_PLOT, isEnabled = true), MeasurementType(key = MeasurementTypeKey.ICW, unit = UnitType.PERCENT, color = 0xFF0288D1.toInt(), icon = MeasurementTypeIcon.IC_M_BUBBLE_CHART, isEnabled = true), MeasurementType(key = MeasurementTypeKey.PROTEIN, unit = UnitType.PERCENT, color = 0xFF9CCC65.toInt(), icon = MeasurementTypeIcon.IC_M_PROTEIN, isEnabled = true), @@ -153,4 +145,4 @@ class OpenScaleApp : Application(), Configuration.Provider { .setWorkerFactory(workerFactory) .build() } -} +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt index 638091d52..cc5d96275 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/data/ScaleMeasurement.kt @@ -30,25 +30,17 @@ data class ScaleMeasurement( var fat: Float = 0.0f, // must be in percentage var water: Float = 0.0f, // must be in percentage var muscle: Float = 0.0f, // must be in percentage - var visceralFat: Float = 0.0f, // vendor-defined visceral-fat level/index + var visceralFat: Float = 0.0f, // must be in percentage var bone: Float = 0.0f, // must be in kg var lbm : Float = 0.0f, // must be in kg var bmr: Float = 0.0f, // Basal Metabolic Rate in kcal var heartRate: Int = 0, // must be bpm var impedance: Double = 0.0, // Ohms — high-frequency band when the scale is dual-band var impedanceLow: Double = 0.0, // Ohms — low-frequency band; 0 when not reported - var deviceImpedance: Double = 0.0, // Ohms — vendor/protocol value with no verified frequency - var phaseAngle: Float = 0.0f, // degrees — primary/50 kHz phase angle - var phaseAngleHigh: Float = 0.0f, // degrees — 100 kHz phase angle; 0 when not reported var ecw: Float = 0.0f, // Extracellular water, % of body weight var icw: Float = 0.0f, // Intracellular water, % of body weight var protein: Float = 0.0f, // Protein, % of body weight var bcm: Float = 0.0f, // Body cell mass, kg - var skeletalMuscle: Float = 0.0f, // Skeletal muscle, % of body weight - var leanSoftTissue: Float = 0.0f, // Fat-free mass minus bone mass, kg - var subcutaneousFat: Float = 0.0f, // Subcutaneous fat, % of body weight - var bodyAge: Int = 0, // Estimated metabolic/body age, years - var bmi22ReferenceWeight: Float = 0.0f, // kg; height-based BMI 22 reference, not a personalized target ) { // --- Utility methods --- @@ -67,20 +59,10 @@ data class ScaleMeasurement( if (other.heartRate > 0f && this.heartRate <= 0f) this.heartRate = other.heartRate if (other.impedance > 0.0 && this.impedance <= 0.0) this.impedance = other.impedance if (other.impedanceLow > 0.0 && this.impedanceLow <= 0.0) this.impedanceLow = other.impedanceLow - if (other.deviceImpedance > 0.0 && this.deviceImpedance <= 0.0) this.deviceImpedance = other.deviceImpedance - if (other.phaseAngle > 0f && this.phaseAngle <= 0f) this.phaseAngle = other.phaseAngle - if (other.phaseAngleHigh > 0f && this.phaseAngleHigh <= 0f) this.phaseAngleHigh = other.phaseAngleHigh if (other.ecw > 0f && this.ecw <= 0f) this.ecw = other.ecw if (other.icw > 0f && this.icw <= 0f) this.icw = other.icw if (other.protein > 0f && this.protein <= 0f) this.protein = other.protein if (other.bcm > 0f && this.bcm <= 0f) this.bcm = other.bcm - if (other.skeletalMuscle > 0f && this.skeletalMuscle <= 0f) this.skeletalMuscle = other.skeletalMuscle - if (other.leanSoftTissue > 0f && this.leanSoftTissue <= 0f) this.leanSoftTissue = other.leanSoftTissue - if (other.subcutaneousFat > 0f && this.subcutaneousFat <= 0f) this.subcutaneousFat = other.subcutaneousFat - if (other.bodyAge > 0 && this.bodyAge <= 0) this.bodyAge = other.bodyAge - if (other.bmi22ReferenceWeight > 0f && this.bmi22ReferenceWeight <= 0f) { - this.bmi22ReferenceWeight = other.bmi22ReferenceWeight - } if (other.userId != 0xFF && (this.userId == 0xFF || this.userId == -1)) { // -1 was common init value diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt index e71d28089..471ac2494 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/GattScaleAdapter.kt @@ -365,16 +365,6 @@ class GattScaleAdapter( } } - override suspend fun awaitPendingOperations() { - val barrier = CompletableDeferred() - val queued = opQueue.trySend { barrier.complete(Unit) } - if (queued.isFailure) { - LogManager.w(TAG, "Unable to enqueue BLE operation barrier") - return - } - barrier.await() - } - override fun disconnect() { currentPeripheral?.let { central.cancelConnection(it) } } @@ -435,4 +425,4 @@ class GattScaleAdapter( runCatching { if (::central.isInitialized) central.close() } super.close() } -} +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt index 7b2bc1208..9651e7dcc 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -549,10 +549,14 @@ class KeepS3Handler : ScaleDeviceHandler() { userId = user.id dateTime = Date() weight = weightKg - if (deviceImpedanceOhm > 0) deviceImpedance = deviceImpedanceOhm.toDouble() if (heartRateBpm > 0) heartRate = heartRateBpm - phaseAngle50Degrees?.takeIf { it.isFinite() && it > 0f }?.let { phaseAngle = it } - phaseAngle100Degrees?.takeIf { it.isFinite() && it > 0f }?.let { phaseAngleHigh = it } + // 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 @@ -596,13 +600,14 @@ class KeepS3Handler : ScaleDeviceHandler() { lbm = composition.fatFreeMassKg bmr = composition.basalMetabolicRateKcal.toFloat() protein = composition.proteinPercent - skeletalMuscle = composition.skeletalMusclePercent - // The vendor calls this "muscle", but its verified formula is FFM minus bone. - // Store it as lean soft tissue instead of openScale's skeletal-muscle metric. - leanSoftTissue = composition.muscleKg - subcutaneousFat = composition.subcutaneousFatPercent - bodyAge = composition.bodyAge - bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg + // Not published — openScale has no measurement type for these. Note that the + // vendor's "muscle" is FFM minus bone, so it is lean soft tissue rather than + // openScale's MUSCLE metric and must not be mapped onto it. + // skeletalMuscle = composition.skeletalMusclePercent + // leanSoftTissue = composition.muscleKg + // subcutaneousFat = composition.subcutaneousFatPercent + // bodyAge = composition.bodyAge + // bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg logI("Keep S3 body composition calculated with offline BHKeep SDK-compatible model") } } @@ -620,12 +625,10 @@ class KeepS3Handler : ScaleDeviceHandler() { writeTo(service, writeCharacteristic, stopRequest, withResponse = true) finishJob = scope.launch { - // A Keep S3 session can leave several 0x57 ACKs queued. Wait for those ACKs, - // the 0x58 ACK, and both stop commands before starting the disconnect delay. - awaitPendingTransportOperations() + // A Keep S3 session can leave several 0x57 ACKs, the 0x58 ACK and both stop + // commands queued. The delay gives them time to drain before disconnecting; + // the stop command is sent twice so a dropped one is not fatal. delay(DISCONNECT_DELAY_MS) - // Include any duplicate events acknowledged during the quiet period. - awaitPendingTransportOperations() requestDisconnect() } } @@ -647,16 +650,13 @@ class KeepS3Handler : ScaleDeviceHandler() { ) } + /** + * The vendor/protocol impedance is not stored by openScale, so the previous record reuses + * the high-frequency band saved with the last measurement. A Keep S3 also accepts an + * all-zero previous record, so a missing value is not fatal. + */ private fun previousDeviceImpedance(previous: ScaleMeasurement): Double { - if (previous.deviceImpedance.isFinite() && previous.deviceImpedance > 0.0) { - return previous.deviceImpedance - } - - // Compatibility with measurements saved by earlier Keep S3 test builds, which placed - // the vendor/protocol impedance in the generic high-frequency field before the three - // distinct impedance values were understood. if (previous.impedance.isFinite() && previous.impedance > 0.0) { - logW("Previous Keep S3 measurement has no device impedance; using legacy impedance value") return previous.impedance } return 0.0 diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt index 253757ae2..91dd57d73 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt @@ -63,7 +63,6 @@ import kotlinx.coroutines.withTimeoutOrNull import java.util.Date import java.util.concurrent.ConcurrentHashMap import kotlin.math.min -import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds // ------------------------------------------------------------------------------------------------- @@ -526,51 +525,13 @@ abstract class ModernScaleAdapter( fun valueOf(key: MeasurementTypeKey): MeasurementValue? = mwv.values.firstOrNull { it.type.key == key }?.value - mwv.values.firstOrNull { it.type.key == MeasurementTypeKey.WEIGHT }?.let { weight -> - m.weight = ConverterUtils.convertFloatValueUnit( - value = weight.value.floatValue ?: 0f, - fromUnit = weight.type.unit, - toUnit = UnitType.KG, - ) - } + valueOf(MeasurementTypeKey.WEIGHT)?.let { m.weight = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.BODY_FAT)?.let { m.fat = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.WATER)?.let { m.water = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.MUSCLE)?.let { m.muscle = it.floatValue ?: 0f } - mwv.values.firstOrNull { it.type.key == MeasurementTypeKey.LEAN_SOFT_TISSUE }?.let { - m.leanSoftTissue = ConverterUtils.convertFloatValueUnit( - value = it.value.floatValue ?: 0f, - fromUnit = it.type.unit, - toUnit = UnitType.KG, - ) - } - mwv.values.firstOrNull { it.type.key == MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT }?.let { - m.bmi22ReferenceWeight = ConverterUtils.convertFloatValueUnit( - value = it.value.floatValue ?: 0f, - fromUnit = it.type.unit, - toUnit = UnitType.KG, - ) - } valueOf(MeasurementTypeKey.VISCERAL_FAT)?.let { m.visceralFat = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.LBM)?.let { m.lbm = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.BONE)?.let { m.bone = it.floatValue ?: 0f } - valueOf(MeasurementTypeKey.HEART_RATE)?.let { - m.heartRate = it.intValue ?: it.floatValue?.roundToInt() ?: 0 - } - valueOf(MeasurementTypeKey.IMPEDANCE)?.let { - m.impedance = (it.floatValue ?: it.intValue?.toFloat() ?: 0f).toDouble() - } - valueOf(MeasurementTypeKey.IMPEDANCE_LOW)?.let { - m.impedanceLow = (it.floatValue ?: it.intValue?.toFloat() ?: 0f).toDouble() - } - valueOf(MeasurementTypeKey.DEVICE_IMPEDANCE)?.let { - m.deviceImpedance = (it.floatValue ?: it.intValue?.toFloat() ?: 0f).toDouble() - } - valueOf(MeasurementTypeKey.PHASE_ANGLE)?.let { - m.phaseAngle = it.floatValue ?: it.intValue?.toFloat() ?: 0f - } - valueOf(MeasurementTypeKey.PHASE_ANGLE_HIGH)?.let { - m.phaseAngleHigh = it.floatValue ?: it.intValue?.toFloat() ?: 0f - } return m } @@ -588,4 +549,4 @@ abstract class ModernScaleAdapter( sb.append(']') return sb.toString() } -} +} \ No newline at end of file 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 04bde3191..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 @@ -238,12 +238,6 @@ abstract class ScaleDeviceHandler { ?: logW("writeTo called without transport") } - /** Suspend until transport operations queued before this call have completed. */ - protected suspend fun awaitPendingTransportOperations() { - transport?.awaitPendingOperations() - ?: logW("awaitPendingTransportOperations called without transport") - } - /** Read a characteristic (rare for scales; most data comes via NOTIFY). */ protected fun readFrom(service: UUID, characteristic: UUID) { transport?.read(service, characteristic) @@ -345,7 +339,6 @@ abstract class ScaleDeviceHandler { fun setNotifyOn(service: UUID, characteristic: UUID) fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean = true) fun read(service: UUID, characteristic: UUID) - suspend fun awaitPendingOperations() = Unit fun disconnect() fun getPeripheral(): BluetoothPeripheral? = null fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean 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 4a2880fa7..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 @@ -405,14 +405,6 @@ enum class MeasurementTypeKey( ICW(32, R.string.measurement_type_icw, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), PROTEIN(33, R.string.measurement_type_protein, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), BCM(34, R.string.measurement_type_bcm, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - PHASE_ANGLE(35, R.string.measurement_type_phase_angle, listOf(UnitType.DEGREE), listOf(InputFieldType.FLOAT)), - PHASE_ANGLE_HIGH(36, R.string.measurement_type_phase_angle_high, listOf(UnitType.DEGREE), listOf(InputFieldType.FLOAT)), - SKELETAL_MUSCLE(37, R.string.measurement_type_skeletal_muscle, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - SUBCUTANEOUS_FAT(38, R.string.measurement_type_subcutaneous_fat, listOf(UnitType.PERCENT, UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - BODY_AGE(39, R.string.measurement_type_body_age, listOf(UnitType.NONE), listOf(InputFieldType.INT)), - BMI_22_REFERENCE_WEIGHT(40, R.string.measurement_type_bmi_22_reference_weight, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - LEAN_SOFT_TISSUE(41, R.string.measurement_type_lean_soft_tissue, listOf(UnitType.KG, UnitType.LB, UnitType.ST), listOf(InputFieldType.FLOAT)), - DEVICE_IMPEDANCE(42, R.string.measurement_type_device_impedance, listOf(UnitType.OHM), listOf(InputFieldType.FLOAT)), CUSTOM(99, R.string.measurement_type_custom_default_name, UnitType.entries.toList(), listOf(InputFieldType.FLOAT, InputFieldType.INT, InputFieldType.TEXT, InputFieldType.DATE, InputFieldType.TIME)); } @@ -427,7 +419,6 @@ enum class UnitType(val displayName: String) { KCAL("kcal"), BPM("bpm"), OHM("Ω"), - DEGREE("°"), NONE(""); fun isWeightUnit(): Boolean { 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 0501f8180..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_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16) + .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 @@ -74,7 +74,7 @@ object DatabaseModule { MeasurementValue::class, MeasurementType::class, ], - version = 16, + version = 15, exportSchema = true ) @TypeConverters(DatabaseConverters::class) @@ -621,78 +621,3 @@ val MIGRATION_14_15 = object : Migration(14, 15) { } } } - -val MIGRATION_15_16 = object : Migration(15, 16) { - override fun migrate(db: SupportSQLiteDatabase) { - val newKeys = setOf( - MeasurementTypeKey.PHASE_ANGLE, - MeasurementTypeKey.PHASE_ANGLE_HIGH, - MeasurementTypeKey.SKELETAL_MUSCLE, - MeasurementTypeKey.LEAN_SOFT_TISSUE, - MeasurementTypeKey.SUBCUTANEOUS_FAT, - MeasurementTypeKey.BODY_AGE, - MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT, - MeasurementTypeKey.DEVICE_IMPEDANCE, - ) - getDefaultMeasurementTypes().filter { it.key in newKeys }.forEach { type -> - db.execSQL( - """ - INSERT INTO MeasurementType - (`key`, `name`, `color`, `icon`, `unit`, `inputType`, `displayOrder`, - `isDerived`, `isEnabled`, `isPinned`, `isOnRightYAxis`, `isInternal`) - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - WHERE NOT EXISTS ( - SELECT 1 FROM MeasurementType WHERE `key` = ? - ) - """.trimIndent(), - arrayOf( - type.key.name, - null, - type.color, - type.icon.name, - type.unit.name, - type.inputType.name, - -1, - if (type.isDerived) 1 else 0, - if (type.isEnabled) 1 else 0, - if (type.isPinned) 1 else 0, - if (type.isOnRightYAxis) 1 else 0, - if (type.isInternal) 1 else 0, - type.key.name, - ), - ) - - // Very old databases are rebuilt by MIGRATION_6_7 using the current default list. - // In a full-chain migration that can create these keys before isInternal exists, so - // normalize their v16 metadata even when the idempotent insert found an existing row. - db.execSQL( - """ - UPDATE MeasurementType - SET `color` = ?, `icon` = ?, `unit` = ?, `inputType` = ?, - `isDerived` = ?, `isEnabled` = ?, `isPinned` = ?, - `isOnRightYAxis` = ?, `isInternal` = ? - WHERE `key` = ? - """.trimIndent(), - arrayOf( - type.color, - type.icon.name, - type.unit.name, - type.inputType.name, - if (type.isDerived) 1 else 0, - if (type.isEnabled) 1 else 0, - if (type.isPinned) 1 else 0, - if (type.isOnRightYAxis) 1 else 0, - if (type.isInternal) 1 else 0, - type.key.name, - ), - ) - } - - getDefaultMeasurementTypes().forEachIndexed { index, measurementType -> - db.execSQL( - "UPDATE MeasurementType SET displayOrder = ? WHERE `key` = ?", - arrayOf(index + 1, measurementType.key.name), - ) - } - } -} diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt index e90e845ee..df7530a45 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/BleConnector.kt @@ -30,7 +30,6 @@ import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.UnitType import com.health.openscale.core.facade.MeasurementFacade -import com.health.openscale.core.utils.ConverterUtils import com.health.openscale.core.utils.LogManager import com.health.openscale.ui.shared.SnackbarEvent import kotlinx.coroutines.CoroutineScope @@ -421,10 +420,8 @@ class BleConnector( * defined by each `MeasurementType`'s `unit` field before persisting. * * ### Raw units assumed for ScaleMeasurement - * - WEIGHT, BONE, LBM, LEAN_SOFT_TISSUE → **KG** - * - BODY_FAT, WATER, MUSCLE, ECW, ICW, PROTEIN, SKELETAL_MUSCLE, - * SUBCUTANEOUS_FAT → **PERCENT** - * - VISCERAL_FAT → vendor-defined unitless level + * - WEIGHT, BONE, LBM → **KG** + * - BODY_FAT, WATER, MUSCLE, VISCERAL_FAT → **PERCENT** * * Other fields in `ScaleMeasurement` (if added later) should be appended here with the correct raw unit. * @@ -474,28 +471,22 @@ class BleConnector( fun getTargetUnit(key: MeasurementTypeKey) = typeKeyToUnitMap[key] ?: UnitType.NONE // Declare raw units provided by ScaleMeasurement for each key. + // Percent-based values will "convert" to themselves (converter returns unchanged value). val rawUnitByKey: Map = mapOf( MeasurementTypeKey.WEIGHT to UnitType.KG, MeasurementTypeKey.BODY_FAT to UnitType.PERCENT, MeasurementTypeKey.WATER to UnitType.PERCENT, MeasurementTypeKey.MUSCLE to UnitType.PERCENT, - MeasurementTypeKey.VISCERAL_FAT to UnitType.NONE, + MeasurementTypeKey.VISCERAL_FAT to UnitType.PERCENT, MeasurementTypeKey.BONE to UnitType.KG, MeasurementTypeKey.LBM to UnitType.KG, MeasurementTypeKey.HEART_RATE to UnitType.BPM, MeasurementTypeKey.IMPEDANCE to UnitType.OHM, MeasurementTypeKey.IMPEDANCE_LOW to UnitType.OHM, - MeasurementTypeKey.DEVICE_IMPEDANCE to UnitType.OHM, - MeasurementTypeKey.PHASE_ANGLE to UnitType.DEGREE, - MeasurementTypeKey.PHASE_ANGLE_HIGH to UnitType.DEGREE, MeasurementTypeKey.ECW to UnitType.PERCENT, MeasurementTypeKey.ICW to UnitType.PERCENT, MeasurementTypeKey.PROTEIN to UnitType.PERCENT, MeasurementTypeKey.BCM to UnitType.KG, - MeasurementTypeKey.SKELETAL_MUSCLE to UnitType.PERCENT, - MeasurementTypeKey.LEAN_SOFT_TISSUE to UnitType.KG, - MeasurementTypeKey.SUBCUTANEOUS_FAT to UnitType.PERCENT, - MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT to UnitType.KG, MeasurementTypeKey.BMR to UnitType.KCAL ) @@ -505,8 +496,7 @@ class BleConnector( * Adds a converted float value for the given key if present & valid. * - Reads the raw unit for the key (what the device/handler provided). * - Looks up the target unit from MeasurementType. - * - Converts percentage-or-mass composition values using the same measurement's - * body weight; all other values use the regular unit converter. + * - Converts using existing ConverterUtils.convertFloatValueUnit. */ fun addConvertedIfValid( value: Float?, @@ -519,22 +509,9 @@ class BleConnector( val rawUnit = rawUnitByKey[key] ?: UnitType.NONE val target = getTargetUnit(key) - val converted = if (ConverterUtils.isPercentageOrMassComposition(key)) { - ConverterUtils.convertPercentageOrMassCompositionUnit( - value = v, - fromUnit = rawUnit, - toUnit = target, - bodyWeightKg = measurementData.weight, - ) ?: run { - LogManager.w( - TAG, - "Skipping $key: cannot convert $rawUnit to $target without a valid body weight." - ) - return - } - } else { - ConverterUtils.convertFloatValueUnit(v, rawUnit, target) - } + val converted = com.health.openscale.core.utils.ConverterUtils.convertFloatValueUnit( + v, rawUnit, target + ) getTypeId(key)?.let { typeId -> values.add( @@ -585,21 +562,10 @@ class BleConnector( addConvertedIfValid(measurementData.heartRate, MeasurementTypeKey.HEART_RATE) addConvertedIfValid(measurementData.impedance.toFloat(), MeasurementTypeKey.IMPEDANCE) addConvertedIfValid(measurementData.impedanceLow.toFloat(), MeasurementTypeKey.IMPEDANCE_LOW) - addConvertedIfValid(measurementData.deviceImpedance.toFloat(), MeasurementTypeKey.DEVICE_IMPEDANCE) - addConvertedIfValid(measurementData.phaseAngle, MeasurementTypeKey.PHASE_ANGLE) - addConvertedIfValid(measurementData.phaseAngleHigh, MeasurementTypeKey.PHASE_ANGLE_HIGH) addConvertedIfValid(measurementData.ecw, MeasurementTypeKey.ECW) addConvertedIfValid(measurementData.icw, MeasurementTypeKey.ICW) addConvertedIfValid(measurementData.protein, MeasurementTypeKey.PROTEIN) addConvertedIfValid(measurementData.bcm, MeasurementTypeKey.BCM) - addConvertedIfValid(measurementData.skeletalMuscle, MeasurementTypeKey.SKELETAL_MUSCLE) - addConvertedIfValid(measurementData.leanSoftTissue, MeasurementTypeKey.LEAN_SOFT_TISSUE) - addConvertedIfValid(measurementData.subcutaneousFat, MeasurementTypeKey.SUBCUTANEOUS_FAT) - addConvertedIfValid(measurementData.bodyAge, MeasurementTypeKey.BODY_AGE) - addConvertedIfValid( - measurementData.bmi22ReferenceWeight, - MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT, - ) if (values.isEmpty()) { LogManager.w(TAG, "No valid values from measurement of $deviceName to save.") @@ -728,4 +694,4 @@ class BleConnector( } LogManager.i(TAG, "BluetoothConnectionManager closed.") } -} +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt index 22f7a7d67..369a2e6db 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt @@ -21,6 +21,7 @@ import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType +import com.health.openscale.core.data.WeightUnit import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.utils.ConverterUtils import com.health.openscale.core.utils.LogManager @@ -75,9 +76,9 @@ class MeasurementTypeCrudUseCases @Inject constructor( * Updates a type (e.g., name, flags, **unit**) and, if its unit changed, converts * all existing values of that type to the new unit. * - * Composition values that support both PERCENT and absolute weight units (KG/LB/ST) use - * the WEIGHT value from the *same measurement*. If the required weight value is missing - * for a row, that row is skipped. + * Special handling: BODY_FAT, WATER, MUSCLE may switch between PERCENT and absolute + * weight units (KG/LB/ST). Conversion uses the WEIGHT value from the *same measurement*. + * If the required weight value is missing for a row, that row is skipped. * * Note: repository.updateMeasurementValue(...) is assumed to trigger derived-value * recalculation. If not, add explicit recalculation here after updates. @@ -129,34 +130,57 @@ class MeasurementTypeCrudUseCases @Inject constructor( var converted: Float? // Percent <-> absolute conversions for composition-like metrics - if (ConverterUtils.isPercentageOrMassComposition(typeKey)) { - val needsBodyWeight = oldUnit == UnitType.PERCENT || newUnit == UnitType.PERCENT - val weightInKg = if (needsBodyWeight) { - val resolvedWeightType = weightType ?: continue - if (!resolvedWeightType.unit.isWeightUnit()) continue - - val weightOnThisMeasurement = repository - .getValuesForMeasurement(mv.measurementId) - .first() - .find { it.typeId == resolvedWeightType.id } - ?.floatValue - ?: continue - - ConverterUtils.convertFloatValueUnit( - weightOnThisMeasurement, - resolvedWeightType.unit, - UnitType.KG, - ) - } else { - null + if (typeKey == MeasurementTypeKey.BODY_FAT || + typeKey == MeasurementTypeKey.WATER || + typeKey == MeasurementTypeKey.MUSCLE + ) { + if (weightType == null) { + // No weight type found; cannot compute percent-based conversions. + continue } - converted = ConverterUtils.convertPercentageOrMassCompositionUnit( - value = current, - fromUnit = oldUnit, - toUnit = newUnit, - bodyWeightKg = weightInKg, - ) ?: continue + val weightOnThisMeasurement = repository + .getValuesForMeasurement(mv.measurementId) + .first() + .find { it.typeId == weightType.id }?.floatValue + + if (weightOnThisMeasurement == null) { + // Missing WEIGHT value for this measurement row; skip. + continue + } + + // Normalize the total weight to KG for math, then convert to target at the end + val weightInKg = when (weightType.unit) { + UnitType.KG -> weightOnThisMeasurement + UnitType.LB -> ConverterUtils.toKilogram(weightOnThisMeasurement, WeightUnit.LB) + UnitType.ST -> ConverterUtils.toKilogram(weightOnThisMeasurement, WeightUnit.ST) + else -> null + } ?: continue + + when { + // PERCENT -> absolute (kg/lb/st) + oldUnit == UnitType.PERCENT && newUnit.isWeightUnit() -> { + val absoluteInKg = (current / 100f) * weightInKg + converted = ConverterUtils.convertFloatValueUnit(absoluteInKg, UnitType.KG, newUnit) + } + // absolute (kg/lb/st) -> PERCENT + oldUnit.isWeightUnit() && newUnit == UnitType.PERCENT -> { + val currentInKg = ConverterUtils.convertFloatValueUnit(current, oldUnit, UnitType.KG) + if (weightInKg != 0f) { + converted = currentInKg / weightInKg * 100f + } else { + converted = 0f + } + } + // absolute <-> absolute + oldUnit.isWeightUnit() && newUnit.isWeightUnit() -> { + converted = ConverterUtils.convertFloatValueUnit(current, oldUnit, newUnit) + } + else -> { + // Unsupported path, keep original + converted = current + } + } } else { // Generic unit conversion converted = ConverterUtils.convertFloatValueUnit(current, oldUnit, newUnit) 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 48ea6b5f9..d8d4d5cea 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 @@ -318,7 +318,6 @@ object GenericValueJson { UnitType.KCAL -> "kcal" UnitType.BPM -> "/min" UnitType.OHM -> "Ohm" - UnitType.DEGREE -> "deg" UnitType.NONE -> "" } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt index c1622430c..8e9d01ed5 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/ConverterUtils.kt @@ -18,7 +18,6 @@ package com.health.openscale.core.utils import com.health.openscale.core.data.MeasureUnit -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.WeightUnit import kotlin.math.floor @@ -32,17 +31,6 @@ object ConverterUtils { private const val LB_PER_ST_DOUBLE: Double = 14.0 - private val percentageOrMassCompositionKeys = setOf( - MeasurementTypeKey.BODY_FAT, - MeasurementTypeKey.WATER, - MeasurementTypeKey.MUSCLE, - MeasurementTypeKey.ECW, - MeasurementTypeKey.ICW, - MeasurementTypeKey.PROTEIN, - MeasurementTypeKey.SKELETAL_MUSCLE, - MeasurementTypeKey.SUBCUTANEOUS_FAT, - ) - @JvmStatic fun toKilogram(value: Float, unit: WeightUnit): Float { when (unit) { @@ -264,54 +252,6 @@ object ConverterUtils { return value } - /** - * Whether [key] represents a body-composition value that may be stored either as a - * percentage of body weight or as an absolute mass. - * - * Mass-only values such as lean soft tissue are intentionally excluded. - */ - @JvmStatic - fun isPercentageOrMassComposition(key: MeasurementTypeKey): Boolean = - key in percentageOrMassCompositionKeys - - /** - * Converts a body-composition value between percent and mass units. - * - * Unlike [convertFloatValueUnit], percent-to-mass conversions require the body weight from - * the same measurement, normalized to kilograms. Unsupported conversions, or conversions - * that need a missing/invalid body weight, return `null` instead of silently returning the - * unconverted value. - */ - @JvmStatic - fun convertPercentageOrMassCompositionUnit( - value: Float, - fromUnit: UnitType, - toUnit: UnitType, - bodyWeightKg: Float?, - ): Float? { - if (!value.isFinite()) return null - if (fromUnit == toUnit) return value - - if (fromUnit.isWeightUnit() && toUnit.isWeightUnit()) { - return convertFloatValueUnit(value, fromUnit, toUnit) - } - - val validBodyWeightKg = bodyWeightKg?.takeIf { it.isFinite() && it > 0f } - ?: return null - - return when { - fromUnit == UnitType.PERCENT && toUnit.isWeightUnit() -> { - val massKg = value / 100f * validBodyWeightKg - convertFloatValueUnit(massKg, UnitType.KG, toUnit) - } - fromUnit.isWeightUnit() && toUnit == UnitType.PERCENT -> { - val massKg = convertFloatValueUnit(value, fromUnit, UnitType.KG) - massKg / validBodyWeightKg * 100f - } - else -> null - } - } - /** * Removes all non-digit characters from [input] and truncates the result to [maxLen] characters. * @@ -323,4 +263,4 @@ object ConverterUtils { fun sanitizeDigits(input: String, maxLen: Int): String = input.filter { it.isDigit() }.take(maxLen) -} +} \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt b/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt index c5e057fea..58afa3d59 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/utils/LocaleUtils.kt @@ -164,7 +164,6 @@ object LocaleUtils { UnitType.KCAL-> "$signPrefix${formatNumber(absVal, maxFraction = 0, locale)} kcal" UnitType.BPM -> "$signPrefix${formatNumber(absVal, maxFraction = 0, locale)} bpm" UnitType.OHM -> "$signPrefix${formatNumber(absVal, maxFraction = 1, locale)} Ω" - UnitType.DEGREE -> "$signPrefix${formatNumber(absVal, maxFraction = 1, locale)}°" UnitType.NONE-> signPrefix + formatNumber(absVal, maxFraction = 1, locale) } } 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 4e4358713..a1027eef0 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 @@ -47,14 +47,6 @@ 体脂率 水分 肌肉 - 相位角 - 相位角(100 kHz) - 骨骼肌率 - 去骨瘦体重 - 设备阻抗 - 皮下脂肪率 - 估算身体年龄 - BMI 22 参考体重 我的目标 %d目标 没有目标 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 4e527a332..e0e94614e 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 @@ -34,14 +34,6 @@ 編輯類型 增加類型 測量類型 - 相位角 - 相位角(100 kHz) - 骨骼肌率 - 去骨瘦體重 - 裝置阻抗 - 皮下脂肪率 - 估算身體年齡 - BMI 22 參考體重 編輯使用者 新增使用者 使用者 diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 606a10de6..7adea8155 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + openScale Last change wasn\'t synchronized. Please open openScale-sync and run a manual sync. @@ -187,14 +187,6 @@ Heart Rate Impedance (high) Impedance (low) - Phase angle - Phase angle (100 kHz) - Skeletal muscle - Lean soft tissue - Device impedance - Subcutaneous fat - Estimated body age - BMI 22 reference weight Extracellular water Intracellular water Protein diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt index 2cb4707e6..62df54e20 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt @@ -23,7 +23,6 @@ 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.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy @@ -174,7 +173,6 @@ class KeepS3HandlerTest { val setup = attachedHandler(scope = this) setup.handler.handleConnected(setup.user) setup.transport.clearWrites() - setup.transport.blockPendingOperations() // A non-final 0x57 can still provide the impedance used by the 0x58 fallback. setup.handler.handleNotification( @@ -194,42 +192,29 @@ class KeepS3HandlerTest { assertThat(setup.callbacks.published).hasSize(1) assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(85.10f) - assertThat(setup.callbacks.published.single().deviceImpedance).isEqualTo(301.0) 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().phaseAngle).isWithin(0.0001f).of(7.6f) - assertThat(setup.callbacks.published.single().phaseAngleHigh).isWithin(0.0001f).of(7.6f) assertThat(setup.callbacks.published.single().fat).isEqualTo(29.5f) assertThat(setup.callbacks.published.single().water).isEqualTo(50.2f) assertThat(setup.callbacks.published.single().muscle).isEqualTo(0f) - assertThat(setup.callbacks.published.single().leanSoftTissue).isEqualTo(57.0f) - assertThat(setup.callbacks.published.single().skeletalMuscle) - .isWithin(0.001f).of(38.42538f) - assertThat(setup.callbacks.published.single().subcutaneousFat).isEqualTo(25.7f) assertThat(setup.callbacks.published.single().visceralFat).isEqualTo(12f) assertThat(setup.callbacks.published.single().protein).isEqualTo(12.7f) assertThat(setup.callbacks.published.single().bone).isEqualTo(3.0f) assertThat(setup.callbacks.published.single().lbm).isEqualTo(60.0f) assertThat(setup.callbacks.published.single().bmr).isEqualTo(1791f) - assertThat(setup.callbacks.published.single().bodyAge).isEqualTo(28) - assertThat(setup.callbacks.published.single().bmi22ReferenceWeight).isEqualTo(62.0f) 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) - runCurrent() - assertThat(setup.transport.awaitPendingCount).isEqualTo(1) - advanceTimeBy(800) + // 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) - setup.transport.releasePendingOperations() - runCurrent() advanceTimeBy(800) runCurrent() - assertThat(setup.transport.awaitPendingCount).isEqualTo(2) assertThat(setup.transport.disconnectCount).isEqualTo(1) } @@ -257,19 +242,11 @@ class KeepS3HandlerTest { assertThat(setup.callbacks.published).hasSize(1) assertThat(setup.callbacks.published.single().userId).isEqualTo(setup.user.id) - assertThat(setup.callbacks.published.single().deviceImpedance).isEqualTo(300.0) assertThat(setup.callbacks.published.single().impedance).isEqualTo(475.0) assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(502.0) - assertThat(setup.callbacks.published.single().phaseAngle).isWithin(0.0001f).of(7.7f) - assertThat(setup.callbacks.published.single().phaseAngleHigh).isWithin(0.0001f).of(7.7f) assertThat(setup.callbacks.published.single().fat).isEqualTo(29.4f) assertThat(setup.callbacks.published.single().water).isEqualTo(50.3f) assertThat(setup.callbacks.published.single().muscle).isEqualTo(0f) - assertThat(setup.callbacks.published.single().leanSoftTissue).isEqualTo(57.1f) - assertThat(setup.callbacks.published.single().skeletalMuscle) - .isWithin(0.001f).of(38.47059f) - assertThat(setup.callbacks.published.single().subcutaneousFat).isEqualTo(25.5f) - assertThat(setup.callbacks.published.single().bodyAge).isEqualTo(28) assertThat(setup.transport.writes.count { it.payload.contentEquals(KeepS3Protocol.buildAck(0x57)) }).isEqualTo(2) @@ -297,11 +274,9 @@ class KeepS3HandlerTest { assertThat(setup.callbacks.published).hasSize(1) assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(85.00f) - assertThat(setup.callbacks.published.single().deviceImpedance).isEqualTo(300.0) assertThat(setup.callbacks.published.single().impedance).isEqualTo(0.0) assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(0.0) assertThat(setup.callbacks.published.single().heartRate).isEqualTo(97) - assertThat(setup.callbacks.published.single().phaseAngle).isEqualTo(0f) } @Test @@ -327,7 +302,8 @@ class KeepS3HandlerTest { 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().bodyAge).isEqualTo(0) + assertThat(setup.callbacks.published.single().water).isEqualTo(0f) + assertThat(setup.callbacks.published.single().lbm).isEqualTo(0f) } @Test @@ -363,8 +339,8 @@ class KeepS3HandlerTest { val actual = setup.callbacks.published.single() assertThat(actual.fat).isEqualTo(expected.bodyFatPercent) assertThat(actual.water).isEqualTo(expected.waterPercent) - assertThat(actual.skeletalMuscle).isEqualTo(expected.skeletalMusclePercent) - assertThat(actual.bodyAge).isEqualTo(expected.bodyAge) + assertThat(actual.bone).isEqualTo(expected.boneKg) + assertThat(actual.lbm).isEqualTo(expected.fatFreeMassKg) } @Test @@ -532,24 +508,7 @@ class KeepS3HandlerTest { } @Test - fun `profile prefers device impedance for previous Keep S3 record`() { - val previous = ScaleMeasurement( - userId = 7, - dateTime = Date(0x1234_5678L * 1000L), - weight = 85.10f, - impedance = 999.0, - deviceImpedance = 301.0, - ) - val setup = attachedHandler(previous = previous) - - driveInitializationThroughProfile(setup) - - val profileRequest = setup.transport.writes.single { requestOpcode(it.payload) == 0x32 }.payload - assertThat(KeepS3Protocol.decodeU16BE(profileRequest, 5 + 54)).isEqualTo(301) - } - - @Test - fun `profile falls back to legacy impedance when device impedance is absent`() { + fun `profile carries the impedance of the previous Keep S3 record`() { val previous = ScaleMeasurement( userId = 7, dateTime = Date(0x1234_5678L * 1000L), @@ -607,7 +566,7 @@ class KeepS3HandlerTest { userId = user.id, dateTime = Date(0x1234_5678L * 1000L), weight = 85.10f, - deviceImpedance = 301.0, + impedance = 301.0, ), ): Setup { val handler = KeepS3Handler() @@ -739,8 +698,6 @@ class KeepS3HandlerTest { val notifications = mutableListOf>() val writes = mutableListOf() var disconnectCount = 0 - var awaitPendingCount = 0 - private var pendingOperationsGate: CompletableDeferred? = null override fun setNotifyOn(service: UUID, characteristic: UUID) { notifications += service to characteristic @@ -757,11 +714,6 @@ class KeepS3HandlerTest { override fun read(service: UUID, characteristic: UUID) = Unit - override suspend fun awaitPendingOperations() { - awaitPendingCount++ - pendingOperationsGate?.await() - } - override fun disconnect() { disconnectCount++ } @@ -769,16 +721,6 @@ class KeepS3HandlerTest { override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = true fun clearWrites() = writes.clear() - - fun blockPendingOperations() { - check(pendingOperationsGate == null) - pendingOperationsGate = CompletableDeferred() - } - - fun releasePendingOperations() { - pendingOperationsGate?.complete(Unit) - pendingOperationsGate = null - } } private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { 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 a98e9d800..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 @@ -22,8 +22,6 @@ 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.MeasurementTypeKey -import com.health.openscale.core.data.UnitType import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -37,7 +35,7 @@ import kotlin.math.abs /** * Exercises the full migration chain end-to-end on the JVM (Robolectric): a hand-built * legacy schema-v6 database is opened with all migrations, running MIGRATION_6_7 (the risky - * legacy rewrite) through MIGRATION_15_16 in sequence. Verifies the enum mapping and that + * legacy rewrite) through MIGRATION_14_15 in sequence. Verifies the enum mapping and that * data survives — the highest data-loss risk in the app. */ @RunWith(RobolectricTestRunner::class) @@ -61,7 +59,7 @@ class MigrationTest { RoomTestSupport.writeLegacyV6Database(dbFile) - // Opening with the full migration chain runs MIGRATION_6_7 .. MIGRATION_15_16 in order. + // Opening with the full migration chain runs MIGRATION_6_7 .. MIGRATION_14_15 in order. val opened = RoomTestSupport.onDisk(context).also { db = it } val repo = RoomTestSupport.repositoryFor(opened) @@ -80,27 +78,8 @@ class MigrationTest { .firstOrNull { abs(it - 72.5f) < 0.1f } assertThat(weight).isNotNull() - val types = repo.getAllMeasurementTypes().first() - assertThat(types).isNotEmpty() - assertThat(types.single { it.key == MeasurementTypeKey.PHASE_ANGLE }.unit) - .isEqualTo(UnitType.DEGREE) - assertThat(types.single { it.key == MeasurementTypeKey.PHASE_ANGLE_HIGH }.isInternal) - .isTrue() - assertThat(types.single { it.key == MeasurementTypeKey.SKELETAL_MUSCLE }.unit) - .isEqualTo(UnitType.PERCENT) - assertThat(types.single { it.key == MeasurementTypeKey.LEAN_SOFT_TISSUE }.unit) - .isEqualTo(UnitType.KG) - assertThat(types.single { it.key == MeasurementTypeKey.SUBCUTANEOUS_FAT }.unit) - .isEqualTo(UnitType.PERCENT) - assertThat(types.single { it.key == MeasurementTypeKey.BODY_AGE }.unit) - .isEqualTo(UnitType.NONE) - assertThat(types.single { it.key == MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT }.unit) - .isEqualTo(UnitType.KG) - assertThat(MeasurementTypeKey.BMI_22_REFERENCE_WEIGHT.id).isEqualTo(40) - val deviceImpedance = types.single { it.key == MeasurementTypeKey.DEVICE_IMPEDANCE } - assertThat(deviceImpedance.unit).isEqualTo(UnitType.OHM) - assertThat(deviceImpedance.isEnabled).isFalse() - assertThat(deviceImpedance.isInternal).isTrue() + // The rewrite seeds the default measurement types. + assertThat(repo.getAllMeasurementTypes().first()).isNotEmpty() } /** @@ -117,7 +96,7 @@ class MigrationTest { RoomTestSupport.writeLegacyV1Database(dbFile) - // Opening with the full migration chain runs MIGRATION_1_2 .. MIGRATION_15_16 in order. + // 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) diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt index b2cab84d9..dd582b60f 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCasesTest.kt @@ -29,7 +29,6 @@ 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.ConverterUtils import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first @@ -121,44 +120,6 @@ class MeasurementTypeCrudUseCasesTest { assertThat(valueOf(bodyFat.id)).isWithin(1e-2f).of(16f) // 20% of 80kg } - @Test - fun compositionConversion_proteinPercentToKg_usesPerMeasurementWeight() = runBlocking { - val weight = type(MeasurementTypeKey.WEIGHT) - val protein = type(MeasurementTypeKey.PROTEIN) - val mId = newMeasurement(1_000L) - repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = weight.id, floatValue = 85.55f)) - repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = protein.id, floatValue = 12.8f)) - - val report = useCase.updateTypeAndConvertValues( - protein, - protein.copy(unit = UnitType.KG), - ).getOrThrow() - - assertThat(report.updatedCount).isEqualTo(1) - assertThat(valueOf(protein.id)).isWithin(1e-4f).of(10.9504f) - } - - @Test - fun compositionConversion_skeletalMuscleKgToPercent_normalizesLbBodyWeight() = runBlocking { - val weight = type(MeasurementTypeKey.WEIGHT) - val skeletalMuscle = type(MeasurementTypeKey.SKELETAL_MUSCLE) - repo.updateMeasurementType(weight.copy(unit = UnitType.LB)) - repo.updateMeasurementType(skeletalMuscle.copy(unit = UnitType.KG)) - - val mId = newMeasurement(1_000L) - val weightLb = ConverterUtils.convertFloatValueUnit(85.55f, UnitType.KG, UnitType.LB) - repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = weight.id, floatValue = weightLb)) - repo.insertMeasurementValue(MeasurementValue(measurementId = mId, typeId = skeletalMuscle.id, floatValue = 33.0223f)) - - val report = useCase.updateTypeAndConvertValues( - skeletalMuscle.copy(unit = UnitType.KG), - skeletalMuscle.copy(unit = UnitType.PERCENT), - ).getOrThrow() - - assertThat(report.updatedCount).isEqualTo(1) - assertThat(valueOf(skeletalMuscle.id)).isWithin(1e-3f).of(38.6f) - } - @Test fun compositionConversion_percentToKg_skipsRowsWithoutWeight() = runBlocking { val bodyFat = type(MeasurementTypeKey.BODY_FAT) diff --git a/android_app/app/src/test/java/com/health/openscale/core/utils/ConverterUtilsTest.kt b/android_app/app/src/test/java/com/health/openscale/core/utils/ConverterUtilsTest.kt index 2b09bea78..6907b9f60 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/utils/ConverterUtilsTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/utils/ConverterUtilsTest.kt @@ -19,7 +19,6 @@ package com.health.openscale.core.utils import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.MeasureUnit -import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.WeightUnit import org.junit.Test @@ -101,102 +100,6 @@ class ConverterUtilsTest { assertThat(ConverterUtils.convertFloatValueUnit(42f, UnitType.PERCENT, UnitType.KG)).isEqualTo(42f) } - // ---- percentage-or-mass body composition --------------------------------------------------- - - @Test - fun compositionConversion_proteinPercentToKg_usesBodyWeight() { - val converted = ConverterUtils.convertPercentageOrMassCompositionUnit( - value = 12.8f, - fromUnit = UnitType.PERCENT, - toUnit = UnitType.KG, - bodyWeightKg = 85.55f, - ) - - assertThat(converted).isNotNull() - assertThat(converted!!).isWithin(1e-4f).of(10.9504f) - } - - @Test - fun compositionConversion_skeletalMusclePercentRoundTripsThroughKg() { - val massKg = ConverterUtils.convertPercentageOrMassCompositionUnit( - value = 38.6f, - fromUnit = UnitType.PERCENT, - toUnit = UnitType.KG, - bodyWeightKg = 85.55f, - ) - val percent = ConverterUtils.convertPercentageOrMassCompositionUnit( - value = massKg!!, - fromUnit = UnitType.KG, - toUnit = UnitType.PERCENT, - bodyWeightKg = 85.55f, - ) - - assertThat(massKg).isWithin(1e-4f).of(33.0223f) - assertThat(percent).isNotNull() - assertThat(percent!!).isWithin(1e-4f).of(38.6f) - } - - @Test - fun compositionConversion_percentToLbAndStone_matchesKgConversion() { - val massKg = 10.9504f - val massLb = ConverterUtils.convertPercentageOrMassCompositionUnit( - 12.8f, UnitType.PERCENT, UnitType.LB, 85.55f - ) - val massSt = ConverterUtils.convertPercentageOrMassCompositionUnit( - 12.8f, UnitType.PERCENT, UnitType.ST, 85.55f - ) - - assertThat(massLb).isNotNull() - assertThat(massLb!!).isWithin(1e-4f) - .of(ConverterUtils.convertFloatValueUnit(massKg, UnitType.KG, UnitType.LB)) - assertThat(massSt).isNotNull() - assertThat(massSt!!).isWithin(1e-4f) - .of(ConverterUtils.convertFloatValueUnit(massKg, UnitType.KG, UnitType.ST)) - } - - @Test - fun compositionConversion_rejectsMissingWeightAndUnsupportedUnits() { - assertThat( - ConverterUtils.convertPercentageOrMassCompositionUnit( - 12.8f, UnitType.PERCENT, UnitType.KG, null - ) - ).isNull() - assertThat( - ConverterUtils.convertPercentageOrMassCompositionUnit( - 12.8f, UnitType.PERCENT, UnitType.CM, 85.55f - ) - ).isNull() - } - - @Test - fun compositionConversion_samePercentUnitDoesNotRequireBodyWeight() { - assertThat( - ConverterUtils.convertPercentageOrMassCompositionUnit( - 12.8f, UnitType.PERCENT, UnitType.PERCENT, null - ) - ).isEqualTo(12.8f) - } - - @Test - fun percentageOrMassCompositionKeys_includeExtendedMetricsButNotLeanSoftTissue() { - val expected = listOf( - MeasurementTypeKey.BODY_FAT, - MeasurementTypeKey.WATER, - MeasurementTypeKey.MUSCLE, - MeasurementTypeKey.ECW, - MeasurementTypeKey.ICW, - MeasurementTypeKey.PROTEIN, - MeasurementTypeKey.SKELETAL_MUSCLE, - MeasurementTypeKey.SUBCUTANEOUS_FAT, - ) - - expected.forEach { - assertThat(ConverterUtils.isPercentageOrMassComposition(it)).isTrue() - } - assertThat(ConverterUtils.isPercentageOrMassComposition(MeasurementTypeKey.LEAN_SOFT_TISSUE)) - .isFalse() - } - // ---- sanitizeDigits ------------------------------------------------------------------------- @Test 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 71e0c08ce..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,7 +31,6 @@ import com.health.openscale.core.database.MIGRATION_11_12 import com.health.openscale.core.database.MIGRATION_12_13 import com.health.openscale.core.database.MIGRATION_13_14 import com.health.openscale.core.database.MIGRATION_14_15 -import com.health.openscale.core.database.MIGRATION_15_16 import com.health.openscale.core.database.MIGRATION_1_2 import com.health.openscale.core.database.MIGRATION_2_3 import com.health.openscale.core.database.MIGRATION_3_4 @@ -83,7 +82,6 @@ object RoomTestSupport { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, - MIGRATION_15_16, ) /** On-disk database at the real [AppDatabase.DATABASE_NAME] path, with all migrations applied. */ From 73b8f0720fcd045b0db3e95541bcb19f011af98c Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sun, 2 Aug 2026 15:45:06 +0200 Subject: [PATCH 3/5] Add muscle percentage mapping in KeepS3Handler --- .../openscale/core/bluetooth/scales/KeepS3Handler.kt | 9 +++++---- .../openscale/core/bluetooth/scales/KeepS3HandlerTest.kt | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt index 9651e7dcc..b0c3bf213 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -600,11 +600,12 @@ class KeepS3Handler : ScaleDeviceHandler() { lbm = composition.fatFreeMassKg bmr = composition.basalMetabolicRateKcal.toFloat() protein = composition.proteinPercent - // Not published — openScale has no measurement type for these. Note that the - // vendor's "muscle" is FFM minus bone, so it is lean soft tissue rather than - // openScale's MUSCLE metric and must not be mapped onto it. + // The vendor's "muscle" is fat-free mass minus bone. That is the same formula + // BodyMiScaleLib.getMuscleMass() uses, and MiScaleHandler publishes it as a + // percentage of body weight in MUSCLE, so this matches openScale's convention. + muscle = composition.musclePercent + // Not published — openScale has no measurement type for these. // skeletalMuscle = composition.skeletalMusclePercent - // leanSoftTissue = composition.muscleKg // subcutaneousFat = composition.subcutaneousFatPercent // bodyAge = composition.bodyAge // bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt index 62df54e20..6069bdbbc 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt @@ -197,7 +197,7 @@ class KeepS3HandlerTest { 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).isEqualTo(0f) + assertThat(setup.callbacks.published.single().muscle).isEqualTo(66.9f) 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) @@ -246,7 +246,7 @@ class KeepS3HandlerTest { 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).isEqualTo(0f) + assertThat(setup.callbacks.published.single().muscle).isEqualTo(67.1f) assertThat(setup.transport.writes.count { it.payload.contentEquals(KeepS3Protocol.buildAck(0x57)) }).isEqualTo(2) @@ -339,6 +339,7 @@ class KeepS3HandlerTest { val actual = setup.callbacks.published.single() assertThat(actual.fat).isEqualTo(expected.bodyFatPercent) assertThat(actual.water).isEqualTo(expected.waterPercent) + assertThat(actual.muscle).isEqualTo(expected.musclePercent) assertThat(actual.bone).isEqualTo(expected.boneKg) assertThat(actual.lbm).isEqualTo(expected.fatFreeMassKg) } From b4645d538dcd7bd427e0a7f61296896f9c26d614 Mon Sep 17 00:00:00 2001 From: Leko Date: Mon, 3 Aug 2026 18:21:52 +0800 Subject: [PATCH 4/5] Fix Keep S3 runtime behavior --- .../core/bluetooth/scales/KeepS3Handler.kt | 96 ++++++++++++++---- .../bluetooth/scales/KeepS3HandlerTest.kt | 98 +++++++++++++++++-- 2 files changed, 167 insertions(+), 27 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt index b0c3bf213..a647218b7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -89,6 +89,12 @@ internal object KeepS3Protocol { 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 @@ -226,6 +232,35 @@ internal object KeepS3Protocol { 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) @@ -600,12 +635,13 @@ class KeepS3Handler : ScaleDeviceHandler() { lbm = composition.fatFreeMassKg bmr = composition.basalMetabolicRateKcal.toFloat() protein = composition.proteinPercent - // The vendor's "muscle" is fat-free mass minus bone. That is the same formula - // BodyMiScaleLib.getMuscleMass() uses, and MiScaleHandler publishes it as a - // percentage of body weight in MUSCLE, so this matches openScale's convention. - muscle = composition.musclePercent + // 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. - // skeletalMuscle = composition.skeletalMusclePercent // subcutaneousFat = composition.subcutaneousFatPercent // bodyAge = composition.bodyAge // bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg @@ -613,6 +649,7 @@ class KeepS3Handler : ScaleDeviceHandler() { } } publish(measurement) + rememberDeviceImpedance(user.id, measurement, deviceImpedanceOhm) published = true } @@ -626,9 +663,10 @@ class KeepS3Handler : ScaleDeviceHandler() { writeTo(service, writeCharacteristic, stopRequest, withResponse = true) finishJob = scope.launch { - // A Keep S3 session can leave several 0x57 ACKs, the 0x58 ACK and both stop - // commands queued. The delay gives them time to drain before disconnecting; - // the stop command is sent twice so a dropped one is not fatal. + // 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() } @@ -644,23 +682,43 @@ class KeepS3Handler : ScaleDeviceHandler() { 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 = previousDeviceImpedance(previous), + impedanceOhm = impedanceOhm, ) } - /** - * The vendor/protocol impedance is not stored by openScale, so the previous record reuses - * the high-frequency band saved with the last measurement. A Keep S3 also accepts an - * all-zero previous record, so a missing value is not fatal. - */ - private fun previousDeviceImpedance(previous: ScaleMeasurement): Double { - if (previous.impedance.isFinite() && previous.impedance > 0.0) { - return previous.impedance + 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) } - return 0.0 } private fun buildProfilePayload(user: ScaleUser): ByteArray { @@ -724,7 +782,7 @@ class KeepS3Handler : ScaleDeviceHandler() { companion object { private const val DEVICE_NAME = "Keep_S3" private const val FINAL_RECORD_WAIT_MS = 2_000L - private const val DISCONNECT_DELAY_MS = 800L + private const val DISCONNECT_DELAY_MS = 6_000L private val DEVICE_SUPPORT = DeviceSupport( displayName = "Keep S3", diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt index 6069bdbbc..15802a249 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt @@ -197,13 +197,23 @@ class KeepS3HandlerTest { 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).isEqualTo(66.9f) + 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) @@ -213,7 +223,11 @@ class KeepS3HandlerTest { runCurrent() assertThat(setup.transport.disconnectCount).isEqualTo(0) - advanceTimeBy(800) + advanceTimeBy(5_999) + runCurrent() + assertThat(setup.transport.disconnectCount).isEqualTo(0) + + advanceTimeBy(1) runCurrent() assertThat(setup.transport.disconnectCount).isEqualTo(1) } @@ -246,7 +260,8 @@ class KeepS3HandlerTest { 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).isEqualTo(67.1f) + 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) @@ -339,7 +354,7 @@ class KeepS3HandlerTest { val actual = setup.callbacks.published.single() assertThat(actual.fat).isEqualTo(expected.bodyFatPercent) assertThat(actual.water).isEqualTo(expected.waterPercent) - assertThat(actual.muscle).isEqualTo(expected.musclePercent) + assertThat(actual.muscle).isEqualTo(expected.skeletalMusclePercent) assertThat(actual.bone).isEqualTo(expected.boneKg) assertThat(actual.lbm).isEqualTo(expected.fatFreeMassKg) } @@ -433,6 +448,26 @@ class KeepS3HandlerTest { 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() @@ -509,19 +544,66 @@ class KeepS3HandlerTest { } @Test - fun `profile carries the impedance of the previous Keep S3 record`() { + 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 = 301.0, + impedance = 478.0, + impedanceLow = 506.0, ) - val setup = attachedHandler(previous = previous) + 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(KeepS3Protocol.decodeU16BE(profileRequest, 5 + 54)).isEqualTo(301) + assertThat(profileRequest.copyOfRange(5 + 48, 5 + 58)).isEqualTo(ByteArray(10)) } @Test From 0f18baf15f3e1ad2f46e9df96118753d49b6a8fc Mon Sep 17 00:00:00 2001 From: Leko Date: Sun, 2 Aug 2026 10:13:45 +0800 Subject: [PATCH 5/5] Complete Traditional Chinese translation --- .../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」