diff --git a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/BleScaleManager.kt b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/BleScaleManager.kt index 8acbdf6..0f424e8 100644 --- a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/BleScaleManager.kt +++ b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/BleScaleManager.kt @@ -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") } } } } diff --git a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/GatewayWebSocketServer.kt b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/GatewayWebSocketServer.kt index b47b20d..277c259 100644 --- a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/GatewayWebSocketServer.kt +++ b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/GatewayWebSocketServer.kt @@ -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() diff --git a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt index 5299b05..3cccabc 100644 --- a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt +++ b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/MainActivity.kt @@ -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 diff --git a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/ScaleProtocol.kt b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/ScaleProtocol.kt index 6547ba4..21261e8 100644 --- a/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/ScaleProtocol.kt +++ b/evershelf-scale-gateway/app/src/main/kotlin/it/dadaloop/evershelf/scalegate/ScaleProtocol.kt @@ -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) diff --git a/evershelf-scale-gateway/app/src/main/res/layout/activity_main.xml b/evershelf-scale-gateway/app/src/main/res/layout/activity_main.xml index 0c86aa0..ca756c3 100644 --- a/evershelf-scale-gateway/app/src/main/res/layout/activity_main.xml +++ b/evershelf-scale-gateway/app/src/main/res/layout/activity_main.xml @@ -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 @@ @@ -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"