Oral-B BLE wire format
This page documents the on-the-wire layout that
oralb_ble.parser
decodes. It is a developer reference for contributors adding new model
IDs, new fixtures, or new sensors — not a stable public API.
The Oral-B brush is passively discoverable: most data arrives in the BLE advertisement payload (manufacturer-specific data). A handful of values (battery percentage, active-connection pressure) are only available by opening a GATT connection and reading the relevant characteristic.
Manufacturer-specific advertisement
Company identifier:
0x00DC(220 decimal) — assigned to “Procter & Gamble Company” by the Bluetooth SIG. The parser exposes this asORALB_MANUFACTURERinparser.py.Payload length: either 9 or 11 bytes. Any other length is ignored and logged at DEBUG.
Length-9 payloads come from older (v1) brushes and omit the
sector_timerandnumber_of_sectorsfields.Length-11 payloads are emitted by every protocol version from v2 onwards (D21/D36/D601/D700/D701/D706 and the IO Series).
The parser indexes the payload as raw bytes. The table below uses zero-based byte offsets.
Byte |
Field |
Notes |
|---|---|---|
0 |
Protocol version |
|
1 |
Model identifier |
Looked up in |
2 |
Reserved |
Not interpreted by this parser. Believed to be firmware/hardware revision. |
3 |
Toothbrush state |
Decoded via |
4 |
Pressure |
Decoded via |
5 |
Brush-time minutes |
High byte of the elapsed brushing time. Combined with byte 6 as |
6 |
Brush-time seconds |
Low component of the elapsed brushing time, in seconds within the current minute. |
7 |
Mode |
Decoded via the per-model |
8 |
Sector code |
Decoded via |
9 |
Sector timer |
Seconds elapsed in the current sector. Present only on length-11 payloads. |
10 |
Number of sectors |
How many sectors the brush is configured for (commonly |
Pressure byte bit-encoding
The PRESSURE lookup table is not a flat list of magic numbers — it is a
2-D table over two independent fields packed into a single byte:
Low nibble (
pressure & 0x0F) encodes the button event:0or2— no button event6— brushing-button pressed8or10— power-button pressed
High nibble (
pressure >> 4) encodes the pressure level:< 9— normal pressure>= 9— high pressure
When both a pressure level and a button event are set, the button-event label wins in the flattened string the parser emits (this is by design — the active-button label is the more actionable signal for the user).
Sector byte bit-encoding
Like the pressure byte, byte 8 packs two independent fields:
Low three bits (
sector & 0x07) encode the quadrant index:0— no quadrant1–6— that quadrant7— a “last quadrant” sentinel; its real number is the brush’s sector count (byte 10). Firmware that does not report a count (0or a length-9 payload) falls back to the historical four-sector assumption.
Upper bits (
sector >> 3) are a display flag indicating which feedback face the handle shows. They do not change the quadrant and are masked off.
This replaces the previous hand-built SECTOR_MAP, which only covered
4-sector brushes: it returned "unknown sector code 5" for sector 5 and
reused "sector 4" for the last-quadrant sentinel, so 6-sector brushes
(IO Series 10) mis-reported sectors 5 and 6.
Reading an example
A real-world IO Series advertisement looks like this in the test suite
(ORALB_IO_SERIES_6):
manufacturer_data = {220: b"\x062k\x02r\x00\x00\x01\x01\x00\x04"}
Byte-by-byte:
Offset |
Hex |
Decimal |
Decoded |
|---|---|---|---|
0 |
|
6 |
Protocol v6 |
1 |
|
50 |
Model |
2 |
|
107 |
Reserved |
3 |
|
2 |
State |
4 |
|
114 |
Pressure |
5 |
|
0 |
Brush-time minutes |
6 |
|
0 |
Brush-time seconds → 0s elapsed |
7 |
|
1 |
Mode |
8 |
|
1 |
Sector |
9 |
|
0 |
Sector timer 0s |
10 |
|
4 |
Number of sectors: 4 |
GATT characteristics
The advertisement-only path misses battery state and a finer pressure
reading. OralBBluetoothDeviceData.async_poll() opens a GATT connection
and reads two characteristics from const.py:
Constant |
UUID |
Payload |
|---|---|---|
|
|
Single byte: battery percentage ( |
|
|
Single byte decoded via |
Note: the connected-mode pressure vocabulary (
low/normal/high) differs from the advertisement-mode vocabulary (normal/high/button pressed/power button pressed). Both write to the samepressuresensor — the last successful read wins.
The remaining UUIDs in const.py are catalogued for future
reverse-engineering work; only the two above are wired up today.
Adding a new model
When a user reports an unknown brush, the workflow is:
Capture the advertisement payload (a
manufacturer_data[220]byte string) and add it as aBluetoothServiceInfofixture at the top oftests/test_parser.py.Decode byte 1 by hand using this document.
Add the new identifier to
MODEL_ID_TO_MODELinparser.py.Pick an existing
Modelsentry that matches (line, mode set) — only add a brand newModelsvalue when the brush ships a mode dict that neitherSMART_SERIES_MODESnorIO_SERIES_MODEScover.Write a regression test that asserts
result.devices[None].modelmatches the expecteddevice_type.
References
Original parser: https://github.com/Ernst79/bleparser/blob/c42ae922e1abed2720c7fac993777e1bd59c0c93/package/bleparser/oral_b.py
Protocol reverse-engineering notes: https://github.com/wise86-android/OralBlue_python/blob/15e1a03bcb3350574d438e4593bcff59608a77a7/Protocol.md
Model-ID source: https://github.com/MatrixEditor/oralb-io/blob/master/oralb/blesdk/brush.py