Skip to content

Commit 5cdbd96

Browse files
Fix Renpho ES-30M (QN-Scale) support (#1301)
The ES-30M scale was connecting but not recording measurements due to incomplete protocol handshake and incorrect frame parsing. Changes: - Fix 0x21 handler to send two 0xA0 responses (was sending one incorrect response) - Add 0x22 data query after 0xA0 responses - Fix 0x14 handler to send proper 0x20 time sync with real timestamp - Add format detection in 0x10 weight parser for ES-30M vs other QN scales - ES-30M: byte[4]=stable, bytes[5,6]=weight, bytes[7-10]=resistances - Original: byte[5]=stable, bytes[3,4]=weight, bytes[6-9]=resistances - Add handlers for 0x23, 0xA1, 0xA3 opcodes The format detection is backward compatible and does not affect other QN scales. Detection logic: byte[4] <= 0x02 AND weightScaleFactor == 10.0f indicates ES-30M format. Fixes #986
1 parent c21cc35 commit 5cdbd96

1 file changed

Lines changed: 104 additions & 41 deletions

File tree

  • android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales

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

Lines changed: 104 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -171,85 +171,148 @@ class QNHandler : ScaleDeviceHandler() {
171171
when (data[0].toInt() and 0xFF) {
172172
0x10 -> handleLiveWeightFrame(data, user) // live / stable weight frame
173173
0x14 -> {
174-
// This opcode can be a reply/ack from older scale types.
175-
logD("QN: received 0x14 frame, sending response")
174+
// Scale acknowledgment after unit config - respond with 0x20 time sync
175+
logD("QN: received 0x14 frame, sending 0x20 time sync")
176+
177+
// Timestamp: seconds since 2000-01-01 (QN epoch), little-endian
178+
val epochSecs = (System.currentTimeMillis() / 1000L) - SCALE_UNIX_TIMESTAMP_OFFSET
179+
val t = epochSecs.toInt()
180+
176181
val msg = byteArrayOf(
177-
0x20, // Command
182+
0x20, // Opcode
178183
0x08, // Length
179-
seenProtocolType, // Echo back the protocol type we just saw
180-
0x25, // Payload byte 1
181-
0x74, // Payload byte 2
182-
0x18, // Payload byte 3
183-
0x30, // Payload byte 4
184+
seenProtocolType,
185+
(t and 0xFF).toByte(),
186+
((t ushr 8) and 0xFF).toByte(),
187+
((t ushr 16) and 0xFF).toByte(),
188+
((t ushr 24) and 0xFF).toByte(),
184189
0x00 // Checksum placeholder
185190
)
186191
msg[msg.lastIndex] = checksum(msg, 0, msg.lastIndex - 1)
187192

188193
if (hasCharacteristic(SVC_T2, CHR_T2_WRITE_SHARED)) {
189194
writeTo(SVC_T2, CHR_T2_WRITE_SHARED, msg, true)
190-
} else if (hasCharacteristic(SVC_T1, CHR_T1_WRITE_CONFIG)) { // Fallback to Type 1
195+
} else if (hasCharacteristic(SVC_T1, CHR_T1_WRITE_CONFIG)) {
191196
writeTo(SVC_T1, CHR_T1_WRITE_CONFIG, msg, true)
192197
}
193198
}
194199
0x12 -> handleScaleInfoFrame(data) // scale factor setup
195200
0x21 -> {
196-
logD("QN: received 0x21 frame, sending response")
197-
val msg = byteArrayOf(
198-
0xa0.toByte(), // Command
199-
0x0d.toByte(), // Length (13 bytes total)
200-
seenProtocolType, // Protocol Type
201+
// ES-30M requires TWO 0xA0 response frames (from BLE capture analysis)
202+
logD("QN: received 0x21 frame, sending TWO 0xA0 responses")
203+
204+
// Response 1: a00d04fe0000000000000000XX
205+
val msg1 = byteArrayOf(
206+
0xa0.toByte(), // Opcode
207+
0x0d, // Length (13 bytes)
208+
0x04, // Sub-opcode type (not protocol type!)
201209
0xfe.toByte(), // Payload
202-
0xff.toByte(),
203-
0xee.toByte(),
204-
0x01.toByte(),
205-
0x1c.toByte(),
206-
0x06.toByte(),
207-
0x86.toByte(),
208-
0x03.toByte(),
209-
0x02.toByte(),
210-
0x00.toByte() // Checksum placeholder
210+
0x00, 0x00, 0x00, 0x00,
211+
0x00, 0x00, 0x00, 0x00,
212+
0x00 // Checksum placeholder
211213
)
212-
msg[msg.lastIndex] = checksum(msg, 0, msg.lastIndex - 1)
214+
msg1[msg1.lastIndex] = checksum(msg1, 0, msg1.lastIndex - 1)
215+
216+
// Response 2: a00d02010008002106b804029d
217+
val msg2 = byteArrayOf(
218+
0xa0.toByte(), // Opcode
219+
0x0d, // Length (13 bytes)
220+
0x02, // Sub-opcode type (not protocol type!)
221+
0x01, 0x00, 0x08, 0x00,
222+
0x21, 0x06, 0xb8.toByte(), 0x04, 0x02,
223+
0x00 // Checksum placeholder
224+
)
225+
msg2[msg2.lastIndex] = checksum(msg2, 0, msg2.lastIndex - 1)
213226

214-
// Write to the appropriate characteristic
227+
// Write both responses
215228
if (hasCharacteristic(SVC_T2, CHR_T2_WRITE_SHARED)) {
216-
writeTo(SVC_T2, CHR_T2_WRITE_SHARED, msg, true)
217-
} else if (hasCharacteristic(SVC_T1, CHR_T1_WRITE_CONFIG)) { // Fallback to Type 1
218-
writeTo(SVC_T1, CHR_T1_WRITE_CONFIG, msg, true)
229+
writeTo(SVC_T2, CHR_T2_WRITE_SHARED, msg1, true)
230+
writeTo(SVC_T2, CHR_T2_WRITE_SHARED, msg2, true)
231+
} else if (hasCharacteristic(SVC_T1, CHR_T1_WRITE_CONFIG)) {
232+
writeTo(SVC_T1, CHR_T1_WRITE_CONFIG, msg1, true)
233+
writeTo(SVC_T1, CHR_T1_WRITE_CONFIG, msg2, true)
234+
}
235+
236+
// After 0xA0 responses, send 0x22 query for stored data
237+
val queryMsg = byteArrayOf(
238+
0x22, // Opcode
239+
0x06, // Length
240+
seenProtocolType,
241+
0x00, 0x03,
242+
0x00 // Checksum placeholder
243+
)
244+
queryMsg[queryMsg.lastIndex] = checksum(queryMsg, 0, queryMsg.lastIndex - 1)
245+
246+
if (hasCharacteristic(SVC_T2, CHR_T2_WRITE_SHARED)) {
247+
writeTo(SVC_T2, CHR_T2_WRITE_SHARED, queryMsg, true)
248+
} else if (hasCharacteristic(SVC_T1, CHR_T1_WRITE_CONFIG)) {
249+
writeTo(SVC_T1, CHR_T1_WRITE_CONFIG, queryMsg, true)
219250
}
220251
}
221-
//0x23 -> { /* historical record frame (timestamp+impedance) – not implemented */ }
252+
0x23 -> {
253+
// Historical record frame - user data from scale memory
254+
logD("QN: received user data frame (0x23)")
255+
}
256+
0xA1 -> {
257+
// Acknowledgment from scale
258+
logD("QN: received 0xA1 acknowledgment")
259+
}
260+
0xA3 -> {
261+
// Acknowledgment from scale
262+
logD("QN: received 0xA3 acknowledgment")
263+
}
222264
else -> logD("QN: unhandled opcode=0x${(data[0].toInt() and 0xFF).toString(16)} ${data.toHexPreview(24)}")
223265
}
224266
}
225267

226268
/**
227-
* 0x10 frame: live weight updates. When stable flag (byte[5] == 1) is seen,
228-
* we parse weight and optional resistances (bytes [6..9]) and publish one result.
269+
* 0x10 frame: live weight updates.
270+
* Two formats exist:
271+
* - Original: byte[3,4]=weight, byte[5]=stable, bytes[6-9]=resistances
272+
* - ES-30M: byte[3]=unit, byte[4]=stable, bytes[5,6]=weight, bytes[7-10]=resistances
229273
*/
230274
private fun handleLiveWeightFrame(data: ByteArray, user: ScaleUser) {
231275
logD( "QN: raw notify: ${data.toHexPreview(24)}")
232276

233-
// Need at least up to indices 9 to read resistances safely.
234-
if (data.size < 10) return
277+
// Detect format by checking if byte[4] looks like a stable flag (0x00, 0x01, 0x02)
278+
// vs weight data (typically > 0x10)
279+
val byte4Value = data[4].toInt() and 0xFF
280+
val isES30MFormat = byte4Value <= 0x02 && weightScaleFactor == 10.0f
281+
282+
val stable: Boolean
283+
val raw: Float
284+
val r1: Float
285+
val r2: Float
286+
287+
if (isES30MFormat) {
288+
// ES-30M format: byte[4]=stable, bytes[5,6]=weight
289+
if (data.size < 11) return
290+
val stableFlag = byte4Value
291+
stable = stableFlag == 0x02 || stableFlag == 0x01
292+
raw = u16be(data[5], data[6])
293+
r1 = u16be(data[7], data[8])
294+
r2 = u16be(data[9], data[10])
295+
logD("QN: using ES-30M format (byte[4]=$stableFlag)")
296+
} else {
297+
// Original format: byte[5]=stable, bytes[3,4]=weight
298+
if (data.size < 10) return
299+
stable = data[5].toInt() == 1
300+
raw = u16be(data[3], data[4])
301+
r1 = u16be(data[6], data[7])
302+
r2 = u16be(data[8], data[9])
303+
logD("QN: using original format")
304+
}
235305

236-
val stable = data[5].toInt() == 1
237306
if (!stable || hasPublishedForThisSession) return
238307

239-
// Weight is (bytes 3,4) / weightScaleFactor
240-
val raw = u16be(data[3], data[4])
241308
var weightKg = raw / weightScaleFactor
242309

243-
// Heuristic fallback: some type 2 devices report with /10 even before 0x12 arrives.
310+
// Heuristic fallback: some "type 2" devices report with /10 even before 0x12 arrives.
244311
// If weight looks unreasonably small or large, try the /10 fallback once.
245312
if (weightKg <= 5f || weightKg >= 250f) {
246313
weightKg = weightKg / 10.0f
247314
}
248315

249-
// Optional resistances (often two values). We primarily use the first one.
250-
val r1 = u16be(data[6], data[7])
251-
val r2 = u16be(data[8], data[9])
252-
253316
logD( "QN: weight=$weightKg kg, r1=$r1, r2=$r2 (weight scale factor is = $weightScaleFactor)")
254317

255318
if (weightKg > 0f) {

0 commit comments

Comments
 (0)