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:
dadaloop82
2026-04-15 15:11:22 +00:00
parent 695ea19d5c
commit d30e9e0aaa
4 changed files with 362 additions and 114 deletions
+2 -2
View File
@@ -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 {
@@ -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)
@@ -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) }
}
}
}
}
@@ -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)
}