1
0
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:
oliexdev
2025-09-02 10:57:45 +02:00
parent 15566aa1e3
commit b40a3bbf5d
9 changed files with 946 additions and 612 deletions

View File

@@ -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
)
}
/**

View File

@@ -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() }
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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>