mirror of
https://github.com/oliexdev/openScale.git
synced 2025-09-10 00:20:40 +02:00
Introduce LinkMode and broadcast-only support for ModernScaleAdapter, allowing handlers to declare whether they communicate via GATT or by parsing advertisement data only.
This commit is contained in:
@@ -24,7 +24,12 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Defines the events that can be emitted by a [ScaleCommunicator].
|
||||
* 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 and [isConnected] stays `false`.
|
||||
*/
|
||||
sealed class BluetoothEvent {
|
||||
enum class UserInteractionType {
|
||||
@@ -32,60 +37,34 @@ sealed class BluetoothEvent {
|
||||
ENTER_CONSENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when a connection to a device has been successfully established.
|
||||
* @param deviceName The name of the connected device.
|
||||
* @param deviceAddress The MAC address of the connected device.
|
||||
*/
|
||||
/** 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()
|
||||
|
||||
/**
|
||||
* Event triggered when an existing connection to a device has been disconnected.
|
||||
* @param deviceAddress The MAC address of the disconnected device.
|
||||
* @param reason An optional reason for the disconnection (e.g., "Connection lost", "Manually disconnected").
|
||||
*/
|
||||
/** Emitted when an existing GATT connection has been disconnected. */
|
||||
data class Disconnected(val deviceAddress: String, val reason: String? = null) : BluetoothEvent()
|
||||
|
||||
/**
|
||||
* Event triggered when a connection attempt to a device has failed.
|
||||
* @param deviceAddress The MAC address of the device to which the connection failed.
|
||||
* @param error An error message describing the reason for the failure.
|
||||
*/
|
||||
/** Emitted when a connection attempt to a device failed. */
|
||||
data class ConnectionFailed(val deviceAddress: String, val error: String) : BluetoothEvent()
|
||||
|
||||
/**
|
||||
* Event triggered when measurement data has been received from the scale.
|
||||
* Uses [ScaleMeasurement] as the common data format.
|
||||
* @param measurement The received [ScaleMeasurement] object.
|
||||
* @param deviceAddress The MAC address of the device from which the measurement originated.
|
||||
*/
|
||||
/** Emitted when a parsed measurement is available. */
|
||||
data class MeasurementReceived(
|
||||
val measurement: ScaleMeasurement,
|
||||
val deviceAddress: String
|
||||
) : BluetoothEvent()
|
||||
|
||||
/**
|
||||
* Event triggered when a general error related to a device occurs.
|
||||
* @param deviceAddress The MAC address of the device associated with the error.
|
||||
* @param error An error message describing the issue.
|
||||
*/
|
||||
/** Emitted for generic device-related errors. */
|
||||
data class Error(val deviceAddress: String, val error: String) : BluetoothEvent()
|
||||
|
||||
/**
|
||||
* Event triggered when a text message (e.g., status or instruction) is received from the device.
|
||||
* @param message The received message.
|
||||
* @param deviceAddress The MAC address of the device from which the message originated.
|
||||
*/
|
||||
/** Emitted for miscellaneous device/user-visible messages. */
|
||||
data class DeviceMessage(val message: String, val deviceAddress: String) : BluetoothEvent()
|
||||
|
||||
/**
|
||||
* Event triggered when user interaction is required to select a user on the scale.
|
||||
* This is often used when a scale supports multiple users and the app needs to clarify
|
||||
* which app user corresponds to the scale user.
|
||||
* @param deviceIdentifier The identifier (e.g., MAC address) of the device requiring user selection.
|
||||
* @param data Optional data associated with the event, potentially containing information about users on the scale.
|
||||
* The exact type should be defined by the communicator implementation if more specific data is available.
|
||||
*/
|
||||
/** Emitted when user interaction is required (e.g., pick user, enter consent code). */
|
||||
data class UserInteractionRequired(
|
||||
val deviceIdentifier: String,
|
||||
val data: Any?,
|
||||
@@ -94,52 +73,30 @@ sealed class BluetoothEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic interface for communication with a Bluetooth scale.
|
||||
* This interface abstracts the specific Bluetooth implementation (e.g., legacy Bluetooth or BLE).
|
||||
* A generic interface for communicating with Bluetooth scales.
|
||||
* Implementations may be GATT-based or broadcast-only (advertisement parsing).
|
||||
*/
|
||||
interface ScaleCommunicator {
|
||||
|
||||
/**
|
||||
* A [StateFlow] indicating whether a connection attempt to a device is currently in progress.
|
||||
* `true` if a connection attempt is active, `false` otherwise.
|
||||
*/
|
||||
/** Indicates whether a connection attempt (or scan for broadcast devices) is in progress. */
|
||||
val isConnecting: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* A [StateFlow] indicating whether an active connection to a device currently exists.
|
||||
* `true` if connected, `false` otherwise.
|
||||
*/
|
||||
/** Indicates whether a GATT connection is active. For broadcast-only devices this is always `false`. */
|
||||
val isConnected: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Initiates a connection attempt to the device with the specified MAC address.
|
||||
* @param address The MAC address of the target device.
|
||||
* @param scaleUser The user to be selected or used on the scale (optional).
|
||||
*/
|
||||
/** Start communicating with a device identified by [address]. Binds the session to [scaleUser]. */
|
||||
fun connect(address: String, scaleUser: ScaleUser?)
|
||||
|
||||
/**
|
||||
* Disconnects the existing connection to the currently connected device.
|
||||
*/
|
||||
/** Terminate the current session (disconnect or stop scanning). */
|
||||
fun disconnect()
|
||||
|
||||
/**
|
||||
* Explicitly requests a new measurement from the connected device.
|
||||
* (Note: Not always supported or required by all scale devices).
|
||||
*/
|
||||
/** Request a measurement (if supported; some devices only push asynchronously). */
|
||||
fun requestMeasurement()
|
||||
|
||||
/**
|
||||
* Provides a [SharedFlow] that emits [BluetoothEvent]s.
|
||||
* Consumers can collect events from this flow to react to connection changes,
|
||||
* received measurements, errors, and other device-related events.
|
||||
* @return A [SharedFlow] of [BluetoothEvent]s.
|
||||
*/
|
||||
/** Stream of [BluetoothEvent] emitted by the communicator. */
|
||||
fun getEventsFlow(): SharedFlow<BluetoothEvent>
|
||||
|
||||
/**
|
||||
* Processes feedback received from the user for a previously requested interaction.
|
||||
*/
|
||||
/** Deliver feedback for a previously requested user interaction. */
|
||||
fun processUserInteractionFeedback(
|
||||
interactionType: BluetoothEvent.UserInteractionType,
|
||||
appUserId: Int,
|
||||
|
@@ -59,7 +59,8 @@ import com.health.openscale.core.bluetooth.legacy.LegacyScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.modern.DeviceSupport
|
||||
import com.health.openscale.core.bluetooth.modern.ModernScaleAdapter
|
||||
import com.health.openscale.core.bluetooth.modern.StandardWeightProfileHandler
|
||||
import com.health.openscale.core.bluetooth.modern.YunmaiDeviceHandler
|
||||
import com.health.openscale.core.bluetooth.modern.Yoda1Handler
|
||||
import com.health.openscale.core.bluetooth.modern.YunmaiHandler
|
||||
import com.health.openscale.core.facade.SettingsFacade
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
@@ -83,8 +84,9 @@ class ScaleFactory @Inject constructor(
|
||||
// List of modern Kotlin-based device handlers.
|
||||
// These are checked first for device compatibility.
|
||||
private val modernKotlinHandlers: List<ScaleDeviceHandler> = listOf(
|
||||
YunmaiDeviceHandler(isMini = false),
|
||||
YunmaiDeviceHandler(isMini = true),
|
||||
YunmaiHandler(isMini = false),
|
||||
YunmaiHandler(isMini = true),
|
||||
Yoda1Handler(),
|
||||
StandardWeightProfileHandler()
|
||||
)
|
||||
|
||||
|
@@ -1,7 +1,19 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 ...
|
||||
* GPLv3+
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
@@ -18,6 +30,7 @@ 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.facade.SettingsFacade
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.LogManager
|
||||
import com.welie.blessed.*
|
||||
import com.welie.blessed.WriteType.WITHOUT_RESPONSE
|
||||
@@ -178,41 +191,15 @@ class FacadeDriverSettings(
|
||||
}
|
||||
|
||||
/**
|
||||
* ## ModernScaleAdapter
|
||||
* ModernScaleAdapter
|
||||
* ------------------
|
||||
* Bridges a device-specific [ScaleDeviceHandler] to the Blessed BLE stack. It owns scan/connect
|
||||
* lifecycle, handles service discovery and IO pacing, and surfaces events to the app via
|
||||
* [BluetoothEvent].
|
||||
*
|
||||
* A small “bridge” that wires a device-specific [ScaleDeviceHandler] to the BLE stack
|
||||
* via the Blessed library. The adapter owns the Bluetooth session (scan → connect →
|
||||
* service discovery → notifications/writes) and exposes a clean, event-driven API to
|
||||
* the rest of the app through [ScaleCommunicator].
|
||||
*
|
||||
* ### What handler authors need to know
|
||||
*
|
||||
* - **You do NOT manage Bluetooth directly.** Implement a subclass of [ScaleDeviceHandler]
|
||||
* and use its protected helpers:
|
||||
* - `setNotifyOn(service, characteristic)` to subscribe.
|
||||
* - `writeTo(service, characteristic, payload, withResponse)` to send commands.
|
||||
* - `readFrom(service, characteristic)` if the device requires reads.
|
||||
* - `publish(measurement)` when you have a complete result.
|
||||
* - `userInfo("…")` to show human-readable messages (e.g., “Step on the scale…”).
|
||||
*
|
||||
* - **Lifecycle in your handler:**
|
||||
* - `onConnected(user)` is called after services are discovered and the link is ready.
|
||||
* Enable notifications here and send your init sequence (user/time/unit/etc.).
|
||||
* - `onNotification(uuid, data, user)` is called for each incoming notify packet.
|
||||
* Parse, update state, and call `publish()` for final results.
|
||||
* - `onDisconnected()` is called for cleanup.
|
||||
*
|
||||
* - **Threading/timing is handled for you.** The adapter serializes BLE I/O with a `Mutex`
|
||||
* and adds small delays between operations to avoid “GATT 133” and write errors.
|
||||
* Don’t add your own blocking sleeps inside the handler; just call the helpers.
|
||||
*
|
||||
* - **Events to the app:** The adapter surfaces connection state and measurements through
|
||||
* a `SharedFlow<BluetoothEvent>`. You don’t emit those directly—use the handler helpers.
|
||||
*
|
||||
* ### Responsibilities split
|
||||
* - Adapter: scans, connects, discovers services, sets MTU/priority, enforces pacing,
|
||||
* funnels notifications/writes, and reports connection events.
|
||||
* - Handler: pure protocol logic for one vendor/model (frame formats, checksums, etc.).
|
||||
* This version additionally supports **broadcast-only** scales (no GATT, advertisement parsing).
|
||||
* For those, the adapter scans, forwards advertisements to the handler, and emits
|
||||
* `BluetoothEvent.Listening` / `BluetoothEvent.BroadcastComplete` around the flow.
|
||||
*/
|
||||
class ModernScaleAdapter(
|
||||
private val context: Context,
|
||||
@@ -223,16 +210,23 @@ class ModernScaleAdapter(
|
||||
|
||||
private val TAG = "ModernScaleAdapter"
|
||||
|
||||
// Active timing profile (defaults to Balanced)
|
||||
private val tuning: BleTuning = bleTuning ?: BleTuningProfile.Balanced.asTuning()
|
||||
private var broadcastAttached = false
|
||||
|
||||
// Coroutine + Android handlers
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Blessed central manager (lazy)
|
||||
private lateinit var central: BluetoothCentralManager
|
||||
|
||||
// Session state
|
||||
private var targetAddress: String? = null
|
||||
private var currentPeripheral: BluetoothPeripheral? = null
|
||||
private var currentUser: ScaleUser? = null
|
||||
|
||||
// Session tracking to correlate logs across reconnects.
|
||||
// Session tracking for log correlation
|
||||
private var sessionCounter = 0
|
||||
private var sessionId = 0
|
||||
private fun newSession() {
|
||||
@@ -240,7 +234,7 @@ class ModernScaleAdapter(
|
||||
LogManager.d(TAG, "session#$sessionId start for $targetAddress")
|
||||
}
|
||||
|
||||
// --- I/O pacing (currently internal defaults; see BleTuning above) --------
|
||||
// IO pacing (small gaps reduce GATT 133 and write failures)
|
||||
private val ioMutex = Mutex()
|
||||
private suspend fun ioGap(ms: Long) { if (ms > 0) delay(ms) }
|
||||
private val GAP_BEFORE_NOTIFY = tuning.notifySetupDelayMs
|
||||
@@ -248,7 +242,7 @@ class ModernScaleAdapter(
|
||||
private val GAP_BEFORE_WRITE_NO_RESP = tuning.writeWithoutResponseDelayMs
|
||||
private val GAP_AFTER_WRITE = tuning.postWriteDelayMs
|
||||
|
||||
// --- Reconnect smoothing / retry (internal defaults) ----------------------
|
||||
// Reconnect smoothing / retry
|
||||
private var lastDisconnectAtMs: Long = 0L
|
||||
private var connectAttempts: Int = 0
|
||||
private val RECONNECT_COOLDOWN_MS = tuning.reconnectCooldownMs
|
||||
@@ -257,7 +251,7 @@ class ModernScaleAdapter(
|
||||
|
||||
private val connectAfterScanDelayMs = tuning.connectAfterScanDelayMs
|
||||
|
||||
// --- Public streams exposed to the app -----------------------------------
|
||||
// Public streams exposed to the app
|
||||
private val _events = MutableSharedFlow<BluetoothEvent>(replay = 1, extraBufferCapacity = 8)
|
||||
override fun getEventsFlow(): SharedFlow<BluetoothEvent> = _events.asSharedFlow()
|
||||
|
||||
@@ -267,17 +261,59 @@ class ModernScaleAdapter(
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
// ---- Blessed callbacks: discovery / link state ---------------------------
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Blessed callbacks: central / peripheral
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
private val centralCallback = object : BluetoothCentralManagerCallback() {
|
||||
override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: android.bluetooth.le.ScanResult) {
|
||||
if (peripheral.address == targetAddress) {
|
||||
LogManager.i(TAG, "session#$sessionId Found $targetAddress → stop scan + connect")
|
||||
central.stopScan()
|
||||
scope.launch {
|
||||
if (connectAfterScanDelayMs > 0) delay(connectAfterScanDelayMs)
|
||||
central.connectPeripheral(peripheral, peripheralCallback)
|
||||
override fun onDiscoveredPeripheral(
|
||||
peripheral: BluetoothPeripheral,
|
||||
scanResult: android.bluetooth.le.ScanResult
|
||||
) {
|
||||
if (peripheral.address != targetAddress) return
|
||||
|
||||
val linkMode = resolveLinkModeFor(peripheral, scanResult)
|
||||
val isBroadcast = linkMode != LinkMode.CONNECT_GATT
|
||||
|
||||
if (isBroadcast) {
|
||||
// Attach lazily on first broadcast frame
|
||||
if (!broadcastAttached) {
|
||||
val driverSettings = FacadeDriverSettings(
|
||||
facade = settingsFacade,
|
||||
scope = scope,
|
||||
deviceAddress = peripheral.address,
|
||||
handlerNamespace = handler::class.simpleName ?: "Handler"
|
||||
)
|
||||
handler.attach(noopTransport, appCallbacks, driverSettings)
|
||||
broadcastAttached = true
|
||||
}
|
||||
|
||||
when (handler.onAdvertisement(scanResult, currentUser ?: return)) {
|
||||
BroadcastAction.IGNORED -> Unit
|
||||
BroadcastAction.CONSUMED_KEEP_SCANNING -> {
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.DeviceMessage(
|
||||
context.getString(R.string.bt_info_waiting_for_measurement),
|
||||
peripheral.address
|
||||
)
|
||||
)
|
||||
}
|
||||
BroadcastAction.CONSUMED_STOP -> {
|
||||
_events.tryEmit(BluetoothEvent.BroadcastComplete(peripheral.address))
|
||||
stopScanInternal()
|
||||
cleanup(peripheral.address)
|
||||
broadcastAttached = false
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GATT path: stop scan + connect
|
||||
LogManager.i(TAG, "session#$sessionId Found $targetAddress → stop scan + connect")
|
||||
central.stopScan()
|
||||
scope.launch {
|
||||
if (connectAfterScanDelayMs > 0) delay(connectAfterScanDelayMs)
|
||||
central.connectPeripheral(peripheral, peripheralCallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,9 +414,8 @@ class ModernScaleAdapter(
|
||||
characteristic: android.bluetooth.BluetoothGattCharacteristic,
|
||||
status: GattStatus
|
||||
) {
|
||||
if (status == GattStatus.SUCCESS) {
|
||||
LogManager.d(TAG, "session#$sessionId Write OK ${characteristic.uuid}")
|
||||
} else {
|
||||
// Only warn on failure; success is noisy if logged for every packet
|
||||
if (status != GattStatus.SUCCESS) {
|
||||
appCallbacks.onWarn(
|
||||
R.string.bt_warn_write_failed_status,
|
||||
characteristic.uuid.toString(),
|
||||
@@ -410,8 +445,11 @@ class ModernScaleAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Transport given to ScaleDeviceHandler (serialized + paced) ----------
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Transport for handlers
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
// Full transport used by GATT devices
|
||||
private val transportImpl = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) {
|
||||
scope.launch {
|
||||
@@ -444,12 +482,12 @@ class ModernScaleAdapter(
|
||||
return@withLock
|
||||
}
|
||||
val ch = p.getCharacteristic(service, characteristic)
|
||||
val type = if (withResponse) WITH_RESPONSE else WITHOUT_RESPONSE
|
||||
if (ch == null) {
|
||||
appCallbacks.onWarn(R.string.bt_warn_characteristic_not_found, characteristic.toString())
|
||||
return@withLock
|
||||
}
|
||||
val type = if (withResponse) WITH_RESPONSE else WITHOUT_RESPONSE
|
||||
LogManager.d(TAG, "session#$sessionId Write chr=$characteristic len=${payload.size} type=$type (props=${propsPretty(ch?.properties ?: 0)})")
|
||||
LogManager.d(TAG, "session#$sessionId Write chr=$characteristic len=${payload.size} type=$type (props=${propsPretty(ch.properties)})")
|
||||
ioGap(if (withResponse) GAP_BEFORE_WRITE_WITH_RESP else GAP_BEFORE_WRITE_NO_RESP)
|
||||
p.writeCharacteristic(service, characteristic, payload, type)
|
||||
ioGap(GAP_AFTER_WRITE)
|
||||
@@ -476,7 +514,17 @@ class ModernScaleAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- App callbacks handed to ScaleDeviceHandler --------------------------
|
||||
// No-op transport used for broadcast-only devices (no GATT operations)
|
||||
private val noopTransport = object : ScaleDeviceHandler.Transport {
|
||||
override fun setNotifyOn(service: UUID, characteristic: UUID) { /* no-op */ }
|
||||
override fun write(service: UUID, characteristic: UUID, payload: ByteArray, withResponse: Boolean) { /* no-op */ }
|
||||
override fun read(service: UUID, characteristic: UUID) { /* no-op */ }
|
||||
override fun disconnect() { stopScanInternal() }
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// App callbacks
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
private val appCallbacks = object : ScaleDeviceHandler.Callbacks {
|
||||
override fun onPublish(measurement: ScaleMeasurement) {
|
||||
@@ -515,12 +563,14 @@ class ModernScaleAdapter(
|
||||
context.getString(resId, *args)
|
||||
}
|
||||
|
||||
// ---- ScaleCommunicator API (used by the app layer) -----------------------
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// ScaleCommunicator API
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start a BLE session to the given MAC and bind the session to [scaleUser].
|
||||
* The adapter scans for the exact address, connects when found, and then
|
||||
* delegates protocol work to the bound [ScaleDeviceHandler].
|
||||
* For broadcast-only devices, we attach the handler immediately with a no-op transport,
|
||||
* emit a Listening event, and start scanning.
|
||||
*/
|
||||
override fun connect(address: String, scaleUser: ScaleUser?) {
|
||||
scope.launch {
|
||||
@@ -540,7 +590,7 @@ class ModernScaleAdapter(
|
||||
}
|
||||
ensureCentral()
|
||||
|
||||
// Cool down between reconnects to avoid Android stack churn.
|
||||
// Cooldown between reconnects to avoid Android stack churn
|
||||
val since = SystemClock.elapsedRealtime() - lastDisconnectAtMs
|
||||
if (since in 1 until RECONNECT_COOLDOWN_MS) {
|
||||
val wait = RECONNECT_COOLDOWN_MS - since
|
||||
@@ -559,7 +609,32 @@ class ModernScaleAdapter(
|
||||
_isConnected.value = false
|
||||
newSession()
|
||||
|
||||
try { central.stopScan() } catch (_: Exception) { /* ignore */ }
|
||||
// Stop any previous scan before starting a new one
|
||||
runCatching { central.stopScan() }
|
||||
|
||||
// Hint only: final decision still happens in onDiscoveredPeripheral via resolveLinkModeFor(...)
|
||||
val isBroadcastPreferred = handler.linkMode != LinkMode.CONNECT_GATT
|
||||
|
||||
if (isBroadcastPreferred) {
|
||||
// Broadcast path: emit Listening and start scanning.
|
||||
// Handler will be attached lazily on first matching advertisement.
|
||||
_events.tryEmit(BluetoothEvent.Listening(address))
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(arrayOf(address))
|
||||
} catch (e: Exception) {
|
||||
LogManager.e(TAG, "session#$sessionId Failed to start broadcast scan: ${e.message}", e)
|
||||
_events.tryEmit(
|
||||
BluetoothEvent.ConnectionFailed(
|
||||
address,
|
||||
e.message ?: context.getString(R.string.bt_error_generic)
|
||||
)
|
||||
)
|
||||
cleanup(address)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Classic GATT path: scan and connect on discovery
|
||||
try {
|
||||
central.scanForPeripheralsWithAddresses(arrayOf(address))
|
||||
} catch (e: Exception) {
|
||||
@@ -575,10 +650,12 @@ class ModernScaleAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Gracefully terminate the current BLE session (if any). */
|
||||
override fun disconnect() {
|
||||
scope.launch {
|
||||
currentPeripheral?.let { central.cancelConnection(it) }
|
||||
stopScanInternal()
|
||||
cleanup(targetAddress)
|
||||
}
|
||||
}
|
||||
@@ -617,7 +694,9 @@ class ModernScaleAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Internals -----------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Internals
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
private fun ensureCentral() {
|
||||
if (!::central.isInitialized) {
|
||||
@@ -625,11 +704,16 @@ class ModernScaleAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScanInternal() {
|
||||
runCatching { if (::central.isInitialized) central.stopScan() }
|
||||
}
|
||||
|
||||
private fun cleanup(addr: String?) {
|
||||
_isConnected.value = false
|
||||
_isConnecting.value = false
|
||||
currentPeripheral = null
|
||||
// keep targetAddress/currentUser for optional retry
|
||||
broadcastAttached = false
|
||||
// Keep targetAddress/currentUser for optional retry
|
||||
}
|
||||
|
||||
/** Free resources; should be called when the adapter is no longer needed. */
|
||||
@@ -652,59 +736,30 @@ class ModernScaleAdapter(
|
||||
if ((props and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS) != 0) names += "EXTENDED_PROPS"
|
||||
return if (names.isEmpty()) props.toString() else names.joinToString("|")
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decide link mode for a discovered peripheral. Uses handler.supportFor() if
|
||||
* the handler exposes it for this device, falling back to handler.linkMode.
|
||||
*/
|
||||
private fun resolveLinkModeFor(
|
||||
peripheral: BluetoothPeripheral,
|
||||
scanResult: android.bluetooth.le.ScanResult?
|
||||
): LinkMode {
|
||||
val record = scanResult?.scanRecord
|
||||
val info = ScannedDeviceInfo(
|
||||
name = peripheral.name,
|
||||
address = peripheral.address,
|
||||
rssi = scanResult?.rssi ?: 0,
|
||||
serviceUuids = record?.serviceUuids?.map { it.uuid } ?: emptyList(),
|
||||
manufacturerData = record?.manufacturerSpecificData,
|
||||
isSupported = false,
|
||||
determinedHandlerDisplayName = null
|
||||
)
|
||||
val support = runCatching { handler.supportFor(info) }.getOrNull()
|
||||
return support?.linkMode ?: handler.linkMode
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Handler Quick-Start (for implementers)
|
||||
--------------------------------------
|
||||
1) Create a new file: `FooDeviceHandler.kt` extending ScaleDeviceHandler.
|
||||
2) Implement `supportFor(device)` to detect your device by name/advertising.
|
||||
3) In `onConnected(user)`, enable notifications and send your init sequence.
|
||||
4) In `onNotification(uuid, data, user)`, parse frames and call `publish()`.
|
||||
5) Optionally call `userInfo("Step on the scale…")` for UI messages.
|
||||
|
||||
Example skeleton:
|
||||
|
||||
class FooDeviceHandler : ScaleDeviceHandler() {
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
|
||||
val name = device.name ?: return null
|
||||
return if (name.startsWith("FOO-SCALE")) {
|
||||
DeviceSupport(
|
||||
displayName = "Foo Scale",
|
||||
capabilities = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC),
|
||||
implemented = setOf(DeviceCapability.BODY_COMPOSITION)
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
// Enable NOTIFY on your measurement characteristic
|
||||
val svc = uuid16(0xFFE0)
|
||||
val chr = uuid16(0xFFE4)
|
||||
setNotifyOn(svc, chr)
|
||||
|
||||
// Send any init / user / time commands the device expects
|
||||
val ctrlSvc = uuid16(0xFFE5)
|
||||
val ctrlChr = uuid16(0xFFE9)
|
||||
val cmd = byteArrayOf(/* vendor-specific payload */)
|
||||
writeTo(ctrlSvc, ctrlChr, cmd, withResponse = true)
|
||||
|
||||
userInfo("Step on the scale…")
|
||||
}
|
||||
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
// Parse vendor frame(s). When you have a complete result:
|
||||
// val measurement = ScaleMeasurement(...populate...)
|
||||
// publish(measurement)
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
// Optional cleanup
|
||||
}
|
||||
}
|
||||
|
||||
Tips:
|
||||
- Do not sleep/block inside the handler; the adapter already sequences and paces I/O.
|
||||
- Prefer small, deterministic parsers per packet type; log unexpected frames.
|
||||
- If your device needs faster/slower pacing, talk to the adapter owner to wire in BleTuning.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
@@ -1,10 +1,23 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 ...
|
||||
* GPLv3+
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.os.Handler
|
||||
import androidx.annotation.StringRes
|
||||
import com.health.openscale.R
|
||||
@@ -20,16 +33,18 @@ import kotlin.math.min
|
||||
/**
|
||||
* What a handler declares about a device it supports.
|
||||
*
|
||||
* @property displayName Human-readable name shown in the UI (“Yunmai Mini”, “Foo SmartScale”).
|
||||
* @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 bleTuning Optional: suggest link timing/retry preferences for flaky devices. See [BleTuning] in `ModernScaleAdapter`. The adapter may honor this in the future.
|
||||
* @property bleTuning Optional link timing/retry preferences (see [BleTuning]).
|
||||
* @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 bleTuning: BleTuning? = null
|
||||
val bleTuning: BleTuning? = null,
|
||||
val linkMode: LinkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
|
||||
/** High-level capabilities a scale might offer. */
|
||||
@@ -43,22 +58,33 @@ enum class DeviceCapability {
|
||||
BATTERY_LEVEL // read battery % / state
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines whether a device communicates via a GATT connection
|
||||
* or only via broadcast advertisements.
|
||||
*/
|
||||
enum class LinkMode { CONNECT_GATT, BROADCAST_ONLY, AUTO }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* - The app (via `ModernScaleAdapter`) injects a BLE `Transport` and `Callbacks`.
|
||||
* - Your subclass implements three core methods:
|
||||
* - [onConnected] – enable notifications, send init/user/time commands.
|
||||
* - [onNotification] – parse frames and call [publish] when you have a complete result.
|
||||
* - [onDisconnected] – optional cleanup.
|
||||
* For GATT devices, the app (via `ModernScaleAdapter`) injects a BLE [Transport] and [Callbacks],
|
||||
* then calls [onConnected] and forwards notifications to [onNotification].
|
||||
*
|
||||
* > You do **not** talk to Android BLE directly. Use the protected helpers:
|
||||
* > [setNotifyOn], [writeTo], [readFrom], [publish], [requestDisconnect], [uuid16].
|
||||
* 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 already serializes and paces BLE I/O. Avoid sleeps or blocking work
|
||||
* inside your handler; just call the helpers in the order your protocol requires.
|
||||
* 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 {
|
||||
|
||||
@@ -70,6 +96,12 @@ abstract class ScaleDeviceHandler {
|
||||
*/
|
||||
abstract fun supportFor(device: ScannedDeviceInfo): DeviceSupport?
|
||||
|
||||
/**
|
||||
* Declares the preferred link mode. Concrete handlers can override this, but the adapter may
|
||||
* also choose based on [DeviceSupport.linkMode].
|
||||
*/
|
||||
open val linkMode: LinkMode = LinkMode.CONNECT_GATT
|
||||
|
||||
// --- Lifecycle entry points called by the adapter -------------------------
|
||||
|
||||
internal fun attach(transport: Transport, callbacks: Callbacks, settings: DriverSettings) {
|
||||
@@ -96,7 +128,7 @@ abstract class ScaleDeviceHandler {
|
||||
|
||||
internal fun handleNotification(characteristic: UUID, data: ByteArray) {
|
||||
val u = currentUser ?: return
|
||||
logD("← notify chr=$characteristic len=${data.size} ${data.toHexPreview(24)}")
|
||||
logD("\u2190 notify chr=$characteristic len=${data.size} ${data.toHexPreview(24)}")
|
||||
try {
|
||||
onNotification(characteristic, data, u)
|
||||
} catch (t: Throwable) {
|
||||
@@ -130,20 +162,26 @@ abstract class ScaleDeviceHandler {
|
||||
|
||||
// --- To be implemented by concrete handlers --------------------------------
|
||||
|
||||
/** Called after services are discovered and the link is ready for I/O. */
|
||||
/** Called after services are discovered and the link is ready for I/O (GATT devices only). */
|
||||
protected abstract fun onConnected(user: ScaleUser)
|
||||
|
||||
/** Called for every incoming notification. Parse and eventually [publish] a result. */
|
||||
/** Called for each incoming notification (GATT devices only). */
|
||||
protected abstract fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser)
|
||||
|
||||
/** 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) {
|
||||
logD("→ setNotifyOn svc=$service chr=$characteristic")
|
||||
logD("\u2192 setNotifyOn svc=$service chr=$characteristic")
|
||||
transport?.setNotifyOn(service, characteristic)
|
||||
?: logW("setNotifyOn called without transport")
|
||||
}
|
||||
@@ -158,28 +196,28 @@ abstract class ScaleDeviceHandler {
|
||||
payload: ByteArray,
|
||||
withResponse: Boolean = true
|
||||
) {
|
||||
logD("→ write svc=$service chr=$characteristic len=${payload.size} withResp=$withResponse ${payload.toHexPreview(24)}")
|
||||
logD("\u2192 write svc=$service chr=$characteristic len=${payload.size} withResp=$withResponse ${payload.toHexPreview(24)}")
|
||||
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) {
|
||||
logD("→ read svc=$service chr=$characteristic")
|
||||
logD("\u2192 read svc=$service chr=$characteristic")
|
||||
transport?.read(service, characteristic)
|
||||
?: logW("readFrom called without transport")
|
||||
}
|
||||
|
||||
/** Publish a fully parsed measurement to the app. */
|
||||
protected fun publish(measurement: ScaleMeasurement) {
|
||||
logI("← publish measurement")
|
||||
logI("\u2190 publish measurement")
|
||||
callbacks?.onPublish(measurement)
|
||||
?: logW("publish called without callbacks")
|
||||
}
|
||||
|
||||
/** Ask the adapter to terminate the link. */
|
||||
protected fun requestDisconnect() {
|
||||
logD("→ requestDisconnect()")
|
||||
logD("\u2192 requestDisconnect()")
|
||||
transport?.disconnect()
|
||||
}
|
||||
|
||||
@@ -214,6 +252,8 @@ abstract class ScaleDeviceHandler {
|
||||
protected fun loadConsentForScaleIndex(scaleIndex: Int): Int =
|
||||
settings.getInt("userMap/consentByIndex/$scaleIndex", -1)
|
||||
|
||||
protected fun settingsGetInt(key: String, default: Int = -1): Int = settings.getInt(key, default)
|
||||
protected fun settingsPutInt(key: String, value: Int) { settings.putInt(key, value) }
|
||||
|
||||
// --- Logging shortcuts (route to LogManager under a single TAG) ------------
|
||||
|
||||
|
@@ -1,9 +1,20 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 ...
|
||||
* GPLv3+
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import android.os.Handler
|
||||
@@ -102,7 +113,8 @@ class StandardWeightProfileHandler : ScaleDeviceHandler() {
|
||||
displayName = "Bluetooth Standard Weight Profile",
|
||||
capabilities = capabilities,
|
||||
implemented = capabilities,
|
||||
bleTuning = null
|
||||
bleTuning = null,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import com.health.openscale.R
|
||||
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
|
||||
import com.health.openscale.core.bluetooth.data.ScaleUser
|
||||
import com.health.openscale.core.bluetooth.libs.TrisaBodyAnalyzeLib
|
||||
import com.health.openscale.core.service.ScannedDeviceInfo
|
||||
import com.health.openscale.core.utils.ConverterUtils
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* TrisaBodyAnalyzeHandler
|
||||
* -----------------------
|
||||
* Modern Kotlin handler for the **Trisa Body Analyze 4.0** (aka Transtek GBF-1257-B).
|
||||
*
|
||||
* Protocol highlights (per legacy driver):
|
||||
* - GATT Service: 0x7802
|
||||
* - Measurement Char: 0x8A21 (indications/notifications)
|
||||
* - Download Command: 0x8A81 (host → device)
|
||||
* - Upload Command: 0x8A82 (device → host)
|
||||
*
|
||||
* Upload (device→host) command opcodes:
|
||||
* - 0xA0 = Password (device sends 32-bit password; should be persisted per device)
|
||||
* - 0xA1 = Challenge (host must xor with password and reply via Download-Result)
|
||||
*
|
||||
* Download (host→device) command opcodes:
|
||||
* - 0x02 = DOWNLOAD_INFORMATION_UTC_COMMAND (send current time)
|
||||
* - 0x20 = DOWNLOAD_INFORMATION_RESULT_COMMAND (send XOR response)
|
||||
* - 0x21 = DOWNLOAD_INFORMATION_BROADCAST_ID_COMMAND (set broadcast id during pairing)
|
||||
* - 0x22 = DOWNLOAD_INFORMATION_ENABLE_DISCONNECT_COMMAND (optional)
|
||||
*/
|
||||
class TrisaBodyAnalyzeHandler : ScaleDeviceHandler() {
|
||||
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
|
||||
// Legacy detection used device names "01257B" / "11257B" prefixes
|
||||
val n = device.name ?: return null
|
||||
val supported = n.startsWith("01257B") || n.startsWith("11257B")
|
||||
return if (supported) {
|
||||
DeviceSupport(
|
||||
displayName = "Trisa Body Analyze 4.0",
|
||||
capabilities = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC),
|
||||
implemented = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC),
|
||||
bleTuning = null,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
// --- UUIDs (Bluetooth Base UUID, 16-bit short codes) ---------------------
|
||||
|
||||
private val SVC_WEIGHT = uuid16(0x7802)
|
||||
private val CHR_MEAS = uuid16(0x8A21)
|
||||
private val CHR_DNLD = uuid16(0x8A81) // host → device
|
||||
private val CHR_UPLD = uuid16(0x8A82) // device → host
|
||||
|
||||
// --- Opcodes --------------------------------------------------------------
|
||||
|
||||
private val UPLOAD_PASSWORD: Byte = 0xA0.toByte()
|
||||
private val UPLOAD_CHALLENGE: Byte = 0xA1.toByte()
|
||||
|
||||
private val CMD_DOWNLOAD_INFORMATION_UTC: Byte = 0x02
|
||||
private val CMD_DOWNLOAD_INFORMATION_RESULT: Byte = 0x20
|
||||
private val CMD_DOWNLOAD_INFORMATION_BROADCAST_ID: Byte = 0x21
|
||||
private val CMD_DOWNLOAD_INFORMATION_ENABLE_DISCONNECT: Byte = 0x22
|
||||
|
||||
private val BROADCAST_ID = 0 // required to complete pairing; value seems arbitrary
|
||||
|
||||
// Timestamp reference (2010-01-01 00:00:00 UTC)
|
||||
private val TIMESTAMP_OFFSET_SECONDS = 1262304000L
|
||||
|
||||
// Pairing state
|
||||
private var pairing = false
|
||||
|
||||
// Cached password (persisted via DriverSettings; see helpers below)
|
||||
private var password: Int? = null
|
||||
|
||||
// --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
override fun onConnected(user: ScaleUser) {
|
||||
// Enable indications/notifications first; device starts pushing frames afterwards.
|
||||
setNotifyOn(SVC_WEIGHT, CHR_MEAS)
|
||||
setNotifyOn(SVC_WEIGHT, CHR_UPLD)
|
||||
|
||||
// Load previously stored password for this device (if any)
|
||||
password = settingsGetInt("trisa/password", -1).takeIf { it != -1 }
|
||||
}
|
||||
|
||||
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
|
||||
when (characteristic) {
|
||||
CHR_UPLD -> handleUploadCommand(data)
|
||||
CHR_MEAS -> handleMeasurement(data, user)
|
||||
else -> logW("Unknown characteristic notify: $characteristic")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Upload (device → host) processing -----------------------------------
|
||||
|
||||
private fun handleUploadCommand(data: ByteArray) {
|
||||
if (data.isEmpty()) {
|
||||
logW("Upload command: empty payload")
|
||||
return
|
||||
}
|
||||
when (data[0]) {
|
||||
UPLOAD_PASSWORD -> onPasswordReceived(data)
|
||||
UPLOAD_CHALLENGE -> onChallengeReceived(data)
|
||||
else -> logW("Upload: unknown opcode ${data[0].toUByte().toString(16)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPasswordReceived(data: ByteArray) {
|
||||
if (data.size < 5) {
|
||||
logW("Password payload too short")
|
||||
return
|
||||
}
|
||||
val pw = ConverterUtils.fromSignedInt32Le(data, 1)
|
||||
password = pw
|
||||
settingsPutInt("trisa/password", pw)
|
||||
|
||||
userInfo(R.string.bluetooth_scale_trisa_success_pairing)
|
||||
|
||||
// Complete pairing: set broadcast ID then disconnect.
|
||||
pairing = true
|
||||
writeCommand(CMD_DOWNLOAD_INFORMATION_BROADCAST_ID, BROADCAST_ID)
|
||||
// We don't receive a write-complete callback, so disconnect right away.
|
||||
requestDisconnect()
|
||||
}
|
||||
|
||||
private fun onChallengeReceived(data: ByteArray) {
|
||||
if (data.size < 5) {
|
||||
logW("Challenge payload too short")
|
||||
return
|
||||
}
|
||||
val pw = password ?: run {
|
||||
userWarn(R.string.bluetooth_scale_trisa_message_not_paired_instruction)
|
||||
requestDisconnect()
|
||||
return
|
||||
}
|
||||
val challenge = ConverterUtils.fromSignedInt32Le(data, 1)
|
||||
val response = challenge xor pw
|
||||
writeCommand(CMD_DOWNLOAD_INFORMATION_RESULT, response)
|
||||
|
||||
val nowDevice = convertJavaTimestampToDevice(System.currentTimeMillis())
|
||||
writeCommand(CMD_DOWNLOAD_INFORMATION_UTC, nowDevice)
|
||||
}
|
||||
|
||||
// --- Measurement parsing --------------------------------------------------
|
||||
|
||||
private fun handleMeasurement(data: ByteArray, user: ScaleUser) {
|
||||
val m = parseScaleMeasurementData(data, user) ?: run {
|
||||
logW("Failed to parse measurement: ${data.toHexPreview(24)}")
|
||||
return
|
||||
}
|
||||
publish(m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse measurement payload.
|
||||
*
|
||||
* Layout:
|
||||
* byte 0 : info flags (bit0 timestamp, bit1 resistance1, bit2 resistance2)
|
||||
* bytes1-4 : weight (base10 float)
|
||||
* bytes5-8 : timestamp (if bit0)
|
||||
* +4 : resistance1 (if bit1)
|
||||
* +4 : resistance2 (if bit2)
|
||||
*/
|
||||
fun parseScaleMeasurementData(data: ByteArray, user: ScaleUser?): ScaleMeasurement? {
|
||||
if (data.size < 9) return null
|
||||
val info = data[0].toInt()
|
||||
val hasTs = (info and 0x01) != 0
|
||||
val hasR1 = (info and 0x02) != 0
|
||||
val hasR2 = (info and 0x04) != 0
|
||||
if (!hasTs) return null
|
||||
|
||||
val weightKg = getBase10Float(data, 1)
|
||||
val deviceTs = ConverterUtils.fromSignedInt32Le(data, 5)
|
||||
|
||||
val measurement = ScaleMeasurement().apply {
|
||||
dateTime = Date(convertDeviceTimestampToJava(deviceTs))
|
||||
weight = weightKg
|
||||
}
|
||||
|
||||
// Only resistance2 is used for derived composition fields
|
||||
val r2Offset = 9 + if (hasR1) 4 else 0
|
||||
if (hasR2 && r2Offset + 4 <= data.size && isValidUser(user)) {
|
||||
val resistance2 = getBase10Float(data, r2Offset)
|
||||
val impedance = if (resistance2 < 410f) 3.0f else 0.3f * (resistance2 - 400f)
|
||||
val sexFlag = if (user!!.gender.isMale()) 1 else 0
|
||||
val lib = TrisaBodyAnalyzeLib(sexFlag, user.age, user.bodyHeight)
|
||||
measurement.fat = lib.getFat(weightKg, impedance)
|
||||
measurement.water = lib.getWater(weightKg, impedance)
|
||||
measurement.muscle = lib.getMuscle(weightKg, impedance)
|
||||
measurement.bone = lib.getBone(weightKg, impedance)
|
||||
}
|
||||
return measurement
|
||||
}
|
||||
|
||||
// --- Command helpers (host → device) -------------------------------------
|
||||
|
||||
private fun writeCommand(opcode: Byte) {
|
||||
writeTo(SVC_WEIGHT, CHR_DNLD, byteArrayOf(opcode), withResponse = true)
|
||||
}
|
||||
|
||||
private fun writeCommand(opcode: Byte, arg: Int) {
|
||||
val bytes = ByteArray(5)
|
||||
bytes[0] = opcode
|
||||
ConverterUtils.toInt32Le(bytes, 1, arg.toLong())
|
||||
writeTo(SVC_WEIGHT, CHR_DNLD, bytes, withResponse = true)
|
||||
}
|
||||
|
||||
// --- Utility --------------------------------------------------------------
|
||||
|
||||
private fun getBase10Float(data: ByteArray, offset: Int): Float {
|
||||
val mantissa = ConverterUtils.fromUnsignedInt24Le(data, offset)
|
||||
val exponent = data[offset + 3].toInt() // signed
|
||||
return (mantissa * Math.pow(10.0, exponent.toDouble())).toFloat()
|
||||
}
|
||||
|
||||
private fun convertJavaTimestampToDevice(javaMillis: Long): Int {
|
||||
return (((javaMillis + 500) / 1000) - TIMESTAMP_OFFSET_SECONDS).toInt()
|
||||
}
|
||||
|
||||
private fun convertDeviceTimestampToJava(deviceSeconds: Int): Long {
|
||||
return 1000L * (TIMESTAMP_OFFSET_SECONDS + deviceSeconds.toLong())
|
||||
}
|
||||
|
||||
private fun isValidUser(user: ScaleUser?): Boolean =
|
||||
user != null && user.age > 0 && user.bodyHeight > 0
|
||||
}
|
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.util.SparseArray
|
||||
import com.health.openscale.core.bluetooth.modern.BroadcastAction.*
|
||||
import com.health.openscale.core.bluetooth.modern.DeviceCapability.LIVE_WEIGHT_STREAM
|
||||
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
|
||||
|
||||
/**
|
||||
* Yoda1DeviceHandler
|
||||
* ------------------
|
||||
* Broadcast-only handler for the "Yoda1" scale family.
|
||||
*
|
||||
* The device does not require a GATT connection. It encodes weight in the
|
||||
* Manufacturer Specific Data (MSD) of its advertisement frames.
|
||||
*
|
||||
* Encoding (from legacy driver knowledge):
|
||||
* - bytes[0..1] : raw weight (big-endian)
|
||||
* - byte[6] : control flags
|
||||
* bit0 -> stabilized flag
|
||||
* bit2 -> unit is KG (otherwise catty/jin)
|
||||
* bit3 -> one decimal place (otherwise extra /10 afterwards)
|
||||
*/
|
||||
class Yoda1Handler : ScaleDeviceHandler() {
|
||||
|
||||
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
|
||||
// Heuristic: name starts with "Yoda1" OR the MSD payload looks like Yoda1 (>= 7 bytes)
|
||||
val nameMatch = device.name?.startsWith("Yoda1", ignoreCase = true) == true
|
||||
val msd = device.manufacturerData
|
||||
val msdLooksLikeYoda = msd != null && msd.size() > 0 && (msd.valueAt(0)?.size ?: 0) >= 7
|
||||
if (!nameMatch && !msdLooksLikeYoda) return null
|
||||
|
||||
return DeviceSupport(
|
||||
displayName = "Yoda1 Scale",
|
||||
capabilities = setOf(LIVE_WEIGHT_STREAM),
|
||||
implemented = setOf(LIVE_WEIGHT_STREAM),
|
||||
bleTuning = null,
|
||||
linkMode = LinkMode.BROADCAST_ONLY
|
||||
)
|
||||
}
|
||||
|
||||
// Not used for broadcast-only devices, but must be implemented
|
||||
override fun onConnected(user: ScaleUser) { /* no-op */ }
|
||||
override fun onNotification(characteristic: java.util.UUID, data: ByteArray, user: ScaleUser) { /* no-op */ }
|
||||
|
||||
override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction {
|
||||
val record = result.scanRecord ?: return IGNORED
|
||||
val msd: SparseArray<ByteArray> = record.manufacturerSpecificData ?: return IGNORED
|
||||
if (msd.size() <= 0) return IGNORED
|
||||
val payload = msd.valueAt(0) ?: return IGNORED
|
||||
if (payload.size < 7) return IGNORED
|
||||
|
||||
val ctrl = payload[6].toInt() and 0xFF
|
||||
val stabilized = isBitSet(ctrl, 0)
|
||||
val unitIsKg = isBitSet(ctrl, 2)
|
||||
val oneDecimal = isBitSet(ctrl, 3)
|
||||
|
||||
val raw = ((payload[0].toInt() and 0xFF) shl 8) or (payload[1].toInt() and 0xFF)
|
||||
var weight = if (unitIsKg) raw / 10.0f else raw / 20.0f // catty/jin conversion
|
||||
if (!oneDecimal) weight /= 10.0f
|
||||
|
||||
val measurement = ScaleMeasurement().apply {
|
||||
setWeight(ConverterUtils.toKilogram(weight, user.scaleUnit))
|
||||
}
|
||||
|
||||
// If not stabilized yet, keep scanning (device often sends intermediate weights).
|
||||
return if (stabilized) {
|
||||
publish(measurement)
|
||||
CONSUMED_STOP
|
||||
} else {
|
||||
CONSUMED_KEEP_SCANNING
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBitSet(value: Int, bit: Int): Boolean = ((value shr bit) and 0x1) == 1
|
||||
}
|
@@ -1,7 +1,19 @@
|
||||
/*
|
||||
* openScale
|
||||
* Copyright (C) 2025 ...
|
||||
* GPLv3+
|
||||
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.health.openscale.core.bluetooth.modern
|
||||
|
||||
@@ -19,7 +31,7 @@ import java.util.UUID
|
||||
* Yunmai (SE/Mini) – minimal, event-driven implementation.
|
||||
* The base class handles sequencing/IO; this class only knows packets & parsing.
|
||||
*/
|
||||
class YunmaiDeviceHandler(
|
||||
class YunmaiHandler(
|
||||
private val isMini: Boolean = true // Mini sends fat sometimes inline; SE usually needs calc
|
||||
) : ScaleDeviceHandler() {
|
||||
|
||||
@@ -49,7 +61,8 @@ class YunmaiDeviceHandler(
|
||||
return DeviceSupport(
|
||||
displayName = if (isMini) "Yunmai Mini" else "Yunmai SE",
|
||||
capabilities = caps,
|
||||
implemented = impl
|
||||
implemented = impl,
|
||||
linkMode = LinkMode.CONNECT_GATT
|
||||
)
|
||||
}
|
||||
|
@@ -392,6 +392,7 @@ enum class ConnectionStatus {
|
||||
NONE,
|
||||
/** Ready but not connected; idle state after init or after a clean stop. */
|
||||
IDLE,
|
||||
BROADCAST_LISTENING,
|
||||
/** Explicitly not connected (after a disconnect or failure). */
|
||||
DISCONNECTED,
|
||||
/** Connecting handshake is in progress. */
|
||||
|
@@ -260,36 +260,79 @@ class BleConnector(
|
||||
LogManager.d(TAG, "BluetoothEvent received: $event for $deviceDisplayName")
|
||||
|
||||
when (event) {
|
||||
is BluetoothEvent.Connected -> {
|
||||
LogManager.i(TAG, "Event: Connected to ${event.deviceName ?: deviceDisplayName} (${event.deviceAddress})")
|
||||
disconnectTimeoutJob?.cancel() // Successfully connected, timeout no longer needed.
|
||||
if (_connectionStatus.value != ConnectionStatus.CONNECTED) {
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||
_connectedDeviceAddress.value = event.deviceAddress
|
||||
_connectedDeviceName.value = event.deviceName ?: deviceInfo.name // Prefer event name.
|
||||
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_connected_to, messageFormatArgs = listOf(event.deviceName ?: deviceDisplayName)))
|
||||
_connectionError.value = null
|
||||
}
|
||||
is BluetoothEvent.Listening -> {
|
||||
// Broadcast-only: scan started for target MAC (no GATT connection)
|
||||
LogManager.i(TAG, "Event: Listening for broadcasts from ${event.deviceAddress}")
|
||||
disconnectTimeoutJob?.cancel() // Don't race a connect-timeout while we're listening
|
||||
// Treat 'listening' as connecting so existing UI states keep working
|
||||
_connectionStatus.value = ConnectionStatus.BROADCAST_LISTENING
|
||||
_connectedDeviceAddress.value = event.deviceAddress
|
||||
_connectedDeviceName.value = deviceInfo.name ?: deviceDisplayName
|
||||
_snackbarEvents.tryEmit(
|
||||
SnackbarEvent(
|
||||
messageResId = R.string.bluetooth_connector_listening_for_device,
|
||||
messageFormatArgs = listOf(deviceDisplayName)
|
||||
)
|
||||
)
|
||||
}
|
||||
is BluetoothEvent.Disconnected -> {
|
||||
LogManager.i(TAG, "Event: Disconnected from ${event.deviceAddress}. Reason: ${event.reason}")
|
||||
disconnectTimeoutJob?.cancel() // Disconnect event received, timeout no longer needed.
|
||||
// Only act if this disconnect event is for the currently tracked device or if we are in the process of disconnecting.
|
||||
if (_connectedDeviceAddress.value == event.deviceAddress || _connectionStatus.value == ConnectionStatus.DISCONNECTING) {
|
||||
|
||||
is BluetoothEvent.BroadcastComplete -> {
|
||||
// Broadcast-only: final/stabilized value was processed; adapter stopped scanning
|
||||
LogManager.i(TAG, "Event: BroadcastComplete for ${event.deviceAddress}")
|
||||
disconnectTimeoutJob?.cancel()
|
||||
if (_connectedDeviceAddress.value == event.deviceAddress ||
|
||||
_connectionStatus.value == ConnectionStatus.CONNECTING) {
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
_connectedDeviceAddress.value = null
|
||||
_connectedDeviceName.value = null
|
||||
_snackbarEvents.tryEmit(
|
||||
SnackbarEvent(
|
||||
messageResId = R.string.bluetooth_connector_broadcast_complete,
|
||||
messageFormatArgs = listOf(deviceDisplayName)
|
||||
)
|
||||
)
|
||||
releaseActiveCommunicator(logPrefix = "BroadcastComplete: ")
|
||||
} else {
|
||||
LogManager.w(TAG, "BroadcastComplete for unexpected address ${event.deviceAddress} (state=${_connectionStatus.value})")
|
||||
}
|
||||
}
|
||||
|
||||
is BluetoothEvent.Connected -> {
|
||||
LogManager.i(TAG, "Event: Connected to ${event.deviceName ?: deviceDisplayName} (${event.deviceAddress})")
|
||||
disconnectTimeoutJob?.cancel()
|
||||
if (_connectionStatus.value != ConnectionStatus.CONNECTED) {
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||
_connectedDeviceAddress.value = event.deviceAddress
|
||||
_connectedDeviceName.value = event.deviceName ?: deviceInfo.name
|
||||
_snackbarEvents.tryEmit(
|
||||
SnackbarEvent(
|
||||
messageResId = R.string.bluetooth_connector_connected_to,
|
||||
messageFormatArgs = listOf(event.deviceName ?: deviceDisplayName)
|
||||
)
|
||||
)
|
||||
_connectionError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
is BluetoothEvent.Disconnected -> {
|
||||
LogManager.i(TAG, "Event: Disconnected from ${event.deviceAddress}. Reason: ${event.reason}")
|
||||
disconnectTimeoutJob?.cancel()
|
||||
if (_connectedDeviceAddress.value == event.deviceAddress ||
|
||||
_connectionStatus.value == ConnectionStatus.DISCONNECTING) {
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
_connectedDeviceAddress.value = null
|
||||
_connectedDeviceName.value = null
|
||||
// Optionally: _connectionError.value = "Disconnected: ${event.reason}"
|
||||
releaseActiveCommunicator(logPrefix = "Disconnected event: ")
|
||||
} else {
|
||||
LogManager.w(TAG, "Disconnected event for unexpected address ${event.deviceAddress} or status ${_connectionStatus.value}")
|
||||
}
|
||||
}
|
||||
|
||||
is BluetoothEvent.ConnectionFailed -> {
|
||||
LogManager.w(TAG, "Event: Connection failed for ${event.deviceAddress}. Reason: ${event.error}")
|
||||
disconnectTimeoutJob?.cancel() // Error, timeout no longer needed.
|
||||
// Check if this error is relevant to the current connection attempt.
|
||||
if (_connectedDeviceAddress.value == event.deviceAddress || _connectionStatus.value == ConnectionStatus.CONNECTING) {
|
||||
disconnectTimeoutJob?.cancel()
|
||||
if (_connectedDeviceAddress.value == event.deviceAddress ||
|
||||
_connectionStatus.value == ConnectionStatus.CONNECTING) {
|
||||
_connectionStatus.value = ConnectionStatus.FAILED
|
||||
_connectionError.value = "Connection to $deviceDisplayName failed: ${event.error}"
|
||||
_connectedDeviceAddress.value = null
|
||||
@@ -299,19 +342,26 @@ class BleConnector(
|
||||
LogManager.w(TAG, "ConnectionFailed event for unexpected address ${event.deviceAddress} or status ${_connectionStatus.value}")
|
||||
}
|
||||
}
|
||||
|
||||
is BluetoothEvent.MeasurementReceived -> {
|
||||
LogManager.i(TAG, "Event: Measurement received from $deviceDisplayName: Weight ${event.measurement.weight}")
|
||||
saveMeasurementFromEvent(event.measurement, event.deviceAddress, deviceDisplayName)
|
||||
}
|
||||
|
||||
is BluetoothEvent.DeviceMessage -> {
|
||||
LogManager.d(TAG, "Event: Message from $deviceDisplayName: ${event.message}")
|
||||
_snackbarEvents.tryEmit(SnackbarEvent(messageResId = R.string.bluetooth_connector_device_message, messageFormatArgs = listOf(deviceDisplayName, event.message)))
|
||||
_snackbarEvents.tryEmit(
|
||||
SnackbarEvent(
|
||||
messageResId = R.string.bluetooth_connector_device_message,
|
||||
messageFormatArgs = listOf(deviceDisplayName, event.message)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is BluetoothEvent.Error -> {
|
||||
LogManager.e(TAG, "Event: Error from $deviceDisplayName: ${event.error}")
|
||||
_connectionError.value = "Error with $deviceDisplayName: ${event.error}"
|
||||
// Consider setting status to FAILED if it's a critical error
|
||||
// that impacts/loses the connection.
|
||||
// Optional: set status to FAILED if appropriate for your UX.
|
||||
}
|
||||
|
||||
is BluetoothEvent.UserInteractionRequired -> {
|
||||
@@ -322,6 +372,7 @@ class BleConnector(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Forwards the user's feedback from an interaction to the active [ScaleCommunicator].
|
||||
*
|
||||
|
@@ -275,6 +275,8 @@
|
||||
<string name="bluetooth_connector_measurement_saved">Messung (%1$.1f kg) von %2$s gespeichert.</string>
|
||||
<string name="bluetooth_connector_measurement_save_error">Fehler beim Speichern der Messung von %1$s.</string>
|
||||
<string name="bluetooth_connector_device_message">%1$s: %2$s</string>
|
||||
<string name="bluetooth_connector_listening_for_device">Suche nach %1$s …</string>
|
||||
<string name="bluetooth_connector_broadcast_complete">Messung von %1$s empfangen.</string>
|
||||
|
||||
<!-- Bluetooth Berechtigungen & Aktivierungs-Karten -->
|
||||
<string name="permissions_required_icon_desc">Symbol für erforderliche Berechtigungen</string>
|
||||
|
@@ -278,6 +278,8 @@
|
||||
<string name="bluetooth_connector_measurement_saved">Measurement (%1$.1f kg) from %2$s saved.</string>
|
||||
<string name="bluetooth_connector_measurement_save_error">Error saving measurement from %1$s.</string>
|
||||
<string name="bluetooth_connector_device_message">%1$s: %2$s</string>
|
||||
<string name="bluetooth_connector_listening_for_device">Listening for %1$s…</string>
|
||||
<string name="bluetooth_connector_broadcast_complete">Measurement received from %1$s.</string>
|
||||
|
||||
<!-- Bluetooth Permissions & Enable Cards -->
|
||||
<string name="permissions_required_icon_desc">Permissions required icon</string>
|
||||
|
Reference in New Issue
Block a user