From cce3ab85f3dfa072caf3bbf6299eadb7b01dc591 Mon Sep 17 00:00:00 2001 From: shisoratsu <277485761+shisoratsu@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:39:16 +0800 Subject: [PATCH] Add minimal Huawei Hagrid WSP support --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../libs/HuaweiHagridSecretProvider.kt | 84 ++ .../core/bluetooth/libs/HuaweiHagridWspLib.kt | 1017 +++++++++++++++++ .../scales/HuaweiHagridWspHandler.kt | 984 ++++++++++++++++ .../openscale/core/facade/SettingsFacade.kt | 25 +- .../openscale/core/service/BleScanner.kt | 37 +- .../bluetooth/libs/HuaweiHagridWspLibTest.kt | 154 +++ .../scales/HuaweiHagridWspHandlerTest.kt | 549 +++++++++ 8 files changed, 2848 insertions(+), 4 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridSecretProvider.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index c46c33d71..80da2afef 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 @@ -41,6 +41,7 @@ import com.health.openscale.core.bluetooth.scales.HesleyHandler import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler import com.health.openscale.core.bluetooth.scales.HuaweiAhCh100Handler import com.health.openscale.core.bluetooth.scales.HuaweiCH100SHandler +import com.health.openscale.core.bluetooth.scales.HuaweiHagridWspHandler import com.health.openscale.core.bluetooth.scales.IHealthHS3Handler import com.health.openscale.core.bluetooth.scales.InlifeHandler import com.health.openscale.core.bluetooth.scales.LinkMode @@ -134,6 +135,7 @@ class ScaleFactory @Inject constructor( IHealthHS3Handler(), HuaweiAhCh100Handler(), HuaweiCH100SHandler(), + HuaweiHagridWspHandler(), HoffenBbs8107Handler(), HesleyHandler(), ExingtechY1Handler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridSecretProvider.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridSecretProvider.kt new file mode 100644 index 000000000..2bc0f28cc --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridSecretProvider.kt @@ -0,0 +1,84 @@ +/* + * 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 + +/** + * Replaceable source for Huawei Hagrid CAK/C1/C2 key material. + * + * Implementations must not log raw secret values. The upstream app only uses + * explicitly configured settings here; private Huawei Health extraction tools + * are outside this boundary. + */ +interface HuaweiHagridSecretProvider { + val id: String + + fun load( + getString: (String) -> String?, + warn: (String) -> Unit + ): HuaweiHagridWspLib.HagridSecrets? + + class DriverSettings( + private val cakKey: String, + private val c1Key: String, + private val c2Key: String + ) : HuaweiHagridSecretProvider { + override val id: String = "driver-settings" + + override fun load( + getString: (String) -> String?, + warn: (String) -> Unit + ): HuaweiHagridWspLib.HagridSecrets? { + val cak = settingsHexOrNull(getString, warn, cakKey, 16) + val c1 = settingsHexOrNull(getString, warn, c1Key, 16) + val c2 = settingsHexOrNull(getString, warn, c2Key, 16) + if (cak == null && c1 == null && c2 == null) return null + if (cak == null || c1 == null || c2 == null) { + warn("Incomplete Huawei Hagrid secrets; require CAK, C1, and C2 hex settings") + return null + } + return runCatching { HuaweiHagridWspLib.HagridSecrets(cak, c1, c2) } + .getOrElse { error -> + warn("Invalid Huawei Hagrid secrets: ${error.message}") + null + } + } + + private fun settingsHexOrNull( + getString: (String) -> String?, + warn: (String) -> Unit, + key: String, + expectedBytes: Int + ): ByteArray? { + val raw = getString(key)?.trim().orEmpty() + if (raw.isEmpty()) return null + val hex = raw.filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + if (hex.length != expectedBytes * 2) { + warn("Ignoring Huawei Hagrid setting $key: expected ${expectedBytes * 2} hex digits, got ${hex.length}") + return null + } + return runCatching { + ByteArray(expectedBytes) { index -> + hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + }.getOrElse { error -> + warn("Ignoring Huawei Hagrid setting $key: ${error.message}") + null + } + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt new file mode 100644 index 000000000..fce680680 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLib.kt @@ -0,0 +1,1017 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import java.security.MessageDigest +import java.util.Calendar +import java.util.Date +import java.util.Locale +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.math.ceil +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Huawei Hagrid / WSP framing, authentication and payload helpers. + * + * The CAK/C1/C2 values are supplied by the caller. This library intentionally + * does not contain any Huawei Health key-extraction logic. + */ +object HuaweiHagridWspLib { + const val FRAME_WRITE_PLAIN = 0xDB + const val FRAME_WRITE_ENCRYPTED = 0xDC + const val FRAME_NOTIFY_PLAIN = 0xBD + const val FRAME_NOTIFY_ENCRYPTED = 0xCD + const val CAPABILITY_BIT_HIGH_FREQUENCY_IMPEDANCE = 18 + + private const val MAX_SIMPLE_PAYLOAD_BYTES = 240 + private const val SIMPLE_CHUNK_BYTES = 15 + private const val USER_INFO_PAYLOAD_BYTES = 69 + private const val MANAGER_HUID_BYTES = 30 + private const val MANAGER_DEVICE_ID_BYTES = 40 + private val AUTH_TOKEN_SUFFIX_TO_SCALE = byteArrayOf(0x31, 0x31, 0x32, 0x33) // "1123" + private val AUTH_TOKEN_SUFFIX_FROM_SCALE = byteArrayOf(0x39, 0x38, 0x35, 0x36) // "9856" + + private val crcTable = intArrayOf( + 0, 4129, 8258, 12387, 16516, 20645, 24774, 28903, 33032, 37161, 41290, 45419, 49548, 53677, 57806, 61935, + 4657, 528, 12915, 8786, 21173, 17044, 29431, 25302, 37689, 33560, 45947, 41818, 54205, 50076, 62463, 58334, + 9314, 13379, 1056, 5121, 25830, 29895, 17572, 21637, 42346, 46411, 34088, 38153, 58862, 62927, 50604, 54669, + 13907, 9842, 5649, 1584, 30423, 26358, 22165, 18100, 46939, 42874, 38681, 34616, 63455, 59390, 55197, 51132, + 18628, 22757, 26758, 30887, 2112, 6241, 10242, 14371, 51660, 55789, 59790, 63919, 35144, 39273, 43274, 47403, + 23285, 19156, 31415, 27286, 6769, 2640, 14899, 10770, 56317, 52188, 64447, 60318, 39801, 35672, 47931, 43802, + 27814, 31879, 19684, 23749, 11298, 15363, 3168, 7233, 60846, 64911, 52716, 56781, 44330, 48395, 36200, 40265, + 32407, 28342, 24277, 20212, 15891, 11826, 7761, 3696, 65439, 61374, 57309, 53244, 48923, 44858, 40793, 36728, + 37256, 33193, 45514, 41451, 53516, 49453, 61774, 57711, 4224, 97, 12482, 8419, 20484, 16421, 28742, 24679, + 33721, 37784, 41979, 46042, 49981, 54044, 58239, 62302, 689, 4752, 8947, 13010, 16949, 21012, 25207, 29270, + 46570, 42443, 38312, 34185, 62830, 58703, 54572, 50445, 13538, 9411, 5280, 1153, 29798, 25671, 21540, 17413, + 42971, 47098, 34713, 38840, 59231, 63358, 50973, 55100, 9939, 14066, 1681, 5808, 26199, 30326, 17941, 22068, + 55628, 51565, 63758, 59695, 39368, 35305, 47498, 43435, 22596, 18533, 30726, 26663, 6336, 2273, 14466, 10403, + 52093, 56156, 60223, 64286, 35833, 39896, 43963, 48026, 19061, 23124, 27191, 31254, 2801, 6864, 10931, 14994, + 64814, 60687, 56684, 52557, 48554, 44427, 40424, 36297, 31782, 27655, 23652, 19525, 15522, 11395, 7392, 3265, + 61215, 65342, 53085, 57212, 44955, 49082, 36825, 40952, 28183, 32310, 20053, 24180, 11923, 16050, 3793, 7920 + ) + + data class WspFrame( + val encrypted: Boolean, + val totalFrames: Int, + val frameIndex: Int, + val payload: ByteArray, + val crcValid: Boolean + ) { + override fun equals(other: Any?): Boolean = + other is WspFrame && + encrypted == other.encrypted && + totalFrames == other.totalFrames && + frameIndex == other.frameIndex && + crcValid == other.crcValid && + payload.contentEquals(other.payload) + + override fun hashCode(): Int { + var result = encrypted.hashCode() + result = 31 * result + totalFrames + result = 31 * result + frameIndex + result = 31 * result + crcValid.hashCode() + result = 31 * result + payload.contentHashCode() + return result + } + } + + data class HagridWeightMeasurement( + val rawLength: Int, + val weightKg: Float, + val fatPercent: Float, + val timestamp: Date?, + val lowFrequencyImpedance: List, + val highFrequencyImpedance: List, + val heartRateBpm: Int?, + val trailingBytesHex: String?, + val historySuspectedFlag: Int? = null, + val historyCompleteFlag: Int? = null + ) { + val representativeLowOhm: Double? + get() = lowFrequencyImpedance.firstNotNullOfOrNull { hagridImpedanceOhm(it) } + + val representativeHighOhm: Double? + get() = highFrequencyImpedance.firstNotNullOfOrNull { hagridImpedanceOhm(it) } + + val lowFrequencyImpedanceOhm: List + get() = lowFrequencyImpedance.map { hagridImpedanceOhm(it) } + + val highFrequencyImpedanceOhm: List + get() = highFrequencyImpedance.map { hagridImpedanceOhm(it) } + + val historyDedupeKey: String? + get() { + val millis = timestamp?.time ?: return null + return listOf( + millis.toString(), + (weightKg * 100.0f).roundToInt().toString(), + (fatPercent * 10.0f).roundToInt().toString(), + heartRateBpm?.toString().orEmpty(), + lowFrequencyImpedance.joinToString(separator = ","), + highFrequencyImpedance.joinToString(separator = ",") + ).joinToString(separator = "|") + } + + val isHistoryComplete: Boolean? + get() = historyCompleteFlag?.let { it == 0 } + + private fun hagridImpedanceOhm(raw: Int): Double? = + when (raw) { + in 1..3999 -> raw.toDouble() + in 4000..39999 -> raw / 10.0 + else -> null + } + } + + data class HagridScaleVersion( + val version: String, + val serialOrDataInfo: String, + val rawLength: Int + ) + + data class HagridWeightUnit( + val raw: Int, + val parsed: Int, + val label: String + ) + + data class HagridProductInfo( + val rawText: String, + val smartProductId: String, + val variant: String, + val rawLength: Int + ) + + enum class HagridProductFamily { + SCALE_2_PRO, + SCALE_3_PRO, + SCALE_3, + HONOR_SCALE_2, + DOBBY, + UNKNOWN + } + + data class HagridProductProfile( + val family: HagridProductFamily, + val displayName: String, + val smartProductId: String?, + val expectedHighFrequencyImpedance: Boolean? + ) + + data class HagridCapabilityBits( + val rawHex: String, + val byteCount: Int, + val supportedBits: Set + ) { + val supportsHighFrequencyImpedance: Boolean + get() = supports(CAPABILITY_BIT_HIGH_FREQUENCY_IMPEDANCE) + + fun supports(bit: Int): Boolean = + bit >= 0 && supportedBits.contains(bit) + } + + data class StandardMeasurement( + val weightKg: Float? = null, + val fatPercent: Float? = null, + val musclePercent: Float? = null, + val bodyWaterMassKg: Float? = null, + val impedanceOhm: Double? = null, + val scaleUserIndex: Int? = null, + val timestamp: Date? = null + ) + + data class HagridUserInfo( + val huid: String, + val uid: String, + val gender: Int, + val ageYears: Int, + val heightCm: Int, + val weightKg: Float, + val userType: Int = 0 + ) + + data class HagridManagerInfo( + val huid: String, + val deviceId: String, + val accountInfo: String?, + val rawLength: Int + ) + + data class HagridSecrets( + val cak: ByteArray, + val c1: ByteArray, + val c2: ByteArray + ) { + init { + require(cak.size == 16) { "CAK must be 16 bytes" } + require(c1.size == 16) { "C1 must be 16 bytes" } + require(c2.size == 16) { "C2 must be 16 bytes" } + } + + override fun equals(other: Any?): Boolean = + other is HagridSecrets && + cak.contentEquals(other.cak) && + c1.contentEquals(other.c1) && + c2.contentEquals(other.c2) + + override fun hashCode(): Int { + var result = cak.contentHashCode() + result = 31 * result + c1.contentHashCode() + result = 31 * result + c2.contentHashCode() + return result + } + } + + data class HagridSession( + val randA: ByteArray, + val randB: ByteArray, + val rootKey: ByteArray, + val workKey: ByteArray + ) { + init { + require(randA.size == 16) { "randA must be 16 bytes" } + require(randB.size == 16) { "randB must be 16 bytes" } + require(rootKey.size == 16) { "rootKey must be 16 bytes" } + require(workKey.size == 16) { "workKey must be 16 bytes" } + } + + override fun equals(other: Any?): Boolean = + other is HagridSession && + randA.contentEquals(other.randA) && + randB.contentEquals(other.randB) && + rootKey.contentEquals(other.rootKey) && + workKey.contentEquals(other.workKey) + + override fun hashCode(): Int { + var result = randA.contentHashCode() + result = 31 * result + randB.contentHashCode() + result = 31 * result + rootKey.contentHashCode() + result = 31 * result + workKey.contentHashCode() + return result + } + } + + class FrameAccumulator(private val requireValidCrc: Boolean = true) { + private var expectedFrames: Int = 0 + private var encrypted: Boolean = false + private var allFramesCrcValid: Boolean = true + private val chunks = mutableMapOf() + + var lastCompletedEncrypted: Boolean = false + private set + + fun ingest(rawFrame: ByteArray): ByteArray? { + val frame = parseNotificationFrame(rawFrame, requireValidCrc) ?: return null + if (frame.totalFrames <= 1) { + lastCompletedEncrypted = frame.encrypted + reset() + return frame.payload + } + + if (expectedFrames != frame.totalFrames || encrypted != frame.encrypted) { + reset() + expectedFrames = frame.totalFrames + encrypted = frame.encrypted + } + + allFramesCrcValid = allFramesCrcValid && frame.crcValid + chunks[frame.frameIndex] = frame.payload + if (chunks.size < expectedFrames) return null + + val out = ByteArray(chunks.values.sumOf { it.size }) + var pos = 0 + for (i in 0 until expectedFrames) { + val chunk = chunks[i] ?: run { + reset() + return null + } + chunk.copyInto(out, pos) + pos += chunk.size + } + lastCompletedEncrypted = encrypted + reset() + return out + } + + fun reset() { + expectedFrames = 0 + encrypted = false + allFramesCrcValid = true + chunks.clear() + } + } + + fun buildWriteFrames(payload: ByteArray, encrypted: Boolean = false): List { + require(payload.size <= MAX_SIMPLE_PAYLOAD_BYTES) { + "WSP long payloads > $MAX_SIMPLE_PAYLOAD_BYTES bytes are not implemented" + } + + val total = maxOf(1, ceil(payload.size / SIMPLE_CHUNK_BYTES.toDouble()).toInt()) + val frames = ArrayList(total) + var offset = 0 + for (index in 0 until total) { + val chunkLen = min(SIMPLE_CHUNK_BYTES, payload.size - offset).coerceAtLeast(0) + val frame = ByteArray(chunkLen + 5) + frame[0] = (if (encrypted) FRAME_WRITE_ENCRYPTED else FRAME_WRITE_PLAIN).toByte() + frame[1] = (chunkLen + 3).toByte() + frame[2] = ((((total - 1) and 0x0F) shl 4) or (index and 0x0F)).toByte() + if (chunkLen > 0) { + payload.copyInto(frame, destinationOffset = 3, startIndex = offset, endIndex = offset + chunkLen) + offset += chunkLen + } + putCrc(frame) + frames += frame + } + return frames + } + + fun parseNotificationFrame(frame: ByteArray, requireValidCrc: Boolean = true): WspFrame? { + if (frame.size < 5) return null + val prefix = frame[0].toInt() and 0xFF + if (prefix != FRAME_NOTIFY_PLAIN && prefix != FRAME_NOTIFY_ENCRYPTED) return null + val crcValid = hasValidNotificationCrc(frame) + if (requireValidCrc && !crcValid) return null + + val payloadLen = (frame[1].toInt() and 0xFF) - 3 + if (payloadLen < 0 || frame.size < payloadLen + 5) return null + + val totalFrames = ((frame[2].toInt() ushr 4) and 0x0F) + 1 + val frameIndex = frame[2].toInt() and 0x0F + if (frameIndex >= totalFrames) return null + + return WspFrame( + encrypted = prefix == FRAME_NOTIFY_ENCRYPTED, + totalFrames = totalFrames, + frameIndex = frameIndex, + payload = frame.copyOfRange(3, 3 + payloadLen), + crcValid = crcValid + ) + } + + fun hasNotificationFramePrefix(frame: ByteArray): Boolean { + val prefix = frame.firstOrNull()?.toInt()?.and(0xFF) ?: return false + return prefix == FRAME_NOTIFY_PLAIN || prefix == FRAME_NOTIFY_ENCRYPTED + } + + fun hasValidNotificationCrc(frame: ByteArray): Boolean { + if (frame.size < 5) return false + val expected = u16le(frame, frame.size - 2) + return crc16Modbus(frame.copyOfRange(0, frame.size - 2)) == expected + } + + fun crc16(data: ByteArray): Int { + var crc = 0 + for (byte in data) { + crc = ((crc shl 8) xor crcTable[((byte.toInt() and 0xFF) xor (crc ushr 8)) and 0xFF]) and 0xFFFF + } + return crc + } + + fun crc16Modbus(data: ByteArray): Int { + var crc = 0xFFFF + for (byte in data) { + crc = crc xor (byte.toInt() and 0xFF) + repeat(8) { + crc = if ((crc and 0x0001) != 0) { + (crc ushr 1) xor 0xA001 + } else { + crc ushr 1 + } + } + crc = crc and 0xFFFF + } + return crc + } + + fun parseRealtimeMeasurement(payload: ByteArray): HagridWeightMeasurement? = + parseHagridWeightMeasurement( + payload = payload, + allowedLengths = setOf(26, 38), + timestampPresent = false + ) + + fun parseHistoryMeasurement(payload: ByteArray): HagridWeightMeasurement? = + parseHagridWeightMeasurement( + payload = payload, + allowedLengths = setOf(27, 28, 39, 40), + timestampPresent = true + ) + + fun parseScaleVersion(payload: ByteArray): HagridScaleVersion? { + if (payload.size < 5 || payload.size > 20) return null + + val versionBits = bitArrayMsb(payload[1]) + bitArrayMsb(payload[2]) + bitArrayMsb(payload[3]) + val major = versionBits.subList(0, 4).bitsToInt() + val minor = versionBits.subList(4, 8).bitsToInt() + val patch = versionBits.subList(8, 12).bitsToInt() + val build = versionBits.subList(12, 22).bitsToInt() + val serial = decodeFixedAscii(payload.copyOfRange(4, payload.size)) + + return HagridScaleVersion( + version = "$major.$minor.$patch.$build", + serialOrDataInfo = serial, + rawLength = payload.size + ) + } + + fun parseWeightUnit(payload: ByteArray): HagridWeightUnit? { + val raw = payload.firstOrNull()?.toInt()?.and(0xFF) ?: return null + val parsed = raw.toString(16).toIntOrNull() ?: raw + val label = when (parsed) { + 1 -> "kg" + 2 -> "lb" + 3 -> "g" + else -> "unknown" + } + return HagridWeightUnit(raw = raw, parsed = parsed, label = label) + } + + fun parseProductInfo(payload: ByteArray): HagridProductInfo? { + if (payload.isEmpty()) return null + val text = decodeFixedAscii(payload) + if (text.isBlank() || text.startsWith("hex:")) return null + + return HagridProductInfo( + rawText = text, + smartProductId = text.take(4), + variant = text.drop(4), + rawLength = payload.size + ) + } + + fun productProfileForMarker(marker: String?): HagridProductProfile { + val normalized = marker + ?.trim() + ?.uppercase(Locale.US) + .orEmpty() + return when (normalized) { + "007B", "HAG-B19", "HAGRID-B19", "HUAWEI SCALE 2 PRO" -> + HagridProductProfile( + family = HagridProductFamily.SCALE_2_PRO, + displayName = "HUAWEI Scale 2 Pro", + smartProductId = "007B", + expectedHighFrequencyImpedance = false + ) + + "M00F", "HAGRID-B29", "HAGRID2021-B19", "HUAWEI SCALE 3 PRO" -> + HagridProductProfile( + family = HagridProductFamily.SCALE_3_PRO, + displayName = "HUAWEI Scale 3 Pro", + smartProductId = "M00F", + expectedHighFrequencyImpedance = true + ) + + "M00D", "HEM-B19", "HERM-B19", "HUAWEI SCALE 3" -> + HagridProductProfile( + family = HagridProductFamily.SCALE_3, + displayName = "HUAWEI Scale 3", + smartProductId = "M00D", + expectedHighFrequencyImpedance = false + ) + + "N001", "LUP-B19", "LUPIN-B19HN" -> + HagridProductProfile( + family = HagridProductFamily.HONOR_SCALE_2, + displayName = "HONOR Scale 2", + smartProductId = "N001", + expectedHighFrequencyImpedance = false + ) + + "M0CJ", "DOBBY-B19" -> + HagridProductProfile( + family = HagridProductFamily.DOBBY, + displayName = "Huawei/Honor WSP Scale", + smartProductId = "M0CJ", + expectedHighFrequencyImpedance = null + ) + + else -> + HagridProductProfile( + family = HagridProductFamily.UNKNOWN, + displayName = "Huawei/Honor WSP Scale", + smartProductId = normalized.ifBlank { null }, + expectedHighFrequencyImpedance = null + ) + } + } + + fun parseCapabilityBits(capacityHex: String?): HagridCapabilityBits? { + val hex = capacityHex + ?.filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + ?.uppercase(Locale.US) + .orEmpty() + if (hex.isEmpty() || hex.length % 2 != 0) return null + + val bytes = runCatching { + ByteArray(hex.length / 2) { index -> + hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + }.getOrNull() ?: return null + + val supportedBits = mutableSetOf() + bytes.forEachIndexed { byteIndex, byte -> + val value = byte.toInt() and 0xFF + for (bitInByte in 0 until 8) { + val mask = 1 shl bitInByte + if ((value and mask) == mask) { + supportedBits += byteIndex * 8 + bitInByte + } + } + } + + return HagridCapabilityBits( + rawHex = hex, + byteCount = bytes.size, + supportedBits = supportedBits + ) + } + + fun buildAuthTokenPayload(randA: ByteArray, randB: ByteArray, cak: ByteArray): ByteArray { + require(randA.size == 16) { "randA must be 16 bytes" } + require(randB.size == 16) { "randB must be 16 bytes" } + require(cak.size == 16) { "CAK must be 16 bytes" } + + return randB + hagridAuthToken(randA, randB, cak, AUTH_TOKEN_SUFFIX_TO_SCALE) + } + + fun expectedAuthResponsePayload(randA: ByteArray, randB: ByteArray, cak: ByteArray): ByteArray { + require(randA.size == 16) { "randA must be 16 bytes" } + require(randB.size == 16) { "randB must be 16 bytes" } + require(cak.size == 16) { "CAK must be 16 bytes" } + + return hagridAuthToken(randA, randB, cak, AUTH_TOKEN_SUFFIX_FROM_SCALE) + } + + fun isValidAuthResponsePayload( + response: ByteArray, + randA: ByteArray, + randB: ByteArray, + cak: ByteArray + ): Boolean = + response.contentEquals(expectedAuthResponsePayload(randA, randB, cak)) + + fun hagridC3FromBluetoothAddress(address: String): ByteArray { + val compact = address.filter { it != ':' && it != '-' }.uppercase(Locale.US) + require(compact.length == 12) { "Bluetooth address must contain 12 hex digits" } + return (compact + "0000").encodeToByteArray() + } + + fun deriveHagridRootKey(c1: ByteArray, c2: ByteArray, c3: ByteArray): ByteArray { + require(c1.size == c2.size) { "C1 and C2 must have the same length" } + require(c1.isNotEmpty()) { "C1 and C2 must not be empty" } + require(c3.size == c1.size) { "C3 must be the same length as C1/C2" } + + val mixed = ByteArray(c1.size) { index -> + ((((c1[index].toInt() and 0xFF) shl 4) xor (c2[index].toInt() and 0xFF)) and 0xFF).toByte() + } + val keyData = sha256First16(mixed) + val folded = ByteArray(keyData.size) { index -> + (((keyData[index].toInt() and 0xFF) ushr 6) xor (c3[index].toInt() and 0xFF)).toByte() + } + return sha256First16(folded) + } + + fun encryptPayload(plaintext: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + require(key.size == 16) { "AES-CTR key must be 16 bytes" } + require(iv.size == 16) { "AES-CTR IV must be 16 bytes" } + return aesCtr(plaintext, key, iv) + } + + fun encryptedPayloadWithIv(plaintext: ByteArray, key: ByteArray, iv: ByteArray): ByteArray = + iv + encryptPayload(plaintext, key, iv) + + fun decryptPayload(ciphertextWithIv: ByteArray, key: ByteArray): ByteArray { + require(ciphertextWithIv.size >= 16) { "Encrypted payload must include a 16-byte IV" } + val iv = ciphertextWithIv.copyOfRange(0, 16) + val ciphertext = ciphertextWithIv.copyOfRange(16, ciphertextWithIv.size) + return encryptPayload(ciphertext, key, iv) + } + + fun buildEncryptedWriteFrames(plaintext: ByteArray, key: ByteArray, iv: ByteArray): List = + buildWriteFrames(encryptedPayloadWithIv(plaintext, key, iv), encrypted = true) + + fun buildSendWorkKeyFrames(workKey: ByteArray, rootKey: ByteArray, iv: ByteArray): List { + require(workKey.size == 16) { "workKey must be 16 bytes" } + return buildEncryptedWriteFrames(workKey, rootKey, iv) + } + + fun buildUserInfoPayload(info: HagridUserInfo): ByteArray { + val payload = ByteArray(USER_INFO_PAYLOAD_BYTES) + copyUtf8Fixed(info.huid, payload, offset = 0, length = 30) + copyUtf8Fixed(info.uid, payload, offset = 30, length = 32) + payload[62] = info.gender.coerceIn(0, 255).toByte() + payload[63] = info.ageYears.coerceIn(0, 255).toByte() + putU16Le(payload, 64, info.heightCm.coerceIn(0, 0xFFFF)) + + val weightCentiKg = if (info.weightKg.isFinite() && info.weightKg > 0f) { + (info.weightKg * 100f).roundToInt().coerceIn(0, 0xFFFF) + } else { + 0 + } + putU16Le(payload, 66, weightCentiKg) + payload[68] = info.userType.coerceIn(0, 255).toByte() + return payload + } + + fun buildManagerInfoPayload(huid: String, deviceId: String, accountInfo: String? = null): ByteArray { + val accountBytes = accountInfo?.encodeToByteArray()?.let { bytes -> + bytes.copyOfRange(0, min(bytes.size, 255)) + } + val payload = if (accountBytes == null) { + ByteArray(MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES) + } else { + ByteArray(MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + 1 + accountBytes.size) + } + copyUtf8Fixed(huid, payload, offset = 0, length = MANAGER_HUID_BYTES) + copyUtf8Fixed(deviceId, payload, offset = MANAGER_HUID_BYTES, length = MANAGER_DEVICE_ID_BYTES) + if (accountBytes != null) { + val accountLengthOffset = MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + payload[accountLengthOffset] = accountBytes.size.toByte() + accountBytes.copyInto(payload, destinationOffset = accountLengthOffset + 1) + } + return payload + } + + fun parseManagerInfo(payload: ByteArray): HagridManagerInfo? { + if (payload.isEmpty()) { + return HagridManagerInfo(huid = "", deviceId = "", accountInfo = null, rawLength = 0) + } + if (payload.size <= MANAGER_HUID_BYTES) return null + + val huid = decodeFixedText(payload.copyOfRange(0, MANAGER_HUID_BYTES)) + val extended = payload.size > MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + val deviceIdEnd = if (extended) { + MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + } else { + payload.size + } + val deviceId = decodeFixedText(payload.copyOfRange(MANAGER_HUID_BYTES, deviceIdEnd)) + + val accountLengthOffset = MANAGER_HUID_BYTES + MANAGER_DEVICE_ID_BYTES + val accountInfo = if (extended) { + val declaredLength = u8(payload, accountLengthOffset) + val accountEnd = min(payload.size, accountLengthOffset + 1 + declaredLength) + if (accountEnd > accountLengthOffset + 1) { + decodeFixedText(payload.copyOfRange(accountLengthOffset + 1, accountEnd)) + .takeIf { it.isNotBlank() } + } else { + null + } + } else { + null + } + + return HagridManagerInfo( + huid = huid, + deviceId = deviceId, + accountInfo = accountInfo, + rawLength = payload.size + ) + } + + fun parseStandardWeightMeasurement(payload: ByteArray): StandardMeasurement? { + if (payload.size < 3) return null + var offset = 0 + val flags = u8(payload, offset) + offset += 1 + + val imperial = (flags and 0x01) != 0 + val timestampPresent = (flags and 0x02) != 0 + val userPresent = (flags and 0x04) != 0 + val bmiAndHeightPresent = (flags and 0x08) != 0 + + val weightRaw = u16leOrNull(payload, offset) ?: return null + offset += 2 + val weightKg = massToKg(weightRaw, imperial) + + val timestamp = if (timestampPresent) { + readTimestamp(payload, offset) ?: return null + } else { + null + } + if (timestampPresent) offset += 7 + + val scaleUserIndex = if (userPresent) { + u8OrNull(payload, offset) ?: return null + } else { + null + } + if (userPresent) offset += 1 + + if (bmiAndHeightPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + u16leOrNull(payload, offset) ?: return null + } + + return StandardMeasurement( + weightKg = weightKg, + scaleUserIndex = scaleUserIndex, + timestamp = timestamp + ) + } + + fun parseStandardBodyCompositionMeasurement(payload: ByteArray): StandardMeasurement? { + if (payload.size < 4) return null + var offset = 0 + val flags = u16le(payload, offset) + offset += 2 + + val imperial = (flags and 0x0001) != 0 + val timestampPresent = (flags and 0x0002) != 0 + val userPresent = (flags and 0x0004) != 0 + val basalMetabolismPresent = (flags and 0x0008) != 0 + val musclePercentPresent = (flags and 0x0010) != 0 + val muscleMassPresent = (flags and 0x0020) != 0 + val fatFreeMassPresent = (flags and 0x0040) != 0 + val softLeanMassPresent = (flags and 0x0080) != 0 + val bodyWaterMassPresent = (flags and 0x0100) != 0 + val impedancePresent = (flags and 0x0200) != 0 + val weightPresent = (flags and 0x0400) != 0 + val heightPresent = (flags and 0x0800) != 0 + + val bodyFatRaw = u16leOrNull(payload, offset) ?: return null + offset += 2 + val fatPercent = bodyFatRaw * 0.1f + + val timestamp = if (timestampPresent) { + readTimestamp(payload, offset) ?: return null + } else { + null + } + if (timestampPresent) offset += 7 + + val scaleUserIndex = if (userPresent) { + u8OrNull(payload, offset) ?: return null + } else { + null + } + if (userPresent) offset += 1 + + if (basalMetabolismPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + } + + val musclePercent = if (musclePercentPresent) { + val raw = u16leOrNull(payload, offset) ?: return null + offset += 2 + raw * 0.1f + } else { + null + } + + if (muscleMassPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + } + if (fatFreeMassPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + } + if (softLeanMassPresent) { + u16leOrNull(payload, offset) ?: return null + offset += 2 + } + + val bodyWaterMassKg = if (bodyWaterMassPresent) { + val raw = u16leOrNull(payload, offset) ?: return null + offset += 2 + massToKg(raw, imperial) + } else { + null + } + + val impedanceOhm = if (impedancePresent) { + val raw = u16leOrNull(payload, offset) ?: return null + offset += 2 + (raw * 0.1f).toDouble() + } else { + null + } + + val weightKg = if (weightPresent) { + val raw = u16leOrNull(payload, offset) ?: return null + offset += 2 + massToKg(raw, imperial) + } else { + null + } + + if (heightPresent) { + u16leOrNull(payload, offset) ?: return null + } + + return StandardMeasurement( + weightKg = weightKg, + fatPercent = fatPercent, + musclePercent = musclePercent, + bodyWaterMassKg = bodyWaterMassKg, + impedanceOhm = impedanceOhm, + scaleUserIndex = scaleUserIndex, + timestamp = timestamp + ) + } + + private fun parseHagridWeightMeasurement( + payload: ByteArray, + allowedLengths: Set, + timestampPresent: Boolean + ): HagridWeightMeasurement? { + if (payload.size !in allowedLengths) return null + + val highResistancePresent = payload.size >= 38 + val baseLength = if (highResistancePresent) 38 else 26 + val weightKg = u16le(payload, 0) / 100.0f + val fatPercent = u16le(payload, 2) / 10.0f + val timestamp = if (timestampPresent) { + readTimestamp(payload, 4) ?: return null + } else { + null + } + val low = (0 until 6).map { u16le(payload, 12 + it * 2) } + val high = if (highResistancePresent) { + (0 until 6).map { u16le(payload, 26 + it * 2) } + } else { + emptyList() + } + val heartRateRaw = u16le(payload, 24) + val heartRate = heartRateRaw.takeIf { it in 1..240 } + val trailing = if (payload.size > baseLength) { + payload.copyOfRange(baseLength, payload.size).toHexString() + } else { + null + } + val historySuspectedFlag = if (timestampPresent && payload.size > baseLength) { + u8(payload, baseLength) + } else { + null + } + val historyCompleteFlag = if (timestampPresent && payload.size > baseLength + 1) { + u8(payload, baseLength + 1) + } else { + null + } + + return HagridWeightMeasurement( + rawLength = payload.size, + weightKg = weightKg, + fatPercent = fatPercent, + timestamp = timestamp, + lowFrequencyImpedance = low, + highFrequencyImpedance = high, + heartRateBpm = heartRate, + trailingBytesHex = trailing, + historySuspectedFlag = historySuspectedFlag, + historyCompleteFlag = historyCompleteFlag + ) + } + + fun currentTimePayload(calendar: Calendar = Calendar.getInstance()): ByteArray { + val year = calendar.get(Calendar.YEAR) + val dayOfWeek = (((calendar.get(Calendar.DAY_OF_WEEK) + 5) % 7) + 1) + return byteArrayOf( + (year and 0xFF).toByte(), + ((year ushr 8) and 0xFF).toByte(), + (calendar.get(Calendar.MONTH) + 1).toByte(), + calendar.get(Calendar.DAY_OF_MONTH).toByte(), + calendar.get(Calendar.HOUR_OF_DAY).toByte(), + calendar.get(Calendar.MINUTE).toByte(), + calendar.get(Calendar.SECOND).toByte(), + dayOfWeek.toByte() + ) + } + + private fun putCrc(frame: ByteArray) { + frame[frame.size - 2] = 0 + frame[frame.size - 1] = 0 + val crc = crc16(frame) + frame[frame.size - 2] = (crc and 0xFF).toByte() + frame[frame.size - 1] = ((crc ushr 8) and 0xFF).toByte() + } + + private fun hagridAuthToken(randA: ByteArray, randB: ByteArray, cak: ByteArray, suffix: ByteArray): ByteArray { + val rand = randA + randB + val innerKey = hmacSha256(rand, cak + suffix) + return hmacSha256(rand, innerKey) + } + + private fun hmacSha256(message: ByteArray, key: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(message) + } + + private fun sha256First16(data: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256").digest(data) + return digest.copyOfRange(0, 16) + } + + private fun aesCtr(input: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(input) + } + + private fun u16le(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) + + private fun putU16Le(data: ByteArray, offset: Int, value: Int) { + data[offset] = (value and 0xFF).toByte() + data[offset + 1] = ((value ushr 8) and 0xFF).toByte() + } + + private fun copyUtf8Fixed(value: String, target: ByteArray, offset: Int, length: Int) { + val bytes = value.encodeToByteArray() + bytes.copyInto(target, destinationOffset = offset, startIndex = 0, endIndex = min(bytes.size, length)) + } + + private fun decodeFixedText(bytes: ByteArray): String { + val nul = bytes.indexOf(0) + val meaningful = if (nul >= 0) bytes.copyOfRange(0, nul) else bytes.copyOf() + val trimmed = meaningful + .dropLastWhile { byte -> byte == 0.toByte() || byte == 0x20.toByte() } + .toByteArray() + if (trimmed.isEmpty()) return "" + + val printableAscii = trimmed.all { byte -> + val c = byte.toInt() and 0xFF + c in 0x20..0x7E + } + return if (printableAscii) trimmed.decodeToString() else "hex:${trimmed.toHexString()}" + } + + private fun decodeFixedAscii(bytes: ByteArray): String = + decodeFixedText(bytes) + + private fun u16leOrNull(data: ByteArray, offset: Int): Int? = + if (offset + 1 < data.size) u16le(data, offset) else null + + private fun u8(data: ByteArray, offset: Int): Int = + data[offset].toInt() and 0xFF + + private fun u8OrNull(data: ByteArray, offset: Int): Int? = + if (offset < data.size) u8(data, offset) else null + + private fun readTimestamp(data: ByteArray, offset: Int): Date? { + if (offset + 6 >= data.size) return null + val year = u16le(data, offset) + val month = u8(data, offset + 2) + val day = u8(data, offset + 3) + val hour = u8(data, offset + 4) + val minute = u8(data, offset + 5) + val second = u8(data, offset + 6) + if (year < 2000 || month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) { + return null + } + val cal = Calendar.getInstance() + cal.set(year, (month - 1).coerceIn(0, 11), day, hour, minute, second) + cal.set(Calendar.MILLISECOND, 0) + return cal.time + } + + private fun massToKg(raw: Int, imperial: Boolean): Float { + val value = if (imperial) raw * 0.01f else raw * 0.005f + return if (imperial) value * 0.45359237f else value + } + + private fun bitArrayMsb(byte: Byte): List { + var value = byte.toInt() + val bits = MutableList(8) { 0 } + for (i in 7 downTo 0) { + bits[i] = value and 1 + value = value shr 1 + } + return bits + } + + private fun List.bitsToInt(): Int { + var value = 0 + forEach { bit -> + value = (value shl 1) or (bit and 1) + } + return value + } + + private fun ByteArray.toHexString(): String = + joinToString(separator = "") { "%02X".format(it.toInt() and 0xFF) } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt new file mode 100644 index 000000000..8f6d0231b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandler.kt @@ -0,0 +1,984 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import android.util.SparseArray +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.HuaweiHagridSecretProvider +import com.health.openscale.core.bluetooth.libs.HuaweiHagridWspLib +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.security.SecureRandom +import java.util.Date +import java.util.Locale +import java.util.UUID +import kotlin.math.roundToInt + +/** + * Huawei/Honor Hagrid WSP scale handler. + * + * Authentication uses only CAK/C1/C2 values explicitly configured in driver + * settings. This handler intentionally does not contain Huawei Health key + * extraction, Frida, root, APK, or native white-box logic. + */ +class HuaweiHagridWspHandler( + private val secretProvider: HuaweiHagridSecretProvider = + HuaweiHagridSecretProvider.DriverSettings( + cakKey = SETTINGS_KEY_CAK_HEX, + c1Key = SETTINGS_KEY_C1_HEX, + c2Key = SETTINGS_KEY_C2_HEX + ), + private val randomBytes: (Int) -> ByteArray = { size -> + ByteArray(size).also { SecureRandom().nextBytes(it) } + } +) : ScaleDeviceHandler() { + + private val svcUserData = uuid16(0x181C) + private val svcCurrentTime = uuid16(0x1805) + private val svcBodyComposition = uuid16(0x181B) + private val svcWeightScale = uuid16(0x181D) + + private val chrRequestAuth = UUID.fromString("02b2a08e-f8b0-4047-b1fd-f4e0efeee679") + private val chrAuthToken = UUID.fromString("32330a04-15d9-421a-91c5-2a2d5c7525c9") + private val chrSendWorkKey = UUID.fromString("a3d330f8-b84f-4f48-a78c-f8d1e33b597a") + private val chrBindRequest = UUID.fromString("42596cbe-d291-4da3-8ca6-d1ae5d1c9174") + private val chrSetUserInfo = UUID.fromString("8cc61d7d-66c0-4802-89c3-38c5a163592e") + private val chrGetManagerInfo = UUID.fromString("4338c65e-ed8e-4085-bbea-a25e33ca6b54") + private val chrCurrentTime = uuid16(0x2A2B) + private val chrProductInfo = UUID.fromString("75143e79-f878-4a00-a628-edc40509de7e") + private val chrScaleVersion = UUID.fromString("1f5d3d5c-496d-4290-af03-c7a8d5419741") + private val chrGetWeightUnit = UUID.fromString("7e6dbc73-42e7-45b9-a6ec-6aa2d7834695") + private val chrMeasurementStatusPoll = UUID.fromString("bfc36f6e-4150-4a4b-9052-3d359e52962e") + private val chrMeasurementStatusResult = UUID.fromString("ba216311-1787-472b-bef6-3eb29e62293e") + private val chrWeightMeasurement = uuid16(0x2A9D) + private val chrBodyCompositionMeasurement = uuid16(0x2A9C) + private val chrRealtimeWeight = UUID.fromString("46797c17-d639-488d-9476-4789e8472878") + private val chrHistoryWeight = UUID.fromString("0212f42a-5f19-4bc1-ba52-d7ec7ccb71a4") + + private val accumulators = mutableMapOf() + private var pendingDeviceAddress: String? = null + private var pendingProductProfile = HuaweiHagridWspLib.productProfileForMarker(null) + private var pendingCapabilityBits: HuaweiHagridWspLib.HagridCapabilityBits? = null + private var configuredSecrets: HuaweiHagridWspLib.HagridSecrets? = null + private var pendingRandA: ByteArray? = null + private var pendingRandB: ByteArray? = null + private var session: HuaweiHagridWspLib.HagridSession? = null + private var handshakeState = HandshakeState.IDLE + private var currentUserInfoSync: ScaleUser? = null + private var userInfoSyncGeneration = 0 + private var lastManagerInfo: HuaweiHagridWspLib.HagridManagerInfo? = null + private var pendingProfileSyncAfterManagerInfo: ScaleUser? = null + private var managerInfoReadGeneration = 0 + private var historyReadActive = false + private var historyReadCount = 0 + private val publishedHistoryKeys = mutableSetOf() + private var measurementStatusPollingActive = false + private var measurementStatusPollCount = 0 + private var measurementStatusPollJob: Job? = null + private var measurementStatusReady = false + private var postStatusMeasurementReadsStarted = false + private var realtimeReadRequestCount = 0 + + @Composable + override fun DeviceConfigurationUi() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Huawei Hagrid WSP secrets", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "Configure CAK, C1, and C2 as 32 hex characters each. Values stay in per-driver app settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SecretHexField("CAK", SETTINGS_KEY_CAK_HEX) + SecretHexField("C1", SETTINGS_KEY_C1_HEX) + SecretHexField("C2", SETTINGS_KEY_C2_HEX) + } + } + + @Composable + private fun SecretHexField(label: String, key: String) { + val persistedValue = settingsGetString(key) ?: "" + var inputValue by remember(persistedValue) { mutableStateOf(persistedValue) } + var lastSavedValue by remember(persistedValue) { mutableStateOf(persistedValue) } + val isValid = inputValue.length == 32 + val showError = inputValue.isNotEmpty() && !isValid + val isSaved = inputValue.isNotEmpty() && isValid && inputValue == lastSavedValue + + LaunchedEffect(inputValue) { + when { + inputValue.isEmpty() && lastSavedValue.isNotEmpty() -> { + settingsPutString(key, "") + lastSavedValue = "" + } + isValid && inputValue != lastSavedValue -> { + settingsPutString(key, inputValue) + lastSavedValue = inputValue + } + } + } + + OutlinedTextField( + value = inputValue, + onValueChange = { newValue -> + val filtered = newValue + .filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + .uppercase(Locale.US) + if (filtered.length <= 32) { + inputValue = filtered + } + }, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) }, + singleLine = true, + isError = showError, + visualTransformation = PasswordVisualTransformation(), + supportingText = { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = when { + showError -> "Expected 32 hex characters" + isSaved -> "Saved" + else -> "" + }, + color = if (showError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + Spacer(modifier = Modifier.weight(1f)) + Text("${inputValue.length}/32") + } + } + ) + } + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val marker = findProductMarker(device) ?: return null + pendingDeviceAddress = device.address + pendingProductProfile = HuaweiHagridWspLib.productProfileForMarker(marker) + pendingCapabilityBits = findCapabilityBits(device) + + val capabilities = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.TIME_SYNC, + DeviceCapability.USER_SYNC, + DeviceCapability.HISTORY_READ, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.UNIT_CONFIG + ) + + return DeviceSupport( + displayName = pendingProductProfile.displayName, + capabilities = capabilities, + implemented = setOf( + DeviceCapability.TIME_SYNC, + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.HISTORY_READ, + ), + tuningProfile = TuningProfile.Conservative, + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + accumulators.clear() + pendingRandA = null + pendingRandB = null + session = null + stopMeasurementStatusPolling() + configuredSecrets = loadConfiguredSecrets() + handshakeState = if (configuredSecrets == null) { + HandshakeState.PROBE_ONLY + } else { + HandshakeState.WAITING_AUTH_CHALLENGE + } + currentUserInfoSync = null + userInfoSyncGeneration++ + lastManagerInfo = null + pendingProfileSyncAfterManagerInfo = null + managerInfoReadGeneration++ + historyReadActive = false + historyReadCount = 0 + publishedHistoryKeys.clear() + postStatusMeasurementReadsStarted = false + realtimeReadRequestCount = 0 + measurementStatusReady = false + + setNotifyIfPresent(svcUserData, chrRequestAuth) + setNotifyIfPresent(svcUserData, chrAuthToken) + setNotifyIfPresent(svcUserData, chrSendWorkKey) + setNotifyIfPresent(svcUserData, chrBindRequest) + setNotifyIfPresent(svcUserData, chrSetUserInfo) + setNotifyIfPresent(svcUserData, chrGetManagerInfo) + setNotifyIfPresent(svcCurrentTime, chrCurrentTime) + setNotifyIfPresent(svcCurrentTime, chrProductInfo) + setNotifyIfPresent(svcCurrentTime, chrScaleVersion) + setNotifyIfPresent(svcCurrentTime, chrGetWeightUnit) + setNotifyIfPresent(svcCurrentTime, chrMeasurementStatusPoll) + setNotifyIfPresent(svcCurrentTime, chrMeasurementStatusResult) + setNotifyIfPresent(svcBodyComposition, chrBodyCompositionMeasurement) + setNotifyIfPresent(svcWeightScale, chrWeightMeasurement) + setNotifyIfPresent(svcBodyComposition, chrRealtimeWeight) + setNotifyIfPresent(svcBodyComposition, chrHistoryWeight) + + userInfo(R.string.bt_info_step_on_scale) + logI( + if (configuredSecrets == null) { + "Huawei Hagrid WSP secrets missing; using probe-only mode" + } else { + "Huawei Hagrid WSP secrets configured; starting authentication" + } + ) + logDeviceProfile("scan") + + writeWspPlainIfPresent(svcUserData, chrRequestAuth, ByteArray(0)) + if (configuredSecrets == null) { + writeWspPlainIfPresent(svcCurrentTime, chrCurrentTime, HuaweiHagridWspLib.currentTimePayload()) + } + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (HuaweiHagridWspLib.hasNotificationFramePrefix(data)) { + val accumulator = accumulators.getOrPut(characteristic) { + HuaweiHagridWspLib.FrameAccumulator() + } + val payload = accumulator.ingest(data) ?: return + handleWspNotification(characteristic, payload, accumulator.lastCompletedEncrypted, user) + return + } + + handleRawNotification(characteristic, data, user) + } + + override fun onDisconnected() { + stopMeasurementStatusPolling() + accumulators.clear() + pendingRandA = null + pendingRandB = null + session = null + configuredSecrets = null + currentUserInfoSync = null + userInfoSyncGeneration++ + lastManagerInfo = null + pendingProfileSyncAfterManagerInfo = null + managerInfoReadGeneration++ + historyReadActive = false + historyReadCount = 0 + publishedHistoryKeys.clear() + postStatusMeasurementReadsStarted = false + realtimeReadRequestCount = 0 + measurementStatusReady = false + handshakeState = HandshakeState.IDLE + } + + private fun handleWspNotification( + characteristic: UUID, + rawPayload: ByteArray, + encrypted: Boolean, + user: ScaleUser + ) { + val payload = if (encrypted) { + val activeSession = session + if (activeSession == null) { + logW("Encrypted Hagrid notification before work key: chr=${characteristicLabel(characteristic)} len=${rawPayload.size}") + return + } + runCatching { HuaweiHagridWspLib.decryptPayload(rawPayload, activeSession.workKey) } + .getOrElse { error -> + logW("Failed to decrypt Hagrid notification chr=${characteristicLabel(characteristic)}: ${error.message}") + return + } + } else { + rawPayload + } + + logD( + "Huawei Hagrid WSP notification chr=${characteristicLabel(characteristic)} " + + "encrypted=$encrypted rawLen=${rawPayload.size} plainLen=${payload.size}" + ) + + when (characteristic) { + chrRealtimeWeight, + chrBindRequest, + chrBodyCompositionMeasurement, + chrWeightMeasurement -> handleRealtimePayload(payload, user) + + chrRequestAuth -> handleAuthChallenge(payload) + chrAuthToken -> handleAuthResponse(payload) + chrSendWorkKey -> handleWorkKeyAck(payload, user) + chrSetUserInfo -> handleUserInfoAck(payload) + chrGetManagerInfo -> handleManagerInfoPayload(payload) + chrProductInfo -> handleProductInfoPayload(payload) + chrScaleVersion -> handleScaleVersionPayload(payload) + chrGetWeightUnit -> handleWeightUnitPayload(payload) + chrMeasurementStatusPoll, + chrMeasurementStatusResult -> handleMeasurementStatusPayload(payload) + chrHistoryWeight -> handleHistoryWeightPayload(payload, user) + chrCurrentTime -> logD("Huawei Hagrid time-sync ack len=${payload.size}") + else -> logD("Unhandled Hagrid WSP notification chr=${characteristicLabel(characteristic)} len=${payload.size}") + } + } + + private fun handleRawNotification(characteristic: UUID, payload: ByteArray, user: ScaleUser) { + when (characteristic) { + chrWeightMeasurement -> handleStandardWeightPayload(payload, user) + chrBodyCompositionMeasurement -> handleStandardBodyCompositionPayload(payload, user) + chrRealtimeWeight, + chrBindRequest -> handleRealtimePayload(payload, user) + chrSetUserInfo -> handleUserInfoAck(payload) + chrGetManagerInfo -> handleManagerInfoPayload(payload) + chrProductInfo -> handleProductInfoPayload(payload) + chrScaleVersion -> handleScaleVersionPayload(payload) + chrGetWeightUnit -> handleWeightUnitPayload(payload) + chrMeasurementStatusPoll, + chrMeasurementStatusResult -> handleMeasurementStatusPayload(payload) + chrHistoryWeight -> handleHistoryWeightPayload(payload, user) + else -> logD("Unhandled Hagrid raw notification chr=${characteristicLabel(characteristic)} len=${payload.size}") + } + } + + private fun handleAuthChallenge(payload: ByteArray) { + if (payload.size != 16) { + logW("Ignoring invalid Hagrid auth challenge length=${payload.size}") + return + } + + val secrets = configuredSecrets + if (secrets == null) { + logD("Hagrid auth challenge received without configured secrets; staying probe-only") + writeWspPlainIfPresent(svcBodyComposition, chrRealtimeWeight, ByteArray(0)) + return + } + + val randB = randomBytes(16) + require(randB.size == 16) { "randomBytes(16) returned ${randB.size} bytes" } + pendingRandA = payload.copyOf() + pendingRandB = randB + handshakeState = HandshakeState.AUTH_TOKEN_SENT + + val authPayload = HuaweiHagridWspLib.buildAuthTokenPayload(payload, randB, secrets.cak) + logI("Sending Huawei Hagrid auth token (${authPayload.size} bytes)") + writeWspPlainIfPresent(svcUserData, chrAuthToken, authPayload) + } + + private fun handleAuthResponse(payload: ByteArray) { + val secrets = configuredSecrets + val randA = pendingRandA + val randB = pendingRandB + if (secrets == null || randA == null || randB == null) { + logW("Hagrid auth response received without pending auth state") + return + } + + if (!HuaweiHagridWspLib.isValidAuthResponsePayload(payload, randA, randB, secrets.cak)) { + handshakeState = HandshakeState.AUTH_FAILED + logW("Huawei Hagrid auth response verification failed (${payload.size} bytes)") + return + } + + val address = getPeripheral()?.address ?: pendingDeviceAddress + if (address.isNullOrBlank()) { + handshakeState = HandshakeState.AUTH_FAILED + logW("Cannot derive Huawei Hagrid root key without Bluetooth address") + return + } + + val rootKey = runCatching { + HuaweiHagridWspLib.deriveHagridRootKey( + secrets.c1, + secrets.c2, + HuaweiHagridWspLib.hagridC3FromBluetoothAddress(address) + ) + }.getOrElse { error -> + handshakeState = HandshakeState.AUTH_FAILED + logW("Cannot derive Huawei Hagrid root key: ${error.message}") + return + } + + val workKey = randomBytes(16) + val iv = randomBytes(16) + require(workKey.size == 16) { "randomBytes(16) returned ${workKey.size} bytes for workKey" } + require(iv.size == 16) { "randomBytes(16) returned ${iv.size} bytes for work-key IV" } + session = HuaweiHagridWspLib.HagridSession(randA.copyOf(), randB.copyOf(), rootKey, workKey.copyOf()) + handshakeState = HandshakeState.WORK_KEY_SENT + + logI("Huawei Hagrid auth verified; sending encrypted work key") + writeWspFramesIfPresent( + svcUserData, + chrSendWorkKey, + HuaweiHagridWspLib.buildSendWorkKeyFrames(workKey, rootKey, iv) + ) + } + + private fun handleWorkKeyAck(payload: ByteArray, user: ScaleUser) { + val status = payload.firstOrNull()?.toInt()?.and(0xFF) + if (status != 0x00) { + handshakeState = HandshakeState.AUTH_FAILED + logW("Huawei Hagrid work key rejected: status=${status ?: -1}") + return + } + + logI("Huawei Hagrid work key accepted; syncing time and selected profile") + writeWspPlainIfPresent(svcCurrentTime, chrCurrentTime, HuaweiHagridWspLib.currentTimePayload()) + readManagerInfoBeforeProfileSync(user) + } + + private fun readManagerInfoBeforeProfileSync(user: ScaleUser) { + if (!hasCharacteristic(svcUserData, chrGetManagerInfo)) { + beginUserProfileSync(user) + return + } + + pendingProfileSyncAfterManagerInfo = user + val generation = ++managerInfoReadGeneration + writeWspPlainIfPresent(svcUserData, chrGetManagerInfo, ByteArray(0)) + scope.launch { + delay(MANAGER_INFO_TIMEOUT_MS) + if (pendingProfileSyncAfterManagerInfo?.id == user.id && managerInfoReadGeneration == generation) { + logW("Huawei Hagrid manager-info read timed out; using local HUID") + pendingProfileSyncAfterManagerInfo = null + beginUserProfileSync(user) + } + } + } + + private fun beginUserProfileSync(selectedUser: ScaleUser) { + if (!sendEncryptedUserInfo(selectedUser)) { + handshakeState = HandshakeState.READY + sendPostUserReads() + return + } + + currentUserInfoSync = selectedUser + handshakeState = HandshakeState.USER_INFO_SENT + val generation = ++userInfoSyncGeneration + scope.launch { + delay(USER_INFO_ACK_TIMEOUT_MS) + if ( + handshakeState == HandshakeState.USER_INFO_SENT && + userInfoSyncGeneration == generation && + currentUserInfoSync?.id == selectedUser.id + ) { + logW("Huawei Hagrid set-user-info ack timed out; continuing with post-auth reads") + currentUserInfoSync = null + handshakeState = HandshakeState.READY + sendPostUserReads() + } + } + } + + private fun sendEncryptedUserInfo(user: ScaleUser): Boolean { + val activeSession = session + if (activeSession == null) { + logW("Cannot send Huawei Hagrid user-info without an active work key") + return false + } + if (!hasCharacteristic(svcUserData, chrSetUserInfo)) { + logD("Characteristic missing, skip encrypted Huawei Hagrid user-info write") + return false + } + + val info = buildUserInfo(user) + val plaintext = HuaweiHagridWspLib.buildUserInfoPayload(info) + val iv = randomBytes(16) + require(iv.size == 16) { "randomBytes(16) returned ${iv.size} bytes for user-info IV" } + + logD( + "Sending encrypted Huawei Hagrid user-info: userId=${user.id} " + + "payloadLen=${plaintext.size} huidPresent=${info.huid.isNotBlank()}" + ) + HuaweiHagridWspLib.buildEncryptedWriteFrames(plaintext, activeSession.workKey, iv) + .forEach { frame -> writeTo(svcUserData, chrSetUserInfo, frame, withResponse = true) } + return true + } + + private fun handleUserInfoAck(payload: ByteArray) { + val status = payload.firstOrNull()?.toInt()?.and(0xFF) + val accepted = status == 0x00 + if (accepted) { + logI("Huawei Hagrid set-user-info accepted") + } else { + logW("Huawei Hagrid set-user-info returned status=${status ?: -1}") + } + + if (handshakeState == HandshakeState.USER_INFO_SENT) { + currentUserInfoSync = null + handshakeState = HandshakeState.READY + sendPostUserReads() + } + } + + private fun sendPostUserReads() { + writeWspPlainIfPresent(svcCurrentTime, chrProductInfo, ByteArray(0)) + writeWspPlainIfPresent(svcUserData, chrGetManagerInfo, ByteArray(0)) + writeWspPlainIfPresent(svcCurrentTime, chrScaleVersion, ByteArray(0)) + writeWspPlainIfPresent(svcCurrentTime, chrGetWeightUnit, ByteArray(0)) + postStatusMeasurementReadsStarted = false + realtimeReadRequestCount = 0 + startMeasurementStatusPolling() + } + + private fun buildUserInfo(user: ScaleUser): HuaweiHagridWspLib.HagridUserInfo { + val lastWeight = lastMeasurementFor(user.id)?.weight + val configuredWeight = when { + lastWeight != null && lastWeight.isFinite() && lastWeight > 0f -> lastWeight + user.initialWeight.isFinite() && user.initialWeight > 0f -> user.initialWeight + else -> 0f + } + val heightCm = if (user.bodyHeight.isFinite() && user.bodyHeight > 0f) { + user.bodyHeight.roundToInt().coerceIn(0, 0xFFFF) + } else { + 0 + } + + return HuaweiHagridWspLib.HagridUserInfo( + huid = lastManagerInfo?.huid?.takeIf { it.isNotBlank() } ?: localSyntheticHuid(), + uid = "u:%08X".format(Locale.US, user.id), + gender = if (user.gender == GenderType.MALE) 0 else 1, + ageYears = user.age.coerceIn(0, 255), + heightCm = heightCm, + weightKg = configuredWeight, + ) + } + + private fun localSyntheticHuid(): String { + val compactAddress = (getPeripheral()?.address ?: pendingDeviceAddress) + ?.filter { it != ':' && it != '-' } + ?.uppercase(Locale.US) + ?.takeIf { it.length == 12 } + ?: "000000000000" + return "openscale:$compactAddress" + } + + private fun handleRealtimePayload(payload: ByteArray, user: ScaleUser) { + val parsed = HuaweiHagridWspLib.parseRealtimeMeasurement(payload) + if (parsed == null || parsed.weightKg <= 0f) { + logD("Ignoring unsupported Hagrid realtime payload len=${payload.size}") + return + } + + userInfo(R.string.bluetooth_scale_info_measuring_weight, parsed.weightKg) + logD( + "Hagrid realtime measurement parsed len=${parsed.rawLength} " + + "lowCount=${parsed.lowFrequencyImpedance.size} highCount=${parsed.highFrequencyImpedance.size} saved=false" + ) + } + + private fun handleHistoryWeightPayload(payload: ByteArray, user: ScaleUser) { + val parsed = HuaweiHagridWspLib.parseHistoryMeasurement(payload) + if (parsed == null || parsed.weightKg <= 0f) { + historyReadActive = false + logD("Hagrid history read finished or unsupported len=${payload.size}") + return + } + + historyReadActive = true + historyReadCount += 1 + val key = parsed.historyDedupeKey + val shouldPublish = key == null || publishedHistoryKeys.add(key) + if (shouldPublish) { + publishHagridMeasurement(parsed, user) + } else { + logD("Skipped duplicate Hagrid history measurement") + } + + val complete = parsed.isHistoryComplete == true + if (!complete && historyReadCount < MAX_HISTORY_RECORDS) { + writeWspPlainIfPresent(svcBodyComposition, chrHistoryWeight, byteArrayOf(0x00)) + } else { + historyReadActive = false + logD( + "Hagrid history read stopped: complete=$complete count=$historyReadCount " + + "saved=${publishedHistoryKeys.size}" + ) + } + } + + private fun publishHagridMeasurement( + parsed: HuaweiHagridWspLib.HagridWeightMeasurement, + user: ScaleUser + ) { + val low = parsed.representativeLowOhm ?: 0.0 + val high = parsed.representativeHighOhm ?: low + val measurement = ScaleMeasurement( + userId = user.id, + dateTime = parsed.timestamp ?: Date(), + weight = parsed.weightKg, + fat = parsed.fatPercent.takeIf { it > 0f && it < 80f } ?: 0f, + heartRate = parsed.heartRateBpm ?: 0, + impedance = high, + impedanceLow = low, + ) + logI( + "Publishing Huawei Hagrid measurement: lowCount=${parsed.lowFrequencyImpedance.size} " + + "highCount=${parsed.highFrequencyImpedance.size}" + ) + publish(measurement) + } + + private fun handleStandardWeightPayload(payload: ByteArray, user: ScaleUser) { + val parsed = HuaweiHagridWspLib.parseStandardWeightMeasurement(payload) + val weight = parsed?.weightKg + if (weight == null || weight <= 0f) { + logD("Ignoring unsupported standard weight payload len=${payload.size}") + return + } + + publish( + ScaleMeasurement( + userId = user.id, + dateTime = parsed.timestamp ?: Date(), + weight = weight, + ) + ) + } + + private fun handleStandardBodyCompositionPayload(payload: ByteArray, user: ScaleUser) { + val parsed = HuaweiHagridWspLib.parseStandardBodyCompositionMeasurement(payload) + if (parsed == null) { + logD("Ignoring unsupported standard body-composition payload len=${payload.size}") + return + } + if (parsed.weightKg == null && parsed.fatPercent == null && parsed.impedanceOhm == null) { + return + } + + publish( + ScaleMeasurement( + userId = user.id, + dateTime = parsed.timestamp ?: Date(), + weight = parsed.weightKg ?: 0f, + fat = parsed.fatPercent?.takeIf { it > 0f && it < 80f } ?: 0f, + muscle = parsed.musclePercent?.takeIf { it > 0f && it < 100f } ?: 0f, + impedance = parsed.impedanceOhm ?: 0.0, + ) + ) + } + + private fun handleManagerInfoPayload(payload: ByteArray) { + val parsed = HuaweiHagridWspLib.parseManagerInfo(payload) + if (parsed == null) { + logD("Ignoring unsupported Hagrid manager-info payload len=${payload.size}") + return + } + lastManagerInfo = parsed + logD("Huawei Hagrid manager-info read len=${parsed.rawLength} huidPresent=${parsed.huid.isNotBlank()}") + + pendingProfileSyncAfterManagerInfo?.let { user -> + pendingProfileSyncAfterManagerInfo = null + beginUserProfileSync(user) + } + } + + private fun handleProductInfoPayload(payload: ByteArray) { + val parsed = HuaweiHagridWspLib.parseProductInfo(payload) + if (parsed == null) { + logD("Ignoring unsupported Hagrid product-info payload len=${payload.size}") + return + } + pendingProductProfile = HuaweiHagridWspLib.productProfileForMarker(parsed.smartProductId) + logDeviceProfile("product-info") + } + + private fun handleScaleVersionPayload(payload: ByteArray) { + val parsed = HuaweiHagridWspLib.parseScaleVersion(payload) + if (parsed == null) { + logD("Ignoring unsupported Hagrid scale-version payload len=${payload.size}") + return + } + logD("Huawei Hagrid scale version=${parsed.version} len=${parsed.rawLength}") + } + + private fun handleWeightUnitPayload(payload: ByteArray) { + val parsed = HuaweiHagridWspLib.parseWeightUnit(payload) + if (parsed == null) { + logD("Ignoring unsupported Hagrid weight-unit payload len=${payload.size}") + return + } + logD("Huawei Hagrid weight unit=${parsed.label} raw=${parsed.raw}") + } + + private fun handleMeasurementStatusPayload(payload: ByteArray) { + val status = payload.firstOrNull()?.toInt()?.and(0xFF) + logD("Huawei Hagrid measurement-status len=${payload.size} status=${status ?: -1}") + if (status == 0x00) { + measurementStatusReady = true + sendMeasurementDataRequests() + } + } + + private fun startMeasurementStatusPolling() { + if (!hasCharacteristic(svcCurrentTime, chrMeasurementStatusPoll)) { + sendMeasurementDataRequests() + return + } + + measurementStatusPollingActive = true + measurementStatusReady = false + measurementStatusPollCount = 0 + sendMeasurementStatusPoll() + + measurementStatusPollJob?.cancel() + measurementStatusPollJob = scope.launch { + while ( + measurementStatusPollingActive && + !measurementStatusReady && + measurementStatusPollCount < MAX_STATUS_POLLS + ) { + delay(MEASUREMENT_STATUS_POLL_INTERVAL_MS) + if (measurementStatusPollingActive && !measurementStatusReady) { + sendMeasurementStatusPoll() + } + } + if (measurementStatusPollingActive && !measurementStatusReady) { + logW("Huawei Hagrid measurement status timed out; requesting measurements directly") + sendMeasurementDataRequests() + } + } + } + + private fun stopMeasurementStatusPolling() { + measurementStatusPollingActive = false + measurementStatusPollJob?.cancel() + measurementStatusPollJob = null + measurementStatusPollCount = 0 + } + + private fun sendMeasurementStatusPoll() { + measurementStatusPollCount += 1 + writeWspPlainIfPresent( + svcCurrentTime, + chrMeasurementStatusPoll, + ByteArray(0), + withResponse = false + ) + } + + private fun sendMeasurementDataRequests() { + stopMeasurementStatusPolling() + if (postStatusMeasurementReadsStarted) { + sendRealtimeMeasurementRequest() + return + } + + postStatusMeasurementReadsStarted = true + if (hasCharacteristic(svcBodyComposition, chrHistoryWeight)) { + historyReadActive = true + historyReadCount = 0 + writeWspPlainIfPresent(svcBodyComposition, chrHistoryWeight, ByteArray(0)) + } + sendRealtimeMeasurementRequest() + } + + private fun sendRealtimeMeasurementRequest() { + realtimeReadRequestCount += 1 + writeWspPlainIfPresent(svcBodyComposition, chrRealtimeWeight, ByteArray(0)) + } + + private fun setNotifyIfPresent(service: UUID, characteristic: UUID) { + if (hasCharacteristic(service, characteristic)) { + setNotifyOn(service, characteristic) + } + } + + private fun writeWspPlainIfPresent( + service: UUID, + characteristic: UUID, + payload: ByteArray, + withResponse: Boolean = true + ) { + if (!hasCharacteristic(service, characteristic)) return + HuaweiHagridWspLib.buildWriteFrames(payload, encrypted = false) + .forEach { frame -> writeTo(service, characteristic, frame, withResponse) } + } + + private fun writeWspFramesIfPresent( + service: UUID, + characteristic: UUID, + frames: List + ) { + if (!hasCharacteristic(service, characteristic)) return + frames.forEach { frame -> writeTo(service, characteristic, frame, withResponse = true) } + } + + private fun loadConfiguredSecrets(): HuaweiHagridWspLib.HagridSecrets? = + runCatching { + secretProvider.load( + getString = { key -> settingsGetString(key) }, + warn = { message -> logW(message) } + ) + } + .onFailure { logW("Invalid Huawei Hagrid secret settings: ${it.message}") } + .getOrNull() + + private fun logDeviceProfile(source: String) { + val highFrequency = pendingCapabilityBits?.supportsHighFrequencyImpedance + ?: pendingProductProfile.expectedHighFrequencyImpedance + logD( + "Huawei Hagrid profile source=$source family=${pendingProductProfile.family} " + + "display=${pendingProductProfile.displayName} highFrequency=$highFrequency" + ) + } + + private fun findProductMarker(device: ScannedDeviceInfo): String? { + extractProductMarker(device.name)?.let { return it } + asciiFromManufacturerData(device.manufacturerData).forEach { text -> + extractProductMarker(text)?.let { return it } + } + asciiFromServiceData(device.serviceData).forEach { text -> + extractProductMarker(text)?.let { return it } + } + return null + } + + private fun findCapabilityBits(device: ScannedDeviceInfo): HuaweiHagridWspLib.HagridCapabilityBits? { + val texts = buildList { + add(device.name) + addAll(asciiFromManufacturerData(device.manufacturerData)) + addAll(asciiFromServiceData(device.serviceData)) + } + + for (text in texts) { + val match = CAPABILITY_PATTERN.find(text) ?: continue + HuaweiHagridWspLib.parseCapabilityBits(match.groupValues[1])?.let { return it } + } + return null + } + + private fun extractProductMarker(raw: String?): String? { + val text = raw?.uppercase(Locale.US) ?: return null + return PRODUCT_MARKERS.firstOrNull { marker -> text.contains(marker) } + } + + private fun asciiFromManufacturerData(data: SparseArray?): List { + if (data == null) return emptyList() + return buildList { + for (index in 0 until data.size()) { + val bytes = data.valueAt(index) ?: continue + add(bytes.toAsciiString()) + } + } + } + + private fun asciiFromServiceData(data: Map): List = + data.values.map { it.toAsciiString() } + + private fun ByteArray.toAsciiString(): String = + map { byte -> + val value = byte.toInt() and 0xFF + if (value in 0x20..0x7E) value.toChar() else ' ' + }.joinToString(separator = "") + + private fun characteristicLabel(characteristic: UUID): String = + when (characteristic) { + chrRequestAuth -> "request-auth" + chrAuthToken -> "auth-token" + chrSendWorkKey -> "send-work-key" + chrBindRequest -> "bind-request" + chrSetUserInfo -> "set-user-info" + chrGetManagerInfo -> "get-manager-info" + chrCurrentTime -> "current-time" + chrProductInfo -> "product-info" + chrScaleVersion -> "scale-version" + chrGetWeightUnit -> "weight-unit" + chrMeasurementStatusPoll -> "measurement-status-poll" + chrMeasurementStatusResult -> "measurement-status-result" + chrWeightMeasurement -> "weight-measurement" + chrBodyCompositionMeasurement -> "body-composition-measurement" + chrRealtimeWeight -> "realtime-weight" + chrHistoryWeight -> "history-weight" + else -> characteristic.toString() + } + + private enum class HandshakeState { + IDLE, + PROBE_ONLY, + WAITING_AUTH_CHALLENGE, + AUTH_TOKEN_SENT, + WORK_KEY_SENT, + USER_INFO_SENT, + READY, + AUTH_FAILED + } + + companion object { + const val SETTINGS_KEY_CAK_HEX = "ble/huawei_hagrid/cak_hex" + const val SETTINGS_KEY_C1_HEX = "ble/huawei_hagrid/c1_hex" + const val SETTINGS_KEY_C2_HEX = "ble/huawei_hagrid/c2_hex" + + private const val MANAGER_INFO_TIMEOUT_MS = 2500L + private const val USER_INFO_ACK_TIMEOUT_MS = 3000L + private const val MEASUREMENT_STATUS_POLL_INTERVAL_MS = 3000L + private const val MAX_STATUS_POLLS = 30 + private const val MAX_HISTORY_RECORDS = 64 + + private val PRODUCT_MARKERS = listOf( + "HUAWEI SCALE 2 PRO", + "HAG-B19", + "HAGRID-B19", + "HUAWEI SCALE 3 PRO", + "HAGRID-B29", + "HAGRID2021-B19", + "HUAWEI SCALE 3", + "HEM-B19", + "HERM-B19", + "HONOR SCALE 2", + "LUP-B19", + "LUPIN-B19HN", + "DOBBY-B19", + "M00F", + "M00D", + "M0CJ", + "N001", + "007B", + ) + + private val CAPABILITY_PATTERN = + Regex("""(?i)(?:capacity|capability|caps?)[:=]([0-9a-f]+)""") + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt index 1dc0fe7f8..61789a824 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt @@ -85,6 +85,7 @@ object SettingsPreferenceKeys { val SAVED_BLUETOOTH_DEVICE_SERVICE_UUIDS = stringSetPreferencesKey("saved_bluetooth_device_service_uuids") val SAVED_BLUETOOTH_DEVICE_HANDLER_HINT = stringPreferencesKey("saved_bluetooth_device_handler_hint") val SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA = stringPreferencesKey("saved_bluetooth_device_manufacturer_data") + val SAVED_BLUETOOTH_DEVICE_SERVICE_DATA = stringPreferencesKey("saved_bluetooth_device_service_data") val SAVED_BLUETOOTH_TUNE_PROFILE = stringPreferencesKey("saved_bluetooth_tune_profile") val SAVED_BLUETOOTH_SMART_ASSIGNMENT_ENABLED = booleanPreferencesKey("saved_bluetooth_smart_assignment_enabled") val SAVED_BLUETOOTH_TOLERANCE_PERCENT = intPreferencesKey("saved_bluetooth_tolerance_percent") @@ -461,14 +462,16 @@ class SettingsFacadeImpl @Inject constructor( val uuidsF = observeSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_UUIDS.name, emptySet()) val hintF = observeSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_HANDLER_HINT.name, null as String?) val manDataF = observeSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA.name, null as String?) + val serviceDataF = observeSetting(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_DATA.name, null as String?) - return combine(addrF, nameF, rssiF, uuidsF, hintF, manDataF) { array -> + return combine(addrF, nameF, rssiF, uuidsF, hintF, manDataF, serviceDataF) { array -> val addr = array[0] as String? val name = array[1] as String? val rssi = array[2] as Int val uuidStrSet = (array[3] as? Set<*>)?.filterIsInstance() ?: emptySet() val hint = array[4] as String? val manDataJson = array[5] as String? + val serviceDataJson = array[6] as String? if (addr.isNullOrBlank()) { null @@ -490,12 +493,25 @@ class SettingsFacadeImpl @Inject constructor( } } + val serviceData = mutableMapOf() + serviceDataJson?.let { jsonStr -> + runCatching { + val jsonObj = JSONObject(jsonStr) + jsonObj.keys().forEach { key -> + val uuid = UUID.fromString(key) + val value = Base64.decode(jsonObj.getString(key), Base64.NO_WRAP) + serviceData[uuid] = value + } + } + } + ScannedDeviceInfo( name = name ?: "", address = addr, rssi = rssi, serviceUuids = uuids, manufacturerData = if (manData.isNotEmpty()) manData else null, + serviceData = serviceData.toMap(), isSupported = false, determinedHandlerDisplayName = hint ) @@ -528,6 +544,12 @@ class SettingsFacadeImpl @Inject constructor( } } prefs[SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA] = manData.toString() + + val serviceData = JSONObject() + device.serviceData.forEach { (uuid, bytes) -> + serviceData.put(uuid.toString(), Base64.encodeToString(bytes, Base64.NO_WRAP)) + } + prefs[SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_DATA] = serviceData.toString() } } @@ -540,6 +562,7 @@ class SettingsFacadeImpl @Inject constructor( prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_UUIDS) prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_HANDLER_HINT) prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_MANUFACTURER_DATA) + prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_DEVICE_SERVICE_DATA) prefs.remove(SettingsPreferenceKeys.SAVED_BLUETOOTH_AUTO_CONNECT) } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/service/BleScanner.kt b/android_app/app/src/main/java/com/health/openscale/core/service/BleScanner.kt index a30f42698..42d91f6a7 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/service/BleScanner.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/service/BleScanner.kt @@ -49,6 +49,7 @@ import kotlin.time.Duration.Companion.milliseconds * @property rssi The received signal strength indicator (RSSI) in dBm. * @property serviceUuids A list of service UUIDs advertised by the device. * @property manufacturerData Manufacturer-specific data advertised by the device. + * @property serviceData Service-data payloads advertised by the device. * @property isSupported Flag indicating whether openScale has a handler for this device. * @property determinedHandlerDisplayName The display name of the handler determined for this device, if any. */ @@ -58,6 +59,7 @@ data class ScannedDeviceInfo( val rssi: Int, val serviceUuids: List, val manufacturerData: SparseArray?, + val serviceData: Map = emptyMap(), var isSupported: Boolean = false, var determinedHandlerDisplayName: String? = null ) @@ -250,6 +252,10 @@ class BluetoothScannerManager( val rssi = scanResult.rssi val serviceUuids: List = scanResult.scanRecord?.serviceUuids?.mapNotNull { it?.uuid } ?: emptyList() val manufacturerData: SparseArray? = scanResult.scanRecord?.manufacturerSpecificData + val serviceData: Map = scanResult.scanRecord?.serviceData + ?.mapKeys { it.key.uuid } + ?.mapValues { it.value.copyOf() } + ?: emptyMap() val newDevice = ScannedDeviceInfo( name = deviceName, @@ -257,6 +263,7 @@ class BluetoothScannerManager( rssi = rssi, serviceUuids = serviceUuids, manufacturerData = manufacturerData, + serviceData = serviceData, isSupported = false, // will be determined in the next getSupportingHandlerInfo determinedHandlerDisplayName = null // // will be determined in the next getSupportingHandlerInfo ) @@ -276,15 +283,17 @@ class BluetoothScannerManager( val handlerChanged = newDevice.determinedHandlerDisplayName != existingDevice.determinedHandlerDisplayName val serviceUuidsUpdated = newDevice.serviceUuids.isNotEmpty() && newDevice.serviceUuids != existingDevice.serviceUuids val manuDataUpdated = newDevice.manufacturerData != null && !newDevice.manufacturerData.contentEquals(existingDevice.manufacturerData) + val serviceDataUpdated = newDevice.serviceData.isNotEmpty() && !newDevice.serviceData.contentEquals(existingDevice.serviceData) - if (newDevice.rssi != existingDevice.rssi || nameChangedToKnown || supportStatusImproved || handlerChanged || serviceUuidsUpdated || manuDataUpdated) { + if (newDevice.rssi != existingDevice.rssi || nameChangedToKnown || supportStatusImproved || handlerChanged || serviceUuidsUpdated || manuDataUpdated || serviceDataUpdated) { deviceMap[newDevice.address] = existingDevice.copy( name = newDevice.name.ifEmpty { existingDevice.name }, // Prefer new name if available. rssi = newDevice.rssi, isSupported = existingDevice.isSupported || newDevice.isSupported, // Retain 'supported' status if ever true. determinedHandlerDisplayName = newDevice.determinedHandlerDisplayName ?: existingDevice.determinedHandlerDisplayName, serviceUuids = if (newDevice.serviceUuids.isNotEmpty()) newDevice.serviceUuids else existingDevice.serviceUuids, - manufacturerData = newDevice.manufacturerData ?: existingDevice.manufacturerData + manufacturerData = newDevice.manufacturerData ?: existingDevice.manufacturerData, + serviceData = newDevice.serviceData.ifEmpty { existingDevice.serviceData } ) listShouldBeUpdated = true } @@ -294,7 +303,8 @@ class BluetoothScannerManager( if (newDevice.isSupported || newDevice.name.isNotEmpty() || newDevice.serviceUuids.isNotEmpty() || - (newDevice.manufacturerData != null && newDevice.manufacturerData.isNotEmpty()) + (newDevice.manufacturerData != null && newDevice.manufacturerData.isNotEmpty()) || + newDevice.serviceData.isNotEmpty() ) { deviceMap[newDevice.address] = newDevice listShouldBeUpdated = true @@ -324,3 +334,24 @@ class BluetoothScannerManager( } } + +private fun SparseArray?.contentEquals(other: SparseArray?): Boolean { + if (this == null || other == null) return this == other + if (size() != other.size()) return false + for (i in 0 until size()) { + val key = keyAt(i) + val otherIndex = other.indexOfKey(key) + if (otherIndex < 0) return false + if (!valueAt(i).contentEquals(other.valueAt(otherIndex))) return false + } + return true +} + +private fun Map.contentEquals(other: Map): Boolean { + if (size != other.size) return false + for ((key, value) in this) { + val otherValue = other[key] ?: return false + if (!value.contentEquals(otherValue)) return false + } + return true +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt new file mode 100644 index 000000000..807029317 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/HuaweiHagridWspLibTest.kt @@ -0,0 +1,154 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.util.Calendar +import java.util.TimeZone + +class HuaweiHagridWspLibTest { + + @Test + fun `write frames reassemble from notification frames`() { + val payload = ByteArray(40) { it.toByte() } + val frames = HuaweiHagridWspLib.buildWriteFrames(payload) + val accumulator = HuaweiHagridWspLib.FrameAccumulator() + + assertThat(frames).hasSize(3) + val first = accumulator.ingest(toNotifyFrame(frames[0])) + val second = accumulator.ingest(toNotifyFrame(frames[1])) + val third = accumulator.ingest(toNotifyFrame(frames[2])) + + assertThat(first).isNull() + assertThat(second).isNull() + assertThat(third).isEqualTo(payload) + } + + @Test + fun `auth token validates expected scale response`() { + val randA = ByteArray(16) { it.toByte() } + val randB = ByteArray(16) { (it + 16).toByte() } + val cak = ByteArray(16) { (it + 32).toByte() } + + val token = HuaweiHagridWspLib.buildAuthTokenPayload(randA, randB, cak) + val expectedResponse = HuaweiHagridWspLib.expectedAuthResponsePayload(randA, randB, cak) + + assertThat(token.copyOfRange(0, 16)).isEqualTo(randB) + assertThat(token).hasLength(48) + assertThat(expectedResponse).hasLength(32) + assertThat(HuaweiHagridWspLib.isValidAuthResponsePayload(expectedResponse, randA, randB, cak)) + .isTrue() + } + + @Test + fun `encrypted payload round trips`() { + val plaintext = "hagrid-user-profile".encodeToByteArray() + val key = ByteArray(16) { (it + 4).toByte() } + val iv = ByteArray(16) { (it + 80).toByte() } + + val encrypted = HuaweiHagridWspLib.encryptedPayloadWithIv(plaintext, key, iv) + + assertThat(encrypted.copyOfRange(0, 16)).isEqualTo(iv) + assertThat(HuaweiHagridWspLib.decryptPayload(encrypted, key)).isEqualTo(plaintext) + } + + @Test + fun `realtime payload parses low and high impedance bands`() { + val parsed = HuaweiHagridWspLib.parseRealtimeMeasurement(realtimePayload()) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.weightKg).isWithin(0.0001f).of(77.32f) + assertThat(parsed.fatPercent).isWithin(0.0001f).of(18.5f) + assertThat(parsed.heartRateBpm).isEqualTo(72) + assertThat(parsed.lowFrequencyImpedanceOhm).containsExactly(500.0, 501.0, 502.0, 503.0, 504.0, 505.0) + assertThat(parsed.highFrequencyImpedanceOhm).containsExactly(600.0, 601.0, 602.0, 603.0, 604.0, 605.0) + } + + @Test + fun `history payload parses timestamp and terminal flag`() { + val parsed = HuaweiHagridWspLib.parseHistoryMeasurement(historyPayload(completeFlag = 0x00)) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.timestamp).isNotNull() + assertThat(parsed.isHistoryComplete).isTrue() + assertThat(parsed.historyDedupeKey).isNotEmpty() + + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + calendar.time = parsed.timestamp!! + assertThat(calendar.get(Calendar.YEAR)).isEqualTo(2026) + assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.JUNE) + assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(22) + } + + @Test + fun `standard weight measurement parses kilograms`() { + val parsed = HuaweiHagridWspLib.parseStandardWeightMeasurement( + byteArrayOf(0x00, 0x8C.toByte(), 0x3C) + ) + + assertThat(parsed).isNotNull() + assertThat(parsed!!.weightKg).isWithin(0.0001f).of(77.5f) + } + + @Test + fun `user info payload has fixed Huawei layout`() { + val payload = HuaweiHagridWspLib.buildUserInfoPayload( + HuaweiHagridWspLib.HagridUserInfo( + huid = "openscale:001122334455", + uid = "u:00000007", + gender = 1, + ageYears = 26, + heightCm = 171, + weightKg = 83.2f, + ) + ) + + assertThat(payload).hasLength(69) + assertThat(payload[62].toInt() and 0xFF).isEqualTo(1) + assertThat(payload[63].toInt() and 0xFF).isEqualTo(26) + assertThat(payload[64].toInt() and 0xFF).isEqualTo(171) + assertThat(payload[65].toInt() and 0xFF).isEqualTo(0) + assertThat(payload[66].toInt() and 0xFF).isEqualTo(0x80) + assertThat(payload[67].toInt() and 0xFF).isEqualTo(0x20) + } + + private fun toNotifyFrame(writeFrame: ByteArray): ByteArray { + val notifyFrame = writeFrame.copyOf() + notifyFrame[0] = HuaweiHagridWspLib.FRAME_NOTIFY_PLAIN.toByte() + val crc = HuaweiHagridWspLib.crc16Modbus(notifyFrame.copyOfRange(0, notifyFrame.size - 2)) + notifyFrame[notifyFrame.size - 2] = (crc and 0xFF).toByte() + notifyFrame[notifyFrame.size - 1] = ((crc ushr 8) and 0xFF).toByte() + return notifyFrame + } + + private fun realtimePayload(): ByteArray = + byteArrayOf( + 0x34, 0x1E, + 0xB9.toByte(), 0x00, + 0xEA.toByte(), 0x07, 0x06, 0x16, 0x0F, 0x0E, 0x2A, 0x00, + 0xF4.toByte(), 0x01, 0xF5.toByte(), 0x01, 0xF6.toByte(), 0x01, + 0xF7.toByte(), 0x01, 0xF8.toByte(), 0x01, 0xF9.toByte(), 0x01, + 0x48, 0x00, + 0x58, 0x02, 0x59, 0x02, 0x5A, 0x02, + 0x5B, 0x02, 0x5C, 0x02, 0x5D, 0x02, + ) + + private fun historyPayload(completeFlag: Int): ByteArray = + realtimePayload() + byteArrayOf(0x01, completeFlag.toByte()) +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt new file mode 100644 index 000000000..8b5624c82 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/HuaweiHagridWspHandlerTest.kt @@ -0,0 +1,549 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import android.util.SparseArray +import com.google.common.truth.Truth.assertThat +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.HuaweiHagridWspLib +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.CoroutineScope +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.ArrayDeque +import java.util.Date +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HuaweiHagridWspHandlerTest { + + @Test + fun `claims known Huawei Hagrid devices without claiming generic BIA`() { + val scale2 = HuaweiHagridWspHandler().supportFor(device("HUAWEI Scale 2 Pro")) + val scale3Pro = HuaweiHagridWspHandler().supportFor(device("Hagrid-B29")) + val generic = HuaweiHagridWspHandler().supportFor( + device("Generic BIA", services = intArrayOf(0x1805, 0x181B, 0x181C, 0x181D)) + ) + + assertThat(scale2).isNotNull() + assertThat(scale2!!.displayName).isEqualTo("HUAWEI Scale 2 Pro") + assertThat(scale3Pro).isNotNull() + assertThat(scale3Pro!!.displayName).isEqualTo("HUAWEI Scale 3 Pro") + assertThat(scale2.implemented).doesNotContain(DeviceCapability.USER_SYNC) + assertThat(scale2.implemented).doesNotContain(DeviceCapability.UNIT_CONFIG) + assertThat(generic).isNull() + } + + @Test + fun `claims product id from manufacturer or service data`() { + val manufacturerData = SparseArray().apply { + put(0x027D, "model=007B".encodeToByteArray()) + } + val manufacturerSupport = HuaweiHagridWspHandler().supportFor( + device(name = "Unknown Scale", manufacturerData = manufacturerData) + ) + val serviceSupport = HuaweiHagridWspHandler().supportFor( + device( + name = "Unknown Scale", + serviceData = mapOf(uuid16(0xFD00) to "product=007B".encodeToByteArray()) + ) + ) + + assertThat(manufacturerSupport).isNotNull() + assertThat(serviceSupport).isNotNull() + assertThat(manufacturerSupport!!.displayName).isEqualTo("HUAWEI Scale 2 Pro") + assertThat(serviceSupport!!.displayName).isEqualTo("HUAWEI Scale 2 Pro") + } + + @Test + fun `raw standard weight notification publishes standard measurement`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + handler.handleNotification( + characteristic = CHR_WEIGHT_MEASUREMENT, + data = byteArrayOf(0x00, 0x8C.toByte(), 0x3C), + ) + + assertThat(callbacks.published).hasSize(1) + assertThat(callbacks.published.single().userId).isEqualTo(7) + assertThat(callbacks.published.single().weight).isWithin(0.0001f).of(77.5f) + } + + @Test + fun `does not send auth token when Hagrid secrets are not configured`() { + val handler = HuaweiHagridWspHandler() + val transport = CapturingTransport.allHagrid() + handler.supportFor(device("HUAWEI Scale 2 Pro", address = HAGRID_ADDRESS)) + handler.attach( + transport = transport, + callbacks = CapturingCallbacks(), + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + handler.handleConnected(ScaleUser(id = 7)) + sendWspNotification(handler, CHR_REQUEST_AUTH, ByteArray(16) { it.toByte() }) + + assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_AUTH_TOKEN) + assertThat(transport.writes.map { it.characteristic }).contains(CHR_REALTIME_WEIGHT) + } + + @Test + fun `plain WSP realtime notification reports progress without publishing`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + handler.attach( + transport = NoopTransport(), + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_REALTIME_WEIGHT, realtimePayload()) + + assertThat(callbacks.published).isEmpty() + assertThat(callbacks.infos.map { it.resId }) + .contains(R.string.bluetooth_scale_info_measuring_weight) + assertThat(callbacks.infos.single().args.single() as Float) + .isWithin(0.0001f).of(77.32f) + } + + @Test + fun `Hagrid history notification publishes non-terminal record and acknowledges next read`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + val transport = CapturingTransport(setOf(SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT)) + handler.attach( + transport = transport, + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_HISTORY_WEIGHT, historyPayload(completeFlag = 0x01)) + + assertThat(callbacks.published).hasSize(1) + val measurement = callbacks.published.single() + assertThat(measurement.weight).isWithin(0.0001f).of(77.32f) + assertThat(measurement.fat).isWithin(0.0001f).of(18.5f) + assertThat(measurement.heartRate).isEqualTo(72) + assertThat(measurement.impedanceLow).isWithin(0.0001).of(500.0) + assertThat(measurement.impedance).isWithin(0.0001).of(600.0) + assertThat(transport.reassembleWriteMessagesTo(CHR_HISTORY_WEIGHT).last()) + .isEqualTo(byteArrayOf(0x00)) + } + + @Test + fun `Hagrid history duplicate is acknowledged without duplicate publish`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + val transport = CapturingTransport(setOf(SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT)) + handler.attach( + transport = transport, + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_HISTORY_WEIGHT, historyPayload(completeFlag = 0x01)) + sendWspNotification(handler, CHR_HISTORY_WEIGHT, historyPayload(completeFlag = 0x01)) + + assertThat(callbacks.published).hasSize(1) + assertThat(transport.reassembleWriteMessagesTo(CHR_HISTORY_WEIGHT)).hasSize(2) + } + + @Test + fun `Hagrid history terminal notification publishes without next-read ack`() { + val handler = HuaweiHagridWspHandler() + val callbacks = CapturingCallbacks() + val transport = CapturingTransport(setOf(SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT)) + handler.attach( + transport = transport, + callbacks = callbacks, + settings = InMemorySettings(), + data = FixedDataProvider(ScaleUser(id = 7)), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + sendWspNotification(handler, CHR_HISTORY_WEIGHT, historyPayload(completeFlag = 0x00)) + + assertThat(callbacks.published).hasSize(1) + assertThat(transport.reassembleWriteMessagesTo(CHR_HISTORY_WEIGHT)).isEmpty() + } + + @Test + fun `configured fake Hagrid secrets authenticate and start measurement reads after status ready`() { + val randA = ByteArray(16) { it.toByte() } + val randB = ByteArray(16) { (it + 16).toByte() } + val workKey = ByteArray(16) { (it + 32).toByte() } + val workKeyIv = ByteArray(16) { (it + 48).toByte() } + val cak = ByteArray(16) { (it + 64).toByte() } + val c1 = ByteArray(16) { (it + 80).toByte() } + val c2 = ByteArray(16) { (it + 96).toByte() } + val userInfoIv = ByteArray(16) { (it + 112).toByte() } + val user = ScaleUser( + id = 7, + birthday = Date(946684800000L), + bodyHeight = 171f, + gender = GenderType.FEMALE, + initialWeight = 83.2f, + ) + val randomValues = ArrayDeque(listOf(randB, workKey, workKeyIv, userInfoIv)) + val handler = HuaweiHagridWspHandler(randomBytes = { size -> + assertThat(size).isEqualTo(16) + randomValues.removeFirst() + }) + val settings = InMemorySettings().apply { + putString(HuaweiHagridWspHandler.SETTINGS_KEY_CAK_HEX, cak.toHex()) + putString(HuaweiHagridWspHandler.SETTINGS_KEY_C1_HEX, c1.toHex()) + putString(HuaweiHagridWspHandler.SETTINGS_KEY_C2_HEX, c2.toHex()) + } + val transport = CapturingTransport.allHagrid() + handler.supportFor(device("HUAWEI Scale 2 Pro", address = HAGRID_ADDRESS)) + handler.attach( + transport = transport, + callbacks = CapturingCallbacks(), + settings = settings, + data = FixedDataProvider(user), + scope = CoroutineScope(EmptyCoroutineContext), + ) + + handler.handleConnected(user) + sendWspNotification(handler, CHR_REQUEST_AUTH, randA) + + val authTokenPayload = transport.reassembleWritesTo(CHR_AUTH_TOKEN) + assertThat(authTokenPayload) + .isEqualTo(HuaweiHagridWspLib.buildAuthTokenPayload(randA, randB, cak)) + + sendWspNotification( + handler, + CHR_AUTH_TOKEN, + HuaweiHagridWspLib.expectedAuthResponsePayload(randA, randB, cak) + ) + + val rootKey = HuaweiHagridWspLib.deriveHagridRootKey( + c1, + c2, + HuaweiHagridWspLib.hagridC3FromBluetoothAddress(HAGRID_ADDRESS) + ) + val workKeyPayload = transport.reassembleWritesTo(CHR_SEND_WORK_KEY) + assertThat(workKeyPayload).hasLength(32) + assertThat(HuaweiHagridWspLib.decryptPayload(workKeyPayload, rootKey)).isEqualTo(workKey) + + sendWspNotification(handler, CHR_SEND_WORK_KEY, byteArrayOf(0x00)) + + assertThat(transport.writes.map { it.characteristic }).contains(CHR_GET_MANAGER_INFO) + assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_SET_USER_INFO) + + sendWspNotification(handler, CHR_GET_MANAGER_INFO, managerInfoPayload()) + + val userInfoPayload = transport.reassembleWritesTo(CHR_SET_USER_INFO) + assertThat(userInfoPayload).hasLength(85) + assertThat(HuaweiHagridWspLib.decryptPayload(userInfoPayload, workKey)) + .isEqualTo( + HuaweiHagridWspLib.buildUserInfoPayload( + HuaweiHagridWspLib.HagridUserInfo( + huid = HAGRID_MANAGER_HUID, + uid = "u:00000007", + gender = 1, + ageYears = user.age, + heightCm = 171, + weightKg = 83.2f, + ) + ) + ) + + val userInfoAck = HuaweiHagridWspLib.encryptedPayloadWithIv( + byteArrayOf(0x00), + workKey, + ByteArray(16) { (it + 128).toByte() } + ) + sendWspNotification(handler, CHR_SET_USER_INFO, userInfoAck, encrypted = true) + + assertThat(transport.writes.map { it.characteristic }) + .containsAtLeast( + CHR_CURRENT_TIME, + CHR_SET_USER_INFO, + CHR_PRODUCT_INFO, + CHR_GET_MANAGER_INFO, + CHR_SCALE_VERSION, + CHR_GET_WEIGHT_UNIT, + CHR_MEASUREMENT_STATUS_POLL, + ) + assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_HISTORY_WEIGHT) + assertThat(transport.writes.map { it.characteristic }).doesNotContain(CHR_REALTIME_WEIGHT) + + sendWspNotification(handler, CHR_MEASUREMENT_STATUS_RESULT, byteArrayOf(0x00)) + + assertThat(transport.writes.map { it.characteristic }) + .containsAtLeast(CHR_HISTORY_WEIGHT, CHR_REALTIME_WEIGHT) + } + + private fun device( + name: String, + address: String = "00:11:22:33:44:55", + services: IntArray = intArrayOf(), + manufacturerData: SparseArray? = null, + serviceData: Map = emptyMap() + ) = ScannedDeviceInfo( + name = name, + address = address, + rssi = -50, + serviceUuids = services.map { uuid16(it) }, + manufacturerData = manufacturerData, + serviceData = serviceData, + ) + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun sendWspNotification( + handler: HuaweiHagridWspHandler, + characteristic: UUID, + payload: ByteArray, + encrypted: Boolean = false + ) { + HuaweiHagridWspLib.buildWriteFrames(payload, encrypted).forEach { writeFrame -> + val notifyFrame = writeFrame.copyOf() + notifyFrame[0] = ( + if (encrypted) { + HuaweiHagridWspLib.FRAME_NOTIFY_ENCRYPTED + } else { + HuaweiHagridWspLib.FRAME_NOTIFY_PLAIN + } + ).toByte() + val crc = HuaweiHagridWspLib.crc16Modbus(notifyFrame.copyOfRange(0, notifyFrame.size - 2)) + notifyFrame[notifyFrame.size - 2] = (crc and 0xFF).toByte() + notifyFrame[notifyFrame.size - 1] = ((crc ushr 8) and 0xFF).toByte() + handler.handleNotification(characteristic, notifyFrame) + } + } + + private fun ByteArray.toHex(): String = + joinToString(separator = "") { "%02X".format(it.toInt() and 0xFF) } + + private fun managerInfoPayload(): ByteArray = + HuaweiHagridWspLib.buildManagerInfoPayload( + huid = HAGRID_MANAGER_HUID, + deviceId = "c55f8f03-1111-4222-8333-aabbccddee49" + ) + + private class CapturingCallbacks : ScaleDeviceHandler.Callbacks { + val published = mutableListOf() + val infos = mutableListOf() + + override fun onPublish(measurement: ScaleMeasurement) { + published += measurement + } + + override fun onInfo(resId: Int, vararg args: Any) { + infos += InfoRecord(resId, args.toList()) + } + + override fun resolveString(resId: Int, vararg args: Any): String = "res:$resId" + } + + private data class InfoRecord( + val resId: Int, + val args: List + ) + + private class NoopTransport : ScaleDeviceHandler.Transport { + override fun setNotifyOn(service: UUID, characteristic: UUID) = Unit + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) = Unit + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = false + } + + private data class WriteRecord( + val service: UUID, + val characteristic: UUID, + val payload: ByteArray, + val withResponse: Boolean + ) + + private class CapturingTransport( + private val available: Set> + ) : ScaleDeviceHandler.Transport { + val writes = mutableListOf() + val notifications = mutableListOf>() + + override fun setNotifyOn(service: UUID, characteristic: UUID) { + notifications += service to characteristic + } + + override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) { + writes += WriteRecord(service, characteristic, payload, withResponse) + } + + override fun read(service: UUID, characteristic: UUID) = Unit + override fun disconnect() = Unit + override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = + available.contains(service to characteristic) + + fun reassembleWritesTo(characteristic: UUID): ByteArray { + val chunks = writes + .filter { it.characteristic == characteristic } + .sortedBy { it.payload[2].toInt() and 0x0F } + .flatMap { record -> + val len = (record.payload[1].toInt() and 0xFF) - 3 + record.payload.copyOfRange(3, 3 + len).toList() + } + return chunks.toByteArray() + } + + fun reassembleWriteMessagesTo(characteristic: UUID): List { + val messages = mutableListOf() + val current = mutableListOf() + var expectedFrames = 0 + + writes.filter { it.characteristic == characteristic }.forEach { record -> + val frame = record.payload + val totalFrames = ((frame[2].toInt() ushr 4) and 0x0F) + 1 + if (current.isEmpty()) { + expectedFrames = totalFrames + } + current += record + if (current.size == expectedFrames) { + val chunks = current + .sortedBy { it.payload[2].toInt() and 0x0F } + .flatMap { + val len = (it.payload[1].toInt() and 0xFF) - 3 + it.payload.copyOfRange(3, 3 + len).toList() + } + messages += chunks.toByteArray() + current.clear() + expectedFrames = 0 + } + } + return messages + } + + companion object { + fun allHagrid(): CapturingTransport = + CapturingTransport( + setOf( + SVC_USER_DATA to CHR_REQUEST_AUTH, + SVC_USER_DATA to CHR_AUTH_TOKEN, + SVC_USER_DATA to CHR_SEND_WORK_KEY, + SVC_USER_DATA to CHR_BIND_REQUEST, + SVC_USER_DATA to CHR_SET_USER_INFO, + SVC_USER_DATA to CHR_GET_MANAGER_INFO, + SVC_CURRENT_TIME to CHR_CURRENT_TIME, + SVC_CURRENT_TIME to CHR_PRODUCT_INFO, + SVC_CURRENT_TIME to CHR_SCALE_VERSION, + SVC_CURRENT_TIME to CHR_GET_WEIGHT_UNIT, + SVC_CURRENT_TIME to CHR_MEASUREMENT_STATUS_POLL, + SVC_CURRENT_TIME to CHR_MEASUREMENT_STATUS_RESULT, + SVC_BODY_COMPOSITION to CHR_REALTIME_WEIGHT, + SVC_BODY_COMPOSITION to CHR_HISTORY_WEIGHT, + SVC_WEIGHT_SCALE to CHR_WEIGHT_MEASUREMENT, + ) + ) + } + } + + private class InMemorySettings : ScaleDeviceHandler.DriverSettings { + private val values = mutableMapOf() + + override fun getInt(key: String, default: Int): Int = values[key]?.toIntOrNull() ?: default + override fun putInt(key: String, value: Int) { + values[key] = value.toString() + } + + override fun getString(key: String, default: String?): String? = values[key] ?: default + override fun putString(key: String, value: String) { + values[key] = value + } + + override fun remove(key: String) { + values.remove(key) + } + } + + private class FixedDataProvider( + private val user: ScaleUser, + private val users: List = listOf(user) + ) : ScaleDeviceHandler.DataProvider { + override fun currentUser(): ScaleUser = user + override fun usersForDevice(): List = users + override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = null + } + + companion object { + private const val HAGRID_ADDRESS = "0C:95:41:6E:9E:50" + private const val HAGRID_MANAGER_HUID = "420086000106881907" + private val SVC_USER_DATA = uuid16Static(0x181C) + private val SVC_CURRENT_TIME = uuid16Static(0x1805) + private val SVC_BODY_COMPOSITION = uuid16Static(0x181B) + private val SVC_WEIGHT_SCALE = uuid16Static(0x181D) + private val CHR_REQUEST_AUTH = UUID.fromString("02b2a08e-f8b0-4047-b1fd-f4e0efeee679") + private val CHR_AUTH_TOKEN = UUID.fromString("32330a04-15d9-421a-91c5-2a2d5c7525c9") + private val CHR_SEND_WORK_KEY = UUID.fromString("a3d330f8-b84f-4f48-a78c-f8d1e33b597a") + private val CHR_BIND_REQUEST = UUID.fromString("42596cbe-d291-4da3-8ca6-d1ae5d1c9174") + private val CHR_SET_USER_INFO = UUID.fromString("8cc61d7d-66c0-4802-89c3-38c5a163592e") + private val CHR_GET_MANAGER_INFO = UUID.fromString("4338c65e-ed8e-4085-bbea-a25e33ca6b54") + private val CHR_CURRENT_TIME = uuid16Static(0x2A2B) + private val CHR_PRODUCT_INFO = UUID.fromString("75143e79-f878-4a00-a628-edc40509de7e") + private val CHR_SCALE_VERSION = UUID.fromString("1f5d3d5c-496d-4290-af03-c7a8d5419741") + private val CHR_GET_WEIGHT_UNIT = UUID.fromString("7e6dbc73-42e7-45b9-a6ec-6aa2d7834695") + private val CHR_MEASUREMENT_STATUS_POLL = UUID.fromString("bfc36f6e-4150-4a4b-9052-3d359e52962e") + private val CHR_MEASUREMENT_STATUS_RESULT = UUID.fromString("ba216311-1787-472b-bef6-3eb29e62293e") + private val CHR_WEIGHT_MEASUREMENT = uuid16Static(0x2A9D) + private val CHR_REALTIME_WEIGHT = UUID.fromString("46797c17-d639-488d-9476-4789e8472878") + private val CHR_HISTORY_WEIGHT = UUID.fromString("0212f42a-5f19-4bc1-ba52-d7ec7ccb71a4") + + private fun uuid16Static(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun realtimePayload(): ByteArray = + byteArrayOf( + 0x34, 0x1E, + 0xB9.toByte(), 0x00, + 0xEA.toByte(), 0x07, 0x06, 0x16, 0x0F, 0x0E, 0x2A, 0x00, + 0xF4.toByte(), 0x01, 0xF5.toByte(), 0x01, 0xF6.toByte(), 0x01, + 0xF7.toByte(), 0x01, 0xF8.toByte(), 0x01, 0xF9.toByte(), 0x01, + 0x48, 0x00, + 0x58, 0x02, 0x59, 0x02, 0x5A, 0x02, + 0x5B, 0x02, 0x5C, 0x02, 0x5D, 0x02, + ) + + private fun historyPayload(completeFlag: Int): ByteArray = + realtimePayload() + byteArrayOf(0x01, completeFlag.toByte()) + } +}