From 1089ac85834eea43648facd6d9c31a55da3fcc7e Mon Sep 17 00:00:00 2001 From: built-dev Date: Thu, 11 Jun 2026 15:58:21 -0500 Subject: [PATCH] fix(qn-scale): support store-and-forward variants that deliver 0x23 records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some QN-protocol Renpho units (confirmed on a real Elis 1 advertising as 'Renpho-Scale') never stream live 0x10 weight frames. The scale measures on its own, stores the finished measurement, and delivers it as a 0x23 record right after the handshake — frames the adapter previously ignored ('historical record, no action needed'). Every session ended in 'Scale disconnected before reading completed' with zero readings. Observed 19-byte 0x23 layout (captured from hardware): [3] total record count (00 = empty 'no more records' frame) [4] record index, 1-based [6-9] measurement timestamp (LE, seconds since 2000-01-01) [10-11] weight (BE, factor-scaled; alternate-factor heuristic applies) [12-13] R1, [14-15] R2 (BIA resistances) Two changes: - parse 0x23 records as readings, with a timestamp set to dedup replays across reconnects - when the history is empty (we connected mid-measurement, so the in-progress reading is not stored yet), re-send the 0x22 start command every 5s (max 12x); the scale then delivers the fresh record in-session — typically by the third nudge, ~15-20s after step-on Verified end-to-end on hardware: previously zero readings; with this change every weigh-in lands, whether the connection happens before, during, or after the measurement. --- src/scales/qn-scale.ts | 94 ++++++++++++++++++++++++++++++++++- tests/scales/qn-scale.test.ts | 82 ++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) diff --git a/src/scales/qn-scale.ts b/src/scales/qn-scale.ts index 42b32cb..3d2ab47 100644 --- a/src/scales/qn-scale.ts +++ b/src/scales/qn-scale.ts @@ -133,6 +133,13 @@ export class QnScaleAdapter implements ScaleAdapter { */ private firstStableNoImpedanceAt: number | null = null; + /** + * Scale-side timestamps (seconds since 2000) of accepted 0x23 + * stored-measurement records. Guards against the scale replaying the same + * record on reconnect. Deliberately NOT reset between connections. + */ + private seenHistoryTimestamps = new Set(); + /** Deduplication guards: prevent duplicate state machine responses. */ private configSent = false; private timeSyncSent = false; @@ -141,6 +148,39 @@ export class QnScaleAdapter implements ScaleAdapter { /** Fallback timer handle for cancellation when state machine fires normally. */ private fallbackTimer: ReturnType | null = null; + /** + * Nudge timer for store-and-forward variants connected mid-measurement. + * When the scale reports an empty history (it hasn't finished/stored the + * in-progress measurement yet), periodically re-send the 0x22 start command + * so the scale delivers the fresh record once it completes — instead of the + * session timing out with no reading. + */ + private measurementNudgeTimer: ReturnType | null = null; + private measurementNudgeCount = 0; + + private stopMeasurementNudge(): void { + if (this.measurementNudgeTimer) { + clearInterval(this.measurementNudgeTimer); + this.measurementNudgeTimer = null; + } + } + + private startMeasurementNudge(): void { + if (this.measurementNudgeTimer || !this.ctx) return; + this.measurementNudgeCount = 0; + this.measurementNudgeTimer = setInterval(() => { + this.measurementNudgeCount += 1; + if (this.measurementNudgeCount > 12 || !this.ctx) { + this.stopMeasurementNudge(); + return; + } + bleLog.debug(`QN: nudging scale for fresh record (0x22 re-send #${this.measurementNudgeCount})`); + const startCmd = [0x22, 0x06, this.seenProtocolType, 0x00, 0x03, 0x00]; + startCmd[5] = startCmd.reduce((a, b) => a + b, 0) & 0xff; + void this.writeCmd(startCmd).catch(() => this.stopMeasurementNudge()); + }, 5000); + } + /** Write to FFF2 (write char), fall back to FFE3 (Type 1). */ private async writeCmd(data: number[]): Promise { if (!this.ctx) return; @@ -192,6 +232,7 @@ export class QnScaleAdapter implements ScaleAdapter { clearTimeout(this.fallbackTimer); this.fallbackTimer = null; } + this.stopMeasurementNudge(); // Try subscribing to AE02 (newer firmware detection). // NOTE: on Linux, 0x12 may arrive before this completes. The state machine @@ -374,11 +415,60 @@ export class QnScaleAdapter implements ScaleAdapter { } // 0xA1, 0xA3: acknowledgment frames (no action needed) - // 0x23: historical record (no action needed) - if (opcode === 0xa1 || opcode === 0xa3 || opcode === 0x23) { + if (opcode === 0xa1 || opcode === 0xa3) { return null; } + // 0x23: stored measurement record. Some Renpho units (e.g. Elis 1 + // store-and-forward firmware) never stream live 0x10 frames: the scale + // finishes measuring during the connection handshake and delivers the + // result only as a stored record right after 0x22 start. Observed 19B + // frame layout: + // [3] total record count (00 = empty "no more records" frame) + // [4] record index, 1-based (00 in the empty frame) + // [6-9] measurement timestamp (LE, seconds since 2000-01-01) + // [10-11] weight (BE, factor-scaled) + // [12-13] R1, [14-15] R2 (BIA resistances) + if (opcode === 0x23) { + if (data.length < 16 || data[3] < 1 || data[4] < 1) { + // Empty "no records" frame — likely connected mid-measurement, the + // record isn't stored yet. Nudge until the fresh record arrives. + this.startMeasurementNudge(); + return null; + } + + const recordTs = data.readUInt32LE(6); + if (recordTs !== 0 && this.seenHistoryTimestamps.has(recordTs)) { + bleLog.debug('QN: stored record replayed (same timestamp), skipping'); + return null; + } + + const rawWeight = data.readUInt16BE(10); + let weight = rawWeight / this.weightScaleFactor; + + // Same heuristic as live 0x10 frames: stored records on factor=10 + // scales have been observed encoded at /100. + if (weight <= 5 || weight >= 250) { + const altFactor = this.weightScaleFactor === 100 ? 10 : 100; + const altWeight = rawWeight / altFactor; + if (altWeight > 5 && altWeight < 250) { + weight = altWeight; + } + } + if (weight <= 5 || weight >= 250 || !Number.isFinite(weight)) return null; + + const r1 = data.readUInt16BE(12); + const r2 = data.readUInt16BE(14); + const impedance = r1 > 0 ? r1 : r2; + + this.seenHistoryTimestamps.add(recordTs); + this.stopMeasurementNudge(); + bleLog.debug( + `QN: accepted stored record (ts=${recordTs}, weight=${weight}kg, R1=${r1}, R2=${r2})`, + ); + return { weight, impedance }; + } + // 0x10: live weight frame if (opcode !== 0x10 || data.length < 10) return null; diff --git a/tests/scales/qn-scale.test.ts b/tests/scales/qn-scale.test.ts index 898f4a0..3e05d70 100644 --- a/tests/scales/qn-scale.test.ts +++ b/tests/scales/qn-scale.test.ts @@ -735,4 +735,86 @@ describe('QnScaleAdapter', () => { expect(reading!.impedance).toBe(509); }); }); + // Store-and-forward variants (e.g. Renpho Elis 1): the scale never streams + // live 0x10 frames; finished measurements arrive as 0x23 stored records + // right after the handshake. Frames below were captured from real hardware. + describe('parseNotification() 0x23 stored records', () => { + /** Build a 19-byte 0x23 stored-record frame. */ + function makeStoredRecord( + count: number, + index: number, + ts: number, + rawWeight: number, + r1: number, + r2: number, + ): Buffer { + const buf = Buffer.alloc(19); + buf[0] = 0x23; + buf[1] = 0x13; + buf[2] = 0xff; + buf[3] = count; + buf[4] = index; + buf[5] = 0xf0; + buf.writeUInt32LE(ts, 6); + buf.writeUInt16BE(rawWeight, 10); + buf.writeUInt16BE(r1, 12); + buf.writeUInt16BE(r2, 14); + buf[18] = buf.subarray(0, 18).reduce((a, b) => a + b, 0) & 0xff; + return buf; + } + + it('parses a stored record (real Elis 1 capture: 77.7 kg, R1=497)', () => { + const adapter = makeAdapter(); + // 23 13 ff 01 01 f0 aa c9 bd 31 1e 5a 01 f1 01 ef 00 00 e2 + const buf = makeStoredRecord(1, 1, 0x31bdc9aa, 0x1e5a, 497, 495); + const reading = adapter.parseNotification(buf); + expect(reading).not.toBeNull(); + expect(reading!.weight).toBe(77.7); + expect(reading!.impedance).toBe(497); + }); + + it('applies the alternate-factor heuristic when scale info set factor=10', () => { + const adapter = makeAdapter(); + // Long 18-byte 0x12 frame (ES-26M style) → factor=10 + const infoBuf = Buffer.alloc(18); + infoBuf[0] = 0x12; + infoBuf[1] = 18; + adapter.parseNotification(infoBuf); + + // Raw 7770 at /10 = 777 kg (unreasonable) → heuristic /100 = 77.7 kg + const reading = adapter.parseNotification(makeStoredRecord(1, 1, 1000, 7770, 497, 495)); + expect(reading).not.toBeNull(); + expect(reading!.weight).toBe(77.7); + }); + + it('parses a multi-record session (count=2, distinct timestamps)', () => { + const adapter = makeAdapter(); + const first = adapter.parseNotification(makeStoredRecord(2, 1, 1000, 7760, 503, 501)); + const second = adapter.parseNotification(makeStoredRecord(2, 2, 1028, 7760, 503, 501)); + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(first!.weight).toBe(77.6); + expect(second!.weight).toBe(77.6); + }); + + it('returns null for the empty "no records" terminator frame', () => { + const adapter = makeAdapter(); + // 23 13 ff 00 00 00 ... (count=0, index=0) + expect(adapter.parseNotification(makeStoredRecord(0, 0, 0x31bdc285, 0, 0, 0))).toBeNull(); + }); + + it('deduplicates a replayed record by its scale-side timestamp', () => { + const adapter = makeAdapter(); + const buf = makeStoredRecord(1, 1, 2000, 7770, 497, 495); + expect(adapter.parseNotification(buf)).not.toBeNull(); + expect(adapter.parseNotification(buf)).toBeNull(); + }); + + it('falls back to R2 when R1 is zero', () => { + const adapter = makeAdapter(); + const reading = adapter.parseNotification(makeStoredRecord(1, 1, 3000, 7770, 0, 480)); + expect(reading).not.toBeNull(); + expect(reading!.impedance).toBe(480); + }); + }); });