Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 92 additions & 2 deletions src/scales/qn-scale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();

/** Deduplication guards: prevent duplicate state machine responses. */
private configSent = false;
private timeSyncSent = false;
Expand All @@ -141,6 +148,39 @@ export class QnScaleAdapter implements ScaleAdapter {
/** Fallback timer handle for cancellation when state machine fires normally. */
private fallbackTimer: ReturnType<typeof setTimeout> | 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<typeof setInterval> | 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<void> {
if (!this.ctx) return;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
82 changes: 82 additions & 0 deletions tests/scales/qn-scale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading