fix: QN Scale notification-driven handshake for newer firmware (#75)#82
fix: QN Scale notification-driven handshake for newer firmware (#75)#82KristianP26 merged 18 commits intomainfrom
Conversation
…ility Some QN/Renpho firmware versions only respond to the 9-byte openScale unlock command, causing the scale to disconnect without sending data. The adapter now sends both the 6-byte and 9-byte variants on every unlock cycle. Also adds debug logging for unlock writes and exposes the diagnose command in Docker. Closes #75
There was a problem hiding this comment.
Pull request overview
This PR extends the legacy BLE “unlock” handshake to support sending multiple unlock command variants (to improve compatibility with QN/FITINDEX-style scales), adds a regression test for that behavior, and exposes the existing BLE diagnostic CLI via the Docker entrypoint.
Changes:
- Add optional
unlockCommandstoScaleAdapterand update legacy init to write each unlock command sequentially. - Update
QnScaleAdapterto provide both known QN unlock command variants. - Add a legacy-mode unit test ensuring all
unlockCommandsare written; adddiagnoseDocker command wiring.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/ble/shared.test.ts | Adds coverage for writing all unlockCommands in legacy mode. |
| src/scales/qn-scale.ts | Provides both QN unlock variants via unlockCommands. |
| src/interfaces/scale-adapter.ts | Extends adapter contract with optional unlockCommands. |
| src/ble/shared.ts | Writes multiple unlock commands in legacy init path and adds debug logging. |
| docker-entrypoint.sh | Adds diagnose command passthrough to the built CLI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| const commands = adapter.unlockCommands | ||
| ? adapter.unlockCommands.map((c) => Buffer.from(c)) | ||
| : [Buffer.from(adapter.unlockCommand)]; |
There was a problem hiding this comment.
adapter.unlockCommands is treated as truthy even when it's an empty array, which would result in commands being empty and no unlock write being sent (and unlockCommand being ignored). Consider falling back to unlockCommand when unlockCommands is undefined OR has length 0.
| const commands = adapter.unlockCommands | |
| ? adapter.unlockCommands.map((c) => Buffer.from(c)) | |
| : [Buffer.from(adapter.unlockCommand)]; | |
| const commands = | |
| adapter.unlockCommands && adapter.unlockCommands.length > 0 | |
| ? adapter.unlockCommands.map((c) => Buffer.from(c)) | |
| : [Buffer.from(adapter.unlockCommand)]; |
| const sendUnlock = async (): Promise<void> => { | ||
| if (isResolved()) return; | ||
| try { | ||
| await writeChar.write(unlockBuf, false); | ||
| } catch (e: unknown) { | ||
| if (!isResolved()) bleLog.error(`Unlock write error: ${errMsg(e)}`); | ||
| for (const buf of commands) { | ||
| try { | ||
| await writeChar.write(buf, false); | ||
| bleLog.debug( | ||
| `Unlock write: [${[...buf].map((b) => b.toString(16).padStart(2, '0')).join(' ')}]`, | ||
| ); | ||
| } catch (e: unknown) { | ||
| if (!isResolved()) bleLog.error(`Unlock write error: ${errMsg(e)}`); |
There was a problem hiding this comment.
sendUnlock() only checks isResolved() once before the loop. If the reading resolves (and cleanup runs) after the first write, this loop can still send remaining unlock commands even though the session is effectively done. Add an isResolved() check inside the loop (or break once resolved) to avoid unnecessary writes / side effects.
| bleLog.debug( | ||
| `Unlock write: [${[...buf].map((b) => b.toString(16).padStart(2, '0')).join(' ')}]`, | ||
| ); |
There was a problem hiding this comment.
This debug log eagerly builds a hex string via [...buf].map(...).join(...) on every unlock write; the string construction cost is paid even when DEBUG logging is disabled (because the argument is evaluated before bleLog.debug() checks the level), and it may also spam logs every unlockIntervalMs. Consider reducing/guarding this formatting (e.g. log buf.toString('hex'), log only once per connection, or add a cheap debug-enabled guard).
| bleLog.debug( | |
| `Unlock write: [${[...buf].map((b) => b.toString(16).padStart(2, '0')).join(' ')}]`, | |
| ); | |
| bleLog.debug(`Unlock write: [${buf.toString('hex')}]`); |
Newer QN firmware (Renpho ES-CS20M, Elis 1) requires an AE01 init write to activate FFF1 notifications. Without it, the scale connects but disconnects without sending any weight data. The adapter now performs the full handshake observed in the official Renpho app packet capture: 1. Subscribe to AE02 notifications (if available) 2. Write AE01 init command (wakes up FFF1 channel) 3. Send three unlock variants (6-byte, 9-byte openScale, 9-byte app) 4. Send user profile command (triggers measurement) All AE00 steps are optional with try/catch, so older firmware that only needs the FFF2 unlock continues to work. Converted from legacy unlockCommands to onConnected for the multi-step handshake. Based on HCI snoop log from #84.
…#75) The packet capture from the official Renpho app shows that after the unlock and user profile, a 0x22 "start measurement" command is needed to trigger weight data (0x10 frames). Without it the scale connects and accepts commands but never starts sending readings. Also adds debug logging for every step in onConnected so failed writes are visible in DEBUG=true output instead of being silently swallowed.
The official app exchanges the word "pass" on AE01 as part of an authentication handshake before sending the 0x22 start measurement command. Without this, the scale silently ignores all commands. Also adds delays between protocol steps to match the timing observed in the packet capture (the previous version fired all commands within 120ms, while the app waits for responses between each step).
Replace fixed write burst with a notification-driven state machine that responds to scale frames in the correct sequence: 0x12 (scale info) -> 0x13 config with echoed protocol type 0x14 (ready ACK) -> 0x20 time sync + A2 user profile + AE01 auth 0x21 (config req) -> A00D history responses + 0x22 start measurement Add ES-30M weight format detection for newer Renpho firmware where the 0x10 frame uses byte[4] as a state flag and weight at bytes[5-6] instead of the original layout (weight at bytes[3-4], stable at byte[5]). Keep the onConnected() burst for backward compatibility with older QN firmware that does not use the notification-driven handshake.
…chine (#75) ES-30M scales send a weight-only stable frame (state=0x02, R1=R2=0) before the impedance-bearing one. Without this guard, isComplete() accepts the impedance=0 reading immediately (broadcast-mode logic), missing the actual impedance measurement. Add deduplication guards to the state machine handlers so duplicate 0x14/0x21 frames from the onConnected burst overlap do not trigger redundant time sync, A00D, and start measurement writes. Add Renpho Elis 1 to supported scales documentation.
On Linux (node-ble / BlueZ D-Bus), the FFF1 CCCD subscription runs in parallel with onConnected(). The scale sends 0x12 in response to the CCCD write, but the notification handler may not be registered yet, causing the state machine to never trigger. The scale then waits ~25s for the handshake and disconnects. Add a 2-second fallback timer that runs the full handshake sequence (0x13 config, 0x20 time sync, A00D history, 0x22 start) if the notification-driven state machine hasn't fired by then. The dedup guards ensure no duplicate commands if the normal path also triggers.
Remove burst commands from onConnected() for AE00 firmware. On Linux (node-ble / BlueZ D-Bus), FFF1 CCCD subscription triggers 0x12 before onConnected finishes, causing the state machine to send 0x13 config before AE01 init. The scale accepts the handshake formally but never starts measurement mode. Move AE01 init into handleScaleInfo() (after receiving 0x12, before 0x13 config) matching the official Renpho app sequence. Cancel the fallback timer when the state machine fires normally. Keep legacy unlock burst for older firmware without AE00 service.
On Linux, 0x12 arrives before onConnected() subscribes AE02, so hasAe00 is still false when handleScaleInfo fires. This caused AE01 init and "pass" auth to be skipped entirely, leaving the scale in a state where the handshake completes formally but no weight data flows. Remove all hasAe00 guards from state machine handlers. AE01 writes are always attempted and fail silently on firmware without AE00. Fix unit byte in 0x13 config: 0x01 (kg per openScale) instead of 0x08 which caused the scale to display in lb. Set fallback timer for both firmware paths.
On Linux, 0x12 arrives before onConnected() subscribes AE02. The state machine then sent AE01 init before AE02 was subscribed. The official Renpho app sequence is AE02 subscribe -> AE01 init -> 0x13 config. handleScaleInfo now subscribes AE02 itself if not already done, ensuring correct ordering regardless of BLE stack timing. Also reverted 0x13 config byte[3] to 0x08 (Renpho app value) since 0x01 (openScale kg value) may not be correct for Elis 1 firmware.
The Renpho app uses 0x08 which works but switches the scale display to lb. openScale uses 0x01 for kg and 0x02 for lb. Using 0x01 to keep the scale in kg mode.
Summary
State machine flow
Test status
Closes #75
Related: #84