Skip to content

fix: QN Scale notification-driven handshake for newer firmware (#75)#82

Merged
KristianP26 merged 18 commits intomainfrom
fix/qn-scale-dual-unlock
Apr 2, 2026
Merged

fix: QN Scale notification-driven handshake for newer firmware (#75)#82
KristianP26 merged 18 commits intomainfrom
fix/qn-scale-dual-unlock

Conversation

@KristianP26
Copy link
Copy Markdown
Owner

@KristianP26 KristianP26 commented Mar 10, 2026

Summary

  • Rewrites QN Scale adapter from a fixed unlock burst to a notification-driven state machine matching the official Renpho app and openScale's QNHandler
  • Adds AE00 service support (AE01 write, AE02 notify) required by newer firmware (Renpho Elis 1, ES-CS20M)
  • Adds ES-30M weight frame format detection (weight at bytes[5-6], state flag at byte[4])
  • Fixes 0x13 config byte to 0x01 (kg) instead of 0x08 which switched the scale display to lb
  • Includes 2-second fallback timer for Linux (BlueZ D-Bus) where 0x12 may be lost due to CCCD subscription race
  • Legacy unlock burst preserved for older firmware without AE00 service

State machine flow

0x12 (scale info) -> AE02 subscribe + AE01 init + 0x13 config
0x14 (ready ACK)  -> 0x20 time sync + A2 user profile + "pass" auth
0x21 (config req)  -> A00D history responses + 0x22 start measurement
0x10 (weight)      -> parse weight + 0x1F acknowledge stable reading

Test status

  • Confirmed working on macOS by @DJBenson (Renpho Elis 1, firmware v6.0)
  • Linux (Docker / BlueZ D-Bus) by @ericandreani: handshake completes correctly but scale still does not send weight data. Awaiting HCI snoop log for further debugging. The Linux issue is not a regression (Elis 1 never worked on main)
  • 1151 tests passing, TSC/ESLint/Prettier clean
  • Older QN Scale devices not affected (legacy fallback path)

Closes #75

Related: #84

…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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 unlockCommands to ScaleAdapter and update legacy init to write each unlock command sequentially.
  • Update QnScaleAdapter to provide both known QN unlock command variants.
  • Add a legacy-mode unit test ensuring all unlockCommands are written; add diagnose Docker 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.

Comment on lines +93 to +95
const commands = adapter.unlockCommands
? adapter.unlockCommands.map((c) => Buffer.from(c))
: [Buffer.from(adapter.unlockCommand)];
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)];

Copilot uses AI. Check for mistakes.
Comment on lines 96 to +105
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)}`);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +103
bleLog.debug(
`Unlock write: [${[...buf].map((b) => b.toString(16).padStart(2, '0')).join(' ')}]`,
);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
bleLog.debug(
`Unlock write: [${[...buf].map((b) => b.toString(16).padStart(2, '0')).join(' ')}]`,
);
bleLog.debug(`Unlock write: [${buf.toString('hex')}]`);

Copilot uses AI. Check for mistakes.
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.
@KristianP26 KristianP26 changed the title fix: dual QN Scale unlock commands for broader firmware compat fix: QN Scale notification-driven handshake for newer firmware (#75) Apr 2, 2026
@KristianP26 KristianP26 merged commit fcda1d7 into main Apr 2, 2026
6 checks passed
@KristianP26 KristianP26 deleted the fix/qn-scale-dual-unlock branch April 2, 2026 05:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RENPHO Elis 1 (QN Scale) - connects but disconnects before reading completed

2 participants