Skip to content

Commit 74bd6cd

Browse files
committed
feat(qn): add ble.qn_report_byte so the history-response byte can be tested
The handshake answers the scale's 0x21 config request with `a0 0d 04 fe 00 ...`. That 0xFE comes from openScale's QNHandler, which took it from an ES-30M capture and annotates it only as a payload byte. Two vendor-app captures on other firmware in the family send 0xFC in the same position: a GE CS 10 G on the 20-byte extended dialect (#235) and an Arboleaf QN-Scale FW V39 on the 19-byte es26m dialect (#75). Both were taken from sessions where the vendor app completed a weigh-in, on scales where this adapter sees the entire handshake acknowledged and then silence. Two reporters reached the same reading of the byte independently, that it chooses between a live report stream and the stored-history path. The default does not move, because that reading is not established: openScale dispatches live 0x10 weight frames while sending 0xFE, and the 0x23 stored-record path this adapter relies on for V10 Renpho and ES-CS20M firmware (#213) hangs off the same exchange. A wrong value here is silent in exactly the way a wrong qn_protocol_byte is, so guessing would trade a diagnosable failure for an undiagnosable one. The setting lets the reporters run the experiment on their own hardware, and the default changes when it produces a reading. Both QN bytes are now reachable from the Home Assistant add-on as well. They were previously custom_config only, which is not a usable escape hatch for the reporters who need them, since all three run the add-on. They are text options so that unset stays distinguishable from 0, and a value outside 0 to 255 is dropped with a warning rather than written into a config that would then fail validation. The A00D pair had no test coverage at all; it now has three tests, one of which pins that the second frame does not move with the first. Refs #235, #75, #331
1 parent 4413599 commit 74bd6cd

10 files changed

Lines changed: 210 additions & 5 deletions

File tree

ble-scale-sync-addon/config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ options:
3838
scale_mac: ''
3939
ble_adapter: ''
4040
force_scale_adapter: ''
41+
qn_protocol_byte: ''
42+
qn_report_byte: ''
4143
weight_unit: kg
4244
height_unit: cm
4345
user_name: Default
@@ -66,6 +68,8 @@ schema:
6668
scale_mac: str?
6769
ble_adapter: str?
6870
force_scale_adapter: str?
71+
qn_protocol_byte: str?
72+
qn_report_byte: str?
6973
weight_unit: list(kg|lbs)
7074
height_unit: list(cm|in)
7175
user_name: str

ble-scale-sync-addon/run.sh

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ else
4545

4646
SCALE_MAC=$(opt scale_mac)
4747
FORCE_SCALE_ADAPTER=$(opt force_scale_adapter)
48+
QN_PROTOCOL_BYTE=$(opt qn_protocol_byte)
49+
QN_REPORT_BYTE=$(opt qn_report_byte)
4850

4951
WEIGHT_UNIT=$(opt weight_unit)
5052
HEIGHT_UNIT=$(opt height_unit)
@@ -135,12 +137,31 @@ YAML
135137
FORCE_SCALE_ADAPTER=""
136138
fi
137139

138-
# BLE section (only if scale_mac, adapter or a forced scale adapter is set)
139-
if [ -n "$SCALE_MAC" ] || [ -n "$BLE_ADAPTER" ] || [ -n "$FORCE_SCALE_ADAPTER" ]; then
140+
# The two QN bytes are text options so that "unset" stays distinguishable from
141+
# 0, which is a meaningful value for both. Anything that is not a plain 0 to
142+
# 255 integer is dropped with a warning rather than written into config.yaml,
143+
# where it would fail schema validation and stop the add-on from starting.
144+
for _qn in QN_PROTOCOL_BYTE QN_REPORT_BYTE; do
145+
eval "_qv=\$$_qn"
146+
[ -z "$_qv" ] && continue
147+
case "$_qv" in
148+
''|*[!0-9]*) _ok=0 ;;
149+
*) [ "$_qv" -le 255 ] && _ok=1 || _ok=0 ;;
150+
esac
151+
if [ "$_ok" != "1" ]; then
152+
log "WARNING: Invalid $(echo "$_qn" | tr '[:upper:]' '[:lower:]') '$_qv' (expected 0 to 255). Ignoring."
153+
eval "$_qn=''"
154+
fi
155+
done
156+
157+
# BLE section (only if scale_mac, adapter, a forced scale adapter or a QN byte is set)
158+
if [ -n "$SCALE_MAC" ] || [ -n "$BLE_ADAPTER" ] || [ -n "$FORCE_SCALE_ADAPTER" ] || [ -n "$QN_PROTOCOL_BYTE" ] || [ -n "$QN_REPORT_BYTE" ]; then
140159
echo "ble:" >> "$FRESH"
141160
[ -n "$SCALE_MAC" ] && echo " scale_mac: \"$(yaml_escape "$SCALE_MAC")\"" >> "$FRESH"
142161
[ -n "$BLE_ADAPTER" ] && echo " adapter: \"$(yaml_escape "$BLE_ADAPTER")\"" >> "$FRESH"
143162
[ -n "$FORCE_SCALE_ADAPTER" ] && echo " force_scale_adapter: \"$(yaml_escape "$FORCE_SCALE_ADAPTER")\"" >> "$FRESH"
163+
[ -n "$QN_PROTOCOL_BYTE" ] && echo " qn_protocol_byte: $QN_PROTOCOL_BYTE" >> "$FRESH"
164+
[ -n "$QN_REPORT_BYTE" ] && echo " qn_report_byte: $QN_REPORT_BYTE" >> "$FRESH"
144165
echo "" >> "$FRESH"
145166
fi
146167

ble-scale-sync-addon/translations/en.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ configuration:
1919
(e.g. Hutbit). Requires the scale MAC to be set, because a forced adapter
2020
claims every device it is shown. Please also report the misdetection so it
2121
can be fixed for everyone.
22+
qn_protocol_byte:
23+
name: QN protocol byte
24+
description: >-
25+
QN-family scales only (Renpho, Arboleaf, FITINDEX, GE and rebadges).
26+
Leave empty unless your QN scale connects, completes the whole handshake
27+
in the log and then never reports a weight. Then try 0, or 255, or the
28+
value the "QN: scale info" log line shows as proto. Accepts 0 to 255.
29+
qn_report_byte:
30+
name: QN history response byte
31+
description: >-
32+
QN-family scales only, and only worth trying after the QN protocol byte
33+
did not help. Try 252, which is what vendor-app captures of a GE CS 10 G
34+
and an Arboleaf QN-Scale send where this add-on sends the default 254.
35+
Accepts 0 to 255.
2236
weight_unit:
2337
name: Weight unit
2438
description: >-

docs/guide/configuration.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ ble:
6060
# force_scale_adapter: 'Hutbit'
6161
# session_timeout_sec: 20
6262
# qn_protocol_byte: 0
63+
# qn_report_byte: 252
6364
```
6465

6566
| Field | Required | Default | Description |
@@ -72,6 +73,7 @@ ble:
7273
| `force_scale_adapter` | No | Auto-detect | Name of the scale protocol adapter to use, bypassing auto-detection. Requires `scale_mac`. See below. |
7374
| `session_timeout_sec` | No | `120` | Seconds one GATT session may wait for a complete reading (5 to 600). Native BLE handlers only; ignored on `mqtt-proxy` and `esphome-proxy`. See below. |
7475
| `qn_protocol_byte` | No | Auto | QN-family scales only. Protocol byte the handshake echoes back to the scale (0 to 255). Set it when a QN scale runs the whole handshake and then reports nothing, or when its scale-info frame is lost in transit on a proxy transport. See below. |
76+
| `qn_report_byte` | No | `254` (0xFE) | QN-family scales only. Payload byte of the history-response frame (0 to 255). Try `252` (0xFC) when a QN scale completes the handshake and then reports nothing. See below. |
7577
| `mqtt_proxy` | If `handler: mqtt-proxy` | (none) | MQTT proxy connection (`broker_url`, `device_id`, `topic_prefix`, `username`, `password`, `auto_connect`, `embedded_broker_*`). See [ESP32 BLE Proxy](./esp32-proxy). |
7678
| `esphome_proxy` | If `handler: esphome-proxy` | (none) | ESPHome Native API connection (`host`, `port`, `encryption_key` or `password`, `client_info`). See [ESPHome Bluetooth Proxy](./esphome-proxy). |
7779

@@ -118,6 +120,36 @@ If a value makes your scale work, please say so in an issue with the model and t
118120

119121
:::
120122

123+
::: tip QN scales that still report nothing (`qn_report_byte`)
124+
125+
If `qn_protocol_byte` did not help, there is one more byte worth trying, and it is a separate one.
126+
127+
When the scale asks for its configuration (`0x21`), the handshake answers with a history-response frame:
128+
129+
```
130+
a0 0d 04 fe 00 00 00 00 00 00 00 00 <checksum>
131+
^^
132+
```
133+
134+
That `fe` comes from openScale, which took it from a capture of an ES-30M and labels it only as a payload byte. Vendor-app captures of two other scales in the family send `fc` in the same position: a GE CS 10 G and an Arboleaf QN-Scale on firmware V39. Both captures are of sessions where the vendor app completed a weigh-in, on scales where this app sees the whole handshake acknowledged and then nothing.
135+
136+
What the byte actually selects is not known. Both reporters read it as choosing between a live weight stream and the stored-history path, which fits their symptoms, but openScale receives live weight frames while sending `fe`, so that reading cannot be the whole story. The default therefore stays where the evidence is:
137+
138+
```yaml
139+
ble:
140+
qn_report_byte: 252 # 0xFC, the value both vendor-app captures send
141+
```
142+
143+
With debug logging on, a session running an overridden byte says so:
144+
145+
```
146+
QN: history response byte forced to 0xfc (default 0xfe)
147+
```
148+
149+
If `252` makes your scale produce a weight, please say so in an issue with the model, the dialect from the `QN: scale info` line and that log line. Two confirmations on different firmware would be enough to move the default.
150+
151+
:::
152+
121153
::: tip Shortening the session (`session_timeout_sec`)
122154
Some scales will not run a standalone weigh-in while a host holds the GATT session open. The Beurer BF500 is the clearest example: it displays `APP` and waits, so only a measurement taken **between** sessions is picked up.
123155

src/config/schema.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ export const BleSchema = z
140140
* (proxy transports) and sessions without it open on the wrong byte.
141141
*/
142142
qn_protocol_byte: z.number().int().min(0).max(255).optional().nullable(),
143+
/**
144+
* Payload byte of the QN A00D history-response frame, default 0xFE (#235,
145+
* #75, #331).
146+
*
147+
* The handshake answers the scale's 0x21 config request with
148+
* `a0 0d 04 <byte> 00 ...`. The default comes from openScale's QNHandler,
149+
* which took it from an ES-30M capture. Two vendor-app captures on other
150+
* firmware in the same family send 0xFC there instead: a GE CS 10 G (20-byte
151+
* dialect) and an Arboleaf QN-Scale V39 (19-byte es26m), both from sessions
152+
* that produced a reading in the vendor app while ble-scale-sync saw the
153+
* handshake acknowledged and then silence.
154+
*
155+
* What the byte selects is NOT decoded. openScale annotates it only as
156+
* "Payload", and it demonstrably does not gate the live 0x10 stream, since
157+
* openScale receives those frames while sending 0xFE. So this ships as a
158+
* setting rather than a changed default: on a scale that reads today, 0xFE
159+
* is the value with evidence behind it, and a wrong choice here is silent in
160+
* exactly the way `qn_protocol_byte` is.
161+
*/
162+
qn_report_byte: z.number().int().min(0).max(255).optional().nullable(),
143163
mqtt_proxy: MqttProxySchema.optional(),
144164
esphome_proxy: EsphomeProxySchema.optional(),
145165
})

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ async function main(): Promise<void> {
286286
const applyAdapterConfig = (bindKey: string | undefined): void => {
287287
const weightUnit = ctx.config.scale.weight_unit;
288288
const qnProtocolByte = ctx.config.ble?.qn_protocol_byte ?? undefined;
289-
for (const a of adapters) a.configure?.({ bindKey, weightUnit, qnProtocolByte });
289+
const qnReportByte = ctx.config.ble?.qn_report_byte ?? undefined;
290+
for (const a of adapters) a.configure?.({ bindKey, weightUnit, qnProtocolByte, qnReportByte });
290291
};
291292
applyAdapterConfig(ctx.config.ble?.bind_key ?? undefined);
292293

src/interfaces/scale-adapter.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,16 @@ export interface AdapterRuntimeConfig {
154154
* The frame length picks a default; this overrides it (#75, #331).
155155
*/
156156
qnProtocolByte?: number;
157+
/**
158+
* Payload byte of the QN A00D history-response frame (`ble.qn_report_byte`).
159+
*
160+
* Defaults to 0xFE, the value openScale's ES-30M capture uses. Two vendor-app
161+
* captures on other firmware in the family send 0xFC instead, from sessions
162+
* that read successfully where this adapter saw the whole handshake
163+
* acknowledged and then silence. What the byte selects is not decoded, so it
164+
* is a setting rather than a changed default (#235, #75, #331).
165+
*/
166+
qnReportByte?: number;
157167
}
158168

159169
/**

src/scales/qn-scale.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,34 @@ const CHR_SIG_WEIGHT_MEASUREMENT = uuid16(0x2a9d);
113113
/** Seconds from Unix epoch to 2000-01-01 00:00:00 UTC. */
114114
const SCALE_EPOCH_OFFSET = 946684800;
115115

116+
/**
117+
* Payload byte of the A00D history-response frame sent in reply to the scale's
118+
* 0x21 config request: `a0 0d 04 <byte> 00 ...`.
119+
*
120+
* 0xFE comes from openScale's QNHandler, which annotates it only as "Payload"
121+
* and took it from an ES-30M BLE capture. Two vendor-app captures on other
122+
* firmware in this family send 0xFC in the same position instead:
123+
*
124+
* #235 GE CS 10 G, 20-byte extended dialect
125+
* #75 Arboleaf QN-Scale FW V39, 19-byte es26m dialect
126+
*
127+
* Both were taken from sessions where the vendor app completed a weigh-in while
128+
* this adapter saw the whole handshake acknowledged and then silence, and both
129+
* reporters reached the same reading of it independently: that the byte selects
130+
* between a live report stream and the stored-history path.
131+
*
132+
* That reading is NOT established, and the default therefore does not move.
133+
* openScale dispatches live 0x10 weight frames while sending 0xFE, so the byte
134+
* plainly does not gate the live stream on the firmware it was captured from,
135+
* and the 0x23 stored-record path this adapter relies on for V10 Renpho and
136+
* ES-CS20M firmware (#213) hangs off the same exchange. A wrong value here is
137+
* silent in exactly the way a wrong `qn_protocol_byte` is: every command is
138+
* acknowledged and no weight ever arrives. So `ble.qn_report_byte` exists to
139+
* let the reporters test 0xFC on their own hardware, and the default changes
140+
* only if that produces a reading.
141+
*/
142+
const REPORT_BYTE_DEFAULT = 0xfe;
143+
116144
/**
117145
* Grace period (ms) to wait for an impedance frame after the first stable
118146
* R1=R2=0 frame on long-frame variants (e.g. ES-26M). If an impedance frame
@@ -339,6 +367,12 @@ export class QnScaleAdapter
339367
*/
340368
private forcedProtocolType: number | null = null;
341369

370+
/**
371+
* Payload byte of the A00D history-response frame, forced by
372+
* `ble.qn_report_byte` (#235, #75, #331). Null leaves REPORT_BYTE_DEFAULT.
373+
*/
374+
private forcedReportByte: number | null = null;
375+
342376
/**
343377
* Whether a completed-weigh-in result frame (0xB4/0xB1) has already produced a
344378
* reading this session. The scale repeats the 0xB4 frame ~3x and then sends
@@ -379,6 +413,7 @@ export class QnScaleAdapter
379413
configure(opts: AdapterRuntimeConfig): void {
380414
if (opts.weightUnit) this.displayUnit = opts.weightUnit;
381415
this.forcedProtocolType = opts.qnProtocolByte ?? null;
416+
this.forcedReportByte = opts.qnReportByte ?? null;
382417
}
383418

384419
/** 0x13 config unit flag: 0x01 kg, 0x02 lb (openScale QNHandler). */
@@ -1132,9 +1167,31 @@ export class QnScaleAdapter
11321167
this.historyResponseSent = true;
11331168
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
11341169

1135-
// A00D response 1 (from openScale QNHandler)
1136-
const msg1 = [0xa0, 0x0d, 0x04, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
1170+
// A00D response 1 (from openScale QNHandler). byte[3] is the payload byte
1171+
// `ble.qn_report_byte` overrides; see REPORT_BYTE_DEFAULT.
1172+
const msg1 = [
1173+
0xa0,
1174+
0x0d,
1175+
0x04,
1176+
this.forcedReportByte ?? REPORT_BYTE_DEFAULT,
1177+
0x00,
1178+
0x00,
1179+
0x00,
1180+
0x00,
1181+
0x00,
1182+
0x00,
1183+
0x00,
1184+
0x00,
1185+
0x00,
1186+
];
11371187
msg1[12] = msg1.reduce((a, b) => a + b, 0) & 0xff;
1188+
if (this.forcedReportByte !== null) {
1189+
bleLog.debug(
1190+
`QN: history response byte forced to ` +
1191+
`0x${this.forcedReportByte.toString(16).padStart(2, '0')} ` +
1192+
`(default 0x${REPORT_BYTE_DEFAULT.toString(16)})`,
1193+
);
1194+
}
11381195
await this.writeCmd(msg1);
11391196

11401197
await wait(200);

tests/config/schema.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,18 @@ describe('BleSchema', () => {
390390
}
391391
});
392392

393+
it('accepts ble.qn_report_byte across the whole byte range', () => {
394+
for (const byte of [0, 0xfc, 0xfe, 255]) {
395+
expect(BleSchema.safeParse({ qn_report_byte: byte }).success).toBe(true);
396+
}
397+
});
398+
399+
it('rejects a ble.qn_report_byte that is not a byte', () => {
400+
for (const byte of [-1, 256, 1.5]) {
401+
expect(BleSchema.safeParse({ qn_report_byte: byte }).success).toBe(false);
402+
}
403+
});
404+
393405
it('rejects ble.session_timeout_sec outside 5 to 600', () => {
394406
for (const secs of [4, 601]) {
395407
expect(BleSchema.safeParse({ session_timeout_sec: secs }).success).toBe(false);

tests/scales/qn-scale.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,40 @@ describe('AE02 dispatch (#75, #235)', () => {
13871387
expect(config).toEqual([0x13, 0x09, 0xff, 0x01, 0x10, 0x00, 0x00, 0x00, 0x2c]);
13881388
});
13891389

1390+
// The A00D history-response pair had no coverage at all before #235/#75
1391+
// put its payload byte in question, so these pin both the default and the
1392+
// override.
1393+
it('sends the A00D history response with the default payload byte 0xFE', async () => {
1394+
const adapter = makeAdapter();
1395+
const writes = await driveHandshake(adapter, makeExtendedScaleInfo());
1396+
const msg1 = writes.find((w) => w[0] === 0xa0 && w[2] === 0x04);
1397+
expect(msg1).toBeDefined();
1398+
expect(msg1![3]).toBe(0xfe);
1399+
expect(msg1![12]).toBe(msg1!.slice(0, 12).reduce((a, b) => a + b, 0) & 0xff);
1400+
});
1401+
1402+
it('applies ble.qn_report_byte to the A00D payload byte and recomputes the checksum', async () => {
1403+
// 0xFC is the value both vendor-app captures send in this position.
1404+
const adapter = makeAdapter();
1405+
adapter.configure({ qnReportByte: 0xfc });
1406+
const writes = await driveHandshake(adapter, makeExtendedScaleInfo());
1407+
const msg1 = writes.find((w) => w[0] === 0xa0 && w[2] === 0x04);
1408+
expect(msg1![3]).toBe(0xfc);
1409+
expect(msg1![12]).toBe(msg1!.slice(0, 12).reduce((a, b) => a + b, 0) & 0xff);
1410+
});
1411+
1412+
it('leaves the second A00D frame alone when the report byte is overridden', async () => {
1413+
// Only byte[3] of the 0x04 frame is in question. The 0x02 frame is a
1414+
// separate command and must not move with it.
1415+
const adapter = makeAdapter();
1416+
adapter.configure({ qnReportByte: 0xfc });
1417+
const writes = await driveHandshake(adapter, makeExtendedScaleInfo());
1418+
const msg2 = writes.find((w) => w[0] === 0xa0 && w[2] === 0x02);
1419+
expect(msg2).toEqual([
1420+
0xa0, 0x0d, 0x02, 0x01, 0x00, 0x08, 0x00, 0x21, 0x06, 0xb8, 0x04, 0x02, 0x9d,
1421+
]);
1422+
});
1423+
13901424
it('20B 0x12 frame makes the 0x22 START byte identical to the vendor app', async () => {
13911425
const adapter = makeAdapter();
13921426
const writes = await driveHandshake(adapter, makeExtendedScaleInfo());

0 commit comments

Comments
 (0)