diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index 67f6b1946..b98faac05 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -210,6 +210,9 @@ dependencies { // Blessed Kotlin implementation(libs.blessed.kotlin) + // BouncyCastle for AES-CCM decryption (Xiaomi S400 scale) + implementation(libs.bouncycastle) + // Test dependencies testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index f18db70b1..0704416b8 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -40,6 +40,7 @@ import com.health.openscale.core.bluetooth.scales.LinkMode import com.health.openscale.core.bluetooth.scales.MGBHandler import com.health.openscale.core.bluetooth.scales.MedisanaBs44xHandler import com.health.openscale.core.bluetooth.scales.MiScaleHandler +import com.health.openscale.core.bluetooth.scales.MiScaleS400Handler import com.health.openscale.core.bluetooth.scales.OkOkHandler import com.health.openscale.core.bluetooth.scales.OneByoneHandler import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler @@ -96,6 +97,7 @@ class ScaleFactory @Inject constructor( OneByoneHandler(), OneByoneNewHandler(), OkOkHandler(), + MiScaleS400Handler(), MiScaleHandler(), MGBHandler(), MedisanaBs44xHandler(), @@ -209,6 +211,17 @@ class ScaleFactory @Inject constructor( return modernKotlinHandlers.firstNotNullOfOrNull { it.supportFor(info) } } + /** + * Returns the first [ScaleDeviceHandler] that supports the given device, or null. + * + * This is safe for read-only inspection (e.g., querying [ScaleDeviceHandler.configFields] + * or [ScaleDeviceHandler.handlerNamespace]). The returned instance is a shared singleton; + * callers must not call [ScaleDeviceHandler.attach] on it. + */ + fun getHandlerFor(deviceInfo: ScannedDeviceInfo): ScaleDeviceHandler? { + return modernKotlinHandlers.firstOrNull { it.supportFor(deviceInfo) != null } + } + /** * Checks if any known handler can theoretically support the given device. * This can be used by the UI to indicate if a device is potentially recognizable. diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/S400Decryptor.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/S400Decryptor.kt new file mode 100644 index 000000000..0c0d37623 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/S400Decryptor.kt @@ -0,0 +1,174 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * 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 . + */ +/** + * Decryption logic for Xiaomi Body Composition Scale S400. + * Based on https://github.com/lswiderski/mi-scale-exporter and + * https://github.com/lswiderski/MiScaleBodyComposition + */ +package com.health.openscale.core.bluetooth.libs + +import org.bouncycastle.crypto.engines.AESEngine +import org.bouncycastle.crypto.modes.CCMBlockCipher +import org.bouncycastle.crypto.params.AEADParameters +import org.bouncycastle.crypto.params.KeyParameter +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Raw measurement data from S400 scale after decryption. + */ +data class S400Measurement( + val weightKg: Float, + val impedance: Float?, + val heartRate: Int? +) + +/** + * Decrypts and parses advertisement data from Xiaomi Body Composition Scale S400. + * + * The S400 sends AES-CCM encrypted BLE advertisement data that requires: + * - The scale's MAC address (used in nonce construction) + * - A 16-byte BLE bind key from Xiaomi Cloud + */ +object S400Decryptor { + + private const val EXPECTED_DATA_LENGTH = 24 + private const val EXPECTED_DATA_LENGTH_WITH_HEADER = 26 + private const val BIND_KEY_HEX_LENGTH = 32 + private const val MAC_TAG_BITS = 32 + + /** + * Decrypt S400 advertisement data and extract measurements. + * + * @param advertisementData Raw service data from BLE advertisement (24 or 26 bytes) + * @param macAddress Scale's Bluetooth MAC address (format: "XX:XX:XX:XX:XX:XX") + * @param bindKey 32-character hex string (16 bytes) from Xiaomi Cloud + * @return Decrypted measurement or null if decryption fails or data is invalid + */ + fun decrypt( + advertisementData: ByteArray, + macAddress: String, + bindKey: String + ): S400Measurement? { + // Validate bind key + if (bindKey.length != BIND_KEY_HEX_LENGTH) { + return null + } + + // Normalize data length (strip 2-byte service UUID header if present) + val data = when (advertisementData.size) { + EXPECTED_DATA_LENGTH_WITH_HEADER -> advertisementData.copyOfRange(2, EXPECTED_DATA_LENGTH_WITH_HEADER) + EXPECTED_DATA_LENGTH -> advertisementData + else -> return null + } + + return try { + val macBytes = hexStringToByteArray(macAddress.replace(":", "")) + val keyBytes = hexStringToByteArray(bindKey) + + if (macBytes.size != 6 || keyBytes.size != 16) { + return null + } + + // Build nonce: MAC_reversed[6] + data[2:5] + data[17:20] + val nonce = macBytes.reversedArray() + + data.copyOfRange(2, 5) + + data.copyOfRange(data.size - 7, data.size - 4) + + // Extract MIC (authentication tag) - last 4 bytes + val mic = data.copyOfRange(data.size - 4, data.size) + + // Extract encrypted payload - bytes 5 to (length - 7) + val encryptedPayload = data.copyOfRange(5, data.size - 7) + + // Combine encrypted payload and MIC for decryption + val cipherText = encryptedPayload + mic + + // AES-CCM decryption + val ccm = CCMBlockCipher.newInstance(AESEngine.newInstance()) + val associatedData = byteArrayOf(0x11) + val params = AEADParameters(KeyParameter(keyBytes), MAC_TAG_BITS, nonce, associatedData) + ccm.init(false, params) + + val decrypted = ByteArray(ccm.getOutputSize(cipherText.size)) + val len = ccm.processBytes(cipherText, 0, cipherText.size, decrypted, 0) + ccm.doFinal(decrypted, len) + + parseDecryptedData(decrypted) + } catch (e: Exception) { + null + } + } + + /** + * Parse decrypted payload to extract weight, impedance, and heart rate. + */ + private fun parseDecryptedData(decrypted: ByteArray): S400Measurement? { + if (decrypted.size < 12) return null + + // Extract bytes 3-12 (9 bytes), then take 4 bytes at offset 1 + val obj = decrypted.copyOfRange(3, 12) + val slice = obj.copyOfRange(1, 5) + + // Convert to little-endian Int32 + val value = ByteBuffer.wrap(slice).order(ByteOrder.LITTLE_ENDIAN).int + + // Extract measurements using bit masks + val weightRaw = value and 0x7FF // bits 0-10 + val heartRateRaw = (value shr 11) and 0x7F // bits 11-17 + val impedanceRaw = value shr 18 // bits 18+ + + val weightKg = weightRaw / 10.0f + + // Heart rate: valid range is 1-126, then add 50 + val heartRate = if (heartRateRaw in 1..126) heartRateRaw + 50 else null + + // Impedance: only valid if both impedance and weight are non-zero + val impedance = if (impedanceRaw != 0 && weightRaw != 0) { + impedanceRaw / 10.0f + } else null + + return if (weightKg > 0) { + S400Measurement(weightKg, impedance, heartRate) + } else null + } + + /** + * Convert hex string to byte array. + */ + private fun hexStringToByteArray(hex: String): ByteArray { + val cleanHex = hex.replace(" ", "").replace(":", "") + return cleanHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + /** + * Validate that a bind key is properly formatted. + */ + fun isValidBindKey(bindKey: String): Boolean { + if (bindKey.length != BIND_KEY_HEX_LENGTH) return false + return bindKey.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + } + + /** + * Validate that a MAC address is properly formatted. + */ + fun isValidMacAddress(mac: String): Boolean { + val pattern = Regex("^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$") + return pattern.matches(mac) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt new file mode 100644 index 000000000..b19c63743 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleS400Handler.kt @@ -0,0 +1,243 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * 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 . + */ +/** + * Xiaomi Body Composition Scale S400 handler. + * + * Based on https://github.com/lswiderski/mi-scale-exporter and + * https://github.com/lswiderski/MiScaleBodyComposition + * + * The S400 broadcasts AES-CCM encrypted advertisement data containing: + * - Weight + * - Impedance (for body composition calculation) + * - Heart rate + * + * Unlike older Mi Scales, the S400 requires: + * - A BLE bind key extracted from Xiaomi Cloud + * - The scale's MAC address (used in nonce construction) + */ +package com.health.openscale.core.bluetooth.scales + +import android.bluetooth.le.ScanResult +import android.os.ParcelUuid +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +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.MiScaleLib +import com.health.openscale.core.bluetooth.libs.S400Decryptor +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date +import java.util.Locale + +/** + * Handler for Xiaomi Body Composition Scale S400. + * + * This scale uses broadcast-only mode (no GATT connection) and sends + * AES-CCM encrypted service data in BLE advertisements. + */ +class MiScaleS400Handler : ScaleDeviceHandler() { + + companion object { + // Settings keys for S400 configuration + const val SETTINGS_KEY_BIND_KEY = "s400_bind_key" + const val SETTINGS_KEY_MAC_ADDRESS = "s400_mac_address" + + // Known S400 device name patterns + // The S400 may advertise with various names depending on firmware/region + // Format seen in practice: "Xiaomi Scale S400 XXXX" where XXXX is last 4 of MAC + private val KNOWN_NAME_PATTERNS = listOf( + "SCALE S400", // Core pattern - matches "Xiaomi Scale S400 8E8B" + "XMTZC14HM", // S400 model identifier (raw BLE name) + "XMTZC", // Xiaomi scale prefix + ) + + // S400 Service UUID (Xiaomi Body Composition service) + private val SERVICE_UUID_S400 = ParcelUuid.fromString("0000181b-0000-1000-8000-00805f9b34fb") + } + + // Track if we've warned about missing configuration + private var warnedMissingConfig = false + + override fun configFields(): List = listOf( + ScaleConfigField( + key = SETTINGS_KEY_BIND_KEY, + labelRes = R.string.s400_bind_key_label, + descriptionRes = R.string.s400_bind_key_description, + placeholderRes = R.string.s400_bind_key_placeholder, + errorRes = R.string.s400_bind_key_error, + icon = Icons.Default.Key, + maxLength = 32, + inputFilter = InputFilter.HEX, + ) + ) + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name.uppercase(Locale.ROOT) + + // Check if device name matches known S400 patterns + // Use contains() for flexibility - some names may have prefixes/suffixes + val isS400 = KNOWN_NAME_PATTERNS.any { pattern -> + val upperPattern = pattern.uppercase(Locale.ROOT) + name.contains(upperPattern) || name.startsWith(upperPattern) + } + + if (!isS400) return null + + return DeviceSupport( + displayName = "Xiaomi Body Composition Scale S400", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION + ), + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION + ), + linkMode = LinkMode.BROADCAST_ONLY + ) + } + + /** + * Process BLE advertisements from the S400 scale. + * + * The S400 sends encrypted service data that we need to decrypt + * using the user's bind key and the scale's MAC address. + */ + override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction { + // Get configured bind key and MAC address + val bindKey = settingsGetString(SETTINGS_KEY_BIND_KEY) + val configuredMac = settingsGetString(SETTINGS_KEY_MAC_ADDRESS) + + // Get MAC from scan result (format: XX:XX:XX:XX:XX:XX) + val deviceMac = result.device.address + + // Use configured MAC if available, otherwise use detected MAC + val macAddress = if (!configuredMac.isNullOrEmpty() && S400Decryptor.isValidMacAddress(configuredMac)) { + configuredMac + } else { + deviceMac + } + + // Validate configuration + if (bindKey.isNullOrEmpty() || !S400Decryptor.isValidBindKey(bindKey)) { + if (!warnedMissingConfig) { + logW("S400: Missing or invalid bind key. Configure in Settings.") + userWarn(R.string.bt_s400_missing_bind_key) + warnedMissingConfig = true + } + return BroadcastAction.IGNORED + } + + // Extract service data from advertisement + val serviceData = extractServiceData(result) ?: return BroadcastAction.IGNORED + + logD("S400 advert: ${serviceData.size} bytes from $deviceMac") + + // Attempt decryption + val measurement = try { + S400Decryptor.decrypt(serviceData, macAddress, bindKey) + } catch (e: Exception) { + logW("S400 decryption failed: ${e.message}") + return BroadcastAction.IGNORED + } + + if (measurement == null) { + logD("S400: No valid measurement in advertisement") + return BroadcastAction.IGNORED + } + + logI("S400 measurement: weight=${measurement.weightKg}kg, impedance=${measurement.impedance}, hr=${measurement.heartRate}") + + // Build scale measurement + val scaleMeasurement = ScaleMeasurement().apply { + dateTime = Date() + weight = measurement.weightKg + userId = user.id + + // Calculate body composition if we have impedance + measurement.impedance?.let { imp -> + if (imp > 0) { + val sex = if (user.gender == GenderType.MALE) 1 else 0 + val lib = MiScaleLib(sex, user.age, user.bodyHeight) + + fat = lib.getBodyFat(weight, imp) + water = lib.getWater(weight, imp) + muscle = lib.getMuscle(weight, imp) + bone = lib.getBoneMass(weight, imp) + lbm = lib.getLBM(weight, imp) + visceralFat = lib.getVisceralFat(weight) + } + } + } + + publish(scaleMeasurement) + + // S400 sends a single final measurement, so we're done + return BroadcastAction.CONSUMED_STOP + } + + /** + * Extract service data from the BLE scan result. + * + * The S400 sends data via Service Data (AD type 0x16) for the + * Body Composition Service UUID (0x181B). + */ + private fun extractServiceData(result: ScanResult): ByteArray? { + val scanRecord = result.scanRecord ?: return null + + // Try to get service data for the Body Composition Service + val serviceData = scanRecord.serviceData + + // Check for 0x181B service data + serviceData?.get(SERVICE_UUID_S400)?.let { data -> + if (data.size >= 24) { + return data + } + } + + // Some devices may include the service UUID in the data + // Try all service data entries + serviceData?.values?.forEach { data -> + if (data.size >= 24 && data.size <= 26) { + return data + } + } + + // Fallback: check raw advertisement bytes for service data + val rawBytes = scanRecord.bytes + if (rawBytes != null && rawBytes.size >= 26) { + // Look for service data AD type (0x16) followed by 0x1B 0x18 (little-endian 0x181B) + for (i in 0 until rawBytes.size - 26) { + if (rawBytes[i] == 0x16.toByte() && + rawBytes[i + 1] == 0x1B.toByte() && + rawBytes[i + 2] == 0x18.toByte()) { + // Found service data header, extract the data + val dataStart = i + 3 + val remaining = rawBytes.size - dataStart + if (remaining >= 24) { + return rawBytes.copyOfRange(dataStart, dataStart + minOf(remaining, 26)) + } + } + } + } + + return null + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 10de4cc51..2a73c8f6e 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -84,6 +84,35 @@ enum class LinkMode { CONNECT_GATT, BROADCAST_ONLY, CLASSIC_SPP } */ enum class BroadcastAction { IGNORED, CONSUMED_KEEP_SCANNING, CONSUMED_STOP } +/** How user input should be filtered for a [ScaleConfigField]. */ +enum class InputFilter { NONE, HEX } + +/** + * Describes a user-configurable setting that a handler needs. + * + * The Bluetooth detail screen renders these generically so that each handler + * can declare its own settings without any handler-specific UI code. + * + * @property key Settings key passed to [ScaleDeviceHandler.DriverSettings] (e.g., "s400_bind_key"). + * @property labelRes String resource for the input field label. + * @property descriptionRes Optional string resource shown as helper text above the input. + * @property placeholderRes Optional string resource shown as placeholder inside the input. + * @property errorRes Optional string resource shown when validation fails. + * @property icon Optional leading icon for the input field. + * @property maxLength Maximum input length; also used for validation (input is valid when length == maxLength). + * @property inputFilter How to filter keystrokes (e.g., [InputFilter.HEX] for hex-only input). + */ +data class ScaleConfigField( + val key: String, + @StringRes val labelRes: Int, + @StringRes val descriptionRes: Int? = null, + @StringRes val placeholderRes: Int? = null, + @StringRes val errorRes: Int? = null, + val icon: ImageVector? = null, + val maxLength: Int? = null, + val inputFilter: InputFilter = InputFilter.NONE, +) + /** * # ScaleDeviceHandler * @@ -114,6 +143,22 @@ abstract class ScaleDeviceHandler { */ abstract fun supportFor(device: ScannedDeviceInfo): DeviceSupport? + /** + * Stable identifier used as the namespace in persisted driver settings keys. + * Matches the value used by [FacadeDriverSettings]: `"ble/{handlerNamespace}/{address}/{key}"`. + */ + val handlerNamespace: String get() = this::class.simpleName ?: "Handler" + + /** + * Override to declare configuration fields shown in the Bluetooth detail screen. + * + * Each returned [ScaleConfigField] becomes an input field that the user can edit. + * The values are persisted via [DriverSettings] using the field's [ScaleConfigField.key]. + * + * Default: empty list (no handler-specific configuration). + */ + open fun configFields(): List = emptyList() + // --- Lifecycle entry points called by the adapter ------------------------- internal fun attach(transport: Transport, callbacks: Callbacks, settings: DriverSettings, data: DataProvider) { diff --git a/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt b/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt index f6b3fbb22..294b6022d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/facade/BluetoothFacade.kt @@ -26,6 +26,7 @@ import com.health.openscale.core.bluetooth.BluetoothEvent import com.health.openscale.core.bluetooth.ScaleFactory import com.health.openscale.core.bluetooth.data.ScaleUser import com.health.openscale.core.bluetooth.scales.DeviceSupport +import com.health.openscale.core.bluetooth.scales.ScaleConfigField import com.health.openscale.core.bluetooth.scales.TuningProfile import com.health.openscale.core.data.ConnectionStatus import com.health.openscale.core.data.User @@ -42,6 +43,7 @@ import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.core.service.BluetoothScannerManager import com.health.openscale.core.service.BleConnector import com.health.openscale.ui.shared.SnackbarEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi /** * Facade responsible for orchestrating Bluetooth operations. @@ -117,6 +119,35 @@ class BluetoothFacade @Inject constructor( } } + // --- Handler configuration --- + // Exposes the saved device's handler config fields and provides generic read/write + // using the same key format as FacadeDriverSettings: "ble/{handlerNamespace}/{address}/{key}" + + /** Config fields declared by the handler for the currently saved device. */ + val savedDeviceConfigFields: StateFlow> = + savedDevice.map { device -> + if (device == null) return@map emptyList() + scaleFactory.getHandlerFor(device)?.configFields() ?: emptyList() + }.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList()) + + /** Observe a single driver setting value for the currently saved device. */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeDriverSetting(key: String): Flow { + return savedDevice.flatMapLatest { device -> + if (device == null) return@flatMapLatest flowOf("") + val ns = scaleFactory.getHandlerFor(device)?.handlerNamespace + ?: return@flatMapLatest flowOf("") + settingsFacade.observeSetting("ble/$ns/${device.address}/$key", "") + } + } + + /** Save a driver setting value for the currently saved device. */ + suspend fun saveDriverSetting(key: String, value: String) { + val device = savedDevice.value ?: return + val ns = scaleFactory.getHandlerFor(device)?.handlerNamespace ?: return + settingsFacade.saveSetting("ble/$ns/${device.address}/$key", value) + } + // --- Current user context --- private val currentAppUser = MutableStateFlow(null) private val currentBtScaleUser = MutableStateFlow(null) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt index 67fc1955e..49ac30aae 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothDetailScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -69,6 +70,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.health.openscale.R +import com.health.openscale.core.bluetooth.scales.InputFilter +import com.health.openscale.core.bluetooth.scales.ScaleConfigField import com.health.openscale.core.bluetooth.scales.TuningProfile import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.MeasurementTypeIcon @@ -107,6 +110,10 @@ fun BluetoothDetailScreen( val availableTuningProfiles = remember { TuningProfile.entries.toList() } var showToleranceDialog by remember { mutableStateOf(false) } + // --- Handler Configuration State --- + val configFields by bluetoothViewModel.configFields.collectAsStateWithLifecycle() + val configValues by bluetoothViewModel.configValues.collectAsStateWithLifecycle() + if (showToleranceDialog) { NumberInputDialog( title = stringResource(R.string.tolerance_label), @@ -139,6 +146,24 @@ fun BluetoothDetailScreen( .padding(all = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + // --- HANDLER CONFIGURATION SECTION (shown when handler declares config fields) --- + if (configFields.isNotEmpty()) { + SettingsSectionTitle(title = stringResource(R.string.scale_configuration_title)) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + configFields.forEach { field -> + ScaleConfigFieldInput( + field = field, + currentValue = configValues[field.key] ?: "", + onSave = { value -> + scope.launch { bluetoothViewModel.setConfigValue(field.key, value) } + } + ) + } + } + } + } + // --- DEVICE TUNING SECTION --- SettingsSectionTitle(title = stringResource(R.string.bluetooth_tuning_title)) ExposedDropdownMenuBox( @@ -375,3 +400,78 @@ private fun SettingsRow( } } } + +/** + * Generic composable that renders a single [ScaleConfigField] as an input field + * with appropriate filtering, validation, and a save button. + */ +@Composable +private fun ScaleConfigFieldInput( + field: ScaleConfigField, + currentValue: String, + onSave: (String) -> Unit +) { + var inputValue by remember(currentValue) { mutableStateOf(currentValue) } + var showError by remember { mutableStateOf(false) } + + val maxLen = field.maxLength + + // Description text + field.descriptionRes?.let { resId -> + Text( + text = stringResource(resId), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + OutlinedTextField( + value = inputValue, + onValueChange = { newValue -> + val filtered = when (field.inputFilter) { + InputFilter.HEX -> newValue + .filter { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + .lowercase() + InputFilter.NONE -> newValue + } + val capped = if (maxLen != null) filtered.take(maxLen) else filtered + inputValue = capped + showError = capped.isNotEmpty() && maxLen != null && capped.length != maxLen + }, + label = { Text(stringResource(field.labelRes)) }, + leadingIcon = field.icon?.let { icon -> + { Icon(imageVector = icon, contentDescription = null) } + }, + placeholder = field.placeholderRes?.let { resId -> + { Text(stringResource(resId)) } + }, + isError = showError, + supportingText = when { + showError && field.errorRes != null -> { + { Text(stringResource(field.errorRes)) } + } + maxLen != null -> { + { Text("${inputValue.length}/$maxLen") } + } + else -> null + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + val isValid = maxLen == null || inputValue.length == maxLen + TextButton( + onClick = { if (isValid) onSave(inputValue) }, + enabled = isValid && inputValue != currentValue + ) { + Text(stringResource(R.string.save)) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt index 8e044a695..e43c40af5 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothViewModel.kt @@ -42,15 +42,23 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent +import com.health.openscale.core.bluetooth.scales.ScaleConfigField import com.health.openscale.core.bluetooth.scales.TuningProfile import com.health.openscale.core.facade.BluetoothFacade import com.health.openscale.core.facade.SettingsFacade import com.health.openscale.core.service.ScannedDeviceInfo import com.health.openscale.ui.shared.SnackbarEvent import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -89,6 +97,30 @@ class BluetoothViewModel @Inject constructor( val smartAssignmentTolerancePercent = settingsFacade.smartAssignmentTolerancePercent val smartAssignmentIgnoreOutsideTolerance = settingsFacade.smartAssignmentIgnoreOutsideTolerance + // --- Handler-declared configuration fields --- + val configFields: StateFlow> = bt.savedDeviceConfigFields + + /** Current values for each handler config field, keyed by [ScaleConfigField.key]. */ + @OptIn(ExperimentalCoroutinesApi::class) + val configValues: StateFlow> = configFields + .flatMapLatest { fields -> + if (fields.isEmpty()) return@flatMapLatest flowOf(emptyMap()) + val flows = fields.map { field -> + bt.observeDriverSetting(field.key).stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5000), "" + ) + } + combine(flows) { values -> + fields.mapIndexed { i, field -> field.key to values[i] }.toMap() + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + /** Save a handler config field value. */ + fun setConfigValue(key: String, value: String) = viewModelScope.launch { + bt.saveDriverSetting(key, value) + } + fun setSmartAssignmentEnabled(enabled: Boolean) = viewModelScope.launch { settingsFacade.setSmartAssignmentEnabled(enabled) } diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 09f98e13f..c45301695 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ Clear Confirm OK + Save On Off Open link @@ -358,6 +359,16 @@ Developer Danger Zone + + Scale Configuration + + + BLE Bind Key + 32-character hex key + The Xiaomi S400 scale requires a BLE bind key for decryption. Extract this key from Xiaomi Cloud using the Xiaomi Cloud Tokens Extractor tool. + Bind key must be exactly 32 hex characters + S400: Bind key not configured. Go to Bluetooth settings to enter your BLE key. + Connected to %1$s Connection to %1$s failed: %2$s diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/S400DecryptorTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/S400DecryptorTest.kt new file mode 100644 index 000000000..7f718a49e --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/S400DecryptorTest.kt @@ -0,0 +1,199 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * 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 . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [S400Decryptor]. + * + * Test vectors are derived from the mi-scale-exporter project: + * https://github.com/lswiderski/MiScaleBodyComposition + */ +class S400DecryptorTest { + + companion object { + // Test configuration from mi-scale-exporter test suite + private const val TEST_MAC = "84:46:93:64:A5:E6" + private const val TEST_BIND_KEY = "58305740b64e4b425e518aa1f4e51339" + + private fun hexToByteArray(hex: String): ByteArray { + val cleanHex = hex.replace(" ", "").lowercase() + return cleanHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + } + + // --- Validation tests --- + + @Test + fun isValidBindKey_acceptsValid32CharHexKey() { + assertThat(S400Decryptor.isValidBindKey("58305740b64e4b425e518aa1f4e51339")).isTrue() + assertThat(S400Decryptor.isValidBindKey("00000000000000000000000000000000")).isTrue() + assertThat(S400Decryptor.isValidBindKey("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")).isTrue() + assertThat(S400Decryptor.isValidBindKey("abcdef0123456789abcdef0123456789")).isTrue() + } + + @Test + fun isValidBindKey_rejectsInvalidKeys() { + // Too short + assertThat(S400Decryptor.isValidBindKey("58305740b64e4b425e518aa1f4e5133")).isFalse() + // Too long + assertThat(S400Decryptor.isValidBindKey("58305740b64e4b425e518aa1f4e513399")).isFalse() + // Contains invalid characters + assertThat(S400Decryptor.isValidBindKey("58305740b64e4b425e518aa1f4e5133g")).isFalse() + // Empty + assertThat(S400Decryptor.isValidBindKey("")).isFalse() + } + + @Test + fun isValidMacAddress_acceptsValidFormats() { + assertThat(S400Decryptor.isValidMacAddress("84:46:93:64:A5:E6")).isTrue() + assertThat(S400Decryptor.isValidMacAddress("00:00:00:00:00:00")).isTrue() + assertThat(S400Decryptor.isValidMacAddress("FF:FF:FF:FF:FF:FF")).isTrue() + assertThat(S400Decryptor.isValidMacAddress("aa:bb:cc:dd:ee:ff")).isTrue() + } + + @Test + fun isValidMacAddress_rejectsInvalidFormats() { + // No colons + assertThat(S400Decryptor.isValidMacAddress("844693e4A5E6")).isFalse() + // Wrong separator + assertThat(S400Decryptor.isValidMacAddress("84-46-93-64-A5-E6")).isFalse() + // Too short + assertThat(S400Decryptor.isValidMacAddress("84:46:93:64:A5")).isFalse() + // Invalid characters + assertThat(S400Decryptor.isValidMacAddress("84:46:93:64:A5:GG")).isFalse() + // Empty + assertThat(S400Decryptor.isValidMacAddress("")).isFalse() + } + + // --- Decryption tests using vectors from mi-scale-exporter --- + + @Test + fun decrypt_24ByteData_returnsCorrectWeight() { + // Test1 from S400Test.cs: expected weight = 74.2 + val data = hexToByteArray("4859d53b2d3314943c58b133638c7457a4000000c3e670dc") + + val result = S400Decryptor.decrypt(data, TEST_MAC, TEST_BIND_KEY) + + assertThat(result).isNotNull() + assertThat(result!!.weightKg).isWithin(0.1f).of(74.2f) + } + + @Test + fun decrypt_26ByteDataFromHex_returnsCorrectWeight() { + // Test26bytesHex from S400Test.cs: expected weight = 73.2 + val data = hexToByteArray("95FE4859D53B3BDE6BC8D05B51C0CDFD9021C9000000925C5039") + + val result = S400Decryptor.decrypt(data, TEST_MAC, TEST_BIND_KEY) + + assertThat(result).isNotNull() + assertThat(result!!.weightKg).isWithin(0.1f).of(73.2f) + } + + @Test + fun decrypt_26ByteDataFromBytes_returnsCorrectWeight() { + // Test26bytes from S400Test.cs: expected weight = 73.3 + val data = byteArrayOf( + 149.toByte(), 254.toByte(), 72, 89, 213.toByte(), 59, 77, 111, 53, + 156.toByte(), 229.toByte(), 111, 31, 126, 126, 10, 221.toByte(), + 220.toByte(), 38, 0, 0, 0, 12, 19, 211.toByte(), 196.toByte() + ) + + val result = S400Decryptor.decrypt(data, TEST_MAC, TEST_BIND_KEY) + + assertThat(result).isNotNull() + assertThat(result!!.weightKg).isWithin(0.1f).of(73.3f) + } + + @Test + fun decrypt_weightOnlyData_returnsWeightWithNoImpedance() { + // Test26bytesOnlyWeight from S400Test.cs: weight > 0, impedance = null + val data = byteArrayOf( + 149.toByte(), 254.toByte(), 72, 89, 213.toByte(), 59, 99, 187.toByte(), + 88, 121, 80, 225.toByte(), 4, 44, 172.toByte(), 28, 95, 24, 246.toByte(), + 0, 0, 0, 219.toByte(), 233.toByte(), 112, 52 + ) + + val result = S400Decryptor.decrypt(data, TEST_MAC, TEST_BIND_KEY) + + assertThat(result).isNotNull() + assertThat(result!!.weightKg).isGreaterThan(0f) + assertThat(result.impedance).isNull() + } + + @Test + fun decrypt_invalidDataLength_returnsNull() { + // TestJustMACAddress from S400Test.cs: too short data (11 bytes) + val data = hexToByteArray("1059d53b06e6a5649346 84") + + val result = S400Decryptor.decrypt(data, TEST_MAC, TEST_BIND_KEY) + + assertThat(result).isNull() + } + + @Test + fun decrypt_invalidBindKey_returnsNull() { + val data = hexToByteArray("4859d53b2d3314943c58b133638c7457a4000000c3e670dc") + val invalidKey = "00000000000000000000000000000000" + + // Decryption with wrong key should fail (or return null/invalid data) + val result = S400Decryptor.decrypt(data, TEST_MAC, invalidKey) + + // With wrong key, decryption will either: + // 1. Return null (if exception is caught) + // 2. Return invalid measurement (weight = 0 or nonsense) + val isInvalidResult = result == null || result.weightKg <= 0f || result.weightKg > 500f + assertThat(isInvalidResult).isTrue() + } + + @Test + fun decrypt_shortBindKey_returnsNull() { + val data = hexToByteArray("4859d53b2d3314943c58b133638c7457a4000000c3e670dc") + val shortKey = "58305740b64e4b42" // Only 16 chars instead of 32 + + val result = S400Decryptor.decrypt(data, TEST_MAC, shortKey) + + assertThat(result).isNull() + } + + @Test + fun decrypt_emptyData_returnsNull() { + val result = S400Decryptor.decrypt(byteArrayOf(), TEST_MAC, TEST_BIND_KEY) + assertThat(result).isNull() + } + + // --- Measurement value extraction tests --- + + @Test + fun measurement_hasExpectedFields() { + val data = hexToByteArray("4859d53b2d3314943c58b133638c7457a4000000c3e670dc") + + val result = S400Decryptor.decrypt(data, TEST_MAC, TEST_BIND_KEY) + + assertThat(result).isNotNull() + // Weight should be reasonable (between 0 and 300 kg) + assertThat(result!!.weightKg).isGreaterThan(0f) + assertThat(result.weightKg).isLessThan(300f) + // Impedance if present should be reasonable (typically 300-1000 ohms for body) + result.impedance?.let { imp -> + assertThat(imp).isGreaterThan(0f) + } + } +} diff --git a/android_app/gradle/libs.versions.toml b/android_app/gradle/libs.versions.toml index 13db668cd..43701e338 100644 --- a/android_app/gradle/libs.versions.toml +++ b/android_app/gradle/libs.versions.toml @@ -25,6 +25,7 @@ glance = "1.1.1" kotlinCsv = "1.10.0" blessedKotlin = "3.0.11" truth = "1.4.5" +bouncycastle = "1.79" [libraries] @@ -67,6 +68,7 @@ androidx-glance-material3 = { group = "androidx.glance", name = "glance-material kotlin-csv-jvm = { group = "com.github.doyaaaaaken", name = "kotlin-csv-jvm", version.ref = "kotlinCsv" } blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } [plugins]