1
0
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:
oliexdev
2025-09-01 18:18:17 +02:00
parent b0c7f44854
commit d4ecb37d11
12 changed files with 720 additions and 243 deletions

View File

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

View File

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

View File

@@ -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.
* Dont 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 dont 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.
--------------------------------------------------------------------------- */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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