|
|
|
|
@@ -1,10 +1,11 @@
|
|
|
|
|
package com.weightscalebridge
|
|
|
|
|
|
|
|
|
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
|
|
|
import com.facebook.react.module.annotations.ReactModule
|
|
|
|
|
import android.Manifest
|
|
|
|
|
import android.bluetooth.BluetoothAdapter
|
|
|
|
|
import android.bluetooth.BluetoothManager
|
|
|
|
|
import android.bluetooth.le.*
|
|
|
|
|
import android.bluetooth.le.BluetoothLeScanner
|
|
|
|
|
import android.bluetooth.le.ScanCallback
|
|
|
|
|
import android.bluetooth.le.ScanResult
|
|
|
|
|
import android.content.Context
|
|
|
|
|
import android.content.pm.PackageManager
|
|
|
|
|
import android.os.Build
|
|
|
|
|
@@ -12,182 +13,181 @@ import android.os.CountDownTimer
|
|
|
|
|
import androidx.core.content.ContextCompat
|
|
|
|
|
import com.facebook.react.bridge.*
|
|
|
|
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
|
|
|
import kotlin.math.abs
|
|
|
|
|
import kotlin.math.pow
|
|
|
|
|
import kotlin.math.round
|
|
|
|
|
|
|
|
|
|
class WeightScaleModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
|
|
|
|
class WeightScaleModule(
|
|
|
|
|
private val reactContext: ReactApplicationContext
|
|
|
|
|
) : ReactContextBaseJavaModule(reactContext) {
|
|
|
|
|
|
|
|
|
|
override fun getName(): String = "WeightScale"
|
|
|
|
|
private var bluetoothAdapter: BluetoothAdapter? = null
|
|
|
|
|
private var scanner: BluetoothLeScanner? = null
|
|
|
|
|
private var isScanning = false
|
|
|
|
|
private var timer: CountDownTimer? = null
|
|
|
|
|
private var currentWeight = 0.0
|
|
|
|
|
|
|
|
|
|
private var bluetoothAdapter: BluetoothAdapter? = null
|
|
|
|
|
private var scanner: BluetoothLeScanner? = null
|
|
|
|
|
private var isScanning = false
|
|
|
|
|
private var timer: CountDownTimer? = null
|
|
|
|
|
override fun getName(): String = "WeightScaleBridge"
|
|
|
|
|
|
|
|
|
|
private var candidateKg: Double = Double.NaN
|
|
|
|
|
private var stableSince: Long = 0L
|
|
|
|
|
init {
|
|
|
|
|
val manager =
|
|
|
|
|
reactContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
|
|
|
bluetoothAdapter = manager.adapter
|
|
|
|
|
scanner = bluetoothAdapter?.bluetoothLeScanner
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private val STABLE_WINDOW_MS = 4000L
|
|
|
|
|
private val WEIGHT_TOL = 0.1
|
|
|
|
|
private val MIN_VALID_KG = 5.0
|
|
|
|
|
private val MAX_VALID_KG = 250.0
|
|
|
|
|
private val MEASURE_WINDOW_MS = 20000L // 20 seconds timeout
|
|
|
|
|
private val TARGET_MAC = "34:5C:F3:40:2C:D8" // Your scale MAC
|
|
|
|
|
private fun sendEvent(name: String, params: WritableMap?) {
|
|
|
|
|
reactContext
|
|
|
|
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
|
|
|
.emit(name, params)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun sendEvent(eventName: String, params: WritableMap) {
|
|
|
|
|
reactApplicationContext
|
|
|
|
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
|
|
|
.emit(eventName, params)
|
|
|
|
|
@ReactMethod
|
|
|
|
|
fun isBluetoothEnabled(promise: Promise) {
|
|
|
|
|
try {
|
|
|
|
|
val enabled = bluetoothAdapter?.isEnabled ?: false
|
|
|
|
|
promise.resolve(enabled)
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
promise.reject("BLUETOOTH_CHECK_ERROR", e.message, e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ReactMethod
|
|
|
|
|
fun startScanning(promise: Promise) {
|
|
|
|
|
try {
|
|
|
|
|
if (!hasPermissions()) {
|
|
|
|
|
promise.reject("PERMISSION", "Bluetooth permissions not granted")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (bluetoothAdapter?.isEnabled != true) {
|
|
|
|
|
promise.reject("BLUETOOTH", "Bluetooth disabled")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isScanning) {
|
|
|
|
|
promise.resolve(true)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isScanning = true
|
|
|
|
|
currentWeight = 0.0
|
|
|
|
|
|
|
|
|
|
scanner?.startScan(scanCallback)
|
|
|
|
|
|
|
|
|
|
sendEvent(
|
|
|
|
|
"onWeightScaleStatus",
|
|
|
|
|
Arguments.createMap().apply { putString("status", "scanning") }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
timer = object : CountDownTimer(20000, 1000) {
|
|
|
|
|
override fun onTick(ms: Long) {}
|
|
|
|
|
override fun onFinish() {
|
|
|
|
|
stopScanning(null)
|
|
|
|
|
}
|
|
|
|
|
}.start()
|
|
|
|
|
|
|
|
|
|
promise.resolve(true)
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
promise.reject("SCAN_ERROR", e.message, e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ReactMethod
|
|
|
|
|
fun stopScanning(promise: Promise?) {
|
|
|
|
|
try {
|
|
|
|
|
scanner?.stopScan(scanCallback)
|
|
|
|
|
} catch (_: Exception) {}
|
|
|
|
|
|
|
|
|
|
timer?.cancel()
|
|
|
|
|
isScanning = false
|
|
|
|
|
|
|
|
|
|
sendEvent(
|
|
|
|
|
"onWeightScaleStatus",
|
|
|
|
|
Arguments.createMap().apply { putString("status", "stopped") }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
promise?.resolve(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private val scanCallback = object : ScanCallback() {
|
|
|
|
|
override fun onScanResult(type: Int, result: ScanResult) {
|
|
|
|
|
val record = result.scanRecord ?: return
|
|
|
|
|
val data = record.manufacturerSpecificData
|
|
|
|
|
|
|
|
|
|
if (data.size() == 0) return
|
|
|
|
|
|
|
|
|
|
val raw = data.valueAt(0)
|
|
|
|
|
if (raw.size < 2) return
|
|
|
|
|
|
|
|
|
|
val weight = (((raw[0].toInt() and 0xff) shl 8) or
|
|
|
|
|
(raw[1].toInt() and 0xff)) / 10.0
|
|
|
|
|
|
|
|
|
|
currentWeight = weight
|
|
|
|
|
|
|
|
|
|
sendEvent(
|
|
|
|
|
"onWeightData",
|
|
|
|
|
Arguments.createMap().apply {
|
|
|
|
|
putDouble("weight", weight)
|
|
|
|
|
putBoolean("isStable", true)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
sendEvent(
|
|
|
|
|
"onWeightScaleStatus",
|
|
|
|
|
Arguments.createMap().apply {
|
|
|
|
|
putString("status", "complete")
|
|
|
|
|
putString("message", "$weight kg")
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
stopScanning(null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ReactMethod
|
|
|
|
|
fun startMeasuring() {
|
|
|
|
|
if (isScanning) return
|
|
|
|
|
|
|
|
|
|
val bluetoothManager = reactApplicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
|
|
|
|
bluetoothAdapter = bluetoothManager.adapter
|
|
|
|
|
|
|
|
|
|
if (bluetoothAdapter == null || !bluetoothAdapter!!.isEnabled) {
|
|
|
|
|
val params = Arguments.createMap().apply {
|
|
|
|
|
putString("message", "Bluetooth is not enabled. Please turn it on in settings.")
|
|
|
|
|
}
|
|
|
|
|
sendEvent("onScaleError", params)
|
|
|
|
|
return
|
|
|
|
|
override fun onScanFailed(errorCode: Int) {
|
|
|
|
|
super.onScanFailed(errorCode)
|
|
|
|
|
|
|
|
|
|
sendEvent(
|
|
|
|
|
"onWeightScaleStatus",
|
|
|
|
|
Arguments.createMap().apply {
|
|
|
|
|
putString("status", "error")
|
|
|
|
|
putString("message", "Scan failed with code: $errorCode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasRequiredPermissions()) {
|
|
|
|
|
val params = Arguments.createMap().apply {
|
|
|
|
|
putString("message", "Required permissions not granted.")
|
|
|
|
|
}
|
|
|
|
|
sendEvent("onScaleError", params)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scanner = bluetoothAdapter!!.bluetoothLeScanner
|
|
|
|
|
|
|
|
|
|
// Reset stability tracking
|
|
|
|
|
candidateKg = Double.NaN
|
|
|
|
|
stableSince = 0L
|
|
|
|
|
isScanning = true
|
|
|
|
|
|
|
|
|
|
// Send initial status
|
|
|
|
|
sendEvent("onStatusChange", Arguments.createMap().apply { putString("status", "scanning") })
|
|
|
|
|
|
|
|
|
|
// Use filter for your specific scale MAC (more efficient)
|
|
|
|
|
val scanFilter = ScanFilter.Builder()
|
|
|
|
|
.setDeviceAddress(TARGET_MAC)
|
|
|
|
|
.build()
|
|
|
|
|
val scanFilters = listOf(scanFilter)
|
|
|
|
|
|
|
|
|
|
val scanSettings = ScanSettings.Builder()
|
|
|
|
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
|
|
|
|
.build()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
scanner!!.startScan(scanFilters, scanSettings, scanCallback)
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
val params = Arguments.createMap().apply { putString("message", e.message ?: "Scan start failed") }
|
|
|
|
|
sendEvent("onScaleError", params)
|
|
|
|
|
stopMeasuring()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 20-second timeout
|
|
|
|
|
timer = object : CountDownTimer(MEASURE_WINDOW_MS, 1000) {
|
|
|
|
|
override fun onTick(millisUntilFinished: Long) {
|
|
|
|
|
// Optional: send remaining seconds if needed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onFinish() {
|
|
|
|
|
stopMeasuring()
|
|
|
|
|
sendEvent("onStatusChange", Arguments.createMap().apply { putString("status", "timeout") })
|
|
|
|
|
}
|
|
|
|
|
}.start()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
isScanning = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ReactMethod
|
|
|
|
|
fun stopMeasuring() {
|
|
|
|
|
if (!isScanning) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
scanner?.stopScan(scanCallback)
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
// Ignore
|
|
|
|
|
}
|
|
|
|
|
timer?.cancel()
|
|
|
|
|
timer = null
|
|
|
|
|
isScanning = false
|
|
|
|
|
|
|
|
|
|
sendEvent("onStatusChange", Arguments.createMap().apply { putString("status", "stopped") })
|
|
|
|
|
@ReactMethod
|
|
|
|
|
fun calculateBMI(weight: Double, heightCm: Double, promise: Promise) {
|
|
|
|
|
try {
|
|
|
|
|
if (heightCm <= 0) {
|
|
|
|
|
promise.reject("INVALID_HEIGHT", "Height must be greater than 0")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val bmi = weight / (heightCm / 100).pow(2)
|
|
|
|
|
promise.resolve(round(bmi * 10) / 10)
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
promise.reject("BMI_ERROR", e.message, e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private val scanCallback = object : ScanCallback() {
|
|
|
|
|
override fun onScanResult(callbackType: Int, result: ScanResult?) {
|
|
|
|
|
result ?: return
|
|
|
|
|
val scanRecord = result.scanRecord ?: return
|
|
|
|
|
val manufacturerData = scanRecord.manufacturerSpecificData
|
|
|
|
|
private fun hasPermissions(): Boolean {
|
|
|
|
|
val perms =
|
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
|
|
|
|
arrayOf(
|
|
|
|
|
Manifest.permission.BLUETOOTH_SCAN,
|
|
|
|
|
Manifest.permission.BLUETOOTH_CONNECT
|
|
|
|
|
)
|
|
|
|
|
else
|
|
|
|
|
arrayOf(
|
|
|
|
|
Manifest.permission.ACCESS_FINE_LOCATION
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (manufacturerData != null && manufacturerData.size() > 0) {
|
|
|
|
|
for (i in 0 until manufacturerData.size()) {
|
|
|
|
|
val data = manufacturerData.valueAt(i) ?: continue
|
|
|
|
|
if (data.size >= 2) {
|
|
|
|
|
val tenths = ((data[0].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF)
|
|
|
|
|
val kg = tenths / 10.0
|
|
|
|
|
if (kg in MIN_VALID_KG..MAX_VALID_KG) {
|
|
|
|
|
processWeight(kg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onScanFailed(errorCode: Int) {
|
|
|
|
|
val params = Arguments.createMap().apply {
|
|
|
|
|
putString("message", "Scan failed with code: $errorCode")
|
|
|
|
|
}
|
|
|
|
|
sendEvent("onScaleError", params)
|
|
|
|
|
stopMeasuring()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun processWeight(kg: Double) {
|
|
|
|
|
// Send live weight update
|
|
|
|
|
val liveParams = Arguments.createMap().apply { putDouble("weight", kg) }
|
|
|
|
|
sendEvent("onLiveWeight", liveParams)
|
|
|
|
|
|
|
|
|
|
// Update status to measuring
|
|
|
|
|
sendEvent("onStatusChange", Arguments.createMap().apply { putString("status", "measuring") })
|
|
|
|
|
|
|
|
|
|
val now = System.currentTimeMillis()
|
|
|
|
|
|
|
|
|
|
// Stability detection
|
|
|
|
|
if (candidateKg.isNaN() || abs(kg - candidateKg) > WEIGHT_TOL) {
|
|
|
|
|
candidateKg = kg
|
|
|
|
|
stableSince = now
|
|
|
|
|
} else if (now - stableSince >= STABLE_WINDOW_MS) {
|
|
|
|
|
// Stable weight achieved
|
|
|
|
|
val stableParams = Arguments.createMap().apply { putDouble("weight", candidateKg) }
|
|
|
|
|
sendEvent("onStableWeight", stableParams)
|
|
|
|
|
sendEvent("onStatusChange", Arguments.createMap().apply { putString("status", "stable") })
|
|
|
|
|
stopMeasuring()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun hasRequiredPermissions(): Boolean {
|
|
|
|
|
val perms = mutableListOf<String>()
|
|
|
|
|
perms.add(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
|
|
|
|
|
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
|
|
|
perms.add(android.Manifest.permission.BLUETOOTH_SCAN)
|
|
|
|
|
perms.add(android.Manifest.permission.BLUETOOTH_CONNECT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return perms.all {
|
|
|
|
|
ContextCompat.checkSelfPermission(reactApplicationContext, it) == PackageManager.PERMISSION_GRANTED
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onCatalystInstanceDestroy() {
|
|
|
|
|
super.onCatalystInstanceDestroy()
|
|
|
|
|
stopMeasuring() // Cleanup when RN instance destroys
|
|
|
|
|
return perms.all {
|
|
|
|
|
ContextCompat.checkSelfPermission(reactContext, it) ==
|
|
|
|
|
PackageManager.PERMISSION_GRANTED
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|