diff --git a/README.md b/README.md index fde2c858b..2409117f0 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ openScale has built-in support for a number of Bluetooth (BLE or "smart") scales - Silvercrest SBF75, SBF77 - Vigorun - Xiaomi Mi scale v1 and v2 -- Yunmai Mini and SE +- Yunmai Mini, SE and X - iHealth HS3 - Easy Home 64050 - and many more 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 c46c33d71..b15f16965 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 @@ -72,6 +72,7 @@ import com.health.openscale.core.bluetooth.scales.StandardBeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.TrisaBodyAnalyzeHandler import com.health.openscale.core.bluetooth.scales.TuningProfile import com.health.openscale.core.bluetooth.scales.YunmaiHandler +import com.health.openscale.core.bluetooth.scales.YunmaiXHandler import com.health.openscale.core.facade.MeasurementFacade import com.health.openscale.core.facade.SettingsFacade import com.health.openscale.core.facade.UserFacade @@ -111,6 +112,7 @@ class ScaleFactory @Inject constructor( RealmeSmartScaleHandler(), YunmaiHandler(isMini = false), YunmaiHandler(isMini = true), + YunmaiXHandler(), TrisaBodyAnalyzeHandler(), SanitasSbf72Handler(), StandardBeurerSanitasHandler(), diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt new file mode 100644 index 000000000..7d56a5e3a --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandler.kt @@ -0,0 +1,207 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.scales + +import android.bluetooth.le.ScanResult +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.YunmaiLib +import com.health.openscale.core.service.ScannedDeviceInfo +import java.util.Date + +/** + * Advertisement parser for the Yunmai X (YMBS-M268) broadcast protocol. + * + * The scale never needs a GATT connection for measurements: it broadcasts them as + * manufacturer-specific data (the vendor app labels this device family "YUNMAI-ADV"). + * It advertises WITHOUT a local name, so identification relies on the advertisement + * structure itself plus the advertised 16-bit service UUID 0x1320. + * + * Full manufacturer AD structure (type 0xFF, 16 bytes; reverse-engineered from a + * YMBS-M268 advertisement capture and confirmed against a live measurement): + * + * [0..5] device MAC, byte-reversed + * [6..8] constant signature 0B 74 17 + * [9] per-session byte, constant within a weigh-in but varying between them + * (observed 0x04, 0x0F, 0x10); purpose unknown, so it is ignored + * [10] state: 01 = measuring (live weight), 02 = weight stable, + * 03 = final result incl. impedance + * [11..12] weight, u16 BE, unit 0.01 kg + * [13..14] impedance, u16 BE, Ω (zero until state 03) + * [15] XOR checksum over bytes [9..14] + * + * Android peels the first two bytes off as the manufacturer id, so the payload seen here is + * the last 14 bytes (offsets above shift by -2). That id is just part of the reversed MAC, + * so matching relies on the MAC echo, the 0B 74 17 signature and the checksum. + */ +internal object YunmaiXAdv { + const val STATE_LIVE = 0x01 + const val STATE_STABLE = 0x02 + const val STATE_FINAL = 0x03 + + /** 16-bit service UUID present in the scale's advertisements. */ + const val ADV_SERVICE_UUID16 = 0x1320 + + data class Frame(val state: Int, val weightKg: Float, val impedanceOhm: Int) + + /** Parse one Android-split manufacturer-data entry; [address] enables the MAC-echo check (skipped if null). */ + fun parse(manufacturerId: Int, payload: ByteArray?, address: String?): Frame? { + if (payload == null || payload.size != 14) return null + + // Constant signature + if (payload[4] != 0x0B.toByte() || payload[5] != 0x74.toByte() || payload[6] != 0x17.toByte()) { + return null + } + + // XOR checksum over the six bytes preceding it + var checksum = 0 + for (i in 7..12) checksum = checksum xor (payload[i].toInt() and 0xFF) + if (checksum != (payload[13].toInt() and 0xFF)) return null + + // The advertisement echoes the device MAC byte-reversed across the + // manufacturer id and the first four payload bytes. + val octets = address?.split(":")?.mapNotNull { it.toIntOrNull(16) } + if (octets != null && octets.size == 6) { + val macMatches = manufacturerId == ((octets[4] shl 8) or octets[5]) && + (payload[0].toInt() and 0xFF) == octets[3] && + (payload[1].toInt() and 0xFF) == octets[2] && + (payload[2].toInt() and 0xFF) == octets[1] && + (payload[3].toInt() and 0xFF) == octets[0] + if (!macMatches) return null + } + + val state = payload[8].toInt() and 0xFF + val weightRaw = ((payload[9].toInt() and 0xFF) shl 8) or (payload[10].toInt() and 0xFF) + val impedance = ((payload[11].toInt() and 0xFF) shl 8) or (payload[12].toInt() and 0xFF) + return Frame(state, weightRaw / 100.0f, impedance) + } +} + +/** + * Yunmai X (YMBS-M268) – broadcast-only handler, see [YunmaiXAdv] for the protocol. + * + * Body composition is derived from weight + impedance via [YunmaiLib], like the + * connected Yunmai SE/Mini handler. The scale repeats the final frame for many + * seconds, so the handler publishes once and re-arms only when a new measurement + * session (live/stable frames) is observed. + */ +class YunmaiXHandler : ScaleDeviceHandler() { + + private val deviceSupport = DeviceSupport( + displayName = "Yunmai X", + capabilities = setOf( + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.LIVE_WEIGHT_STREAM + ), + implemented = setOf( + DeviceCapability.BODY_COMPOSITION + ), + linkMode = LinkMode.BROADCAST_ONLY + ) + + /** Publish the next final frame; re-armed by live/stable frames of a new session. */ + private var armed = true + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val manufacturerData = device.manufacturerData + if (manufacturerData != null) { + for (i in 0 until manufacturerData.size()) { + if (YunmaiXAdv.parse(manufacturerData.keyAt(i), manufacturerData.valueAt(i), device.address) != null) { + return deviceSupport + } + } + } + + // Saved-device snapshots carry no manufacturer data, and the scale advertises no + // name — fall back to the advertised 16-bit service UUID. + if (device.serviceUuids.any { it == uuid16(YunmaiXAdv.ADV_SERVICE_UUID16) }) { + return deviceSupport + } + + return null + } + + override fun onAdvertisement(result: ScanResult, user: ScaleUser): BroadcastAction { + val manufacturerData = result.scanRecord?.manufacturerSpecificData ?: return BroadcastAction.IGNORED + + var frame: YunmaiXAdv.Frame? = null + for (i in 0 until manufacturerData.size()) { + frame = YunmaiXAdv.parse( + manufacturerData.keyAt(i), + manufacturerData.valueAt(i), + result.device?.address + ) + if (frame != null) break + } + val f = frame ?: return BroadcastAction.IGNORED + + return when (f.state) { + YunmaiXAdv.STATE_LIVE, YunmaiXAdv.STATE_STABLE -> { + armed = true + BroadcastAction.CONSUMED_KEEP_SCANNING + } + + YunmaiXAdv.STATE_FINAL -> { + if (!armed) return BroadcastAction.CONSUMED_KEEP_SCANNING + if (f.weightKg <= 0f || !f.weightKg.isFinite()) return BroadcastAction.IGNORED + + publish(buildMeasurement(f, user)) + armed = false + logI("Measurement published: weight=${f.weightKg} kg, R=${f.impedanceOhm} Ω") + BroadcastAction.CONSUMED_STOP + } + + else -> BroadcastAction.IGNORED + } + } + + override fun onConnected(user: ScaleUser) { + logI("Yunmai X handler – broadcast-only, waiting for advertisements") + } + + private fun buildMeasurement(f: YunmaiXAdv.Frame, user: ScaleUser): ScaleMeasurement { + val m = ScaleMeasurement().apply { + userId = user.id + dateTime = Date() + weight = f.weightKg + } + + if (f.impedanceOhm > 0) { + m.impedance = f.impedanceOhm.toDouble() + + val sexInt = if (user.gender.isMale()) 1 else 0 + val yunmai = YunmaiLib(sexInt, user.bodyHeight, user.activityLevel) + val fatPct = yunmai.getFat(user.age, f.weightKg, f.impedanceOhm) + + if (fatPct > 0f && fatPct.isFinite()) { + m.fat = fatPct + m.muscle = yunmai.getMuscle(fatPct) + m.water = yunmai.getWater(fatPct) + m.bone = yunmai.getBoneMass(m.muscle, f.weightKg) + m.lbm = yunmai.getLeanBodyMass(f.weightKg, fatPct) + m.visceralFat = yunmai.getVisceralFat(fatPct, user.age) + } else { + logW("Body fat is zero/invalid (R=${f.impedanceOhm})") + } + } else { + logW("Final frame without impedance – weight-only measurement") + } + + return m + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt index c9447ba30..fc7663d39 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt @@ -248,7 +248,10 @@ fun BluetoothScreen( color = MaterialTheme.colorScheme.primary ) Text( - text = savedDevice?.name ?: stringResource(R.string.unknown_device), + // Some scales advertise no name; fall back to the handler's display name. + text = savedDevice?.name?.takeIf { it.isNotBlank() } + ?: savedDevice?.determinedHandlerDisplayName + ?: stringResource(R.string.unknown_device), style = MaterialTheme.typography.titleSmall ) Text( diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandlerTest.kt new file mode 100644 index 000000000..18dc8d580 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/YunmaiXHandlerTest.kt @@ -0,0 +1,186 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * 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.scales + +import android.util.SparseArray +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.service.ScannedDeviceInfo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +/** + * Tests for [YunmaiXAdv] frame parsing and [YunmaiXHandler.supportFor] matching. + * + * Fixtures are synthetic frames built to the reverse-engineered YMBS-M268 layout via + * [frame] (matching the shape observed on real hardware), not personal captures. The + * frames use a placeholder device address; Android parses the first two AD bytes as the + * manufacturer id, so [frame] returns the remaining 14-byte payload. + * + * Robolectric is required for android.util.SparseArray in ScannedDeviceInfo. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class YunmaiXHandlerTest { + + // Placeholder device: the advertisement echoes the MAC byte-reversed, and the last two + // bytes become Android's manufacturer id. + private val ADDRESS = "C0:FF:EE:12:34:56" + private val MFR_ID = 0x3456 // (0x34 << 8) or 0x56 + + /** + * Build a 14-byte manufacturer payload (as split by Android) for the placeholder device. + * + * @param state 0x01 live, 0x02 stable, 0x03 final + * @param weightRaw weight in units of 0.01 kg (u16 BE) + * @param impedance impedance in Ω (u16 BE); 0 until the final frame + * @param sessionByte the per-weigh-in byte at offset [7]; varies between sessions + */ + private fun frame(state: Int, weightRaw: Int, impedance: Int = 0, sessionByte: Int = 0x04): ByteArray { + val body = byteArrayOf( + 0x12.toByte(), 0xEE.toByte(), 0xFF.toByte(), 0xC0.toByte(), // MAC bytes 2..5, reversed + 0x0B, 0x74, 0x17, // signature + sessionByte.toByte(), + state.toByte(), + (weightRaw shr 8).toByte(), weightRaw.toByte(), + (impedance shr 8).toByte(), impedance.toByte() + ) + var xor = 0 + for (i in 7..12) xor = xor xor (body[i].toInt() and 0xFF) + return body + xor.toByte() + } + + private fun uuid16(short: Int): UUID = + UUID.fromString(String.format("0000%04x-0000-1000-8000-00805f9b34fb", short)) + + private fun device( + payload: ByteArray? = null, + mfrId: Int = MFR_ID, + address: String = ADDRESS, + vararg services: Int + ) = ScannedDeviceInfo( + name = "", + address = address, + rssi = -50, + serviceUuids = services.map { uuid16(it) }, + manufacturerData = payload?.let { + SparseArray().apply { put(mfrId, it) } + }, + ) + + // --- Frame parsing -------------------------------------------------------- + + @Test + fun `parses live frame`() { + val f = YunmaiXAdv.parse(MFR_ID, frame(0x01, weightRaw = 7955), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_LIVE) + assertThat(f.weightKg).isWithin(1e-3f).of(79.55f) + assertThat(f.impedanceOhm).isEqualTo(0) + } + + @Test + fun `parses stable frame`() { + val f = YunmaiXAdv.parse(MFR_ID, frame(0x02, weightRaw = 8000), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_STABLE) + assertThat(f.weightKg).isWithin(1e-3f).of(80.00f) + } + + @Test + fun `parses final frame with impedance`() { + val f = YunmaiXAdv.parse(MFR_ID, frame(0x03, weightRaw = 8000, impedance = 500), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_FINAL) + assertThat(f.weightKg).isWithin(1e-3f).of(80.00f) + assertThat(f.impedanceOhm).isEqualTo(500) + } + + @Test + fun `parses final frame regardless of the per-session byte`() { + // byte[7] varies between weigh-ins and must not be treated as a constant. + val f = YunmaiXAdv.parse(MFR_ID, frame(0x03, weightRaw = 8000, impedance = 500, sessionByte = 0x10), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_FINAL) + assertThat(f.weightKg).isWithin(1e-3f).of(80.00f) + assertThat(f.impedanceOhm).isEqualTo(500) + } + + @Test + fun `parses idle frame as zero weight`() { + val f = YunmaiXAdv.parse(MFR_ID, frame(0x01, weightRaw = 0), ADDRESS)!! + assertThat(f.state).isEqualTo(YunmaiXAdv.STATE_LIVE) + assertThat(f.weightKg).isEqualTo(0f) + } + + @Test + fun `rejects corrupted checksum`() { + val corrupted = frame(0x03, weightRaw = 8000, impedance = 500).also { it[9] = 0x1C } // weight changed, checksum stale + assertThat(YunmaiXAdv.parse(MFR_ID, corrupted, ADDRESS)).isNull() + } + + @Test + fun `rejects wrong signature`() { + val wrongSig = frame(0x03, weightRaw = 8000, impedance = 500).also { it[5] = 0x75 } + assertThat(YunmaiXAdv.parse(MFR_ID, wrongSig, ADDRESS)).isNull() + } + + @Test + fun `rejects frame whose MAC echo does not match the device address`() { + assertThat(YunmaiXAdv.parse(MFR_ID, frame(0x03, weightRaw = 8000, impedance = 500), "AA:BB:CC:DD:EE:FF")).isNull() + } + + @Test + fun `rejects wrong payload size`() { + assertThat(YunmaiXAdv.parse(MFR_ID, frame(0x03, weightRaw = 8000).copyOf(13), ADDRESS)).isNull() + } + + // --- Device matching -------------------------------------------------------- + + @Test + fun `claims nameless device advertising a valid measurement frame`() { + assertThat(YunmaiXHandler().supportFor(device(frame(0x03, weightRaw = 8000, impedance = 500)))).isNotNull() + } + + @Test + fun `claims nameless device advertising an idle frame`() { + assertThat(YunmaiXHandler().supportFor(device(frame(0x01, weightRaw = 0)))).isNotNull() + } + + @Test + fun `claims saved-device snapshot via service uuid 0x1320`() { + // Snapshots carry no manufacturer data, only the advertised service UUIDs. + assertThat(YunmaiXHandler().supportFor(device(null, MFR_ID, ADDRESS, 0x1320))).isNotNull() + } + + @Test + fun `does not claim device with foreign manufacturer data`() { + // Same length, invalid signature/checksum (e.g. a wearable's frame) + val foreign = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14) + assertThat(YunmaiXHandler().supportFor(device(foreign))).isNull() + } + + @Test + fun `does not claim device without manufacturer data or 0x1320 service`() { + assertThat(YunmaiXHandler().supportFor(device(null, MFR_ID, ADDRESS, 0x180D, 0x180F))).isNull() + } + + @Test + fun `does not claim valid frame relocated to another device address`() { + // Replayed/echoed frame on a different MAC must not match. + assertThat(YunmaiXHandler().supportFor(device(frame(0x03, weightRaw = 8000, impedance = 500), address = "AA:BB:CC:DD:EE:FF"))).isNull() + } +}