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:
2026-08-23 11:11:38 +02:00
parent efebcb111f
commit 261755794b
24 changed files with 4411 additions and 3 deletions

View File

@@ -80,6 +80,7 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.okhttp)
implementation(libs.blessed.kotlin)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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?,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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