Repository navigation
Scan the Fitdays scale's full body composition from the Journal - #77
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
basal_met— a field this project never sent before, unlocked because the scan computes it. Garmin also acceptsactive_metandvisceral_fat_mass; the scale reports neither (its visceral figure is a rating, not a mass), so they stay unset rather than being synthesised.scale_readings.extras: per-limb fat and muscle, raw impedance, BMR, protein, skeletal muscle, subcutaneous fat and body score.physique_ratingis left unset on purpose. Garmin's is a 1–9 Tanita-style code where 9 means very muscular; the vendor'sbody_typeenum 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. Sobody_type + 1would 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, andplan_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/framesresponse. The browser writes them in order.scales/wla37.py: runs Fitdays' own model fromlibICBodyFatAlgorithms.sounder Unicorn emulation. The library is proprietary and not committed; it is looked up atWLA37_SO_PATHorvendor/(now gitignored), with optional depsgarmin-insights[scale-engine]. Without it the scan still saves weight and raw impedance and says why composition was skipped.AdapterDescriptorgainsindicate_uuidandserver_writes. The olderlefuadapter is kept for other units.The protocol is documented publicly at dandwhelan/fitdays-lefu-scale.
Testing
app.jsparses cleanly (tree-sitter).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_KGoverrides it for protocol experiments.Stored and uploaded data were never affected — the server takes age from
BIRTH_DATEand 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 <= f2is 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 a6then1c 1b a6: the low byte never moves, only the middle one. So the weight is the big-endianu16one byte earlier, in units of 0.01 kg, anda6…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
🤖 Generated with Claude Code