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 as ORALB_MANUFACTURER in parser.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_timer and number_of_sectors fields.

    • 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 (D36 / Pro 6000), 2 (Triumph v2 / early D36), 3 (D601 / D701), 4 (D700), 6 (IO Series), 7 (IO 4).

1

Model identifier

Looked up in MODEL_ID_TO_MODEL. Unknown IDs fall back to Models.Unknown (uses SMART_SERIES_MODES).

2

Reserved

Not interpreted by this parser. Believed to be firmware/hardware revision.

3

Toothbrush state

Decoded via STATES. 3 is the only value that means “actively brushing” — drives the brushing binary sensor.

4

Pressure

Decoded via PRESSURE. See pressure bit-encoding below.

5

Brush-time minutes

High byte of the elapsed brushing time. Combined with byte 6 as data[5] * 60 + data[6] seconds.

6

Brush-time seconds

Low component of the elapsed brushing time, in seconds within the current minute.

7

Mode

Decoded via the per-model modes dict (SMART_SERIES_MODES or IO_SERIES_MODES).

8

Sector code

Decoded via _decode_sector. See sector bit-encoding. Reported as "no sector" whenever the state is not running.

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 4 or 6). Present only on length-11 payloads.

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:

    • 0 or 2 — no button event

    • 6 — brushing-button pressed

    • 8 or 10 — 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 quadrant

    • 16 — that quadrant

    • 7 — a “last quadrant” sentinel; its real number is the brush’s sector count (byte 10). Firmware that does not report a count (0 or 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

06

6

Protocol v6

1

32

50

Model IOSeries (“SONOS IO BIG_TI”)

2

6b

107

Reserved

3

02

2

State idle

4

72

114

Pressure normal (high nibble 7, low nibble 2)

5

00

0

Brush-time minutes

6

00

0

Brush-time seconds → 0s elapsed

7

01

1

Mode sensitive (IO Series modes)

8

01

1

Sector 1 — overridden to no sector because idle

9

00

0

Sector timer 0s

10

04

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

CHARACTERISTIC_BATTERY

a0f0ff05-5047-4d53-8208-4f72616c2d42

Single byte: battery percentage (0100).

CHARACTERISTIC_PRESSURE

a0f0ff0b-5047-4d53-8208-4f72616c2d42

Single byte decoded via ACTIVE_CONNECTION_PRESSURElow/normal/high.

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 same pressure sensor — 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:

  1. Capture the advertisement payload (a manufacturer_data[220] byte string) and add it as a BluetoothServiceInfo fixture at the top of tests/test_parser.py.

  2. Decode byte 1 by hand using this document.

  3. Add the new identifier to MODEL_ID_TO_MODEL in parser.py.

  4. Pick an existing Models entry that matches (line, mode set) — only add a brand new Models value when the brush ships a mode dict that neither SMART_SERIES_MODES nor IO_SERIES_MODES cover.

  5. Write a regression test that asserts result.devices[None].model matches the expected device_type.

References