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:
+16
-16
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-7
@@ -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()
|
||||
|
||||
+27
-25
@@ -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
|
||||
|
||||
+115
-97
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user