gateway v1.5.0: protocol-aware parsers, debug fix, auto-reconnect
BREAKING FIX: 'always 1.8 kg' — the old brute-force parseGeneric was matching noise bytes. Replaced with protocol-specific parsers: Protocol support (from openScale research): - Bluetooth SIG 0x2A9D/0x2A9C (standard weight/body composition) - QN/Yolanda/FITINDEX (opcode 0x10 weight, 0x12 scale info) - 1byone/Eufy (0xCF header, LE weight at bytes 3-4) - Hesley/YunChen (20-byte body composition frame) - Renpho proprietary (0x2E header on 0x2A9D) - Safe generic fallback (stricter: min 4 bytes, min 2kg, unstable) Body composition fields: fat%, muscle%, water%, bone, BMR/kcal, impedance — all displayed when available. Debug panel fix: capped at 150 lines, UI updates throttled to 200ms (was: unbounded StringBuilder updated on every BLE notification = freeze). Auto-reconnect: saves last connected device MAC to SharedPreferences, auto-starts scan on app launch and connects when saved device found. GATT service discovery: now explicitly subscribes to QN (FFE0/FFE1) and custom FFF0 (FFF4 or FFF1) characteristics in addition to standard Weight Scale and Body Composition services. ScaleProtocol state: resetState() called on new connection to reset QN weight divisor (100 or 10, learned from 0x12 info frame).
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.scalegate"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 3
|
||||
versionName = "1.4.0"
|
||||
versionCode = 4
|
||||
versionName = "1.5.0"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
||||
+49
-4
@@ -13,6 +13,8 @@ import androidx.core.content.ContextCompat
|
||||
|
||||
private const val TAG = "BleScaleManager"
|
||||
private const val SCAN_PERIOD_MS = 15_000L
|
||||
private const val PREFS_NAME = "evershelf_gateway"
|
||||
private const val PREF_LAST_DEVICE = "last_device_address"
|
||||
|
||||
/**
|
||||
* Represents a discovered BLE device during scan.
|
||||
@@ -56,6 +58,7 @@ class BleScaleManager(
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var isScanning = false
|
||||
private var connectedDeviceName: String = ""
|
||||
private var autoConnectAddress: String? = null
|
||||
|
||||
// The characteristics we will subscribe to (multiple may exist).
|
||||
private val pendingSubscriptions = ArrayDeque<BluetoothGattCharacteristic>()
|
||||
@@ -64,6 +67,22 @@ class BleScaleManager(
|
||||
|
||||
val isConnected: Boolean get() = gatt != null && connectedDeviceName.isNotEmpty()
|
||||
|
||||
// ─── Saved device (auto-reconnect) ─────────────────────────────────────────
|
||||
|
||||
fun getSavedDeviceAddress(): String? {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(PREF_LAST_DEVICE, null)
|
||||
}
|
||||
|
||||
private fun saveDeviceAddress(address: String) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(PREF_LAST_DEVICE, address).apply()
|
||||
}
|
||||
|
||||
fun enableAutoConnect() {
|
||||
autoConnectAddress = getSavedDeviceAddress()
|
||||
}
|
||||
|
||||
// ─── Permissions helper ────────────────────────────────────────────────────
|
||||
|
||||
fun hasRequiredPermissions(): Boolean {
|
||||
@@ -127,6 +146,15 @@ class BleScaleManager(
|
||||
val score = scoreLikelyScale(name, result.scanRecord)
|
||||
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
|
||||
mainHandler.post { listener.onDeviceFound(info) }
|
||||
|
||||
// Auto-connect to saved device
|
||||
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
|
||||
autoConnectAddress = null // prevent re-trigger
|
||||
mainHandler.post {
|
||||
listener.onDebugEvent("\uD83D\uDD04 Auto-connessione a $name (${device.address})")
|
||||
connect(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
@@ -153,11 +181,14 @@ class BleScaleManager(
|
||||
var score = 0
|
||||
val lower = name.lowercase()
|
||||
if (listOf("scale", "bilancia", "weight", "body", "balance",
|
||||
"lepulse", "qardio", "xiaomi", "mi body", "körper")
|
||||
"lepulse", "qardio", "xiaomi", "mi body", "körper",
|
||||
"qn-scale", "fitindex", "renpho", "1byone", "eufy",
|
||||
"yunmai", "senssun", "yunchen", "hesley")
|
||||
.any { lower.contains(it) }) score += 10
|
||||
scanRecord?.serviceUuids?.let { uuids ->
|
||||
val us = uuids.map { it.uuid.toString().lowercase() }
|
||||
if (us.any { it.startsWith("0000181d") || it.startsWith("0000181b") }) score += 20
|
||||
if (us.any { it.startsWith("0000ffe0") || it.startsWith("0000fff0") }) score += 15
|
||||
}
|
||||
return score
|
||||
}
|
||||
@@ -168,6 +199,7 @@ class BleScaleManager(
|
||||
stopScan()
|
||||
disconnect()
|
||||
connectedDeviceName = ""
|
||||
ScaleProtocol.resetState()
|
||||
mainHandler.post { listener.onConnecting(device) }
|
||||
try {
|
||||
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@@ -228,17 +260,27 @@ class BleScaleManager(
|
||||
?.getCharacteristic(BleUuids.BODY_COMPOSITION_CHAR)
|
||||
?.let { if (!targetChars.contains(it)) targetChars.add(it) }
|
||||
|
||||
// Fallback: any notifiable characteristic from unknown services
|
||||
// Priority 3: QN/Yolanda Type 1 (FFE0)
|
||||
gatt.getService(BleUuids.QN_SERVICE_FFE0)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.QN_NOTIFY_FFE1)?.let { targetChars.add(it) }
|
||||
}
|
||||
|
||||
// Priority 4: Custom FFF0 service (QN Type 2, 1byone, Hesley)
|
||||
gatt.getService(BleUuids.CUSTOM_FFF0)?.let { svc ->
|
||||
svc.getCharacteristic(BleUuids.CUSTOM_FFF4)?.let { targetChars.add(it) }
|
||||
?: svc.getCharacteristic(BleUuids.CUSTOM_FFF1)?.let { targetChars.add(it) }
|
||||
}
|
||||
|
||||
// Fallback: any notifiable characteristic from remaining services
|
||||
if (targetChars.isEmpty()) {
|
||||
for (service in gatt.services) {
|
||||
// Skip standard generic services
|
||||
if (service.uuid.toString().startsWith("00001800") ||
|
||||
service.uuid.toString().startsWith("00001801")) continue
|
||||
for (char in service.characteristics) {
|
||||
val props = char.properties
|
||||
if ((props and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 ||
|
||||
(props and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) {
|
||||
targetChars.add(char)
|
||||
if (!targetChars.contains(char)) targetChars.add(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,6 +316,9 @@ class BleScaleManager(
|
||||
}
|
||||
mainHandler.post { listener.onDebugEvent(dbg) }
|
||||
|
||||
// Save device for auto-reconnect
|
||||
try { gatt.device?.address?.let { saveDeviceAddress(it) } } catch (_: SecurityException) {}
|
||||
|
||||
pendingSubscriptions.clear()
|
||||
pendingSubscriptions.addAll(targetChars)
|
||||
|
||||
|
||||
+48
-14
@@ -36,9 +36,14 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
private lateinit var deviceAdapter: DeviceAdapter
|
||||
|
||||
private var batteryLevel: Int? = null
|
||||
private val debugLog = StringBuilder()
|
||||
private val debugLines = mutableListOf<String>()
|
||||
private var debugVisible = false
|
||||
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
private var lastDebugUpdate = 0L
|
||||
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
private companion object {
|
||||
const val MAX_DEBUG_LINES = 150
|
||||
const val DEBUG_THROTTLE_MS = 200L
|
||||
}
|
||||
|
||||
// ─── Permission launcher ───────────────────────────────────────────────────
|
||||
|
||||
@@ -90,6 +95,12 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
|
||||
updateGatewayUrl()
|
||||
checkPermissionsAndStart()
|
||||
|
||||
// Auto-connect: if we have a saved device, start scanning with auto-connect enabled
|
||||
if (bleManager.getSavedDeviceAddress() != null) {
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "🔄 Ricerca bilancia salvata…"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -133,11 +144,12 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
}
|
||||
devices.clear()
|
||||
deviceAdapter.notifyDataSetChanged()
|
||||
debugLog.clear()
|
||||
debugLines.clear()
|
||||
binding.tvDebugLog.text = ""
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "Ricerca bilance BLE in corso…"
|
||||
binding.btnScan.isEnabled = false
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
|
||||
@@ -153,6 +165,12 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
} catch (e: Exception) {
|
||||
binding.tvGatewayStatus.text = "❌ Impossibile avviare il gateway: ${e.message}"
|
||||
}
|
||||
|
||||
// Auto-scan if there's a saved device
|
||||
if (bleManager.getSavedDeviceAddress() != null && bleManager.hasRequiredPermissions()) {
|
||||
bleManager.enableAutoConnect()
|
||||
bleManager.startScan()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateGatewayUrl() {
|
||||
@@ -205,15 +223,24 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
|
||||
override fun onWeightReceived(reading: WeightReading) {
|
||||
val kg = "%.2f".format(reading.weightKg)
|
||||
binding.tvWeight.text = "$kg kg"
|
||||
|
||||
val extras = buildString {
|
||||
reading.fatPct?.let { append(" Grasso: ${"%.1f".format(it)}%") }
|
||||
reading.bmi?.let { append(" BMI: ${"%.1f".format(it)}") }
|
||||
}
|
||||
binding.tvWeight.text = "$kg kg$extras"
|
||||
if (reading.stable) {
|
||||
binding.tvWeightHint.text = "✓ Lettura stabile"
|
||||
reading.fatPct?.let { append("Grasso: ${"%.1f".format(it)}% ") }
|
||||
reading.muscle?.let { append("Muscoli: ${"%.1f".format(it)}% ") }
|
||||
reading.water?.let { append("Acqua: ${"%.1f".format(it)}% ") }
|
||||
reading.bone?.let { append("Ossa: ${"%.1f".format(it)}kg ") }
|
||||
reading.bmi?.let { append("BMI: ${"%.1f".format(it)} ") }
|
||||
reading.kcal?.let { append("BMR: ${it}kcal ") }
|
||||
reading.impedance?.let { append("Z: ${"%.0f".format(it)}\u03A9 ") }
|
||||
}.trim()
|
||||
|
||||
if (extras.isNotEmpty()) {
|
||||
binding.tvWeightHint.text = extras
|
||||
} else if (reading.stable) {
|
||||
binding.tvWeightHint.text = "\u2713 Lettura stabile"
|
||||
} else {
|
||||
binding.tvWeightHint.text = "…"
|
||||
binding.tvWeightHint.text = "\u23f3 Misurazione in corso\u2026"
|
||||
}
|
||||
wsServer?.publishWeight(reading.weightKg, reading.stable, batteryLevel)
|
||||
}
|
||||
@@ -243,10 +270,17 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
override fun onDebugEvent(message: String) {
|
||||
runOnUiThread {
|
||||
val ts = debugTimeFmt.format(Date())
|
||||
debugLog.append("[$ts] $message\n")
|
||||
binding.tvDebugLog.text = debugLog
|
||||
if (debugVisible) {
|
||||
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
|
||||
debugLines.add("[$ts] $message")
|
||||
// Keep only last MAX_DEBUG_LINES
|
||||
while (debugLines.size > MAX_DEBUG_LINES) debugLines.removeAt(0)
|
||||
// Throttle UI updates to avoid freezing
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastDebugUpdate >= DEBUG_THROTTLE_MS) {
|
||||
lastDebugUpdate = now
|
||||
binding.tvDebugLog.text = debugLines.joinToString("\n")
|
||||
if (debugVisible) {
|
||||
binding.svDebugLog.post { binding.svDebugLog.fullScroll(View.FOCUS_DOWN) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+263
-94
@@ -12,6 +12,11 @@ data class WeightReading(
|
||||
val battery: Int? = null, // battery percentage (0-100), if reported
|
||||
val fatPct: Float? = null, // body fat %, if available
|
||||
val bmi: Float? = null, // BMI, if available
|
||||
val muscle: Float? = null, // muscle mass %, if available
|
||||
val water: Float? = null, // water %, if available
|
||||
val bone: Float? = null, // bone mass kg, if available
|
||||
val kcal: Int? = null, // BMR / kcal, if available
|
||||
val impedance: Float? = null, // impedance Ω, if available
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -20,14 +25,14 @@ data class WeightReading(
|
||||
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
/**
|
||||
* Bluetooth SIG standard service and characteristic UUIDs.
|
||||
* Bluetooth SIG standard + common vendor service/characteristic UUIDs.
|
||||
*/
|
||||
object BleUuids {
|
||||
// Weight Scale Service
|
||||
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
|
||||
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Body Composition Service (also used by many smart scales)
|
||||
// Body Composition Service
|
||||
val BODY_COMPOSITION_SERVICE = UUID.fromString("0000181b-0000-1000-8000-00805f9b34fb")
|
||||
val BODY_COMPOSITION_CHAR = UUID.fromString("00002a9c-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
@@ -35,106 +40,141 @@ object BleUuids {
|
||||
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Xiaomi Mi Scale 2 / Mi Body Composition Scale 2
|
||||
val XIAOMI_SCALE_SERVICE = UUID.fromString("0000181b-0000-1000-8000-00805f9b34fb")
|
||||
val XIAOMI_SCALE_CHAR = UUID.fromString("00002a9c-0000-1000-8000-00805f9b34fb")
|
||||
// QN/Yolanda/FITINDEX (Type 1)
|
||||
val QN_SERVICE_FFE0 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
|
||||
val QN_NOTIFY_FFE1 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
|
||||
val QN_WRITE_FFE3 = UUID.fromString("0000ffe3-0000-1000-8000-00805f9b34fb")
|
||||
val QN_WRITE_FFE4 = UUID.fromString("0000ffe4-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
// Common custom services (QN Type 2, 1byone, Eufy, Hesley, etc.)
|
||||
val CUSTOM_FFF0 = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
|
||||
val CUSTOM_FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
|
||||
val CUSTOM_FFF2 = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb")
|
||||
val CUSTOM_FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses BLE characteristic data for various scale protocols.
|
||||
* Returns a WeightReading or null if the data does not match a known format.
|
||||
* Protocol-aware parser for BLE scale data.
|
||||
*
|
||||
* Supports:
|
||||
* - Bluetooth SIG Weight Measurement (0x2A9D) and Body Composition (0x2A9C)
|
||||
* - QN/Yolanda/FITINDEX protocol (opcode 0x10 weight, 0x12 info)
|
||||
* - 1byone/Eufy protocol (0xCF frames)
|
||||
* - Hesley/YunChen (20-byte body-composition frames)
|
||||
* - Renpho proprietary (0x2E header)
|
||||
* - Safe generic fallback (stricter than brute-force)
|
||||
*/
|
||||
object ScaleProtocol {
|
||||
|
||||
/**
|
||||
* Attempt to parse weight data from a GATT characteristic change.
|
||||
* Tries known protocols in order of specificity.
|
||||
*/
|
||||
fun parse(char: BluetoothGattCharacteristic, data: ByteArray, debugCallback: ((String) -> Unit)? = null): WeightReading? {
|
||||
return when (char.uuid) {
|
||||
BleUuids.WEIGHT_MEASUREMENT_CHAR -> parseWeightMeasurement(data)
|
||||
BleUuids.BODY_COMPOSITION_CHAR -> parseBodyComposition(data)
|
||||
else -> parseGeneric(data, debugCallback)
|
||||
}
|
||||
// ── QN protocol state (weight divisor set by 0x12 info frame) ───────────
|
||||
@Volatile
|
||||
private var qnWeightDivisor: Float = 100f
|
||||
|
||||
/** Call when starting a new connection to reset protocol state. */
|
||||
fun resetState() {
|
||||
qnWeightDivisor = 100f
|
||||
}
|
||||
|
||||
/**
|
||||
* Bluetooth SIG Weight Measurement Characteristic (0x2A9D)
|
||||
*
|
||||
* Byte 0 : Flags
|
||||
* Bit 0 = 0 → SI (kg/m), 1 → Imperial (lb/in)
|
||||
* Bit 1 = Time Stamp present
|
||||
* Bit 2 = User ID present
|
||||
* Bit 3 = BMI & Height present
|
||||
* Bytes 1-2: Weight (uint16)
|
||||
* SI: 0.005 kg per unit
|
||||
* Imperial: 0.01 lb per unit
|
||||
*/
|
||||
fun parseWeightMeasurement(data: ByteArray): WeightReading? {
|
||||
// ── Main entry point ────────────────────────────────────────────────────
|
||||
|
||||
fun parse(
|
||||
char: BluetoothGattCharacteristic,
|
||||
data: ByteArray,
|
||||
debug: ((String) -> Unit)? = null,
|
||||
): WeightReading? {
|
||||
// 1. Standard BLE SIG characteristics (identified by UUID)
|
||||
when (char.uuid) {
|
||||
BleUuids.WEIGHT_MEASUREMENT_CHAR -> {
|
||||
// Renpho uses standard UUID but proprietary encoding (header 0x2E)
|
||||
if (data.isNotEmpty() && data[0] == 0x2E.toByte()) {
|
||||
return parseRenpho(data, debug)
|
||||
}
|
||||
return parseWeightMeasurement(data, debug)
|
||||
}
|
||||
BleUuids.BODY_COMPOSITION_CHAR -> return parseBodyComposition(data, debug)
|
||||
}
|
||||
|
||||
// 2. Vendor protocol detection from data content
|
||||
if (data.isEmpty()) return null
|
||||
val opcode = data[0].toInt() and 0xFF
|
||||
|
||||
// QN/Yolanda family
|
||||
if (opcode == 0x10 && data.size >= 6) return parseQNWeight(data, debug)
|
||||
if (opcode == 0x12 && data.size > 2) { handleQNInfo(data, debug); return null }
|
||||
if (opcode in listOf(0x14, 0x20, 0x21, 0xA1, 0xA3)) {
|
||||
debug?.invoke("QN ack/handshake opcode=0x${"%02X".format(opcode)}")
|
||||
return null
|
||||
}
|
||||
|
||||
// 1byone / Eufy
|
||||
if (opcode == 0xCF && data.size >= 11) return parse1byone(data, debug)
|
||||
|
||||
// Renpho on custom characteristic
|
||||
if (opcode == 0x2E && data.size >= 3) return parseRenpho(data, debug)
|
||||
|
||||
// Hesley / YunChen (exactly 20 bytes, validated)
|
||||
if (data.size == 20) {
|
||||
val result = parseHesley(data, debug)
|
||||
if (result != null) return result
|
||||
}
|
||||
|
||||
// 3. Safe generic fallback
|
||||
return parseGenericSafe(data, debug)
|
||||
}
|
||||
|
||||
// ── Bluetooth SIG 0x2A9D ────────────────────────────────────────────────
|
||||
|
||||
fun parseWeightMeasurement(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 3) return null
|
||||
val flags = data[0].toInt() and 0xFF
|
||||
val isImperial = (flags and 0x01) != 0
|
||||
val rawWeight = (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)
|
||||
val rawWeight = u16le(data, 1)
|
||||
|
||||
val weightKg = if (isImperial) {
|
||||
rawWeight * 0.01f / 2.20462f // lb → kg
|
||||
rawWeight * 0.01f / 2.20462f
|
||||
} else {
|
||||
rawWeight * 0.005f // SI resolution
|
||||
rawWeight * 0.005f
|
||||
}
|
||||
|
||||
// Bit 3: BMI & Height present → offset 5 if no timestamp/user
|
||||
var bmi: Float? = null
|
||||
var offset = 3
|
||||
if ((flags and 0x02) != 0) offset += 7 // timestamp: 7 bytes
|
||||
if ((flags and 0x04) != 0) offset += 1 // user ID: 1 byte
|
||||
if ((flags and 0x02) != 0) offset += 7 // timestamp
|
||||
if ((flags and 0x04) != 0) offset += 1 // user ID
|
||||
if ((flags and 0x08) != 0 && data.size >= offset + 4) {
|
||||
val rawBmi = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
val rawBmi = u16le(data, offset)
|
||||
bmi = rawBmi * 0.1f
|
||||
}
|
||||
|
||||
return WeightReading(weightKg = weightKg, stable = true, bmi = bmi)
|
||||
val unit = if (isImperial) "lb" else "kg"
|
||||
debug?.invoke("SIG 2A9D: raw=$rawWeight imp=$isImperial -> ${"%.2f".format(weightKg)}kg ($unit)")
|
||||
if (weightKg in 0.1f..500f) return WeightReading(weightKg, stable = true, bmi = bmi)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bluetooth SIG Body Composition Measurement Characteristic (0x2A9C)
|
||||
*
|
||||
* Bytes 0-1 : Flags (16-bit)
|
||||
* Bit 0 = 0 → SI, 1 → Imperial
|
||||
* Bit 1 = Time Stamp present
|
||||
* Bit 7 = Weight present
|
||||
* Bit 8 = Height present
|
||||
* Bit 9 = Multiple Users
|
||||
* Bit 10 = Basal Metabolism present
|
||||
* Bit 11 = Muscle Percentage present
|
||||
* Bit 13 = Body Fat Percentage present ← always present (mandatory)
|
||||
* Bytes 2-3 : Body Fat % (uint16, resolution 0.1%)
|
||||
* … then optional fields
|
||||
* When Bit 7 (Weight) is set, weight (uint16) follows at an offset after other optionals.
|
||||
*/
|
||||
fun parseBodyComposition(data: ByteArray): WeightReading? {
|
||||
// ── Bluetooth SIG 0x2A9C ────────────────────────────────────────────────
|
||||
|
||||
fun parseBodyComposition(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 4) return null
|
||||
val flags = (data[0].toInt() and 0xFF) or ((data[1].toInt() and 0xFF) shl 8)
|
||||
val flags = u16le(data, 0)
|
||||
val isImperial = (flags and 0x0001) != 0
|
||||
|
||||
// Body fat % (mandatory, bytes 2-3)
|
||||
val rawFat = (data[2].toInt() and 0xFF) or ((data[3].toInt() and 0xFF) shl 8)
|
||||
val rawFat = u16le(data, 2)
|
||||
val fatPct = rawFat * 0.1f
|
||||
|
||||
// Walk through optional fields to reach weight
|
||||
var offset = 4
|
||||
if ((flags and 0x0002) != 0) offset += 7 // timestamp
|
||||
if ((flags and 0x0200) != 0) offset += 1 // multiple users → User ID byte
|
||||
if ((flags and 0x0200) != 0) offset += 1 // user ID
|
||||
|
||||
// Weight (Bit 7)
|
||||
var weightKg: Float? = null
|
||||
if ((flags and 0x0080) != 0 && data.size >= offset + 2) {
|
||||
val rawW = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
|
||||
val rawW = u16le(data, offset)
|
||||
weightKg = if (isImperial) rawW * 0.01f / 2.20462f else rawW * 0.005f
|
||||
offset += 2
|
||||
}
|
||||
|
||||
if (weightKg == null || weightKg <= 0f) return null
|
||||
|
||||
debug?.invoke("SIG 2A9C: weight=${"%.2f".format(weightKg)}kg fat=${"%.1f".format(fatPct)}%")
|
||||
return WeightReading(
|
||||
weightKg = weightKg,
|
||||
stable = true,
|
||||
@@ -142,46 +182,175 @@ object ScaleProtocol {
|
||||
)
|
||||
}
|
||||
|
||||
// ── QN / Yolanda / FITINDEX ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generic / fallback parser.
|
||||
* Tries every 2-byte window in the packet with three common resolutions
|
||||
* (0.1 kg, 0.01 kg, 0.005 kg) in both little-endian and big-endian order.
|
||||
* Reports the first result in the plausible range [1..300] kg.
|
||||
* The debugCallback receives the winning candidate or a failure message.
|
||||
* QN 0x10 weight frame.
|
||||
* Two layouts:
|
||||
* Original: [0x10][len][proto][weight_hi][weight_lo][stable][r1_hi][r1_lo][r2_hi][r2_lo]
|
||||
* ES-30M: [0x10][len][proto][unit][stable][weight_hi][weight_lo][r1...][r2...]
|
||||
*/
|
||||
fun parseGeneric(data: ByteArray, debugCallback: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 2) return null
|
||||
fun parseQNWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 6) return null
|
||||
|
||||
data class Candidate(val start: Int, val res: Float, val be: Boolean)
|
||||
val byte4 = data[4].toInt() and 0xFF
|
||||
// ES-30M: byte[4] is a stable flag (0x00/0x01/0x02), and divisor is 10
|
||||
val isES30M = byte4 <= 0x02 && qnWeightDivisor == 10f
|
||||
|
||||
// Scan priorities: common flag-then-weight positions first, then brute-force remainder
|
||||
val preferred = listOf(1, 2, 3, data.size - 2, 0, 4, 5)
|
||||
val all = (preferred + (0 until data.size - 1).toList()).distinct()
|
||||
.filter { it in 0..data.size - 2 }
|
||||
val stable: Boolean
|
||||
val raw: Int
|
||||
|
||||
val candidates = buildList {
|
||||
for (pos in all) {
|
||||
for (res in listOf(0.1f, 0.01f, 0.005f)) {
|
||||
add(Candidate(pos, res, false)) // little-endian
|
||||
add(Candidate(pos, res, true)) // big-endian
|
||||
}
|
||||
if (isES30M && data.size >= 7) {
|
||||
stable = byte4 == 0x02 || byte4 == 0x01
|
||||
raw = u16be(data, 5)
|
||||
} else {
|
||||
raw = u16be(data, 3)
|
||||
stable = data.size > 5 && data[5].toInt() == 1
|
||||
}
|
||||
|
||||
var weightKg = raw / qnWeightDivisor
|
||||
// Heuristic: if weight is unreasonable with the current divisor, try the other
|
||||
if (weightKg < 1f || weightKg > 300f) {
|
||||
val alt = if (qnWeightDivisor == 100f) 10f else 100f
|
||||
val altW = raw / alt
|
||||
if (altW in 1f..300f) {
|
||||
weightKg = altW
|
||||
debug?.invoke("QN: auto-adjusted divisor $qnWeightDivisor -> $alt")
|
||||
}
|
||||
}
|
||||
|
||||
for (c in candidates) {
|
||||
val raw = if (c.be) {
|
||||
((data[c.start].toInt() and 0xFF) shl 8) or (data[c.start + 1].toInt() and 0xFF)
|
||||
} else {
|
||||
(data[c.start].toInt() and 0xFF) or ((data[c.start + 1].toInt() and 0xFF) shl 8)
|
||||
}
|
||||
val weight = raw * c.res
|
||||
if (weight in 1f..300f) {
|
||||
val tag = if (c.be) "BE" else "LE"
|
||||
debugCallback?.invoke("\u2705 parseGeneric[$tag] start=${c.start} res=${c.res} raw=$raw \u2192 ${"%.3f".format(weight)} kg")
|
||||
return WeightReading(weightKg = weight, stable = true)
|
||||
}
|
||||
}
|
||||
debugCallback?.invoke("\u274c parseGeneric: nessun candidato in [1..300]kg su ${data.size} bytes")
|
||||
debug?.invoke("QN 0x10: raw=$raw div=$qnWeightDivisor -> ${"%.2f".format(weightKg)}kg stable=$stable")
|
||||
if (weightKg in 0.1f..300f) return WeightReading(weightKg, stable)
|
||||
return null
|
||||
}
|
||||
|
||||
/** QN 0x12 scale info frame. Byte[10] tells us the weight scaling factor. */
|
||||
fun handleQNInfo(data: ByteArray, debug: ((String) -> Unit)? = null) {
|
||||
if (data.size > 10) {
|
||||
qnWeightDivisor = if (data[10].toInt() == 1) 100f else 10f
|
||||
}
|
||||
debug?.invoke("QN 0x12: weight divisor set to $qnWeightDivisor")
|
||||
}
|
||||
|
||||
// ── 1byone / Eufy ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 1byone protocol: 0xCF header, weight at bytes [3..4] as uint16 LE / 100.
|
||||
* Byte[9] == 1 means impedance not present (we treat as "still measuring").
|
||||
*/
|
||||
fun parse1byone(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 5) return null
|
||||
val raw = u16le(data, 3)
|
||||
val weightKg = raw / 100f
|
||||
val stable = data.size > 9 && data[9].toInt() != 1
|
||||
|
||||
debug?.invoke("1byone CF: raw=$raw -> ${"%.2f".format(weightKg)}kg stable=$stable")
|
||||
if (weightKg in 0.1f..300f) return WeightReading(weightKg, stable)
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Hesley / YunChen ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 20-byte frame with full body composition:
|
||||
* [2..3] weight (BE, /100), [4..5] fat (BE, /10), [8..9] water (BE, /10),
|
||||
* [10..11] muscle (BE, /10), [12..13] bone (BE, /10), [14..15] kcal (BE).
|
||||
*/
|
||||
fun parseHesley(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 20) return null
|
||||
|
||||
val rawWeight = u16be(data, 2)
|
||||
val weight = rawWeight / 100f
|
||||
val rawFat = u16be(data, 4)
|
||||
val fat = rawFat / 10f
|
||||
|
||||
// Validation: weight must be plausible and fat % must be reasonable
|
||||
if (weight !in 0.5f..300f) return null
|
||||
if (fat > 80f) return null
|
||||
|
||||
val water = u16be(data, 8) / 10f
|
||||
val muscle = u16be(data, 10) / 10f
|
||||
val bone = u16be(data, 12) / 10f
|
||||
val kcal = u16be(data, 14)
|
||||
|
||||
debug?.invoke("Hesley: ${"%.2f".format(weight)}kg fat=${"%.1f".format(fat)}% water=${"%.1f".format(water)}% muscle=${"%.1f".format(muscle)}% bone=${"%.1f".format(bone)}kg kcal=$kcal")
|
||||
return WeightReading(
|
||||
weightKg = weight,
|
||||
stable = true,
|
||||
fatPct = if (fat > 0f) fat else null,
|
||||
water = if (water > 0f) water else null,
|
||||
muscle = if (muscle > 0f) muscle else null,
|
||||
bone = if (bone > 0f) bone else null,
|
||||
kcal = if (kcal > 0) kcal else null,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Renpho ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renpho proprietary on 0x2A9D: header 0x2E, weight = u16mix(1,2) / 20.
|
||||
*/
|
||||
fun parseRenpho(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 3 || data[0] != 0x2E.toByte()) return null
|
||||
// Renpho uses (data[2] << 8 | data[1]) i.e. big-endian-ish
|
||||
val raw = ((data[2].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF)
|
||||
val weightKg = raw / 20f
|
||||
|
||||
debug?.invoke("Renpho 2E: raw=$raw -> ${"%.2f".format(weightKg)}kg")
|
||||
if (weightKg in 0.1f..300f) return WeightReading(weightKg, stable = true)
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Safe generic fallback ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Conservative fallback parser.
|
||||
* Only tries a few positions with strict validation.
|
||||
* Returns readings as unstable to signal uncertainty.
|
||||
*/
|
||||
fun parseGenericSafe(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
|
||||
// Skip very short packets (usually control/ack frames)
|
||||
if (data.size < 4) {
|
||||
debug?.invoke("generic: skip short packet (${data.size}B)")
|
||||
return null
|
||||
}
|
||||
|
||||
// Try the most common positions and divisors
|
||||
data class Candidate(val pos: Int, val div: Float, val be: Boolean, val label: String)
|
||||
val candidates = listOf(
|
||||
Candidate(1, 100f, true, "pos1 BE/100"),
|
||||
Candidate(1, 100f, false, "pos1 LE/100"),
|
||||
Candidate(3, 100f, true, "pos3 BE/100"),
|
||||
Candidate(3, 100f, false, "pos3 LE/100"),
|
||||
Candidate(2, 100f, true, "pos2 BE/100"),
|
||||
Candidate(2, 100f, false, "pos2 LE/100"),
|
||||
Candidate(1, 10f, true, "pos1 BE/10"),
|
||||
Candidate(1, 10f, false, "pos1 LE/10"),
|
||||
Candidate(3, 10f, true, "pos3 BE/10"),
|
||||
Candidate(3, 10f, false, "pos3 LE/10"),
|
||||
Candidate(1, 20f, true, "pos1 BE/20"),
|
||||
Candidate(1, 20f, false, "pos1 LE/20"),
|
||||
)
|
||||
|
||||
for (c in candidates) {
|
||||
if (c.pos + 1 >= data.size) continue
|
||||
val raw = if (c.be) u16be(data, c.pos) else u16le(data, c.pos)
|
||||
val w = raw / c.div
|
||||
// Strict range: 2 kg minimum to avoid false positives from control bytes
|
||||
if (w in 2f..250f) {
|
||||
debug?.invoke("generic [${c.label}]: raw=$raw -> ${"%.2f".format(w)}kg (UNSTABLE)")
|
||||
return WeightReading(w, stable = false)
|
||||
}
|
||||
}
|
||||
debug?.invoke("generic: no valid candidate in ${data.size} bytes")
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun u16le(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun u16be(b: ByteArray, off: Int): Int =
|
||||
((b[off].toInt() and 0xFF) shl 8) or (b[off + 1].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user