gateway: UX improvements + debug mode v1.4.0
- Show device names from ScanRecord (fixes MAC-only display)
- Show 'Senza nome' for unnamed devices instead of hiding them
- Show proximity (Vicino/Medio/Lontano) instead of raw dBm
- Sort scan results: scale-likely devices first (keyword + UUID scoring)
- Add debug panel (toggle with 🐛 Debug button):
shows GATT service map, raw hex bytes, parse attempts
- Expand parseGeneric: all 2-byte windows × 3 resolutions × LE+BE
(adds 0.1f and big-endian candidates – common in cheap consumer scales)
- Log GATT services/characteristics after connection
- Log raw hex bytes on every characteristic notification
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "it.dadaloop.evershelf.scalegate"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "1.3.0"
|
||||
versionCode = 3
|
||||
versionName = "1.4.0"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
||||
+59
-6
@@ -21,6 +21,8 @@ data class BleDeviceInfo(
|
||||
val device: BluetoothDevice,
|
||||
val name: String,
|
||||
val rssi: Int,
|
||||
val proximity: String,
|
||||
val scaleScore: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -35,6 +37,7 @@ interface BleScaleListener {
|
||||
fun onBatteryReceived(level: Int)
|
||||
fun onError(message: String)
|
||||
fun onScanStopped()
|
||||
fun onDebugEvent(message: String)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,8 +121,11 @@ class BleScaleManager(
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device
|
||||
val name = getDeviceName(device).takeIf { it.isNotBlank() } ?: return
|
||||
val info = BleDeviceInfo(device, name, result.rssi)
|
||||
val name = result.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
|
||||
?: getDeviceName(device)
|
||||
val proximity = rssiToProximity(result.rssi)
|
||||
val score = scoreLikelyScale(name, result.scanRecord)
|
||||
val info = BleDeviceInfo(device, name, result.rssi, proximity, score)
|
||||
mainHandler.post { listener.onDeviceFound(info) }
|
||||
}
|
||||
|
||||
@@ -131,12 +137,31 @@ class BleScaleManager(
|
||||
|
||||
private fun getDeviceName(device: BluetoothDevice): String {
|
||||
return try {
|
||||
device.name?.takeIf { it.isNotBlank() } ?: device.address
|
||||
device.name?.takeIf { it.isNotBlank() } ?: "Senza nome"
|
||||
} catch (e: SecurityException) {
|
||||
device.address
|
||||
"Senza nome"
|
||||
}
|
||||
}
|
||||
|
||||
private fun rssiToProximity(rssi: Int) = when {
|
||||
rssi >= -60 -> "📶 Vicino"
|
||||
rssi >= -80 -> "📶 Medio"
|
||||
else -> "📶 Lontano"
|
||||
}
|
||||
|
||||
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
|
||||
var score = 0
|
||||
val lower = name.lowercase()
|
||||
if (listOf("scale", "bilancia", "weight", "body", "balance",
|
||||
"lepulse", "qardio", "xiaomi", "mi body", "körper")
|
||||
.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
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// ─── Connection ────────────────────────────────────────────────────────────
|
||||
|
||||
fun connect(device: BluetoothDevice) {
|
||||
@@ -229,6 +254,26 @@ class BleScaleManager(
|
||||
?.getCharacteristic(BleUuids.BATTERY_LEVEL_CHAR)
|
||||
?.let { targetChars.add(it) }
|
||||
|
||||
// Debug: log all discovered services and characteristics
|
||||
val dbg = buildString {
|
||||
append("Servizi GATT (${gatt.services.size}):\n")
|
||||
for (svc in gatt.services) {
|
||||
append(" SVC: ${svc.uuid}\n")
|
||||
for (ch in svc.characteristics) {
|
||||
val p = ch.properties
|
||||
val flags = buildString {
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) append("N")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) append("I")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) append("R")
|
||||
if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) append("W")
|
||||
}
|
||||
append(" CHAR: ${ch.uuid} [$flags]\n")
|
||||
}
|
||||
}
|
||||
append("Iscritto a ${targetChars.size} caratteristiche")
|
||||
}
|
||||
mainHandler.post { listener.onDebugEvent(dbg) }
|
||||
|
||||
pendingSubscriptions.clear()
|
||||
pendingSubscriptions.addAll(targetChars)
|
||||
|
||||
@@ -322,10 +367,18 @@ class BleScaleManager(
|
||||
return
|
||||
}
|
||||
|
||||
// Debug: log raw bytes received
|
||||
val hex = data.joinToString(" ") { "%02X".format(it) }
|
||||
mainHandler.post { listener.onDebugEvent("📡 ${char.uuid}\n HEX [${data.size}B]: $hex") }
|
||||
|
||||
// Weight / body composition
|
||||
val reading = ScaleProtocol.parse(char, data) ?: return
|
||||
if (reading.weightKg > 0f) {
|
||||
val reading = ScaleProtocol.parse(char, data) { msg ->
|
||||
mainHandler.post { listener.onDebugEvent(msg) }
|
||||
}
|
||||
if (reading != null && reading.weightKg > 0f) {
|
||||
mainHandler.post { listener.onWeightReceived(reading) }
|
||||
} else {
|
||||
mainHandler.post { listener.onDebugEvent("⚠️ Peso non decodificato") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-4
@@ -20,6 +20,9 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import it.dadaloop.evershelf.scalegate.databinding.ActivityMainBinding
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private const val WS_PORT = 8765
|
||||
|
||||
@@ -33,6 +36,9 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
private lateinit var deviceAdapter: DeviceAdapter
|
||||
|
||||
private var batteryLevel: Int? = null
|
||||
private val debugLog = StringBuilder()
|
||||
private var debugVisible = false
|
||||
private val debugTimeFmt = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
|
||||
// ─── Permission launcher ───────────────────────────────────────────────────
|
||||
|
||||
@@ -76,6 +82,11 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
bleManager.disconnect()
|
||||
updateUiDisconnected()
|
||||
}
|
||||
binding.btnDebug.setOnClickListener {
|
||||
debugVisible = !debugVisible
|
||||
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
|
||||
binding.btnDebug.text = if (debugVisible) "🐛 Nascondi Debug" else "🐛 Debug"
|
||||
}
|
||||
|
||||
updateGatewayUrl()
|
||||
checkPermissionsAndStart()
|
||||
@@ -122,6 +133,8 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
}
|
||||
devices.clear()
|
||||
deviceAdapter.notifyDataSetChanged()
|
||||
debugLog.clear()
|
||||
binding.tvDebugLog.text = ""
|
||||
binding.tvScanHint.visibility = View.VISIBLE
|
||||
binding.tvScanHint.text = "Ricerca bilance BLE in corso…"
|
||||
binding.btnScan.isEnabled = false
|
||||
@@ -158,10 +171,12 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
// ─── BleScaleListener ─────────────────────────────────────────────────────
|
||||
|
||||
override fun onDeviceFound(info: BleDeviceInfo) {
|
||||
// Avoid duplicates
|
||||
if (devices.none { it.device.address == info.device.address }) {
|
||||
devices.add(info)
|
||||
deviceAdapter.notifyItemInserted(devices.size - 1)
|
||||
// Insert keeping descending scaleScore order (scale-likely devices first)
|
||||
val insertAt = devices.indexOfFirst { it.scaleScore < info.scaleScore }
|
||||
.let { if (it < 0) devices.size else it }
|
||||
devices.add(insertAt, info)
|
||||
deviceAdapter.notifyItemInserted(insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +240,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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ServerEventListener ──────────────────────────────────────────────────
|
||||
|
||||
override fun onClientConnected(address: String) {
|
||||
@@ -296,7 +322,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
|
||||
val info = items[position]
|
||||
holder.tvName.text = info.name
|
||||
holder.tvAddr.text = info.device.address
|
||||
holder.tvRssi.text = "${info.rssi} dBm"
|
||||
holder.tvRssi.text = info.proximity
|
||||
holder.itemView.setOnClickListener { onClick(info) }
|
||||
}
|
||||
|
||||
|
||||
+32
-18
@@ -50,11 +50,11 @@ 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): WeightReading? {
|
||||
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)
|
||||
else -> parseGeneric(data, debugCallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,30 +144,44 @@ object ScaleProtocol {
|
||||
|
||||
/**
|
||||
* Generic / fallback parser.
|
||||
* Many cheap BLE scales send 2 bytes or a small packet with weight as a little-endian uint16
|
||||
* in units of 0.1 kg, 0.01 kg, or 10 g. We try each interpretation and pick a plausible result.
|
||||
* 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.
|
||||
*/
|
||||
fun parseGeneric(data: ByteArray): WeightReading? {
|
||||
fun parseGeneric(data: ByteArray, debugCallback: ((String) -> Unit)? = null): WeightReading? {
|
||||
if (data.size < 2) return null
|
||||
|
||||
// Try common byte positions
|
||||
val candidates = listOf(
|
||||
// (startByte, resolution in kg, stabilityBit, stabilityByte, stabilityValue)
|
||||
Triple(data.size - 2, 0.01f, false), // last 2 bytes, 0.01 kg resolution
|
||||
Triple(data.size - 2, 0.005f, false), // last 2 bytes, 0.005 kg resolution
|
||||
Triple(1, 0.01f, false), // bytes 1-2, 0.01 kg
|
||||
Triple(0, 0.1f, false), // bytes 0-1, 0.1 kg
|
||||
)
|
||||
data class Candidate(val start: Int, val res: Float, val be: Boolean)
|
||||
|
||||
for ((start, resolution, _) in candidates) {
|
||||
if (start < 0 || start + 1 >= data.size) continue
|
||||
val raw = (data[start].toInt() and 0xFF) or ((data[start + 1].toInt() and 0xFF) shl 8)
|
||||
val weight = raw * resolution
|
||||
// Sanity check: a realistic weight is between 1 kg and 300 kg
|
||||
// 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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,35 @@
|
||||
android:layout_marginBottom="8dp"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_debug"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🐛 Debug"
|
||||
android:backgroundTint="#374151"
|
||||
android:textColor="#FFFFFF"
|
||||
android:layout_marginBottom="8dp"
|
||||
style="@style/Widget.MaterialComponents.Button" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/sv_debug_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="#111827"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_debug_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="10sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="#4ADE80"
|
||||
android:padding="8dp" />
|
||||
</ScrollView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_scan_hint"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
Reference in New Issue
Block a user