Read and decode the weigh-in history of an Omron VIVA (HBF-222T) body composition scale over Bluetooth LE — and, more importantly, a written-down explanation of the protocol it actually speaks.
MIT licensed. No affiliation with Omron Healthcare.
As of August 2026 there was nothing public for this scale. omblepy covers
several Omron blood pressure monitors, but not the VIVA, and the channel the VIVA really
uses is documented nowhere.
It was reconstructed here from an HCI capture of the official app, and then validated against a second, independent source: 59 records decoded straight out of the scale's EEPROM turned out to be identical — same weight to the 0.1 kg, same timestamp to the minute — to what the Omron cloud had recorded for the same weigh-ins over three years. That is the difference between a plausible decoding and a verified one, and it is why this is worth publishing.
The other half of the value is the section The gotcha that costs weeks. If you only read one part, read that one.
| Layer | Module | Needs |
|---|---|---|
| decode — 32 bytes → one record, a pure function | omron_viva.decoder |
standard library only |
| read — the BLE channel: unlock, CCCD dance, EEPROM blocks | omron_viva.transport |
bleak |
The decoder never imports bleak. If you already dump the EEPROM some other way — an
ESP32, another language, a file saved months ago — you only need the decoder, and you can
test it without hardware.
pip install omron-viva # decoder only
pip install "omron-viva[ble]" # + the BLE transportDecoding a dump you already have:
from omron_viva import decode_dump
with open("dump.bin", "rb") as fh:
for record in decode_dump(fh.read()):
print(record.timestamp, record.weight_kg, record.body_fat_pct)Reading from the scale:
import asyncio
from omron_viva.transport import VivaClient
async def main():
key = bytes.fromhex("...") # YOUR key — see below
async with VivaClient("XX:XX:XX:XX:XX:XX", key) as scale:
for record in await scale.read_history():
print(record.timestamp, record.weight_kg)
asyncio.run(main())examples/dump_and_decode.py is the same thing as a command-line tool.
No key is bundled with this library, and there cannot be one. The 16-byte key is what
the official Omron app registered on your scale; whether it is constant across devices
is unknown, and either way it is not ours to publish. VivaClient takes it as a required
argument.
To recover yours, capture the official app talking to your scale and read it off the wire:
- On Android, enable Developer options → Enable Bluetooth HCI snoop log, then toggle Bluetooth off and on.
- Open the Omron app, step on the scale, and wait for the measurement to appear in the app. (A capture without a weigh-in shows you the status protocol and not the data protocol — this cost us a whole round.)
adb bugreportand find thebtsnoop_hci.loginside the archive.- Open it in Wireshark and look for a 17-byte write to characteristic
b305b680-aee7-11e1-a730-0002a5d5c51b. A write starting with0x01is an unlock: the 16 bytes after it are the key.
All on service ecbe3980-c9a2-11e1-b1bd-0002a5d5c51b — the "legacy" Omron channel, the
same one the omblepy devices use.
| Role | Characteristic |
|---|---|
| unlock | b305b680-aee7-11e1-a730-0002a5d5c51b |
| command | db5b55e0-aee7-11e1-965e-0002a5d5c51b |
| RX0 | 49123040-aee8-11e1-a74d-0002a5d5c51b |
| RX1 | 4d0bf320-aee8-11e1-a0d9-0002a5d5c51b |
| RX2 | 5128ce60-aee8-11e1-b84b-0002a5d5c51b |
| RX3 | 560f1420-aee8-11e1-8184-0002a5d5c51b |
The scale also advertises the standard SIG Weight Scale (0x181D) and Body Composition
(0x181B) services. The official app never uses them — no write to the User Control
Point, no RACP, no indication received. Do not spend time on that path; we did, and there
was nothing there.
The link must be bonded/encrypted: the scale sends an SMP Security Request as soon as the unlock characteristic is touched, and rejects everything on an unencrypted link. On BlueZ this happens by itself on the first authenticated write.
Three 17-byte commands on the unlock characteristic, each answered with two status bytes:
| Write | Meaning | OK reply |
|---|---|---|
01 + key |
unlock with a registered key | 8100 |
02 + 16 × 00 |
enter key programming mode | 8200 |
00 + key |
register a key in the slot | 8000 |
A non-zero second byte is a refusal (e.g. 820f). 01 + key unlocks on its own — the
02 the app sends on every connection is not needed.
A normal BLE stack subscribes to every notify characteristic on connect. The VIVA does
not tolerate that: startTransmission simply goes unanswered, with no GATT error to
tell you why, and the scale is left showing Err.
The order the official app uses, and the only one that works:
- Before the unlock, only RX0 and the unlock characteristic may be subscribed —
RX1, RX2 and RX3 must have their CCCDs off (write
0000, or never turn them on). - Unlock (
01+ key), wait for8100. - After the unlock, turn the unlock characteristic's CCCD off (
0000), then turn on RX1, RX2, RX3 in that order (0100). - Only now send
startTransmission.
On a stack where notifications are registered separately from the descriptor write (ESP-IDF, for instance), you need both: the notify registration, and the CCCDs off before the unlock and on after it. With only one of the two, the long response frame never completes.
VivaClient.__aenter__ does exactly this. Do not reorder it for tidiness.
On the command characteristic:
| Command | Bytes |
|---|---|
| startTransmission | 08 00 00 00 00 10 00 18 |
| read block | 08 01 00 <addr_hi> <addr_lo> 30 00 <crc> |
| endTransmission | 08 0f 00 00 00 00 00 07 |
crc is the 8-bit XOR of the preceding bytes; 0x30 (48) is the block length. Real
examples: 08010001a0300098, 08010001d03000e8, 080100020030003b.
startTransmission or a read gets no answer, send endTransmission anyway
before giving up. An abandoned session leaves the scale stuck on Err until it is reset.
Responses come back fragmented across RX0–RX3 and must be reassembled in the order the notifications arrive, not per characteristic.
byte 0 total frame length (0x38 = 56)
byte 1 0x81
bytes 2..4 block address, 3-byte big-endian
byte 5 payload length (0x30 = 48)
bytes 6..53 payload
... trailing bytes
A frame is complete once you have frame[0] bytes; truncate to that length. The 8-bit XOR
of the whole truncated frame must be 0.
Observed on one device, with the history log not yet wrapped:
| Range | Contents |
|---|---|
0x01B0–0x02A0 |
profile / configuration |
0x02C0–0x0E3F |
the weigh-in log, 32-byte records, contiguous |
from 0x0EA0 |
erased |
The log is a circular buffer of 30 records per profile, so how far back it reaches depends entirely on how often that person weighs in — three weeks for a daily user, most of a year for an occasional one. It is a rolling window, not an archive.
32 bytes. Fields are bit-packed, and bit 0 is the MSB of byte 0.
| Field | Bits | Encoding |
|---|---|---|
| weight | [0:11] |
÷10 → kg |
| body fat % | [17:26] |
÷10 |
| visceral fat | [28:32] |
integer |
| basal metabolic rate | [33:44] |
kcal |
| skeletal muscle % | [49:58] |
÷10 |
| year | [58:64] |
+2000 |
| BMI | [64:74] |
÷10 |
| minute | [74:80] |
|
| month | [92:96] |
|
| day | [96:101] |
|
| hour | [101:106] |
|
| counter | bytes [24:26] |
uint16 BE, per profile |
| weight (check copy) | [208:219] |
same encoding as weight |
Zeroed composition is an absent value. When the scale has no complete profile data for
the person — a guest/shared slot, or a child young enough that it will not run bioimpedance
at all — body fat, muscle, BMR and visceral fat come back as zero. That is "not
measured", not "measured as zero". The decoder returns None for those fields and sets
body_composition_available = False. Weight and BMI are still there.
The trailing weight copy is the integrity check. The record repeats the weight at
bits [208:219]. If the two disagree the record is corrupt and must be dropped; it is the
only per-record integrity check that exists. decode_dump() drops them by default.
There is no person field. Nothing in the record says which profile it belongs to — we
looked, and two promising candidates turned out to be something else (see below). If a
household shares a scale and someone presses the wrong button, the information is lost at
the source. The best available hint is the implied height, sqrt(weight / BMI),
exposed as implied_height_m: it tells you which profile's height the scale used, which is
often enough to notice that a weigh-in landed on the wrong one.
Verified, on real data:
- the unlock sequence, the CCCD choreography, the commands, the frame format and its CRC — reproduced byte for byte from a capture of the official app, then run in production;
- the record layout above — every field cross-checked against values recorded independently by the Omron cloud for the same weigh-ins.
Not verified, and stated as such:
- Seconds. They are not in the record and we did not find where they live. Timestamps have one-minute resolution.
- The blocks at
0x01B0/0x0240, which contain what looks like a connection timestamp and a profile id. That reading is a hypothesis built on two observations, not a fact; this library does not use them. - Whether the unlock key is constant across devices, or per-device. Unknown.
Two fields that were "found" and then retracted deserve a mention, because the pattern is
worth knowing: a byte at offset 24 discriminated the profiles perfectly across 48
records — until it didn't; and bit[17:20] looked like a slot id until it turned out to be
the top three bits of the body fat percentage. Both were regularities mistaken for fields.
Anchor on a value you can verify independently (here: the timestamp), not on a correlation.
This library only reads. There is no EEPROM write function, and that is deliberate: the low EEPROM region holds the sensor's factory calibration, and overwriting it breaks the scale in a way an end user cannot undo. (The official app does write small markers — we do not replicate that.)
register_new_key() exists, sits at the bottom of transport.py behind an explicit
i_understand_this_evicts_the_official_app=True flag, and should be treated as a last
resort: registering a new key evicts the official app's key, and the scale does not give
it back without re-pairing from the app.
Also note the scale talks to one peer at a time. If your phone is connected, this library is not.
pip install -e ".[ble,dev]"
python -m pytest -qThe tests run without any hardware and without any real dump: every vector is synthesised from the layout and checked round-trip. There are no real weigh-ins in this repository, and please don't add any — a dump is somebody's weigh-in diary.
Not affiliated with, endorsed by, or connected to Omron Healthcare. "OMRON" and "VIVA" are trademarks of their respective owners and are used here only to say which device this software talks to.
The official OMRON connect app was never disassembled, decompiled, patched or modified in any way, and none of its code was examined. The app was used exactly as shipped, on an unmodified phone; what was observed is the Bluetooth traffic it exchanges over the air with a scale owned by the author, captured with the phone's own built-in HCI snoop log. No Omron software or service was accessed, altered or interfered with.
This repository contains no Omron code, firmware, assets or keys. What it documents is a wire protocol — the ideas and principles behind an interface, which are not themselves protected by copyright (Directive 2009/24/EC, Art. 1(2)) — reconstructed by observing radio traffic from hardware the author owns, in order to build an independently created program that interoperates with it (Art. 5(3) and Art. 6 of the same Directive; Art. 8 makes contractual terms contrary to those provisions void).
The unlock key is deliberately not included: it is a per-installation secret that each user must recover from their own device.
No warranty of any kind, per the MIT license below. This software is not a medical
device and its output must not be used for diagnosis or treatment. Using it may cause
the official app to stop syncing while the library is connected; register_new_key() can
break the official app's pairing altogether. You run it on your own hardware, at your own
risk.
MIT © Andrea Cruciani