Skip to content

Commit 0757487

Browse files
authored
Updating the code to handle any MAC address and timestamps (#1319)
* Updating the code to handle any MAC address and fully deobfuscate packets * Update to handle timestamps correctly
1 parent eddf451 commit 0757487

1 file changed

Lines changed: 88 additions & 78 deletions

File tree

android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/RealmeSmartScaleHandler.kt

Lines changed: 88 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -22,59 +22,51 @@ import com.health.openscale.core.bluetooth.data.ScaleUser
2222
import com.health.openscale.core.service.ScannedDeviceInfo
2323
import com.health.openscale.core.utils.LogManager
2424
import java.util.Date
25+
import java.util.TimeZone
2526
import java.util.Timer
2627
import java.util.TimerTask
2728
import java.util.UUID
29+
import kotlin.math.roundToInt
2830

2931
/**
30-
* Handler for the Realme Smart Scale (Lifesense lineage).
32+
* Handler for the Realme Smart Scale (Lifesense A6 lineage).
3133
* Protocol architecture:
3234
* - Requires a 6-step active handshake written to 0xA624 immediately upon connection.
33-
* - Handshake automatically triggers an unacknowledged dump of the offline history buffer.
35+
* - Handshake commands are dynamically generated and XOR-obfuscated using the scale's MAC address.
3436
* - Requires a continuous 0x0001D9 keep-alive ping written to 0xA622 every 1 second.
3537
* - Live and history measurement streams to 0xA621.
36-
* - Final locked measurement packet identifiable by prefix [0x10, 0x11].
38+
* - Packets are fully XOR encrypted using a repeating MAC[i % 6] cipher.
3739
*/
3840
class RealmeScaleHandler : ScaleDeviceHandler() {
3941

4042
companion object {
4143
private const val TAG = "RealmeScaleHandler"
4244

43-
// Service & Characteristics
4445
private val SVC_A602 = UUID.fromString("0000a602-0000-1000-8000-00805f9b34fb")
45-
private val CHR_A621 = UUID.fromString("0000a621-0000-1000-8000-00805f9b34fb") // Notify (Measurements)
46-
private val CHR_A622 = UUID.fromString("0000a622-0000-1000-8000-00805f9b34fb") // Write (Keep-Alive)
47-
private val CHR_A624 = UUID.fromString("0000a624-0000-1000-8000-00805f9b34fb") // Write (Handshake)
48-
private val CHR_A625 = UUID.fromString("0000a625-0000-1000-8000-00805f9b34fb") // Notify (Echos/Keep-Alive)
46+
private val CHR_A621 = UUID.fromString("0000a621-0000-1000-8000-00805f9b34fb")
47+
private val CHR_A622 = UUID.fromString("0000a622-0000-1000-8000-00805f9b34fb")
48+
private val CHR_A624 = UUID.fromString("0000a624-0000-1000-8000-00805f9b34fb")
49+
private val CHR_A625 = UUID.fromString("0000a625-0000-1000-8000-00805f9b34fb")
4950

5051
private val KEEP_ALIVE_CMD = byteArrayOf(0x00, 0x01, 0xD9.toByte())
51-
52-
private val HANDSHAKE_CMDS = listOf(
53-
byteArrayOf(0x10, 0x0b, 0xd8.toByte(), 0x03, 0xca.toByte(), 0x14, 0x0e, 0xc4.toByte(), 0xd8.toByte(), 0x0b, 0xcb.toByte(), 0x14, 0x0c),
54-
byteArrayOf(0x10, 0x08, 0xd8.toByte(), 0x01, 0xd3.toByte(), 0x7d, 0x97.toByte(), 0x14, 0x61, 0x37),
55-
byteArrayOf(0x10, 0x04, 0x90.toByte(), 0x0a, 0xcb.toByte(), 0x15),
56-
byteArrayOf(0x10, 0x0b, 0xc8.toByte(), 0x0a, 0xca.toByte(), 0x14, 0x1a, 0xc4.toByte(), 0x77, 0x0b, 0xcb.toByte(), 0x0d, 0x6a),
57-
byteArrayOf(0x10, 0x03, 0xc8.toByte(), 0x0f, 0xcb.toByte()),
58-
byteArrayOf(0x10, 0x03, 0xc8.toByte(), 0x0c, 0xca.toByte())
59-
)
6052
}
6153

6254
private var keepAliveTimer: Timer? = null
63-
64-
// Time Anchoring Variables
65-
private val historyBuffer = mutableListOf<Pair<Long, ScaleMeasurement>>()
66-
private var isBuffering = true
67-
private var timeOffset: Long = 0
55+
private var scaleMacBytes = ByteArray(6)
6856

6957
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
7058
val name = device.name.lowercase()
7159
val hasSvc = device.serviceUuids.any { it == SVC_A602 }
7260

7361
if (!name.contains("realme") && !hasSvc) return null
7462

63+
// Cache the MAC address for the XOR cipher
64+
scaleMacBytes = macStringToBytes(device.address)
65+
7566
val caps = setOf(
7667
DeviceCapability.BODY_COMPOSITION,
77-
DeviceCapability.LIVE_WEIGHT_STREAM
68+
DeviceCapability.LIVE_WEIGHT_STREAM,
69+
DeviceCapability.HISTORY_READ
7870
)
7971

8072
return DeviceSupport(
@@ -86,19 +78,14 @@ class RealmeScaleHandler : ScaleDeviceHandler() {
8678
}
8779

8880
override fun onConnected(user: ScaleUser) {
89-
historyBuffer.clear()
90-
timeOffset = 0
91-
isBuffering = true
92-
9381
setNotifyOn(SVC_A602, CHR_A621)
9482
setNotifyOn(SVC_A602, CHR_A625)
9583

96-
LogManager.d(TAG, "Sending handshake sequence...")
97-
HANDSHAKE_CMDS.forEach { cmd ->
84+
LogManager.d(TAG, "Generating and sending dynamic MAC-obfuscated handshake...")
85+
buildHandshake(user).forEach { cmd ->
9886
writeTo(SVC_A602, CHR_A624, cmd)
9987
}
10088

101-
// Start the Keep-Alive loop
10289
keepAliveTimer = Timer()
10390
keepAliveTimer?.scheduleAtFixedRate(object : TimerTask() {
10491
override fun run() {
@@ -110,13 +97,6 @@ class RealmeScaleHandler : ScaleDeviceHandler() {
11097
}
11198
}, 500, 1000)
11299

113-
// Close the buffer 2.5 seconds after connection to process the archive dump
114-
Timer().schedule(object : TimerTask() {
115-
override fun run() {
116-
flushHistoryBuffer()
117-
}
118-
}, 2500)
119-
120100
LogManager.i(TAG, "Realme scale connected and active.")
121101
}
122102

@@ -134,50 +114,27 @@ class RealmeScaleHandler : ScaleDeviceHandler() {
134114
LogManager.d(TAG, "Disconnected. Stopped keep-alive timer.")
135115
}
136116

137-
private fun flushHistoryBuffer() {
138-
synchronized(historyBuffer) {
139-
if (historyBuffer.isNotEmpty()) {
140-
// Find the newest timestamp in the buffer
141-
val maxScaleTime = historyBuffer.maxOf { it.first }
142-
val currentUnixTime = System.currentTimeMillis() / 1000L
143-
144-
// Calculate the delta to shift the scale's broken 2024 clock to the present
145-
timeOffset = currentUnixTime - maxScaleTime
146-
LogManager.i(TAG, "History buffer closed. Calculated clock offset: +$timeOffset seconds.")
147-
148-
historyBuffer.forEach { (scaleTime, measurement) ->
149-
measurement.dateTime = Date((scaleTime + timeOffset) * 1000L)
150-
LogManager.i(TAG, "Offline Measurement: Weight=${measurement.weight} kg, Date=${measurement.dateTime}")
151-
publish(measurement)
152-
}
153-
historyBuffer.clear()
154-
} else {
155-
LogManager.i(TAG, "History buffer closed. No offline records found.")
156-
}
157-
isBuffering = false
158-
}
159-
}
160-
161117
private fun parseMeasurement(data: ByteArray, user: ScaleUser) {
162-
// Reverse engineered XOR decoding
163-
val weightRaw = u16be(data, 10)
164-
val weightKg = (weightRaw xor 0xCB14) / 100.0f
118+
// Strip header/length and completely decrypt the payload
119+
val payload = deobfuscate(data)
165120

166-
val impRaw = u16be(data, 16)
167-
val impedance = impRaw xor 0xCB14
121+
// Read directly from the clean payload bytes
122+
val weightRaw = u16be(payload, 8)
123+
val weightKg = weightRaw / 100.0f
168124

169-
val scaleTime = u32be(data, 12)
125+
val scaleTime = u32be(payload, 10)
126+
val impedance = u16be(payload, 14)
170127

171128
// Sanity check
172129
if (weightKg <= 0.5f || weightKg > 300f) return
173130

174131
val measurement = ScaleMeasurement().apply {
175132
this.userId = user.id
133+
this.dateTime = if (scaleTime > 0) Date(scaleTime * 1000L) else Date()
176134
this.weight = weightKg
177135
}
178136

179137
// --- LOCAL BIA CALCULATION ENGINE ---
180-
// If impedance > 0, the user was barefoot. Run the local math.
181138
if (impedance > 0) {
182139
measurement.impedance = impedance.toDouble()
183140

@@ -196,15 +153,68 @@ class RealmeScaleHandler : ScaleDeviceHandler() {
196153
}
197154
}
198155

199-
synchronized(historyBuffer) {
200-
if (isBuffering) {
201-
historyBuffer.add(Pair(scaleTime, measurement))
202-
} else {
203-
measurement.dateTime = Date((scaleTime + timeOffset) * 1000L)
204-
LogManager.i(TAG, "Live Measurement: Weight=${measurement.weight} kg, Fat=${measurement.fat}%, Date=${measurement.dateTime}")
205-
publish(measurement)
156+
LogManager.i(TAG, "Measurement: Weight=$weightKg kg, Fat=${measurement.fat}%, Date=${measurement.dateTime}")
157+
publish(measurement)
158+
}
159+
160+
// --- PROTOCOL DYNAMIC GENERATORS ---
161+
162+
private fun deobfuscate(data: ByteArray): ByteArray {
163+
val payload = ByteArray(data.size - 2)
164+
for (i in payload.indices) {
165+
payload[i] = (data[i + 2].toInt() xor (scaleMacBytes[i % 6].toInt() and 0xFF)).toByte()
166+
}
167+
return payload
168+
}
169+
170+
private fun buildHandshake(user: ScaleUser): List<ByteArray> {
171+
val ts = (System.currentTimeMillis() / 1000L).toInt()
172+
val tz = (TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60000).toByte()
173+
174+
val sexByte = if (user.gender.isMale()) 0x00.toByte() else 0x80.toByte()
175+
val hCm = user.bodyHeight.roundToInt()
176+
177+
// Handle new users with no initial weight
178+
val weightToSend = if (user.initialWeight <= 0.0f) {
179+
0xFFFF
180+
} else {
181+
(user.initialWeight * 100.0f).roundToInt()
182+
}
183+
184+
// Un-obfuscated raw payloads
185+
val p1 = byteArrayOf(0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02) // Register
186+
val p2 = byteArrayOf(0x00, 0x0A, 0x18, (ts shr 24).toByte(), (ts shr 16).toByte(), (ts shr 8).toByte(), ts.toByte(), tz) // Set Time
187+
val p3 = byteArrayOf(0x48, 0x01, 0x00, 0x01) // Start Measure
188+
val p4 = byteArrayOf(
189+
0x10, 0x01, 0x01, sexByte, user.age.toByte(),
190+
(hCm shr 8).toByte(), hCm.toByte(), 0x00, 0x00,
191+
(weightToSend shr 8).toByte(), weightToSend.toByte()
192+
) // User Info
193+
val p5 = byteArrayOf(0x10, 0x04, 0x00) // Formula
194+
val p6 = byteArrayOf(0x10, 0x07, 0x01) // Unit (KG)
195+
196+
return listOf(p1, p2, p3, p4, p5, p6).map { wrapAndObfuscate(it) }
197+
}
198+
199+
private fun wrapAndObfuscate(payload: ByteArray): ByteArray {
200+
val out = ByteArray(payload.size + 2)
201+
out[0] = 0x10 // Header
202+
out[1] = payload.size.toByte() // Length
203+
for (i in payload.indices) {
204+
out[i + 2] = (payload[i].toInt() xor (scaleMacBytes[i % 6].toInt() and 0xFF)).toByte()
205+
}
206+
return out
207+
}
208+
209+
private fun macStringToBytes(mac: String): ByteArray {
210+
val clean = mac.replace(":", "").replace("-", "")
211+
val out = ByteArray(6)
212+
if (clean.length == 12) {
213+
for (i in 0 until 6) {
214+
out[i] = clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
206215
}
207216
}
217+
return out
208218
}
209219

210220
private fun u16be(b: ByteArray, off: Int): Int {
@@ -215,8 +225,8 @@ class RealmeScaleHandler : ScaleDeviceHandler() {
215225
private fun u32be(b: ByteArray, off: Int): Long {
216226
if (off + 3 >= b.size) return 0
217227
return ((b[off].toLong() and 0xFF) shl 24) or
218-
((b[off + 1].toLong() and 0xFF) shl 16) or
219-
((b[off + 2].toLong() and 0xFF) shl 8) or
220-
(b[off + 3].toLong() and 0xFF)
228+
((b[off + 1].toLong() and 0xFF) shl 16) or
229+
((b[off + 2].toLong() and 0xFF) shl 8) or
230+
(b[off + 3].toLong() and 0xFF)
221231
}
222232
}

0 commit comments

Comments
 (0)