diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt index e7ef37e15..9ea96d302 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/OneByoneHandler.kt @@ -25,6 +25,9 @@ import com.health.openscale.core.bluetooth.libs.OneByoneLib import com.health.openscale.core.data.GenderType import com.health.openscale.core.data.WeightUnit import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.util.Calendar import java.util.UUID import kotlin.math.max @@ -61,10 +64,12 @@ class OneByoneHandler : ScaleDeviceHandler() { private var waitAckClock = false // true after sending F1 until we receive "F1 00" private var historicMode = false // true while reading history (F2 00 .. F2 00) private var historyCount = 0 // number of historic measurements seen + private var clockAckFallbackJob: Job? = null + private var promptedForMeasurement = false - // prevent saving measurements too close in time (ms) - private val DATE_TIME_THRESHOLD_MS = 3000 - private var lastSavedAt: Long = 0L + // One live measurement per connection: the scale repeats its settled frame, immediately as a + // duplicate and again a few seconds later once its bioimpedance retry has run. + private var publishedLive = false // --- Capability declaration ----------------------------------------------- @@ -114,6 +119,56 @@ class OneByoneHandler : ScaleDeviceHandler() { readFrom(SVC_180F, CHR_2A19) // NOTE: After we receive the ACK, we will request history (F2 00) in onNotification(). + // Not every scale in this family answers F1 (the 1byone "Health Scale" never does), so + // don't let the whole session hang on an ACK that may never come. + armClockAckFallback() + } + + /** + * Prompt for a live measurement if the `F1 00` clock ACK does not arrive. + * + * Both the history request and the "step on the scale" prompt hang off that ACK, but not every + * scale in this family sends one -- the 1byone "Health Scale" never does, and the vendor app + * never even sends `F1` on that model. Without this fallback such a scale produces a fully + * working connection with no user-visible feedback at all, which is indistinguishable from a + * failed one. + * + * [waitAckClock] is deliberately left set: a late ACK should still start the history read. + * Only the prompt is forced, and [promptedForMeasurement] keeps it to one per connection. + */ + private fun armClockAckFallback() { + clockAckFallbackJob?.cancel() + clockAckFallbackJob = scope.launch { + delay(CLOCK_ACK_TIMEOUT_MS) + if (!waitAckClock) return@launch + + logI("No F1 clock ACK after ${CLOCK_ACK_TIMEOUT_MS}ms - prompting for a live measurement") + promptForMeasurement() + } + } + + /** Show the "step on the scale" prompt at most once per connection. */ + private fun promptForMeasurement() { + if (promptedForMeasurement) return + promptedForMeasurement = true + userInfo(R.string.bt_info_step_on_scale) + } + + /** Stop the pending prompt: it is no longer useful, or never was. */ + private fun suppressStepOnPrompt() { + clockAckFallbackJob?.cancel() + clockAckFallbackJob = null + promptedForMeasurement = true + } + + override fun onDisconnected() { + clockAckFallbackJob?.cancel() + clockAckFallbackJob = null + waitAckClock = false + historicMode = false + historyCount = 0 + promptedForMeasurement = false + publishedLive = false } override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { @@ -136,6 +191,7 @@ class OneByoneHandler : ScaleDeviceHandler() { when { // Clock ACK: proceed to request history waitAckClock && data[0] == 0xF1.toByte() && data[1] == 0x00.toByte() -> { + clockAckFallbackJob?.cancel() waitAckClock = false historicMode = true historyCount = 0 @@ -151,7 +207,7 @@ class OneByoneHandler : ScaleDeviceHandler() { writeTo(SVC_FFF0, CHR_FFF1, byteArrayOf(0xF2.toByte(), 0x01.toByte())) // clear history } // Prompt user for a live measurement - userInfo(R.string.bt_info_step_on_scale) + promptForMeasurement() } return } @@ -160,6 +216,11 @@ class OneByoneHandler : ScaleDeviceHandler() { // CF ... frames carry weight/impedance (+ optional timestamp if length >= 18) if (data.isNotEmpty() && data[0] == 0xCF.toByte() && data.size >= 11) { + // A live measurement frame means the user is already standing on the scale, so the + // "step on the scale" fallback would arrive too late to be anything but confusing. + // History frames prove nothing about the here and now — the prompt after the transfer + // is exactly what the user needs then, so leave it armed. + if (!historicMode) suppressStepOnPrompt() if (historicMode) historyCount++ parseMeasurementFrame(data, user, isHistoric = historicMode) } else { @@ -180,11 +241,30 @@ class OneByoneHandler : ScaleDeviceHandler() { // A flag in b9 == 1 means "impedance not present" (legacy observation) val impedancePresent = (bytes[9].toInt() != 1) && (impedanceOhm != 0f) - // Historic entries include timestamp (length >= 18) - val hasTimestamp = bytes.size >= 18 + // Historic entries include a timestamp at bytes 11..17 (length >= 18) + val hasTimestamp = hasHistoryTimestamp(bytes) - // Discard unwanted frames: history without time, or anything without impedance - if (!impedancePresent || (isHistoric && !hasTimestamp)) return + // A history entry without its timestamp cannot be placed on the graph, so drop it. + if (isHistoric && !hasTimestamp) return + + // Only record settled readings. Byte 9 is the lock status: 0x00 and 0x36 mean the scale has + // finished weighing, anything else is still in progress. Historic entries are settled by + // definition, so the gate applies to live frames only. + if (!isHistoric && !isFinalReading(bytes)) { + logD("Ignoring in-progress frame (status=0x%02X, %.2f kg)" + .format(bytes[9].toInt() and 0xFF, weightKg)) + return + } + + // Frames with no usable weight carry nothing worth saving. + if (weightKg <= 0f) return + + // The settled reading is sent more than once: as an immediate duplicate, and again after + // the scale has retried its bioimpedance measurement. Record the first one only. + if (!isHistoric && publishedLive) { + logD("Live measurement already published this session, ignoring repeat") + return + } // Timestamp (BE year + plain month/day/time), used when provided val whenCal = Calendar.getInstance() @@ -206,11 +286,6 @@ class OneByoneHandler : ScaleDeviceHandler() { } } - // Rate-limit saves (avoid too-dense series) - val nowMs = max(System.currentTimeMillis(), whenCal.timeInMillis) - if (nowMs - lastSavedAt < DATE_TIME_THRESHOLD_MS) return - lastSavedAt = nowMs - // Build composition using OneByoneLib (same as legacy) val (sex, peopleType) = mapUserToLibParams(user) val lib = OneByoneLib(sex, user.age, user.bodyHeight, peopleType) @@ -220,24 +295,34 @@ class OneByoneHandler : ScaleDeviceHandler() { dateTime = if (hasTimestamp) whenCal.time else Calendar.getInstance().time this[MeasurementType.WEIGHT] = Kg(weightKg) // Store the raw impedance so body composition can be recomputed later. - this[MeasurementType.IMPEDANCE] = Ohm(impedanceOhm.toFloat()) + if (impedancePresent) impedance = impedanceOhm.toDouble() } - try { - // Derivations - val fatPct = lib.getBodyFat((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm) - m[MeasurementType.BODY_FAT] = Percent(fatPct) - m[MeasurementType.WATER] = Percent(lib.getWater(fatPct)) - m[MeasurementType.BONE] = Kg(lib.getBoneMass((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) - m[MeasurementType.VISCERAL_FAT] = lib.getVisceralFat((m[MeasurementType.WEIGHT]?.value ?: 0f)) - m[MeasurementType.MUSCLE] = Percent(lib.getMuscle((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) - m[MeasurementType.LBM] = Kg(lib.getLBM((m[MeasurementType.WEIGHT]?.value ?: 0f), (m[MeasurementType.BODY_FAT]?.value ?: 0f))) - - publish(m) - } catch (t: Throwable) { - // If library throws on impossible inputs, just log & ignore this frame - logW("OneByoneLib failed: ${t.message}") + // Body composition needs impedance. The scale reports zero when it could not run the + // bioimpedance measurement (socks or shoes, poor foot contact), and the weight is still + // perfectly good — record it rather than losing the weigh-in entirely. + if (impedancePresent) { + try { + val fatPct = lib.getBodyFat((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm) + m[MeasurementType.BODY_FAT] = Percent(fatPct) + m[MeasurementType.WATER] = Percent(lib.getWater(fatPct)) + m[MeasurementType.BONE] = Kg(lib.getBoneMass((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) + m[MeasurementType.VISCERAL_FAT] = lib.getVisceralFat((m[MeasurementType.WEIGHT]?.value ?: 0f)) + m[MeasurementType.MUSCLE] = Percent(lib.getMuscle((m[MeasurementType.WEIGHT]?.value ?: 0f), impedanceOhm)) + m[MeasurementType.LBM] = Kg(lib.getLBM((m[MeasurementType.WEIGHT]?.value ?: 0f), (m[MeasurementType.BODY_FAT]?.value ?: 0f))) + } catch (t: Throwable) { + // If the library throws on impossible inputs, keep the weight and drop the rest. + logW("OneByoneLib failed, publishing weight only: ${t.message}") + } + } else { + // No user-facing notice here on purpose: a snackbar emitted at this point is dismissed + // by BleConnector's saved-measurement snackbar ~700 ms later, so it never really shows. + logI("No impedance in frame - publishing weight only (%.2f kg)".format(weightKg)) + this[MeasurementType.IMPEDANCE] = Ohm(impedanceOhm.toFloat()) } + + if (!isHistoric) publishedLive = true + publish(m) } // --- Command builders ------------------------------------------------------ @@ -277,10 +362,68 @@ class OneByoneHandler : ScaleDeviceHandler() { // --- Helpers --------------------------------------------------------------- - private fun xorChecksum(b: ByteArray, len: Int): Byte { - var x = 0 - for (i in 0 until len) x = x xor (b[i].toInt() and 0xFF) - return (x and 0xFF).toByte() + companion object { + /** + * Grace period for the `F1 00` clock ACK before prompting anyway. + * + * Generous on purpose: the `F1` write itself only leaves the queue ~600 ms after connect + * (notify setup and the `FD 37` write are paced ahead of it), so a tight timeout would fire + * before a scale that does ACK had a fair chance to answer. + */ + private const val CLOCK_ACK_TIMEOUT_MS = 3000L + + + /** Length of a live measurement frame: `CF …` payload plus the XOR byte at index 10. */ + const val LIVE_FRAME_LEN = 11 + + /** Frame type marker for a body-fat measurement. */ + private const val TYPE_BODY_FAT = 0xCF.toByte() + + fun xorChecksum(b: ByteArray, len: Int): Byte { + var x = 0 + for (i in 0 until len) x = x xor (b[i].toInt() and 0xFF) + return (x and 0xFF).toByte() + } + + /** + * True when [bytes] begins with a complete live measurement frame, i.e. the XOR checksum + * at byte 10 covers bytes 0..9. + */ + fun isLiveFrame(bytes: ByteArray): Boolean = + bytes.size >= LIVE_FRAME_LEN && bytes[10] == xorChecksum(bytes, 10) + + /** + * True when byte 9 marks the reading as settled ("locked" in the vendor app, which treats + * 0x00 and 0x36 as final and everything else as still in progress). + */ + fun isFinalReading(bytes: ByteArray): Boolean { + if (bytes.size < LIVE_FRAME_LEN) return false + return when (bytes[9].toInt() and 0xFF) { + 0x00, 0x36 -> true + else -> false + } + } + + /** + * True when [bytes] carries a history timestamp in bytes 11..17. + * + * Length alone is not enough to decide this. The scale sends its final measurement twice, + * and the two copies can arrive coalesced into one notification -- the ATT payload caps at + * 20 bytes, so the buffer is a whole 11-byte frame followed by the first 9 bytes of its + * duplicate. That is >= 18 bytes but is not history, and reading bytes 11..17 as a + * timestamp yields garbage (year 53138, day 156, hour 39) that gets the reading discarded. + * + * A genuine history frame stores the year at bytes 11..12, so its byte 11 is the year's + * high byte (0x07 for 2026) and its byte 10 is measurement data rather than a checksum + * over bytes 0..9. Requiring both a valid live-frame checksum *and* a second frame marker + * at byte 11 separates the two cases without disturbing history reads on the Eufy models + * that share this handler. + */ + fun hasHistoryTimestamp(bytes: ByteArray): Boolean { + if (bytes.size < 18) return false + val isCoalescedDuplicate = isLiveFrame(bytes) && bytes[11] == TYPE_BODY_FAT + return !isCoalescedDuplicate + } } private fun mapUserToLibParams(u: ScaleUser): Pair { diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/OneByoneHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/OneByoneHandlerTest.kt new file mode 100644 index 000000000..105f2c3e1 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/OneByoneHandlerTest.kt @@ -0,0 +1,172 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [OneByoneHandler] frame framing decisions. + * + * Vectors are taken verbatim from openScale session logs of a 1byone "Health Scale". The scale + * sends its final measurement twice: sometimes as two separate 11-byte notifications, and sometimes + * coalesced into a single 20-byte one, because the ATT payload caps at 20 bytes. The coalesced form + * used to be misread as a history frame and silently discarded. + */ +class OneByoneHandlerTest { + + private fun hex(s: String): ByteArray = + s.filterNot { it.isWhitespace() }.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + /** Separate 11-byte frames — these were accepted even before the fix. */ + private val single1016 = hex("CF 50 0F B0 27 10 13 51 00 00 55") + private val single1014 = hex("CF 60 0E 9C 27 04 02 F0 00 00 EC") + + /** Coalesced frame + truncated duplicate — each of these was silently dropped. */ + private val coalesced1016 = hex("CF 32 0F B0 27 FD F7 0D 00 00 62 CF 32 0F B0 27 FD F7 0D 00") + private val coalesced1014a = hex("CF 6A 0E 9C 27 60 64 68 00 00 7C CF 6A 0E 9C 27 60 64 68 00") + private val coalesced1014b = hex("CF 92 0E 9C 27 1D 13 5F 00 00 B9 CF 92 0E 9C 27 1D 13 5F 00") + + /** A coalesced frame captured after the fix, which parsed and published correctly. */ + private val coalesced1027 = hex("CF 76 0C 1E 28 DF D6 07 01 00 8C CF 76 0C 1E 28 DF D6 07 01") + + private val allCoalesced = listOf(coalesced1016, coalesced1014a, coalesced1014b, coalesced1027) + + private fun weightKg(b: ByteArray): Float = + ((b[3].toInt() and 0xFF) or ((b[4].toInt() and 0xFF) shl 8)) / 100.0f + + private fun impedanceOhm(b: ByteArray): Float = + (((b[2].toInt() and 0xFF) shl 8) + (b[1].toInt() and 0xFF)) * 0.1f + + @Test + fun `all captured frames carry a valid XOR checksum`() { + for (frame in listOf(single1016, single1014) + allCoalesced) { + assertThat(OneByoneHandler.isLiveFrame(frame)).isTrue() + } + } + + @Test + fun `coalesced duplicate frames are not mistaken for history`() { + // Each is >= 18 bytes, so the old `size >= 18` rule read bytes 11..17 as a timestamp, + // produced an impossible date, and the non-lenient Calendar threw the reading away. + for (frame in allCoalesced) { + assertThat(frame.size).isAtLeast(18) + assertThat(OneByoneHandler.hasHistoryTimestamp(frame)).isFalse() + } + } + + @Test + fun `coalesced frames decode to the same weight as the retry that succeeded`() { + // The user reweighed after each drop; these are the weights openScale did record. + assertThat(weightKg(coalesced1016)).isWithin(1e-3f).of(101.60f) + assertThat(weightKg(coalesced1014a)).isWithin(1e-3f).of(101.40f) + assertThat(weightKg(coalesced1014b)).isWithin(1e-3f).of(101.40f) + + assertThat(weightKg(coalesced1016)).isEqualTo(weightKg(single1016)) + assertThat(weightKg(coalesced1014a)).isEqualTo(weightKg(single1014)) + } + + @Test + fun `a coalesced frame still yields its impedance`() { + // Captured on hardware after the fix: this frame published full body composition. + assertThat(weightKg(coalesced1027)).isWithin(1e-3f).of(102.70f) + assertThat(impedanceOhm(coalesced1027)).isWithin(1e-3f).of(319.0f) + } + + @Test + fun `single frames are below the history length threshold`() { + assertThat(OneByoneHandler.hasHistoryTimestamp(single1016)).isFalse() + assertThat(OneByoneHandler.hasHistoryTimestamp(single1014)).isFalse() + } + + @Test + fun `a genuine history frame is still treated as history`() { + // 18-byte historic entry: 11-byte body then year 2026 (07 EA), 08-12, 09:11:43. + // Byte 10 is measurement data here, not a checksum over bytes 0..9. + val historic = hex("CF 60 0E 9C 27 04 02 F0 00 00 11 07 EA 08 0C 09 0B 2B") + assertThat(historic.size).isAtLeast(18) + assertThat(OneByoneHandler.isLiveFrame(historic)).isFalse() + assertThat(OneByoneHandler.hasHistoryTimestamp(historic)).isTrue() + } + + @Test + fun `history detection needs a frame marker at byte 11 not just a valid checksum`() { + // Valid live checksum at byte 10, but byte 11 is a plausible year high byte rather than + // another 0xCF - that is a history frame, so the timestamp must be read. + val checksumCollision = hex("CF 60 0E 9C 27 04 02 F0 00 00 EC 07 EA 08 0C 09 0B 2B") + assertThat(OneByoneHandler.isLiveFrame(checksumCollision)).isTrue() + assertThat(OneByoneHandler.hasHistoryTimestamp(checksumCollision)).isTrue() + } + + /** + * Captured after the coalescing fix shipped: the scale settled (status 0x00) but reported + * **zero impedance** — no bioimpedance run. The weight is valid and must survive; only the + * body composition is skipped. + */ + private val zeroImpedance = hex("CF 00 00 28 28 00 00 00 01 00 CE") + + @Test + fun `a settled zero-impedance frame is a valid weight`() { + assertThat(OneByoneHandler.isLiveFrame(zeroImpedance)).isTrue() + assertThat(OneByoneHandler.isFinalReading(zeroImpedance)).isTrue() + assertThat(weightKg(zeroImpedance)).isWithin(1e-3f).of(102.80f) + assertThat(impedanceOhm(zeroImpedance)).isEqualTo(0f) + } + + @Test + fun `settled readings are distinguished from in-progress ones`() { + // 0x00 and 0x36 are the vendor app's "locked" values. + for (frame in listOf(single1016, single1014) + allCoalesced) { + assertThat(OneByoneHandler.isFinalReading(frame)).isTrue() + } + assertThat(OneByoneHandler.isFinalReading(hex("CF 50 0F B0 27 10 13 51 00 36 63"))).isTrue() + + // Anything else is still settling and must not be recorded. + assertThat(OneByoneHandler.isFinalReading(hex("CF 50 0F B0 27 10 13 51 00 01 54"))).isFalse() + assertThat(OneByoneHandler.isFinalReading(hex("CF 50 0F B0 27 10 13 51 00 02 57"))).isFalse() + assertThat(OneByoneHandler.isFinalReading(ByteArray(4))).isFalse() + } + + /** + * The back-to-back pair that validated the weight-only path on real hardware: the same + * 103.00 kg weigh-in taken in socks (no bioimpedance) and barefoot (coalesced frame). + */ + private val socks = hex("CF 00 00 3C 28 00 00 00 01 00 DA") + private val barefootCoalesced = hex("CF B6 0D 3C 28 B4 B5 99 01 00 F9 CF B6 0D 3C 28 B4 B5 99 01") + + @Test + fun `socks and barefoot frames agree on weight and differ only in impedance`() { + for (frame in listOf(socks, barefootCoalesced)) { + assertThat(OneByoneHandler.isLiveFrame(frame)).isTrue() + assertThat(OneByoneHandler.isFinalReading(frame)).isTrue() + assertThat(weightKg(frame)).isWithin(1e-3f).of(103.00f) + } + + // Socks block the bioimpedance measurement; barefoot produces a usable reading. + assertThat(impedanceOhm(socks)).isEqualTo(0f) + assertThat(impedanceOhm(barefootCoalesced)).isWithin(1e-3f).of(351.0f) + } + + @Test + fun `rejects truncated and corrupted frames`() { + assertThat(OneByoneHandler.isLiveFrame(hex("CF 50 0F B0 27"))).isFalse() + assertThat(OneByoneHandler.isLiveFrame(ByteArray(0))).isFalse() + // Same frame with a corrupted checksum byte. + assertThat(OneByoneHandler.isLiveFrame(hex("CF 50 0F B0 27 10 13 51 00 00 FF"))).isFalse() + } +}