feat(gateway): fix QN-KS 0.1g resolution, unit passthrough, English UI

- ScaleProtocol: WeightReading now holds Float value + String unit
- parseQNFood: divide raw by 10 (0.1-unit resolution) so 170 raw -> 17.0g
- parseQNFood: detect unit from byte[4] (0x01=g, 0x02=oz, 0x03-04=ml)
- GatewayWebSocketServer: publishWeight(value: Float, unit: String, ...)
  WebSocket now sends {"value":17.0,"unit":"g"} with correct precision
- BleScaleManager: reading.grams -> reading.value (Float check > 0f)
- All Italian UI strings translated to English in all 4 Kotlin files + XML
This commit is contained in:
dadaloop82
2026-04-15 20:02:51 +00:00
parent 7be02c7174
commit a146ba124a
5 changed files with 176 additions and 154 deletions
@@ -98,11 +98,11 @@ class BleScaleManager(
fun startScan() {
val adapter = bluetoothAdapter ?: run {
listener.onError("Bluetooth non disponibile su questo dispositivo.")
listener.onError("Bluetooth not available on this device.")
return
}
if (!adapter.isEnabled) {
listener.onError("Bluetooth disattivato. Attivalo e riprova.")
listener.onError("Bluetooth is off. Enable it and try again.")
return
}
if (isScanning) stopScan()
@@ -151,7 +151,7 @@ class BleScaleManager(
if (autoConnectAddress != null && device.address == autoConnectAddress && !isConnected) {
autoConnectAddress = null // prevent re-trigger
mainHandler.post {
listener.onDebugEvent("\uD83D\uDD04 Auto-connessione a $name (${device.address})")
listener.onDebugEvent("\uD83D\uDD04 Auto-connecting to $name (${device.address})")
connect(device)
}
}
@@ -159,22 +159,22 @@ class BleScaleManager(
override fun onScanFailed(errorCode: Int) {
isScanning = false
mainHandler.post { listener.onError("Scansione BLE fallita (codice: $errorCode)") }
mainHandler.post { listener.onError("BLE scan failed (code: $errorCode)") }
}
}
private fun getDeviceName(device: BluetoothDevice): String {
return try {
device.name?.takeIf { it.isNotBlank() } ?: "Senza nome"
device.name?.takeIf { it.isNotBlank() } ?: "Unnamed"
} catch (e: SecurityException) {
"Senza nome"
"Unnamed"
}
}
private fun rssiToProximity(rssi: Int) = when {
rssi >= -60 -> "📶 Vicino"
rssi >= -80 -> "📶 Medio"
else -> "📶 Lontano"
rssi >= -60 -> "📶 Near"
rssi >= -80 -> "📶 Medium"
else -> "📶 Far"
}
private fun scoreLikelyScale(name: String, scanRecord: android.bluetooth.le.ScanRecord?): Int {
@@ -229,7 +229,7 @@ class BleScaleManager(
device.connectGatt(context, false, gattCallback)
}
} catch (e: SecurityException) {
mainHandler.post { listener.onError("Permesso mancante: ${e.message}") }
mainHandler.post { listener.onError("Missing permission: ${e.message}") }
}
}
@@ -308,7 +308,7 @@ class BleScaleManager(
}
if (targetChars.isEmpty()) {
mainHandler.post { listener.onError("Nessuna caratteristica di peso trovata. Verifica che sia una bilancia da cucina BLE.") }
mainHandler.post { listener.onError("No weight characteristic found. Make sure it's a BLE kitchen scale.") }
return
}
@@ -319,7 +319,7 @@ class BleScaleManager(
// Debug: log all discovered services and characteristics
val dbg = buildString {
append("Servizi GATT (${gatt.services.size}):\n")
append("GATT services (${gatt.services.size}):\n")
for (svc in gatt.services) {
append(" SVC: ${svc.uuid}\n")
for (ch in svc.characteristics) {
@@ -333,7 +333,7 @@ class BleScaleManager(
append(" CHAR: ${ch.uuid} [$flags]\n")
}
}
append("Iscritto a ${targetChars.size} caratteristiche")
append("Subscribed to ${targetChars.size} characteristics")
}
mainHandler.post { listener.onDebugEvent(dbg) }
@@ -343,7 +343,7 @@ class BleScaleManager(
pendingSubscriptions.clear()
pendingSubscriptions.addAll(targetChars)
val deviceName = try { gatt.device?.name ?: "Bilancia" } catch (e: SecurityException) { "Bilancia" }
val deviceName = try { gatt.device?.name ?: "Scale" } catch (e: SecurityException) { "Scale" }
connectedDeviceName = deviceName
mainHandler.post { listener.onConnected(deviceName) }
@@ -441,7 +441,7 @@ class BleScaleManager(
val reading = ScaleProtocol.parse(char, data) { msg ->
mainHandler.post { listener.onDebugEvent(msg) }
}
if (reading != null && reading.grams > 0) {
if (reading != null && reading.value > 0f) {
mainHandler.post { listener.onWeightReceived(reading) }
} else {
val rawDump = data.mapIndexed { i, b ->
@@ -449,7 +449,7 @@ class BleScaleManager(
val h = "%02X".format(v)
"[$i]=$v(0x$h)"
}.joinToString(" ")
mainHandler.post { listener.onDebugEvent("⚠️ Peso non decodificato\n RAW: $rawDump") }
mainHandler.post { listener.onDebugEvent("\u26a0\ufe0f Weight not decoded\n RAW: $rawDump") }
}
}
}
@@ -26,8 +26,8 @@ interface ServerEventListener {
* Message protocol (JSON):
*
* Server -> Client:
* {"type":"status","state":"connected"|"disconnected","device":"CK10G","battery":80}
* {"type":"weight","value":350,"unit":"g","stable":true,"timestamp":1712345678000}
* {"type":"status","state":"connected"|"disconnected","device":"QN-KS","battery":80}
* {"type":"weight","value":17.0,"unit":"g","stable":true,"timestamp":1712345678000}
* {"type":"pong"}
*
* Client → Server:
@@ -110,8 +110,8 @@ class GatewayWebSocketServer(
* Broadcast a weight reading to all clients.
* If [stable] is true, also fulfil pending on-demand weight requests.
*/
fun publishWeight(grams: Int, stable: Boolean, battery: Int? = null) {
val json = buildWeightJson(grams, stable)
fun publishWeight(value: Float, unit: String, stable: Boolean, battery: Int? = null) {
val json = buildWeightJson(value, unit, stable)
lastWeightJson = json
broadcast(json)
@@ -135,11 +135,13 @@ class GatewayWebSocketServer(
return obj.toString()
}
private fun buildWeightJson(grams: Int, stable: Boolean): String {
private fun buildWeightJson(value: Float, unit: String, stable: Boolean): String {
val obj = JSONObject()
obj.put("type", "weight")
obj.put("value", grams)
obj.put("unit", "g")
// Round to 1 decimal to avoid floating point noise (e.g. 17.000001)
val rounded = Math.round(value * 10f) / 10.0
obj.put("value", rounded)
obj.put("unit", unit)
obj.put("stable", stable)
obj.put("timestamp", System.currentTimeMillis())
return obj.toString()
@@ -54,8 +54,8 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
if (granted.values.all { it }) {
startGatewayServer()
} else {
showDialog("Permessi mancanti",
"L'app necessita dei permessi Bluetooth e Posizione per funzionare.")
showDialog("Missing permissions",
"The app requires Bluetooth and Location permissions to function.")
}
}
@@ -63,7 +63,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) checkPermissionsAndStart()
else showDialog("Bluetooth richiesto", "Attiva il Bluetooth per usare il gateway.")
else showDialog("Bluetooth required", "Please enable Bluetooth to use the gateway.")
}
// ─── Lifecycle ─────────────────────────────────────────────────────────────
@@ -93,13 +93,13 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
binding.svDebugLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnCopyLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnShareLog.visibility = if (debugVisible) View.VISIBLE else View.GONE
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Nascondi Debug" else "\uD83D\uDC1B Debug"
binding.btnDebug.text = if (debugVisible) "\uD83D\uDC1B Hide Debug" else "\uD83D\uDC1B Debug"
}
binding.btnCopyLog.setOnClickListener {
val log = debugLines.joinToString("\n")
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Scale Log", log))
Toast.makeText(this, "Log copiato negli appunti", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show()
}
binding.btnShareLog.setOnClickListener {
val log = debugLines.joinToString("\n")
@@ -108,7 +108,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
putExtra(Intent.EXTRA_SUBJECT, "EverShelf Scale Gateway - Debug Log")
putExtra(Intent.EXTRA_TEXT, log)
}
startActivity(Intent.createChooser(intent, "Condividi log"))
startActivity(Intent.createChooser(intent, "Share log"))
}
// Show app version
@@ -123,7 +123,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
// 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…"
binding.tvScanHint.text = "\uD83D\uDD04 Reconnecting to saved scale\u2026"
}
}
@@ -171,7 +171,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
debugLines.clear()
binding.tvDebugLog.text = ""
binding.tvScanHint.visibility = View.VISIBLE
binding.tvScanHint.text = "Ricerca bilance BLE in corso…"
binding.tvScanHint.text = "Scanning for BLE scales\u2026"
binding.btnScan.isEnabled = false
bleManager.enableAutoConnect()
bleManager.startScan()
@@ -185,9 +185,9 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
wsServer = GatewayWebSocketServer(WS_PORT, this)
wsServer!!.start()
updateGatewayUrl()
binding.tvGatewayStatus.text = " Gateway attivo sulla porta $WS_PORT"
binding.tvGatewayStatus.text = "\u2705 Gateway active on port $WS_PORT"
} catch (e: Exception) {
binding.tvGatewayStatus.text = "❌ Impossibile avviare il gateway: ${e.message}"
binding.tvGatewayStatus.text = "\u274C Failed to start gateway: ${e.message}"
}
// Auto-scan if there's a saved device
@@ -201,12 +201,12 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
val ip = getLocalIpAddress() ?: ""
val url = "ws://$ip:$WS_PORT"
binding.tvGatewayUrl.text = url
binding.tvGatewayUrlHint.text = "Incolla questo URL in EverShelf → Impostazioni → Bilancia Smart"
binding.tvGatewayUrlHint.text = "Paste this URL in EverShelf \u2192 Settings \u2192 Smart Scale"
binding.btnCopyUrl.setOnClickListener {
val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(android.content.ClipData.newPlainText("EverShelf Gateway URL", url))
binding.btnCopyUrl.text = " Copiato!"
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "📋 Copia URL" }, 2000)
binding.btnCopyUrl.text = "\u2705 Copied!"
binding.btnCopyUrl.postDelayed({ binding.btnCopyUrl.text = "\uD83D\uDCCB Copy URL" }, 2000)
}
}
@@ -224,14 +224,14 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
override fun onConnecting(device: BluetoothDevice) {
val name = try { device.name ?: device.address } catch (e: SecurityException) { device.address }
binding.tvScaleStatus.text = "⏳ Connessione a $name"
binding.tvScaleStatus.text = "\u23f3 Connecting to $name\u2026"
binding.tvWeight.text = "— — —"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_orange_light))
}
override fun onConnected(deviceName: String) {
binding.tvScaleStatus.text = " Connessa: $deviceName"
binding.tvWeight.text = "In attesa di un peso…"
binding.tvScaleStatus.text = "\u2705 Connected: $deviceName"
binding.tvWeight.text = "Waiting for weight\u2026"
binding.cardConnection.setCardBackgroundColor(getColor(android.R.color.holo_green_light))
binding.btnDisconnect.visibility = View.VISIBLE
binding.rvDevices.visibility = View.GONE
@@ -246,14 +246,16 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
}
override fun onWeightReceived(reading: WeightReading) {
binding.tvWeight.text = "${reading.grams} g"
val displayValue = if (reading.value % 1f == 0f) reading.value.toInt().toString()
else "%.1f".format(reading.value)
binding.tvWeight.text = "$displayValue ${reading.unit}"
if (reading.stable) {
binding.tvWeightHint.text = "\u2713 Lettura stabile"
binding.tvWeightHint.text = "\u2713 Stable reading"
} else {
binding.tvWeightHint.text = "\u23f3 Misurazione in corso\u2026"
binding.tvWeightHint.text = "\u23f3 Measuring\u2026"
}
wsServer?.publishWeight(reading.grams, reading.stable, batteryLevel)
wsServer?.publishWeight(reading.value, reading.unit, reading.stable, batteryLevel)
}
override fun onBatteryReceived(level: Int) {
@@ -261,7 +263,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
binding.tvBattery.text = "🔋 $level%"
binding.tvBattery.visibility = View.VISIBLE
wsServer?.publishStatus("connected", binding.tvScaleStatus.text.toString()
.removePrefix(" Connessa: "), level)
.removePrefix("\u2705 Connected: "), level)
}
override fun onError(message: String) {
@@ -272,9 +274,9 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
override fun onScanStopped() {
binding.btnScan.isEnabled = true
if (devices.isEmpty()) {
binding.tvScanHint.text = "Nessuna bilancia trovata. Assicurati che sia accesa e premi di nuovo Cerca."
binding.tvScanHint.text = "No scale found. Make sure it's on, then scan again."
} else {
binding.tvScanHint.text = "Tocca una bilancia per connettersi."
binding.tvScanHint.text = "Tap a scale to connect."
}
}
@@ -300,7 +302,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
override fun onClientConnected(address: String) {
runOnUiThread {
binding.tvClientCount.text = "🌐 Client connesso: $address"
binding.tvClientCount.text = "\uD83C\uDF10 Client connected: $address"
binding.tvClientCount.visibility = View.VISIBLE
}
}
@@ -316,7 +318,7 @@ class MainActivity : AppCompatActivity(), BleScaleListener, ServerEventListener
// ─── UI helpers ───────────────────────────────────────────────────────────
private fun updateUiDisconnected() {
binding.tvScaleStatus.text = "⚡ Pronto — cerca una bilancia"
binding.tvScaleStatus.text = "\u26a1 Ready \u2014 scan for a scale"
binding.tvWeight.text = "— — —"
binding.tvWeightHint.text = ""
binding.tvBattery.visibility = View.GONE
@@ -5,8 +5,14 @@ import java.util.UUID
// --- Data model ---
/**
* A single weight reading from a BLE scale.
* [value] is in the scale's current display unit (grams, oz, ml, lb).
* [unit] is "g", "oz", "ml", or "lb".
*/
data class WeightReading(
val grams: Int,
val value: Float,
val unit: String,
val stable: Boolean,
)
@@ -16,11 +22,11 @@ val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
object BleUuids {
// BLE SIG Weight Scale (some kitchen scales use this)
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_SCALE_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb")
val WEIGHT_MEASUREMENT_CHAR = UUID.fromString("00002a9d-0000-1000-8000-00805f9b34fb")
// Battery
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL_CHAR = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
// Common vendor services used by kitchen scales
@@ -30,11 +36,11 @@ object BleUuids {
val FFF1 = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
val FFF4 = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
// Acaia coffee scales
// Acaia / Brewista coffee scales
val ACAIA_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
val ACAIA_CHAR = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
// QN/Yolanda food scale (e.g. QN-KS) secondary service
// QN/Yolanda food scale secondary service (QN-KS, etc.)
val QN_AE00 = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
val QN_AE02 = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb")
}
@@ -43,9 +49,9 @@ object BleUuids {
object ScaleProtocol {
// Max plausible kitchen scale weight: 15kg = 15000g
private const val MAX_GRAMS = 15000
private const val MIN_GRAMS = 1
// Plausible kitchen scale range
private const val MAX_GRAMS = 15000f
private const val MIN_GRAMS = 0.5f // allow tare/small values
fun resetState() { /* reserved for future use */ }
@@ -59,129 +65,141 @@ object ScaleProtocol {
return null
}
// Try known UUID-based parsers first
// UUID-specific parsers
when (char.uuid) {
BleUuids.WEIGHT_MEASUREMENT_CHAR -> return parseSigWeight(data, debug)
}
// QN/Yolanda food scale (QN-KS, etc.): 18-byte frame, opcode=0x10, weight at bytes 9-10 BE
// Frame: [0x10][0x12=len][...][flags][weight_hi][weight_lo][...][crc]
if (data.size == 18 && (data[0].toInt() and 0xFF) == 0x10
// QN/Yolanda food scale (QN-KS, BC-KS, etc.):
// 18-byte frame starting with 0x10 0x12 on FFF1
if (data.size == 18
&& (data[0].toInt() and 0xFF) == 0x10
&& (data[1].toInt() and 0xFF) == 0x12) {
return parseQNFood(data, debug)
}
// Try pattern-based parsers on any characteristic
return parseGeneric(data, debug)
}
// --- QN/Yolanda food scale (QN-KS) ---
// Observed protocol: 18-byte notification on 0000FFF1
// [0x10][0x12][00][78][01][02][05][01][flags][weight_hi][weight_lo][7E][1F][02][58][02][01][crc]
// Weight = u16BE(data, 9) in grams (1g resolution, up to ~5000g)
// Stable = bit 3 of data[8]: 0xF8 => stable, 0xF0 => settling
// CRC = (sum of bytes 0..16) mod 256
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
// Verify checksum
val crc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
if (crc != (data[17].toInt() and 0xFF)) {
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(crc, data[17].toInt() and 0xFF))
return null
}
val grams = u16be(data, 9)
val stable = (data[8].toInt() and 0x08) != 0
debug?.invoke("QN-KS: ${grams}g stable=$stable")
if (grams == 0) return null // scale empty / tared
if (grams in MIN_GRAMS..MAX_GRAMS) return WeightReading(grams, stable)
return null
}
// --- BLE SIG 0x2A9D Weight Measurement ---
// Kitchen scales that use the SIG profile send weight in
// resolution of 0.005 kg (metric) or 0.01 lb (imperial).
// -------------------------------------------------------------------------
// BLE SIG 0x2A9D Weight Measurement
// -------------------------------------------------------------------------
private fun parseSigWeight(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) return null
val flags = data[0].toInt() and 0xFF
val flags = data[0].toInt() and 0xFF
val isImperial = (flags and 0x01) != 0
val raw = u16le(data, 1)
val raw = u16le(data, 1)
val grams = if (isImperial) {
Math.round(raw * 0.01f * 453.592f) // lb -> g
return if (isImperial) {
val lb = raw * 0.01f
debug?.invoke("SIG 2A9D: raw=$raw -> ${lb}lb")
if (lb < 0.01f || lb > 33f) null
else WeightReading(lb, "lb", stable = true)
} else {
Math.round(raw * 5f) // resolution 0.005kg = 5g per unit
val g = raw * 5f // 0.005 kg resolution = 5 g/unit
debug?.invoke("SIG 2A9D: raw=$raw -> ${g}g")
if (g < MIN_GRAMS || g > MAX_GRAMS) null
else WeightReading(g, "g", stable = true)
}
val unit = if (isImperial) "lb" else "kg"
debug?.invoke("SIG 2A9D: raw=$raw $unit -> ${grams}g")
if (grams in MIN_GRAMS..MAX_GRAMS) return WeightReading(grams, stable = true)
return null
}
// --- Generic food scale parser ---
// Tries common data layouts used by BLE kitchen scales.
// Many cheap kitchen scales send a simple frame with weight as uint16.
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) {
debug?.invoke("skip: too short for generic (" + data.size + "B)")
// -------------------------------------------------------------------------
// QN / Yolanda food scale (QN-KS, BC-KS, YolandaKS, ...)
//
// 18-byte notification on service 0xFFF0, char 0xFFF1:
// [0x10][0x12][00][??][unit][02][05][01][flags][w_hi][w_lo][7E][1F][02][58][02][01][crc]
// index: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
//
// weight = u16BE(data, 9) / 10.0 (0.1-unit resolution)
// unit = byte[4]: 0x01=g, 0x02=oz, 0x03=ml(water), 0x04=ml(milk)
// stable = bit3 of byte[8] != 0 (0xF8=stable, 0xF0=settling)
// crc = sum(bytes[0..16]) mod 256
// -------------------------------------------------------------------------
private fun parseQNFood(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
// Verify checksum
val calc = data.take(17).sumOf { it.toInt() and 0xFF } and 0xFF
if (calc != (data[17].toInt() and 0xFF)) {
debug?.invoke("QN-KS: CRC mismatch (calc=0x%02X got=0x%02X)".format(calc, data[17].toInt() and 0xFF))
return null
}
data class C(
val pos: Int,
val be: Boolean,
val toGrams: (Int) -> Int,
val label: String,
)
val rawValue = u16be(data, 9)
val stable = (data[8].toInt() and 0x08) != 0
val unit = when (data[4].toInt() and 0xFF) {
0x01 -> "g"
0x02 -> "oz"
0x03 -> "ml" // water mode
0x04 -> "ml" // milk mode
else -> "g"
}
// Resolution is 0.1 unit (e.g. 170 raw = 17.0 g, 195 raw = 19.5 g)
val value = rawValue / 10f
debug?.invoke("QN-KS: ${value}${unit} stable=$stable (raw=$rawValue unit_byte=0x%02X)".format(data[4].toInt() and 0xFF))
if (rawValue == 0) return null
// Convert to grams for range check
val valueG = if (unit == "oz") value * 28.3495f else value
if (valueG < MIN_GRAMS || valueG > MAX_GRAMS) return null
return WeightReading(value, unit, stable)
}
// -------------------------------------------------------------------------
// Generic fallback parser
// Tries common frame layouts used by many BLE kitchen scales.
// -------------------------------------------------------------------------
private fun parseGeneric(data: ByteArray, debug: ((String) -> Unit)? = null): WeightReading? {
if (data.size < 3) {
debug?.invoke("generic: skip short packet (" + data.size + "B)")
return null
}
data class C(val pos: Int, val be: Boolean, val div: Float, val label: String)
// Candidates: position in frame, endianness, conversion to grams
val candidates = listOf(
// Raw = grams directly
C(1, false, { it }, "pos1 LE grams"),
C(1, true, { it }, "pos1 BE grams"),
C(2, false, { it }, "pos2 LE grams"),
C(2, true, { it }, "pos2 BE grams"),
C(3, false, { it }, "pos3 LE grams"),
C(3, true, { it }, "pos3 BE grams"),
// Raw = 0.1g units (high-precision scales, e.g. coffee)
C(1, false, { Math.round(it / 10f) }, "pos1 LE 0.1g"),
C(1, true, { Math.round(it / 10f) }, "pos1 BE 0.1g"),
C(2, false, { Math.round(it / 10f) }, "pos2 LE 0.1g"),
C(2, true, { Math.round(it / 10f) }, "pos2 BE 0.1g"),
// Raw = 0.5g units
C(1, false, { Math.round(it * 0.5f) }, "pos1 LE 0.5g"),
C(1, true, { Math.round(it * 0.5f) }, "pos1 BE 0.5g"),
// Raw = centgrams (raw / 100 = kg, so raw * 10 = g)
C(1, false, { it * 10 }, "pos1 LE cg"),
C(1, true, { it * 10 }, "pos1 BE cg"),
C(3, false, { it * 10 }, "pos3 LE cg"),
C(3, true, { it * 10 }, "pos3 BE cg"),
// Raw = kg*100 (body-style but small ranges work for food too)
C(1, false, { Math.round(it * 10f) }, "pos1 LE kg100"),
C(1, true, { Math.round(it * 10f) }, "pos1 BE kg100"),
// Raw = oz * 10
C(1, false, { Math.round(it * 2.835f) }, "pos1 LE oz10"),
C(1, true, { Math.round(it * 2.835f) }, "pos1 BE oz10"),
// Direct grams (1g resolution)
C(1, false, 1f, "pos1 LE g"),
C(1, true, 1f, "pos1 BE g"),
C(2, false, 1f, "pos2 LE g"),
C(2, true, 1f, "pos2 BE g"),
C(3, false, 1f, "pos3 LE g"),
C(3, true, 1f, "pos3 BE g"),
// 0.1g resolution (high-precision scales)
C(1, false, 10f, "pos1 LE 0.1g"),
C(1, true, 10f, "pos1 BE 0.1g"),
C(2, false, 10f, "pos2 LE 0.1g"),
C(2, true, 10f, "pos2 BE 0.1g"),
C(3, false, 10f, "pos3 LE 0.1g"),
C(3, true, 10f, "pos3 BE 0.1g"),
// 0.5g resolution
C(1, false, 2f, "pos1 LE 0.5g"),
C(1, true, 2f, "pos1 BE 0.5g"),
// Raw = centgrams (raw*10 = g)
C(1, false, 0.1f, "pos1 LE cg"),
C(1, true, 0.1f, "pos1 BE cg"),
C(3, false, 0.1f, "pos3 LE cg"),
C(3, true, 0.1f, "pos3 BE cg"),
)
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)
if (raw == 0) continue
val grams = c.toGrams(raw)
if (grams in MIN_GRAMS..MAX_GRAMS) {
debug?.invoke("generic [" + c.label + "]: raw=$raw -> ${grams}g (unstable)")
return WeightReading(grams, stable = false)
val g = raw / c.div
if (g in MIN_GRAMS..MAX_GRAMS) {
debug?.invoke("generic [${c.label}]: raw=$raw -> ${g}g (unstable)")
return WeightReading(g, "g", stable = false)
}
}
debug?.invoke("generic: no valid weight in " + data.size + " bytes")
debug?.invoke("generic: no valid candidate in " + data.size + " bytes")
return null
}
// --- Helpers ---
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun u16le(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
@@ -31,7 +31,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Collega la tua bilancia smart a EverShelf via Bluetooth"
android:text="Connect your smart scale to EverShelf via Bluetooth"
android:textSize="13sp"
android:textColor="#64748B" />
@@ -65,7 +65,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🌐 URL Gateway (incolla in EverShelf)"
android:text="🌐 Gateway URL (paste into EverShelf)"
android:textSize="12sp"
android:textColor="#64748B"
android:paddingBottom="4dp" />
@@ -85,7 +85,7 @@
android:id="@+id/tv_gateway_url_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Impostazioni → Bilancia Smart"
android:text="Settings → Smart Scale"
android:textSize="11sp"
android:textColor="#94A3B8"
android:paddingBottom="8dp" />
@@ -94,7 +94,7 @@
android:id="@+id/btn_copy_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📋 Copia URL"
android:text="📋 Copy URL"
android:backgroundTint="#1D4ED8"
android:textColor="#FFFFFF"
style="@style/Widget.MaterialComponents.Button" />
@@ -107,7 +107,7 @@
android:id="@+id/tv_gateway_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⏳ Avvio gateway…"
android:text="⏳ Starting gateway…"
android:textSize="13sp"
android:textColor="#64748B"
android:paddingBottom="4dp" />
@@ -142,7 +142,7 @@
android:id="@+id/tv_scale_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚡ Pronto — cerca una bilancia"
android:text="⚡ Ready — scan for a scale"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#FFFFFF"
@@ -183,7 +183,7 @@
android:id="@+id/btn_disconnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔌 Disconnetti bilancia"
android:text="🔌 Disconnect scale"
android:backgroundTint="#EF4444"
android:textColor="#FFFFFF"
android:visibility="gone"
@@ -198,7 +198,7 @@
android:id="@+id/btn_scan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔍 Cerca Bilance Bluetooth"
android:text="🔍 Scan for Bluetooth Scales"
android:backgroundTint="#7C3AED"
android:textColor="#FFFFFF"
android:layout_marginBottom="8dp"
@@ -269,7 +269,7 @@
android:id="@+id/tv_scan_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Premi per cercare bilance BLE nelle vicinanze.\nAssicurati che la bilancia sia accesa."
android:text="Press to scan for nearby BLE scales.\nMake sure the scale is turned on."
android:textSize="12sp"
android:textColor="#64748B"
android:paddingBottom="12dp"