ESPHome external component that exposes an Oclean BLE electric toothbrush to Home Assistant. One component instance ("hub") per brush; several hubs run on a single ESP32 with their first polls staggered so the radio is not contended.
The component reads battery, dock/charge state, device settings and the buffered brushing sessions, and writes back a small set of controls: brushing mode (including custom programs), over-pressure alert, raise-to-wake, brush-head replacement days, brush-head counter reset, display language, clock.
It connects only to poll and then disconnects (connect-poll-disconnect), so it does not hold the brush's BLE radio open and keeps brush battery drain low. The brush buffers sessions internally and never streams while brushing; the component downloads the records after the fact.
The document is split in two:
- Part 1 - Integrator (yaml only): how to wire a brush into an ESPHome device and what entities you get.
- Part 2 - Extender (C++ + python): how the component is structured, the BLE lifecycle, the wire formats, and how to add a new entity.
This repository ships three pieces that install by different mechanisms.
Not a HACS item: ESPHome pulls external components straight from GitHub. Add to your ESPHome device YAML:
external_components:
- source: github://dzikus/esphome-oclean
components: [oclean]Add ref: with a release tag to pin a version; without it the build follows
main (optionally with refresh: 1d).
Manual alternative: copy components/oclean/ next to your device YAML and use
source: components. Part 1 covers the YAML in full.
HACS -> top-right menu -> Custom repositories -> URL
https://github.com/dzikus/esphome-oclean, category Dashboard -> Add.
Open the new entry, Download. On storage-mode dashboards HACS registers the
resource automatically; add a card of type custom:oclean-coverage-card. If the
card does not resolve (YAML-mode dashboards), add the resource by hand: Settings
-> Dashboards -> menu -> Resources -> Add, URL
/hacsfiles/esphome-oclean/oclean-coverage-card.js, type JavaScript module.
Manual alternative: copy dist/oclean-coverage-card.js to config/www/ and add
the resource /local/oclean-coverage-card.js.
Backfills brushing history into long-term statistics under real past timestamps.
HACS -> Custom repositories -> the same URL, category Integration ->
Add -> open -> Download -> restart Home Assistant. Then map your brushes
in configuration.yaml (see Session history).
Manual alternative: copy custom_components/oclean_stats/ to
config/custom_components/ and restart.
The card and the bridge are added as two separate custom repositories with the same URL because HACS keys a repository by (URL, category).
The protocol profile is selected at runtime from the device model string (DIS
characteristic 0x2A24), so one build serves the whole family rather than
being hardcoded to a single model.
| Line | Model id (DIS 0x2A24) | Profile | Status |
|---|---|---|---|
| X / X Pro / Pro Elite / Ultra / Pro 20 | OCLEANY3, OCLEANY3M*, OCLEANY3P*, OCLEANV1, OCLEANX20 |
TYPE1 | X Pro Elite (OCLEANY3P / OCLEANY3PD) verified on hardware; others untested |
| Z1 | OCLEANY5 |
TYPE_Z1 | untested (needs a capture to freeze the record layout) |
| other / new firmware | unmatched | UNKNOWN fallback | battery + status only |
Everything below was verified empirically on two X Pro Elite brushes unless noted. For untested models the session-record layout is not considered frozen; battery and status are the safe baseline. Reports and PRs welcome.
ESP32 side: any board capable of ble_client. The default esp-idf BLE host
limit is 3 concurrent connections; raise max_connections only when the node
hosts more BLE clients than that.
| Service | Characteristic | Use |
|---|---|---|
8082caa8-41a6-4021-91c6-56f9b954cc18 |
9d84b9a3-000c-49d8-9183-855b673fbb85 (WRITE) |
Tx, most commands |
8082caa8-41a6-4021-91c6-56f9b954cc18 |
5f78df94-798c-46f5-990a-855b673fbb86 (READ/NOTIFY) |
Rx, status / settings / acks |
8082caa8-41a6-4021-91c6-56f9b954cc18 |
5f78df94-798c-46f5-990a-855b673fbb89 (WRITE) |
Tx, session-download command |
8082caa8-41a6-4021-91c6-56f9b954cc18 |
5f78df94-798c-46f5-990a-855b673fbb90 (NOTIFY) |
Rx, session record stream |
0x180F |
0x2A19 (READ/NOTIFY) |
battery percent, single byte |
0x180A |
0x2A24 / 0x2A27 / 0x2A28 (READ) |
model / HW revision / SW revision |
No pairing, no bonding, no auth: connect and write. Integers are big-endian, frames carry no CRC. Write With Response is mandatory; Write No Response is silently dropped by the brush.
Numbers below are with the default expose_dev_sensors: false. With
expose_dev_sensors: true you also get dev entities (settings readbacks and
toggles with no observable effect on the verified brushes, the
session-capture button). Several always-on entities are created
with disabled_by_default: true, so they stay hidden in HA until enabled per
entity.
- 18 sensors always on: battery, last-session score / duration / valid duration / coverage, 8 per-zone gesture values, brush-head used days / sessions / used time, device theme, clock drift. 1 dev sensor: volume index. (device theme, head used time and clock drift are hidden by default.)
- 4 binary sensors always on: charging, docked, BLE connected (hidden), auto mode (hidden). 4 dev binary sensors: volume / calendar / splash prevention / fill brush readbacks.
- 9 text sensors always on: last session, last session mode, device clock, hardware revision, software version, last seen, timezone, MAC address, model (the last six hidden).
- 3 switches always on: over-pressure alert, raise to wake, bluetooth (BLE link master switch). 3 dev switches: area reminder, brush pause, brush mode.
- 9 numbers: head replacement days plus 8 custom-program step parameters.
- 2 selects: brushing mode, display language.
- 3 buttons always on: reset brush head, sync clock (needs
time_id), poll now (hidden). 1 dev button: capture sessions.
This component uses the ESPHome sub-device API and current entity APIs, so it
needs ESPHome 2026.1.0 or newer. Pin it with
esphome: { min_version: 2026.1.0 } so an older install fails fast instead of
erroring deep in code generation.
Replace the MAC with the brush's MAC (any BLE scanner shows it while the brush
is awake). Append @<tag> to the source to pin a release.
external_components:
- source: github://dzikus/esphome-oclean
components: [oclean]
time:
- platform: homeassistant
id: ha_time
ble_client:
- id: ble_brush
mac_address: AA:BB:CC:DD:EE:FF
oclean:
- id: brush_hub
ble_client_id: ble_brush
time_id: ha_time
sensor:
- platform: oclean
oclean_id: brush_hub
binary_sensor:
- platform: oclean
oclean_id: brush_hub
text_sensor:
- platform: oclean
oclean_id: brush_hub
switch:
- platform: oclean
oclean_id: brush_hub
number:
- platform: oclean
oclean_id: brush_hub
select:
- platform: oclean
oclean_id: brush_hub
button:
- platform: oclean
oclean_id: brush_hubThat creates every default entity, named in English, with default icons and
categories. Each platform auto-creates its entities; nothing has to be listed
key by key. Every individual entity can still be customised; see Override
per-entity below. A complete single-brush config is in
example.yaml.
Auto-creation is a deliberate departure from the usual ESPHome style, where
every entity is spelled out in YAML. One brush exposes around fifty of them, and
listing each by hand would be pages of boilerplate for a device whose entity set
is fixed by the protocol. The escape hatches are per-entity overrides and
false to drop one.
The four control platforms are optional. Leave switch, number, select or
button out and their code is not compiled into the firmware at all.
That alone does not make the node read-only: auto_sync_time defaults to on
whenever the hub has a time_id, and it writes the brush clock (0201) on its
own. For a node that never writes anything, drop the four control platforms
and set auto_sync_time: false.
Set on the oclean: entry, not on the platforms.
| Option | Type | Default | Effect |
|---|---|---|---|
ble_client_id |
id | - | Required. Points to the ble_client entry with this brush's MAC. |
update_interval |
time | 3600s (min 60s) |
Off-dock cadence: gap between connect-poll-disconnect cycles while the brush runs on battery. |
charging_interval |
time | 600s (min 60s) |
Docked cadence: faster polls while the brush sits on the dock (charging or fully charged). Clamped down to update_interval if set larger; set both equal for fixed-interval polling. |
hold_connection_while_docked |
bool | true |
Keep the BLE link open while the brush is docked instead of disconnecting after each poll; re-queries on the live link every charging_interval. The link drops when the brush leaves the dock. Docked means charging, so this costs no brush battery. Set false for plain connect-poll-disconnect. |
time_id |
id | none | A time: platform id (local time source). Enables the sync-clock button, auto clock-sync and the wall-clock stamps (last seen, session timestamps). |
tzindex |
int 1-33 | 16 |
1-based index into the brush's 33-entry GMT-offset table, written together with the clock. 16 = CEST (UTC+2), 15 = CET (UTC+1). |
auto_sync_time |
bool | on when time_id is set, off otherwise |
Resync the brush clock during a poll when it has drifted past sync_drift_threshold. Explicit true without time_id fails validation. |
sync_drift_threshold |
time | 120s |
Drift that triggers an auto resync. 0s resyncs whenever the clocks differ by at least one second. |
expose_dev_sensors |
bool | false |
Creates the dev-gated entities (see the per-platform tables). |
name_prefix |
string | derived when more than one hub is configured, empty otherwise | Prepended to every default entity name on this hub, so two brushes do not both call a sensor Battery. Derived from the hub id with a leading oclean_ stripped, so oclean_brush_b gives Brush B Score. Set it explicitly to choose the wording, or to "" to opt out. Names you write yourself are never touched. See Two brushes on one ESP32. |
The brushing-mode select additionally accepts custom_modes (a list of named
programs); that option lives under the select: platform, not the hub. See
Entities (select).
Dock-aware adaptive polling is always on: the hub polls at charging_interval
while the brush is docked and at update_interval while it is off the dock.
Dock presence (not the charge phase) selects the cadence, so a fully charged
brush still on the dock keeps the fast cadence. With several hubs on one node
the first poll of hub N is deferred by N * 90 s after boot so the cycles do not
race for the single scanner.
All auto-created. "Hidden" means disabled_by_default: true in HA (enable per
entity). "Dev" rows exist only on hubs with expose_dev_sensors: true.
| Key | Default name | Source | Notes |
|---|---|---|---|
battery |
Battery | battery characteristic / STATUS | percent, diagnostic |
last_session_score |
Score | session record byte 33 | 0-100; the no-score sentinel (0xFF) reads as unknown |
last_session_duration |
Duration | session record bytes 7-8 BE | seconds |
last_session_valid_duration |
Valid duration | session record bytes 9-10 BE | seconds counted as effective |
last_session_coverage |
Coverage | derived | valid / duration, percent |
gesture_zone_1 .. gesture_zone_8 |
Zone 1 .. Zone 8 | session record bytes 23-30 | per-region values; 1-4 left, 5-8 right (upper-outer / upper-inner / lower-outer / lower-inner per side) |
head_used_days |
Brush head used days | settings buffer 27-28 BE | cumulative since head reset |
head_used_times |
Brush head sessions | settings buffer 29-30 BE | cumulative since head reset |
head_used_time |
Brush head used time | settings buffer 14-15 BE | hidden; unit unconfirmed |
device_theme |
Device theme | settings buffer 0 | hidden; raw index |
volume_index |
Volume index | settings buffer 9 | dev; hidden; raw index |
The last decoded session survives reboots: the newest record is persisted in NVS per hub and re-published on boot.
| Key | Default name | Source | Notes |
|---|---|---|---|
charging |
Charging | STATUS byte 2 == 0x01 | actively charging on the dock |
docked |
Docked | STATUS byte 2 == 0x01 or 0x03 | on the dock, charging or fully charged |
connected |
BLE connected | link state | hidden; off almost always by design (the link is up only seconds per poll); use Last seen for freshness |
auto_mode |
Auto mode | settings buffer 4 | hidden; read-only (the brush rejects the write opcode) |
volume_enabled |
Volume enabled | settings buffer 8 (inverted) | dev |
calendar_enabled |
Calendar enabled | settings buffer 10 (inverted) | dev |
splash_prevent |
Splash prevention | settings buffer 13 | dev |
fill_brush |
Fill brush | settings buffer 3 | dev; read-only (write opcode rejected) |
| Key | Default name | Source | Notes |
|---|---|---|---|
last_session_time |
Last session | session record bytes 0-5 | timestamp of the newest buffered session (brush clock) |
last_session_mode |
Last session mode | session record byte 6 | scheme id decoded to the brushing-mode name; unknown ids fall back to the number |
device_clock |
Device clock | settings buffer 16-21 | the brush's own clock |
last_seen |
Last seen | wall clock | hidden; timestamp device class, renders "x ago" in HA; stamped on every successful poll, the freshness signal for the slow cadence |
timezone |
Timezone | settings buffer 24 | hidden; decoded GMT offset, e.g. "GMT+02:00" |
hw_revision |
Hardware revision | DIS 0x2A27 | hidden |
sw_version |
Software version | DIS 0x2A28 | hidden |
mac_address |
MAC address | BLE | hidden |
model |
Model | DIS 0x2A24 | hidden; the raw model id that drives profile selection |
All device-backed switches publish optimistically and are then corrected by the
settings readback; their restore mode is DISABLED so nothing is written on
boot. The brush acks every accepted write with <opcode> 4F 4B ("OK").
| Key | Default name | Write | Notes |
|---|---|---|---|
over_pressure |
Over-pressure alert | 02 12 + 01/00 |
readback at settings buffer 22 |
raise_wake |
Raise to wake | 02 23 + 01/00 |
readback at settings buffer 2 |
bluetooth |
Bluetooth | local only | master switch for the BLE link; OFF drops pending writes and tears the link down; RESTORE_DEFAULT_ON so a reboot never leaves the brush silently unreachable |
area_reminder |
Area reminder | 02 0D + 01/00 |
dev; no observable effect on the verified brushes |
brush_pause |
Brush pause | 02 22 + 01/00 |
dev |
brush_mode |
Brush mode | 02 09 + 01/EC |
dev; off byte is the 0xEC sentinel, not 0x00 |
| Key | Default name | Range | Notes |
|---|---|---|---|
head_max_days |
Head replacement days | 1-365 | writes 02 17 + 2B BE; box input (a slider would fire a write per step) |
custom_step1_gear .. custom_step4_gear |
Custom step N gear | 1-41, default 8 | parameters of the runtime Custom program; stored on the node (flash-persisted), written to the brush only when Custom is selected |
custom_step1_duration .. custom_step4_duration |
Custom step N duration | 5-120 s, step 5, default 30 | same; changing a parameter while Custom is active re-programs the brush (debounced) |
| Key | Default name | Options | Notes |
|---|---|---|---|
brush_scheme |
Brushing mode | 19 presets + named custom_modes + "Custom" |
writes the full per-step program (02 06 / 02 0B); current option read back from settings buffer 11 |
device_language |
Display language | 17 languages | writes 02 16 + language id; readback from settings buffer 31 |
Preset options are labelled "name (duration)", e.g. "Quick cleaning (1m20s)". Named custom modes are declared under the select:
select:
- platform: oclean
oclean_id: brush_hub
brush_scheme:
custom_modes:
- name: "Evening strong"
program:
- { gear: 16, duration: 30 }
- { gear: 16, duration: 30 }
- { gear: 24, duration: 30 }
- { gear: 16, duration: 30 }
- name: "Morning express"
program:
- { gear: 8, duration: 20 }
- { gear: 8, duration: 20 }
- { gear: 8, duration: 20 }
- { gear: 8, duration: 20 }Up to 20 modes, 1-4 steps each, gear 1-41, duration 5-120 s. Modes get ids 121+ in list order (reordering shifts the ids, which only affects how old session records decode). The runtime "Custom" option (id 120) builds its program from the custom-step number entities at selection time. Step boundaries double as the brush's pause signals and summary segments, so a program wants four steps to keep the four-quadrant guidance.
| Key | Default name | Effect | Notes |
|---|---|---|---|
reset_head |
Reset brush head | writes 02 0F |
irreversible: zeroes the brush-head usage counters |
sync_time |
Sync clock | writes 02 01 + 8 bytes |
created only when the hub has time_id; writes on press only |
poll_now |
Poll now | immediate poll cycle | hidden by default; read-only on the brush |
capture_sessions |
Capture sessions | session download + 30 s hold | dev; keeps the link open so the raw record stream lands in the log |
Every key on every platform accepts the normal ESPHome entity config. Override the name, icon, category or any other entity field directly under the key:
sensor:
- platform: oclean
oclean_id: brush_hub
battery:
name: "Brush Battery"
last_session_score:
name: "Brushing Score"
icon: "mdi:star"Schema defaults are injected before validation, so omitted fields keep their
defaults. If you do not set name, the default in the tables above is used.
Two ble_client entries and two oclean hubs. Use device_id to put each
brush's entities under a separate sub-device in HA:
esphome:
devices:
- id: dev_brush_a
name: "Oclean A"
- id: dev_brush_b
name: "Oclean B"
ble_client:
- id: ble_a
mac_address: AA:BB:CC:DD:EE:FF
- id: ble_b
mac_address: AA:BB:CC:DD:EE:00
oclean:
- id: hub_a
ble_client_id: ble_a
time_id: ha_time
- id: hub_b
ble_client_id: ble_b
time_id: ha_time
sensor:
- platform: oclean
oclean_id: hub_a
device_id: dev_brush_a
- platform: oclean
oclean_id: hub_b
device_id: dev_brush_b
binary_sensor:
- platform: oclean
oclean_id: hub_a
device_id: dev_brush_a
- platform: oclean
oclean_id: hub_b
device_id: dev_brush_b
text_sensor:
- platform: oclean
oclean_id: hub_a
device_id: dev_brush_a
- platform: oclean
oclean_id: hub_b
device_id: dev_brush_b
switch:
- platform: oclean
oclean_id: hub_a
device_id: dev_brush_a
- platform: oclean
oclean_id: hub_b
device_id: dev_brush_b
number:
- platform: oclean
oclean_id: hub_a
device_id: dev_brush_a
- platform: oclean
oclean_id: hub_b
device_id: dev_brush_b
select:
- platform: oclean
oclean_id: hub_a
device_id: dev_brush_a
- platform: oclean
oclean_id: hub_b
device_id: dev_brush_b
button:
- platform: oclean
oclean_id: hub_a
device_id: dev_brush_a
- platform: oclean
oclean_id: hub_b
device_id: dev_brush_bBoot polls are staggered automatically.
device_id decides which HA device an entity belongs to, but it does not make
the entity's name unique, and on some transports the name is the identity.
MQTT builds its state topic, discovery topic and unique_id from the name
alone, with no device in any of them, so two brushes both exposing Battery
publish over each other. The native API is unaffected: it passes device_id
next to the key and Home Assistant 2025.8+ tracks entities as
(device_id, key).
The component therefore prefixes its own default names as soon as a second hub is configured, taking the prefix from the hub id:
hub_a -> "Hub A Battery" oclean_brush_b -> "Brush B Battery"
Set name_prefix on each hub to choose the wording:
oclean:
- id: hub_a
ble_client_id: ble_a
name_prefix: "Brush A"
- id: hub_b
ble_client_id: ble_b
name_prefix: "Brush B"Set it to "" to keep the bare names, which is safe if you only use the native
API. A single-brush node is never prefixed.
Each new session from the brush's ring buffer fires an esphome.oclean_session
event (score, duration, valid duration, coverage, scheme, per-zone values,
timestamp). A per-brush watermark stored in NVS prevents re-emitting old
sessions across reboots.
The events need homeassistant_services: true under api: (it is off by
default in ESPHome). Without it the firmware still builds and every entity
works; only the events are compiled out, and validation prints a warning saying
so.
Independently of the event, each new session also fires the on_session
trigger, so a node can act on a session without Home Assistant in the loop. x
is the decoded record (score, duration_s, valid_duration_s, scheme,
zones[8], the year..second fields, has_score). Trigger and event both
fire oldest session first, and both run before the session entities are updated,
so read the session from x rather than from the entity states:
oclean:
- id: brush
ble_client_id: brush_ble
on_session:
- logger.log:
format: "brushed %us, score %u"
args: ["(unsigned) x.duration_s", "(unsigned) x.score"]api:
encryption:
key: !secret api_encryption_key
homeassistant_services: trueThe optional oclean_stats integration (Installation, path 3) writes these into
long-term statistics under their real past timestamps, so brushing history charts
even for sessions that happened while Home Assistant was down. Map each brush MAC
to a slug in configuration.yaml:
oclean_stats:
brushes:
"AA:BB:CC:DD:EE:FF": alice
"AA:BB:CC:DD:EE:00": bobThe MAC must match what the component reports (upper-case, colons); the slug
becomes part of the statistic id (oclean:<slug>_score), so keep it to
[a-z0-9_]. The bridge is read-only to the brush and creates no entities; the
statistics show up in a Statistics card pointed at oclean:<slug>_score and in
Settings -> Dashboards -> ... -> Statistics.
custom:oclean-coverage-card draws the eight per-zone gesture values of the last
session as a colored mouth map (upper and lower arch, left/right side, outer/inner
surface). Read-only: it reads the zone / score / coverage entities and recorder
history and never talks to the brush. Install it through HACS (Installation,
path 2) or by hand.
type: custom:oclean-coverage-card
title: Brushing coverage
zone_prefix: sensor.oclean_zone_ # expands to _1 .. _8
score_entity: sensor.oclean_score
coverage_entity: sensor.oclean_coverage
time_entity: sensor.oclean_last_session| Option | Default | Meaning |
|---|---|---|
zones |
- | explicit list of 8 entities in gesture_zone_1..8 order (instead of zone_prefix) |
zone_prefix |
- | entity prefix that 1..8 is appended to |
title |
- | card header |
mirror |
false |
swap the on-screen left / right sides |
normalize |
share |
colouring: share (vs an even 1/8), max (vs the best surface), absolute (vs target) |
target |
15 |
per-surface target for normalize: absolute |
score_entity / coverage_entity / time_entity |
- | values shown in the header |
labels |
EN | override the on-card labels |
Clicking a surface opens the more-info dialog for that zone entity. Arrows and a slider step through the sessions found in recorder history.
A control change calls into the hub, which raises the BLE link immediately if idle; the latency is the time until the brush is connectable, not the poll interval. A sleeping brush is not connectable: the queued write flushes on the next successful connect (next poll, or wake the brush by pressing its button).
The brush accepts one BLE central at a time. While the component is connected
or connecting, the official app cannot pair. To use the app, turn the
bluetooth switch OFF on the brush's HA device, do the app work, then turn it
back ON.
components/oclean/
__init__.py hub config + schema, adaptive-poll validation, dev gating
sensor.py 17 + 1 dev sensor keys, schema + to_code
binary_sensor.py 4 + 4 dev binary sensor keys
text_sensor.py 8 + 1 dev text sensor keys
switch.py 5 command switches + the local bluetooth switch
number.py head_max_days + 8 custom-program parameters
select.py scheme presets + custom modes, language table
button.py capture / reset-head / sync-clock / poll-now
oclean_protocol.{h,cpp} pure C++: command table, session + settings
assemblers, record decode, scheme/clock/toggle
builders, adaptive-poll helpers
oclean_profile.{h,cpp} model-string to profile dispatch
oclean.{h,cpp} OcleanHub: BLE client node + PollingComponent +
poll state machine, NVS persistence
oclean_switch.h OcleanCommandSwitch / OcleanBleSwitch
oclean_number.h OcleanHeadDaysNumber / OcleanCustomParamNumber
oclean_button.h the four button classes
oclean_select.h OcleanSchemeSelect / OcleanLanguageSelect
oclean_protocol.{h,cpp} has no ESPHome dependencies and is what the
PlatformIO unit tests link against. Everything else needs the ESPHome runtime.
[IDLE] --poll due (adaptive cadence) --> [CONNECTING] --open + discovery--> [POLLING]
^ |
| queries done + hold elapsed, or 60 s whole-poll watchdog |
+----------------------------------------------------------------------------+
- A poll cycle enables the BLE client, waits for the GATT open and service discovery, resolves all characteristic handles synchronously in the search-complete event, registers for notifies, then after a settle delay issues the query sequence: battery, device information (cached for 24 h), STATUS, SETTINGS, session download.
- Pending writes queued by HA controls flush at the start of the query phase of the next connect; a write while idle raises the link immediately.
- The link is dropped after a short hold (8 s normal poll, 30 s capture). A 60 s whole-poll watchdog tears down a stuck cycle; a cycle killed before the GATT open retries at the next tick instead of waiting a full interval.
- With
hold_connection_while_dockeda poll that reads back a docked state keeps the link, re-queries everycharging_interval(each round under its own watchdog), and leaves the hold when STATUS reports off-dock, the link drops, or the bluetooth switch turns OFF. - The brush pushes a spontaneous STATUS on dock changes while connected, so leaving the dock is detected immediately during a hold.
Timings (from oclean_protocol.h): post-connect settle 800 ms, whole poll 60 s,
boot stagger 90 s per hub, capture hold 30 s, poll hold 8 s, DIS cache 24 h,
enrichment wait 2.5 s, queued-write spacing 300 ms, query spacing 500 ms,
backfill publish spacing 1.5 s.
All commands go to the main write characteristic (...fbb85) as Write With
Response, except the session download which goes to ...fbb89. Accepted
writes are acked with <opcode> 4F 4B ("OK") on the main notify
characteristic; rejected opcodes return a one-byte 02 stub.
| Bytes | Meaning |
|---|---|
03 03 |
STATUS: 8-byte reply, battery at byte 5, dock/charge state at byte 2 (01 charging, 02 off dock, 03 docked and full) |
03 02 01 |
SETTINGS: replied as a two-frame transfer reassembled into a 34-byte buffer |
02 02 |
device info (ack only on the verified family) |
03 07 |
session download (reply streams on the session notify characteristic) |
02 01 + 8B |
set clock: [year-2000][month][day][hour][min][sec][weekday][tzindex], plain decimal bytes, local time, weekday 0 = Sunday |
02 0F |
reset brush-head counter |
02 17 + 2B BE |
head replacement days |
02 06 / 02 0B |
brushing-scheme program (split frames) |
02 16 + 1B |
display language id |
02 0D / 02 12 / 02 22 / 02 23 / 02 09 + 1B |
config toggles (area reminder, over-pressure, brush pause, raise wake, brush mode; brush-mode off byte is EC) |
The SETTINGS reply is two 03 02 notifies: the start frame (03 02 23 24 +
16 payload bytes) fills buffer [0..16), the continuation (03 02 + 18
payload bytes) fills [16..34). SettingsAssembler accepts them in either
order. Confirmed offsets:
| Offset | Field |
|---|---|
| 0 | device theme |
| 1 / 2 / 3 / 4 | brush pause / raise wake / fill brush / auto mode (!=0) |
| 8 / 10 | volume / calendar enabled (inverted: 0 = enabled) |
| 9 | volume index |
| 11 | active scheme id (pNum) |
| 12 | brush mode (off sentinel 0xEC) |
| 13 | splash prevent |
| 14-15 | head used time (BE) |
| 16-21 | device clock (year-2000, month, day, hour, min, sec) |
| 22 / 23 | over-pressure / area reminder |
| 24 | timezone index (1-based, 33-entry GMT table) |
| 25-26 / 27-28 / 29-30 | head max days / used days / used sessions (BE) |
| 31 | device language id |
The 03 07 reply on the session notify characteristic starts with
03 07 2A 42 23 [count_hi] [count_lo], then inline record bytes;
continuation notifies are raw bytes. SessionAssembler concatenates until
count * 42 bytes are in, then cuts 42-byte records. The device ring holds 32
records (the assembler accepts up to 64); the ring is not chronological, the
newest record is found by timestamp.
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | year - 2000 |
| 1-5 | 5 | month / day / hour / min / sec (brush clock) |
| 6 | 1 | scheme id (pNum) |
| 7-8 | 2 BE | duration (s) |
| 9-10 | 2 BE | valid duration (s) |
| 11-15 | 5 | area values |
| 23-30 | 8 | gesture array (left 0-3, right 4-7) |
| 33 | 1 | score 0-100 (0xFF = none) |
The full ring is sent only when unread sessions exist. Otherwise the reply is
a single inline notify: the count=0 header plus the first 13 bytes of the
newest already-read record, enough for timestamp, scheme, duration and valid
duration (decode_inline_0307); score and zones do not fit and are published
only from full records. A timestamp gate keeps a partial inline decode from
overwriting the score/zones of an already-published session.
A brushing scheme is a full per-step program, not an id:
02 06 [pNum][stepCount] (enc_gear, gear, duration_s)*N 00 05
enc_gear is a hint byte: gears 1-12 map to a fixed table, others encode as 0.
A program over 20 bytes is split: the first write carries the first 16 logical
bytes plus a 2A 2B marker, the second starts 02 0B and carries the rest.
Programs of up to 4 steps always fit a single frame; the split path is built
and unit-tested but has not been exercised on hardware. The firmware accepts
and persists arbitrary programs under non-preset ids (verified on hardware
with a custom id).
Each settings-backed entity follows the same shape:
- Decode the field in
parse_device_settings(oclean_protocol.cpp) and add it to theDeviceSettingsstruct; add a host unit test against a real captured frame. - Add a
set_*pointer setter and member onOcleanHub(oclean.h) and publish from the settings-readback path inoclean.cpp. - Add a row to the platform table in the matching
.pyfile (key, setter name, icon, category, default name). Auto-create anddevice_idpropagation come from the shared_inject_defaultspattern. - For a writable control, build the command in
oclean_protocol.{h,cpp}(host-testable) and route it throughOcleanHub::send_command, which queues while disconnected and wakes the link. Publish optimistically and let the settings readback correct the state. - Gate it behind
expose_dev_sensors(add the key to the platform's dev-key set) until its effect is verified on hardware.
Writes are mutations of someone's toothbrush: keep new controls dev-gated until the readback and the physical effect are both confirmed.
Unit tests under tests/test_protocol/ build with PlatformIO + Unity. They
link only oclean_protocol.{h,cpp} and run on the host (no ESP32 required),
covering the command builders, the session and settings assemblers, record and
inline decode, clock drift, timezone decode and the adaptive-poll helpers,
with fixtures taken from real captured frames.
pio test -d tests -e native
tests/.pio/build/native/programThe second line runs the produced binary directly for the authoritative Unity
summary and exit code. CI (.github/workflows/test.yml) runs ruff +
pre-commit, the unit tests, and a full-component compile on every push. A
devcontainer (.devcontainer/) provides esphome, platformio, ruff and
pre-commit.
| Constraint | Effect / workaround |
|---|---|
| Passive advertisements carry no data | Only name / MAC / RSSI; even battery needs an active GATT connection, hence connect-poll-disconnect. |
| The brush does not stream while brushing | Sessions are buffered and downloaded after the fact; expect them at the next poll, or press Poll now. |
| One BLE central at a time | The official app cannot connect while the component holds the link. Use the bluetooth switch to release it. |
| Session timestamps use the brush clock | Drift shifts session times; auto_sync_time (with a time_id) keeps the clock within sync_drift_threshold. |
| Write No Response is dropped | All writes go out as Write With Response. |
| Some toggle opcodes are rejected by firmware | Fill brush and auto mode read back fine but their writes return an error stub; they are exposed as binary sensors, not switches. |
| The brush intensity level (display button) has no BLE representation | It can be neither read nor written; no entity exists for it. |
| Holding the link drains the brush | hold_connection_while_docked only ever holds while docked (charging, so no drain); off the dock the component always disconnects after each poll. On by default. |
GPL-3.0. This component derives from a GPL-3.0 ESPHome component and inherits
that license. See LICENSE.
