@@ -22,59 +22,51 @@ import com.health.openscale.core.bluetooth.data.ScaleUser
2222import com.health.openscale.core.service.ScannedDeviceInfo
2323import com.health.openscale.core.utils.LogManager
2424import java.util.Date
25+ import java.util.TimeZone
2526import java.util.Timer
2627import java.util.TimerTask
2728import 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 */
3840class 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