Oral-B Toothbrush on ESPHome

I did a little bit of research to find out how to add my OralB bluetooth toothbrush to Home Assistant and I came up with this on ESPHome.
It doesn’t work perfectly and it makes my ESP32 restart by itself, but I am posting this in case someone figures out the bugs.

esphome:
  name: smartlock
  platform: ESP32
  board: nodemcu-32s

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

logger:
ota:
api:

esp32_ble_tracker:

ble_client:
  - mac_address: FF:FF:FF:FF:FF:FF
    id: oralb1

sensor:
  - platform: ble_client
    ble_client_id: oralb1
    name: "OralB1 battery level"
    service_uuid: 'a0f0ff00-5047-4d53-8208-4f72616c2d42'
    characteristic_uuid: 'A0F0FF05-5047-4D53-8208-4F72616C2D42'
    icon: 'mdi:battery'
    unit_of_measurement: '%'
    update_interval: 4s
    lambda: |-
      return x[1];
  - platform: ble_client
    ble_client_id: oralb1
    name: "OralB1 brush time"
    service_uuid: 'a0f0ff00-5047-4d53-8208-4f72616c2d42'
    characteristic_uuid: 'a0f0ff08-5047-4d53-8208-4f72616c2d42'
    icon: 'mdi:timer'
    unit_of_measurement: 's'
    update_interval: 1s
    lambda: |-
      return x[0];

On my computer it works perfectly, so I am also attaching the pythong code that I am using:

import asyncio
from bleak import BleakClient
import time

chars = {
    "a0f0ff08-5047-4d53-8208-4f72616c2d42": "time",
    "a0f0ff05-5047-4d53-8208-4f72616c2d42": "battery",
    "a0f0ff04-5047-4d53-8208-4f72616c2d42": "status",
    "a0f0ff07-5047-4d53-8208-4f72616c2d42": "mode",
}


statuses = {
    2: "IDLE",
    3: "RUN",
}
modes = {
    0: "OFF",
    1: "DAILY_CLEAN",
    7: "INTENSE",
    2: "SENSITIVE",
    4: "WHITEN",
    3: "GUM_CARE",
    6: "TONGUE_CLEAN",
}

async def main(address):
    async with BleakClient(address) as client:
        while True:
            try:
                print(dir(client))
                cl_services = await client.get_services()
                import ipdb
                ipdb.set_trace()
                tasks = []
                for char, res_type in chars.items():
                    tasks.append(asyncio.create_task(client.read_gatt_char(char)))
                results = await asyncio.gather(*tasks)
                res_dict = dict(zip(chars.values(), results))

                print(f"Brush Time: {60 * res_dict['time'][0] + res_dict['time'][1]}")
                print(f"Battery: {res_dict['battery'][0]}")
                print(f"Status: {statuses.get(res_dict['status'][0], 'UNKNOWN')}")
                print(f"Mode: {modes.get(res_dict['mode'][0], 'UNKNOWN')}")

                time.sleep(1)
                print("\n")
            except Exception as e:
                print(e)
                return 0

address = "FF:FF:FF:FF:FF:FF"
asyncio.run(main(address))

It would be great if we could make an integration for this.

1 Like

I’m quite sure I have seen a thread about this before. I believe there is working code for it here on the forum.

I got as far as using the new BTLE framework created in 2022.08 to scan and find my toothbrush as a test - this means an integration is possible with a BT interface connected to HASS itself:

Oral-b toothbrushes are already somewhat supported by the BLE Passive Montior add-on.

I am searching for a way to add support on ESP32 because I don’t have bluetooth dongle on my home assistant installation.
It kinda worked, but I had problems. I only posted the python code in case it helps someone who is interested on porting it to ESP32.

In case you’ve not seen the 2022.09 release video, ESPHome Bluetooth proxies were announced along with a lot of support for local/ remote/ proxy Bluetooth receivers.

Looks like add one line to an ESPhome config on an ESP32 to create a proxy receiver, although wired Ethernet might be an advantage (apparently WLAN + BT share one aerial so may multiplex).

I’d suggest watching the 2022.09 release video for lots of detail and demos of ESP32 acting as remote Bluetooth receivers with the ideas behind the features and future development.

I’ve created an integration for OralB toothbrush in this PR:

2 Likes

RE this block - on my Smart 7 tooth brush I was getting random values which had no relation with the front power display (189%, 253%, 128% etc)

sensor:
  - platform: ble_client
    ble_client_id: oralb1
    name: "OralB1 battery level"
    service_uuid: 'a0f0ff00-5047-4d53-8208-4f72616c2d42'
    characteristic_uuid: 'A0F0FF05-5047-4D53-8208-4F72616C2D42'
    icon: 'mdi:battery'
    unit_of_measurement: '%'
    update_interval: 4s
    lambda: |-
      return x[1];

According to this page OralBlue_python/Protocol.md at 15e1a03bcb3350574d438e4593bcff59608a77a7 · wise86-android/OralBlue_python · GitHub the second and third bytes are an LE representation of seconds remaining. Changing it to return x[0] gets what look like sensible values.

On my brush the brushing time using the above is in minutes, looking at the same link it appears this is another two byte field - x[0] minutes, and x[1] seconds, and indeed if I do x[0] * 60 + x[1] I get sensible values.

Seeing as it looks like this is going to make it into Home Assistant nativly I thought I should flag before it does in case those havent yet been fixed.

My full config is

substitutions:
  devicename: mortara
  upper_devicename: "Mortara"
  toothbrush_name: "Kev's Toothbrush"

esphome:
  name: $devicename
  platform: ESP32
  board: nodemcu-32s

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_passwd

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "{$upper_devicename} Fallback Hotspot"
    password: !secret fallback_passwd

# Enable logging
logger:

# Enable Home Assistant API
api:

ota:

switch:
  - platform: restart
    name: "$upper_devicename Restart"

ble_client:
  - mac_address: "XX:XX:XX:XX:XX:XX"
    id: oralb_kev

binary_sensor:
  - platform: ble_presence
    mac_address: "XX:XX:XX:XX:XX:XX"
    name: "$toothbrush_name Online"

sensor:
  - platform: wifi_signal
    name: "$upper_devicename - WiFi Signal Sensor"
    update_interval: 60s
  - platform: uptime
    name: "$upper_devicename - Uptime Sensor"
  - platform: ble_client
    ble_client_id: oralb_kev
    name: "$toothbrush_name battery level"
    type: characteristic
    service_uuid: 'a0f0ff00-5047-4d53-8208-4f72616c2d42'
    characteristic_uuid: 'A0F0FF05-5047-4D53-8208-4F72616C2D42'
    icon: 'mdi:battery'
    unit_of_measurement: '%'
    update_interval: 4s
    lambda: |-
      return x[0];
  - platform: ble_client
    ble_client_id: oralb_kev
    type: characteristic
    name: "$toothbrush_name brush time"
    service_uuid: 'a0f0ff00-5047-4d53-8208-4f72616c2d42'
    characteristic_uuid: 'a0f0ff08-5047-4d53-8208-4f72616c2d42'
    icon: 'mdi:timer'
    unit_of_measurement: 's'
    update_interval: 1s
    lambda: |-
      return (x[0] * 60) + x[1];
  - platform: ble_client
    ble_client_id: oralb_kev
    type: characteristic
    name: "$toothbrush_name status ID"
    service_uuid: 'a0f0ff00-5047-4d53-8208-4f72616c2d42'
    characteristic_uuid: 'a0f0ff04-5047-4d53-8208-4f72616c2d42'
    icon: 'mdi:timer'
    update_interval: 4s
    lambda: |-
      switch( x[0] )
      {
        case 2:
          id(kev_status).publish_state(std::string("IDLE"));
          break;
        case 3:
          id(kev_status).publish_state(std::string("RUN"));
          break;
        default:
          id(kev_status).publish_state(std::string("UNKNOWN"));
      }
      return x[0];
  - platform: ble_client
    ble_client_id: oralb_kev
    type: characteristic
    name: "$toothbrush_name mode ID"
    service_uuid: 'a0f0ff00-5047-4d53-8208-4f72616c2d42'
    characteristic_uuid: 'a0f0ff07-5047-4d53-8208-4f72616c2d42'
    icon: 'mdi:timer'
    update_interval: 4s
    lambda: |-
      switch( x[0] )
      {
        case 0:
          id(kev_mode).publish_state(std::string("OFF"));
          break;
        case 1:
          id(kev_mode).publish_state(std::string("DAILY_CLEAN"));
          break;
        case 2:
          id(kev_mode).publish_state(std::string("SENSITIVE"));
          break;
        case 3:
          id(kev_mode).publish_state(std::string("GUM_CARE"));
          break;
        case 4:
          id(kev_mode).publish_state(std::string("WHITEN"));
          break;
        case 5:
          id(kev_mode).publish_state(std::string("DEEP_CLEAN"));
          break;
        case 6:
          id(kev_mode).publish_state(std::string("TOUNGE_CLEAN"));
          break;
        case 7:
          id(kev_mode).publish_state(std::string("INTENSE"));
          break;
        default:
          id(kev_mode).publish_state(std::string("UNKNOWN"));
      }
      return x[0];
text_sensor:
  - platform: version
    name: "$upper_devicename - ESPHome Version"
  - platform: template
    name: "$toothbrush_name status"
    id: kev_status
  - platform: template
    name: "$toothbrush_name mode"
    id: kev_mode

2 Likes

Maybe it is not base-10? or maybe it is on a scale from 0 to 255? Something similar?
Just an idea.

Next line said what it probably was - “the second and third bytes are an LE representation of seconds remaining.”. Decoding two byte stuff on ESPHome is beyond me!

It’s late and I haven’t seen the data but If it’s a little-endian, couldn’t it be decoded with something like this x[0] + x[1]*255 ?

1 Like

Ta, had never messed with Little Endian so no idea how it was parsed and a quick search wasn’t being helpful.

Changing the battery sensor to the following

  - platform: ble_client
    ble_client_id: oralb_kev
    name: "$toothbrush_name battery level"
    type: characteristic
    service_uuid: 'a0f0ff00-5047-4d53-8208-4f72616c2d42'
    characteristic_uuid: 'A0F0FF05-5047-4D53-8208-4F72616C2D42'
    icon: 'mdi:battery'
    unit_of_measurement: '%'
    update_interval: 4s
    lambda: |-
      id(kev_seconds).publish_state(x[1] + (x[2] * 255));
      return x[0];

(note the penultimate line) and adding the following sensor

  - platform: template
    name: "$toothbrush_name battery seconds"
    id: kev_seconds

have now given me another entity which seems to work - a 120 second brush reduced the battery level by 71 seconds if this is “accurate”. Note it doesn’t update during the brush (unlike the %) only once you stop.

Awesome! I noticed the integration listed two brushes as supported, I just received an iO 10 yesterday. When I get a chance I’ll try it out and report back.

Yeah, I’ll be giving the integration a try too when it gets to 2022.11.4!

Got sick of using my my old Oral B brush and finding 10 seconds into a brush the battery would be flat! When it died found this thread and the mention of battery usage, so Bluetooth brush it is.

I just installed your Oral-B integration and hooked up my iO Series 10 and it seams to work.

But I noticed the following things:

  • It’s identified as IO Series 7/8
  • Tongue Cleaning shows up as “unknown mode 6”
  • Pressure & Sector sensors have values even though the brush is inactive, maybe default to unknown while not brushing?

How can I help you to gather the data needed to correctly identify the series 10 brush?

It seems the toothbrush only transmitting the values when you turn it off, so these values are just the ones from the last session.
There is no transmission during the brush session.

Interestingly with the “proper” integration my 7000 shows up as a 6000 Series and doesn’t report battery level, but it has picked up a neighbour’s toothbrush which it identifies as a 7000!

Will stick with ESP Home for now - much more useful set of information
image

vs Home Assistant directly

@kevjs1982 love the information you get from ESPHome, could you share your configuration for these sensors you have on your 1st screenshot?

  • Battery Left (secs)
  • Brushes Today
  • Last Charged
  • Needs Charging
  • etc…

It’s shared further up this thread

and the battery seconds was added here:-

Need’s Changing is a separate Input Helper which records the date I last changed the brush head and a template sensor in Home Assistant (this is from sensors.yaml - not the new style)

  kevs_toothbrush_head_needs_changing:
    value_template: >-
      {%- if (as_timestamp(now())) > (((as_timestamp(states('input_datetime.toothbrush_head_changed')) + (90*24*3600)))) -%}yes{%- else %}no{%- endif %} 

When that changes to “yes” an automation sends a notification to my phone this is in automation.yaml

- id: '1638906269550'
  alias: Toothbrush Head Change Reminder
  description: ''
  use_blueprint:
    path: task_due_enhanced.yaml
    input:
      due_task_entity: sensor.kevs_toothbrush_head_needs_changing
      alert_notification_device: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
      image_url: https://home.example.com/local/android/smile_teeth.jpg
      entity_to_update: input_datetime.toothbrush_head_changed

and the blueprint for task_due_enhanced.yaml is


blueprint:
  name: Task Reminder
  description: Alert when a task is due (status is yes)
  domain: automation
  input:
    due_task_entity:
      name: Due Task (status is yes when due)
      selector:
        entity:
          domain: sensor
    alert_notification_device:
      name: Which device to alert
      selector:
        device:
          integration: mobile_app
    image_url:
      name: image_to_include_in_notification
    entity_to_update:
      name: Which entity to update (Date Input Helper with today's date)
      selector:
        entity:
          domain: input_datetime
mode: single
max_exceeded: silent
variables:
  due_task_entity: !input due_task_entity
  entity_to_update: !input entity_to_update
  action_done: "task_reminder_{{ remind_me_entity }}"

trigger:
  - platform: state
    entity_id: !input due_task_entity
condition:
  - condition: state
    entity_id: !input due_task_entity
    state: "yes"

action:
  - domain: mobile_app
    type: notify
    device_id: !input alert_notification_device
    title: >
      {{ state_attr(due_task_entity,'friendly_name')|replace('Does','')|trim }}
    message: >
      {{ state_attr(due_task_entity,'friendly_name')|replace('Does','')|replace('?','')|trim }} is due
    data:
      image: !input image_url
      channel: !input due_task_entity
      tag: !input due_task_entity
      persistent: true
      actions:
        - action: "{{ action_done }}"
          title: "Done"
  # Wait for the user to select an action
  - wait_for_trigger:
      platform: event
      event_type: mobile_app_notification_action
      event_data:
        action: "{{ action_done }}"
  - service: input_datetime.set_datetime
    target:
      entity_id: !input entity_to_update
    data:
      date: '{{ now().strftime(''%Y-%m-%d'') }}'

# https://community.home-assistant.io/t/actionable-notifications-for-android/256773 See this for examples...
2 Likes

Thanks a lot for sharing this, I’m not familiar with lambda at the moment and wondering if you had seen this error at all;

/config/esphome/esp32-bluetooth-proxy-f8f828.yaml:38:65: error: no matching function for call to 'esphome::template_::TemplateTextSensor::publish_state(int)'
       id(name_toothbrush_seconds).publish_state(x[1] + (x[2] * 255));
                                                                 ^
In file included from src/esphome/core/controller.h:20,
                 from src/esphome/components/api/api_server.h:4,
                 from src/esphome/components/api/api_connection.h:6,
                 from src/esphome.h:3,
                 from src/main.cpp:3:
src/esphome/components/text_sensor/text_sensor.h:34:8: note: candidate: 'void esphome::text_sensor::TextSensor::publish_state(const string&)'
   void publish_state(const std::string &state);
        ^~~~~~~~~~~~~
src/esphome/components/text_sensor/text_sensor.h:34:8: note:   no known conversion for argument 1 from 'int' to 'const string&' {aka 'const std::__cxx11::basic_string<char>&'}

I’ve had a hunt around (and maybe I’m just being useless at search engine hunting) but haven’t come up with any suggestions on it :confused: