openScale-port: GPL-3.0-relicens + BT-vågsdrivrutiner (grund + Exingtech Y1/VScale)
- LICENSE → GPL-3.0 (krav för att bädda in openScales kod) - Vendrat com.health.openscale.core.bluetooth: ScaleDeviceHandler, Gatt/Broadcast/Spp-adaptrar, ScaleFactory (utan Hilt), ScaleCommunicator, BleScanner, ConverterUtils, ScaleMeasurement/ScaleUser — copyright-headers kvar - ExingtechY1Handler: Biltema 84-1002 (PT-727), annonserar som "VScale" - Shims för openScales interna beroenden: facades (DataStore-backade), slimmade enums/User/MeasurementWithValues, LogManager → logcat - blessed-kotlin 3.0.12 via JitPack; Kotlin 2.0.21→2.2.0, KSP2, Room 2.7.2 - BLE-permissions (BLUETOOTH_SCAN neverForLocation + CONNECT) i manifestet - Svenska strängresurser för drivrutinernas meddelanden Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C2GA94E1f3cmrvdQLQhYdg
This commit is contained in:
@@ -5,6 +5,12 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- BT-våg (openScale-drivrutiner). minSdk 31 → bara de nya BLE-permissionerna behövs. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".FitnessDroidApplication"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Domain events emitted by a [ScaleCommunicator].
|
||||
*
|
||||
* Notes for broadcast-only devices (advertisement parsing, no GATT):
|
||||
* - The adapter emits [Listening] when scanning starts for the target MAC.
|
||||
* - When a final (stabilized) measurement was published and scanning stops, it emits [BroadcastComplete].
|
||||
* - For such devices, [Connected] is typically never emitted.
|
||||
*/
|
||||
sealed class BluetoothEvent {
|
||||
enum class UserInteractionType {
|
||||
CHOOSE_USER,
|
||||
ENTER_CONSENT
|
||||
}
|
||||
|
||||
/** Emitted when scanning starts for a broadcast-only device. */
|
||||
data class Listening(val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted after a broadcast-only flow has completed (e.g., stabilized measurement parsed). */
|
||||
data class BroadcastComplete(val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a GATT connection has been established. */
|
||||
data class Connected(val deviceName: String, val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when an existing GATT connection has been disconnected. */
|
||||
data class Disconnected(val deviceAddress: String, val reason: String? = null) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a connection attempt to a device failed. */
|
||||
data class ConnectionFailed(val deviceAddress: String, val error: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when a parsed measurement is available. */
|
||||
data class MeasurementReceived(
|
||||
val measurement: ScaleMeasurement,
|
||||
val deviceAddress: String
|
||||
) : BluetoothEvent()
|
||||
|
||||
/** Emitted for generic device-related errors. */
|
||||
data class Error(val deviceAddress: String, val error: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted for miscellaneous device/user-visible messages. */
|
||||
data class DeviceMessage(val message: String, val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/** Emitted when user interaction is required (e.g., pick user, enter consent code). */
|
||||
data class UserInteractionRequired(
|
||||
val deviceIdentifier: String,
|
||||
val data: Any?,
|
||||
val interactionType: UserInteractionType,
|
||||
) : BluetoothEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic interface for communicating with Bluetooth scales.
|
||||
* Implementations may be GATT-based or broadcast-only (advertisement parsing).
|
||||
*/
|
||||
interface ScaleCommunicator {
|
||||
|
||||
/** Indicates whether a connection attempt (or scan for broadcast devices) is in progress. */
|
||||
val isConnecting: StateFlow<Boolean>
|
||||
|
||||
/** Indicates whether a GATT connection is active. For broadcast-only devices this is always `false`. */
|
||||
val isConnected: StateFlow<Boolean>
|
||||
|
||||
/** Start communicating with a device identified by [address]. Binds the session to [scaleUser]. */
|
||||
fun connect(address: String, scaleUser: ScaleUser?)
|
||||
|
||||
/** Terminate the current session (disconnect or stop scanning). */
|
||||
fun disconnect()
|
||||
|
||||
/** Request a measurement (if supported; some devices only push asynchronously). */
|
||||
fun requestMeasurement()
|
||||
|
||||
/**
|
||||
* Renders the device-specific configuration UI.
|
||||
* This allows the device handler to inject custom settings fields
|
||||
* (like bind keys or user slots) into the settings screen.
|
||||
*/
|
||||
@Composable
|
||||
fun DeviceConfigurationUi()
|
||||
|
||||
/** Stream of [BluetoothEvent] emitted by the communicator. */
|
||||
fun getEventsFlow(): SharedFlow<BluetoothEvent>
|
||||
|
||||
/** Deliver feedback for a previously requested user interaction. */
|
||||
suspend fun processUserInteractionFeedback(
|
||||
interactionType: BluetoothEvent.UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*
|
||||
* FitnessDroid-anpassning: Hilt-DI borttagen (manuell DI), och handler-listan
|
||||
* innehåller än så länge bara de drivrutiner som portats. Fler portas från
|
||||
* openScale allteftersom — behåll upstream-ordningen när listan växer
|
||||
* (första matchande handler vinner).
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth
|
||||
|
||||
import android.content.Context
|
||||
import com.health.openscale.core.bluetooth.scales.DebugGattHandler
|
||||
import com.health.openscale.core.bluetooth.scales.DeviceSupport
|
||||
import com.health.openscale.core.bluetooth.scales.ExingtechY1Handler
|
||||
import com.health.openscale.core.bluetooth.scales.GattScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.BroadcastScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.LinkMode
|
||||
import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler
|
||||
import com.health.openscale.core.bluetooth.scales.SppScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.scales.TuningProfile
|
||||
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.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Factory class responsible for creating appropriate [ScaleCommunicator] instances
|
||||
* for different Bluetooth scale devices.
|
||||
*/
|
||||
class ScaleFactory(
|
||||
private val applicationContext: Context,
|
||||
private val settingsFacade: SettingsFacade,
|
||||
private val measurementFacade: MeasurementFacade,
|
||||
private val userFacade: UserFacade,
|
||||
) {
|
||||
private val TAG = "ScaleHandlerFactory"
|
||||
|
||||
private val modernKotlinHandlers: List<ScaleDeviceHandler> = createHandlers()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Builds the list of modern Kotlin-based device handlers.
|
||||
*
|
||||
* Order matters: [createCommunicator] returns the FIRST handler whose
|
||||
* [ScaleDeviceHandler.supportFor] is non-null.
|
||||
*/
|
||||
internal fun createHandlers(): List<ScaleDeviceHandler> = listOf(
|
||||
// Portade från openScale hittills:
|
||||
ExingtechY1Handler(), // Biltema 84-1002 (PT-727) annonserar som "VScale"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current value of a settings [Flow] from a non-suspending context.
|
||||
*/
|
||||
private fun <T> readSettingBlocking(flow: Flow<T>): T? = runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
withTimeout(250.milliseconds) { flow.firstOrNull() }
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun createModernCommunicator(
|
||||
handler: ScaleDeviceHandler,
|
||||
support: DeviceSupport
|
||||
): ScaleCommunicator? {
|
||||
val effectiveTuning: TuningProfile = run {
|
||||
val saved: String? = readSettingBlocking(settingsFacade.savedBluetoothTuneProfile)
|
||||
|
||||
saved?.let { runCatching { TuningProfile.valueOf(it) }.getOrNull() }
|
||||
?: support.tuningProfile
|
||||
}
|
||||
|
||||
return when (support.linkMode) {
|
||||
LinkMode.CONNECT_GATT ->
|
||||
GattScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
|
||||
LinkMode.BROADCAST_ONLY ->
|
||||
BroadcastScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
|
||||
LinkMode.CLASSIC_SPP ->
|
||||
SppScaleAdapter(
|
||||
applicationContext,
|
||||
settingsFacade,
|
||||
measurementFacade,
|
||||
userFacade,
|
||||
handler,
|
||||
effectiveTuning
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the most suitable [ScaleCommunicator] for the given scanned device.
|
||||
*/
|
||||
fun createCommunicator(deviceInfo: ScannedDeviceInfo): ScaleCommunicator? {
|
||||
val primaryIdentifier = deviceInfo.name
|
||||
LogManager.d(TAG, "createCommunicator: Searching for communicator for '${primaryIdentifier}' (${deviceInfo.address}). Handler hint: '${deviceInfo.determinedHandlerDisplayName}'")
|
||||
|
||||
if (readSettingBlocking(settingsFacade.developerModeEnabled) == true) {
|
||||
LogManager.i(TAG, "Developer mode active → routing '$primaryIdentifier' to DebugGattHandler. No measurement will be stored.")
|
||||
return createModernCommunicator(DebugGattHandler(), DebugGattHandler.SUPPORT)
|
||||
}
|
||||
|
||||
for (handler in modernKotlinHandlers) {
|
||||
val support = handler.supportFor(deviceInfo)
|
||||
if (support != null) {
|
||||
LogManager.i(TAG, "Modern handler '${support.displayName}' supports '$primaryIdentifier'.")
|
||||
val modern = createModernCommunicator(handler, support)
|
||||
if (modern != null) {
|
||||
LogManager.i(TAG, "Modern communicator '${modern.javaClass.simpleName}' created for '$primaryIdentifier' with linkMode=${support.linkMode}.")
|
||||
return modern
|
||||
}
|
||||
LogManager.w(TAG, "Modern handler '${support.displayName}' supports '$primaryIdentifier', but no communicator is available.")
|
||||
}
|
||||
}
|
||||
|
||||
LogManager.w(TAG, "No suitable communicator found for device (name: '${deviceInfo.name}', address: '${deviceInfo.address}', handler hint: '${deviceInfo.determinedHandlerDisplayName}').")
|
||||
return null
|
||||
}
|
||||
|
||||
fun getDeviceSupportFor(name: String, address: String): DeviceSupport? {
|
||||
val info = ScannedDeviceInfo(name, address, 0, emptyList(), null)
|
||||
return modernKotlinHandlers.firstNotNullOfOrNull { it.supportFor(info) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any known handler can theoretically support the given device.
|
||||
*/
|
||||
fun getSupportingHandlerInfo(deviceInfo: ScannedDeviceInfo): Pair<Boolean, String?> {
|
||||
for (handler in modernKotlinHandlers) {
|
||||
val support = handler.supportFor(deviceInfo)
|
||||
if (support != null) return true to support.displayName
|
||||
}
|
||||
|
||||
return false to null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.data
|
||||
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Represents a single measurement record from a scale, potentially combined from multiple BLE packets.
|
||||
*/
|
||||
data class ScaleMeasurement(
|
||||
var userId: Int = 0xFF, // openScale's internal app user ID
|
||||
var dateTime: Date? = null,
|
||||
var weight: Float = 0.0f, // must be in kg
|
||||
var fat: Float = 0.0f, // must be in percentage
|
||||
var water: Float = 0.0f, // must be in percentage
|
||||
var muscle: Float = 0.0f, // must be in percentage
|
||||
var visceralFat: Float = 0.0f, // must be in percentage
|
||||
var bone: Float = 0.0f, // must be in kg
|
||||
var lbm : Float = 0.0f, // must be in kg
|
||||
var bmr: Float = 0.0f, // Basal Metabolic Rate in kcal
|
||||
var heartRate: Int = 0, // must be bpm
|
||||
var impedance: Double = 0.0, // Ohms — high-frequency band when the scale is dual-band
|
||||
var impedanceLow: Double = 0.0, // Ohms — low-frequency band; 0 when not reported
|
||||
var ecw: Float = 0.0f, // Extracellular water, % of body weight
|
||||
var icw: Float = 0.0f, // Intracellular water, % of body weight
|
||||
var protein: Float = 0.0f, // Protein, % of body weight
|
||||
var bcm: Float = 0.0f, // Body cell mass, kg
|
||||
) {
|
||||
|
||||
// --- Utility methods ---
|
||||
|
||||
fun hasWeight(): Boolean = this.weight > 0f
|
||||
|
||||
fun mergeWith(other: ScaleMeasurement) = apply {
|
||||
if (other.weight > 0f && this.weight <= 0f) this.weight = other.weight
|
||||
if (other.fat > 0f && this.fat <= 0f) this.fat = other.fat
|
||||
if (other.water > 0f && this.water <= 0f) this.water = other.water
|
||||
if (other.muscle > 0f && this.muscle <= 0f) this.muscle = other.muscle
|
||||
if (other.visceralFat > 0f && this.visceralFat <= 0f) this.visceralFat = other.visceralFat
|
||||
if (other.bone > 0f && this.bone <= 0f) this.bone = other.bone
|
||||
if (other.lbm > 0f && this.lbm <= 0f) this.lbm = other.lbm
|
||||
if (other.bmr > 0f && this.bmr <= 0f) this.bmr = other.bmr
|
||||
if (other.heartRate > 0f && this.heartRate <= 0f) this.heartRate = other.heartRate
|
||||
if (other.impedance > 0.0 && this.impedance <= 0.0) this.impedance = other.impedance
|
||||
if (other.impedanceLow > 0.0 && this.impedanceLow <= 0.0) this.impedanceLow = other.impedanceLow
|
||||
if (other.ecw > 0f && this.ecw <= 0f) this.ecw = other.ecw
|
||||
if (other.icw > 0f && this.icw <= 0f) this.icw = other.icw
|
||||
if (other.protein > 0f && this.protein <= 0f) this.protein = other.protein
|
||||
if (other.bcm > 0f && this.bcm <= 0f) this.bcm = other.bcm
|
||||
|
||||
if (other.userId != 0xFF &&
|
||||
(this.userId == 0xFF || this.userId == -1)) { // -1 was common init value
|
||||
this.userId = other.userId
|
||||
}
|
||||
|
||||
if (this.dateTime == null && other.dateTime != null) this.dateTime = other.dateTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.data
|
||||
|
||||
import com.health.openscale.core.data.ActivityLevel
|
||||
import com.health.openscale.core.data.GenderType
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
|
||||
data class ScaleUser (
|
||||
var id: Int = 0,
|
||||
var userName: String = "",
|
||||
var birthday: Date = Date(),
|
||||
var bodyHeight: Float = -1f, // always in cm
|
||||
var gender: GenderType = GenderType.MALE,
|
||||
var initialWeight: Float = 0f, // always in kg
|
||||
var goalWeight: Float = 0f, // always in kg
|
||||
var scaleUnit: WeightUnit = WeightUnit.KG,
|
||||
var activityLevel: ActivityLevel = ActivityLevel.SEDENTARY
|
||||
){
|
||||
fun getAge(todayDate: Date?): Int {
|
||||
val calToday = Calendar.getInstance()
|
||||
if (todayDate != null) {
|
||||
calToday.setTime(todayDate)
|
||||
}
|
||||
|
||||
val calBirthday = Calendar.getInstance()
|
||||
calBirthday.setTime(birthday)
|
||||
|
||||
return yearsBetween(calBirthday, calToday)
|
||||
}
|
||||
|
||||
val age: Int
|
||||
get() = getAge(null)
|
||||
|
||||
private fun yearsBetween(start: Calendar, end: Calendar): Int {
|
||||
var years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR)
|
||||
|
||||
val startMonth = start.get(Calendar.MONTH)
|
||||
val endMonth = end.get(Calendar.MONTH)
|
||||
if (endMonth < startMonth
|
||||
|| (endMonth == startMonth
|
||||
&& end.get(Calendar.DAY_OF_MONTH) < start.get(Calendar.DAY_OF_MONTH))
|
||||
) {
|
||||
years -= 1
|
||||
}
|
||||
return years
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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.scales
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import eu.brassepc.fitnessdroid.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 kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Broadcast adapter (no GATT)
|
||||
// - uses Blessed to scan for a specific address and forwards advertisements to handler.onAdvertisement()
|
||||
// - applies tuning: max scan window, retry/backoff, RSSI filter, packet de-dup, stabilization window
|
||||
// - attaches handler with a no-op transport immediately on start
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class BroadcastScaleAdapter(
|
||||
context: android.content.Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BleBroadcastTuning = profile.forBroadcast()
|
||||
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
private var broadcastAttached = false
|
||||
private var scanTimeoutJob: Job? = null
|
||||
private var attempt = 0
|
||||
private var isScanning = false
|
||||
|
||||
// de-duplication: contentHash -> lastSeenMs
|
||||
private val dedupSeen = LinkedHashMap<Int, Long>(64, 0.75f, true)
|
||||
private var lastForwardAtMs = 0L
|
||||
|
||||
private fun now() = SystemClock.elapsedRealtime()
|
||||
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscovered(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
// Filter: only target MAC
|
||||
if (peripheral.address != targetAddress) return
|
||||
|
||||
// Filter: optional RSSI threshold
|
||||
val rssi = scanResult.rssi
|
||||
tuning.minRssiDbm?.let { if (rssi < it) return }
|
||||
|
||||
// De-dup: collapse identical packets within packetDedupWindowMs
|
||||
val bytes = scanResult.scanRecord?.bytes
|
||||
|
||||
LogManager.d(TAG,"Discovered advertisement from ${peripheral.address} RSSI=$rssi ${bytes?.toHexPreview(24)}")
|
||||
|
||||
val hash = contentHash(bytes, rssi)
|
||||
val t = now()
|
||||
val last = dedupSeen[hash]
|
||||
if (last != null && (t - last) <= tuning.packetDedupWindowMs) {
|
||||
LogManager.w(TAG, "Deduplicated packet hash=$hash from ${peripheral.address}")
|
||||
return
|
||||
}
|
||||
dedupSeen[hash] = t
|
||||
trimDedup(t)
|
||||
|
||||
// Attach handler as soon as we see the target device (if not already)
|
||||
ensureAttached(peripheral.address)
|
||||
|
||||
// Optional stabilization: avoid forwarding bursts too quickly
|
||||
if (t - lastForwardAtMs < tuning.stabilizeWindowMs) {
|
||||
LogManager.w(TAG, "Skipping forwarding to handler (stabilize window) from ${peripheral.address}")
|
||||
return
|
||||
}
|
||||
|
||||
val user = selectedUserSnapshot ?: return
|
||||
LogManager.d(TAG,"Forwarding advertisement to handler: ${peripheral.address} RSSI=$rssi ${bytes?.toHexPreview(24)}")
|
||||
val action = handler.onAdvertisement(scanResult, user)
|
||||
LogManager.d(TAG, "Handler returned $action for ${peripheral.address}")
|
||||
|
||||
when (action) {
|
||||
BroadcastAction.IGNORED -> LogManager.d(TAG, "Advertisement IGNORED for ${peripheral.address}")
|
||||
BroadcastAction.CONSUMED_KEEP_SCANNING -> {
|
||||
LogManager.d(TAG, "Advertisement CONSUMED for ${peripheral.address}")
|
||||
lastForwardAtMs = t
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
}
|
||||
BroadcastAction.CONSUMED_STOP -> {
|
||||
lastForwardAtMs = t
|
||||
LogManager.d(TAG, "Measurement stabilized → BroadcastComplete for ${peripheral.address}")
|
||||
_events.tryEmit(BluetoothEvent.BroadcastComplete(peripheral.address))
|
||||
stopScanInternal()
|
||||
cleanup()
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
private fun ensureCentral() {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureAttached(address: String) {
|
||||
if (broadcastAttached) return
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(noopTransport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
broadcastAttached = true
|
||||
_events.tryEmit(BluetoothEvent.Listening(address))
|
||||
}
|
||||
|
||||
private fun startScanAttempt(address: String) {
|
||||
// reset per-attempt state
|
||||
isScanning = true
|
||||
lastForwardAtMs = 0
|
||||
dedupSeen.clear()
|
||||
|
||||
// Blessed does not expose ScanSettings directly; we emulate tuning at our level
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(setOf(address))
|
||||
LogManager.d(TAG, "Broadcast scan started (attempt ${attempt + 1}/${tuning.common.maxRetries}) for $address")
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Failed to start broadcast scan: ${e.message}", e)
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, e.message ?: context.getString(R.string.bt_error_generic)))
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// Arm scan timeout for this attempt
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = scope.launch {
|
||||
delay(tuning.maxScanMs.milliseconds)
|
||||
if (!isScanning) return@launch
|
||||
LogManager.w(TAG, "Broadcast scan timed out for $address")
|
||||
stopScanInternal()
|
||||
|
||||
attempt++
|
||||
if (attempt <= tuning.common.maxRetries) {
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
startScanAttempt(address)
|
||||
} else {
|
||||
cleanup()
|
||||
// keep attached? we detach to be consistent with failure
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScanInternal() {
|
||||
scanTimeoutJob?.cancel(); scanTimeoutJob = null
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
isScanning = false
|
||||
lastDisconnectAtMs = now()
|
||||
}
|
||||
|
||||
private fun trimDedup(t: Long) {
|
||||
// simple time-based eviction
|
||||
val it = dedupSeen.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
val e = it.next()
|
||||
if (t - e.value > tuning.packetDedupWindowMs) it.remove()
|
||||
else break // map is access-ordered, earliest first
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentHash(bytes: ByteArray?, rssi: Int): Int {
|
||||
if (bytes == null || bytes.isEmpty()) return rssi // fallback
|
||||
// A lightweight rolling hash (faster than Arrays.hashCode in hot path)
|
||||
var h = 1125899907
|
||||
for (b in bytes) h = (h * 131) xor (b.toInt() and 0xFF)
|
||||
// mix in coarse rssi bucket to avoid treating level-only jitter as new data
|
||||
val bucket = (rssi / 3) // bucketize
|
||||
return (h shl 1) xor bucket
|
||||
}
|
||||
|
||||
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 getPeripheral(): BluetoothPeripheral? = null
|
||||
override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean = false
|
||||
}
|
||||
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
ensureCentral()
|
||||
|
||||
// Attach early so UI can show “Listening…”
|
||||
ensureAttached(address)
|
||||
|
||||
_isConnecting.value = false
|
||||
_isConnected.value = false
|
||||
|
||||
attempt = 0
|
||||
startScanAttempt(address)
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
stopScanInternal()
|
||||
runCatching { handler.handleDisconnected() }
|
||||
runCatching { handler.detach() }
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.scales
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* ## DebugGattHandler
|
||||
*
|
||||
* A pure **inspection** handler that:
|
||||
*
|
||||
* - Only activates while developer mode is enabled in the Bluetooth settings; it is not part of
|
||||
* the handler registry and never claims a device on its own.
|
||||
* - On connect, **dumps the full GATT table** (all services and characteristics) by
|
||||
* asking the adapter/transport for the current `BluetoothPeripheral`.
|
||||
* - Optionally performs a few safe reads/subscriptions on common services to trigger
|
||||
* some traffic (helpful to verify notifications).
|
||||
* - Logs **every incoming notification** with a compact hex & ASCII preview.
|
||||
*
|
||||
* This handler **never publishes measurements** and is intended solely for diagnostics.
|
||||
*
|
||||
* ### Adapter requirement
|
||||
* The adapter/transport must expose `debugGetPeripheral(): BluetoothPeripheral?`.
|
||||
* In `GattScaleAdapter`, implement it by returning the current `BluetoothPeripheral`.
|
||||
*
|
||||
* ### Why this lives here
|
||||
* We keep all formatting, pretty-printing, and logging **inside this handler**, while
|
||||
* the adapter stays minimal and unopinionated.
|
||||
*/
|
||||
class DebugGattHandler : ScaleDeviceHandler() {
|
||||
companion object {
|
||||
/**
|
||||
* The support descriptor this handler runs with. Exposed because the handler is no longer
|
||||
* part of the registry: [com.health.openscale.core.bluetooth.ScaleFactory] instantiates it
|
||||
* directly when developer mode is on and needs the descriptor to pick the adapter.
|
||||
*/
|
||||
val SUPPORT = DeviceSupport(
|
||||
displayName = "Debug",
|
||||
capabilities = emptySet(), // no functional features
|
||||
implemented = emptySet(),
|
||||
tuningProfile = TuningProfile.Balanced,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Never claims a device by itself. Developer mode is a separate setting
|
||||
* ([com.health.openscale.core.facade.SettingsFacade.developerModeEnabled]); routing it through
|
||||
* the device name used to overwrite the saved scale's identity, which silently broke the
|
||||
* pairing (issue #1478).
|
||||
*/
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? = null
|
||||
|
||||
/**
|
||||
* On connect:
|
||||
* 1) Dump the **entire** GATT service/characteristic tree.
|
||||
* 2) Optionally poke a few common characteristics (best-effort).
|
||||
* 3) Arm light-weight subscriptions to typical measurement characteristics,
|
||||
* if present (best-effort; errors are logged).
|
||||
*/
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
logD("Connected in Debug mode. Dumping full GATT services/characteristics…")
|
||||
dumpAllGatt()
|
||||
|
||||
// --- Optional sanity probes (best-effort; they can fail silently) -----
|
||||
// Generic Access: Device Name
|
||||
readSafe(uuid16(0x1800), uuid16(0x2A00))
|
||||
// Device Information: Manufacturer / Model / FW / SW
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A29))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A24))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A26))
|
||||
readSafe(uuid16(0x180A), uuid16(0x2A28))
|
||||
// Battery Level
|
||||
readSafe(uuid16(0x180F), uuid16(0x2A19))
|
||||
|
||||
// Subscribe to common measurement characteristics if present
|
||||
setNotifySafe(uuid16(0x181D), uuid16(0x2A9D)) // Weight Scale -> Weight Measurement
|
||||
setNotifySafe(uuid16(0x181B), uuid16(0x2A9C)) // Body Comp -> Body Composition Measurement
|
||||
|
||||
logD("Debug handler armed. Incoming NOTIFY frames will be logged; no data is stored.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every incoming notification is logged in a concise form:
|
||||
* - Pretty UUID (16-bit when possible)
|
||||
* - Hex preview (up to 64 bytes)
|
||||
* - ASCII preview (non-printables as '?')
|
||||
*/
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
val hex = data.toHexPreview(64)
|
||||
val ascii = data.toAsciiPreview(64)
|
||||
logD("NOTIFY chr=${prettyUuid(characteristic)} $hex ascii=$ascii")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Dump utilities
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Dump all discovered GATT services and their characteristics (with property flags).
|
||||
* Uses the transport's `debugGetPeripheral()` hook to access the raw peripheral.
|
||||
*/
|
||||
private fun dumpAllGatt() {
|
||||
val peripheral = getPeripheral()
|
||||
if (peripheral == null) {
|
||||
logD("No peripheral available yet (transport.debugGetPeripheral() returned null).")
|
||||
return
|
||||
}
|
||||
|
||||
val services = peripheral.services
|
||||
logD("=== GATT Service Dump BEGIN ===")
|
||||
if (services.isEmpty()) {
|
||||
logD( "(no services)")
|
||||
logD( "=== GATT Service Dump END ===")
|
||||
return
|
||||
}
|
||||
|
||||
for (svc in services) {
|
||||
logD( "Service ${prettyUuid(svc.uuid)}")
|
||||
val chars = svc.characteristics ?: emptyList()
|
||||
for (ch in chars) {
|
||||
logD(" └─ Char ${prettyUuid(ch.uuid)} props=${propsToString(ch.properties)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
logD("=== GATT Service Dump END ===")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Safe wrappers (never throw; best-effort operations)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private fun setNotifySafe(service: UUID, characteristic: UUID) {
|
||||
logD("→ setNotifyOn svc=${prettyUuid(service)} chr=${prettyUuid(characteristic)}")
|
||||
runCatching { setNotifyOn(service, characteristic) }
|
||||
.onFailure { logD("setNotifyOn failed: ${it.message ?: it::class.simpleName}") }
|
||||
}
|
||||
|
||||
private fun readSafe(service: UUID, characteristic: UUID) {
|
||||
logD("→ read svc=${prettyUuid(service)} chr=${prettyUuid(characteristic)} (best effort)")
|
||||
runCatching { readFrom(service, characteristic) }
|
||||
.onFailure { logD("read failed: ${it.message ?: it::class.simpleName}") }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Pretty-print helpers
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a standard 128-bit UUID with the Bluetooth base into a compact **0xNNNN** form.
|
||||
* Leaves full UUIDs intact for vendor/custom values.
|
||||
*/
|
||||
private fun prettyUuid(u: UUID): String {
|
||||
val s = u.toString().lowercase(Locale.ROOT)
|
||||
return if (s.startsWith("0000") && s.endsWith("-0000-1000-8000-00805f9b34fb"))
|
||||
"0x" + s.substring(4, 8)
|
||||
else
|
||||
s
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn Android GATT property flags into a readable pipe-separated string.
|
||||
* Example: `READ|WRITE_NR|NOTIFY|INDICATE`
|
||||
*/
|
||||
private fun propsToString(p: Int): String {
|
||||
val flags = mutableListOf<String>()
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_READ) != 0) flags += "READ"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) flags += "WRITE"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) flags += "WRITE_NR"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) flags += "NOTIFY"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) flags += "INDICATE"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE) != 0) flags += "SIGNED"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_BROADCAST) != 0) flags += "BROADCAST"
|
||||
if ((p and BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS) != 0) flags += "EXT"
|
||||
return if (flags.isEmpty()) "0" else flags.joinToString("|")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.scales
|
||||
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.ConverterUtils
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Handler for Exingtech Y1 scales (often advertising as "VScale").
|
||||
*
|
||||
* Protocol:
|
||||
* - Custom Service: f433bd80-75b8-11e2-97d9-0002a5d5c51b
|
||||
* - Notify Characteristic: 1a2ea400-75b9-11e2-be05-0002a5d5c51b
|
||||
* - Write Characteristic: 29f11080-75b9-11e2-8bf6-0002a5d5c51b
|
||||
*
|
||||
* Flow:
|
||||
* 1) Enable NOTIFY on data characteristic.
|
||||
* 2) Write user block: [0x10, userId, gender(0=male/1=female), age, height(cm)].
|
||||
* 3) Wait for a 20-byte result frame; the first one may only contain weight.
|
||||
* Publish when body composition (fat) is present (data[6] != 0xFF).
|
||||
*/
|
||||
class ExingtechY1Handler : ScaleDeviceHandler() {
|
||||
|
||||
private val SERVICE: UUID =
|
||||
UUID.fromString("f433bd80-75b8-11e2-97d9-0002a5d5c51b")
|
||||
private val CHAR_NOTIFY: UUID =
|
||||
UUID.fromString("1a2ea400-75b9-11e2-be05-0002a5d5c51b")
|
||||
private val CHAR_CMD: UUID =
|
||||
UUID.fromString("29f11080-75b9-11e2-8bf6-0002a5d5c51b")
|
||||
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
|
||||
val name = device.name.lowercase(Locale.US)
|
||||
val byName = (name == "vscale")
|
||||
|
||||
val byService = device.serviceUuids.any {
|
||||
it.equals(SERVICE)
|
||||
}
|
||||
|
||||
if (!byName && !byService) return null
|
||||
|
||||
val caps = setOf(
|
||||
DeviceCapability.BODY_COMPOSITION,
|
||||
DeviceCapability.USER_SYNC
|
||||
)
|
||||
|
||||
return DeviceSupport(
|
||||
displayName = "Exingtech Y1 (VScale)",
|
||||
capabilities = caps,
|
||||
implemented = caps,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
}
|
||||
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
// Enable notifications for result frames
|
||||
setNotifyOn(SERVICE, CHAR_NOTIFY)
|
||||
|
||||
// Send user block (id is truncated to 1 byte like legacy driver)
|
||||
val userIdOneByte = (user.id and 0xFF).toByte()
|
||||
val gender = if (user.gender.isMale()) 0x00 else 0x01
|
||||
val age = (user.age and 0xFF).toByte()
|
||||
val height = (user.bodyHeight.toInt() and 0xFF).toByte()
|
||||
|
||||
val cmd = byteArrayOf(
|
||||
0x10,
|
||||
userIdOneByte,
|
||||
gender.toByte(),
|
||||
age,
|
||||
height
|
||||
)
|
||||
writeTo(SERVICE, CHAR_CMD, cmd, withResponse = true)
|
||||
|
||||
// Prompt user
|
||||
userInfo(R.string.bt_info_step_on_scale)
|
||||
}
|
||||
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
if (characteristic != CHAR_NOTIFY) return
|
||||
if (data.size != 20) return
|
||||
|
||||
// The first notify can be "weight only"; full composition follows.
|
||||
// In legacy code we waited until fat != 0xFF.
|
||||
val fatHi = data[6]
|
||||
if (fatHi.toInt() and 0xFF == 0xFF) {
|
||||
logD("VScale: weight-only frame, waiting for full composition…")
|
||||
return
|
||||
}
|
||||
|
||||
publish(parseMeasurement(data))
|
||||
}
|
||||
|
||||
// --- Parsing --------------------------------------------------------------
|
||||
|
||||
private fun parseMeasurement(frame: ByteArray): ScaleMeasurement {
|
||||
// Big-endian 16-bit fields, matching legacy ConverterUtils.fromUnsignedInt16Be
|
||||
val weight = ConverterUtils.fromUnsignedInt16Be(frame, 4) / 10.0f
|
||||
val fat = ConverterUtils.fromUnsignedInt16Be(frame, 6) / 10.0f
|
||||
val water = ConverterUtils.fromUnsignedInt16Be(frame, 8) / 10.0f
|
||||
val bone = ConverterUtils.fromUnsignedInt16Be(frame, 10) / 10.0f
|
||||
val muscle = ConverterUtils.fromUnsignedInt16Be(frame, 12) / 10.0f
|
||||
val visceralIndex = (frame[14].toInt() and 0xFF).toFloat()
|
||||
// calorie (offset 15) and BMI (offset 17) exist but are computed by app; skip.
|
||||
|
||||
return ScaleMeasurement().apply {
|
||||
dateTime = Date()
|
||||
this.weight = weight
|
||||
this.fat = fat
|
||||
this.water = water
|
||||
this.muscle = muscle
|
||||
this.bone = bone
|
||||
this.visceralFat = visceralIndex
|
||||
}.also {
|
||||
logD("VScale result kg=${it.weight} fat=${it.fat} water=${it.water} muscle=${it.muscle} bone=${it.bone} visc=${it.visceralFat}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/*
|
||||
* 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.scales
|
||||
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import eu.brassepc.fitnessdroid.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.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// 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: Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BleGattTuning = profile.forGatt()
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
private var currentPeripheral: BluetoothPeripheral? = null
|
||||
|
||||
private val opQueue = Channel<suspend () -> Unit>(Channel.UNLIMITED)
|
||||
|
||||
private data class PendingOp(
|
||||
val id: Long,
|
||||
val deferred: CompletableDeferred<Unit>
|
||||
)
|
||||
|
||||
private val deferredMap = ConcurrentHashMap<UUID, PendingOp>()
|
||||
private var nextOpId = 0L
|
||||
|
||||
private val ioMutex = Mutex()
|
||||
|
||||
private var connectAttempts = 0
|
||||
|
||||
init {
|
||||
// Worker coroutine processes queued BLE operations sequentially
|
||||
scope.launch {
|
||||
for (op in opQueue) {
|
||||
// wait until BLE connection is established
|
||||
while (!_isConnected.value) {
|
||||
delay(10.milliseconds)
|
||||
}
|
||||
|
||||
try {
|
||||
ioMutex.lock()
|
||||
op()
|
||||
} catch (t: Throwable) {
|
||||
LogManager.e(TAG, "BLE operation failed", t)
|
||||
} finally {
|
||||
ioMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
private suspend fun ioGap(ms: Long) {
|
||||
if (ms > 0) delay(ms.milliseconds)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Bluetooth central callbacks
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscovered(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.milliseconds)
|
||||
central.connect(peripheral, peripheralCallback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnected(peripheral: BluetoothPeripheral) {
|
||||
scope.launch {
|
||||
currentPeripheral = peripheral
|
||||
_isConnected.value = true
|
||||
_isConnecting.value = false
|
||||
_events.tryEmit(BluetoothEvent.Connected(peripheral.name, peripheral.address))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: HciStatus) {
|
||||
scope.launch {
|
||||
LogManager.e(TAG, "Connection failed ${peripheral.address}: $status")
|
||||
if (connectAttempts < tuning.common.maxRetries) {
|
||||
val nextTry = connectAttempts + 1
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_reconnecting_try, nextTry, tuning.common.maxRetries),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
connectAttempts = nextTry
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
runCatching { central.stopScan() }
|
||||
central.scanForPeripheralsWithAddresses(setOf(peripheral.address))
|
||||
_isConnecting.value = true
|
||||
} else {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(peripheral.address, status.toString()))
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(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 callback receives all GATT events
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
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 = selectedUserSnapshot ?: run {
|
||||
central.cancelConnection(peripheral); return
|
||||
}
|
||||
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
|
||||
handler.attach(transport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
handler.handleConnected(user)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 write response chr=${characteristic.uuid} len=${value.size} status=${status} ${value.toHexPreview(24)}")
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationStateUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 notify state chr=${characteristic.uuid} status=${status}")
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicUpdate(
|
||||
peripheral: BluetoothPeripheral,
|
||||
value: ByteArray,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
LogManager.d(TAG,"\u2190 received data chr=${characteristic.uuid} len=${value.size} status=${status} ${value.toHexPreview(24)}")
|
||||
|
||||
handler.handleNotification(characteristic.uuid, value)
|
||||
|
||||
deferredMap[characteristic.uuid]?.let { op ->
|
||||
op.deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Transport exposed to handler; operations are queued automatically
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
private val transport = object : ScaleDeviceHandler.Transport {
|
||||
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
LogManager.d(TAG, "→ set notify on chr=$characteristic svc=$service")
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
val started = p.startNotify(service, characteristic)
|
||||
if (!started) {
|
||||
LogManager.w(TAG, "Failed to initiate notify for $characteristic")
|
||||
// appCallbacks.onWarn(R.string.bt_warn_notify_failed, characteristic.toString())
|
||||
deferred.complete(Unit)
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait with timeout from tuning
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
LogManager.w(TAG, "Timeout waiting for notify on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.notifySetupDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
val ch = p.getCharacteristic(service, characteristic) ?: return@trySend
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
val supportsWriteNoResponse = ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0
|
||||
val supportsWriteResponse = ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0
|
||||
|
||||
val type = when {
|
||||
withResponse && supportsWriteResponse -> WriteType.WITH_RESPONSE
|
||||
!withResponse && supportsWriteNoResponse -> WriteType.WITHOUT_RESPONSE
|
||||
supportsWriteResponse -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support WITHOUT_RESPONSE, using WITH_RESPONSE instead")
|
||||
WriteType.WITH_RESPONSE
|
||||
}
|
||||
supportsWriteNoResponse -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support WITH_RESPONSE, using WITHOUT_RESPONSE instead")
|
||||
WriteType.WITHOUT_RESPONSE
|
||||
}
|
||||
else -> {
|
||||
LogManager.w(TAG, "Characteristic $characteristic does not support writing")
|
||||
return@trySend
|
||||
}
|
||||
}
|
||||
|
||||
ioGap(if (withResponse) tuning.writeWithResponseDelayMs else tuning.writeWithoutResponseDelayMs)
|
||||
p.writeCharacteristic(service, characteristic, payload, type)
|
||||
|
||||
LogManager.d(TAG,"\u2192 write to chr=$characteristic svc=$service len=${payload.size} withResp=$withResponse ${payload.toHexPreview(24)}")
|
||||
|
||||
try {
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
LogManager.w(TAG, "Timeout waiting for write on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.postWriteDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(service: UUID, characteristic: UUID) {
|
||||
opQueue.trySend {
|
||||
val p = currentPeripheral ?: return@trySend
|
||||
p.getCharacteristic(service, characteristic) ?: return@trySend
|
||||
|
||||
val opId = ++nextOpId
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
deferredMap[characteristic] = PendingOp(opId, deferred)
|
||||
|
||||
p.readCharacteristic(service, characteristic)
|
||||
|
||||
LogManager.d(TAG,"\u2192 read from chr=$characteristic svc=$service")
|
||||
|
||||
try {
|
||||
withTimeout(tuning.operationTimeoutMs.milliseconds) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
LogManager.w(TAG, "Timeout waiting for read on $characteristic")
|
||||
} finally {
|
||||
val current = deferredMap[characteristic]
|
||||
if (current?.id == opId) {
|
||||
deferredMap.remove(characteristic)
|
||||
}
|
||||
deferred.cancel()
|
||||
}
|
||||
|
||||
ioGap(tuning.postReadDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
currentPeripheral?.let { central.cancelConnection(it) }
|
||||
}
|
||||
|
||||
override fun getPeripheral(): BluetoothPeripheral? = currentPeripheral
|
||||
|
||||
override fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean {
|
||||
val p = currentPeripheral ?: return false
|
||||
return p.getCharacteristic(service, characteristic) != null
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Connection management
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
if (!::central.isInitialized) {
|
||||
central = BluetoothCentralManager(context, centralCallback, mainHandler)
|
||||
}
|
||||
|
||||
val sinceLastDisconnect = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
val waitMs = (tuning.common.reconnectCooldownMs - sinceLastDisconnect).coerceAtLeast(0)
|
||||
|
||||
connectAttempts = 0
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = true
|
||||
|
||||
runCatching { central.stopScan() }
|
||||
|
||||
scope.launch {
|
||||
if (waitMs > 0) delay(waitMs.milliseconds)
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(setOf(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doDisconnect() {
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
currentPeripheral?.let { runCatching { central.cancelConnection(it) } }
|
||||
currentPeripheral = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// Stop accepting new BLE operations and release the Blessed central
|
||||
// (its broadcast receivers) before the base cancels the coroutine scope,
|
||||
// which terminates the busy-waiting op-queue worker.
|
||||
runCatching { opQueue.close() }
|
||||
runCatching { if (::central.isInitialized) central.close() }
|
||||
super.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
/*
|
||||
* 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.scales
|
||||
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt1Bar
|
||||
import androidx.compose.material.icons.outlined.SignalCellularAlt2Bar
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent
|
||||
import com.health.openscale.core.bluetooth.ScaleCommunicator
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.data.MeasurementTypeKey
|
||||
import com.health.openscale.core.data.MeasurementValue
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.User
|
||||
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.utils.ConverterUtils
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.Date
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Shared tuning for BLE pacing & retry (used by GATT adapter).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// Common knobs every link can use
|
||||
data class CommonTuning(
|
||||
val reconnectCooldownMs: Long = 2000,
|
||||
val retryBackoffMs: Long = 1500,
|
||||
val maxRetries: Int = 3
|
||||
)
|
||||
|
||||
// GATT-specific
|
||||
data class BleGattTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val notifySetupDelayMs: Long = 120,
|
||||
val writeWithResponseDelayMs: Long = 80,
|
||||
val writeWithoutResponseDelayMs: Long = 35,
|
||||
val postWriteDelayMs: Long = 20,
|
||||
val postReadDelayMs: Long = 20,
|
||||
val connectAfterScanDelayMs: Long = 650,
|
||||
val requestHighConnectionPriority: Boolean = false,
|
||||
val requestMtuBytes: Int = 185,
|
||||
val operationTimeoutMs: Long = 1000
|
||||
)
|
||||
|
||||
// Broadcast scanner tuning
|
||||
data class BleBroadcastTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val scanMode: Int = ScanSettings.SCAN_MODE_LOW_LATENCY,
|
||||
val maxScanMs: Long = 20_000,
|
||||
val restartBackoffMs: Long = 1500,
|
||||
val packetDedupWindowMs: Long = 750,
|
||||
val stabilizeWindowMs: Long = 1200,
|
||||
val minRssiDbm: Int? = null // e.g. -90
|
||||
)
|
||||
|
||||
// Classic SPP tuning
|
||||
data class BtSppTuning(
|
||||
val common: CommonTuning = CommonTuning(),
|
||||
val connectTimeoutMs: Long = 10_000,
|
||||
val readTimeoutMs: Long = 3000,
|
||||
val writeChunkBytes: Int = 256,
|
||||
val interChunkDelayMs: Long = 10,
|
||||
val soKeepAlive: Boolean = true
|
||||
)
|
||||
|
||||
enum class TuningProfile(
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
Conservative(
|
||||
labelRes = R.string.tuning_conservative,
|
||||
icon = Icons.Filled.SignalCellularAlt1Bar
|
||||
),
|
||||
Balanced(
|
||||
labelRes = R.string.tuning_balanced,
|
||||
icon = Icons.Outlined.SignalCellularAlt2Bar
|
||||
),
|
||||
Aggressive(
|
||||
labelRes = R.string.tuning_aggressive,
|
||||
icon = Icons.Filled.SignalCellularAlt
|
||||
)
|
||||
}
|
||||
fun TuningProfile.forGatt(): BleGattTuning = when (this) {
|
||||
TuningProfile.Balanced -> BleGattTuning(
|
||||
common = CommonTuning(2200, 1500, 3),
|
||||
notifySetupDelayMs = 120,
|
||||
writeWithResponseDelayMs = 80,
|
||||
writeWithoutResponseDelayMs = 35,
|
||||
postWriteDelayMs = 20,
|
||||
postReadDelayMs = 20,
|
||||
connectAfterScanDelayMs = 650,
|
||||
requestHighConnectionPriority = false,
|
||||
requestMtuBytes = 185,
|
||||
operationTimeoutMs = 1000
|
||||
)
|
||||
TuningProfile.Conservative -> BleGattTuning(
|
||||
common = CommonTuning(2500, 1800, 3),
|
||||
notifySetupDelayMs = 160,
|
||||
writeWithResponseDelayMs = 100,
|
||||
writeWithoutResponseDelayMs = 50,
|
||||
postWriteDelayMs = 30,
|
||||
postReadDelayMs = 30,
|
||||
connectAfterScanDelayMs = 800,
|
||||
requestHighConnectionPriority = false,
|
||||
requestMtuBytes = 0,
|
||||
operationTimeoutMs = 2000
|
||||
)
|
||||
TuningProfile.Aggressive -> BleGattTuning(
|
||||
common = CommonTuning(1200, 1200, 2),
|
||||
notifySetupDelayMs = 80,
|
||||
writeWithResponseDelayMs = 60,
|
||||
writeWithoutResponseDelayMs = 25,
|
||||
postWriteDelayMs = 15,
|
||||
postReadDelayMs = 15,
|
||||
connectAfterScanDelayMs = 400,
|
||||
requestHighConnectionPriority = true,
|
||||
requestMtuBytes = 247,
|
||||
operationTimeoutMs = 500
|
||||
)
|
||||
}
|
||||
|
||||
fun TuningProfile.forBroadcast(): BleBroadcastTuning = when (this) {
|
||||
TuningProfile.Balanced -> BleBroadcastTuning(common = CommonTuning(2200,1500,3))
|
||||
TuningProfile.Conservative -> BleBroadcastTuning(
|
||||
common = CommonTuning(2500,1800,3),
|
||||
scanMode = ScanSettings.SCAN_MODE_BALANCED,
|
||||
maxScanMs = 30_000
|
||||
)
|
||||
TuningProfile.Aggressive -> BleBroadcastTuning(
|
||||
common = CommonTuning(1200,1200,2),
|
||||
scanMode = ScanSettings.SCAN_MODE_LOW_LATENCY,
|
||||
maxScanMs = 15_000,
|
||||
stabilizeWindowMs = 900
|
||||
)
|
||||
}
|
||||
|
||||
fun TuningProfile.forSpp(): BtSppTuning = when (this) {
|
||||
TuningProfile.Balanced -> BtSppTuning()
|
||||
TuningProfile.Conservative -> BtSppTuning(connectTimeoutMs = 12_000, interChunkDelayMs = 15)
|
||||
TuningProfile.Aggressive -> BtSppTuning(connectTimeoutMs = 8_000, interChunkDelayMs = 5)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Small persisted driver settings wrapper backed by SettingsFacade (shared by all adapters).
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
class FacadeDriverSettings(
|
||||
private val facade: SettingsFacade,
|
||||
private val scope: CoroutineScope,
|
||||
handlerNamespace: String
|
||||
) : ScaleDeviceHandler.DriverSettings {
|
||||
|
||||
private val prefix = "ble/$handlerNamespace/"
|
||||
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 = runCatching {
|
||||
runBlocking(Dispatchers.IO) { withTimeout(300.milliseconds) { facade.observeSetting(k, default).first() } }
|
||||
}.getOrElse { default }
|
||||
mem[k] = v.toString()
|
||||
return v
|
||||
}
|
||||
|
||||
override fun putInt(key: String, value: Int) {
|
||||
val k = prefix + key
|
||||
mem[k] = value.toString()
|
||||
scope.launch { facade.saveSetting(k, value) }
|
||||
}
|
||||
|
||||
override fun getString(key: String, default: String?): String? {
|
||||
val k = prefix + key
|
||||
mem[k]?.let { return it }
|
||||
val raw = runCatching {
|
||||
runBlocking(Dispatchers.IO) { withTimeout(300.milliseconds) { facade.observeSetting(k, default ?: "").first() } }
|
||||
}.getOrElse { default ?: "" }
|
||||
val result = if (raw.isEmpty() && default == null) null else raw
|
||||
result?.let { mem[k] = it }
|
||||
return result
|
||||
}
|
||||
|
||||
override fun putString(key: String, value: String) {
|
||||
val k = prefix + key
|
||||
mem[k] = value
|
||||
scope.launch { facade.saveSetting(k, value) }
|
||||
}
|
||||
|
||||
override fun remove(key: String) {
|
||||
val k = prefix + key
|
||||
mem.remove(k)
|
||||
scope.launch { facade.saveSetting(k, "") }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// ModernScaleAdapter (abstract base)
|
||||
// - Owns app integration, user/measurements snapshots, event streams, handler wiring.
|
||||
// - Concrete subclasses implement link-specific connect/disconnect logic.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
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, AutoCloseable {
|
||||
|
||||
protected val TAG = this::class.simpleName ?: "ModernScaleAdapter"
|
||||
|
||||
// ---- coroutine & lifecycle -----------------------------------------------------------------
|
||||
protected val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
protected val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// ---- session targeting ---------------------------------------------------------------------
|
||||
protected var targetAddress: String? = null
|
||||
protected var lastDisconnectAtMs: Long = 0L
|
||||
|
||||
// ---- UI streams ----------------------------------------------------------------------------
|
||||
val _events = MutableSharedFlow<BluetoothEvent>(replay = 1, extraBufferCapacity = 8)
|
||||
override fun getEventsFlow(): SharedFlow<BluetoothEvent> = _events.asSharedFlow()
|
||||
|
||||
protected val _isConnecting = MutableStateFlow(false)
|
||||
override val isConnecting: StateFlow<Boolean> = _isConnecting.asStateFlow()
|
||||
|
||||
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 {
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler.javaClass.simpleName
|
||||
)
|
||||
handler.attachSettings(driverSettings)
|
||||
|
||||
// Keep a *live* non-blocking snapshot of the current user.
|
||||
scope.launch {
|
||||
userFacade.observeSelectedUser().collect { 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 {
|
||||
combine(users.map { u -> measurementFacade.getMeasurementsForUser(u.id) }) { lists ->
|
||||
val out = HashMap<Int, ScaleMeasurement>(users.size)
|
||||
users.forEachIndexed { idx, u ->
|
||||
val newest = lists[idx].maxByOrNull { it.measurement.timestamp }
|
||||
mapMeasurement(newest)?.let { out[u.id] = it }
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
.collect { latestMap -> lastSnapshot = latestMap }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ScaleCommunicator entry points ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Template method: base validates input & selected user, then calls [doConnect].
|
||||
* Many handlers expect a selected user from app state;
|
||||
*/
|
||||
final override fun connect(address: String, scaleUser: ScaleUser?) {
|
||||
targetAddress = address
|
||||
_isConnecting.value = true
|
||||
|
||||
scope.launch {
|
||||
val user: ScaleUser? =
|
||||
scaleUser
|
||||
?: selectedUserSnapshot
|
||||
?: withTimeoutOrNull(750.milliseconds) {
|
||||
userFacade.observeSelectedUser().first()
|
||||
}?.let(::mapUser)
|
||||
|
||||
if (user == null) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
context.getString(R.string.bt_error_no_user_selected)
|
||||
)
|
||||
)
|
||||
_isConnecting.value = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
doConnect(address, user)
|
||||
}.onFailure { t ->
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
t.message ?: "—"
|
||||
)
|
||||
)
|
||||
_isConnecting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Template method: calls [doDisconnect] and resets shared state.
|
||||
*/
|
||||
final override fun disconnect() {
|
||||
doDisconnect()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
addr
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun processUserInteractionFeedback(
|
||||
interactionType: BluetoothEvent.UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any
|
||||
) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
handler.onUserInteractionFeedback(interactionType, appUserId, feedbackData)
|
||||
}.onFailure { t ->
|
||||
val addr = targetAddress ?: "unknown"
|
||||
LogManager.e(TAG, "Delivering user feedback failed: ${t.message}", t)
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_error_delivery_user_feedback, t.message ?: "—"),
|
||||
addr
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- abstract link hooks -------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
protected fun cleanup() {
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = false
|
||||
// keep targetAddress to allow higher layer to retry if wanted
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { scope.cancel() }
|
||||
}
|
||||
|
||||
// ---- mapping helpers (core -> legacy DTOs used by handlers) --------------------------------
|
||||
|
||||
protected fun mapUser(u: User): ScaleUser =
|
||||
ScaleUser().apply {
|
||||
runCatching { id = u.id }
|
||||
runCatching { userName = u.name }
|
||||
when (val b = runCatching { u.birthDate }.getOrNull()) {
|
||||
is Long -> birthday = Date(b)
|
||||
}
|
||||
runCatching { bodyHeight = u.heightCm }
|
||||
runCatching { gender = u.gender }
|
||||
runCatching { activityLevel = u.activityLevel }
|
||||
|
||||
runCatching {
|
||||
runBlocking(scope.coroutineContext) {
|
||||
val userGoals = userFacade.getAllGoalsForUser(u.id).first()
|
||||
|
||||
val goalWeightGoal = userGoals.find { it.measurementTypeId == MeasurementTypeKey.WEIGHT.id }
|
||||
if (goalWeightGoal != null) {
|
||||
val goalType = measurementFacade.getAllMeasurementTypes().first()
|
||||
.find { it.id == goalWeightGoal.measurementTypeId }
|
||||
|
||||
if (goalType != null) {
|
||||
goalWeight = ConverterUtils.convertFloatValueUnit(
|
||||
value = goalWeightGoal.goalValue,
|
||||
fromUnit = goalType.unit,
|
||||
toUnit = UnitType.KG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val allMeasurements = measurementFacade.getMeasurementsForUser(u.id).first()
|
||||
|
||||
val oldestWeightMeasurementValue = allMeasurements
|
||||
.sortedBy { it.measurement.timestamp }
|
||||
.firstNotNullOfOrNull { measurementWithValues ->
|
||||
measurementWithValues.values.find { it.type.key == MeasurementTypeKey.WEIGHT }
|
||||
}
|
||||
|
||||
if (oldestWeightMeasurementValue != null) {
|
||||
initialWeight = ConverterUtils.convertFloatValueUnit(
|
||||
value = oldestWeightMeasurementValue.value.floatValue ?: 0f,
|
||||
fromUnit = oldestWeightMeasurementValue.type.unit,
|
||||
toUnit = UnitType.KG
|
||||
)
|
||||
}
|
||||
|
||||
val allTypes = measurementFacade.getAllMeasurementTypes().first()
|
||||
|
||||
val weightType = allTypes.find { it.key == MeasurementTypeKey.WEIGHT }
|
||||
if (weightType != null) {
|
||||
scaleUnit = weightType.unit.toWeightUnit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun mapMeasurement(mwv: MeasurementWithValues?): ScaleMeasurement? {
|
||||
if (mwv == null) return null
|
||||
val m = ScaleMeasurement()
|
||||
runCatching { m.userId = mwv.measurement.userId }
|
||||
runCatching { m.dateTime = Date(mwv.measurement.timestamp) }
|
||||
|
||||
fun valueOf(key: MeasurementTypeKey): MeasurementValue? =
|
||||
mwv.values.firstOrNull { it.type.key == key }?.value
|
||||
|
||||
valueOf(MeasurementTypeKey.WEIGHT)?.let { m.weight = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.BODY_FAT)?.let { m.fat = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.WATER)?.let { m.water = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.MUSCLE)?.let { m.muscle = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.VISCERAL_FAT)?.let { m.visceralFat = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.LBM)?.let { m.lbm = it.floatValue ?: 0f }
|
||||
valueOf(MeasurementTypeKey.BONE)?.let { m.bone = it.floatValue ?: 0f }
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
/** Pretty print a few leading bytes of a payload for logs. */
|
||||
fun ByteArray.toHexPreview(limit: Int): String {
|
||||
if (limit <= 0 || isEmpty()) return "(payload ${size}b)"
|
||||
val show = min(size, limit)
|
||||
val sb = StringBuilder("payload=[")
|
||||
for (i in 0 until show) {
|
||||
if (i > 0) sb.append(' ')
|
||||
sb.append(String.format("%02X", this[i]))
|
||||
}
|
||||
if (size > limit) sb.append(" …(+").append(size - limit).append("b)")
|
||||
sb.append(']')
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* 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.scales
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoGraph
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.outlined.BatteryStd
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.brassepc.fitnessdroid.R
|
||||
import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.BluetoothPeripheral
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.util.UUID
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* What a handler declares about a device it supports.
|
||||
*
|
||||
* @property displayName Human-friendly name shown in the UI (e.g., "Yunmai Mini").
|
||||
* @property capabilities Features the device *can* support in theory.
|
||||
* @property implemented Features this handler actually implements today (may be a subset).
|
||||
* @property tuningProfile Optional link timing/retry preferences (see [TuningProfile]).
|
||||
* @property linkMode Whether the device uses GATT or broadcast-only advertisements.
|
||||
*/
|
||||
data class DeviceSupport(
|
||||
val displayName: String,
|
||||
val capabilities: Set<DeviceCapability>,
|
||||
val implemented: Set<DeviceCapability>,
|
||||
val tuningProfile: TuningProfile = TuningProfile.Balanced,
|
||||
val linkMode: LinkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
|
||||
/** High-level capabilities a scale might offer. */
|
||||
enum class DeviceCapability(
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
BODY_COMPOSITION( R.string.cap_body_composition, Icons.Filled.FitnessCenter ),
|
||||
TIME_SYNC( R.string.cap_time_sync, Icons.Filled.Schedule ),
|
||||
USER_SYNC( R.string.cap_user_sync, Icons.Filled.Group ),
|
||||
HISTORY_READ( R.string.cap_history_read, Icons.Filled.History ),
|
||||
LIVE_WEIGHT_STREAM(R.string.cap_live_weight, Icons.Filled.AutoGraph ),
|
||||
UNIT_CONFIG( R.string.cap_unit_config, Icons.Filled.Tune ),
|
||||
BATTERY_LEVEL( R.string.cap_battery, Icons.Outlined.BatteryStd )
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines whether a device communicates via a GATT connection
|
||||
* or only via broadcast advertisements.
|
||||
*/
|
||||
enum class LinkMode { CONNECT_GATT, BROADCAST_ONLY, CLASSIC_SPP }
|
||||
|
||||
/**
|
||||
* Signals how the handler consumed an advertisement.
|
||||
* - IGNORED: payload not relevant; adapter keeps scanning silently.
|
||||
* - CONSUMED_KEEP_SCANNING: payload processed, but we want to continue scanning (e.g., waiting for stability).
|
||||
* - CONSUMED_STOP: final payload processed; adapter should stop scanning and finish the session.
|
||||
*/
|
||||
enum class BroadcastAction { IGNORED, CONSUMED_KEEP_SCANNING, CONSUMED_STOP }
|
||||
|
||||
/**
|
||||
* # ScaleDeviceHandler
|
||||
*
|
||||
* Minimal base class for a **device-specific** BLE protocol handler.
|
||||
*
|
||||
* For GATT devices, the app (via `ModernScaleAdapter`) injects a BLE [Transport] and [Callbacks],
|
||||
* then calls [onConnected] and forwards notifications to [onNotification].
|
||||
*
|
||||
* For broadcast-only devices, the adapter attaches a **no-op** transport and forwards
|
||||
* advertisement frames to [onAdvertisement]. The handler can call [publish] to emit results.
|
||||
*
|
||||
* Threading: the adapter serializes and paces BLE I/O. Avoid sleeps or blocking work inside your
|
||||
* handler; just call the helpers in the order your protocol requires.
|
||||
*/
|
||||
abstract class ScaleDeviceHandler {
|
||||
val TAG = this::class.simpleName ?: "ScaleDeviceHandler"
|
||||
|
||||
companion object {
|
||||
// Pseudo UUIDs for Classic/SPP
|
||||
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.
|
||||
*/
|
||||
abstract fun supportFor(device: ScannedDeviceInfo): DeviceSupport?
|
||||
|
||||
/**
|
||||
* Optional UI component for device-specific settings.
|
||||
* Override this in concrete handlers to show custom input fields.
|
||||
*/
|
||||
@Composable
|
||||
open fun DeviceConfigurationUi() {
|
||||
// Default message when no specific configuration is required
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_special_configuration_available),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lifecycle entry points called by the adapter -------------------------
|
||||
|
||||
internal fun attachSettings(settings: DriverSettings) {
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
internal fun attach(transport: Transport, callbacks: Callbacks, settings: DriverSettings, data: DataProvider, scope: CoroutineScope) {
|
||||
this.transport = transport
|
||||
this.callbacks = callbacks
|
||||
this.settings = settings
|
||||
this.data = data
|
||||
this._scope = scope
|
||||
logD("attach()")
|
||||
}
|
||||
|
||||
internal fun handleConnected(user: ScaleUser) {
|
||||
logD("handleConnected(userId=${user.id}, height=${user.bodyHeight}, age=${user.age})")
|
||||
try {
|
||||
onConnected(user)
|
||||
} catch (t: Throwable) {
|
||||
logE("onConnected failed: ${t.message}", t)
|
||||
callbacks?.onError(
|
||||
R.string.bt_error_handler_connect_failed,
|
||||
t,
|
||||
t.message ?: "—"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleNotification(characteristic: UUID, data: ByteArray) {
|
||||
val u = currentAppUser()
|
||||
try {
|
||||
onNotification(characteristic, data, u)
|
||||
} catch (t: Throwable) {
|
||||
logE("onNotification failed for $characteristic: ${t.message}", t)
|
||||
callbacks?.onError(
|
||||
R.string.bt_error_handler_parse_error,
|
||||
t,
|
||||
characteristic.toString(),
|
||||
t.message ?: "—"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleDisconnected() {
|
||||
logD("handleDisconnected()")
|
||||
try {
|
||||
onDisconnected()
|
||||
} catch (t: Throwable) {
|
||||
logW("onDisconnected threw: ${t.message}")
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun detach() {
|
||||
logD("detach()")
|
||||
transport = null
|
||||
callbacks = null
|
||||
}
|
||||
|
||||
// --- To be implemented by concrete handlers --------------------------------
|
||||
|
||||
/** Called after services are discovered and the link is ready for I/O (GATT devices only). */
|
||||
protected open fun onConnected(user: ScaleUser) = Unit
|
||||
|
||||
/** Called for each incoming notification (GATT devices only). */
|
||||
protected open fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) = Unit
|
||||
|
||||
/** Optional cleanup hook. */
|
||||
protected open fun onDisconnected() = Unit
|
||||
|
||||
/**
|
||||
* Called for each advertisement seen for the target device (broadcast-only devices).
|
||||
* Default implementation ignores the advertisement.
|
||||
*/
|
||||
open fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction = BroadcastAction.IGNORED
|
||||
|
||||
// --- Protected helper methods (use these from your handler) ----------------
|
||||
|
||||
/** Enable notifications for a characteristic. */
|
||||
protected fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
transport?.setNotifyOn(service, characteristic)
|
||||
?: logW("setNotifyOn called without transport")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a command to a characteristic.
|
||||
* @param withResponse true for `Write With Response` (default), false for `Write Without Response`.
|
||||
*/
|
||||
protected fun writeTo(
|
||||
service: UUID,
|
||||
characteristic: UUID,
|
||||
payload: ByteArray,
|
||||
withResponse: Boolean = true
|
||||
) {
|
||||
transport?.write(service, characteristic, payload, withResponse)
|
||||
?: logW("writeTo called without transport")
|
||||
}
|
||||
|
||||
/** Read a characteristic (rare for scales; most data comes via NOTIFY). */
|
||||
protected fun readFrom(service: UUID, characteristic: UUID) {
|
||||
transport?.read(service, characteristic)
|
||||
?: logW("readFrom called without transport")
|
||||
}
|
||||
|
||||
/** Publish a fully parsed measurement to the app. */
|
||||
protected fun publish(measurement: ScaleMeasurement) {
|
||||
logI("\u2190 publish measurement to app")
|
||||
callbacks?.onPublish(measurement)
|
||||
?: logW("publish called without callbacks")
|
||||
}
|
||||
|
||||
/** Ask the adapter to terminate the link. */
|
||||
protected fun requestDisconnect() {
|
||||
logD("\u2192 request BLE disconnect")
|
||||
transport?.disconnect()
|
||||
}
|
||||
|
||||
fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean {
|
||||
val hasUUID = transport?.hasCharacteristic(service, characteristic) ?: false
|
||||
if (!hasUUID)
|
||||
logD("hasCharacteristic: $service/$characteristic → false")
|
||||
return hasUUID
|
||||
}
|
||||
|
||||
protected fun getPeripheral(): BluetoothPeripheral? {
|
||||
return transport?.getPeripheral()
|
||||
}
|
||||
|
||||
/** Helper to build a 16-bit Bluetooth Base UUID (e.g., `uuid16(0xFFE4)`). */
|
||||
protected fun uuid16(short: Int): UUID =
|
||||
UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short))
|
||||
|
||||
protected fun resolveString(@StringRes resId: Int, vararg args: Any): String =
|
||||
callbacks?.resolveString(resId, *args) ?: "res:$resId"
|
||||
|
||||
protected fun settingsGetInt(key: String, default: Int = -1): Int = settings.getInt(key, default)
|
||||
protected fun settingsPutInt(key: String, value: Int) { settings.putInt(key, value) }
|
||||
|
||||
protected fun settingsGetString(key: String, default: String? = null): String? = settings.getString(key, default)
|
||||
protected fun settingsPutString(key: String, value: String) { settings.putString(key, value) }
|
||||
|
||||
protected fun currentAppUser(): ScaleUser = data.currentUser()
|
||||
protected fun usersForDevice(): List<ScaleUser> = data.usersForDevice()
|
||||
protected fun lastMeasurementFor(userId: Int): ScaleMeasurement? = data.lastMeasurementFor(userId)
|
||||
|
||||
// --- Logging shortcuts (route to LogManager under a single TAG) ------------
|
||||
|
||||
protected fun logD(msg: String) = LogManager.d(TAG, msg)
|
||||
protected fun logI(msg: String) = LogManager.i(TAG, msg)
|
||||
protected fun logW(msg: String) = LogManager.w(TAG, msg, null)
|
||||
protected fun logE(msg: String, t: Throwable? = null) = LogManager.e(TAG, msg, t)
|
||||
|
||||
// Human-readable messages for users (e.g., snackbars/toasts)
|
||||
protected fun userInfo(@StringRes resId: Int, vararg args: Any) {
|
||||
callbacks?.onInfo(resId, *args) ?: logD("userInfo dropped: res=$resId")
|
||||
}
|
||||
protected fun userWarn(@StringRes resId: Int, vararg args: Any) {
|
||||
callbacks?.onWarn(resId, *args) ?: logW("userWarn dropped: res=$resId")
|
||||
}
|
||||
protected fun userError(@StringRes resId: Int, vararg args: Any, t: Throwable? = null) {
|
||||
callbacks?.onError(resId, t, *args) ?: logE("userError dropped: res=$resId", t)
|
||||
}
|
||||
|
||||
protected fun requestUserInteraction(
|
||||
interactionType: UserInteractionType,
|
||||
data: Any?
|
||||
) {
|
||||
callbacks?.onUserInteractionRequired(interactionType, data)
|
||||
?: logW("requestUserInteraction dropped: $interactionType")
|
||||
}
|
||||
|
||||
open suspend fun onUserInteractionFeedback(
|
||||
interactionType: UserInteractionType,
|
||||
appUserId: Int,
|
||||
feedbackData: Any) { /* no-op */ }
|
||||
|
||||
// --- Wiring provided by the adapter ---------------------------------------
|
||||
|
||||
private var transport: Transport? = null
|
||||
private var callbacks: Callbacks? = null
|
||||
private lateinit var settings: DriverSettings
|
||||
private lateinit var data: DataProvider
|
||||
private var _scope: CoroutineScope? = null
|
||||
|
||||
/**
|
||||
* Lifecycle-bound coroutine scope provided by the adapter (cancelled when the communicator
|
||||
* is closed). Handlers that need timeout/fallback coroutines should use this instead of
|
||||
* creating their own scope. Valid after [attach] — i.e. inside onConnected/onNotification.
|
||||
*/
|
||||
protected val scope: CoroutineScope
|
||||
get() = _scope ?: error("ScaleDeviceHandler.scope accessed before attach()")
|
||||
/**
|
||||
* BLE transport the adapter provides. No threading/queueing implied here—
|
||||
* the adapter already serializes and paces I/O.
|
||||
*/
|
||||
interface Transport {
|
||||
fun setNotifyOn(service: UUID, characteristic: UUID)
|
||||
fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean = true)
|
||||
fun read(service: UUID, characteristic: UUID)
|
||||
fun disconnect()
|
||||
fun getPeripheral(): BluetoothPeripheral? = null
|
||||
fun hasCharacteristic(service: UUID, characteristic: UUID): Boolean
|
||||
}
|
||||
|
||||
// ----- DataProvider: live app data the handler can query -----
|
||||
interface DataProvider {
|
||||
/** Currently selected app user (may be null if none). */
|
||||
fun currentUser(): ScaleUser
|
||||
|
||||
/** Fresh snapshot of app users that are relevant for this device. */
|
||||
fun usersForDevice(): List<ScaleUser>
|
||||
|
||||
/** Latest saved measurement for the given user (or null if none). */
|
||||
fun lastMeasurementFor(userId: Int): ScaleMeasurement?
|
||||
}
|
||||
|
||||
interface DriverSettings {
|
||||
fun getInt(key: String, default: Int = -1): Int
|
||||
fun putInt(key: String, value: Int)
|
||||
|
||||
fun getString(key: String, default: String? = null): String?
|
||||
fun putString(key: String, value: String)
|
||||
|
||||
fun remove(key: String)
|
||||
}
|
||||
|
||||
/** App callbacks to emit parsed results and user-visible messages. */
|
||||
interface Callbacks {
|
||||
fun onPublish(measurement: ScaleMeasurement)
|
||||
fun onInfo(@StringRes resId: Int, vararg args: Any) { /* optional */ }
|
||||
fun onWarn(@StringRes resId: Int, vararg args: Any) { /* optional */ }
|
||||
fun onError(@StringRes resId: Int, t: Throwable? = null, vararg args: Any) { /* optional */ }
|
||||
|
||||
fun onUserInteractionRequired(interactionType: UserInteractionType, data: Any?) { /* optional */ }
|
||||
fun resolveString(@StringRes resId: Int, vararg args: Any): String
|
||||
}
|
||||
|
||||
// --- Small utils -----------------------------------------------------------
|
||||
|
||||
/** Pretty print a few leading bytes of a payload for logs. */
|
||||
fun ByteArray.toHexPreview(limit: Int): String {
|
||||
if (limit <= 0 || isEmpty()) return "(payload ${size}b)"
|
||||
val show = min(size, limit)
|
||||
val sb = StringBuilder("payload=[")
|
||||
for (i in 0 until show) {
|
||||
if (i > 0) sb.append(' ')
|
||||
sb.append(String.format("%02X", this[i]))
|
||||
}
|
||||
if (size > limit) sb.append(" …(+").append(size - limit).append("b)")
|
||||
sb.append(']')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* ASCII preview of the first `max` bytes; non-printable bytes are rendered as '?'.
|
||||
*/
|
||||
fun ByteArray.toAsciiPreview(max: Int = 64): String {
|
||||
if (isEmpty()) return ""
|
||||
val n = min(size, max)
|
||||
val sb = StringBuilder(n)
|
||||
for (i in 0 until n) {
|
||||
val ch = (this[i].toInt() and 0xFF).toChar()
|
||||
sb.append(if (ch.isISOControl()) '?' else ch)
|
||||
}
|
||||
if (size > max) sb.append("…(+").append(size - max).append("b)")
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* 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.scales
|
||||
|
||||
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 android.os.SystemClock
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.content.getSystemService
|
||||
import eu.brassepc.fitnessdroid.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.BluetoothPeripheral
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* SPP (Bluetooth Classic / RFCOMM) adapter that plugs a [ScaleDeviceHandler] into a raw byte stream.
|
||||
*
|
||||
* Tuning usage:
|
||||
* - Reconnect cooldown between attempts (common.reconnectCooldownMs)
|
||||
* - Bounded retry on initial connect (common.maxRetries + common.retryBackoffMs)
|
||||
* - Connect timeout (connectTimeoutMs)
|
||||
* - Chunked writes (writeChunkBytes + interChunkDelayMs)
|
||||
* - Small settle delay after connect (derived from interChunkDelayMs)
|
||||
*/
|
||||
class SppScaleAdapter(
|
||||
context: Context,
|
||||
settingsFacade: SettingsFacade,
|
||||
measurementFacade: MeasurementFacade,
|
||||
userFacade: UserFacade,
|
||||
handler: ScaleDeviceHandler,
|
||||
profile: TuningProfile = TuningProfile.Balanced
|
||||
) : ModernScaleAdapter(context, settingsFacade, measurementFacade, userFacade, handler) {
|
||||
|
||||
private val tuning: BtSppTuning = profile.forSpp()
|
||||
|
||||
private var sppSocket: BluetoothSocket? = null
|
||||
private var sppReaderJob: Job? = null
|
||||
private var sppIn: InputStream? = null
|
||||
private var sppOut: OutputStream? = null
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
@Composable
|
||||
override fun DeviceConfigurationUi() {
|
||||
// Delegate to the actual protocol handler
|
||||
handler.DeviceConfigurationUi()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun doConnect(address: String, selectedUser: ScaleUser) {
|
||||
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 (_: 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) {
|
||||
// Cooldown between attempts
|
||||
val since = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
if (since in 1 until tuning.common.reconnectCooldownMs) {
|
||||
delay((tuning.common.reconnectCooldownMs - since).milliseconds)
|
||||
}
|
||||
|
||||
safeCancelDiscovery(adapter)
|
||||
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
try {
|
||||
LogManager.i(TAG, "Attempting SPP connection (attempt ${attempt + 1})")
|
||||
val socket = device.createRfcommSocketToServiceRecord(ScaleDeviceHandler.CLASSIC_DATA_UUID)
|
||||
sppSocket = socket
|
||||
|
||||
// --- Connect with manual timeout guard ---
|
||||
var connected = false
|
||||
// Start the blocking connect() in a child job
|
||||
val connectJob = launch(Dispatchers.IO) {
|
||||
socket.connect() // blocking call
|
||||
connected = true
|
||||
LogManager.i(TAG, "SPP connect() succeeded")
|
||||
}
|
||||
// Start a guard that closes the socket if connect takes too long
|
||||
val guardJob = launch(Dispatchers.IO) {
|
||||
val to = tuning.connectTimeoutMs
|
||||
if (to > 0) {
|
||||
delay(to.milliseconds)
|
||||
if (!connected) {
|
||||
LogManager.w(TAG, "Connect timeout reached ($to ms), closing socket")
|
||||
// Force the connect() to abort by closing the socket
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until either connect finishes or guard closes the socket
|
||||
connectJob.join()
|
||||
guardJob.cancel()
|
||||
|
||||
// If connect failed, connect() would have thrown and we’d be in catch{}
|
||||
sppIn = socket.inputStream
|
||||
sppOut = socket.outputStream
|
||||
|
||||
// Small settle delay before wiring the handler
|
||||
val settleDelay = maxOf(50L, tuning.interChunkDelayMs * 3)
|
||||
delay(settleDelay.milliseconds)
|
||||
|
||||
_isConnecting.value = false
|
||||
_isConnected.value = true
|
||||
|
||||
val name = safeDeviceName(device)
|
||||
val addr = safeDeviceAddress(device)
|
||||
LogManager.i(TAG, "Connected to device $name [$addr]")
|
||||
_events.tryEmit(BluetoothEvent.Connected(name, addr))
|
||||
|
||||
// Attach handler
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(sppTransport, appCallbacks, driverSettings, dataProvider, scope)
|
||||
handler.handleConnected(selectedUser)
|
||||
|
||||
// Reader loop (idle-timeout via available()+delay)
|
||||
sppReaderJob = launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(1024)
|
||||
var lastRx = SystemClock.elapsedRealtime()
|
||||
try {
|
||||
while (isActive) {
|
||||
val ins = sppIn ?: break
|
||||
val avail = runCatching { ins.available() }.getOrDefault(0)
|
||||
|
||||
if (avail > 0) {
|
||||
val n = ins.read(buf, 0, min(buf.size, avail))
|
||||
if (n <= 0) break
|
||||
lastRx = SystemClock.elapsedRealtime()
|
||||
val payload = buf.copyOf(n)
|
||||
LogManager.d(TAG, "Received $n bytes from SPP ${payload.toHexPreview(24)}")
|
||||
handler.handleNotification(ScaleDeviceHandler.CLASSIC_DATA_UUID, payload)
|
||||
} else {
|
||||
delay(50.milliseconds)
|
||||
val idle = SystemClock.elapsedRealtime() - lastRx
|
||||
if (tuning.readTimeoutMs > 0 && idle >= tuning.readTimeoutMs) {
|
||||
LogManager.w(TAG, "Read idle timeout reached, disconnecting")
|
||||
sppTransport.disconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
LogManager.w(TAG, "SPP read error: ${t.message}", t)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
val da = safeDeviceAddress(device)
|
||||
LogManager.i(TAG, "Reader loop finished, emitting disconnect")
|
||||
_events.tryEmit(BluetoothEvent.Disconnected(da, "SPP stream closed"))
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
cleanup()
|
||||
doDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// success → exit retry loop
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
attempt++
|
||||
LogManager.e(TAG, "SPP connect failed (attempt $attempt/${tuning.common.maxRetries}): ${t.message}", t)
|
||||
|
||||
if (attempt <= tuning.common.maxRetries) {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_reconnecting_try, attempt, tuning.common.maxRetries),
|
||||
address
|
||||
)
|
||||
)
|
||||
delay(tuning.common.retryBackoffMs.milliseconds)
|
||||
safeCancelDiscovery(adapter)
|
||||
continue
|
||||
} else {
|
||||
_events.tryEmit(BluetoothEvent.ConnectionFailed(address, t.message ?: "SPP connect failed"))
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
cleanup()
|
||||
doDisconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
lastDisconnectAtMs = SystemClock.elapsedRealtime()
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
// RFCOMM is a stream; we still pace writes and chunk large payloads
|
||||
scope.launch(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
LogManager.d(TAG, "Starting write of ${payload.size} bytes to SPP: ${payload.toHexPreview(24)}")
|
||||
val chunk = maxOf(1, tuning.writeChunkBytes)
|
||||
var i = 0
|
||||
while (i < payload.size) {
|
||||
val end = min(i + chunk, payload.size)
|
||||
sppOut?.write(payload, i, end - i)
|
||||
sppOut?.flush()
|
||||
val writtenChunk = payload.copyOfRange(i, end)
|
||||
LogManager.d(TAG, "Wrote chunk ${i / chunk + 1}: ${writtenChunk.toHexPreview(16)}")
|
||||
i = end
|
||||
if (i < payload.size && tuning.interChunkDelayMs > 0) {
|
||||
delay(tuning.interChunkDelayMs.milliseconds)
|
||||
}
|
||||
}
|
||||
LogManager.i(TAG, "Finished writing ${payload.size} bytes to SPP")
|
||||
} catch (t: Throwable) {
|
||||
LogManager.e(TAG, "SPP write failed: ${t.message}", t)
|
||||
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()
|
||||
}
|
||||
|
||||
override fun getPeripheral(): BluetoothPeripheral? = null
|
||||
|
||||
override fun hasCharacteristic(
|
||||
service: UUID,
|
||||
characteristic: UUID
|
||||
): Boolean {
|
||||
// Not applicable for SPP
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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" }
|
||||
}
|
||||
180
app/src/main/java/com/health/openscale/core/data/Enums.kt
Normal file
180
app/src/main/java/com/health/openscale/core/data/Enums.kt
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*
|
||||
* FitnessDroid: nedbantad kopia av openScales Enums.kt — bara det som
|
||||
* bluetooth-drivrutinerna behöver (strängresurser/ikoner urplockade).
|
||||
*/
|
||||
package com.health.openscale.core.data
|
||||
|
||||
enum class GenderType {
|
||||
MALE,
|
||||
FEMALE;
|
||||
|
||||
fun isMale(): Boolean {
|
||||
return this == MALE
|
||||
}
|
||||
}
|
||||
|
||||
enum class ActivityLevel {
|
||||
SEDENTARY, MILD, MODERATE, HEAVY, EXTREME;
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
SEDENTARY -> return 0
|
||||
MILD -> return 1
|
||||
MODERATE -> return 2
|
||||
HEAVY -> return 3
|
||||
EXTREME -> return 4
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): ActivityLevel {
|
||||
when (unit) {
|
||||
0 -> return SEDENTARY
|
||||
1 -> return MILD
|
||||
2 -> return MODERATE
|
||||
3 -> return HEAVY
|
||||
4 -> return EXTREME
|
||||
}
|
||||
return SEDENTARY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class WeightUnit {
|
||||
KG, LB, ST;
|
||||
|
||||
override fun toString(): String {
|
||||
when (this) {
|
||||
LB -> return "lb"
|
||||
ST -> return "st"
|
||||
KG -> return "kg"
|
||||
}
|
||||
}
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
LB -> return 1
|
||||
ST -> return 2
|
||||
KG -> return 0
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): WeightUnit {
|
||||
when (unit) {
|
||||
1 -> return LB
|
||||
2 -> return ST
|
||||
else -> return KG
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class MeasurementTypeKey(val id: Int) {
|
||||
WEIGHT(1),
|
||||
BMI(2),
|
||||
BODY_FAT(3),
|
||||
WATER(4),
|
||||
MUSCLE(5),
|
||||
LBM(6),
|
||||
BONE(7),
|
||||
WAIST(8),
|
||||
WHR(9),
|
||||
WHTR(10),
|
||||
HIPS(11),
|
||||
VISCERAL_FAT(12),
|
||||
CHEST(13),
|
||||
THIGH(14),
|
||||
BICEPS(15),
|
||||
NECK(16),
|
||||
CALIPER_1(17),
|
||||
CALIPER_2(18),
|
||||
CALIPER_3(19),
|
||||
CALIPER(20),
|
||||
BMR(21),
|
||||
TDEE(22),
|
||||
HEART_RATE(23),
|
||||
CALORIES(24),
|
||||
DATE(25),
|
||||
TIME(26),
|
||||
COMMENT(27),
|
||||
USER(28),
|
||||
IMPEDANCE(29),
|
||||
IMPEDANCE_LOW(30),
|
||||
ECW(31),
|
||||
ICW(32),
|
||||
PROTEIN(33),
|
||||
BCM(34),
|
||||
CUSTOM(99);
|
||||
}
|
||||
|
||||
enum class MeasureUnit {
|
||||
CM, INCH;
|
||||
|
||||
override fun toString(): String {
|
||||
when (this) {
|
||||
CM -> return "cm"
|
||||
INCH -> return "in"
|
||||
}
|
||||
}
|
||||
|
||||
fun toInt(): Int {
|
||||
when (this) {
|
||||
CM -> return 0
|
||||
INCH -> return 1
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromInt(unit: Int): MeasureUnit {
|
||||
when (unit) {
|
||||
1 -> return INCH
|
||||
else -> return CM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class UnitType(val displayName: String) {
|
||||
KG("kg"),
|
||||
LB("lb"),
|
||||
ST("st"),
|
||||
PERCENT("%"),
|
||||
CM("cm"),
|
||||
INCH("in"),
|
||||
KCAL("kcal"),
|
||||
BPM("bpm"),
|
||||
OHM("Ω"),
|
||||
NONE("");
|
||||
|
||||
fun isWeightUnit(): Boolean {
|
||||
return this == KG || this == LB || this == ST
|
||||
}
|
||||
|
||||
fun toWeightUnit(): WeightUnit {
|
||||
return when (this) {
|
||||
LB -> WeightUnit.LB
|
||||
ST -> WeightUnit.ST
|
||||
else -> WeightUnit.KG
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/health/openscale/core/data/User.kt
Normal file
39
app/src/main/java/com/health/openscale/core/data/User.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales User-entitet (upstream är en Room-entitet).
|
||||
* Bara fälten som bluetooth-adaptrarnas mapUser() läser.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.health.openscale.core.data
|
||||
|
||||
data class User(
|
||||
val id: Int = 1,
|
||||
val name: String = "",
|
||||
/** Födelsedag i epoch-millis (null = okänd) */
|
||||
val birthDate: Long? = null,
|
||||
/** Längd i cm */
|
||||
val heightCm: Float = -1f,
|
||||
val gender: GenderType = GenderType.MALE,
|
||||
val activityLevel: ActivityLevel = ActivityLevel.SEDENTARY,
|
||||
)
|
||||
|
||||
/** Målvikt m.m. — används av mapUser(); FitnessDroid har inga mål ännu. */
|
||||
data class Goal(
|
||||
val measurementTypeId: Int,
|
||||
val goalValue: Float,
|
||||
)
|
||||
|
||||
/** Mättyp med enhet — används för att avgöra vågens viktenhet. */
|
||||
data class MeasurementType(
|
||||
val id: Int,
|
||||
val key: MeasurementTypeKey,
|
||||
val unit: UnitType,
|
||||
)
|
||||
|
||||
/** Ett enskilt mätvärde. */
|
||||
data class MeasurementValue(
|
||||
val floatValue: Float?,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* FitnessDroid-shims för openScales facades (upstream pratar med Room +
|
||||
* inställningssystem). Här backas de av DataStore respektive appens profil,
|
||||
* med exakt den yta som bluetooth-paketet använder:
|
||||
*
|
||||
* - SettingsFacade: drivrutinernas key/value-inställningar + tuning-profil
|
||||
* - UserFacade: aktuell användare (längd/ålder/kön) som drivrutinerna behöver
|
||||
* - MeasurementFacade: senaste mätningar (används av vissa drivrutiner för
|
||||
* igenkänning av användare) — tom tills vidare
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.health.openscale.core.facade
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.health.openscale.core.data.Goal
|
||||
import com.health.openscale.core.data.MeasurementType
|
||||
import com.health.openscale.core.data.MeasurementTypeKey
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.User
|
||||
import com.health.openscale.core.model.MeasurementWithValues
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.driverDataStore by preferencesDataStore(name = "openscale_driver")
|
||||
|
||||
class SettingsFacade(private val context: Context) {
|
||||
|
||||
/** Sparad BLE-tuning-profil ("Conservative"/"Balanced"/"Aggressive"), null = handlerns default. */
|
||||
val savedBluetoothTuneProfile: Flow<String?> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey("tuning_profile")] }
|
||||
|
||||
/** openScales utvecklarläge (dumpar GATT-trädet i loggen istället för att mäta). */
|
||||
val developerModeEnabled: Flow<Boolean> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey("developer_mode")] == "true" }
|
||||
|
||||
fun observeSetting(key: String, default: Int): Flow<Int> =
|
||||
context.driverDataStore.data.map { it[intPreferencesKey(key)] ?: default }
|
||||
|
||||
fun observeSetting(key: String, default: String): Flow<String> =
|
||||
context.driverDataStore.data.map { it[stringPreferencesKey(key)] ?: default }
|
||||
|
||||
suspend fun saveSetting(key: String, value: Int) {
|
||||
context.driverDataStore.edit { it[intPreferencesKey(key)] = value }
|
||||
}
|
||||
|
||||
suspend fun saveSetting(key: String, value: String) {
|
||||
context.driverDataStore.edit { it[stringPreferencesKey(key)] = value }
|
||||
}
|
||||
}
|
||||
|
||||
class UserFacade {
|
||||
|
||||
private val selectedUser = MutableStateFlow<User?>(null)
|
||||
|
||||
/** Appen uppdaterar den aktuella användaren (längd/ålder/kön) härifrån. */
|
||||
fun setSelectedUser(user: User?) {
|
||||
selectedUser.value = user
|
||||
}
|
||||
|
||||
fun observeSelectedUser(): StateFlow<User?> = selectedUser
|
||||
|
||||
fun observeAllUsers(): Flow<List<User>> = selectedUser.map { listOfNotNull(it) }
|
||||
|
||||
fun getAllGoalsForUser(userId: Int): Flow<List<Goal>> = flowOf(emptyList())
|
||||
}
|
||||
|
||||
class MeasurementFacade {
|
||||
|
||||
/** Senaste mätningar per användare — används av vissa drivrutiner för användar-igenkänning. */
|
||||
fun getMeasurementsForUser(userId: Int): Flow<List<MeasurementWithValues>> = flowOf(emptyList())
|
||||
|
||||
fun getAllMeasurementTypes(): Flow<List<MeasurementType>> = flowOf(
|
||||
listOf(MeasurementType(MeasurementTypeKey.WEIGHT.id, MeasurementTypeKey.WEIGHT, UnitType.KG))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales MeasurementWithValues (upstream är en
|
||||
* Room-relation). Bara det som bluetooth-adaptrarnas mapMeasurement() läser.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.health.openscale.core.model
|
||||
|
||||
import com.health.openscale.core.data.MeasurementType
|
||||
import com.health.openscale.core.data.MeasurementValue
|
||||
|
||||
data class Measurement(
|
||||
val userId: Int,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
data class ValueWithType(
|
||||
val type: MeasurementType,
|
||||
val value: MeasurementValue,
|
||||
)
|
||||
|
||||
data class MeasurementWithValues(
|
||||
val measurement: Measurement,
|
||||
val values: List<ValueWithType>,
|
||||
)
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* 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.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.SparseArray
|
||||
import com.health.openscale.core.bluetooth.ScaleFactory
|
||||
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.ScanFailure
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import androidx.core.util.isNotEmpty
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Data class to hold information about a scanned Bluetooth LE device.
|
||||
*
|
||||
* @property name The advertised name of the device. Can be null.
|
||||
* @property address The MAC address of the device.
|
||||
* @property rssi The received signal strength indicator (RSSI) in dBm.
|
||||
* @property serviceUuids A list of service UUIDs advertised by the device.
|
||||
* @property manufacturerData Manufacturer-specific data advertised by the device.
|
||||
* @property serviceData Service-data payloads advertised by the device.
|
||||
* @property isSupported Flag indicating whether openScale has a handler for this device.
|
||||
* @property determinedHandlerDisplayName The display name of the handler determined for this device, if any.
|
||||
*/
|
||||
data class ScannedDeviceInfo(
|
||||
var name: String,
|
||||
val address: String,
|
||||
val rssi: Int,
|
||||
val serviceUuids: List<UUID>,
|
||||
val manufacturerData: SparseArray<ByteArray>?,
|
||||
val serviceData: Map<UUID, ByteArray> = emptyMap(),
|
||||
var isSupported: Boolean = false,
|
||||
var determinedHandlerDisplayName: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Manages Bluetooth LE device scanning operations using the Blessed library.
|
||||
*
|
||||
* This class handles starting, stopping, and processing scan results. It exposes
|
||||
* [StateFlow]s for discovered devices, scanning status, and scan errors, allowing
|
||||
* UI components or ViewModels to observe scanning activity.
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param externalScope A [CoroutineScope] (typically from a ViewModel) for launching tasks like scan timeouts.
|
||||
* @param scaleFactory An instance of [ScaleFactory] used to determine device support and handler information.
|
||||
*/
|
||||
class BluetoothScannerManager(
|
||||
private val context: Context,
|
||||
private val externalScope: CoroutineScope,
|
||||
private val scaleFactory: ScaleFactory
|
||||
) {
|
||||
private companion object {
|
||||
const val TAG = "BluetoothScannerMgr"
|
||||
}
|
||||
|
||||
// Ensures Blessed library callbacks are executed on the main thread.
|
||||
private val blessedBluetoothHandler = Handler(Looper.getMainLooper())
|
||||
private val centralManager: BluetoothCentralManager by lazy {
|
||||
BluetoothCentralManager(context, centralManagerCallback, blessedBluetoothHandler)
|
||||
}
|
||||
|
||||
private val _scannedDevices = MutableStateFlow<List<ScannedDeviceInfo>>(emptyList())
|
||||
/**
|
||||
* Emits the current list of discovered and processed [ScannedDeviceInfo] objects.
|
||||
* The list is sorted by support status (supported first), then by RSSI (strongest signal first),
|
||||
* and finally by device name.
|
||||
*/
|
||||
val scannedDevices: StateFlow<List<ScannedDeviceInfo>> = _scannedDevices.asStateFlow()
|
||||
|
||||
private val _isScanning = MutableStateFlow(false)
|
||||
/**
|
||||
* Emits `true` if a Bluetooth LE scan is currently active, `false` otherwise.
|
||||
*/
|
||||
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
|
||||
|
||||
private val _scanError = MutableStateFlow<String?>(null)
|
||||
/**
|
||||
* Emits error messages related to the scanning process.
|
||||
* Emits `null` if there is no current error or an error has been cleared.
|
||||
*/
|
||||
val scanError: StateFlow<String?> = _scanError.asStateFlow()
|
||||
|
||||
private var scanTimeoutJob: Job? = null
|
||||
// Stores unique devices found during a scan, keyed by MAC address, for efficient updates.
|
||||
private val deviceMap = mutableMapOf<String, ScannedDeviceInfo>()
|
||||
|
||||
/**
|
||||
* Starts a Bluetooth LE scan for a specified duration.
|
||||
*
|
||||
* Prerequisites (e.g., Bluetooth enabled, permissions granted) are checked.
|
||||
* If a scan is already in progress, this method returns without action.
|
||||
*
|
||||
* @param scanDurationMs The duration in milliseconds for the scan.
|
||||
* The scan automatically stops after this period if not manually stopped earlier.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // Permissions are expected to be checked by the calling ViewModel.
|
||||
fun startScan(scanDurationMs: Long) {
|
||||
if (!validateScanPrerequisites()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (_isScanning.value || centralManager.isScanning) {
|
||||
LogManager.d(TAG, "Scan is already in progress.")
|
||||
return
|
||||
}
|
||||
LogManager.i(TAG, "Starting device scan for $scanDurationMs ms.")
|
||||
|
||||
deviceMap.clear()
|
||||
_scannedDevices.value = emptyList()
|
||||
_scanError.value = null // Clear previous errors.
|
||||
_isScanning.value = true
|
||||
|
||||
try {
|
||||
centralManager.scanForPeripherals()
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Exception while starting scan: ${e.message}", e)
|
||||
_scanError.value = "Error starting scan: ${e.localizedMessage ?: "Unknown error"}"
|
||||
_isScanning.value = false
|
||||
return
|
||||
}
|
||||
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = externalScope.launch {
|
||||
delay(scanDurationMs.milliseconds)
|
||||
if (_isScanning.value) {
|
||||
LogManager.i(TAG, "Scan timeout reached after $scanDurationMs ms.")
|
||||
stopScanInternal(isTimeout = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the currently active Bluetooth LE scan.
|
||||
*/
|
||||
fun stopScan() {
|
||||
stopScanInternal(isTimeout = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation for stopping the scan.
|
||||
* @param isTimeout Indicates if the stop was triggered by a timeout.
|
||||
*/
|
||||
private fun stopScanInternal(isTimeout: Boolean) {
|
||||
if (!_isScanning.value && !centralManager.isScanning) {
|
||||
return // Scan not active.
|
||||
}
|
||||
LogManager.i(TAG, "Stopping device scan. Triggered by timeout: $isTimeout")
|
||||
scanTimeoutJob?.cancel()
|
||||
scanTimeoutJob = null
|
||||
|
||||
try {
|
||||
if (centralManager.isScanning) {
|
||||
centralManager.stopScan()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Exception while stopping scan: ${e.message}", e)
|
||||
// Optionally, an error could be set here, but it's often not critical for a stop action.
|
||||
}
|
||||
_isScanning.value = false
|
||||
|
||||
if (isTimeout && deviceMap.isEmpty()) {
|
||||
_scanError.value = "No devices found."
|
||||
}
|
||||
LogManager.d(TAG, "Scan stopped. Found devices: ${deviceMap.size}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if conditions are met to start a scan (e.g., Bluetooth enabled).
|
||||
* Note: Permission checks are the responsibility of the calling ViewModel.
|
||||
*
|
||||
* @return `true` if prerequisites are met, `false` otherwise.
|
||||
* If `false`, `_scanError` is updated with the reason.
|
||||
*/
|
||||
private fun validateScanPrerequisites(): Boolean {
|
||||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
|
||||
if (bluetoothManager?.adapter?.isEnabled != true) {
|
||||
LogManager.w(TAG, "Scan prerequisites not met: Bluetooth is disabled.")
|
||||
_scanError.value = "Bluetooth is disabled. Please enable it to scan."
|
||||
return false
|
||||
}
|
||||
|
||||
if (_isScanning.value) {
|
||||
LogManager.d(TAG, "Scan is already in progress (checked in validate).")
|
||||
return false
|
||||
}
|
||||
_scanError.value = null // Clear errors if prerequisites are met.
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any active scan error message from `scanError` StateFlow.
|
||||
*/
|
||||
fun clearScanError() {
|
||||
if (_scanError.value != null) {
|
||||
_scanError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources used by the scanner, including the Blessed [BluetoothCentralManager].
|
||||
* Call this when the scanner is no longer needed (e.g., in ViewModel's `onCleared`).
|
||||
*/
|
||||
fun close() {
|
||||
LogManager.i(TAG, "Closing BluetoothScannerManager.")
|
||||
stopScanInternal(isTimeout = false) // Ensure scan is stopped.
|
||||
try {
|
||||
// Crucial to close BluetoothCentralManager to release system resources
|
||||
// and unregister internal broadcast receivers used by the Blessed library.
|
||||
centralManager.close()
|
||||
LogManager.d(TAG, "Blessed BluetoothCentralManager closed successfully.")
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "Error closing Blessed BluetoothCentralManager: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private val centralManagerCallback = object : BluetoothCentralManagerCallback() {
|
||||
@SuppressLint("MissingPermission") // Permissions are handled before scan initiation.
|
||||
override fun onDiscovered(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
||||
val deviceName = peripheral.name
|
||||
val deviceAddress = peripheral.address
|
||||
val rssi = scanResult.rssi
|
||||
val serviceUuids: List<UUID> = scanResult.scanRecord?.serviceUuids?.mapNotNull { it?.uuid } ?: emptyList()
|
||||
val manufacturerData: SparseArray<ByteArray>? = scanResult.scanRecord?.manufacturerSpecificData
|
||||
val serviceData: Map<UUID, ByteArray> = scanResult.scanRecord?.serviceData
|
||||
?.mapKeys { it.key.uuid }
|
||||
?.mapValues { it.value.copyOf() }
|
||||
?: emptyMap()
|
||||
|
||||
val newDevice = ScannedDeviceInfo(
|
||||
name = deviceName,
|
||||
address = deviceAddress,
|
||||
rssi = rssi,
|
||||
serviceUuids = serviceUuids,
|
||||
manufacturerData = manufacturerData,
|
||||
serviceData = serviceData,
|
||||
isSupported = false, // will be determined in the next getSupportingHandlerInfo
|
||||
determinedHandlerDisplayName = null // // will be determined in the next getSupportingHandlerInfo
|
||||
)
|
||||
|
||||
val (isSupported, handlerName) = scaleFactory.getSupportingHandlerInfo(newDevice)
|
||||
|
||||
newDevice.isSupported = isSupported
|
||||
newDevice.determinedHandlerDisplayName = handlerName
|
||||
|
||||
val existingDevice = deviceMap[newDevice.address]
|
||||
var listShouldBeUpdated = false
|
||||
|
||||
if (existingDevice != null) {
|
||||
// Update criteria: if RSSI changed, or if key device info (name, support, handler, services, manufacturer data) has improved or changed.
|
||||
val nameChangedToKnown = newDevice.name.isNotEmpty() && existingDevice.name.isEmpty()
|
||||
val supportStatusImproved = !existingDevice.isSupported && newDevice.isSupported
|
||||
val handlerChanged = newDevice.determinedHandlerDisplayName != existingDevice.determinedHandlerDisplayName
|
||||
val serviceUuidsUpdated = newDevice.serviceUuids.isNotEmpty() && newDevice.serviceUuids != existingDevice.serviceUuids
|
||||
val manuDataUpdated = newDevice.manufacturerData != null && !newDevice.manufacturerData.contentEquals(existingDevice.manufacturerData)
|
||||
val serviceDataUpdated = newDevice.serviceData.isNotEmpty() && !newDevice.serviceData.contentEquals(existingDevice.serviceData)
|
||||
|
||||
if (newDevice.rssi != existingDevice.rssi || nameChangedToKnown || supportStatusImproved || handlerChanged || serviceUuidsUpdated || manuDataUpdated || serviceDataUpdated) {
|
||||
deviceMap[newDevice.address] = existingDevice.copy(
|
||||
name = newDevice.name.ifEmpty { existingDevice.name }, // Prefer new name if available.
|
||||
rssi = newDevice.rssi,
|
||||
isSupported = existingDevice.isSupported || newDevice.isSupported, // Retain 'supported' status if ever true.
|
||||
determinedHandlerDisplayName = newDevice.determinedHandlerDisplayName ?: existingDevice.determinedHandlerDisplayName,
|
||||
serviceUuids = if (newDevice.serviceUuids.isNotEmpty()) newDevice.serviceUuids else existingDevice.serviceUuids,
|
||||
manufacturerData = newDevice.manufacturerData ?: existingDevice.manufacturerData,
|
||||
serviceData = newDevice.serviceData.ifEmpty { existingDevice.serviceData }
|
||||
)
|
||||
listShouldBeUpdated = true
|
||||
}
|
||||
} else {
|
||||
// Add new device if it's supported, or has a meaningful name, or provides service/manufacturer data.
|
||||
// This avoids populating the list with devices that have no identifying information and are not supported.
|
||||
if (newDevice.isSupported ||
|
||||
newDevice.name.isNotEmpty() ||
|
||||
newDevice.serviceUuids.isNotEmpty() ||
|
||||
(newDevice.manufacturerData != null && newDevice.manufacturerData.isNotEmpty()) ||
|
||||
newDevice.serviceData.isNotEmpty()
|
||||
) {
|
||||
deviceMap[newDevice.address] = newDevice
|
||||
listShouldBeUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (listShouldBeUpdated) {
|
||||
// Filter ensures only devices that are supported or have a meaningful name (not generic "Unknown Device") are emitted.
|
||||
// Sorting provides a consistent and user-friendly order.
|
||||
_scannedDevices.value = deviceMap.values
|
||||
.filter { it.isSupported || (it.name.isNotEmpty() && it.name != "Unbekanntes Gerät" && it.name != "Unknown Device") }
|
||||
.sortedWith(compareByDescending<ScannedDeviceInfo> { it.isSupported }
|
||||
.thenByDescending { it.rssi }
|
||||
.thenBy { it.name.ifEmpty { "zzzz" }.lowercase() }) // "zzzz" ensures unnamed devices sort last.
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(scanFailure: ScanFailure) {
|
||||
LogManager.e(TAG, "Bluetooth scan failed: $scanFailure")
|
||||
externalScope.launch {
|
||||
_scanError.value = "Bluetooth Scan Failed: $scanFailure"
|
||||
_isScanning.value = false
|
||||
scanTimeoutJob?.cancel() // Stop scan timeout if scan fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun SparseArray<ByteArray>?.contentEquals(other: SparseArray<ByteArray>?): Boolean {
|
||||
if (this == null || other == null) return this == other
|
||||
if (size() != other.size()) return false
|
||||
for (i in 0 until size()) {
|
||||
val key = keyAt(i)
|
||||
val otherIndex = other.indexOfKey(key)
|
||||
if (otherIndex < 0) return false
|
||||
if (!valueAt(i).contentEquals(other.valueAt(otherIndex))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun Map<UUID, ByteArray>.contentEquals(other: Map<UUID, ByteArray>): Boolean {
|
||||
if (size != other.size) return false
|
||||
for ((key, value) in this) {
|
||||
val otherValue = other[key] ?: return false
|
||||
if (!value.contentEquals(otherValue)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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.utils
|
||||
|
||||
import com.health.openscale.core.data.MeasureUnit
|
||||
import com.health.openscale.core.data.UnitType
|
||||
import com.health.openscale.core.data.WeightUnit
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
object ConverterUtils {
|
||||
private const val KG_LB: Float = 2.20462f
|
||||
private const val KG_ST: Float = 0.157473f
|
||||
private const val CM_IN: Float = 0.393701f
|
||||
|
||||
private const val LB_PER_ST_DOUBLE: Double = 14.0
|
||||
|
||||
@JvmStatic
|
||||
fun toKilogram(value: Float, unit: WeightUnit): Float {
|
||||
when (unit) {
|
||||
WeightUnit.LB -> return value / KG_LB
|
||||
WeightUnit.ST -> return value / KG_ST
|
||||
WeightUnit.KG -> return value
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun fromKilogram(kg: Float, unit: WeightUnit): Float {
|
||||
when (unit) {
|
||||
WeightUnit.LB -> return kg * KG_LB
|
||||
WeightUnit.ST -> return kg * KG_ST
|
||||
WeightUnit.KG -> return kg
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun toCentimeter(value: Float, unit: MeasureUnit): Float {
|
||||
when (unit) {
|
||||
MeasureUnit.INCH -> return value / CM_IN
|
||||
MeasureUnit.CM -> return value
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun fromCentimeter(cm: Float, unit: MeasureUnit): Float {
|
||||
when (unit) {
|
||||
MeasureUnit.INCH -> return cm * CM_IN
|
||||
MeasureUnit.CM -> return cm
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun decimalStToStLb(stDec: Double): Pair<Int, Int> {
|
||||
val totalLb = stDec * LB_PER_ST_DOUBLE
|
||||
var st = floor(totalLb / LB_PER_ST_DOUBLE).toInt()
|
||||
var lb = (totalLb - st * LB_PER_ST_DOUBLE).roundToInt()
|
||||
if (lb == 14) { st += 1; lb = 0 } // normalize carry
|
||||
return st to lb
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun stLbToStDecimal(st: Int, lb: Int): Double =
|
||||
st + (lb / LB_PER_ST_DOUBLE)
|
||||
|
||||
@JvmStatic
|
||||
fun fromSignedInt16Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 1].toInt() shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt16Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 8
|
||||
value += data[offset + 1].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt16Le(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt16Le(data, offset) and 0xFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt16Be(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt16Be(data, offset) and 0xFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Le(data: ByteArray, offset: Int, value: Int) {
|
||||
data[offset + 0] = (value and 0xFF).toByte()
|
||||
data[offset + 1] = ((value shr 8) and 0xFF).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Be(data: ByteArray, offset: Int, value: Int) {
|
||||
data[offset + 0] = ((value shr 8) and 0xFF).toByte()
|
||||
data[offset + 1] = (value and 0xFF).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Le(value: Int): ByteArray {
|
||||
val data = ByteArray(2)
|
||||
toInt16Le(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt16Be(value: Int): ByteArray {
|
||||
val data = ByteArray(2)
|
||||
toInt16Be(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt24Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 2].toInt() shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt24Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset + 2].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt24Le(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt24Le(data, offset) and 0xFFFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt24Be(data: ByteArray, offset: Int): Int {
|
||||
return fromSignedInt24Be(data, offset) and 0xFFFFFF
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt32Le(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset + 3].toInt() shl 24
|
||||
value += (data[offset + 2].toInt() and 0xFF) shl 16
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 8
|
||||
value += data[offset].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromSignedInt32Be(data: ByteArray, offset: Int): Int {
|
||||
var value = data[offset].toInt() shl 24
|
||||
value += (data[offset + 1].toInt() and 0xFF) shl 16
|
||||
value += (data[offset + 2].toInt() and 0xFF) shl 8
|
||||
value += data[offset + 3].toInt() and 0xFF
|
||||
return value
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt32Le(data: ByteArray, offset: Int): Long {
|
||||
return fromSignedInt32Le(data, offset).toLong() and 0xFFFFFFFFL
|
||||
}
|
||||
@JvmStatic
|
||||
fun fromUnsignedInt32Be(data: ByteArray, offset: Int): Long {
|
||||
return fromSignedInt32Be(data, offset).toLong() and 0xFFFFFFFFL
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Le(data: ByteArray, offset: Int, value: Long) {
|
||||
data[offset + 3] = ((value shr 24) and 0xFFL).toByte()
|
||||
data[offset + 2] = ((value shr 16) and 0xFFL).toByte()
|
||||
data[offset + 1] = ((value shr 8) and 0xFFL).toByte()
|
||||
data[offset + 0] = (value and 0xFFL).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Be(data: ByteArray, offset: Int, value: Long) {
|
||||
data[offset + 0] = ((value shr 24) and 0xFFL).toByte()
|
||||
data[offset + 1] = ((value shr 16) and 0xFFL).toByte()
|
||||
data[offset + 2] = ((value shr 8) and 0xFFL).toByte()
|
||||
data[offset + 3] = (value and 0xFFL).toByte()
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Le(value: Long): ByteArray {
|
||||
val data = ByteArray(4)
|
||||
toInt32Le(data, 0, value)
|
||||
return data
|
||||
}
|
||||
@JvmStatic
|
||||
fun toInt32Be(value: Long): ByteArray {
|
||||
val data = ByteArray(4)
|
||||
toInt32Be(data, 0, value)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Float value from one UnitType to another, if a conversion is defined.
|
||||
* Returns the original value if no conversion is applicable or units are the same.
|
||||
*
|
||||
* @param value The float value to convert.
|
||||
* @param fromUnit The original UnitType of the value.
|
||||
* @param toUnit The target UnitType for the value.
|
||||
* @return The converted float value, or the original value if no conversion is done.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun convertFloatValueUnit(value: Float, fromUnit: UnitType, toUnit: UnitType): Float {
|
||||
if (fromUnit == toUnit) return value
|
||||
|
||||
// KG -> Andere Gewichtseinheiten
|
||||
if (fromUnit == UnitType.KG) {
|
||||
return when (toUnit) {
|
||||
UnitType.LB -> fromKilogram(value, WeightUnit.LB)
|
||||
UnitType.ST -> fromKilogram(value, WeightUnit.ST)
|
||||
else -> value // Keine Umrechnung zu anderen Typen von KG aus
|
||||
}
|
||||
}
|
||||
// LB -> Andere Gewichtseinheiten (erst zu KG, dann zum Ziel)
|
||||
if (fromUnit == UnitType.LB) {
|
||||
val kgValue = toKilogram(value, WeightUnit.LB)
|
||||
return when (toUnit) {
|
||||
UnitType.KG -> kgValue
|
||||
UnitType.ST -> fromKilogram(kgValue, WeightUnit.ST)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
// ST -> Andere Gewichtseinheiten (erst zu KG, dann zum Ziel)
|
||||
if (fromUnit == UnitType.ST) {
|
||||
val kgValue = toKilogram(value, WeightUnit.ST)
|
||||
return when (toUnit) {
|
||||
UnitType.KG -> kgValue
|
||||
UnitType.LB -> fromKilogram(kgValue, WeightUnit.LB)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
// CM -> Andere Längeneinheiten
|
||||
if (fromUnit == UnitType.CM) {
|
||||
return when (toUnit) {
|
||||
UnitType.INCH -> fromCentimeter(value, MeasureUnit.INCH)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
if (fromUnit == UnitType.INCH) {
|
||||
val cmValue = toCentimeter(value, MeasureUnit.INCH)
|
||||
return when (toUnit) {
|
||||
UnitType.CM -> cmValue
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all non-digit characters from [input] and truncates the result to [maxLen] characters.
|
||||
*
|
||||
* @param input The raw string to sanitize.
|
||||
* @param maxLen The maximum number of digit characters to retain.
|
||||
* @return A string containing only digit characters, at most [maxLen] characters long.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun sanitizeDigits(input: String, maxLen: Int): String =
|
||||
input.filter { it.isDigit() }.take(maxLen)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* FitnessDroid-shim för openScales LogManager.
|
||||
* Upstream har filloggning m.m. — här räcker Androids vanliga logcat.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.health.openscale.core.utils
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object LogManager {
|
||||
fun d(tag: String, msg: String) { Log.d(tag, msg) }
|
||||
fun i(tag: String, msg: String) { Log.i(tag, msg) }
|
||||
fun w(tag: String, msg: String, t: Throwable? = null) { Log.w(tag, msg, t) }
|
||||
fun e(tag: String, msg: String, t: Throwable? = null) { Log.e(tag, msg, t) }
|
||||
}
|
||||
@@ -1,4 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">FitnessDroid</string>
|
||||
|
||||
<!-- Strängar som de vendrade openScale-drivrutinerna refererar (svenska motsvarigheter
|
||||
till upstreams engelska; formatargumenten måste matcha upstream). -->
|
||||
<string name="bt_error_delivery_user_feedback">Kunde inte leverera användarsvar: %1$s</string>
|
||||
<string name="bt_error_generic">Fel</string>
|
||||
<string name="bt_error_handler_connect_failed">Drivrutinen misslyckades vid anslutning: %1$s</string>
|
||||
<string name="bt_error_handler_parse_error">Drivrutinen kunde inte tolka %1$s: %2$s</string>
|
||||
<string name="bt_error_no_bluetooth_adapter">Ingen Bluetooth-adapter hittades på enheten.</string>
|
||||
<string name="bt_error_no_device_found">Vågen hittades inte. Kontrollera att den är på och inom räckhåll.</string>
|
||||
<string name="bt_error_no_user_selected">Ingen användare vald</string>
|
||||
<string name="bt_info_reconnecting_try">Återansluter… (försök %1$d/%2$d)</string>
|
||||
<string name="bt_info_step_on_scale">Ställ dig barfota på vågen</string>
|
||||
<string name="bt_info_waiting_for_measurement">Väntar på mätning…</string>
|
||||
<string name="bt_warn_notify_failed">Kunde inte aktivera notifieringar för %1$s.</string>
|
||||
<string name="bt_warn_write_failed_status">Skrivning till %1$s misslyckades: %2$s</string>
|
||||
<string name="cap_battery">Batteri</string>
|
||||
<string name="cap_body_composition">Kroppssammansättning</string>
|
||||
<string name="cap_history_read">Historikläsning</string>
|
||||
<string name="cap_live_weight">Livevikt</string>
|
||||
<string name="cap_time_sync">Tidssynk</string>
|
||||
<string name="cap_unit_config">Enhetsval</string>
|
||||
<string name="cap_user_sync">Användarsynk</string>
|
||||
<string name="no_special_configuration_available">Ingen extra konfiguration finns för den här vågen.</string>
|
||||
<string name="tuning_aggressive">Aggressiv</string>
|
||||
<string name="tuning_balanced">Balanserad</string>
|
||||
<string name="tuning_conservative">Försiktig</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user