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())
+ }
+}