diff --git a/README.md b/README.md index 2409117f0..c7db9603f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Monitor and track your weight, BMI, body fat, body water, muscle and other body openScale has built-in support for a number of Bluetooth (BLE or "smart") scales from many manufacturers, e.g. Beurer, Sanitas, Yunmai, Xiaomi, etc. (see model list below). Together with our users we constantly improve and extend the set of supported scales and in many cases pick up where the original app falls short. - Custom made Bluetooth scale -- Beurer BF105, BF500, BF600, BF700, BF710, BF720, BF800, BF850 and BF950 +- Beurer BF105, BF500, BF600, BF700, BF710, BF720, BF800, BF850, BF950 and BF1000 - Digoo DG-S038H - Excelvan CF369BLE - Exingtech Y1 diff --git a/android_app/app/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..92f3faa34 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 @@ -76,6 +76,16 @@ fun getDefaultMeasurementTypes(): List { MeasurementType(key = MeasurementTypeKey.ICW, unit = UnitType.PERCENT, color = 0xFF0288D1.toInt(), icon = MeasurementTypeIcon.IC_M_BUBBLE_CHART, isEnabled = true), MeasurementType(key = MeasurementTypeKey.PROTEIN, unit = UnitType.PERCENT, color = 0xFF9CCC65.toInt(), icon = MeasurementTypeIcon.IC_M_PROTEIN, isEnabled = true), MeasurementType(key = MeasurementTypeKey.BCM, unit = UnitType.KG, color = 0xFF558B2F.toInt(), icon = MeasurementTypeIcon.IC_M_HIVE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BODY_FAT_LEFT_ARM, unit = UnitType.PERCENT, color = 0xFFE57373.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BODY_FAT_RIGHT_ARM, unit = UnitType.PERCENT, color = 0xFFEF5350.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BODY_FAT_TORSO, unit = UnitType.PERCENT, color = 0xFFD32F2F.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BODY_FAT_LEFT_LEG, unit = UnitType.PERCENT, color = 0xFFEF9A9A.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.BODY_FAT_RIGHT_LEG, unit = UnitType.PERCENT, color = 0xFFE53935.toInt(), icon = MeasurementTypeIcon.IC_BODY_FAT, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.MUSCLE_LEFT_ARM, unit = UnitType.PERCENT, color = 0xFF81C784.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.MUSCLE_RIGHT_ARM, unit = UnitType.PERCENT, color = 0xFF66BB6A.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.MUSCLE_TORSO, unit = UnitType.PERCENT, color = 0xFF43A047.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.MUSCLE_LEFT_LEG, unit = UnitType.PERCENT, color = 0xFFA5D6A7.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), + MeasurementType(key = MeasurementTypeKey.MUSCLE_RIGHT_LEG, unit = UnitType.PERCENT, color = 0xFF2E7D32.toInt(), icon = MeasurementTypeIcon.IC_MUSCLE, isEnabled = true), MeasurementType(key = MeasurementTypeKey.CALORIES, unit = UnitType.KCAL, color = 0xFF4CAF50.toInt(), icon = MeasurementTypeIcon.IC_CALORIES, isEnabled = true), MeasurementType(key = MeasurementTypeKey.COMMENT, inputType = InputFieldType.TEXT, unit = UnitType.NONE, color = 0xFFE0E0E0.toInt(), icon = MeasurementTypeIcon.IC_COMMENT, isPinned = true, isEnabled = true), MeasurementType(key = MeasurementTypeKey.DATE, inputType = InputFieldType.DATE, unit = UnitType.NONE, color = 0xFF9E9E9E.toInt(), icon = MeasurementTypeIcon.IC_DATE, isEnabled = true), @@ -145,4 +155,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 cc5d96275..a1938985d 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 @@ -41,6 +41,16 @@ data class ScaleMeasurement( 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 fatLeftArm: Float = 0.0f, // must be in percentage + var fatRightArm: Float = 0.0f, // must be in percentage + var fatTorso: Float = 0.0f, // must be in percentage + var fatLeftLeg: Float = 0.0f, // must be in percentage + var fatRightLeg: Float = 0.0f, // must be in percentage + var muscleLeftArm: Float = 0.0f, // must be in percentage + var muscleRightArm: Float = 0.0f, // must be in percentage + var muscleTorso: Float = 0.0f, // must be in percentage + var muscleLeftLeg: Float = 0.0f, // must be in percentage + var muscleRightLeg: Float = 0.0f, // must be in percentage ) { // --- Utility methods --- @@ -53,6 +63,16 @@ data class ScaleMeasurement( if (other.water > 0f && this.water <= 0f) this.water = other.water if (other.muscle > 0f && this.muscle <= 0f) this.muscle = other.muscle if (other.visceralFat > 0f && this.visceralFat <= 0f) this.visceralFat = other.visceralFat + if (other.fatLeftArm > 0f && this.fatLeftArm <= 0f) this.fatLeftArm = other.fatLeftArm + if (other.fatRightArm > 0f && this.fatRightArm <= 0f) this.fatRightArm = other.fatRightArm + if (other.fatTorso > 0f && this.fatTorso <= 0f) this.fatTorso = other.fatTorso + if (other.fatLeftLeg > 0f && this.fatLeftLeg <= 0f) this.fatLeftLeg = other.fatLeftLeg + if (other.fatRightLeg > 0f && this.fatRightLeg <= 0f) this.fatRightLeg = other.fatRightLeg + if (other.muscleLeftArm > 0f && this.muscleLeftArm <= 0f) this.muscleLeftArm = other.muscleLeftArm + if (other.muscleRightArm > 0f && this.muscleRightArm <= 0f) this.muscleRightArm = other.muscleRightArm + if (other.muscleTorso > 0f && this.muscleTorso <= 0f) this.muscleTorso = other.muscleTorso + if (other.muscleLeftLeg > 0f && this.muscleLeftLeg <= 0f) this.muscleLeftLeg = other.muscleLeftLeg + if (other.muscleRightLeg > 0f && this.muscleRightLeg <= 0f) this.muscleRightLeg = other.muscleRightLeg if (other.bone > 0f && this.bone <= 0f) this.bone = other.bone if (other.lbm > 0f && this.lbm <= 0f) this.lbm = other.lbm if (other.bmr > 0f && this.bmr <= 0f) this.bmr = other.bmr diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000Lib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000Lib.kt new file mode 100644 index 000000000..0d2b30b3a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000Lib.kt @@ -0,0 +1,301 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import java.util.Calendar +import java.util.Date + +/** + * Parser for the Beurer BF1000 Super Precision measurement packets. + * + * The scale uses the standard Weight Scale (2A9D) and Body Composition (2A9C) + * characteristics, then adds two Beurer-private segmental packets on service + * FFFF: characteristic 0009 for visceral/segmental fat and 000A for segmental + * muscle. These functions intentionally stay free of Android dependencies so + * captured packets can be asserted in JVM tests. + */ +object BeurerBf1000Lib { + private const val KG_STEP = 0.005f + private const val LB_STEP = 0.01f + private const val LB_TO_KG = 0.45359237f + + data class WeightMeasurement( + val weightKg: Float, + val dateTime: Date?, + val scaleUserIndex: Int?, + val bmi: Float?, + val heightMeters: Float?, + val isKg: Boolean + ) + + data class BodyCompositionMeasurement( + val bodyFatPercent: Float, + val dateTime: Date?, + val scaleUserIndex: Int?, + val bmrKcal: Float?, + val musclePercent: Float?, + val softLeanMassKg: Float?, + val waterMassKg: Float?, + val impedanceOhm: Double?, + val weightKg: Float?, + val leanBodyMassKg: Float?, + val boneMassKg: Float?, + val isKg: Boolean, + val isMultiPacket: Boolean + ) + + data class SegmentalFatMeasurement( + val visceralFat: Float, + val leftArm: Float, + val rightArm: Float, + val torso: Float, + val leftLeg: Float, + val rightLeg: Float + ) + + data class SegmentalMuscleMeasurement( + val leftArm: Float, + val rightArm: Float, + val torso: Float, + val leftLeg: Float, + val rightLeg: Float + ) + + fun parseWeightMeasurement(value: ByteArray): WeightMeasurement? { + if (!hasBytes(value, 0, 3)) return null + var offset = 0 + + val flags = u8(value, offset); offset += 1 + val isKg = (flags and 0x01) == 0 + val tsPresent = (flags and 0x02) != 0 + val userPresent = (flags and 0x04) != 0 + val bmiHeightPresent = (flags and 0x08) != 0 + + if (!hasBytes(value, offset, 2)) return null + val weightKg = massKg(u16le(value, offset), isKg); offset += 2 + + val dateTime = if (tsPresent) { + val parsed = parseDateTime(value, offset) ?: return null + offset += 7 + parsed + } else { + null + } + + val scaleUserIndex = if (userPresent) { + if (!hasBytes(value, offset, 1)) return null + u8(value, offset).also { offset += 1 } + } else { + null + } + + var bmi: Float? = null + var heightMeters: Float? = null + if (bmiHeightPresent) { + if (!hasBytes(value, offset, 4)) return null + bmi = u16le(value, offset) * 0.1f; offset += 2 + heightMeters = u16le(value, offset) * 0.001f + } + + return WeightMeasurement( + weightKg = weightKg, + dateTime = dateTime, + scaleUserIndex = scaleUserIndex, + bmi = bmi, + heightMeters = heightMeters, + isKg = isKg + ) + } + + fun parseBodyCompositionMeasurement( + value: ByteArray, + fallbackWeightKg: Float? = null + ): BodyCompositionMeasurement? { + if (!hasBytes(value, 0, 4)) return null + var offset = 0 + + val flags = u16le(value, offset); offset += 2 + val isKg = (flags and 0x0001) == 0 + val tsPresent = (flags and 0x0002) != 0 + val userPresent = (flags and 0x0004) != 0 + val bmrPresent = (flags and 0x0008) != 0 + val musclePctPresent = (flags and 0x0010) != 0 + val muscleMassPresent = (flags and 0x0020) != 0 + val fatFreeMassPresent = (flags and 0x0040) != 0 + val softLeanPresent = (flags and 0x0080) != 0 + val waterMassPresent = (flags and 0x0100) != 0 + val impedancePresent = (flags and 0x0200) != 0 + val weightPresent = (flags and 0x0400) != 0 + val heightPresent = (flags and 0x0800) != 0 + val multiPacket = (flags and 0x1000) != 0 + + if (!hasBytes(value, offset, 2)) return null + val bodyFatPercent = u16le(value, offset) * 0.1f; offset += 2 + + val dateTime = if (tsPresent) { + val parsed = parseDateTime(value, offset) ?: return null + offset += 7 + parsed + } else { + null + } + + val scaleUserIndex = if (userPresent) { + if (!hasBytes(value, offset, 1)) return null + u8(value, offset).also { offset += 1 } + } else { + null + } + + val bmrKcal = if (bmrPresent) { + if (!hasBytes(value, offset, 2)) return null + val bmrKilojoules = u16le(value, offset); offset += 2 + ((bmrKilojoules / 4.1868f) * 10f).toInt() / 10f + } else { + null + } + + val musclePercent = if (musclePctPresent) { + if (!hasBytes(value, offset, 2)) return null + (u16le(value, offset) * 0.1f).also { offset += 2 } + } else { + null + } + + if (muscleMassPresent) { + if (!hasBytes(value, offset, 2)) return null + offset += 2 + } + + if (fatFreeMassPresent) { + if (!hasBytes(value, offset, 2)) return null + offset += 2 + } + + val softLeanMassKg = if (softLeanPresent) { + if (!hasBytes(value, offset, 2)) return null + massKg(u16le(value, offset), isKg).also { offset += 2 } + } else { + null + } + + val waterMassKg = if (waterMassPresent) { + if (!hasBytes(value, offset, 2)) return null + massKg(u16le(value, offset), isKg).also { offset += 2 } + } else { + null + } + + val impedanceOhm = if (impedancePresent) { + if (!hasBytes(value, offset, 2)) return null + (u16le(value, offset) * 0.1f).toDouble().also { offset += 2 } + } else { + null + } + + val weightKg = if (weightPresent) { + if (!hasBytes(value, offset, 2)) return null + massKg(u16le(value, offset), isKg).also { offset += 2 } + } else { + fallbackWeightKg?.takeIf { it > 0f } + } + + if (heightPresent) { + if (!hasBytes(value, offset, 2)) return null + offset += 2 + } + + val leanBodyMassKg = weightKg?.takeIf { it > 0f }?.let { + it * (1f - bodyFatPercent / 100f) + } + val boneMassKg = leanBodyMassKg + ?.let { leanBodyMass -> softLeanMassKg?.let { softLean -> leanBodyMass - softLean } } + ?.takeIf { it > 0f } + + return BodyCompositionMeasurement( + bodyFatPercent = bodyFatPercent, + dateTime = dateTime, + scaleUserIndex = scaleUserIndex, + bmrKcal = bmrKcal?.takeIf { it > 0f }, + musclePercent = musclePercent, + softLeanMassKg = softLeanMassKg, + waterMassKg = waterMassKg, + impedanceOhm = impedanceOhm?.takeIf { it > 0.0 }, + weightKg = weightKg, + leanBodyMassKg = leanBodyMassKg, + boneMassKg = boneMassKg, + isKg = isKg, + isMultiPacket = multiPacket + ) + } + + fun parseSegmentalFatMeasurement(value: ByteArray): SegmentalFatMeasurement? { + if (!hasBytes(value, 0, 12) || u8(value, 0) != 0x7E) return null + val fat = readTenths(value, offset = 2, count = 5) + return SegmentalFatMeasurement( + visceralFat = u8(value, 1) * 0.1f, + leftArm = fat[0], + rightArm = fat[1], + torso = fat[2], + leftLeg = fat[3], + rightLeg = fat[4] + ) + } + + fun parseSegmentalMuscleMeasurement(value: ByteArray): SegmentalMuscleMeasurement? { + if (!hasBytes(value, 0, 11) || u8(value, 0) != 0x3E) return null + val muscle = readTenths(value, offset = 1, count = 5) + return SegmentalMuscleMeasurement( + leftArm = muscle[0], + rightArm = muscle[1], + torso = muscle[2], + leftLeg = muscle[3], + rightLeg = muscle[4] + ) + } + + private fun massKg(raw: Int, isKg: Boolean): Float = + if (isKg) raw * KG_STEP else raw * LB_STEP * LB_TO_KG + + private fun parseDateTime(value: ByteArray, offset: Int): Date? { + if (!hasBytes(value, offset, 7)) return null + val year = u16le(value, offset) + val month = u8(value, offset + 2) + val day = u8(value, offset + 3) + val hour = u8(value, offset + 4) + val minute = u8(value, offset + 5) + val second = u8(value, offset + 6) + + return Calendar.getInstance().apply { + set(year, (month - 1).coerceAtLeast(0), day, hour, minute, second) + set(Calendar.MILLISECOND, 0) + }.time + } + + private fun readTenths(data: ByteArray, offset: Int, count: Int): List = + List(count) { index -> u16le(data, offset + index * 2) * 0.1f } + + private fun hasBytes(data: ByteArray, offset: Int, count: Int): Boolean = + offset >= 0 && count >= 0 && offset + count <= data.size + + private fun u8(data: ByteArray, offset: Int): Int = + data[offset].toInt() and 0xFF + + private fun u16le(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt index 91dd57d73..6fab32956 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 @@ -530,6 +530,16 @@ abstract class ModernScaleAdapter( valueOf(MeasurementTypeKey.WATER)?.let { m.water = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.MUSCLE)?.let { m.muscle = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.VISCERAL_FAT)?.let { m.visceralFat = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.BODY_FAT_LEFT_ARM)?.let { m.fatLeftArm = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.BODY_FAT_RIGHT_ARM)?.let { m.fatRightArm = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.BODY_FAT_TORSO)?.let { m.fatTorso = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.BODY_FAT_LEFT_LEG)?.let { m.fatLeftLeg = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.BODY_FAT_RIGHT_LEG)?.let { m.fatRightLeg = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.MUSCLE_LEFT_ARM)?.let { m.muscleLeftArm = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.MUSCLE_RIGHT_ARM)?.let { m.muscleRightArm = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.MUSCLE_TORSO)?.let { m.muscleTorso = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.MUSCLE_LEFT_LEG)?.let { m.muscleLeftLeg = it.floatValue ?: 0f } + valueOf(MeasurementTypeKey.MUSCLE_RIGHT_LEG)?.let { m.muscleRightLeg = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.LBM)?.let { m.lbm = it.floatValue ?: 0f } valueOf(MeasurementTypeKey.BONE)?.let { m.bone = it.floatValue ?: 0f } @@ -549,4 +559,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/StandardBeurerSanitasHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt index f02a57d67..cc722c7cf 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt @@ -17,7 +17,9 @@ */ package com.health.openscale.core.bluetooth.scales +import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.BeurerBf1000Lib import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType import com.health.openscale.core.service.ScannedDeviceInfo @@ -32,7 +34,7 @@ import java.util.UUID */ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { - private enum class Model { BEURER_BF105, BEURER_BF950, BEURER_BF500, BEURER_BF600 } + private enum class Model { BEURER_BF105, BEURER_BF1000, BEURER_BF950, BEURER_BF500, BEURER_BF600 } private val scaleUserList = mutableListOf() private data class Profile( @@ -47,9 +49,22 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { private var activeModel: Model? = null private var friendlyName: String? = null private var profile: Profile? = null + private var pendingBf1000Measurement: ScaleMeasurement? = null + + private val bf1000WeightMeasurement = uuid16(0x2A9D) + private val bf1000BodyCompositionMeasurement = uuid16(0x2A9C) + private val bf1000MeasurementStatus = uuid16(0x0006) + private val bf1000SegmentalFatMeasurement = uuid16(0x0009) + private val bf1000SegmentalMuscleMeasurement = uuid16(0x000A) + private val bf1000CustomMeasurementChars = listOf( + bf1000MeasurementStatus, + bf1000SegmentalFatMeasurement, + bf1000SegmentalMuscleMeasurement + ) private fun pFor(m: Model) = when (m) { - Model.BEURER_BF105 -> Profile( + Model.BEURER_BF105, + Model.BEURER_BF1000 -> Profile( service = uuid16(0xFFFF), chrUserList = uuid16(0x0001), chrActivity = uuid16(0x0004), @@ -85,6 +100,7 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { private fun nameFor(m: Model) = when (m) { Model.BEURER_BF105 -> "Beurer BF105/720" + Model.BEURER_BF1000 -> "Beurer BF1000" Model.BEURER_BF950 -> "Beurer BF950" Model.BEURER_BF500 -> "Beurer BF500" Model.BEURER_BF600 -> "Beurer BF600" @@ -98,6 +114,7 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { val model = when { "bf105" in name || "bf720" in name -> Model.BEURER_BF105 + "bf1000" in name -> Model.BEURER_BF1000 "bf950" in name || "sbf77" in name || "sbf76" in name -> Model.BEURER_BF950 "bf500" in name -> Model.BEURER_BF500 "bf600" in name || "bf850" in name -> Model.BEURER_BF600 @@ -145,6 +162,10 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { } } } + + if (activeModel == Model.BEURER_BF1000) { + enableBf1000CustomMeasurements() + } } override fun writeUserDataToScale() { @@ -176,15 +197,32 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { return } - when (characteristic) { - p.chrUserList -> { + when { + activeModel == Model.BEURER_BF1000 && characteristic == bf1000WeightMeasurement -> { + handleBf1000WeightMeasurement(data) + } + activeModel == Model.BEURER_BF1000 && characteristic == bf1000BodyCompositionMeasurement -> { + handleBf1000BodyCompositionMeasurement(data) + } + characteristic == p.chrUserList -> { handleUserList(data, user) } + activeModel == Model.BEURER_BF1000 && isBf1000CustomMeasurementCharacteristic(characteristic) -> { + handleBf1000CustomMeasurementData(characteristic, data) + } else -> super.onNotification(characteristic, data, user) } } + override fun onDisconnected() { + if (activeModel == Model.BEURER_BF1000) { + publishPendingBf1000Measurement("disconnect") + } + + super.onDisconnected() + } + override fun onRequestMeasurement() { profile?.let { logD("Requesting measurement: writing 0x00 to chrTakeMeasurement=${it.chrTakeMeasurement}") @@ -194,6 +232,180 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { // ---- Vendor write helpers ------------------------------------------------- + private fun enableBf1000CustomMeasurements() { + val p = profile ?: return + + logD("Enabling BF1000 custom measurement characteristics") + bf1000CustomMeasurementChars.forEach { chr -> + setNotifyOn(p.service, chr) + } + } + + private fun handleBf1000WeightMeasurement(data: ByteArray) { + logD("BF1000 standard weight len=${data.size} ${data.toHexPreview(64)}") + val decoded = BeurerBf1000Lib.parseWeightMeasurement(data) + if (decoded == null) { + logW("BF1000 standard weight packet could not be decoded") + return + } + + val measurement = ScaleMeasurement().apply { + weight = decoded.weightKg + dateTime = decoded.dateTime + } + + decoded.scaleUserIndex?.let { scaleUserIndex -> + val appId = loadUserIdForScaleIndex(scaleUserIndex) + if (appId != -1) measurement.userId = appId + logD( + "BF1000 weight: idx=$scaleUserIndex mappedAppId=$appId " + + "kg=${decoded.isKg} value=${measurement.weight}" + ) + } + decoded.bmi?.let { + logD("BF1000 BMI=$it height(m)=${decoded.heightMeters}") + } + + mergePendingBf1000Measurement(measurement, "standard weight") + } + + private fun handleBf1000BodyCompositionMeasurement(data: ByteArray) { + logD("BF1000 standard body composition len=${data.size} ${data.toHexPreview(64)}") + val decoded = BeurerBf1000Lib.parseBodyCompositionMeasurement( + data, + fallbackWeightKg = pendingBf1000Measurement?.weight?.takeIf { it > 0f } + ) + if (decoded == null) { + logW("BF1000 standard body composition packet could not be decoded") + return + } + + val measurement = ScaleMeasurement().apply { + fat = decoded.bodyFatPercent + dateTime = decoded.dateTime + decoded.bmrKcal?.let { bmr = it } + decoded.musclePercent?.let { muscle = it } + decoded.waterMassKg?.let { water = it } + decoded.impedanceOhm?.let { impedance = it } + decoded.weightKg?.let { weight = it } + decoded.leanBodyMassKg?.let { lbm = it } + decoded.boneMassKg?.let { bone = it } + } + + decoded.scaleUserIndex?.let { scaleUserIndex -> + val appId = loadUserIdForScaleIndex(scaleUserIndex) + if (appId != -1) measurement.userId = appId + logD("BF1000 body composition: idx=$scaleUserIndex mappedAppId=$appId fat=${measurement.fat}%") + } + decoded.bmrKcal?.let { logD("BF1000 BMR ~= $it kcal") } + decoded.softLeanMassKg?.let { logD("BF1000 soft lean mass=$it kg") } + decoded.impedanceOhm?.let { logD("BF1000 impedance=$it ohm") } + decoded.boneMassKg?.let { logD("BF1000 bone mass=$it kg") } + if (decoded.isMultiPacket) { + logW("BF1000 body composition: multi-packet measurement not supported") + } + + mergePendingBf1000Measurement(measurement, "standard body composition") + } + + private fun handleBf1000CustomMeasurementData(characteristic: UUID, data: ByteArray) { + logD("BF1000 custom chr=${characteristic.shortId()} len=${data.size} ${data.toHexPreview(64)}") + + when { + characteristic == bf1000SegmentalFatMeasurement -> { + val decoded = BeurerBf1000Lib.parseSegmentalFatMeasurement(data) + if (decoded == null) { + logW("BF1000 segmental fat packet could not be decoded") + return + } + val measurement = ScaleMeasurement().apply { + visceralFat = decoded.visceralFat + fatLeftArm = decoded.leftArm + fatRightArm = decoded.rightArm + fatTorso = decoded.torso + fatLeftLeg = decoded.leftLeg + fatRightLeg = decoded.rightLeg + } + + logD( + "BF1000 segmental fat: visceral=${measurement.visceralFat} " + + "leftArm=${measurement.fatLeftArm}% rightArm=${measurement.fatRightArm}% " + + "torso=${measurement.fatTorso}% leftLeg=${measurement.fatLeftLeg}% " + + "rightLeg=${measurement.fatRightLeg}%" + ) + mergePendingBf1000Measurement(measurement, "segmental fat") + } + characteristic == bf1000SegmentalMuscleMeasurement -> { + val decoded = BeurerBf1000Lib.parseSegmentalMuscleMeasurement(data) + if (decoded == null) { + logW("BF1000 segmental muscle packet could not be decoded") + return + } + val measurement = ScaleMeasurement().apply { + muscleLeftArm = decoded.leftArm + muscleRightArm = decoded.rightArm + muscleTorso = decoded.torso + muscleLeftLeg = decoded.leftLeg + muscleRightLeg = decoded.rightLeg + } + + logD( + "BF1000 segmental muscle: leftArm=${measurement.muscleLeftArm}% " + + "rightArm=${measurement.muscleRightArm}% torso=${measurement.muscleTorso}% " + + "leftLeg=${measurement.muscleLeftLeg}% rightLeg=${measurement.muscleRightLeg}%" + ) + mergePendingBf1000Measurement(measurement, "segmental muscle") + } + } + + if (characteristic == bf1000MeasurementStatus && + data.firstOrNull()?.toInt()?.and(0xFF) == 0x01) { + logD("BF1000 measurement-complete status received") + publishPendingBf1000Measurement("measurement complete") + } + } + + private fun isBf1000CustomMeasurementCharacteristic(characteristic: UUID): Boolean = + characteristic in bf1000CustomMeasurementChars + + private fun mergePendingBf1000Measurement(measurement: ScaleMeasurement, source: String) { + val current = pendingBf1000Measurement + if (current == null) { + pendingBf1000Measurement = measurement + } else if (canMergeBf1000(current, measurement)) { + current.mergeWith(measurement) + } else { + publishPendingBf1000Measurement("new BF1000 user packet before $source") + pendingBf1000Measurement = measurement + } + + pendingBf1000Measurement?.let { + if (it.weight > 0f && it.fat > 0f && it.lbm <= 0f) { + it.lbm = it.weight * (1f - it.fat / 100f) + } + } + + logD("BF1000 pending after $source: $pendingBf1000Measurement") + } + + private fun canMergeBf1000(left: ScaleMeasurement, right: ScaleMeasurement): Boolean = + left.userId == 0xFF || + right.userId == 0xFF || + left.userId == right.userId + + private fun publishPendingBf1000Measurement(reason: String) { + val measurement = pendingBf1000Measurement ?: return + if (!measurement.hasWeight()) { + logW("Dropping BF1000 pending measurement on $reason because it has no weight: $measurement") + pendingBf1000Measurement = null + return + } + + pendingBf1000Measurement = null + logD("Publishing BF1000 measurement on $reason: $measurement") + publish(transformBeforePublish(measurement)) + } + private fun handleUserList(data: ByteArray, user : ScaleUser) { val parser = BluetoothBytesParser(data) @@ -294,4 +506,7 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { } } } + + private fun UUID.shortId(): String = + String.format("0x%04x", (mostSignificantBits shr 32) and 0xFFFF) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt b/android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt index f081f5fb1..f2d2af92e 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,16 @@ 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)), + BODY_FAT_LEFT_ARM(35, R.string.measurement_type_body_fat_left_arm, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + BODY_FAT_RIGHT_ARM(36, R.string.measurement_type_body_fat_right_arm, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + BODY_FAT_TORSO(37, R.string.measurement_type_body_fat_torso, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + BODY_FAT_LEFT_LEG(38, R.string.measurement_type_body_fat_left_leg, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + BODY_FAT_RIGHT_LEG(39, R.string.measurement_type_body_fat_right_leg, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + MUSCLE_LEFT_ARM(40, R.string.measurement_type_muscle_left_arm, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + MUSCLE_RIGHT_ARM(41, R.string.measurement_type_muscle_right_arm, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + MUSCLE_TORSO(42, R.string.measurement_type_muscle_torso, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + MUSCLE_LEFT_LEG(43, R.string.measurement_type_muscle_left_leg, listOf(UnitType.PERCENT), listOf(InputFieldType.FLOAT)), + MUSCLE_RIGHT_LEG(44, R.string.measurement_type_muscle_right_leg, listOf(UnitType.PERCENT), 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)); } diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt b/android_app/app/src/main/java/com/health/openscale/core/database/AppDatabase.kt index 304abe315..c2134832a 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) @@ -622,3 +622,67 @@ val MIGRATION_14_15 = object : Migration(14, 15) { } } } + +val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + // Seed segmental BF1000 composition measurements. There is no schema + // change in this migration; existing installs just need the new + // MeasurementType rows that fresh installs receive from + // getDefaultMeasurementTypes(). + val newKeys = setOf( + MeasurementTypeKey.BODY_FAT_LEFT_ARM, + MeasurementTypeKey.BODY_FAT_RIGHT_ARM, + MeasurementTypeKey.BODY_FAT_TORSO, + MeasurementTypeKey.BODY_FAT_LEFT_LEG, + MeasurementTypeKey.BODY_FAT_RIGHT_LEG, + MeasurementTypeKey.MUSCLE_LEFT_ARM, + MeasurementTypeKey.MUSCLE_RIGHT_ARM, + MeasurementTypeKey.MUSCLE_TORSO, + MeasurementTypeKey.MUSCLE_LEFT_LEG, + MeasurementTypeKey.MUSCLE_RIGHT_LEG, + ) + val newTypes = getDefaultMeasurementTypes().filter { it.key in newKeys } + newTypes.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 + ) + ) + } + + val defaultTypesInOrder = getDefaultMeasurementTypes() + db.beginTransaction() + try { + defaultTypesInOrder.forEachIndexed { index, measurementType -> + db.execSQL( + "UPDATE MeasurementType SET displayOrder = ? WHERE `key` = ?", + arrayOf(index + 1, measurementType.key.name) + ) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } +} 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..be208811d 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 @@ -421,7 +421,7 @@ class BleConnector( * * ### Raw units assumed for ScaleMeasurement * - WEIGHT, BONE, LBM → **KG** - * - BODY_FAT, WATER, MUSCLE, VISCERAL_FAT → **PERCENT** + * - BODY_FAT, WATER, MUSCLE, VISCERAL_FAT and segmental fat/muscle → **PERCENT** * * Other fields in `ScaleMeasurement` (if added later) should be appended here with the correct raw unit. * @@ -478,6 +478,16 @@ class BleConnector( MeasurementTypeKey.WATER to UnitType.PERCENT, MeasurementTypeKey.MUSCLE to UnitType.PERCENT, MeasurementTypeKey.VISCERAL_FAT to UnitType.PERCENT, + MeasurementTypeKey.BODY_FAT_LEFT_ARM to UnitType.PERCENT, + MeasurementTypeKey.BODY_FAT_RIGHT_ARM to UnitType.PERCENT, + MeasurementTypeKey.BODY_FAT_TORSO to UnitType.PERCENT, + MeasurementTypeKey.BODY_FAT_LEFT_LEG to UnitType.PERCENT, + MeasurementTypeKey.BODY_FAT_RIGHT_LEG to UnitType.PERCENT, + MeasurementTypeKey.MUSCLE_LEFT_ARM to UnitType.PERCENT, + MeasurementTypeKey.MUSCLE_RIGHT_ARM to UnitType.PERCENT, + MeasurementTypeKey.MUSCLE_TORSO to UnitType.PERCENT, + MeasurementTypeKey.MUSCLE_LEFT_LEG to UnitType.PERCENT, + MeasurementTypeKey.MUSCLE_RIGHT_LEG to UnitType.PERCENT, MeasurementTypeKey.BONE to UnitType.KG, MeasurementTypeKey.LBM to UnitType.KG, MeasurementTypeKey.HEART_RATE to UnitType.BPM, @@ -557,6 +567,16 @@ class BleConnector( addConvertedIfValid(measurementData.water, MeasurementTypeKey.WATER) addConvertedIfValid(measurementData.muscle, MeasurementTypeKey.MUSCLE) addConvertedIfValid(measurementData.visceralFat, MeasurementTypeKey.VISCERAL_FAT) + addConvertedIfValid(measurementData.fatLeftArm, MeasurementTypeKey.BODY_FAT_LEFT_ARM) + addConvertedIfValid(measurementData.fatRightArm, MeasurementTypeKey.BODY_FAT_RIGHT_ARM) + addConvertedIfValid(measurementData.fatTorso, MeasurementTypeKey.BODY_FAT_TORSO) + addConvertedIfValid(measurementData.fatLeftLeg, MeasurementTypeKey.BODY_FAT_LEFT_LEG) + addConvertedIfValid(measurementData.fatRightLeg, MeasurementTypeKey.BODY_FAT_RIGHT_LEG) + addConvertedIfValid(measurementData.muscleLeftArm, MeasurementTypeKey.MUSCLE_LEFT_ARM) + addConvertedIfValid(measurementData.muscleRightArm, MeasurementTypeKey.MUSCLE_RIGHT_ARM) + addConvertedIfValid(measurementData.muscleTorso, MeasurementTypeKey.MUSCLE_TORSO) + addConvertedIfValid(measurementData.muscleLeftLeg, MeasurementTypeKey.MUSCLE_LEFT_LEG) + addConvertedIfValid(measurementData.muscleRightLeg, MeasurementTypeKey.MUSCLE_RIGHT_LEG) addConvertedIfValid(measurementData.bone, MeasurementTypeKey.BONE) addConvertedIfValid(measurementData.lbm, MeasurementTypeKey.LBM) addConvertedIfValid(measurementData.heartRate, MeasurementTypeKey.HEART_RATE) @@ -694,4 +714,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/MeasurementDemoUseCase.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementDemoUseCase.kt index 5300fee85..5e1de4a17 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementDemoUseCase.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementDemoUseCase.kt @@ -395,6 +395,16 @@ class MeasurementDemoUseCase @Inject constructor( MeasurementTypeKey.WATER -> (100f - f - noise(1f, 4f)).coerceIn(40f, 75f) MeasurementTypeKey.BONE -> (w * 0.04f + noise(-0.2f, 0.2f)).coerceIn(1f, 10f) MeasurementTypeKey.VISCERAL_FAT -> (f / 3f + noise(-1f, 1f)).coerceIn(1f, 20f) + MeasurementTypeKey.BODY_FAT_LEFT_ARM, + MeasurementTypeKey.BODY_FAT_RIGHT_ARM, + MeasurementTypeKey.BODY_FAT_TORSO, + MeasurementTypeKey.BODY_FAT_LEFT_LEG, + MeasurementTypeKey.BODY_FAT_RIGHT_LEG -> (f + noise(-1.5f, 1.5f)).coerceIn(3f, 70f) + MeasurementTypeKey.MUSCLE_LEFT_ARM, + MeasurementTypeKey.MUSCLE_RIGHT_ARM, + MeasurementTypeKey.MUSCLE_TORSO, + MeasurementTypeKey.MUSCLE_LEFT_LEG, + MeasurementTypeKey.MUSCLE_RIGHT_LEG -> (m + noise(-2f, 2f)).coerceIn(15f, 60f) MeasurementTypeKey.WAIST, MeasurementTypeKey.HIPS, MeasurementTypeKey.CHEST, @@ -419,4 +429,4 @@ class MeasurementDemoUseCase @Inject constructor( companion object { private const val MS_PER_DAY = 86_400_000L } -} \ No newline at end of file +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementEvaluationUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementEvaluationUseCases.kt index aa07ddc23..90cc7944f 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementEvaluationUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementEvaluationUseCases.kt @@ -124,8 +124,18 @@ class MeasurementEvaluationUseCases @Inject constructor() { fun plausiblePercentRangeFor(typeKey: MeasurementTypeKey): ClosedFloatingPointRange? = when (typeKey) { MeasurementTypeKey.WATER -> 35f..75f - MeasurementTypeKey.BODY_FAT -> 3f..70f - MeasurementTypeKey.MUSCLE -> 15f..60f + MeasurementTypeKey.BODY_FAT, + MeasurementTypeKey.BODY_FAT_LEFT_ARM, + MeasurementTypeKey.BODY_FAT_RIGHT_ARM, + MeasurementTypeKey.BODY_FAT_TORSO, + MeasurementTypeKey.BODY_FAT_LEFT_LEG, + MeasurementTypeKey.BODY_FAT_RIGHT_LEG -> 3f..70f + MeasurementTypeKey.MUSCLE, + MeasurementTypeKey.MUSCLE_LEFT_ARM, + MeasurementTypeKey.MUSCLE_RIGHT_ARM, + MeasurementTypeKey.MUSCLE_TORSO, + MeasurementTypeKey.MUSCLE_LEFT_LEG, + MeasurementTypeKey.MUSCLE_RIGHT_LEG -> 15f..60f else -> null } } diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 078a1ab97..d9137f840 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -191,6 +191,16 @@ Intracellular water Protein Body cell mass + Body Fat Left Arm + Body Fat Right Arm + Body Fat Torso + Body Fat Left Leg + Body Fat Right Leg + Muscle Left Arm + Muscle Right Arm + Muscle Torso + Muscle Left Leg + Muscle Right Leg Bone formula Impedance-based regression (Legacy) Heymsfield (anthropometric) diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt index 692ad7757..02b919ef8 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt @@ -171,6 +171,7 @@ object ScaleCatalog { device("SANITAS SBF73") claimedBy SanitasSbf72Handler::class.java, device("Beurer BF915") claimedBy SanitasSbf72Handler::class.java, device("Beurer BF105") claimedBy StandardBeurerSanitasHandler::class.java, + device("Beurer BF1000") claimedBy StandardBeurerSanitasHandler::class.java, device("Beurer BF500") claimedBy StandardBeurerSanitasHandler::class.java, device("Beurer BF600") claimedBy StandardBeurerSanitasHandler::class.java, device("Beurer BF950") claimedBy StandardBeurerSanitasHandler::class.java, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000LibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000LibTest.kt new file mode 100644 index 000000000..37eb197aa --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BeurerBf1000LibTest.kt @@ -0,0 +1,130 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Calendar + +/** + * Tests for the Beurer BF1000 Super Precision decoder. + * + * The vectors are from an openScale file-log capture taken against a BF1000: + * 2A9D standard weight, 2A9C standard body composition, then Beurer-private + * FFFF/0009 segmental fat and FFFF/000A segmental muscle notifications. + */ +class BeurerBf1000LibTest { + private fun hex(s: String): ByteArray = + s.filterNot { it.isWhitespace() }.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + @Test + fun `decodes standard weight measurement with timestamp user bmi and height`() { + // flags, kg weight, timestamp, scale user index, BMI, height. + val decoded = BeurerBf1000Lib.parseWeightMeasurement( + hex("0E 48 44 EA 07 08 15 00 02 0E 01 04 01 26 07") + ) + + assertThat(decoded).isNotNull() + with(decoded!!) { + assertThat(isKg).isTrue() + assertThat(weightKg).isWithin(1e-4f).of(87.4f) + assertThat(scaleUserIndex).isEqualTo(1) + assertThat(bmi!!).isWithin(1e-4f).of(26.0f) + assertThat(heightMeters!!).isWithin(1e-4f).of(1.83f) + } + + with(Calendar.getInstance().apply { time = decoded.dateTime!! }) { + assertThat(get(Calendar.YEAR)).isEqualTo(2026) + assertThat(get(Calendar.MONTH)).isEqualTo(Calendar.AUGUST) + assertThat(get(Calendar.DAY_OF_MONTH)).isEqualTo(21) + assertThat(get(Calendar.HOUR_OF_DAY)).isEqualTo(0) + assertThat(get(Calendar.MINUTE)).isEqualTo(2) + assertThat(get(Calendar.SECOND)).isEqualTo(14) + } + } + + @Test + fun `decodes body composition and derives displayed bone mass from soft lean mass`() { + // flags, body fat %, BMR kJ, muscle %, soft lean mass, water mass, impedance. + val decoded = BeurerBf1000Lib.parseBodyCompositionMeasurement( + hex("98 03 72 00 A2 1E EA 01 74 39 D6 2B 02 12"), + fallbackWeightKg = 87.4f + ) + + assertThat(decoded).isNotNull() + with(decoded!!) { + assertThat(isKg).isTrue() + assertThat(isMultiPacket).isFalse() + assertThat(bodyFatPercent).isWithin(1e-4f).of(11.4f) + assertThat(bmrKcal!!).isWithin(1e-4f).of(1873.0f) + assertThat(musclePercent!!).isWithin(1e-4f).of(49.0f) + assertThat(softLeanMassKg!!).isWithin(1e-4f).of(73.54f) + assertThat(waterMassKg!!).isWithin(1e-4f).of(56.11f) + assertThat(impedanceOhm!!).isWithin(1e-4).of(461.0) + assertThat(weightKg!!).isWithin(1e-4f).of(87.4f) + assertThat(leanBodyMassKg!!).isWithin(1e-4f).of(77.4364f) + assertThat(boneMassKg!!).isWithin(1e-4f).of(3.8964f) + } + } + + @Test + fun `decodes private segmental fat measurement`() { + val decoded = BeurerBf1000Lib.parseSegmentalFatMeasurement( + hex("7E 32 8E 00 8F 00 63 00 81 00 7C 00") + ) + + assertThat(decoded).isNotNull() + with(decoded!!) { + assertThat(visceralFat).isWithin(1e-4f).of(5.0f) + assertThat(leftArm).isWithin(1e-4f).of(14.2f) + assertThat(rightArm).isWithin(1e-4f).of(14.3f) + assertThat(torso).isWithin(1e-4f).of(9.9f) + assertThat(leftLeg).isWithin(1e-4f).of(12.9f) + assertThat(rightLeg).isWithin(1e-4f).of(12.4f) + } + } + + @Test + fun `decodes private segmental muscle measurement`() { + val decoded = BeurerBf1000Lib.parseSegmentalMuscleMeasurement( + hex("3E EA 01 E9 01 E8 01 F2 01 F5 01") + ) + + assertThat(decoded).isNotNull() + with(decoded!!) { + assertThat(leftArm).isWithin(1e-4f).of(49.0f) + assertThat(rightArm).isWithin(1e-4f).of(48.9f) + assertThat(torso).isWithin(1e-4f).of(48.8f) + assertThat(leftLeg).isWithin(1e-4f).of(49.8f) + assertThat(rightLeg).isWithin(1e-4f).of(50.1f) + } + } + + @Test + fun `rejects short or unexpected private packets`() { + assertThat(BeurerBf1000Lib.parseSegmentalFatMeasurement(ByteArray(11))).isNull() + assertThat(BeurerBf1000Lib.parseSegmentalMuscleMeasurement(ByteArray(10))).isNull() + + assertThat(BeurerBf1000Lib.parseSegmentalFatMeasurement( + hex("00 32 8E 00 8F 00 63 00 81 00 7C 00") + )).isNull() + assertThat(BeurerBf1000Lib.parseSegmentalMuscleMeasurement( + hex("00 EA 01 E9 01 E8 01 F2 01 F5 01") + )).isNull() + } +} 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..3db2bca37 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/database/MigrationTest.kt @@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.health.openscale.core.data.ActivityLevel import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.testutil.RoomTestSupport import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -35,7 +36,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 +60,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 +79,21 @@ class MigrationTest { .firstOrNull { abs(it - 72.5f) < 0.1f } assertThat(weight).isNotNull() - // The rewrite seeds the default measurement types. - assertThat(repo.getAllMeasurementTypes().first()).isNotEmpty() + // The rewrite seeds the default measurement types, including later segmental BF1000 types. + assertThat(repo.getAllMeasurementTypes().first().map { it.key }).containsAtLeastElementsIn( + listOf( + MeasurementTypeKey.BODY_FAT_LEFT_ARM, + MeasurementTypeKey.BODY_FAT_RIGHT_ARM, + MeasurementTypeKey.BODY_FAT_TORSO, + MeasurementTypeKey.BODY_FAT_LEFT_LEG, + MeasurementTypeKey.BODY_FAT_RIGHT_LEG, + MeasurementTypeKey.MUSCLE_LEFT_ARM, + MeasurementTypeKey.MUSCLE_RIGHT_ARM, + MeasurementTypeKey.MUSCLE_TORSO, + MeasurementTypeKey.MUSCLE_LEFT_LEG, + MeasurementTypeKey.MUSCLE_RIGHT_LEG, + ) + ) } /** @@ -96,7 +110,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) @@ -114,6 +128,19 @@ class MigrationTest { .firstOrNull { abs(it - 80.5f) < 0.1f } assertThat(weight).isNotNull() - assertThat(repo.getAllMeasurementTypes().first()).isNotEmpty() + assertThat(repo.getAllMeasurementTypes().first().map { it.key }).containsAtLeastElementsIn( + listOf( + MeasurementTypeKey.BODY_FAT_LEFT_ARM, + MeasurementTypeKey.BODY_FAT_RIGHT_ARM, + MeasurementTypeKey.BODY_FAT_TORSO, + MeasurementTypeKey.BODY_FAT_LEFT_LEG, + MeasurementTypeKey.BODY_FAT_RIGHT_LEG, + MeasurementTypeKey.MUSCLE_LEFT_ARM, + MeasurementTypeKey.MUSCLE_RIGHT_ARM, + MeasurementTypeKey.MUSCLE_TORSO, + MeasurementTypeKey.MUSCLE_LEFT_LEG, + MeasurementTypeKey.MUSCLE_RIGHT_LEG, + ) + ) } } 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..b358c33ea 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 @@ -81,7 +82,7 @@ object RoomTestSupport { val ALL_MIGRATIONS: Array = arrayOf( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, - MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + 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. */