Skip to content

Scan the Fitdays scale's full body composition from the Journal - #77

Merged
dandwhelan merged 10 commits into
mainfrom
feat/fitdays-full-scan
Sep 24, 2026
Merged

dandwhelan merged 10 commits into
mainfrom
feat/fitdays-full-scan

Conversation

@dandwhelan

@dandwhelan dandwhelan commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

The Journal's Scan scale button now runs the Fitdays/Lefu scale's full protocol over Web Bluetooth, the way the vendor app does. Previously it got weight only, and that weight was decoded wrong. Now a scan gets the scale's ten segmental impedance readings and Fitdays' own body-composition figures.

What changes for the user

  • Press Scan scale, pick the scale, then stand on it barefoot until it finishes.
  • The weigh-in form fills with the vendor app's figures: weight, BMI, fat, water, muscle, bone, visceral fat and body age. These are the fields Garmin Connect accepts, so Upload to Garmin sends as much as Garmin can store.
  • BMR now goes to Garmin too, as basal_met — a field this project never sent before, unlocked because the scan computes it. Garmin also accepts active_met and visceral_fat_mass; the scale reports neither (its visceral figure is a rating, not a mass), so they stay unset rather than being synthesised.
  • Everything Garmin still has no field for is kept locally in scale_readings.extras: per-limb fat and muscle, raw impedance, BMR, protein, skeletal muscle, subcutaneous fat and body score.
  • Two new dashboard charts under Fitness & Body use that local history: Segmental Muscle and Segmental Fat.
  • A Scan results panel in the Journal shows the whole weigh-in as soon as it finishes — fat and fat-free mass, skeletal muscle, protein, subcutaneous fat, BMR, body score and type, a per-limb muscle/fat table and the ten impedance readings. Rows Garmin has no field for are tagged local, so it is obvious what syncs and what does not.
  • physique_rating is left unset on purpose. Garmin's is a 1–9 Tanita-style code where 9 means very muscular; the vendor's body_type enum runs 0–8 and tracks body fat instead — measured across body shapes it returns 2 at 9.4% fat and 8 at 47–60% fat. So body_type + 1 would have recorded the fattest reading as the most muscular: wrong in direction, not merely offset, and permanent once synced. It is shown in the scan readout instead.

How

  • scales/fitdays.py (new default adapter): frame codec, the 5-bit checksum, live weight (BE u24 grams), the ten-value result frame, and plan_writes(). The scale only runs its impedance sweep after a timestamped profile push arrives while someone is already standing on it, so the handshake can't be a fixed list. The server plans it from what the scale has sent and returns the frames in each /api/scale/frames response. The browser writes them in order.
  • A result is only accepted after the weight has settled, because the scale replays its previous result on connect.
  • scales/wla37.py: runs Fitdays' own model from libICBodyFatAlgorithms.so under Unicorn emulation. The library is proprietary and not committed; it is looked up at WLA37_SO_PATH or vendor/ (now gitignored), with optional deps garmin-insights[scale-engine]. Without it the scan still saves weight and raw impedance and says why composition was skipped.
  • AdapterDescriptor gains indicate_uuid and server_writes. The older lefu adapter is kept for other units.

The protocol is documented publicly at dandwhelan/fitdays-lefu-scale.

Testing

  • 22 new tests pin the codec, the handshake planner and the end-to-end scan API to frames captured from the real scale. One checks that the vendor engine reproduces the app's report for a paired weigh-in; it skips when the library is absent.
  • The full suite passes (693). app.js parses cleanly (tree-sitter).
  • The live web server is restarted onto this branch and serves the new adapter and charts.
  • Exercised end to end on the hardware through the browser: a scan filled the form, saved the per-limb breakdown locally, and uploaded to Garmin Connect successfully.

A bug this surfaced, found on hardware

The first real scan made the scale show the wrong age on its own display (16 instead of 38), while weighing through the Fitdays app showed it correctly.

Diffing all 19 profile writes the app makes against ours, they were identical in every field but one: the 16-bit weight in the profile frame. The app never puts the live reading there — in the captured session it sent 71.334 kg before the weigh-in and 72.614 kg after, while the measurement was 71.95 kg. It is a stored profile weight, and overwriting it each scan skewed the age the scale computes for its display. Pinning it to a stored value fixed the display, confirmed on the hardware.

That field now carries the user's last saved weigh-in, resolved once per scan session, falling back to the live reading only when there is no prior weigh-in. FITDAYS_PROFILE_WEIGHT_KG overrides it for protocol experiments.

Stored and uploaded data were never affected — the server takes age from BIRTH_DATE and recorded body age 35 correctly throughout.

A second bug the first real scans surfaced

Two scans 75 minutes apart reported 6.7% and 10.9% body fat — impossible for fat mass. Weight was not the cause (swapping the two weights moves the result 0.3 points; swapping the impedance moves it the full 4.5).

The first scan's trunk read 25.7 Ω at low frequency against 33.6 Ω at high. That is backwards — low frequency must exceed high, since current only crosses cell membranes at the higher one — while all four limbs behaved normally at 1.15–1.20. Trunk impedance is small and weighs heavily in the model, so one bad segment dragged body fat 4 points low.

Any segment with f1 <= f2 is now treated as a bad contact: weight and raw impedance are saved, composition is skipped, and the scan explains why and asks for a re-scan. A confident wrong body-fat figure is worse than none. The affected reading was quarantined locally — derived fields cleared, weight and impedance kept, with a note recording why.

A third bug: the profile frame's weight offset

The scale displayed age 16, then 38 once the weight field was pinned, then 110 with a different value — so that field was the driver, but the decode of it was wrong.

The app's two pushes read 1c 16 a6 then 1c 1b a6: the low byte never moves, only the middle one. So the weight is the big-endian u16 one byte earlier, in units of 0.01 kg, and a6… begins a constant run. Decoded that way the app sent 71.90 kg before the weigh-in and 71.95 kg after — the latter exactly the weight it had just measured, which the earlier misreading (71.334 / 72.614) could never explain.

Writing our value one byte late corrupted the following byte, which is what threw the displayed age around. The byte-exact test passed only because the old construction happened to produce the same bytes for one particular weight; the tests now pin both captured frames, before and after the measurement.

Validation against the vendor app

A second app weigh-in (an hour after one of ours, so a different sweep — not a paired sample) puts the engine within ~0.5 on every metric: fat 10.3% vs 10.8, water 65.8% vs 65.4, muscle 131.2 lb vs 130.4, BMR 1748 vs 1738, body age 35 vs 35. The left-arm-over-right asymmetry matched a third time, further supporting the arm mapping.

Known limitations

  • The profile id sent to the scale is the one the vendor app used for Dan. If Helen scans, she uses the same profile slot on the scale, which could overwrite Dan's stored weight there (not in the database). Separate ids for multiple users are unverified.
  • Names are truncated to 3 letters because the frame has a fixed 3-byte name field.

🤖 Generated with Claude Code

dandwhelan and others added 10 commits September 15, 2026 23:13
The Journal's scan button got weight only from the Fitdays/Lefu scale, and
decoded even that wrongly. It now speaks the scale's real protocol: the
sweep only runs after a timestamped profile push arrives while someone is
standing on the scale, and every frame is checksummed, so the server plans
the handshake from what the scale has sent and returns the frames for the
browser to write. The browser stays a dumb pipe.

A settled result's ten segmental impedances go through Fitdays' own WLA37
model (scales/wla37.py, the proprietary library run under emulation, looked
up in the gitignored vendor/ dir and never committed). The form fills with
every field Garmin Connect accepts, and the rest - per-limb fat and muscle,
BMR, protein, raw impedance - is kept in scale_readings extras and charted as
Segmental Muscle and Segmental Fat. Without the library a scan still saves
weight and impedance and says why composition was skipped.

A result is only trusted after the weight settles, because the scale replays
its previous result on connect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Garmin's body-composition API accepts basal_met, active_met and
visceral_fat_mass, none of which we sent. The Fitdays scan now produces a
BMR, so it goes up as basal_met - one more real field on every weigh-in.

The other two stay unset deliberately: the scale reports a visceral fat
*rating*, not a mass, and nothing here measures active metabolic rate.
Synthesising either would put invented numbers in Garmin.

The form gains a BMR field (prefilled by a scan, editable for manual
entries) and it is bounds-checked like the rest, since out-of-range values
crash garminconnect's FIT packer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A scan measures far more than Garmin Connect can store, and everything it
could not store was invisible: it went into the database and the charts but
was never shown at the moment of weighing. The Journal now renders the full
readout - fat and fat-free mass, skeletal muscle, protein, subcutaneous fat,
BMR, body score and type, the per-limb muscle/fat table, and the ten
impedance readings - with the rows Garmin has no field for tagged "local".

Fixes a silent gap while wiring it: the response's extras were built only
from the composition engine's output, so the segmental impedance (which
comes from the protocol decoder) never reached the browser. The impedance
table would have rendered empty with nothing to explain why. The response
contract is now pinned by a test, with internal bookkeeping kept out.

Also exposes the vendor's body_type. It is NOT sent as Garmin's
physique_rating: that is a 1-9 Tanita-style code, while this enum was
observed at 0-8 with different semantics, so mapping them would put a
plausible-looking but wrong rating in Garmin. Of the three fields Garmin
accepts and we still do not send, active_met and visceral_fat_mass measure
things this scale never reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Garmin's physique_rating is a 1-9 Tanita-style code where 9 means "very
muscular". The vendor's body_type enum runs 0-8 and tracks body fat instead:
measured across body shapes it gives 9.4% fat -> 2 and 47-60% fat -> 8. So
body_type + 1 would have recorded the fattest reading as the most muscular,
and a lean athletic reading as under-muscled - wrong in direction, not just
offset, and permanent once synced.

It is surfaced in the scan readout for display only, and the form field's
tooltip now says why a scan leaves it empty.

That leaves active_met and visceral_fat_mass as the only Garmin fields still
unset, and this scale measures neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scanning through the web page makes the scale display the wrong age (16
instead of 38), while weighing through the Fitdays app shows it correctly.
Our profile frames are byte-identical to the app's captured ones except for
one field, so that field is the only candidate.

The vendor app does not put the live reading in the profile frame's 16-bit
weight slot: across the captured session it sent 71.334 kg before the
weigh-in and 72.614 kg after, while the measurement itself was 71.95 kg. It
looks like a stored profile weight, and we overwrite it every scan, which
may be what skews the age the scale computes for its own display.

ScaleProfile.profile_weight_kg makes that field settable independently,
defaulting to the previous behaviour, with FITDAYS_PROFILE_WEIGHT_KG to pin
it for an A/B test against the display. No user-visible change yet; the
stored and uploaded data were never affected, since the server uses
BIRTH_DATE for age (it recorded body age 35 correctly).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Confirmed on hardware: writing the live reading into the profile frame's
weight field made the scale display the wrong age on its own screen (16
instead of 38). Pinning that field to a stored weight fixed the display.

The vendor app never puts the live reading there - across the captured
session it sent 71.334 kg before the weigh-in and 72.614 kg after, while the
measurement was 71.95 kg. It is a stored profile weight, and overwriting it
each scan skewed the age the scale computes for its display.

The field now carries the user's last saved weigh-in, resolved once per scan
session rather than per frame batch, falling back to the live reading only
when there is no prior weigh-in at all. FITDAYS_PROFILE_WEIGHT_KG still
overrides it for protocol experiments.

Stored and uploaded data were never affected: the server takes age from
BIRTH_DATE and recorded body age 35 correctly throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two scans 75 minutes apart reported 6.7% and 10.9% body fat. Fat mass cannot
move 4 points in 75 minutes, and weight was not the cause: swapping the two
weights changes the result by 0.3 points, swapping the impedance changes it
by the whole 4.5.

The first scan's trunk read 25.7 ohm at low frequency and 33.6 at high.
That is backwards - low frequency must exceed high, because current only
crosses cell membranes at the higher one - while all four limbs behaved
normally at 1.15-1.20. Trunk impedance is small and weighs heavily in the
model, so one bad segment dragged body fat 4 points low.

A segment with f1 <= f2 is now treated as a bad contact: the weight and the
raw impedance are saved, composition is skipped, and the scan says what
happened and to try again barefoot. Storing a confident wrong figure is
worse than storing none.

The affected reading has been quarantined in the local database - derived
fields cleared, weight and raw impedance kept, with a note recording why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…splay

The scale showed age 16, then 38 once the weight field was pinned, then 110
once it carried a different value - so the field was the driver, but the
decode of it was wrong.

The app's two profile pushes read `1c 16 a6` then `1c 1b a6`: the LOW byte
never moves, only the middle one. The weight is therefore the big-endian u16
one byte earlier, in units of 0.01 kg, and `a6...` starts a constant run.
Decoded that way the app sent 71.90 kg before the weigh-in and 71.95 kg
after - the latter exactly the weight it had just measured, which the
previous reading (71.334 / 72.614) could never explain.

Writing our value one byte late corrupted the following byte, which is what
threw the displayed age around. The byte-exact tests passed only because the
old construction happened to reproduce the same bytes for one weight.

Tests now pin both of the app's captured frames, before and after the
measurement, and post-measurement writes the freshly measured weight as the
app does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	garmin-insights/src/garmin_insights/web/static/index.html
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dandwhelan
dandwhelan merged commit 99987f7 into main Sep 24, 2026
4 checks passed
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.

1 participant