I paid 150 EUR for a Garmin Index S2 and it was the least useful thing in my setup. Step on it, wait. Sometimes the measurement lands, sometimes nothing happens and I step off and try again later.
I replaced it with a Renpho ES-CS20M that was already in the flat. It cost about 12 EUR and has no idea Garmin Connect exists. What sits between the scale and Garmin Connect is a Raspberry Pi Zero 2W that has been running since January.
The part worth writing about is the rewrite in the middle, which I started as a language change.
The first version
January 13. Three Python files and a config template. scale_scanner.py did the bioimpedance math, scale_sync.py did everything else: connect over BLE, unlock the scale, decode the notification, log in to Garmin, upload.
It worked on the first evening. One scale, one destination, no configuration beyond my email and my height.
The contract between the two halves looked like this:
garmin.add_body_composition(
timestamp=None,
weight=weight,
percent_fat=metrics["Body Fat (%)"],
percent_hydration=metrics["Water (%)"],
bone_mass=metrics["Bone Mass (kg)"],
muscle_mass=metrics["Muscle Mass (kg)"],
metabolic_age=metrics["Metabolic Age"],
body_mass_index=metrics["BMI"],
)
The keys are display strings. "Body Fat (%)", unit and parenthesis included, produced for console output and then read back as an API field name. That is fine as long as one function writes the dict and one function reads it, and both live in files you have open at the same time.
Everything else was in the same shape. The unlock command was a literal inside the connect routine. The frame filter was if data[0] != 0x10 inline in the notification handler. Whether a reading was finished was a global boolean called sync_completed.
It was a script that solved my problem, and for that it was fine.
What broke it
I wanted the numbers somewhere other than Garmin. That was the whole of it at first: one more destination next to the existing one.
Then I figured a lot of people own a scale like this and cannot get the readings out of it either, so I put it out as open source.
As long as it was my script, a second destination meant one more function next to the Garmin one. If somebody else runs it, they have a different scale and they want the data somewhere I do not. Something has to exist between the BLE frames and the upload, and it has to have a name. In the script there was no place to put it, because nothing had ever needed one.
The rewrite
February 8. I rewrote it. All four files gone, three TypeScript ones in their place.
I described the change to myself at the time as introducing an adapter pattern. There is no language in that description, which took me a while to notice.
export interface ScaleAdapter {
readonly name: string;
readonly charNotifyUuid: string;
readonly charWriteUuid: string;
readonly unlockCommand: number[];
readonly unlockIntervalMs: number;
matches(peripheral: Peripheral): boolean;
parseNotification(data: Buffer): ScaleReading | null;
isComplete(reading: ScaleReading): boolean;
computeMetrics(reading: ScaleReading, profile: UserProfile): GarminPayload;
}
Every field in there was already in the Python version. It was just spread across three files as literals, inline conditions and a global. Writing it down as one declaration is what made room for scales I do not own.
Python has abstract base classes and protocols and would have expressed the same interface. What I wanted on top of it was a check that runs before the code does. A decoder that reads the wrong two bytes out of a frame does not raise anything. It returns a number, and if the number is plausible it gets uploaded. By the time anyone can tell it was wrong, it is in somebody’s Garmin history with a timestamp on it and has to be deleted by hand. Wrong types are the one class of that failure I can catch without owning the hardware it happens on.
The day after, fifteen more scale adapters went in.
The type was named after its first consumer
On February 10 the readings got a second destination: MQTT, so they land in Home Assistant as well.
The payload type at that point was called GarminPayload. Every field in it - body fat, water, bone mass, visceral fat - is a property of a human body, not of an API. I had named the domain type after its first consumer, which is invisible until a second one turns up.
What I did about it was this:
/** Alias for GarminPayload - used by the exporter system. */
export type BodyComposition = GarminPayload;
I did not rename it. I aliased it, shipped MQTT, and left the old name sitting underneath. The seam stayed there for weeks.
It is BodyComposition now, and the exporter contract has grown a field the first version could not have anticipated:
export interface Exporter {
readonly name: string;
/** True when this exporter honours `context.timestamp`. */
readonly supportsBackdate?: boolean;
export(data: BodyComposition, context?: ExportContext): Promise<ExportResult>;
healthcheck?(): Promise<ExportResult>;
}
Some scales cache measurements while nothing is listening and dump them when you next connect. Those readings have their own time. Platforms handle that differently: some accept a back-dated entry, others silently stamp it with now, which is worse than rejecting it. There was nowhere to express that until the exporter had an interface of its own.
What the project cost
The protocols were already done. Most of them came from openScale, which has been collecting this work for years, and porting a decoder is an afternoon.
Making it work for me was one evening. Making it work for someone whose scale I do not own, on a machine I cannot log into, has taken the seven months since: a config schema, a setup wizard that finds the scale for you, a diagnostic command for when Bluetooth is the problem rather than the scale, a Home Assistant add-on, an ESP32 proxy for people whose bathroom is not next to their server.
Nothing there is interesting to write about, but without it nobody except me would be running this.
What runs here
A Pi Zero 2W with 512 MB of RAM, running the standalone install rather than the container. Docker works and I recommend it on anything bigger, but on that board the daemon costs enough memory to matter, and I want the Pi to do more than one job.
I returned the Index S2.
The project is BLE Scale Sync, open source under GPL-3.0.