Skip to content

feat: Add support for the Lescale F4P (Runstar R6 protocol) - #1563

Merged
oliexdev merged 1 commit into
oliexdev:masterfrom
daniel-walker:feat/lescale-f4p
Oct 24, 2026
Merged

oliexdev merged 1 commit into
oliexdev:masterfrom
daniel-walker:feat/lescale-f4p

Conversation

@daniel-walker

Copy link
Copy Markdown
Contributor

The Lescale F4P (ICOMON model FI2019LB-C, firmware 1.0.3, hardware 1.0.1) is another ICOMON body-composition scale that speaks the Runstar R6 protocol. It exposes service 0xFFB0 (FFB1 write, FFB2 notify, FFB3 indicate), next to the Nordic legacy DFU service 0x1530. RunstarR6Handler now claims it by its advertised name, Lescale F4P.

Two differences from the R6 were seen in every capture:

1. The trailer byte carries a unit flag

The top three bits of the trailer are flags, not part of the checksum. The low five bits are the usual sum(bytes[3..18]) & 0x1F. 0x20 is set on every frame while the scale displays pounds and clear while it displays kg:

A2 live, lb:   ff 07 00 a2 01 00 01 1f 4e 00 00 00 00 00 00 00 00 00 00 31   (0x20 | 0x11)
A3 result, kg: 06 08 00 a3 00 01 1f 80 00 00 00 00 00 00 00 00 00 00 00 03

The current exact comparison rejects every frame from a scale set to pounds. isChecksumValid() now compares the low five bits only. The R6, HO-36 and FG2202WB captures in the tests leave those bits clear, so they're unaffected.

The flag in frames the app sends sets the scale's display unit. A 0xBA user frame sent without it switched the scale from pounds to kg. The same frame with 0x20 was accepted (A0 01) and the scale stayed in pounds. Frames to the F4P therefore carry the user's unit: 0x20 for LB, nothing for KG. Stones were never tested and fall back to KG rather than a guessed bit.

2. It wants the 0xBA user frame, like the HO-36

After the 0xA1 ack the F4P gets the same user frame as the HO-36. The existing encodeHoffenUserInfo() payload works as-is. The scale answers A0 01, shows body-composition values on its own display, and reports impedance and heart rate in 0xA3 once the weight has locked:

Phase Frames Duration
measuring A2 state 01 ~13 s
settling A2 state 02 ~6 s
locked (BIA + heart rate) A2 state 04 ~21 s
result A3 —
19 08 00 a3 | 00 | 01 1f 4e | 47 | 01 ed | 00 … | 26
              OK   73.55 kg   71    493 Ω    (0x20 = lb)
                              bpm

Like the R6, the scale streams A2 without any handshake and replays offline weigh-ins as A4 (timestamp + weight) on the next connect. The user frame also sets the scale's clock: A4 timestamps were correct after it.

Testing

  • Captures: six weigh-ins logged from a Linux BlueZ/bleak script that implements this handler's protocol, covering the unit flag in both directions, A4 history, impedance 0 (stepped off early) and impedance with heart rate (500 Ω / 0 bpm, 493 Ω / 71 bpm).
  • End-to-end: a debug build on a Pixel (Android 13). The scale was listed as "Lescale F4P" (not "SWAN / Icomon (MGB)"), and one weigh-in was decoded and saved with all 9 values:
    RunstarR6Handler: Runstar R6 final result payload=[20 08 00 A3 00 01 22 3C 4E 01 EC 00 00 00 00 00 00 00 00 1D]
    RunstarR6Handler: Runstar R6 body composition (StandardImpedanceLib, impedance=492Ω): fat=18.79% water=58.33% muscle=45.05% bone=3.44kg lbm=60.34kg bmr=1673kcal
    BleConnector: Measurement from Lescale F4P for User 1 saved ... Values: 9
    
    The scale's own display showed ~19 % body fat for the same weigh-in.
  • Unit tests: 6 new tests in RunstarR6HandlerTest, all from verbatim F4P frames, plus a ScaleCatalog entry. ./gradlew :app:testDebugUnitTest --tests 'com.health.openscale.core.bluetooth.*': 645 tests, 0 failures.

Known limitation: the scale is switched to kg

ScaleUser.scaleUnit is never filled in: BluetoothFacade.toScaleUser() copies name, birthday, height and gender only. So every handler sees the default KG, and this handler sends the kg flag. A user who prefers pounds will see the scale's own display switch to kg after connecting to openScale. Measurements are stored correctly either way, and the app shows the unit chosen for the Weight type. I've opened a separate issue for this because the fix affects every handler that reads scaleUnit: #1562

Unknowns, unchanged from the HO-36

  • The A1 device-info payload (80 71 1e 5a 00 07 05) is not decoded.
  • The constant bytes of the 0xBA frame are copied from Fitdays captures of the HO-36. Their meaning is unknown, but the F4P accepts them.

🤖 Generated with Claude Code

The Lescale F4P (ICOMON model FI2019LB-C, firmware 1.0.3) speaks the
Runstar R6 protocol on service 0xFFB0, next to the Nordic DFU service
0x1530. RunstarR6Handler now claims it by its advertised name, "Lescale
F4P". Verified against six weigh-ins captured with a BlueZ script, then
an end-to-end weigh-in with a debug build on a Pixel (Android 13):
74.3 kg, 78 bpm and 492 ohm decoded and saved with body composition.

Two differences from the R6, both seen in every capture:

- The top three bits of the trailer byte are flags, not part of the
  checksum. 0x20 is set on every frame while the scale displays pounds.
  isChecksumValid() now compares the low five bits only; the other
  scales in this family leave the top bits clear. The flag in frames
  the app sends sets the display unit: a 0xBA user frame without it
  switched the scale from pounds to kg. Frames to the F4P therefore
  carry the user's unit (0x20 for LB; KG and the untested ST send 0).
- Like the HO-36, the F4P gets the 0xBA user frame right after the 0xA1
  ack. It answers 0xA0 01, shows body composition on its display, and
  after the weight locks (A2 state 0x04, about 20 s) reports impedance
  and heart rate in 0xA3. The existing encodeHoffenUserInfo() payload
  is accepted as-is.

Known limitation: ScaleUser.scaleUnit is never filled in from the
user's weight unit (BluetoothFacade.toScaleUser()), so handlers always
see KG and the F4P is switched to kg. Measurements are stored correctly
either way; the scale's own display shows kg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Daniel Walker <dwalker@fifo99.com>
@daniel-walker

Copy link
Copy Markdown
Contributor Author

The known limitation above (the F4P being switched to kg because ScaleUser.scaleUnit is always KG) is fixed by #1564. Tested together on the F4P: with the Weight type set to lb, the scale stays in pounds and its frames carry the 0x20 flag. The two PRs are independent and can be merged in either order.

@oliexdev
oliexdev merged commit 87edad4 into oliexdev:master Oct 24, 2026
@oliexdev

Copy link
Copy Markdown
Owner

Merged. The known limitation from the description no longer applies: on master
ScaleUser.scaleUnit reaches the handlers, so with the Weight type set to lb the F4P keeps
displaying pounds.

If the F4P can display stones and you get the chance, it would be worth trying whether 0x40
in the trailer selects them, so stone users don't get kg on the display.

Thanks for your PR 👍

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.

2 participants