diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index a5c446ea7..80ad9daed 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -46,6 +46,7 @@ import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler import com.health.openscale.core.bluetooth.scales.InlifeHandler +import com.health.openscale.core.bluetooth.scales.KeepS3Handler import com.health.openscale.core.bluetooth.scales.LinkMode import com.health.openscale.core.bluetooth.scales.MGBHandler import com.health.openscale.core.bluetooth.scales.MedisanaBs44xHandler @@ -111,6 +112,8 @@ class ScaleFactory @Inject constructor( // TaylorBIAHandler and FitTrackDaraHandler must stay ahead of MGBHandler — all live on service // 0xFFB0, which MGBHandler also matches, so a later position would let MGB wrongly claim them. private val modernKotlinHandlers: List = listOf( + // Exact-name match must precede generic LeFu/0xFFF0 handlers (first match wins). + KeepS3Handler(), BeurerBF450Handler(), TaylorBIAHandler(), RyFitHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyComposition.kt new file mode 100644 index 000000000..0622e9dad --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyComposition.kt @@ -0,0 +1,251 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.health.openscale.core.data.GenderType + +/** + * Offline compatibility implementation of BestHealth's `BHKeep_2023-03-30` two-leg, + * dual-frequency body-composition routine bundled in Keep 9.0.80. + * + * The APK does not call this library from its application code; captured Keep reports appear to + * receive composition values from Keep's server and differ slightly from this routine. These + * results are therefore local BIA estimates, not Keep cloud values or direct scale measurements. + * The implementation was recovered for interoperability and does not contain or load vendor code. + * + * Inputs and fixed-point output units match the bundled SDK. Keeping the intermediate arithmetic + * as [Float] and tenths is intentional: the ARM64 routine uses IEEE-754 single precision and + * truncation toward zero. + */ +internal object KeepS3BodyComposition { + data class Input( + val gender: GenderType, + val age: Int, + val heightCm: Int, + val weightKg: Float, + val impedance50Ohm: Int, + val impedance100Ohm: Int, + val athlete: Boolean = false, + ) + + data class Result( + val bodyFatKg: Float, + val bodyFatPercent: Float, + val fatFreeMassKg: Float, + val waterPercent: Float, + val boneKg: Float, + val muscleKg: Float, + val musclePercent: Float, + val skeletalMuscleKg: Float, + val skeletalMusclePercent: Float, + val subcutaneousFatKg: Float, + val subcutaneousFatPercent: Float, + val proteinPercent: Float, + val visceralFatLevel: Int, + val basalMetabolicRateKcal: Int, + val bodyAge: Int, + val bmi22ReferenceWeightKg: Float, + ) + + fun calculate(input: Input): Result? { + if (input.age !in 6..99 || input.heightCm !in 90..220) return null + if (!input.weightKg.isFinite() || input.weightKg !in 10f..200f) return null + if (input.impedance50Ohm !in 200..1200 || input.impedance100Ohm !in 200..1200) { + return null + } + + val male = input.gender == GenderType.MALE + val weightRaw = (input.weightKg * 10f).toInt().coerceIn(100, 2000) + val height = input.heightCm + val age = input.age + val averageImpedance = (input.impedance50Ohm + input.impedance100Ohm) ushr 1 + val heightSquared = height * height + val bmiRaw = weightRaw * 10_000 / heightSquared + val bmi22ReferenceWeightRaw = (heightSquared.toFloat() * 0.022f).toInt() + + val rawFatFreeMass = heightSquared.toFloat() * 9.058f / 10_000f + + 12.226f + weightRaw.toFloat() * 0.032f - + averageImpedance.toFloat() * 0.0068f - age.toFloat() * 0.0542f + + var adjustedFatFreeMass = rawFatFreeMass - when { + male -> 0.8f + age < 50 -> 9.25f + else -> 7.25f + } + + if (male) { + adjustedFatFreeMass *= 1.05f + if (averageImpedance.toFloat() / height.toFloat() < 2.6f) { + adjustedFatFreeMass *= 1.03f + } else { + adjustedFatFreeMass *= 0.96f + if (weightRaw < 610) adjustedFatFreeMass *= 0.97f + if (height > 170) adjustedFatFreeMass *= 0.98f + } + } else { + adjustedFatFreeMass *= 1.02f + if (weightRaw < 500) adjustedFatFreeMass *= 1.02f + if (weightRaw > 600) adjustedFatFreeMass *= 0.96f + if (height > 160) adjustedFatFreeMass *= 1.03f + } + + var fatKg = weightRaw.toFloat() / 10f - adjustedFatFreeMass + if (input.athlete) { + fatKg = if (male) fatKg * 0.778f - 0.93f else fatKg * 0.992f - 1.5f + } + val fatRateRaw = (fatKg * 10_000f / weightRaw.toFloat()).toInt().coerceIn(50, 750) + val fatKgRaw = fatRateRaw * weightRaw / 1000 + val fatFreeMassRaw = weightRaw - fatKgRaw + + var waterRateRaw = ((1000 - fatRateRaw) * 7 / 10).let { base -> + base * if (base > 500) 98 else 102 + } / 100 + if (input.athlete) { + waterRateRaw = (waterRateRaw.toFloat() * (if (male) 0.996f else 0.985f) + + (if (male) 4f else 9f)).toInt() + } + waterRateRaw = waterRateRaw.coerceAtLeast(350) + + var boneRaw = (rawFatFreeMass * 0.5158f - if (male) 1.802f else 2.4569f).toInt() + boneRaw += if (boneRaw > 22) 1 else -1 + if (input.athlete) { + boneRaw += when { + boneRaw < 20 -> 1 + boneRaw < 30 -> 2 + else -> 3 + } + } + + val bodyAge = calculateBodyAge(age, bmiRaw) + var bmrRaw = if (male) { + weightRaw.toFloat() * 1.4916f + 877.8f - height.toFloat() * 0.726f - + age.toFloat() * 8.976f + } else { + weightRaw.toFloat() * 1.0204f + 864.6f - height.toFloat() * 0.3934f - + age.toFloat() * 6.204f + }.toInt() + if (input.athlete) bmrRaw = (bmrRaw.toFloat() * 1.16f - 149f).toInt() + bmrRaw = bmrRaw.coerceAtLeast(500) + + val visceralFat = calculateVisceralFat( + male = male, + athlete = input.athlete, + age = age, + height = height, + weightRaw = weightRaw, + heightSquared = heightSquared, + ) + + val muscleKgRaw = (fatFreeMassRaw - boneRaw).coerceAtLeast(0) + val muscleRateRaw = (muscleKgRaw.toFloat() * 1000f / weightRaw.toFloat()).toInt() + val waterKgRaw = waterRateRaw * weightRaw / 1000 + val skeletalMuscleKgRaw = (waterKgRaw.toFloat() * 0.832f - 27.354f) + .toInt().coerceAtLeast(0) + val skeletalMuscleRate = if (weightRaw > 0) { + skeletalMuscleKgRaw.toFloat() * 100f / weightRaw.toFloat() + } else { + 0f + } + + val boneRateRaw = boneRaw.toFloat() * 1000f / weightRaw.toFloat() + val proteinRateRaw = ((1000 - fatRateRaw).toFloat() - + waterRateRaw.toFloat() * 1.08f - boneRateRaw).toInt().coerceIn(20, 300) + + var subcutaneousFatKgRaw = averageImpedance.toFloat() * 0.031f + + bmiRaw.toFloat() * 0.94f + age.toFloat() * 1.049f - 210.772f + subcutaneousFatKgRaw = subcutaneousFatKgRaw.coerceIn(10f, 300f) * -9.4f / 34f + + fatKgRaw.toFloat() + if (input.athlete) subcutaneousFatKgRaw *= 0.85f + val subcutaneousFatRateRaw = (subcutaneousFatKgRaw * 1000f / weightRaw.toFloat()) + .toInt().coerceIn(10, 600) + val storedSubcutaneousFatKgRaw = subcutaneousFatRateRaw * weightRaw / 1000 + + return Result( + bodyFatKg = fatKgRaw / 10f, + bodyFatPercent = fatRateRaw / 10f, + fatFreeMassKg = fatFreeMassRaw / 10f, + waterPercent = waterRateRaw / 10f, + boneKg = boneRaw / 10f, + muscleKg = muscleKgRaw / 10f, + musclePercent = muscleRateRaw / 10f, + skeletalMuscleKg = skeletalMuscleKgRaw / 10f, + skeletalMusclePercent = skeletalMuscleRate, + subcutaneousFatKg = storedSubcutaneousFatKgRaw / 10f, + subcutaneousFatPercent = subcutaneousFatRateRaw / 10f, + proteinPercent = proteinRateRaw / 10f, + visceralFatLevel = visceralFat, + basalMetabolicRateKcal = bmrRaw, + bodyAge = bodyAge, + bmi22ReferenceWeightKg = bmi22ReferenceWeightRaw / 10f, + ) + } + + private fun calculateBodyAge(age: Int, bmiRaw: Int): Int { + val first = (age.toFloat() + 28.428f - bmiRaw.toFloat() * 0.1428f) + .toInt().coerceIn(age - 5, age + 5) + val second = (bmiRaw.toFloat() * 0.1724f + age.toFloat() - 34.931f) + .toInt().coerceIn(age - 8, age + 8) + val result = if (bmiRaw < 30) { + first.toFloat() * 0.6f + second.toFloat() * 0.4f + } else { + first.toFloat() * 0.4f + second.toFloat() * 0.6f + } + return result.toInt().coerceIn(6, 99) + } + + private fun calculateVisceralFat( + male: Boolean, + athlete: Boolean, + age: Int, + height: Int, + weightRaw: Int, + heightSquared: Int, + ): Int { + val weight = weightRaw.toFloat() + val stature = height.toFloat() + var visceral = if (male) { + if (weight * 0.16f + 63f > stature) { + weight * 30.5f / + (heightSquared.toFloat() * 0.0826f - stature * 0.4f + 48f) - + 2.9f + age.toFloat() * 0.15f + } else { + (stature * -0.0015f + 0.765f) * weight / 10f - + stature * 0.143f + age.toFloat() * 0.15f - 5f + } + } else { + if (stature * 5f - 130f < weight) { + weight * 50f / + (heightSquared.toFloat() * 0.1158f + stature * 1.45f - 144f) - + 6f + age.toFloat() * 0.07f + } else { + (stature * -0.0024f + 0.691f) * weight / 10f - + stature * 0.027f + age.toFloat() * 0.07f - 10.5f + } + } + + if (athlete) { + visceral = when { + visceral < 2f -> 1f + visceral < 10f -> visceral - 2f + visceral < 20f -> visceral * 0.8f + else -> visceral * 0.85f + } + } + return visceral.toInt().coerceIn(1, 50) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt new file mode 100644 index 000000000..a647218b7 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/KeepS3Handler.kt @@ -0,0 +1,808 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.KeepS3BodyComposition +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.security.SecureRandom +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.UUID +import kotlin.math.roundToInt + +/** Pure codec for the capture-verified Keep S3 application protocol. */ +internal object KeepS3Protocol { + const val FRAME_REQUEST = 0x01 + const val FRAME_RESPONSE = 0x02 + const val FRAME_EVENT = 0x03 + const val FRAME_ACK = 0x04 + const val MAGIC = 0x53 + const val STATUS_OK = 0x80 + + const val OP_SET_TIME = 0x01 + const val OP_BATTERY = 0x03 + const val OP_SET_UNIT = 0x05 + const val OP_READ_TIME = 0x0A + const val OP_UNKNOWN_20 = 0x20 + const val OP_PROFILE = 0x32 + const val OP_MEASUREMENT_CONTROL = 0x36 + const val OP_NEGOTIATE = 0x38 + const val OP_MEASUREMENT_EVENT = 0x57 + const val OP_FINAL_RECORD = 0x58 + const val OP_DEVICE_INFO = 0xE7 + const val OP_UNKNOWN_F5 = 0xF5 + + const val STAGE_FINAL = 0x29 + + private const val PROFILE_PAYLOAD_SIZE = 63 + private const val CONTROL_PAYLOAD_SIZE = 50 + private const val TOKEN_SIZE = 24 + private const val TOKEN_PAIR_SIZE = TOKEN_SIZE * 2 + + data class FrameHeader(val type: Int, val opcode: Int, val payloadLength: Int) + data class Response(val opcode: Int, val data: ByteArray) + data class MeasurementEvent( + val stage: Int, + val weightKg: Float, + val impedanceOhm: Int, + val heartRateBpm: Int, + ) + + data class FinalRecord( + val weightKg: Float, + val encodedImpedance50: Int, + val encodedImpedance100: Int, + val impedance50Ohm: Int?, + val impedance100Ohm: Int?, + val phaseAngle50Raw: Int, + val phaseAngle100Raw: Int, + val phaseAngle50Degrees: Float?, + val phaseAngle100Degrees: Float?, + val heartRateBpm: Int, + ) + + data class PreviousRecord( + val weightKg: Float = 0f, + val timestampSeconds: Long = 0L, + val impedanceOhm: Double = 0.0, + ) + + data class PersistedDeviceImpedance( + val timestampSeconds: Long, + val weightRaw: Int, + val impedanceOhm: Int, + ) + + /** Read only the non-sensitive frame identity. Payload validity is checked separately. */ + fun peekFrameHeader(frame: ByteArray): FrameHeader? { + if (frame.size < 5) return null + if (u8(frame[1]) != MAGIC || u8(frame[3]) != 0) return null + val type = u8(frame[0]) + if (type !in FRAME_REQUEST..FRAME_ACK) return null + return FrameHeader(type, u8(frame[2]), u8(frame[4])) + } + + /** Validate the complete frame, including the type-specific declared payload length. */ + fun parseFrameHeader(frame: ByteArray): FrameHeader? { + val header = peekFrameHeader(frame) ?: return null + val expectedSize = when (header.type) { + FRAME_RESPONSE -> 6 + header.payloadLength // status byte is not included in data length + FRAME_ACK -> 6 + header.payloadLength + else -> 5 + header.payloadLength + } + return header.takeIf { frame.size == expectedSize } + } + + fun parseResponse(frame: ByteArray): Response? { + val header = parseFrameHeader(frame) ?: return null + if (header.type != FRAME_RESPONSE || u8(frame[5]) != STATUS_OK) return null + return Response(header.opcode, frame.copyOfRange(6, frame.size)) + } + + fun parseMeasurementEvent(frame: ByteArray): MeasurementEvent? { + val header = parseFrameHeader(frame) ?: return null + if (header.type != FRAME_EVENT || header.opcode != OP_MEASUREMENT_EVENT) return null + if (header.payloadLength != 8 || frame.size != 13) return null + + return MeasurementEvent( + stage = u8(frame[5]), + weightKg = decodeU16BE(frame, 6) / 200.0f, + impedanceOhm = decodeU16BE(frame, 10), + heartRateBpm = u8(frame[12]), + ) + } + + fun parseFinalRecord(frame: ByteArray): FinalRecord? { + val header = parseFrameHeader(frame) ?: return null + if (header.type != FRAME_EVENT || header.opcode != OP_FINAL_RECORD) return null + if (header.payloadLength != 61 || frame.size != 66) return null + + val encodedImpedance50 = decodeU24BE(frame, 55) + val encodedImpedance100 = decodeU24BE(frame, 58) + val phaseAngle50Raw = decodeI16BE(frame, 61) + val phaseAngle100Raw = decodeI16BE(frame, 63) + return FinalRecord( + weightKg = decodeU16BE(frame, 53) / 200.0f, + encodedImpedance50 = encodedImpedance50, + encodedImpedance100 = encodedImpedance100, + impedance50Ohm = decodeEncodedImpedance(encodedImpedance50), + impedance100Ohm = decodeEncodedImpedance(encodedImpedance100), + phaseAngle50Raw = phaseAngle50Raw, + phaseAngle100Raw = phaseAngle100Raw, + phaseAngle50Degrees = decodePhaseAngleDegrees(phaseAngle50Raw), + phaseAngle100Degrees = decodePhaseAngleDegrees(phaseAngle100Raw), + heartRateBpm = u8(frame[65]), + ) + } + + fun buildRequest(opcode: Int, payload: ByteArray = byteArrayOf()): ByteArray { + require(opcode in 0..0xFF) { "opcode must fit in one byte" } + require(payload.size <= 0xFF) { "payload is too large" } + return byteArrayOf( + FRAME_REQUEST.toByte(), + MAGIC.toByte(), + opcode.toByte(), + 0x00, + payload.size.toByte(), + ) + payload + } + + fun buildAck(opcode: Int): ByteArray { + require(opcode in 0..0xFF) { "opcode must fit in one byte" } + return byteArrayOf( + FRAME_ACK.toByte(), + MAGIC.toByte(), + opcode.toByte(), + 0x00, + 0x00, + STATUS_OK.toByte(), + ) + } + + fun buildProfilePayload( + token: String, + previous: PreviousRecord?, + heightCm: Int, + birthYear: Int, + birthMonth: Int, + birthDay: Int, + ): ByteArray { + require(validateToken(token)) { "invalid Keep S3 ID24 token" } + val payload = ByteArray(PROFILE_PAYLOAD_SIZE) + repeatedTokenBytes(token).copyInto(payload) + + val record = previous ?: PreviousRecord() + encodeU16BE(payload, 48, encodeWeightRaw(record.weightKg)) + encodeU32BE(payload, 50, record.timestampSeconds.coerceIn(0L, 0xFFFF_FFFFL)) + encodeU16BE(payload, 54, encodeImpedanceRaw(record.impedanceOhm)) + encodeU16BE(payload, 56, 0) // Capture-verified reserved field; semantic is unknown. + payload[58] = heightCm.coerceIn(0, 0xFF).toByte() + encodeU16BE(payload, 59, birthYear.coerceIn(0, 0xFFFF)) + payload[61] = birthMonth.coerceIn(1, 12).toByte() + payload[62] = birthDay.coerceIn(1, 31).toByte() + return payload + } + + fun buildMeasurementControl(token: String, start: Boolean): ByteArray { + require(validateToken(token)) { "invalid Keep S3 ID24 token" } + val payload = repeatedTokenBytes(token) + byteArrayOf(0x00, if (start) 0x01 else 0x00) + check(payload.size == CONTROL_PAYLOAD_SIZE) + return payload + } + + fun repeatedTokenBytes(token: String): ByteArray { + require(validateToken(token)) { "invalid Keep S3 ID24 token" } + val bytes = (token + token).encodeToByteArray() + check(bytes.size == TOKEN_PAIR_SIZE) + return bytes + } + + fun validateToken(token: String): Boolean = + token.length == TOKEN_SIZE && token.all { it in '0'..'9' || it in 'a'..'f' } + + /** Generate an opaque token from exactly 12 random bytes; the vendor algorithm is unknown. */ + fun generateToken(randomBytes: ByteArray): String { + require(randomBytes.size == 12) { "Keep S3 ID24 requires 12 random bytes" } + return randomBytes.joinToString(separator = "") { + String.format(Locale.ROOT, "%02x", u8(it)) + } + } + + fun tokenSettingKey(userId: Int): String = "id24-user-$userId" + + fun deviceImpedanceSettingKey(userId: Int): String = "previous-device-impedance-user-$userId" + + fun serializeDeviceImpedance( + timestampSeconds: Long, + weightKg: Float, + impedanceOhm: Int, + ): String? { + if (timestampSeconds !in 1L..0xFFFF_FFFFL || impedanceOhm !in 1..0xFFFF) return null + val weightRaw = encodeWeightRaw(weightKg).takeIf { it > 0 } ?: return null + return "$timestampSeconds:$weightRaw:$impedanceOhm" + } + + fun parseDeviceImpedance(value: String?): PersistedDeviceImpedance? { + val fields = value?.split(':') ?: return null + if (fields.size != 3) return null + val timestampSeconds = fields[0].toLongOrNull()?.takeIf { it in 1L..0xFFFF_FFFFL } + ?: return null + val weightRaw = fields[1].toIntOrNull()?.takeIf { it in 1..0xFFFF } ?: return null + val impedanceOhm = fields[2].toIntOrNull()?.takeIf { it in 1..0xFFFF } ?: return null + return PersistedDeviceImpedance(timestampSeconds, weightRaw, impedanceOhm) + } + + fun deviceImpedanceMatches( + stored: PersistedDeviceImpedance, + timestampSeconds: Long, + weightKg: Float, + ): Boolean = stored.timestampSeconds == timestampSeconds && + stored.weightRaw == encodeWeightRaw(weightKg) + + fun encodeU16BE(target: ByteArray, offset: Int, value: Int) { + require(offset >= 0 && offset + 2 <= target.size) + val clamped = value.coerceIn(0, 0xFFFF) + target[offset] = (clamped ushr 8).toByte() + target[offset + 1] = clamped.toByte() + } + + fun encodeU32BE(target: ByteArray, offset: Int, value: Long) { + require(offset >= 0 && offset + 4 <= target.size) + val clamped = value.coerceIn(0L, 0xFFFF_FFFFL) + target[offset] = (clamped ushr 24).toByte() + target[offset + 1] = (clamped ushr 16).toByte() + target[offset + 2] = (clamped ushr 8).toByte() + target[offset + 3] = clamped.toByte() + } + + fun decodeU16BE(data: ByteArray, offset: Int): Int { + require(offset >= 0 && offset + 2 <= data.size) + return (u8(data[offset]) shl 8) or u8(data[offset + 1]) + } + + fun decodeU32BE(data: ByteArray, offset: Int): Long { + require(offset >= 0 && offset + 4 <= data.size) + return (u8(data[offset]).toLong() shl 24) or + (u8(data[offset + 1]).toLong() shl 16) or + (u8(data[offset + 2]).toLong() shl 8) or + u8(data[offset + 3]).toLong() + } + + fun decodeU24BE(data: ByteArray, offset: Int): Int { + require(offset >= 0 && offset + 3 <= data.size) + return (u8(data[offset]) shl 16) or + (u8(data[offset + 1]) shl 8) or + u8(data[offset + 2]) + } + + fun decodeI16BE(data: ByteArray, offset: Int): Int { + val unsigned = decodeU16BE(data, offset) + return if (unsigned and 0x8000 != 0) unsigned - 0x1_0000 else unsigned + } + + /** Decoder recovered from the APK's BhGetBodyComposition_TwoLegs240 input path. */ + fun decodeEncodedImpedance(encoded: Int): Int? { + if (encoded !in 0..0xFF_FFFF || encoded == 0xFF_FFFF) return null + val upper = (encoded and 0x0F00) or ((encoded ushr 16) and 0xFF) + val lower = ((encoded and 0xFF) shl 2) + ((encoded ushr 12) and 0x0F) + val decoded = (upper - lower) / 2 // Kotlin division truncates toward zero, like the SDK. + return decoded.takeIf { it in 200..1200 } + } + + /** Both captures and the APK's native call path encode phase angle as negative tenths. */ + fun decodePhaseAngleDegrees(raw: Int): Float? = + if (raw in Short.MIN_VALUE until 0) -raw / 10.0f else null + + private fun encodeWeightRaw(weightKg: Float): Int { + if (!weightKg.isFinite() || weightKg <= 0f) return 0 + return (weightKg.toDouble() * 200.0).roundToInt().coerceIn(0, 0xFFFF) + } + + private fun encodeImpedanceRaw(impedanceOhm: Double): Int { + if (!impedanceOhm.isFinite() || impedanceOhm <= 0.0) return 0 + return impedanceOhm.roundToInt().coerceIn(0, 0xFFFF) + } + + private fun u8(value: Byte): Int = value.toInt() and 0xFF +} + +internal enum class KeepS3InitStep(val opcode: Int) { + NEGOTIATE(KeepS3Protocol.OP_NEGOTIATE), + READ_TIME(KeepS3Protocol.OP_READ_TIME), + SET_TIME(KeepS3Protocol.OP_SET_TIME), + SET_UNIT(KeepS3Protocol.OP_SET_UNIT), + DEVICE_INFO(KeepS3Protocol.OP_DEVICE_INFO), + UNKNOWN_F5_FIRST(KeepS3Protocol.OP_UNKNOWN_F5), + UNKNOWN_F5_SECOND(KeepS3Protocol.OP_UNKNOWN_F5), + BATTERY(KeepS3Protocol.OP_BATTERY), + UNKNOWN_20(KeepS3Protocol.OP_UNKNOWN_20), + PROFILE(KeepS3Protocol.OP_PROFILE), + START(KeepS3Protocol.OP_MEASUREMENT_CONTROL), +} + +/** Response-driven initialization state. No transition occurs for a foreign opcode. */ +internal class KeepS3InitStateMachine { + private var index = 0 + + val expectedStep: KeepS3InitStep? + get() = KeepS3InitStep.entries.getOrNull(index) + + fun reset(): KeepS3InitStep { + index = 0 + return KeepS3InitStep.entries[index] + } + + fun acceptSuccessfulResponse(opcode: Int): KeepS3InitStep? { + val expected = expectedStep ?: return null + if (expected.opcode != opcode) return null + index++ + return expectedStep + } +} + +/** + * Keep S3 handler for the capture-verified 0x00FF / FF01 / FF02 protocol. + * + * The device also exposes 0xFFF0, but both captured official-app sessions exclusively used + * FF01 notifications and FF02 Write With Response. Body-composition values are calculated offline + * by the SDK-compatible routine bundled in Keep 9.0.80; Keep's cloud report differs slightly. + */ +class KeepS3Handler : ScaleDeviceHandler() { + private val service: UUID = uuid16(0x00FF) + private val notifyCharacteristic: UUID = uuid16(0xFF01) + private val writeCharacteristic: UUID = uuid16(0xFF02) + + private val initState = KeepS3InitStateMachine() + private var currentUser: ScaleUser? = null + private var token = "" + private var published = false + private var finishing = false + private var latestImpedanceOhm = 0 + private var pendingFinalEvent: KeepS3Protocol.MeasurementEvent? = null + private var finalPublishJob: Job? = null + private var finishJob: Job? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + if (!device.name.equals(DEVICE_NAME, ignoreCase = true)) return null + + if (device.serviceUuids.contains(service)) { + logD("Keep S3 matched by exact name with advertised 0x00FF service") + } else { + // Saved/incomplete scan results do not always carry advertised service UUIDs. + logD("Keep S3 matched by exact name; advertised 0x00FF service was not present") + } + + return DEVICE_SUPPORT + } + + override fun onConnected(user: ScaleUser) { + resetSession() + currentUser = user + token = loadOrCreateToken(user.id) + + // GattScaleAdapter queues these operations, so the first request follows notify setup. + setNotifyOn(service, notifyCharacteristic) + sendStep(initState.reset(), responseData = byteArrayOf()) + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (characteristic != notifyCharacteristic) return + + val identity = KeepS3Protocol.peekFrameHeader(data) + if (identity == null) { + logMalformedFrame(data) + return + } + + when (identity.type) { + KeepS3Protocol.FRAME_RESPONSE -> handleResponse(data, user) + KeepS3Protocol.FRAME_EVENT -> handleEvent(identity, data, user) + else -> logW("Ignoring unexpected frame type=${identity.type} opcode=0x${identity.opcode.toString(16)} len=${data.size}") + } + } + + override fun onDisconnected() { + publishPendingFinalWithoutRecord() + resetSession() + } + + private fun handleResponse(frame: ByteArray, user: ScaleUser) { + val response = KeepS3Protocol.parseResponse(frame) + if (response == null) { + logMalformedFrame(frame) + return + } + + val completedStep = initState.expectedStep + if (completedStep == null) { + logD("Ignoring response after initialization opcode=0x${response.opcode.toString(16)}") + return + } + if (completedStep.opcode != response.opcode) { + logW( + "Ignoring out-of-order response opcode=0x${response.opcode.toString(16)} " + + "expected=0x${completedStep.opcode.toString(16)}", + ) + return + } + + val nextStep = initState.acceptSuccessfulResponse(response.opcode) + if (completedStep == KeepS3InitStep.BATTERY && response.data.size == 1) { + logI("Keep S3 battery level=${response.data[0].toInt() and 0xFF}% (no battery callback API)") + } + + if (nextStep == null) { + logI("Keep S3 initialization completed") + return + } + sendStep(nextStep, response.data, user) + } + + private fun sendStep( + step: KeepS3InitStep, + responseData: ByteArray, + user: ScaleUser? = currentUser, + ) { + val activeUser = user ?: return + val payload = when (step) { + KeepS3InitStep.SET_TIME -> { + if (responseData.size == 4) { + responseData + } else { + logW("Scale time response had no four-byte timestamp; using current Unix time") + unixTimeBytes() + } + } + + KeepS3InitStep.SET_UNIT -> byteArrayOf(0x00) // Capture strongly supports 0x00 = kg. + KeepS3InitStep.PROFILE -> buildProfilePayload(activeUser) + KeepS3InitStep.START -> KeepS3Protocol.buildMeasurementControl(token, start = true) + else -> byteArrayOf() + } + + writeTo( + service, + writeCharacteristic, + KeepS3Protocol.buildRequest(step.opcode, payload), + withResponse = true, + ) + + if (step == KeepS3InitStep.START) { + userInfo(R.string.bt_info_step_on_scale) + } + } + + private fun handleEvent(header: KeepS3Protocol.FrameHeader, frame: ByteArray, user: ScaleUser) { + when (header.opcode) { + KeepS3Protocol.OP_MEASUREMENT_EVENT -> { + // ACK from the verified envelope before attempting to parse the event payload. + sendAck(KeepS3Protocol.OP_MEASUREMENT_EVENT) + val event = KeepS3Protocol.parseMeasurementEvent(frame) + if (event == null) { + logMalformedFrame(frame) + return + } + handleMeasurementEvent(event, user) + } + + KeepS3Protocol.OP_FINAL_RECORD -> { + sendAck(KeepS3Protocol.OP_FINAL_RECORD) + val record = KeepS3Protocol.parseFinalRecord(frame) + if (record == null) { + logMalformedFrame(frame) + return + } + finalPublishJob?.cancel() + finalPublishJob = null + val finalEvent = pendingFinalEvent + logD( + "Keep S3 final record impedance50=${record.impedance50Ohm ?: "invalid"}Ω " + + "impedance100=${record.impedance100Ohm ?: "invalid"}Ω " + + "phase50=${record.phaseAngle50Degrees ?: "invalid"}° " + + "phase100=${record.phaseAngle100Degrees ?: "invalid"}°", + ) + publishOnce( + user = user, + weightKg = finalEvent?.weightKg ?: record.weightKg, + deviceImpedanceOhm = finalEvent?.impedanceOhm ?: latestImpedanceOhm, + heartRateBpm = finalEvent?.heartRateBpm?.takeIf { it > 0 } + ?: record.heartRateBpm, + phaseAngle50Degrees = record.phaseAngle50Degrees, + phaseAngle100Degrees = record.phaseAngle100Degrees, + compositionImpedance50Ohm = record.impedance50Ohm, + compositionImpedance100Ohm = record.impedance100Ohm, + ) + finishSession() + } + + else -> logD("Ignoring unknown event opcode=0x${header.opcode.toString(16)} len=${frame.size}") + } + } + + private fun handleMeasurementEvent(event: KeepS3Protocol.MeasurementEvent, user: ScaleUser) { + if (event.impedanceOhm > 0) latestImpedanceOhm = event.impedanceOhm + + // Only 0x29 has a capture-verified final-result meaning. Other stage labels are unknown. + if (event.stage == KeepS3Protocol.STAGE_FINAL) { + pendingFinalEvent = event + if (finalPublishJob == null) { + finalPublishJob = scope.launch { + delay(FINAL_RECORD_WAIT_MS) + publishPendingFinalWithoutRecord() + finalPublishJob = null + } + } + } else if (event.stage == 0x00 || event.stage == 0x01) { + // These two progress stages carry live weight; their formal vendor names are unknown. + if (event.weightKg > 0f) { + userInfo(R.string.bluetooth_scale_info_measuring_weight, event.weightKg) + } + } + } + + private fun publishPendingFinalWithoutRecord() { + val user = currentUser ?: return + val event = pendingFinalEvent ?: return + publishOnce(user, event.weightKg, event.impedanceOhm, event.heartRateBpm) + } + + private fun publishOnce( + user: ScaleUser, + weightKg: Float, + deviceImpedanceOhm: Int, + heartRateBpm: Int, + phaseAngle50Degrees: Float? = null, + phaseAngle100Degrees: Float? = null, + compositionImpedance50Ohm: Int? = null, + compositionImpedance100Ohm: Int? = null, + ) { + if (published || !weightKg.isFinite() || weightKg <= 0f) return + + val measurement = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + weight = weightKg + if (heartRateBpm > 0) heartRate = heartRateBpm + // openScale has no measurement type for the vendor impedance or the phase angles, + // so they are decoded and logged but not published. Re-enabling any of these needs + // a MeasurementTypeKey, a ScaleMeasurement field, a DB migration and BleConnector + // wiring. + // if (deviceImpedanceOhm > 0) deviceImpedance = deviceImpedanceOhm.toDouble() + // phaseAngle50Degrees?.takeIf { it.isFinite() && it > 0f }?.let { phaseAngle = it } + // phaseAngle100Degrees?.takeIf { it.isFinite() && it > 0f }?.let { phaseAngleHigh = it } + + val hasDualFrequencyImpedance = + compositionImpedance50Ohm != null && compositionImpedance100Ohm != null + if (hasDualFrequencyImpedance) { + // ScaleMeasurement's established dual-band convention is high frequency in + // impedance and low frequency in impedanceLow. Keep S3 reports 100/50 kHz. + impedance = compositionImpedance100Ohm.toDouble() + impedanceLow = compositionImpedance50Ohm.toDouble() + } + + val composition = if (hasDualFrequencyImpedance) { + KeepS3BodyComposition.calculate( + KeepS3BodyComposition.Input( + gender = user.gender, + age = user.age, + heightCm = if (user.bodyHeight.isFinite()) { + user.bodyHeight.roundToInt() + } else { + 0 + }, + weightKg = weightKg, + impedance50Ohm = compositionImpedance50Ohm, + impedance100Ohm = compositionImpedance100Ohm, + // openScale's activity level is a TDEE multiplier, not the Keep SDK's + // athlete body-type flag. Capture/native API evidence does not establish + // an equivalence, so keep the vendor flag off until there is an explicit, + // user-controlled Keep setting. + athlete = false, + ), + ) + } else { + null + } + if (composition == null) { + logW("Keep S3 body composition unavailable; missing or invalid dual-frequency input") + } else { + fat = composition.bodyFatPercent + water = composition.waterPercent + visceralFat = composition.visceralFatLevel.toFloat() + bone = composition.boneKg + lbm = composition.fatFreeMassKg + bmr = composition.basalMetabolicRateKcal.toFloat() + protein = composition.proteinPercent + // openScale evaluates MUSCLE as a skeletal-muscle percentage (plausible range + // 15-60%). Keep's broader "muscle" value is FFM minus bone and can exceed that + // range, so publish the separately decoded skeletal-muscle percentage here. + muscle = composition.skeletalMusclePercent + // Keep's broader composition.musclePercent remains decoded by the model but is + // not published because openScale has no lean-soft-tissue measurement type. + // Not published — openScale has no measurement type for these. + // subcutaneousFat = composition.subcutaneousFatPercent + // bodyAge = composition.bodyAge + // bmi22ReferenceWeight = composition.bmi22ReferenceWeightKg + logI("Keep S3 body composition calculated with offline BHKeep SDK-compatible model") + } + } + publish(measurement) + rememberDeviceImpedance(user.id, measurement, deviceImpedanceOhm) + published = true + } + + private fun finishSession() { + if (finishing) return + finishing = true + + val stopPayload = KeepS3Protocol.buildMeasurementControl(token, start = false) + val stopRequest = KeepS3Protocol.buildRequest(KeepS3Protocol.OP_MEASUREMENT_CONTROL, stopPayload) + writeTo(service, writeCharacteristic, stopRequest, withResponse = true) + writeTo(service, writeCharacteristic, stopRequest, withResponse = true) + + finishJob = scope.launch { + // The scale can emit more than 140 measurement events per session and leave a + // double-digit ACK backlog when the final record arrives. Allow that backlog, the + // 0x58 ACK and both stop commands to drain before disconnecting. The stop command + // is sent twice so a dropped one is not fatal. + delay(DISCONNECT_DELAY_MS) + requestDisconnect() + } + } + + /** + * Isolates the first-measurement all-zero policy from the wire codec. A Keep S3 accepted + * this fallback during hardware validation; the vendor-defined semantics remain unknown. + */ + private fun previousRecordFor(user: ScaleUser): KeepS3Protocol.PreviousRecord? { + val previous = lastMeasurementFor(user.id) + if (previous == null) { + logI("No previous measurement for user ${user.id}; using all-zero previous record") + return null + } + val impedanceOhm = previousDeviceImpedance(user.id, previous) + if (impedanceOhm == null) { + // Reusing impedance/impedanceLow would send a decoded 100/50 kHz band, while the + // captured official-app profile uses the distinct impedance from the 0x57 event. + logW("No matching Keep S3 protocol impedance; using all-zero previous record") + return null + } + logI("Keep S3 previous record uses matched protocol impedance=${impedanceOhm.roundToInt()}Ω") + return KeepS3Protocol.PreviousRecord( + weightKg = previous.weight, + timestampSeconds = (previous.dateTime?.time ?: 0L) / 1000L, + impedanceOhm = impedanceOhm, + ) + } + + private fun rememberDeviceImpedance( + userId: Int, + measurement: ScaleMeasurement, + deviceImpedanceOhm: Int, + ) { + val timestampSeconds = measurement.dateTime?.time?.div(1000L) ?: return + val encoded = KeepS3Protocol.serializeDeviceImpedance( + timestampSeconds = timestampSeconds, + weightKg = measurement.weight, + impedanceOhm = deviceImpedanceOhm, + ) ?: return + settingsPutString(KeepS3Protocol.deviceImpedanceSettingKey(userId), encoded) + } + + private fun previousDeviceImpedance(userId: Int, previous: ScaleMeasurement): Double? { + val timestampSeconds = previous.dateTime?.time?.div(1000L) ?: return null + val stored = KeepS3Protocol.parseDeviceImpedance( + settingsGetString(KeepS3Protocol.deviceImpedanceSettingKey(userId)), + ) ?: return null + return stored.impedanceOhm.toDouble().takeIf { + KeepS3Protocol.deviceImpedanceMatches(stored, timestampSeconds, previous.weight) + } + } + + private fun buildProfilePayload(user: ScaleUser): ByteArray { + val birthday = Calendar.getInstance().apply { time = user.birthday } + val heightCm = if (user.bodyHeight.isFinite()) user.bodyHeight.roundToInt() else 0 + return KeepS3Protocol.buildProfilePayload( + token = token, + previous = previousRecordFor(user), + heightCm = heightCm, + birthYear = birthday.get(Calendar.YEAR), + birthMonth = birthday.get(Calendar.MONTH) + 1, + birthDay = birthday.get(Calendar.DAY_OF_MONTH), + ) + } + + private fun loadOrCreateToken(userId: Int): String { + val key = KeepS3Protocol.tokenSettingKey(userId) + val stored = settingsGetString(key) + val normalized = stored?.lowercase(Locale.ROOT) + if (normalized != null && KeepS3Protocol.validateToken(normalized)) { + if (stored != normalized) settingsPutString(key, normalized) + return normalized + } + + val randomBytes = ByteArray(12).also(SecureRandom()::nextBytes) + return KeepS3Protocol.generateToken(randomBytes).also { settingsPutString(key, it) } + } + + private fun sendAck(opcode: Int) { + writeTo( + service, + writeCharacteristic, + KeepS3Protocol.buildAck(opcode), + withResponse = true, + ) + } + + private fun unixTimeBytes(): ByteArray = ByteArray(4).also { + KeepS3Protocol.encodeU32BE(it, 0, System.currentTimeMillis() / 1000L) + } + + private fun logMalformedFrame(frame: ByteArray) { + val type = frame.getOrNull(0)?.toInt()?.and(0xFF) + val opcode = frame.getOrNull(2)?.toInt()?.and(0xFF) + logW("Ignoring malformed Keep S3 frame len=${frame.size} type=$type opcode=$opcode") + } + + private fun resetSession() { + finalPublishJob?.cancel() + finalPublishJob = null + finishJob?.cancel() + finishJob = null + currentUser = null + token = "" + published = false + finishing = false + latestImpedanceOhm = 0 + pendingFinalEvent = null + } + + companion object { + private const val DEVICE_NAME = "Keep_S3" + private const val FINAL_RECORD_WAIT_MS = 2_000L + private const val DISCONNECT_DELAY_MS = 6_000L + + private val DEVICE_SUPPORT = DeviceSupport( + displayName = "Keep S3", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.BATTERY_LEVEL, + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG, + DeviceCapability.BODY_COMPOSITION, + ), + tuningProfile = TuningProfile.Balanced, + linkMode = LinkMode.CONNECT_GATT, + ) + } +} diff --git a/android_app/app/src/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」 diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyCompositionTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyCompositionTest.kt new file mode 100644 index 000000000..58e2fb0df --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/KeepS3BodyCompositionTest.kt @@ -0,0 +1,209 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.data.GenderType +import org.junit.Test + +class KeepS3BodyCompositionTest { + @Test + fun firstCapture_matchesBundledNativeSdk() { + val result = calculate( + weightKg = 85.1f, + impedance50 = 506, + impedance100 = 478, + ) + + assertThat(result.bodyFatKg).isEqualTo(25.1f) + assertThat(result.bodyFatPercent).isEqualTo(29.5f) + assertThat(result.fatFreeMassKg).isEqualTo(60.0f) + assertThat(result.waterPercent).isEqualTo(50.2f) + assertThat(result.boneKg).isEqualTo(3.0f) + assertThat(result.muscleKg).isEqualTo(57.0f) + assertThat(result.musclePercent).isEqualTo(66.9f) + assertThat(result.skeletalMuscleKg).isEqualTo(32.7f) + assertThat(result.skeletalMusclePercent).isWithin(0.001f).of(38.42538f) + assertThat(result.subcutaneousFatKg).isEqualTo(21.8f) + assertThat(result.subcutaneousFatPercent).isEqualTo(25.7f) + assertThat(result.proteinPercent).isEqualTo(12.7f) + assertThat(result.visceralFatLevel).isEqualTo(12) + assertThat(result.basalMetabolicRateKcal).isEqualTo(1791) + assertThat(result.bodyAge).isEqualTo(28) + assertThat(result.bmi22ReferenceWeightKg).isEqualTo(62.0f) + } + + @Test + fun secondCapture_matchesBundledNativeSdk() { + val result = calculate( + weightKg = 85.0f, + impedance50 = 502, + impedance100 = 475, + ) + + assertThat(result.bodyFatKg).isEqualTo(24.9f) + assertThat(result.bodyFatPercent).isEqualTo(29.4f) + assertThat(result.fatFreeMassKg).isEqualTo(60.1f) + assertThat(result.waterPercent).isEqualTo(50.3f) + assertThat(result.boneKg).isEqualTo(3.0f) + assertThat(result.muscleKg).isEqualTo(57.1f) + assertThat(result.musclePercent).isEqualTo(67.1f) + assertThat(result.skeletalMuscleKg).isEqualTo(32.7f) + assertThat(result.subcutaneousFatKg).isEqualTo(21.6f) + assertThat(result.subcutaneousFatPercent).isEqualTo(25.5f) + assertThat(result.proteinPercent).isEqualTo(12.7f) + assertThat(result.visceralFatLevel).isEqualTo(12) + assertThat(result.basalMetabolicRateKcal).isEqualTo(1790) + assertThat(result.bodyAge).isEqualTo(28) + } + + @Test + fun femaleAthlete_matchesBundledNativeSdkBranches() { + val result = calculate( + gender = GenderType.FEMALE, + age = 42, + athlete = true, + weightKg = 85.1f, + impedance50 = 506, + impedance100 = 478, + ) + + assertThat(result.bodyFatKg).isEqualTo(32.6f) + assertThat(result.bodyFatPercent).isEqualTo(38.4f) + assertThat(result.fatFreeMassKg).isEqualTo(52.5f) + assertThat(result.waterPercent).isEqualTo(44.1f) + assertThat(result.boneKg).isEqualTo(3.1f) + assertThat(result.muscleKg).isEqualTo(49.4f) + assertThat(result.musclePercent).isEqualTo(58.0f) + assertThat(result.skeletalMuscleKg).isEqualTo(28.4f) + assertThat(result.subcutaneousFatKg).isEqualTo(24.5f) + assertThat(result.subcutaneousFatPercent).isEqualTo(28.9f) + assertThat(result.proteinPercent).isEqualTo(10.3f) + assertThat(result.visceralFatLevel).isEqualTo(7) + assertThat(result.basalMetabolicRateKcal).isEqualTo(1481) + assertThat(result.bodyAge).isEqualTo(44) + } + + @Test + fun invalidSdkInput_isRejected() { + assertThat( + KeepS3BodyComposition.calculate( + KeepS3BodyComposition.Input( + gender = GenderType.MALE, + age = 26, + heightCm = 168, + weightKg = 85.1f, + impedance50Ohm = 199, + impedance100Ohm = 478, + ), + ), + ).isNull() + } + + @Test + fun bmi22ReferenceWeight_usesHeightSquaredTimesPoint022() { + val result = calculate( + heightCm = 190, + weightKg = 85.1f, + impedance50 = 506, + impedance100 = 478, + ) + + // The SDK truncates the fixed-point value before converting tenths of a kg. + val expectedKg = (190 * 190 * 0.022f).toInt() / 10f + assertThat(result.bmi22ReferenceWeightKg).isEqualTo(expectedKg) + assertThat(result.bmi22ReferenceWeightKg).isEqualTo(79.4f) + } + + @Test + fun crossProfileBranches_matchBundledNativeSdk() { + assertComposition( + calculate(age = 30, heightCm = 190, weightKg = 60f, impedance50 = 506, impedance100 = 478), + Expected(3.0f, 5.0f, 57.0f, 65.1f, 2.9f, 54.1f, 90.1f, 29.7f, + 2.7f, 4.5f, 19.8f, 1, 1365, 27, 79.4f), + ) + assertComposition( + calculate(age = 30, heightCm = 180, athlete = true, weightKg = 70f, + impedance50 = 506, impedance100 = 478), + Expected(8.7f, 12.5f, 61.3f, 60.0f, 3.1f, 58.2f, 83.1f, 32.2f, + 6.4f, 9.2f, 18.2f, 6, 1615, 30, 71.2f), + ) + assertComposition( + calculate(gender = GenderType.FEMALE, age = 55, heightCm = 160, weightKg = 50f, + impedance50 = 506, impedance100 = 478), + Expected(11.4f, 22.8f, 38.6f, 52.9f, 1.9f, 36.7f, 73.4f, 19.2f, + 10.1f, 20.2f, 16.2f, 4, 970, 53, 56.3f), + ) + assertComposition( + calculate(gender = GenderType.FEMALE, age = 30, heightCm = 150, weightKg = 45f, + impedance50 = 506, impedance100 = 478), + Expected(10.8f, 24.2f, 34.2f, 51.9f, 1.8f, 32.4f, 72.0f, 16.6f, + 10.1f, 22.5f, 15.7f, 2, 1078, 29, 49.5f), + ) + } + + private fun calculate( + gender: GenderType = GenderType.MALE, + age: Int = 26, + heightCm: Int = 168, + athlete: Boolean = false, + weightKg: Float, + impedance50: Int, + impedance100: Int, + ): KeepS3BodyComposition.Result = checkNotNull( + KeepS3BodyComposition.calculate( + KeepS3BodyComposition.Input( + gender = gender, + age = age, + heightCm = heightCm, + weightKg = weightKg, + impedance50Ohm = impedance50, + impedance100Ohm = impedance100, + athlete = athlete, + ), + ), + ) + + private fun assertComposition(result: KeepS3BodyComposition.Result, expected: Expected) { + assertThat(result.bodyFatKg).isEqualTo(expected.fatKg) + assertThat(result.bodyFatPercent).isEqualTo(expected.fatPercent) + assertThat(result.fatFreeMassKg).isEqualTo(expected.ffmKg) + assertThat(result.waterPercent).isEqualTo(expected.waterPercent) + assertThat(result.boneKg).isEqualTo(expected.boneKg) + assertThat(result.muscleKg).isEqualTo(expected.muscleKg) + assertThat(result.musclePercent).isEqualTo(expected.musclePercent) + assertThat(result.skeletalMuscleKg).isEqualTo(expected.skeletalKg) + assertThat(result.subcutaneousFatKg).isEqualTo(expected.subcutaneousKg) + assertThat(result.subcutaneousFatPercent).isEqualTo(expected.subcutaneousPercent) + assertThat(result.proteinPercent).isEqualTo(expected.proteinPercent) + assertThat(result.visceralFatLevel).isEqualTo(expected.visceral) + assertThat(result.basalMetabolicRateKcal).isEqualTo(expected.bmr) + assertThat(result.bodyAge).isEqualTo(expected.bodyAge) + assertThat(result.bmi22ReferenceWeightKg).isEqualTo(expected.bmi22ReferenceWeightKg) + } + + private data class Expected( + val fatKg: Float, + val fatPercent: Float, + val ffmKg: Float, + val waterPercent: Float, + val boneKg: Float, + val muscleKg: Float, + val musclePercent: Float, + val skeletalKg: Float, + val subcutaneousKg: Float, + val subcutaneousPercent: Float, + val proteinPercent: Float, + val visceral: Int, + val bmr: Int, + val bodyAge: Int, + val bmi22ReferenceWeightKg: Float, + ) +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt new file mode 100644 index 000000000..15802a249 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/KeepS3HandlerTest.kt @@ -0,0 +1,848 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.KeepS3BodyComposition +import com.health.openscale.core.data.ActivityLevel +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Calendar +import java.util.Date +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.math.roundToInt + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class KeepS3HandlerTest { + private val service = uuid16(0x00FF) + private val notifyCharacteristic = uuid16(0xFF01) + private val writeCharacteristic = uuid16(0xFF02) + + @Test + fun `builds empty negotiate request`() { + assertThat(KeepS3Protocol.buildRequest(0x38)) + .isEqualTo(bytes("01 53 38 00 00")) + } + + @Test + fun `builds measurement event ack`() { + assertThat(KeepS3Protocol.buildAck(0x57)) + .isEqualTo(bytes("04 53 57 00 00 80")) + } + + @Test + fun `parses first capture-verified final measurement`() { + val event = KeepS3Protocol.parseMeasurementEvent( + bytes("03 53 57 00 08 29 42 7C 00 00 01 2D 6B"), + )!! + + assertThat(event.stage).isEqualTo(0x29) + assertThat(event.weightKg).isWithin(0.0001f).of(85.10f) + assertThat(event.impedanceOhm).isEqualTo(301) + assertThat(event.heartRateBpm).isEqualTo(107) + } + + @Test + fun `parses second capture-verified final measurement`() { + val event = KeepS3Protocol.parseMeasurementEvent( + bytes("03 53 57 00 08 29 42 68 00 00 01 2C 61"), + )!! + + assertThat(event.stage).isEqualTo(0x29) + assertThat(event.weightKg).isWithin(0.0001f).of(85.00f) + assertThat(event.impedanceOhm).isEqualTo(300) + assertThat(event.heartRateBpm).isEqualTo(97) + } + + @Test + fun `parses live weight without publishing`() { + val setup = attachedHandler() + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + val live = bytes("03 53 57 00 08 00 42 5E 00 00 00 00 00") + val parsed = KeepS3Protocol.parseMeasurementEvent(live)!! + setup.handler.handleNotification(notifyCharacteristic, live) + + assertThat(parsed.stage).isEqualTo(0x00) + assertThat(parsed.weightKg).isWithin(0.0001f).of(84.95f) + assertThat(setup.callbacks.published).isEmpty() + assertThat(setup.transport.writes).hasSize(1) + assertThat(setup.transport.writes.single().payload) + .isEqualTo(bytes("04 53 57 00 00 80")) + } + + @Test + fun `rejects truncated measurement event safely and still acknowledges known envelope`() { + val setup = attachedHandler() + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + val truncated = bytes("03 53 57 00 08 29 42 7C 00 00 01 2D") + + assertThat(KeepS3Protocol.parseMeasurementEvent(truncated)).isNull() + setup.handler.handleNotification(notifyCharacteristic, truncated) + + assertThat(setup.callbacks.published).isEmpty() + assertThat(setup.transport.writes).hasSize(1) + assertThat(setup.transport.writes.single().payload) + .isEqualTo(bytes("04 53 57 00 00 80")) + } + + @Test + fun `parses first capture final record and decodes dual frequency fields`() { + val record = KeepS3Protocol.parseFinalRecord( + finalRecord( + weightRaw = 17020, + encodedImpedance50 = 0x5E255A, + encodedImpedance100 = 0x2627DA, + phaseAngle50Raw = -76, + phaseAngle100Raw = -76, + heartRate = 107, + ), + )!! + + assertThat(record.weightKg).isWithin(0.0001f).of(85.10f) + assertThat(record.encodedImpedance50).isEqualTo(0x5E255A) + assertThat(record.encodedImpedance100).isEqualTo(0x2627DA) + assertThat(record.impedance50Ohm).isEqualTo(506) + assertThat(record.impedance100Ohm).isEqualTo(478) + assertThat(record.phaseAngle50Raw).isEqualTo(-76) + assertThat(record.phaseAngle100Raw).isEqualTo(-76) + assertThat(record.phaseAngle50Degrees!!).isWithin(0.0001f).of(7.6f) + assertThat(record.phaseAngle100Degrees!!).isWithin(0.0001f).of(7.6f) + assertThat(record.heartRateBpm).isEqualTo(107) + } + + @Test + fun `parses second capture final record and decodes dual frequency fields`() { + val record = KeepS3Protocol.parseFinalRecord( + finalRecord( + weightRaw = 17000, + encodedImpedance50 = 0x8227E5, + encodedImpedance100 = 0x6C66AC, + phaseAngle50Raw = -77, + phaseAngle100Raw = -77, + heartRate = 97, + ), + )!! + + assertThat(record.weightKg).isWithin(0.0001f).of(85.00f) + assertThat(record.impedance50Ohm).isEqualTo(502) + assertThat(record.impedance100Ohm).isEqualTo(475) + assertThat(record.phaseAngle50Degrees!!).isWithin(0.0001f).of(7.7f) + assertThat(record.phaseAngle100Degrees!!).isWithin(0.0001f).of(7.7f) + assertThat(record.heartRateBpm).isEqualTo(97) + } + + @Test + fun `rejects malformed final record and impedance sentinel safely`() { + assertThat(KeepS3Protocol.parseFinalRecord(ByteArray(65))).isNull() + assertThat(KeepS3Protocol.decodeEncodedImpedance(0xFFFFFF)).isNull() + } + + @Test + fun `final record falls back once and finishes once while every event is acknowledged`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + + // A non-final 0x57 can still provide the impedance used by the 0x58 fallback. + setup.handler.handleNotification( + notifyCharacteristic, + bytes("03 53 57 00 08 09 42 7C 00 00 01 2D 00"), + ) + val record = finalRecord( + weightRaw = 17020, + encodedImpedance50 = 0x5E255A, + encodedImpedance100 = 0x2627DA, + phaseAngle50Raw = -76, + phaseAngle100Raw = -76, + heartRate = 107, + ) + setup.handler.handleNotification(notifyCharacteristic, record) + setup.handler.handleNotification(notifyCharacteristic, record) + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(85.10f) + assertThat(setup.callbacks.published.single().impedance).isEqualTo(478.0) + assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(506.0) + assertThat(setup.callbacks.published.single().heartRate).isEqualTo(107) + assertThat(setup.callbacks.published.single().fat).isEqualTo(29.5f) + assertThat(setup.callbacks.published.single().water).isEqualTo(50.2f) + assertThat(setup.callbacks.published.single().muscle) + .isWithin(0.001f).of(38.42538f) + assertThat(setup.callbacks.published.single().visceralFat).isEqualTo(12f) + assertThat(setup.callbacks.published.single().protein).isEqualTo(12.7f) + assertThat(setup.callbacks.published.single().bone).isEqualTo(3.0f) + assertThat(setup.callbacks.published.single().lbm).isEqualTo(60.0f) + assertThat(setup.callbacks.published.single().bmr).isEqualTo(1791f) + + val storedDeviceImpedance = KeepS3Protocol.parseDeviceImpedance( + setup.settings.strings[KeepS3Protocol.deviceImpedanceSettingKey(setup.user.id)], + )!! + assertThat(storedDeviceImpedance.timestampSeconds).isEqualTo( + setup.callbacks.published.single().dateTime!!.time / 1000L, + ) + assertThat(storedDeviceImpedance.weightRaw).isEqualTo(17020) + assertThat(storedDeviceImpedance.impedanceOhm).isEqualTo(301) + + val payloads = setup.transport.writes.map { it.payload } + assertThat(payloads.count { it.contentEquals(KeepS3Protocol.buildAck(0x58)) }).isEqualTo(2) + assertThat(payloads.count { isControlRequest(it, start = false) }).isEqualTo(2) + + // The disconnect is held back for DISCONNECT_DELAY_MS so the queued ACKs and stop + // commands can drain first. + runCurrent() + assertThat(setup.transport.disconnectCount).isEqualTo(0) + + advanceTimeBy(5_999) + runCurrent() + assertThat(setup.transport.disconnectCount).isEqualTo(0) + + advanceTimeBy(1) + runCurrent() + assertThat(setup.transport.disconnectCount).isEqualTo(1) + } + + @Test + fun `final measurement waits briefly for final record and publishes enriched data once`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + val final = bytes("03 53 57 00 08 29 42 68 00 00 01 2C 61") + val record = finalRecord( + weightRaw = 17000, + encodedImpedance50 = 0x8227E5, + encodedImpedance100 = 0x6C66AC, + phaseAngle50Raw = -77, + phaseAngle100Raw = -77, + heartRate = 97, + ) + + setup.handler.handleNotification(notifyCharacteristic, final) + setup.handler.handleNotification(notifyCharacteristic, final) + assertThat(setup.callbacks.published).isEmpty() + + setup.handler.handleNotification(notifyCharacteristic, record) + setup.handler.handleNotification(notifyCharacteristic, record) + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().userId).isEqualTo(setup.user.id) + assertThat(setup.callbacks.published.single().impedance).isEqualTo(475.0) + assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(502.0) + assertThat(setup.callbacks.published.single().fat).isEqualTo(29.4f) + assertThat(setup.callbacks.published.single().water).isEqualTo(50.3f) + assertThat(setup.callbacks.published.single().muscle) + .isWithin(0.001f).of(38.47059f) + assertThat(setup.transport.writes.count { + it.payload.contentEquals(KeepS3Protocol.buildAck(0x57)) + }).isEqualTo(2) + assertThat(setup.transport.writes.count { + it.payload.contentEquals(KeepS3Protocol.buildAck(0x58)) + }).isEqualTo(2) + } + + @Test + fun `final measurement publishes once after timeout when final record is missing`() = runTest { + val setup = attachedHandler(scope = this) + setup.handler.handleConnected(setup.user) + setup.transport.clearWrites() + val final = bytes("03 53 57 00 08 29 42 68 00 00 01 2C 61") + + setup.handler.handleNotification(notifyCharacteristic, final) + setup.handler.handleNotification(notifyCharacteristic, final) + + assertThat(setup.callbacks.published).isEmpty() + advanceTimeBy(1_999) + runCurrent() + assertThat(setup.callbacks.published).isEmpty() + advanceTimeBy(1) + runCurrent() + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().weight).isWithin(0.0001f).of(85.00f) + assertThat(setup.callbacks.published.single().impedance).isEqualTo(0.0) + assertThat(setup.callbacks.published.single().impedanceLow).isEqualTo(0.0) + assertThat(setup.callbacks.published.single().heartRate).isEqualTo(97) + } + + @Test + fun `invalid user profile keeps verified result and omits composition safely`() = runTest { + val user = syntheticUser().apply { bodyHeight = Float.NaN } + val setup = attachedHandler(user = user, scope = this) + setup.handler.handleConnected(user) + setup.transport.clearWrites() + + setup.handler.handleNotification( + notifyCharacteristic, + finalRecord( + weightRaw = 17020, + encodedImpedance50 = 0x5E255A, + encodedImpedance100 = 0x2627DA, + phaseAngle50Raw = -76, + phaseAngle100Raw = -76, + heartRate = 107, + ), + ) + + assertThat(setup.callbacks.published).hasSize(1) + assertThat(setup.callbacks.published.single().weight).isEqualTo(85.1f) + assertThat(setup.callbacks.published.single().heartRate).isEqualTo(107) + assertThat(setup.callbacks.published.single().fat).isEqualTo(0f) + assertThat(setup.callbacks.published.single().water).isEqualTo(0f) + assertThat(setup.callbacks.published.single().lbm).isEqualTo(0f) + } + + @Test + fun `extreme activity level does not enable unverified Keep athlete mode`() = runTest { + val user = syntheticUser().apply { activityLevel = ActivityLevel.EXTREME } + val setup = attachedHandler(user = user, scope = this) + setup.handler.handleConnected(user) + setup.transport.clearWrites() + + setup.handler.handleNotification( + notifyCharacteristic, + finalRecord( + weightRaw = 17020, + encodedImpedance50 = 0x5E255A, + encodedImpedance100 = 0x2627DA, + phaseAngle50Raw = -76, + phaseAngle100Raw = -76, + heartRate = 107, + ), + ) + + val expected = KeepS3BodyComposition.calculate( + KeepS3BodyComposition.Input( + gender = user.gender, + age = user.age, + heightCm = user.bodyHeight.roundToInt(), + weightKg = 85.10f, + impedance50Ohm = 506, + impedance100Ohm = 478, + athlete = false, + ), + )!! + val actual = setup.callbacks.published.single() + assertThat(actual.fat).isEqualTo(expected.bodyFatPercent) + assertThat(actual.water).isEqualTo(expected.waterPercent) + assertThat(actual.muscle).isEqualTo(expected.skeletalMusclePercent) + assertThat(actual.bone).isEqualTo(expected.boneKg) + assertThat(actual.lbm).isEqualTo(expected.fatFreeMassKg) + } + + @Test + fun `builds profile payload with repeated token previous record and big endian fields`() { + val token = "0123456789abcdef01234567" + val payload = KeepS3Protocol.buildProfilePayload( + token = token, + previous = KeepS3Protocol.PreviousRecord( + weightKg = 85.10f, + timestampSeconds = 0x1234_5678L, + impedanceOhm = 301.0, + ), + heightCm = 168, + birthYear = 2000, + birthMonth = 5, + birthDay = 20, + ) + + assertThat(payload).hasLength(63) + assertThat(payload.copyOfRange(0, 24)).isEqualTo(token.encodeToByteArray()) + assertThat(payload.copyOfRange(24, 48)).isEqualTo(token.encodeToByteArray()) + assertThat(KeepS3Protocol.decodeU16BE(payload, 48)).isEqualTo(17020) + assertThat(KeepS3Protocol.decodeU32BE(payload, 50)).isEqualTo(0x1234_5678L) + assertThat(KeepS3Protocol.decodeU16BE(payload, 54)).isEqualTo(301) + assertThat(KeepS3Protocol.decodeU16BE(payload, 56)).isEqualTo(0) + assertThat(payload[58].toInt() and 0xFF).isEqualTo(168) + assertThat(KeepS3Protocol.decodeU16BE(payload, 59)).isEqualTo(2000) + assertThat(payload[61].toInt() and 0xFF).isEqualTo(5) + assertThat(payload[62].toInt() and 0xFF).isEqualTo(20) + } + + @Test + fun `missing previous measurement produces isolated all-zero record`() { + val payload = KeepS3Protocol.buildProfilePayload( + token = "0123456789abcdef01234567", + previous = null, + heightCm = 168, + birthYear = 2000, + birthMonth = 5, + birthDay = 20, + ) + + assertThat(payload.copyOfRange(48, 58)).isEqualTo(ByteArray(10)) + } + + @Test + fun `profile numeric fields clamp overflow`() { + val payload = KeepS3Protocol.buildProfilePayload( + token = "0123456789abcdef01234567", + previous = KeepS3Protocol.PreviousRecord( + weightKg = Float.MAX_VALUE, + timestampSeconds = Long.MAX_VALUE, + impedanceOhm = Double.MAX_VALUE, + ), + heightCm = Int.MAX_VALUE, + birthYear = Int.MAX_VALUE, + birthMonth = 99, + birthDay = 99, + ) + + assertThat(KeepS3Protocol.decodeU16BE(payload, 48)).isEqualTo(0xFFFF) + assertThat(KeepS3Protocol.decodeU32BE(payload, 50)).isEqualTo(0xFFFF_FFFFL) + assertThat(KeepS3Protocol.decodeU16BE(payload, 54)).isEqualTo(0xFFFF) + assertThat(payload[58].toInt() and 0xFF).isEqualTo(0xFF) + assertThat(KeepS3Protocol.decodeU16BE(payload, 59)).isEqualTo(0xFFFF) + assertThat(payload[61].toInt() and 0xFF).isEqualTo(12) + assertThat(payload[62].toInt() and 0xFF).isEqualTo(31) + } + + @Test + fun `builds start and stop control payloads`() { + val token = "0123456789abcdef01234567" + val start = KeepS3Protocol.buildMeasurementControl(token, start = true) + val stop = KeepS3Protocol.buildMeasurementControl(token, start = false) + + assertThat(start).hasLength(50) + assertThat(start.copyOfRange(0, 48)).isEqualTo((token + token).encodeToByteArray()) + assertThat(start.copyOfRange(48, 50)).isEqualTo(byteArrayOf(0x00, 0x01)) + assertThat(stop.copyOfRange(48, 50)).isEqualTo(byteArrayOf(0x00, 0x00)) + } + + @Test + fun `validates and deterministically formats token bytes`() { + val token = KeepS3Protocol.generateToken(ByteArray(12) { it.toByte() }) + + assertThat(token).isEqualTo("000102030405060708090a0b") + assertThat(KeepS3Protocol.validateToken(token)).isTrue() + assertThat(KeepS3Protocol.validateToken(token.uppercase())).isFalse() + assertThat(KeepS3Protocol.repeatedTokenBytes(token)).hasLength(48) + } + + @Test + fun `persisted device impedance is accepted only for its source measurement`() { + val encoded = KeepS3Protocol.serializeDeviceImpedance( + timestampSeconds = 0x1234_5678L, + weightKg = 85.10f, + impedanceOhm = 301, + )!! + val stored = KeepS3Protocol.parseDeviceImpedance(encoded)!! + + assertThat(stored.timestampSeconds).isEqualTo(0x1234_5678L) + assertThat(stored.weightRaw).isEqualTo(17020) + assertThat(stored.impedanceOhm).isEqualTo(301) + assertThat(KeepS3Protocol.deviceImpedanceMatches(stored, 0x1234_5678L, 85.10f)).isTrue() + assertThat(KeepS3Protocol.deviceImpedanceMatches(stored, 0x1234_5679L, 85.10f)).isFalse() + assertThat(KeepS3Protocol.deviceImpedanceMatches(stored, 0x1234_5678L, 85.00f)).isFalse() + assertThat(KeepS3Protocol.serializeDeviceImpedance(0L, 85.10f, 301)).isNull() + assertThat(KeepS3Protocol.serializeDeviceImpedance(0x1234_5678L, 0f, 301)).isNull() + assertThat(KeepS3Protocol.parseDeviceImpedance("invalid")).isNull() + } + + @Test + fun `generated token persists and reloads for the same user`() { + val settings = InMemorySettings() + val user = syntheticUser() + val first = attachedHandler(user = user, settings = settings) + first.handler.handleConnected(user) + val key = KeepS3Protocol.tokenSettingKey(user.id) + val generated = settings.strings[key]!! + + val second = attachedHandler(user = user, settings = settings) + second.handler.handleConnected(user) + + assertThat(KeepS3Protocol.validateToken(generated)).isTrue() + assertThat(settings.strings[key]).isEqualTo(generated) + } + + @Test + fun `state machine advances only on expected successful opcode and requires both F5 responses`() { + val state = KeepS3InitStateMachine() + assertThat(state.reset()).isEqualTo(KeepS3InitStep.NEGOTIATE) + assertThat(state.acceptSuccessfulResponse(0x0A)).isNull() + assertThat(state.expectedStep).isEqualTo(KeepS3InitStep.NEGOTIATE) + + assertThat(state.acceptSuccessfulResponse(0x38)).isEqualTo(KeepS3InitStep.READ_TIME) + assertThat(state.acceptSuccessfulResponse(0x0A)).isEqualTo(KeepS3InitStep.SET_TIME) + assertThat(state.acceptSuccessfulResponse(0x01)).isEqualTo(KeepS3InitStep.SET_UNIT) + assertThat(state.acceptSuccessfulResponse(0x05)).isEqualTo(KeepS3InitStep.DEVICE_INFO) + assertThat(state.acceptSuccessfulResponse(0xE7)).isEqualTo(KeepS3InitStep.UNKNOWN_F5_FIRST) + assertThat(state.acceptSuccessfulResponse(0xF5)).isEqualTo(KeepS3InitStep.UNKNOWN_F5_SECOND) + assertThat(state.expectedStep).isEqualTo(KeepS3InitStep.UNKNOWN_F5_SECOND) + assertThat(state.acceptSuccessfulResponse(0x03)).isNull() + assertThat(state.expectedStep).isEqualTo(KeepS3InitStep.UNKNOWN_F5_SECOND) + assertThat(state.acceptSuccessfulResponse(0xF5)).isEqualTo(KeepS3InitStep.BATTERY) + } + + @Test + fun `handler sends response-driven initialization exactly once`() { + val setup = attachedHandler( + settings = InMemorySettings().apply { + putString(KeepS3Protocol.tokenSettingKey(7), "0123456789abcdef01234567") + }, + ) + setup.handler.handleConnected(setup.user) + + assertThat(setup.transport.notifications) + .containsExactly(service to notifyCharacteristic) + assertThat(requestOpcodes(setup.transport)).containsExactly(0x38).inOrder() + + setup.handler.handleNotification(notifyCharacteristic, response(0x22)) + setup.handler.handleNotification(notifyCharacteristic, response(0x38)) + setup.handler.handleNotification(notifyCharacteristic, response(0x38)) // duplicate old response + setup.handler.handleNotification(notifyCharacteristic, response(0x0A, bytes("12 34 56 78"))) + setup.handler.handleNotification(notifyCharacteristic, response(0x01)) + setup.handler.handleNotification(notifyCharacteristic, response(0x05)) + setup.handler.handleNotification(notifyCharacteristic, response(0xE7)) + setup.handler.handleNotification(notifyCharacteristic, response(0xF5, byteArrayOf(0x01))) + setup.handler.handleNotification(notifyCharacteristic, response(0xF5, byteArrayOf(0x01))) + setup.handler.handleNotification(notifyCharacteristic, response(0x03, byteArrayOf(95))) + setup.handler.handleNotification(notifyCharacteristic, response(0x20)) + setup.handler.handleNotification(notifyCharacteristic, response(0x32, byteArrayOf(0x00))) + setup.handler.handleNotification(notifyCharacteristic, response(0x32, byteArrayOf(0x00))) // duplicate old response + setup.handler.handleNotification(notifyCharacteristic, response(0x36)) + setup.handler.handleNotification(notifyCharacteristic, response(0x36)) // duplicate after completion + + assertThat(requestOpcodes(setup.transport)).containsExactly( + 0x38, 0x0A, 0x01, 0x05, 0xE7, 0xF5, 0xF5, 0x03, 0x20, 0x32, 0x36, + ).inOrder() + assertThat(setup.transport.writes.all { it.withResponse }).isTrue() + assertThat(setup.transport.writes.all { + it.service == service && it.characteristic == writeCharacteristic + }).isTrue() + val timeRequest = setup.transport.writes.first { requestOpcode(it.payload) == 0x01 }.payload + assertThat(timeRequest.copyOfRange(5, 9)).isEqualTo(bytes("12 34 56 78")) + } + + @Test + fun `profile carries the persisted protocol impedance instead of either frequency band`() = runTest { + val settings = InMemorySettings() + val first = attachedHandler(previous = null, settings = settings, scope = this) + first.handler.handleConnected(first.user) + first.handler.handleNotification( + notifyCharacteristic, + bytes("03 53 57 00 08 29 42 68 00 00 01 2C 61"), + ) + first.handler.handleNotification( + notifyCharacteristic, + finalRecord( + weightRaw = 17000, + encodedImpedance50 = 0x8227E5, + encodedImpedance100 = 0x6C66AC, + phaseAngle50Raw = -77, + phaseAngle100Raw = -77, + heartRate = 97, + ), + ) + val previous = first.callbacks.published.single() + assertThat(previous.impedance).isEqualTo(475.0) + assertThat(previous.impedanceLow).isEqualTo(502.0) + + val setup = attachedHandler(previous = previous, settings = settings, scope = this) + + driveInitializationThroughProfile(setup) + + val profileRequest = setup.transport.writes.single { requestOpcode(it.payload) == 0x32 }.payload + assertThat(KeepS3Protocol.decodeU16BE(profileRequest, 5 + 48)).isEqualTo(17000) + assertThat(KeepS3Protocol.decodeU32BE(profileRequest, 5 + 50)).isEqualTo( + previous.dateTime!!.time / 1000L, + ) + assertThat(KeepS3Protocol.decodeU16BE(profileRequest, 5 + 54)).isEqualTo(300) + } + + @Test + fun `profile uses all-zero record when persisted protocol impedance does not match`() { + val previous = ScaleMeasurement( + userId = 7, + dateTime = Date(0x1234_5678L * 1000L), + weight = 85.10f, + impedance = 478.0, + impedanceLow = 506.0, + ) + val settings = InMemorySettings().apply { + putString( + KeepS3Protocol.deviceImpedanceSettingKey(previous.userId), + KeepS3Protocol.serializeDeviceImpedance( + timestampSeconds = 0x1234_5677L, + weightKg = 85.10f, + impedanceOhm = 301, + )!!, + ) + } + val setup = attachedHandler(previous = previous, settings = settings) + + driveInitializationThroughProfile(setup) + + val profileRequest = setup.transport.writes.single { requestOpcode(it.payload) == 0x32 }.payload + assertThat(profileRequest.copyOfRange(5 + 48, 5 + 58)).isEqualTo(ByteArray(10)) + } + + @Test + fun `failed response status does not advance initialization`() { + val setup = attachedHandler() + setup.handler.handleConnected(setup.user) + + setup.handler.handleNotification( + notifyCharacteristic, + bytes("02 53 38 00 00 81"), + ) + + assertThat(requestOpcodes(setup.transport)).containsExactly(0x38) + } + + @Test + fun `matches exact Keep S3 name without requiring advertised service`() { + val handler = KeepS3Handler() + val support = handler.supportFor(device("keep_s3"))!! + + assertThat(support.displayName).isEqualTo("Keep S3") + assertThat(support.implemented).containsExactly( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.UNIT_CONFIG, + DeviceCapability.BODY_COMPOSITION, + ) + assertThat(support.capabilities).contains(DeviceCapability.BODY_COMPOSITION) + assertThat(support.implemented).doesNotContain(DeviceCapability.BATTERY_LEVEL) + assertThat(handler.supportFor(device("Keep_S3", 0x00FF))).isNotNull() + assertThat(handler.supportFor(device("Keep_S3_extra", 0x00FF))).isNull() + assertThat(handler.supportFor(device("Other", 0x00FF, 0xFFF0))).isNull() + } + + private fun attachedHandler( + user: ScaleUser = syntheticUser(), + settings: InMemorySettings = InMemorySettings().apply { + putString(KeepS3Protocol.tokenSettingKey(user.id), "0123456789abcdef01234567") + }, + scope: CoroutineScope = CoroutineScope(EmptyCoroutineContext), + previous: ScaleMeasurement? = ScaleMeasurement( + userId = user.id, + dateTime = Date(0x1234_5678L * 1000L), + weight = 85.10f, + impedance = 301.0, + ), + ): Setup { + val handler = KeepS3Handler() + val transport = CapturingTransport() + val callbacks = CapturingCallbacks() + handler.attach( + transport = transport, + callbacks = callbacks, + settings = settings, + data = FixedDataProvider(user, previous), + scope = scope, + ) + return Setup(handler, transport, callbacks, settings, user) + } + + private fun syntheticUser(): ScaleUser { + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + val birthday = Calendar.getInstance().apply { + clear() + set(currentYear - 26, Calendar.JANUARY, 1) + }.time + return ScaleUser(id = 7, birthday = birthday, bodyHeight = 168f) + } + + private fun response(opcode: Int, data: ByteArray = byteArrayOf()): ByteArray = + byteArrayOf( + KeepS3Protocol.FRAME_RESPONSE.toByte(), + KeepS3Protocol.MAGIC.toByte(), + opcode.toByte(), + 0x00, + data.size.toByte(), + KeepS3Protocol.STATUS_OK.toByte(), + ) + data + + private fun driveInitializationThroughProfile(setup: Setup) { + setup.handler.handleConnected(setup.user) + setup.handler.handleNotification(notifyCharacteristic, response(0x38)) + setup.handler.handleNotification(notifyCharacteristic, response(0x0A, bytes("12 34 56 78"))) + setup.handler.handleNotification(notifyCharacteristic, response(0x01)) + setup.handler.handleNotification(notifyCharacteristic, response(0x05)) + setup.handler.handleNotification(notifyCharacteristic, response(0xE7)) + setup.handler.handleNotification(notifyCharacteristic, response(0xF5, byteArrayOf(0x01))) + setup.handler.handleNotification(notifyCharacteristic, response(0xF5, byteArrayOf(0x01))) + setup.handler.handleNotification(notifyCharacteristic, response(0x03, byteArrayOf(95))) + setup.handler.handleNotification(notifyCharacteristic, response(0x20)) + } + + private fun finalRecord( + weightRaw: Int, + encodedImpedance50: Int = 0, + encodedImpedance100: Int = 0, + phaseAngle50Raw: Int = 0, + phaseAngle100Raw: Int = 0, + heartRate: Int, + ): ByteArray { + val frame = ByteArray(66) + frame[0] = KeepS3Protocol.FRAME_EVENT.toByte() + frame[1] = KeepS3Protocol.MAGIC.toByte() + frame[2] = KeepS3Protocol.OP_FINAL_RECORD.toByte() + frame[3] = 0x00 + frame[4] = 61 + val tokenPair = KeepS3Protocol.repeatedTokenBytes("0123456789abcdef01234567") + tokenPair.copyInto(frame, destinationOffset = 5) + KeepS3Protocol.encodeU16BE(frame, 53, weightRaw) + encodeU24BE(frame, 55, encodedImpedance50) + encodeU24BE(frame, 58, encodedImpedance100) + KeepS3Protocol.encodeU16BE(frame, 61, phaseAngle50Raw and 0xFFFF) + KeepS3Protocol.encodeU16BE(frame, 63, phaseAngle100Raw and 0xFFFF) + frame[65] = heartRate.toByte() + return frame + } + + private fun encodeU24BE(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value ushr 16).toByte() + target[offset + 1] = (value ushr 8).toByte() + target[offset + 2] = value.toByte() + } + + private fun requestOpcodes(transport: CapturingTransport): List = + transport.writes.mapNotNull { requestOpcode(it.payload) } + + private fun requestOpcode(payload: ByteArray): Int? = + if (payload.size >= 5 && (payload[0].toInt() and 0xFF) == KeepS3Protocol.FRAME_REQUEST) { + payload[2].toInt() and 0xFF + } else { + null + } + + private fun isControlRequest(payload: ByteArray, start: Boolean): Boolean = + requestOpcode(payload) == KeepS3Protocol.OP_MEASUREMENT_CONTROL && + payload.size == 55 && + payload[53] == 0x00.toByte() && + payload[54] == (if (start) 0x01 else 0x00).toByte() + + private fun device(name: String, vararg services: Int) = ScannedDeviceInfo( + name = name, + address = "00:11:22:33:44:55", + rssi = -50, + serviceUuids = services.map(::uuid16), + manufacturerData = null, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun bytes(hex: String): ByteArray = hex + .trim() + .split(Regex("\\s+")) + .filter(String::isNotEmpty) + .map { it.toInt(16).toByte() } + .toByteArray() + + private data class Setup( + val handler: KeepS3Handler, + val transport: CapturingTransport, + val callbacks: CapturingCallbacks, + val settings: InMemorySettings, + val user: ScaleUser, + ) + + private data class Write( + val service: UUID, + val characteristic: UUID, + val payload: ByteArray, + val withResponse: Boolean, + ) + + private class CapturingTransport : ScaleDeviceHandler.Transport { + val notifications = mutableListOf>() + val writes = mutableListOf() + var disconnectCount = 0 + + override fun setNotifyOn(service: UUID, characteristic: UUID) { + notifications += service to characteristic + } + + override fun write( + service: UUID, + characteristic: UUID, + payload: ByteArray, + withResponse: Boolean, + ) { + writes += Write(service, characteristic, payload.copyOf(), withResponse) + } + + override fun read(service: UUID, characteristic: UUID) = Unit + + override fun disconnect() { + disconnectCount++ + } + + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = true + + fun clearWrites() = writes.clear() + } + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + + override fun onPublish(measurement: ScaleMeasurement) { + published += measurement.copy() + } + + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + val strings = mutableMapOf() + private val ints = mutableMapOf() + + override fun getInt(key: String, default: Int): Int = ints[key] ?: default + override fun putInt(key: String, value: Int) { + ints[key] = value + } + + override fun getString(key: String, default: String?): String? = strings[key] ?: default + override fun putString(key: String, value: String) { + strings[key] = value + } + + override fun remove(key: String) { + strings.remove(key) + ints.remove(key) + } + } + + private class FixedDataProvider( + private val user: ScaleUser, + private val previous: ScaleMeasurement?, + ) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = listOf(user) + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = + previous?.takeIf { userId == user.id } + } +}