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:
dadaloop82
2026-04-15 13:15:44 +00:00
parent 4d972b824e
commit 695ea19d5c
5 changed files with 152 additions and 30 deletions
+2 -2
View File
@@ -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 {
@@ -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") }
}
}
}
@@ -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) }
}
@@ -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"