mirror of
https://github.com/oliexdev/openScale.git
synced 2025-09-09 16:10:41 +02:00
Separate ModernScaleAdapter into link-specific adapters:
- `ModernScaleAdapter` (abstract base): Handles app integration, event streams, and handler wiring. - `GattScaleAdapter`: Implements BLE GATT connections using Blessed. - `BroadcastScaleAdapter`: Handles devices that only use BLE advertisements. - `SppScaleAdapter`: Implements Bluetooth Classic SPP connections.
This commit is contained in:
@@ -56,9 +56,13 @@ import com.health.openscale.core.bluetooth.legacy.BluetoothTrisaBodyAnalyze
|
||||
import com.health.openscale.core.bluetooth.legacy.BluetoothYoda1Scale
|
||||
import com.health.openscale.core.bluetooth.legacy.BluetoothYunmaiSE_Mini
|
||||
import com.health.openscale.core.bluetooth.legacy.LegacyScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.modern.BleTuningProfile
|
||||
import com.health.openscale.core.bluetooth.modern.BroadcastScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.modern.DeviceSupport
|
||||
import com.health.openscale.core.bluetooth.modern.ESCS20mHandler
|
||||
import com.health.openscale.core.bluetooth.modern.GattScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.modern.InlifeHandler
|
||||
import com.health.openscale.core.bluetooth.modern.LinkMode
|
||||
import com.health.openscale.core.bluetooth.modern.MGBHandler
|
||||
import com.health.openscale.core.bluetooth.modern.MedisanaBs44xHandler
|
||||
import com.health.openscale.core.bluetooth.modern.MiScaleHandler
|
||||
@@ -72,10 +76,12 @@ import com.health.openscale.core.bluetooth.modern.SanitasSBF72Handler
|
||||
import com.health.openscale.core.bluetooth.modern.SenssunHandler
|
||||
import com.health.openscale.core.bluetooth.modern.SinocareHandler
|
||||
import com.health.openscale.core.bluetooth.modern.SoehnleHandler
|
||||
import com.health.openscale.core.bluetooth.modern.SppScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.modern.StandardWeightProfileHandler
|
||||
import com.health.openscale.core.bluetooth.modern.TrisaBodyAnalyzeHandler
|
||||
import com.health.openscale.core.bluetooth.modern.Yoda1Handler
|
||||
import com.health.openscale.core.bluetooth.modern.YunmaiHandler
|
||||
import com.health.openscale.core.bluetooth.modern.asTuning
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
@@ -332,16 +338,32 @@ class ScaleFactory @Inject constructor(
|
||||
private fun createModernCommunicator(
|
||||
handler: ScaleDeviceHandler,
|
||||
support: DeviceSupport
|
||||
): ScaleCommunicator? {
|
||||
LogManager.i(TAG, "Creating ModernScaleAdapter for handler '${handler.javaClass.simpleName}'.")
|
||||
return ModernScaleAdapter(
|
||||
context = applicationContext,
|
||||
settingsFacade = settingsFacade,
|
||||
measurementFacade = measurementFacade,
|
||||
userFacade = userFacade,
|
||||
handler = handler,
|
||||
bleTuning = support.bleTuning
|
||||
)
|
||||
): ScaleCommunicator? = when (support.linkMode) {
|
||||
LinkMode.CONNECT_GATT ->
|
||||
GattScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
support.bleTuning ?: BleTuningProfile.Balanced.asTuning()
|
||||
)
|
||||
LinkMode.BROADCAST_ONLY ->
|
||||
BroadcastScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler
|
||||
)
|
||||
LinkMode.CLASSIC_SPP ->
|
||||
SppScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import com.health.openscale.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.welie.blessed.BluetoothCentralManager
|
||||
import com.welie.blessed.BluetoothCentralManagerCallback
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import java.util.UUID
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Broadcast adapter (no GATT)
|
||||
// - scans and forwards advertisements to handler.onAdvertisement()
|
||||
// - attaches handler with a no-op transport
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class BroadcastScaleAdapter(
|
||||
context: android.content.Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
private var broadcastAttached = false
|
||||
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
if (peripheral.address != targetAddress) return
|
||||
|
||||
if (!broadcastAttached) {
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
deviceAddress = peripheral.address,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(noopTransport, appCallbacks, driverSettings, dataProvider)
|
||||
broadcastAttached = true
|
||||
_events.tryEmit(BluetoothEvent.Listening(peripheral.address))
|
||||
}
|
||||
|
||||
val user = ensureSelectedUserOrFail(peripheral.address) ?: return
|
||||
when (handler.onAdvertisement(scanResult, user)) {
|
||||
BroadcastAction.IGNORED -> Unit
|
||||
BroadcastAction.CONSUMED_KEEP_SCANNING -> {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
}
|
||||
BroadcastAction.CONSUMED_STOP -> {
|
||||
_events.tryEmit(BluetoothEvent.BroadcastComplete(peripheral.address))
|
||||
doDisconnect()
|
||||
cleanup(peripheral.address)
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val noopTransport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {}
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {}
|
||||
override fun read(service: UUID, characteristic: UUID) {}
|
||||
override fun disconnect() { doDisconnect() }
|
||||
}
|
||||
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
}
|
||||
_isConnecting.value = false
|
||||
_isConnected.value = false
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(arrayOf(address))
|
||||
} catch (e: Exception) {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, e.message ?: context.getString(R.string.bt_error_generic)))
|
||||
cleanup(address)
|
||||
}
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
}
|
||||
}
|
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.os.SystemClock
|
||||
import com.health.openscale.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothCentralManager
|
||||
import com.welie.blessed.BluetoothCentralManagerCallback
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import com.welie.blessed.BluetoothPeripheralCallback
|
||||
import com.welie.blessed.ConnectionPriority
|
||||
import com.welie.blessed.GattStatus
|
||||
import com.welie.blessed.HciStatus
|
||||
import com.welie.blessed.WriteType
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.UUID
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// GATT adapter (BLE)
|
||||
// - scans for a specific address and connects via Blessed
|
||||
// - enables notifications, handles read/write with pacing (BleTuning)
|
||||
// - forwards notifications to handler.onNotification()
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class GattScaleAdapter(
|
||||
context: android.content.Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
bleTuning: BleTuning = BleTuningProfile.Balanced.asTuning()
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning = bleTuning
|
||||
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
private var currentPeripheral: BluetoothPeripheral? = null
|
||||
|
||||
private val ioMutex = Mutex()
|
||||
private suspend fun ioGap(ms: Long) { if (ms > 0) delay(ms) }
|
||||
|
||||
private var connectAttempts = 0
|
||||
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
if (peripheral.address != targetAddress) return
|
||||
LogManager.i(TAG, "Found $targetAddress → stop scan + connect")
|
||||
central.stopScan()
|
||||
scope.launch {
|
||||
if (tuning.connectAfterScanDelayMs > 0) delay(tuning.connectAfterScanDelayMs)
|
||||
central.connectPeripheral(peripheral, peripheralCallback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectedPeripheral(peripheral: BluetoothPeripheral) {
|
||||
scope.launch {
|
||||
currentPeripheral = peripheral
|
||||
_isConnected.value = true
|
||||
_isConnecting.value = false
|
||||
_events.tryEmit(BluetoothEvent.Connected(peripheral.name ?: "Unknown", peripheral.address))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.e(TAG, "Connection failed ${peripheral.address}: $status")
|
||||
if (connectAttempts < tuning.maxRetries) {
|
||||
val nextTry = connectAttempts + 1
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_reconnecting_try, nextTry, tuning.maxRetries),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
connectAttempts = nextTry
|
||||
delay(tuning.retryBackoffMs)
|
||||
runCatching { central.stopScan() }
|
||||
central.scanForPeripheralsWithAddresses(arrayOf(peripheral.address))
|
||||
_isConnecting.value = true
|
||||
} else {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(peripheral.address, status.toString()))
|
||||
cleanup(peripheral.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnectedPeripheral(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.i(TAG, "Disconnected ${peripheral.address}: $status")
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
if (peripheral.address == targetAddress) {
|
||||
_events.tryEmit(BluetoothEvent.Disconnected(peripheral.address, status.toString()))
|
||||
cleanup(peripheral.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val peripheralCallback = object : BluetoothPeripheralCallback() {
|
||||
override fun onServicesDiscovered(peripheral: BluetoothPeripheral) {
|
||||
LogManager.d(TAG, "Services discovered for ${peripheral.address}")
|
||||
currentPeripheral = peripheral
|
||||
|
||||
if (tuning.requestHighConnectionPriority) runCatching { peripheral.requestConnectionPriority(
|
||||
ConnectionPriority.HIGH) }
|
||||
if (tuning.requestMtuBytes > 23) runCatching { peripheral.requestMtu(tuning.requestMtuBytes) }
|
||||
|
||||
val user = ensureSelectedUserOrFail(peripheral.address) ?: run {
|
||||
central.cancelConnection(peripheral); return
|
||||
}
|
||||
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
deviceAddress = peripheral.address,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
|
||||
handler.attach(transport, appCallbacks, driverSettings, dataProvider)
|
||||
handler.handleConnected(user)
|
||||
}
|
||||
|
||||
override fun onCharacteristicUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: android.bluetooth.BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
if (status == GattStatus.SUCCESS) {
|
||||
handler.handleNotification(characteristic.uuid, value)
|
||||
} else {
|
||||
appCallbacks.onWarn(
|
||||
R.string.bt_warn_notify_state_failed_status,
|
||||
characteristic.uuid.toString(),
|
||||
status.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: android.bluetooth.BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
if (status != GattStatus.SUCCESS) {
|
||||
appCallbacks.onWarn(
|
||||
R.string.bt_warn_write_failed_status,
|
||||
characteristic.uuid.toString(),
|
||||
status.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationStateUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
characteristic: android.bluetooth.BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
if (status != GattStatus.SUCCESS) {
|
||||
appCallbacks.onWarn(
|
||||
R.string.bt_warn_notify_state_failed_status,
|
||||
characteristic.uuid.toString(),
|
||||
status.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val transport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
scope.launch {
|
||||
ioMutex.withLock {
|
||||
val p = currentPeripheral ?: run {
|
||||
appCallbacks.onWarn(R.string.bt_warn_no_peripheral_for_setnotify, characteristic.toString()); return@withLock
|
||||
}
|
||||
val ch = p.getCharacteristic(service, characteristic) ?: run {
|
||||
appCallbacks.onWarn(R.string.bt_warn_characteristic_not_found, characteristic.toString()); return@withLock
|
||||
}
|
||||
ioGap(tuning.notifySetupDelayMs)
|
||||
if (!p.setNotify(ch, true)) {
|
||||
appCallbacks.onWarn(R.string.bt_warn_notify_failed, characteristic.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {
|
||||
scope.launch {
|
||||
ioMutex.withLock {
|
||||
val p = currentPeripheral ?: run {
|
||||
appCallbacks.onWarn(R.string.bt_warn_no_peripheral_for_write, characteristic.toString()); return@withLock
|
||||
}
|
||||
val ch = p.getCharacteristic(service, characteristic) ?: run {
|
||||
appCallbacks.onWarn(R.string.bt_warn_characteristic_not_found, characteristic.toString()); return@withLock
|
||||
}
|
||||
val type = if (withResponse) WriteType.WITH_RESPONSE else WriteType.WITHOUT_RESPONSE
|
||||
ioGap(if (withResponse) tuning.writeWithResponseDelayMs else tuning.writeWithoutResponseDelayMs)
|
||||
p.writeCharacteristic(service, characteristic, payload, type)
|
||||
ioGap(tuning.postWriteDelayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(service: UUID, characteristic: UUID) {
|
||||
scope.launch {
|
||||
ioMutex.withLock {
|
||||
val p = currentPeripheral ?: run {
|
||||
appCallbacks.onWarn(R.string.bt_warn_no_peripheral_for_read, characteristic.toString()); return@withLock
|
||||
}
|
||||
p.readCharacteristic(service, characteristic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
currentPeripheral?.let { central.cancelConnection(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
}
|
||||
|
||||
// Cooldown to avoid Android stack churn
|
||||
val since = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
if (since in 1 until tuning.reconnectCooldownMs) {
|
||||
scope.launch { delay(tuning.reconnectCooldownMs - since) }
|
||||
}
|
||||
|
||||
connectAttempts = 0
|
||||
_isConnected.value = false
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(arrayOf(address))
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Failed to start scan/connect: ${e.message}", e)
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, e.message ?: context.getString(R.string.bt_error_generic)))
|
||||
cleanup(address)
|
||||
}
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
currentPeripheral?.let { runCatching { central.cancelConnection(it) } }
|
||||
currentPeripheral = null
|
||||
}
|
||||
}
|
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* iHealth HS3 (HS33FA4A) classic SPP handler.
|
||||
*
|
||||
* Transport: CLASSIC_SPP
|
||||
* Stream protocol (observed in legacy driver):
|
||||
* - Weight packet header: A0 09 A6 28, then 5 don't-care bytes, then 2 weight bytes.
|
||||
* - Time packet header: A0 09 A6 33 (ignored).
|
||||
* - Weight bytes are encoded as hex digits; the decimal point sits before the last *nibble*.
|
||||
* Example: bytes 0x12 0x34 -> "123.4" kg.
|
||||
*
|
||||
* We keep a small state machine to find headers in the raw SPP byte stream.
|
||||
*/
|
||||
class IHealthHS3Handler : ScaleDeviceHandler() {
|
||||
|
||||
override val linkMode: LinkMode = LinkMode.CLASSIC_SPP
|
||||
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
|
||||
// iHealth advertises as "iHealth HS3..." in most cases
|
||||
val n = device.name?.uppercase() ?: return null
|
||||
if (!n.startsWith("IHEALTH HS3")) return null
|
||||
|
||||
val caps = setOf(DeviceCapability.LIVE_WEIGHT_STREAM)
|
||||
return DeviceSupport(
|
||||
displayName = "iHealth HS3",
|
||||
capabilities = caps,
|
||||
implemented = caps,
|
||||
linkMode = LinkMode.CLASSIC_SPP
|
||||
)
|
||||
}
|
||||
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
// Nothing to configure on SPP; just tell UI to step on the scale
|
||||
userInfo(com.health.openscale.R.string.bt_info_step_on_scale)
|
||||
}
|
||||
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
if (characteristic != CLASSIC_DATA_UUID || data.isEmpty()) return
|
||||
feedStream(data)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stream parser (state machine)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// States:
|
||||
// 0: seek A0
|
||||
// 1: expect 09
|
||||
// 2: expect A6
|
||||
// 3: expect type (28=weight, 33=time, else reset)
|
||||
// 4: skip 5 bytes (don't care)
|
||||
// 5: read weight hi
|
||||
// 6: read weight lo -> parse/publish -> reset
|
||||
private var state = 0
|
||||
private var skipRemain = 0
|
||||
private var wHi: Byte = 0
|
||||
|
||||
// De-duplication: identical weight within 60s is dropped (matches legacy)
|
||||
private var lastW0: Byte = 0
|
||||
private var lastW1: Byte = 0
|
||||
private var lastWeighedMs: Long = 0
|
||||
private val maxTimeDiffMs = 60_000L
|
||||
|
||||
private fun feedStream(chunk: ByteArray) {
|
||||
for (b in chunk) {
|
||||
when (state) {
|
||||
0 -> {
|
||||
if (b == 0xA0.toByte()) state = 1
|
||||
}
|
||||
1 -> {
|
||||
state = if (b == 0x09.toByte()) 2 else if (b == 0xA0.toByte()) 1 else 0
|
||||
}
|
||||
2 -> {
|
||||
state = if (b == 0xA6.toByte()) 3 else 0
|
||||
}
|
||||
3 -> { // type
|
||||
when (b) {
|
||||
0x28.toByte() -> { // weight packet
|
||||
skipRemain = 5
|
||||
state = 4
|
||||
}
|
||||
0x33.toByte() -> { // time packet; ignore
|
||||
state = 0
|
||||
}
|
||||
else -> state = 0
|
||||
}
|
||||
}
|
||||
4 -> { // skip 5 bytes
|
||||
if (--skipRemain <= 0) state = 5
|
||||
}
|
||||
5 -> { // weight hi
|
||||
wHi = b
|
||||
state = 6
|
||||
}
|
||||
6 -> { // weight lo -> parse
|
||||
val wLo = b
|
||||
if (!isDuplicate(wHi, wLo)) {
|
||||
parseAndPublishWeight(wHi, wLo)
|
||||
lastW0 = wHi
|
||||
lastW1 = wLo
|
||||
lastWeighedMs = System.currentTimeMillis()
|
||||
} else {
|
||||
logD("Duplicate weight within window; dropped")
|
||||
}
|
||||
state = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDuplicate(hi: Byte, lo: Byte): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
return hi == lastW0 && lo == lastW1 && (now - lastWeighedMs) < maxTimeDiffMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy driver formed weight by hex-string of the two bytes and inserting
|
||||
* a decimal before the last nibble. Example: 0x12 0x34 -> "123.4".
|
||||
*/
|
||||
private fun parseAndPublishWeight(hi: Byte, lo: Byte) {
|
||||
val hex = String.format("%02X%02X", hi.toInt() and 0xFF, lo.toInt() and 0xFF)
|
||||
val weightStr = hex.dropLast(1) + "." + hex.takeLast(1)
|
||||
val weight = weightStr.toFloatOrNull()
|
||||
if (weight == null) {
|
||||
logW("Failed to parse weight from hex '$hex'")
|
||||
return
|
||||
}
|
||||
val m = ScaleMeasurement().apply {
|
||||
dateTime = Date()
|
||||
this.weight = weight
|
||||
}
|
||||
logD("Parsed weight: $weight kg")
|
||||
publish(m)
|
||||
}
|
||||
}
|
@@ -17,15 +17,11 @@
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import android.R.attr.type
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.StringRes
|
||||
import com.health.openscale.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType
|
||||
import com.health.openscale.core.bluetooth.ScaleCommunicator
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
@@ -36,12 +32,10 @@ import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.model.MeasurementWithValues
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.*
|
||||
import com.welie.blessed.WriteType.WITHOUT_RESPONSE
|
||||
import com.welie.blessed.WriteType.WITH_RESPONSE
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -52,56 +46,35 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.Int
|
||||
|
||||
/**
|
||||
* Describes BLE link timing and retry behavior used by the adapter when talking to a scale.
|
||||
*
|
||||
* These values are mainly about *pacing* (small delays) and *robustness* (cooldowns/retries).
|
||||
* Some scales/firmware are sensitive to how quickly notifications are enabled or writes are
|
||||
* issued in sequence. Small gaps drastically reduce GATT 133 errors and write failures.
|
||||
*
|
||||
* NOTE: This adapter currently uses internal defaults (see GAP_* and retry constants below).
|
||||
* The [BleTuning] type is provided to make the intent explicit and to allow future wiring
|
||||
* so device handlers can request different timings if needed.
|
||||
*/
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Shared tuning for BLE pacing & retry (used by GATT adapter).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
data class BleTuning(
|
||||
// I/O pacing (ms)
|
||||
val notifySetupDelayMs: Long,
|
||||
val writeWithResponseDelayMs: Long,
|
||||
val writeWithoutResponseDelayMs: Long,
|
||||
val postWriteDelayMs: Long,
|
||||
// Connect/retry policy
|
||||
val reconnectCooldownMs: Long,
|
||||
val retryBackoffMs: Long,
|
||||
val maxRetries: Int,
|
||||
val connectAfterScanDelayMs: Long,
|
||||
// Link behavior hints
|
||||
val requestHighConnectionPriority: Boolean,
|
||||
val requestMtuBytes: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Suggested presets that tend to work across vendors:
|
||||
*
|
||||
* - Balanced: good default for most scales.
|
||||
* - Conservative: more gaps, helpful for flaky radios/older Androids.
|
||||
* - Aggressive: tighter timings, for fast/science-mode devices.
|
||||
*
|
||||
* NOTE: The adapter consumes values from bleTuning (or Balanced profile if null).
|
||||
*/
|
||||
sealed class BleTuningProfile {
|
||||
object Balanced : BleTuningProfile()
|
||||
object Conservative : BleTuningProfile()
|
||||
object Aggressive : BleTuningProfile()
|
||||
}
|
||||
|
||||
/** Convert a preset into concrete numbers. */
|
||||
fun BleTuningProfile.asTuning(): BleTuning = when (this) {
|
||||
BleTuningProfile.Balanced -> BleTuning(
|
||||
notifySetupDelayMs = 120,
|
||||
@@ -141,6 +114,10 @@ fun BleTuningProfile.asTuning(): BleTuning = when (this) {
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Small persisted driver settings wrapper backed by SettingsFacade (shared by all adapters).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class FacadeDriverSettings(
|
||||
private val facade: SettingsFacade,
|
||||
private val scope: CoroutineScope,
|
||||
@@ -149,19 +126,14 @@ class FacadeDriverSettings(
|
||||
) : ScaleDeviceHandler.DriverSettings {
|
||||
|
||||
private val prefix = "ble/$handlerNamespace/$deviceAddress/"
|
||||
|
||||
private val mem = ConcurrentHashMap<String, String>()
|
||||
|
||||
override fun getInt(key: String, default: Int): Int {
|
||||
val k = prefix + key
|
||||
mem[k]?.toIntOrNull()?.let { return it }
|
||||
|
||||
val v: Int = runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
withTimeout(300) { facade.observeSetting(k, default).first() }
|
||||
}
|
||||
val v = runCatching {
|
||||
runBlocking(Dispatchers.IO) { withTimeout(300) { facade.observeSetting(k, default).first() } }
|
||||
}.getOrElse { default }
|
||||
|
||||
mem[k] = v.toString()
|
||||
return v
|
||||
}
|
||||
@@ -175,13 +147,9 @@ class FacadeDriverSettings(
|
||||
override fun getString(key: String, default: String?): String? {
|
||||
val k = prefix + key
|
||||
mem[k]?.let { return it }
|
||||
|
||||
val raw: String = runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
withTimeout(300) { facade.observeSetting(k, default ?: "").first() }
|
||||
}
|
||||
val raw = runCatching {
|
||||
runBlocking(Dispatchers.IO) { withTimeout(300) { facade.observeSetting(k, default ?: "").first() } }
|
||||
}.getOrElse { default ?: "" }
|
||||
|
||||
val result = if (raw.isEmpty() && default == null) null else raw
|
||||
result?.let { mem[k] = it }
|
||||
return result
|
||||
@@ -200,96 +168,57 @@ class FacadeDriverSettings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ModernScaleAdapter
|
||||
* ------------------
|
||||
* Bridges a device-specific [ScaleDeviceHandler] to the Blessed BLE stack. It owns scan/connect
|
||||
* lifecycle, handles service discovery and IO pacing, and surfaces events to the app via
|
||||
* [BluetoothEvent].
|
||||
*
|
||||
* This version additionally supports **broadcast-only** scales (no GATT, advertisement parsing).
|
||||
* For those, the adapter scans, forwards advertisements to the handler, and emits
|
||||
* `BluetoothEvent.Listening` / `BluetoothEvent.BroadcastComplete` around the flow.
|
||||
*/
|
||||
class ModernScaleAdapter(
|
||||
private val context: Context,
|
||||
private val settingsFacade: SettingsFacade,
|
||||
private val measurementFacade: MeasurementFacade,
|
||||
private val userFacade: UserFacade,
|
||||
private val handler: ScaleDeviceHandler,
|
||||
private val bleTuning: BleTuning? = null
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// ModernScaleAdapter (abstract base)
|
||||
// - Owns app integration, user/measurements snapshots, event streams, handler wiring.
|
||||
// - Concrete subclasses implement link-specific connect/disconnect logic.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
abstract class ModernScaleAdapter(
|
||||
protected val context: android.content.Context,
|
||||
protected val settingsFacade: SettingsFacade,
|
||||
protected val measurementFacade: MeasurementFacade,
|
||||
protected val userFacade: UserFacade,
|
||||
protected val handler: ScaleDeviceHandler
|
||||
) : ScaleCommunicator {
|
||||
|
||||
private val TAG = "ModernScaleAdapter"
|
||||
protected val TAG = this::class.simpleName ?: "ModernScaleAdapter"
|
||||
|
||||
// Active timing profile (defaults to Balanced)
|
||||
private val tuning: BleTuning = bleTuning ?: BleTuningProfile.Balanced.asTuning()
|
||||
private var broadcastAttached = false
|
||||
// ---- coroutine & lifecycle -----------------------------------------------------------------
|
||||
protected val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
protected val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Coroutine + Android handlers
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
// ---- session targeting ---------------------------------------------------------------------
|
||||
protected var targetAddress: String? = null
|
||||
protected var lastDisconnectAtMs: Long = 0L
|
||||
|
||||
// Blessed central manager (lazy)
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
|
||||
// Session state
|
||||
private var targetAddress: String? = null
|
||||
private var currentPeripheral: BluetoothPeripheral? = null
|
||||
|
||||
@Volatile private lateinit var selectedUserSnapshot: ScaleUser
|
||||
@Volatile private var usersSnapshot: List<ScaleUser> = emptyList()
|
||||
@Volatile private var lastSnapshot: Map<Int, ScaleMeasurement> = emptyMap()
|
||||
|
||||
// Session tracking for log correlation
|
||||
private var sessionCounter = 0
|
||||
private var sessionId = 0
|
||||
private fun newSession() {
|
||||
sessionId = ++sessionCounter
|
||||
LogManager.d(TAG, "session#$sessionId start for $targetAddress")
|
||||
}
|
||||
|
||||
// IO pacing (small gaps reduce GATT 133 and write failures)
|
||||
private val ioMutex = Mutex()
|
||||
private suspend fun ioGap(ms: Long) { if (ms > 0) delay(ms) }
|
||||
private val GAP_BEFORE_NOTIFY = tuning.notifySetupDelayMs
|
||||
private val GAP_BEFORE_WRITE_WITH_RESP = tuning.writeWithResponseDelayMs
|
||||
private val GAP_BEFORE_WRITE_NO_RESP = tuning.writeWithoutResponseDelayMs
|
||||
private val GAP_AFTER_WRITE = tuning.postWriteDelayMs
|
||||
|
||||
// Reconnect smoothing / retry
|
||||
private var lastDisconnectAtMs: Long = 0L
|
||||
private var connectAttempts: Int = 0
|
||||
private val RECONNECT_COOLDOWN_MS = tuning.reconnectCooldownMs
|
||||
private val RETRY_BACKOFF_MS = tuning.retryBackoffMs
|
||||
private val MAX_RETRY = tuning.maxRetries
|
||||
|
||||
private val connectAfterScanDelayMs = tuning.connectAfterScanDelayMs
|
||||
|
||||
// Public streams exposed to the app
|
||||
private val _events = MutableSharedFlow<BluetoothEvent>(replay = 1, extraBufferCapacity = 8)
|
||||
// ---- UI streams ----------------------------------------------------------------------------
|
||||
val _events = MutableSharedFlow<BluetoothEvent>(replay = 1, extraBufferCapacity = 8)
|
||||
override fun getEventsFlow(): SharedFlow<BluetoothEvent> = _events.asSharedFlow()
|
||||
|
||||
private val _isConnecting = MutableStateFlow(false)
|
||||
protected val _isConnecting = MutableStateFlow(false)
|
||||
override val isConnecting: StateFlow<Boolean> = _isConnecting.asStateFlow()
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
protected val _isConnected = MutableStateFlow(false)
|
||||
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
// ---- app snapshots for handler.DataProvider -------------------------------------------------
|
||||
@Volatile protected var selectedUserSnapshot: ScaleUser? = null
|
||||
@Volatile protected var usersSnapshot: List<ScaleUser> = emptyList()
|
||||
@Volatile protected var lastSnapshot: Map<Int, ScaleMeasurement> = emptyMap()
|
||||
|
||||
init {
|
||||
// Keep a *live* non-blocking snapshot of the current user.
|
||||
scope.launch {
|
||||
userFacade.observeSelectedUser().collect { u ->
|
||||
if (u != null) {
|
||||
selectedUserSnapshot = mapUser(u)
|
||||
}
|
||||
selectedUserSnapshot = u?.let(::mapUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep a *fresh enough* snapshot of users & their latest measurement.
|
||||
scope.launch {
|
||||
userFacade.observeAllUsers()
|
||||
.flatMapLatest { users ->
|
||||
usersSnapshot = users.map(::mapUser)
|
||||
|
||||
if (users.isEmpty()) {
|
||||
flowOf(emptyMap())
|
||||
} else {
|
||||
@@ -307,400 +236,31 @@ class ModernScaleAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Blessed callbacks: central / peripheral
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscoveredPeripheral(
|
||||
peripheral: BluetoothPeripheral,
|
||||
scanResult: android.bluetooth.le.ScanResult
|
||||
) {
|
||||
// Only react to the target address
|
||||
if (peripheral.address != targetAddress) return
|
||||
|
||||
// Decide link mode using handler.supportFor(...) + scan data; fallback to handler.linkMode
|
||||
val linkMode = resolveLinkModeFor(peripheral, scanResult)
|
||||
val isBroadcast = linkMode != LinkMode.CONNECT_GATT
|
||||
|
||||
if (isBroadcast) {
|
||||
// Broadcast path: attach handler lazily on first matching advertisement
|
||||
if (!broadcastAttached) {
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
deviceAddress = peripheral.address,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(noopTransport, appCallbacks, driverSettings, dataProvider)
|
||||
broadcastAttached = true
|
||||
// Now we know it's a broadcast flow → inform UI
|
||||
_events.tryEmit(BluetoothEvent.Listening(peripheral.address))
|
||||
}
|
||||
|
||||
// Forward the advertisement to the handler for parsing/aggregation
|
||||
val user = selectedUserSnapshot
|
||||
when (handler.onAdvertisement(scanResult, user)) {
|
||||
BroadcastAction.IGNORED -> Unit
|
||||
|
||||
BroadcastAction.CONSUMED_KEEP_SCANNING -> {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BroadcastAction.CONSUMED_STOP -> {
|
||||
_events.tryEmit(BluetoothEvent.BroadcastComplete(peripheral.address))
|
||||
stopScanInternal()
|
||||
cleanup(peripheral.address)
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GATT path: stop scan and connect to the peripheral
|
||||
LogManager.i(TAG, "session#$sessionId Found $targetAddress → stop scan + connect")
|
||||
central.stopScan()
|
||||
scope.launch {
|
||||
if (connectAfterScanDelayMs > 0) delay(connectAfterScanDelayMs)
|
||||
central.connectPeripheral(peripheral, peripheralCallback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectedPeripheral(peripheral: BluetoothPeripheral) {
|
||||
scope.launch {
|
||||
LogManager.i(TAG, "session#$sessionId Connected ${peripheral.name ?: "Unknown"} (${peripheral.address})")
|
||||
currentPeripheral = peripheral
|
||||
_isConnected.value = true
|
||||
_isConnecting.value = false
|
||||
_events.tryEmit(BluetoothEvent.Connected(peripheral.name ?: "Unknown", peripheral.address))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.e(TAG, "session#$sessionId Connection failed ${peripheral.address}: $status")
|
||||
if (connectAttempts < MAX_RETRY) {
|
||||
val nextTry = connectAttempts + 1
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_reconnecting_try, nextTry, MAX_RETRY),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
connectAttempts = nextTry
|
||||
LogManager.w(TAG, "session#$sessionId Retry #$nextTry in ${RETRY_BACKOFF_MS}ms…")
|
||||
delay(RETRY_BACKOFF_MS)
|
||||
|
||||
runCatching { central.stopScan() }
|
||||
central.scanForPeripheralsWithAddresses(arrayOf(peripheral.address))
|
||||
_isConnecting.value = true
|
||||
} else {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(peripheral.address, status.toString()))
|
||||
cleanup(peripheral.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnectedPeripheral(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.i(TAG, "session#$sessionId Disconnected ${peripheral.address}: $status")
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
if (peripheral.address == targetAddress) {
|
||||
_events.tryEmit(BluetoothEvent.Disconnected(peripheral.address, status.toString()))
|
||||
cleanup(peripheral.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val peripheralCallback = object : BluetoothPeripheralCallback() {
|
||||
override fun onServicesDiscovered(peripheral: BluetoothPeripheral) {
|
||||
LogManager.d(TAG, "session#$sessionId Services discovered for ${peripheral.address}")
|
||||
currentPeripheral = peripheral // ensure available to the handler
|
||||
|
||||
if (tuning.requestHighConnectionPriority) runCatching { peripheral.requestConnectionPriority(ConnectionPriority.HIGH) }
|
||||
if (tuning.requestMtuBytes > 23) {
|
||||
runCatching { peripheral.requestMtu(tuning.requestMtuBytes) }
|
||||
}
|
||||
LogManager.d(TAG, "session#$sessionId Link params: highPrio=${tuning.requestHighConnectionPriority}, mtu=${tuning.requestMtuBytes}")
|
||||
|
||||
val user = if (::selectedUserSnapshot.isInitialized) {
|
||||
selectedUserSnapshot
|
||||
} else {
|
||||
LogManager.e(TAG, "no user selected before services discovered")
|
||||
central.cancelConnection(peripheral)
|
||||
return
|
||||
}
|
||||
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
deviceAddress = peripheral.address,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
|
||||
// From here the handler drives protocol: enable NOTIFY, write commands, parse frames.
|
||||
handler.attach(transportImpl, appCallbacks, driverSettings, dataProvider)
|
||||
handler.handleConnected(user)
|
||||
}
|
||||
|
||||
override fun onCharacteristicUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: android.bluetooth.BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
if (status == GattStatus.SUCCESS) {
|
||||
handler.handleNotification(characteristic.uuid, value)
|
||||
} else {
|
||||
LogManager.w(TAG, "session#$sessionId Notify error ${characteristic.uuid}: $status")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: android.bluetooth.BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
// Only warn on failure; success is noisy if logged for every packet
|
||||
if (status != GattStatus.SUCCESS) {
|
||||
appCallbacks.onWarn(
|
||||
R.string.bt_warn_write_failed_status,
|
||||
characteristic.uuid.toString(),
|
||||
status.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationStateUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
characteristic: android.bluetooth.BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
if (status == GattStatus.SUCCESS) {
|
||||
LogManager.d(TAG, "session#$sessionId Notify ${characteristic.uuid} -> ${peripheral.isNotifying(characteristic)}")
|
||||
} else {
|
||||
appCallbacks.onWarn(
|
||||
R.string.bt_warn_notify_state_failed_status,
|
||||
characteristic.uuid.toString(),
|
||||
status.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(peripheral: BluetoothPeripheral, mtu: Int, status: GattStatus) {
|
||||
LogManager.d(TAG, "session#$sessionId MTU changed to $mtu (status=$status)")
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Transport for handlers
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
// Full transport used by GATT devices
|
||||
private val transportImpl = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
scope.launch {
|
||||
ioMutex.withLock {
|
||||
val p = currentPeripheral
|
||||
if (p == null) {
|
||||
appCallbacks.onWarn(R.string.bt_warn_no_peripheral_for_setnotify, characteristic.toString())
|
||||
return@withLock
|
||||
}
|
||||
val ch = p.getCharacteristic(service, characteristic)
|
||||
if (ch == null) {
|
||||
appCallbacks.onWarn(R.string.bt_warn_characteristic_not_found, characteristic.toString())
|
||||
return@withLock
|
||||
}
|
||||
LogManager.d(TAG, "session#$sessionId Enable NOTIFY for $characteristic (props=${propsPretty(ch.properties)})")
|
||||
ioGap(GAP_BEFORE_NOTIFY)
|
||||
if (!p.setNotify(ch, true)) {
|
||||
appCallbacks.onWarn(R.string.bt_warn_notify_failed, characteristic.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {
|
||||
scope.launch {
|
||||
ioMutex.withLock {
|
||||
val p = currentPeripheral
|
||||
if (p == null) {
|
||||
appCallbacks.onWarn(R.string.bt_warn_no_peripheral_for_write, characteristic.toString())
|
||||
return@withLock
|
||||
}
|
||||
val ch = p.getCharacteristic(service, characteristic)
|
||||
val type = if (withResponse) WITH_RESPONSE else WITHOUT_RESPONSE
|
||||
if (ch == null) {
|
||||
appCallbacks.onWarn(R.string.bt_warn_characteristic_not_found, characteristic.toString())
|
||||
return@withLock
|
||||
}
|
||||
LogManager.d(TAG, "session#$sessionId Write chr=$characteristic len=${payload.size} type=$type (props=${propsPretty(ch.properties)})")
|
||||
ioGap(if (withResponse) GAP_BEFORE_WRITE_WITH_RESP else GAP_BEFORE_WRITE_NO_RESP)
|
||||
p.writeCharacteristic(service, characteristic, payload, type)
|
||||
ioGap(GAP_AFTER_WRITE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(service: UUID, characteristic: UUID) {
|
||||
scope.launch {
|
||||
ioMutex.withLock {
|
||||
val p = currentPeripheral
|
||||
if (p == null) {
|
||||
appCallbacks.onWarn(R.string.bt_warn_no_peripheral_for_read, characteristic.toString())
|
||||
return@withLock
|
||||
}
|
||||
LogManager.d(TAG, "session#$sessionId Read chr=$characteristic")
|
||||
p.readCharacteristic(service, characteristic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
currentPeripheral?.let { central.cancelConnection(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private val dataProvider = object : ScaleDeviceHandler.DataProvider {
|
||||
override fun currentUser(): ScaleUser = selectedUserSnapshot
|
||||
override fun usersForDevice(): List<ScaleUser> = usersSnapshot
|
||||
override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = lastSnapshot[userId]
|
||||
}
|
||||
|
||||
// No-op transport used for broadcast-only devices (no GATT operations)
|
||||
private val noopTransport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) { /* no-op */ }
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) { /* no-op */ }
|
||||
override fun read(service: UUID, characteristic: UUID) { /* no-op */ }
|
||||
override fun disconnect() { stopScanInternal() }
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// App callbacks
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
private val appCallbacks = object : ScaleDeviceHandler.Callbacks {
|
||||
override fun onPublish(measurement: ScaleMeasurement) {
|
||||
val addr = currentPeripheral?.address ?: targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.MeasurementReceived(measurement, addr))
|
||||
}
|
||||
|
||||
override fun onInfo(@StringRes resId: Int, vararg args: Any) {
|
||||
val addr = currentPeripheral?.address ?: targetAddress ?: "unknown"
|
||||
val msg = context.getString(resId, *args)
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(msg, addr))
|
||||
}
|
||||
|
||||
override fun onWarn(@StringRes resId: Int, vararg args: Any) {
|
||||
val addr = currentPeripheral?.address ?: targetAddress ?: "unknown"
|
||||
val msg = context.getString(resId, *args)
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(msg, addr))
|
||||
}
|
||||
|
||||
override fun onError(@StringRes resId: Int, t: Throwable?, vararg args: Any) {
|
||||
val addr = currentPeripheral?.address ?: targetAddress ?: "unknown"
|
||||
val msg = context.getString(resId, *args)
|
||||
LogManager.e(TAG, msg, t)
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(msg, addr))
|
||||
}
|
||||
|
||||
override fun onUserInteractionRequired(
|
||||
interactionType: UserInteractionType,
|
||||
data: Any?
|
||||
) {
|
||||
val addr = currentPeripheral?.address ?: targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.UserInteractionRequired(addr, data, interactionType))
|
||||
}
|
||||
|
||||
override fun resolveString(@StringRes resId: Int, vararg args: Any): String =
|
||||
context.getString(resId, *args)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// ScaleCommunicator API
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// ---- ScaleCommunicator entry points ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start a BLE session to the given MAC and bind the session to [scaleUser].
|
||||
* The adapter starts scanning; for broadcast-only devices it will lazily attach the handler
|
||||
* on the first matching advertisement and emit a Listening event at that moment.
|
||||
* Template method: base validates input & selected user, then calls [doConnect].
|
||||
* Many handlers expect a selected user from app state; the `scaleUser` parameter is ignored.
|
||||
*/
|
||||
override fun connect(address: String, scaleUser: ScaleUser?) {
|
||||
scope.launch {
|
||||
if (!ensureSelectedUserSnapshot()) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
context.getString(R.string.bt_error_no_user_selected)
|
||||
)
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (targetAddress == address && (_isConnecting.value || _isConnected.value)) {
|
||||
LogManager.d(TAG, "session#$sessionId connect($address) ignored: already connecting/connected")
|
||||
return@launch
|
||||
}
|
||||
ensureCentral()
|
||||
|
||||
// Cooldown between reconnects to avoid Android stack churn
|
||||
val since = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
if (since in 1 until RECONNECT_COOLDOWN_MS) {
|
||||
val wait = RECONNECT_COOLDOWN_MS - since
|
||||
LogManager.d(TAG, "Cooldown ${wait}ms before reconnect to $address")
|
||||
delay(wait)
|
||||
}
|
||||
|
||||
// If currently busy with another device, disconnect first
|
||||
if ((_isConnected.value || _isConnecting.value) && targetAddress != address) {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
// Initialize session state
|
||||
targetAddress = address
|
||||
connectAttempts = 0
|
||||
_isConnecting.value = true
|
||||
_isConnected.value = false
|
||||
newSession()
|
||||
|
||||
// Always start by scanning; final link mode decision happens in onDiscoveredPeripheral(...)
|
||||
runCatching { central.stopScan() }
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(arrayOf(address))
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "session#$sessionId Failed to start scan/connect: ${e.message}", e)
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
e.message ?: context.getString(R.string.bt_error_generic)
|
||||
)
|
||||
)
|
||||
cleanup(address)
|
||||
}
|
||||
}
|
||||
final override fun connect(address: String, scaleUser: ScaleUser?) {
|
||||
targetAddress = address
|
||||
val user = ensureSelectedUserOrFail(address) ?: return
|
||||
_isConnecting.value = true
|
||||
doConnect(address, user)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Gracefully terminate the current BLE session (if any). */
|
||||
override fun disconnect() {
|
||||
scope.launch {
|
||||
currentPeripheral?.let { central.cancelConnection(it) }
|
||||
stopScanInternal()
|
||||
cleanup(targetAddress)
|
||||
}
|
||||
/**
|
||||
* Template method: calls [doDisconnect] and resets shared state.
|
||||
*/
|
||||
final override fun disconnect() {
|
||||
doDisconnect()
|
||||
cleanup(targetAddress)
|
||||
}
|
||||
|
||||
/** Some scales only ever push via NOTIFY; we surface a helpful user message. */
|
||||
/**
|
||||
* Default UX helper for devices that only push data via NOTIFY or broadcasts.
|
||||
* Subclasses can override if they can actively trigger measurement on device.
|
||||
*/
|
||||
override fun requestMeasurement() {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(
|
||||
@@ -711,9 +271,8 @@ class ModernScaleAdapter(
|
||||
)
|
||||
}
|
||||
|
||||
/** Reserved for UI round-trips (rare for scales); currently unused. */
|
||||
override fun processUserInteractionFeedback(
|
||||
interactionType: UserInteractionType,
|
||||
interactionType: BluetoothEvent.UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any,
|
||||
uiHandler: Handler
|
||||
@@ -722,7 +281,7 @@ class ModernScaleAdapter(
|
||||
runCatching {
|
||||
handler.onUserInteractionFeedback(interactionType, appUserId, feedbackData, uiHandler)
|
||||
}.onFailure { t ->
|
||||
val addr = currentPeripheral?.address ?: targetAddress ?: "unknown"
|
||||
val addr = targetAddress ?: "unknown"
|
||||
LogManager.e(TAG, "Delivering user feedback failed: ${t.message}", t)
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
@@ -734,71 +293,74 @@ class ModernScaleAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Internals
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// ---- abstract link hooks -------------------------------------------------------------------
|
||||
|
||||
private fun ensureCentral() {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
protected abstract fun doConnect(address: String, selectedUser: ScaleUser)
|
||||
protected abstract fun doDisconnect()
|
||||
|
||||
// ---- callbacks & data provider for handlers ------------------------------------------------
|
||||
|
||||
protected val appCallbacks = object : ScaleDeviceHandler.Callbacks {
|
||||
override fun onPublish(measurement: ScaleMeasurement) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.MeasurementReceived(measurement, addr))
|
||||
}
|
||||
|
||||
override fun onInfo(@StringRes resId: Int, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(context.getString(resId, *args), addr))
|
||||
}
|
||||
|
||||
override fun onWarn(@StringRes resId: Int, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(context.getString(resId, *args), addr))
|
||||
}
|
||||
|
||||
override fun onError(@StringRes resId: Int, t: Throwable?, vararg args: Any) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
val msg = context.getString(resId, *args)
|
||||
LogManager.e(TAG, msg, t)
|
||||
_events.tryEmit(BluetoothEvent.DeviceMessage(msg, addr))
|
||||
}
|
||||
|
||||
override fun onUserInteractionRequired(interactionType: BluetoothEvent.UserInteractionType, data: Any?) {
|
||||
val addr = targetAddress ?: "unknown"
|
||||
_events.tryEmit(BluetoothEvent.UserInteractionRequired(addr, data, interactionType))
|
||||
}
|
||||
|
||||
override fun resolveString(@StringRes resId: Int, vararg args: Any): String =
|
||||
context.getString(resId, *args)
|
||||
}
|
||||
|
||||
private fun stopScanInternal() {
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
protected val dataProvider = object : ScaleDeviceHandler.DataProvider {
|
||||
override fun currentUser(): ScaleUser = selectedUserSnapshot
|
||||
?: error("No selected user snapshot available")
|
||||
override fun usersForDevice(): List<ScaleUser> = usersSnapshot
|
||||
override fun lastMeasurementFor(userId: Int): ScaleMeasurement? = lastSnapshot[userId]
|
||||
}
|
||||
|
||||
private fun cleanup(addr: String?) {
|
||||
protected fun cleanup(addr: String?) {
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = false
|
||||
currentPeripheral = null
|
||||
broadcastAttached = false
|
||||
// Keep targetAddress/currentUser for optional retry
|
||||
// keep targetAddress to allow higher layer to retry if wanted
|
||||
}
|
||||
|
||||
/** Free resources; should be called when the adapter is no longer needed. */
|
||||
fun release() {
|
||||
disconnect()
|
||||
scope.cancel()
|
||||
runCatching { if (::central.isInitialized) central.close() }
|
||||
}
|
||||
|
||||
/** Turn a GATT property bitfield into a human-readable string for logs. */
|
||||
private fun propsPretty(props: Int): String {
|
||||
val names = mutableListOf<String>()
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_READ) != 0) names += "READ"
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) names += "WRITE"
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) names += "WRITE_NO_RESP"
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) names += "NOTIFY"
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) names += "INDICATE"
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE) != 0) names += "SIGNED_WRITE"
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_BROADCAST) != 0) names += "BROADCAST"
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS) != 0) names += "EXTENDED_PROPS"
|
||||
return if (names.isEmpty()) props.toString() else names.joinToString("|")
|
||||
}
|
||||
|
||||
|
||||
private suspend fun ensureSelectedUserSnapshot(): Boolean {
|
||||
if (::selectedUserSnapshot.isInitialized) return true
|
||||
|
||||
val u0 = userFacade.observeSelectedUser().first()
|
||||
if (u0 != null) {
|
||||
selectedUserSnapshot = mapUser(u0)
|
||||
return true
|
||||
}
|
||||
|
||||
runCatching { userFacade.restoreOrSelectDefaultUser() }.onFailure { return false }
|
||||
|
||||
val u1 = userFacade.observeSelectedUser().first()
|
||||
return if (u1 != null) {
|
||||
selectedUserSnapshot = mapUser(u1)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
protected fun ensureSelectedUserOrFail(address: String): ScaleUser? {
|
||||
val u = selectedUserSnapshot
|
||||
if (u == null) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
context.getString(R.string.bt_error_no_user_selected)
|
||||
)
|
||||
)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
private fun mapUser(u: User): ScaleUser =
|
||||
// ---- mapping helpers (core -> legacy DTOs used by handlers) --------------------------------
|
||||
|
||||
protected fun mapUser(u: User): ScaleUser =
|
||||
ScaleUser().apply {
|
||||
runCatching { setId(u.id) }
|
||||
runCatching { setUserName(u.name) }
|
||||
@@ -811,22 +373,15 @@ class ModernScaleAdapter(
|
||||
runCatching { setActivityLevel(u.activityLevel) }
|
||||
}
|
||||
|
||||
private fun mapMeasurement(
|
||||
mwv: MeasurementWithValues?
|
||||
): ScaleMeasurement? {
|
||||
protected fun mapMeasurement(mwv: MeasurementWithValues?): ScaleMeasurement? {
|
||||
if (mwv == null) return null
|
||||
|
||||
val m = ScaleMeasurement()
|
||||
|
||||
runCatching { m.setId(mwv.measurement.id) }
|
||||
runCatching { m.setUserId(mwv.measurement.userId) }
|
||||
runCatching { m.setDateTime(Date(mwv.measurement.timestamp)) }
|
||||
|
||||
// Helper to pull a float by key from the value list
|
||||
fun valueOf(key: MeasurementTypeKey): MeasurementValue? {
|
||||
val v = mwv.values.firstOrNull { it.type.key == key } ?: return null
|
||||
return v.value
|
||||
}
|
||||
fun valueOf(key: MeasurementTypeKey): MeasurementValue? =
|
||||
mwv.values.firstOrNull { it.type.key == key }?.value
|
||||
|
||||
valueOf(MeasurementTypeKey.WEIGHT)?.let { m.setWeight(it.floatValue ?: 0f) }
|
||||
valueOf(MeasurementTypeKey.BODY_FAT)?.let { m.setFat(it.floatValue ?: 0f) }
|
||||
@@ -838,30 +393,4 @@ class ModernScaleAdapter(
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decide link mode for a discovered peripheral. Uses handler.supportFor() if
|
||||
* the handler exposes it for this device, falling back to handler.linkMode.
|
||||
*/
|
||||
private fun resolveLinkModeFor(
|
||||
peripheral: BluetoothPeripheral,
|
||||
scanResult: android.bluetooth.le.ScanResult?
|
||||
): LinkMode {
|
||||
val record = scanResult?.scanRecord
|
||||
val info = ScannedDeviceInfo(
|
||||
name = peripheral.name,
|
||||
address = peripheral.address,
|
||||
rssi = scanResult?.rssi ?: 0,
|
||||
serviceUuids = record?.serviceUuids?.map { it.uuid } ?: emptyList(),
|
||||
manufacturerData = record?.manufacturerSpecificData,
|
||||
isSupported = false,
|
||||
determinedHandlerDisplayName = null
|
||||
)
|
||||
val support = runCatching { handler.supportFor(info) }.getOrNull()
|
||||
return support?.linkMode ?: handler.linkMode
|
||||
}
|
||||
}
|
||||
}
|
@@ -62,7 +62,7 @@ enum class DeviceCapability {
|
||||
* Defines whether a device communicates via a GATT connection
|
||||
* or only via broadcast advertisements.
|
||||
*/
|
||||
enum class LinkMode { CONNECT_GATT, BROADCAST_ONLY, AUTO }
|
||||
enum class LinkMode { CONNECT_GATT, BROADCAST_ONLY, CLASSIC_SPP }
|
||||
|
||||
/**
|
||||
* Signals how the handler consumed an advertisement.
|
||||
@@ -88,8 +88,14 @@ enum class BroadcastAction { IGNORED, CONSUMED_KEEP_SCANNING, CONSUMED_STOP }
|
||||
*/
|
||||
abstract class ScaleDeviceHandler {
|
||||
|
||||
companion object { const val TAG = "ScaleDeviceHandler" }
|
||||
|
||||
companion object {
|
||||
const val TAG = "ScaleDeviceHandler"
|
||||
// Pseudo UUIDs for Classic/SPP
|
||||
val CLASSIC_FAKE_SERVICE: UUID =
|
||||
UUID.fromString("00000000-0000-0000-0000-00000000C1A0")
|
||||
val CLASSIC_DATA_UUID: UUID =
|
||||
UUID.fromString("00000000-0000-0000-0000-00000000C1A5")
|
||||
}
|
||||
/**
|
||||
* Identify whether this handler supports the given scanned device.
|
||||
* Return a [DeviceSupport] description if yes, or `null` if not.
|
||||
|
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.Context
|
||||
import androidx.core.content.getSystemService
|
||||
import com.health.openscale.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.facade.MeasurementFacade
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.facade.UserFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* SPP (Bluetooth Classic / RFCOMM) adapter that plugs a [ScaleDeviceHandler] into a raw byte stream.
|
||||
*
|
||||
* Differences vs. GATT:
|
||||
* - There are no services/characteristics; we expose a virtual characteristic UUID
|
||||
* ([ScaleDeviceHandler.CLASSIC_DATA_UUID]) to the handler for symmetry.
|
||||
* - The stream is "always notifying": reads are forwarded as notifications.
|
||||
* - Writes are plain byte writes to the RFCOMM socket.
|
||||
*
|
||||
* Permissions:
|
||||
* - UI layer must request BLUETOOTH_CONNECT/SCAN (Android 12+) beforehand.
|
||||
* - We still guard sensitive calls with try/catch (SecurityException) to be robust.
|
||||
*/
|
||||
class SppScaleAdapter(
|
||||
context: Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private var sppSocket: BluetoothSocket? = null
|
||||
private var sppReaderJob: Job? = null
|
||||
private var sppIn: InputStream? = null
|
||||
private var sppOut: OutputStream? = null
|
||||
|
||||
/**
|
||||
* Establish an RFCOMM connection and wire the byte stream to the handler.
|
||||
* Uses AndroidX Core-KTX system service helper.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // Permissions are acquired in UI; here we additionally guard with try/catch
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
// Modern KTX way to obtain the BluetoothManager (no deprecated getDefaultAdapter())
|
||||
val btManager: BluetoothManager? = context.getSystemService()
|
||||
val adapter: BluetoothAdapter? = btManager?.adapter
|
||||
|
||||
if (adapter == null) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
context.getString(R.string.bt_error_no_bluetooth_adapter)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val device: BluetoothDevice = try {
|
||||
adapter.getRemoteDevice(address)
|
||||
} catch (t: Throwable) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
context.getString(R.string.bt_error_no_device_found)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
_isConnecting.value = true
|
||||
_isConnected.value = false
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Discovery interferes with connects; cancel if running (may throw if permission revoked)
|
||||
safeCancelDiscovery(adapter)
|
||||
|
||||
// Create & connect SPP socket (UUID provided by handler contract)
|
||||
val socket = device.createRfcommSocketToServiceRecord(ScaleDeviceHandler.CLASSIC_DATA_UUID)
|
||||
sppSocket = socket
|
||||
socket.connect()
|
||||
|
||||
sppIn = socket.inputStream
|
||||
sppOut = socket.outputStream
|
||||
|
||||
_isConnecting.value = false
|
||||
_isConnected.value = true
|
||||
|
||||
val name = safeDeviceName(device) // avoids SecurityException on Android 12+
|
||||
val addr = safeDeviceAddress(device)
|
||||
|
||||
_events.tryEmit(BluetoothEvent.Connected(name, addr))
|
||||
|
||||
// Hand off to the device handler
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
deviceAddress = addr,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(sppTransport, appCallbacks, driverSettings, dataProvider)
|
||||
handler.handleConnected(selectedUser)
|
||||
|
||||
// Reader loop: forward bytes as "notifications" on CLASSIC_DATA_UUID
|
||||
sppReaderJob = launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(1024)
|
||||
try {
|
||||
while (isActive) {
|
||||
val n = sppIn?.read(buf) ?: -1
|
||||
if (n <= 0) break
|
||||
handler.handleNotification(ScaleDeviceHandler.CLASSIC_DATA_UUID, buf.copyOf(n))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
LogManager.w(TAG, "SPP read error: ${t.message}", null)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
_events.tryEmit(BluetoothEvent.Disconnected(addr, "SPP stream closed"))
|
||||
cleanup(addr)
|
||||
doDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
LogManager.e(TAG, "SPP connect failed: ${t.message}", t)
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, t.message ?: "SPP connect failed"))
|
||||
cleanup(address)
|
||||
doDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
sppReaderJob?.cancel(); sppReaderJob = null
|
||||
runCatching { sppIn?.close() }; sppIn = null
|
||||
runCatching { sppOut?.close() }; sppOut = null
|
||||
runCatching { sppSocket?.close() }; sppSocket = null
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = false
|
||||
}
|
||||
|
||||
// --- Transport exposed to the handler -------------------------------------
|
||||
|
||||
private val sppTransport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
// Not applicable for SPP: stream is always "notifying"
|
||||
}
|
||||
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
sppOut?.write(payload)
|
||||
sppOut?.flush()
|
||||
} catch (t: Throwable) {
|
||||
appCallbacks.onWarn(
|
||||
R.string.bt_warn_write_failed_status,
|
||||
"SPP",
|
||||
t.message ?: "write failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(service: UUID, characteristic: UUID) {
|
||||
// Not applicable for SPP; reads are handled by the continuous reader loop
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
doDisconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Small helpers with defensive permission handling ----------------------
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeCancelDiscovery(adapter: BluetoothAdapter) {
|
||||
try {
|
||||
if (adapter.isDiscovering) adapter.cancelDiscovery()
|
||||
} catch (se: SecurityException) {
|
||||
LogManager.w(TAG, "cancelDiscovery blocked by missing permission", se)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeDeviceName(device: BluetoothDevice): String =
|
||||
try { device.name } catch (_: SecurityException) { null } ?: "Unknown"
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeDeviceAddress(device: BluetoothDevice): String =
|
||||
try { device.address } catch (_: SecurityException) { "unknown" }
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SppScaleAdapter"
|
||||
}
|
||||
}
|
@@ -606,4 +606,6 @@
|
||||
|
||||
<string name="bt_error_handler_connect_failed">Handler-Fehler beim Verbinden: %1$s</string>
|
||||
<string name="bt_error_handler_parse_error">Handler-Parserfehler für %1$s: %2$s</string>
|
||||
<string name="bt_error_no_bluetooth_adapter">Kein Bluetooth-Adapter auf diesem Gerät vorhanden.</string>
|
||||
<string name="bt_error_no_device_found">Gerät nicht gefunden. Bitte sicherstellen, dass es eingeschaltet ist und sich in Reichweite befindet.</string>
|
||||
</resources>
|
||||
|
@@ -607,4 +607,6 @@
|
||||
|
||||
<string name="bt_error_handler_connect_failed">Handler failed on connect: %1$s</string>
|
||||
<string name="bt_error_handler_parse_error">Handler parse error for %1$s: %2$s</string>
|
||||
<string name="bt_error_no_bluetooth_adapter">No Bluetooth adapter detected on this device.</string>
|
||||
<string name="bt_error_no_device_found">Device not found. Make sure it is powered on and in range.</string>
|
||||
</resources>
|
||||
|
Reference in New Issue
Block a user