@@ -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