Skip to content

Add Keep S3 scale support - #1438

Open
lekoOwO wants to merge 4 commits into
oliexdev:masterfrom
lekoOwO:feature/keep-s3-support
Open

Add Keep S3 scale support#1438
lekoOwO wants to merge 4 commits into
oliexdev:masterfrom
lekoOwO:feature/keep-s3-support

Conversation

@lekoOwO

@lekoOwO lekoOwO commented Aug 2, 2026

Copy link
Copy Markdown

Summary

  • add a dedicated KeepS3Handler selected by the exact Keep_S3 device name before generic LeFu/FFF0 handlers
  • implement the observed 00FF/FF01/FF02 protocol with a response-driven initialization state machine, per-event acknowledgements, session deduplication, previous-record synchronization, and orderly stop/disconnect handling
  • publish weight, heart rate, dual-frequency impedance, and SDK-compatible offline body-composition estimates through existing openScale measurement fields
  • add stable per-user ID24 persistence and focused protocol/body-composition tests without adding measurement types or changing the database schema

Protocol and field mapping

The handler uses service 0x00FF, notify characteristic 0xFF01, and write-with-response characteristic 0xFF02. Initialization follows 38 → 0A → 01 → 05 → E7 → F5 → F5 → 03 → 20 → 32 → 36 and advances only after the expected successful response.

Every valid 0x57 and 0x58 event is acknowledged. Stage 0x29 of 0x57 publishes at most once; 0x58 is the final-record fallback and ends the session. The handler then queues two stop commands and delays disconnection long enough for the serialized BLE operation queue to drain.

The protocol impedance from 0x57 is distinct from the 50/100 kHz values decoded from 0x58. It is retained per user in DriverSettings and reused for the next 0x32 profile only when its timestamp and weight match the last stored measurement. A missing or stale value produces an isolated all-zero previous record instead of substituting a different impedance band.

The 100/50 kHz values map to openScale's existing high/low impedance fields. The offline composition model supplies body fat, water, skeletal-muscle percentage, visceral-fat level, bone mass, lean body mass, BMR, and protein percentage. These are local SDK-compatible estimates, not values directly measured by the scale or an official public formula. The vendor's broader muscle percentage is intentionally not published because openScale's existing MUSCLE field represents skeletal-muscle percentage.

Validation

  • ./gradlew :app:testDebugUnitTest :app:assembleDebug — passed (367 tests across 52 suites, no failures, errors, or skips)
  • git diff --check — passed
  • lintDebug reached the repository's existing baseline; no report entries referenced the Keep S3 implementation

Two additional hardware sessions validated the queued shutdown path without exposing measurement data:

  • 252/252 observed 0x57 events were acknowledged successfully
  • 2/2 observed 0x58 events were acknowledged successfully
  • all six 0x36 writes succeeded: one start and two stops per session
  • no write timeout or operation cancellation occurred
  • exactly one measurement was inserted per session
  • each connection disconnected successfully about six seconds after its final record
  • the previous-record flow used the matching stored protocol impedance on both sessions

Earlier hardware validation also confirmed that a fresh user with an all-zero previous record was accepted and that the generated ID24 remained stable across connections.

Known limitations

  • names for non-final 0x57 stages remain sequence-based inferences
  • the business meaning of opcodes F5 and 20 is unknown
  • the original ID24 generation algorithm is unknown; this implementation uses a stable random lowercase hexadecimal token
  • the vendor meaning of the undecoded portions of 0x58 is not publicly specified
  • the body-composition model is based on the available native SDK observations and device runs, not a public vendor specification, so its outputs remain estimates

Raw Bluetooth captures, device identifiers, generated user tokens, health measurements, screenshots, and validation logs are intentionally excluded.

@lekoOwO
lekoOwO marked this pull request as ready for review August 2, 2026 02:19
@lekoOwO

lekoOwO commented Aug 2, 2026

Copy link
Copy Markdown
Author

Wiki follow-up after merge

Please add Keep S3 to the supported scales wiki after this PR is merged:

<tr>
<td>Keep S3</td>
<td align="center">&#10003;</td>
<td align="center">not verified</td>
<td align="center">&#10003;</td>
<td>See <a href="https://github.com/oliexdev/openScale/pull/1438">#1438</a>. Weight, impedance, and heart rate are received from the scale; additional body metrics are calculated locally.</td>
</tr>

History retrieval is marked not verified because the available captures do not establish whether the scale supports stored-history download; using n/a or o would overstate the evidence.

A matching wiki commit has been prepared locally, but GitHub wiki repositories cannot be contributed to through the normal fork PR workflow and this account does not have write access to the upstream wiki.

This change removes support for several recently added measurement types and reverts the database schema to version 15.

Key changes include:
*   **Measurement Types:** Removed `PHASE_ANGLE`, `SKELETAL_MUSCLE`, `LEAN_SOFT_TISSUE`, `SUBCUTANEOUS_FAT`, `BODY_AGE`, `BMI_22_REFERENCE_WEIGHT`, and `DEVICE_IMPEDANCE` from `MeasurementTypeKey` and `ScaleMeasurement`.
*   **Database:** Reverted `AppDatabase` version to 15, deleted `MIGRATION_15_16`, and removed the version 16 JSON schema.
*   **Units & Strings:** Removed `UnitType.DEGREE` and associated string resources and localization logic.
*   **Logic Cleanup:**
    *   Removed generalized percentage-to-mass unit conversion logic in `ConverterUtils` and `MeasurementTypeCrudUseCases`.
    *   Updated `KeepS3Handler` to stop publishing extended metrics.
    *   Removed `awaitPendingOperations` from the BLE transport interface.
@oliexdev

oliexdev commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for this — the reverse engineering is impressive, and the test coverage on both the protocol decoder and the body-composition model is well above what I usually get for a new scale. I want this in.

My only problem was scope. The PR was titled "Add Keep S3 scale support", but it also carried a new measurement-type system, a ConverterUtils refactor, a second scale, and hand-edited translations. Rather than send you through several review rounds for that, I've pushed the trimmed-down version to your branch directly. Please review what I did — and most importantly, please re-test it on your actual scale, because two of the changes touch runtime behaviour.

Where it stands now

The PR is down to 5 files and one touched existing file:

core/bluetooth/ScaleFactory.kt                 |   3 +
core/bluetooth/libs/KeepS3BodyComposition.kt   | 251 ++  (new)
core/bluetooth/scales/KeepS3Handler.kt         | 749 ++  (new)
test/…/libs/KeepS3BodyCompositionTest.kt       | 209 ++  (new)
test/…/scales/KeepS3HandlerTest.kt             | 765 ++  (new)

./gradlew testDebugUnitTest passes (365 tests) and assembleDebug is green, but that only proves it compiles and the decoder still behaves — it says nothing about the scale.

What I removed, and why

The 8 new measurement types. PHASE_ANGLE, PHASE_ANGLE_HIGH, SKELETAL_MUSCLE, SUBCUTANEOUS_FAT, BODY_AGE, BMI_22_REFERENCE_WEIGHT, LEAN_SOFT_TISSUE, DEVICE_IMPEDANCE, plus UnitType.DEGREE, the DB bump to v16, MIGRATION_15_16, schemas/16.json, OpenScaleApp defaults, the ScaleMeasurement fields and the BleConnector / ModernScaleAdapter /
SyncUseCases wiring.

These are too specialised for openScale. They'd appear in the measurement-type list for every user of every supported scale, and I'd be committing to maintaining them — units, conversions, translations, insights, sync — for a single device. Two of them I'd have pushed back on regardless: DEVICE_IMPEDANCE is redundant next to the two real bands you already publish into impedance and impedanceLow, and SKELETAL_MUSCLE overlaps with the existing MUSCLE type.

In the handler I commented the assignments out rather than deleting them, together with a note on what re-enabling would require. Your decoding work is preserved and readable; only the persistence is gone.

What still gets stored: weight, body fat, muscle, water, visceral fat, bone, LBM, BMR, protein, heart rate, impedance (100 kHz) and impedance low (50 kHz). That's a richer set than most scales in the tree deliver.

The two changes that need hardware testing

1. The transport barrier is gone. awaitPendingTransportOperations() was the only reason the PR had to touch ScaleDeviceHandler and GattScaleAdapter, and it's a new concept no other handler uses. finishSession() is now:

finishJob = scope.launch {
    delay(DISCONNECT_DELAY_MS)   // 800 ms
    requestDisconnect()
}

The opQueue drains sequentially, each write awaiting its completion callback plus postWriteDelayMs (20 ms on TuningProfile.Balanced). With ~4 writes queued at that point, 800 ms should be comfortable. The failure mode I can't rule out from here is a write hitting the 1000 ms operation timeout and the disconnect landing before the second stop command. Please confirm the session still ends cleanly on your scale, and that a second consecutive measurement still works. If it's marginal, just raise DISCONNECT_DELAY_MS.

2. previousDeviceImpedance() now reads previous.impedance. With deviceImpedance gone, the previous record reuses the stored high-frequency band instead of the vendor value:

private fun previousDeviceImpedance(previous: ScaleMeasurement): Double {
    if (previous.impedance.isFinite() && previous.impedance > 0.0) {
        return previous.impedance
    }
    return 0.0
}

So the profile payload now carries the 100 kHz impedance where it used to carry the vendor value. Your own comment says a Keep S3 accepted an all-zero previous record during validation, so I expect this to be harmless — but it's an unverified assumption on my side. Please check that the second and third measurements still produce correct body composition, since that's where the previous record actually gets sent.

What I'd like from you

  • Look over the two changes above and tell me if I got anything wrong about the protocol.
  • Run at least three consecutive measurements on the real scale: first one on a fresh install (no previous record), then two more, and confirm the values match what the Keep app reports.
  • Confirm the disconnect behaves.

If that checks out, I'll merge it and add the wiki entry. Thanks again for the quality of the work here — the trimming is about openScale's scope, not about your implementation.

@lekoOwO

lekoOwO commented Aug 3, 2026

Copy link
Copy Markdown
Author

Thanks for trimming the PR and for calling out the two runtime changes. I reviewed them against the captures and ran the requested consecutive hardware tests.

Previous impedance

The 100 kHz fallback was not protocol-correct. The captured second official-app session puts the previous 0x57 protocol impedance into 0x32; the stored 100 kHz band is a distinct value.

Commit b4645d53 now keeps that protocol-only impedance in per-user DriverSettings, bound to the source measurement's timestamp and encoded weight. The next profile uses it only when it matches lastMeasurementFor(user.id). If it is unavailable or stale, the handler sends the hardware-validated all-zero previous record instead of substituting either 50/100 kHz band. Consecutive hardware sessions confirmed that each profile reused the immediately preceding 0x57 value.

Queue drain and disconnect

The 800 ms delay was not sufficient on this device. In three initial validation sessions the scale emitted 406 0x57 events, but only 374 ACK writes reached the characteristic before disconnect. None of the three 0x58 ACKs or six stop commands reached the characteristic; only the three start commands did. The sessions still disconnected and a later measurement could start, but the protocol shutdown was incomplete.

The largest observed backlog at 0x58 was 19 pending 0x57 ACKs, plus the 0x58 ACK and two stops. I raised the handler-local delay to 6000 ms, without restoring the transport barrier.

Two consecutive follow-up measurements then produced:

  • 252/252 0x57 notifications acknowledged, all writes successful
  • 2/2 0x58 notifications acknowledged, both writes successful
  • 6/6 0x36 control writes successful (one start and two stops per session)
  • zero write timeouts or operation cancellations
  • exactly one insert and one successful disconnect per session
  • final-record-to-disconnect times of about 6.0 seconds

Muscle mapping and body-composition comparison

The vendor's broad musclePercent is FFM minus bone divided by weight and exceeded openScale's plausible MUSCLE range during validation. Since the existing type is being used for skeletal muscle here, the handler now publishes the separately calculated skeletalMusclePercent. This matched the Keep App's skeletal-muscle field and removed the plausibility warning. The broader vendor muscle value remains decoded by the model but is not persisted.

The other locally calculated values are SDK-compatible estimates, not exact Keep App report values. The nearby comparison was close for several fields but not identical, and the results were sensitive to measured impedance/contact, so I would keep the existing estimate wording rather than claim exact vendor-report equivalence.

Automated validation

  • :app:testDebugUnitTest — 367 tests, zero failures/errors
  • :app:assembleDebug — passed
  • git diff --check — passed

Raw logs, body measurements, device addresses, and generated user tokens remain private and are not included in the repository or this comment.

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