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