Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

omron-viva

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.

Why this exists

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.

Two layers, on purpose

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 transport

Quick start

Decoding 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.

Getting your key

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:

  1. On Android, enable Developer options → Enable Bluetooth HCI snoop log, then toggle Bluetooth off and on.
  2. 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.)
  3. adb bugreport and find the btsnoop_hci.log inside the archive.
  4. Open it in Wireshark and look for a 17-byte write to characteristic b305b680-aee7-11e1-a730-0002a5d5c51b. A write starting with 0x01 is an unlock: the 16 bytes after it are the key.

The protocol

GATT map

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.

Unlock

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.

The gotcha that costs weeks

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:

  1. 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).
  2. Unlock (01 + key), wait for 8100.
  3. After the unlock, turn the unlock characteristic's CCCD off (0000), then turn on RX1, RX2, RX3 in that order (0100).
  4. 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.

Commands

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.

⚠️ If 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.

Response frames

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.

Memory map

Observed on one device, with the history log not yet wrapped:

Range Contents
0x01B00x02A0 profile / configuration
0x02C00x0E3F 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.

The record

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

Three things that look like bugs and are not

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.

What is verified, and what is not

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.

Safety

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.

Development

pip install -e ".[ble,dev]"
python -m pytest -q

The 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.

Legal

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.

License

MIT © Andrea Cruciani

About

Read and decode weigh-in records from an Omron VIVA (HBF-222T) body composition scale over BLE

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages