Oralb toothbrush with esphome BLE

I recently bought new electric toothbrushes. The old ones had a dedicated timer display (separate box with 7-segment lcd) that displayed the brushed time. This feature was missing with the new toothbrush, so i tried to replicate it.
The new ones have Bluetooth and you find a handful of projects on the web where people are trying to get information from the brush.

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

# Enable logging
logger:
#  level: VERY_VERBOSE

# Enable Home Assistant API
api:

esp32_ble_tracker:
  on_ble_advertise:
    - mac_address: 0c:ec:80:fc:6b:fe
      then:
        - lambda: |-
            for (auto data : x.get_manufacturer_datas()) {
                ESP_LOGD("ble_adv", "    - %s: (length %i)", data.uuid.to_string().c_str(), data.data.size());
                if(data.data.size() >= 10) {
                    char time[6];
                    sprintf(time, "%02d:%02d", data.data[5], data.data[6]);
                    std::string times(time);
                    id(zbp_state).publish_state(data.data[3]);
                    id(zbp_mode).publish_state(data.data[7]);
                    id(zbp_quadrant).publish_state(data.data[8]);
                    id(zbp_time).publish_state(times);
                }
            }
    - mac_address: 0c:ec:80:fa:a6:ff
      then:
        - lambda: |-
            for (auto data : x.get_manufacturer_datas()) {
                ESP_LOGD("ble_adv", "    - %s: (length %i)", data.uuid.to_string().c_str(), data.data.size());
                if(data.data.size() >= 10) {
                    char time[6];
                    sprintf(time, "%02d:%02d", data.data[5], data.data[6]);
                    std::string times(time);
                    id(zbn_state).publish_state(data.data[3]);
                    id(zbn_mode).publish_state(data.data[7]);
                    id(zbn_quadrant).publish_state(data.data[8]);
                    id(zbn_time).publish_state(times);
                }
            }

    
text_sensor:
  - platform: template
    name: "zahnbuerste_p_zeit"
    id: zbp_time
  - platform: template
    name: "zahnbuerste_n_zeit"
    id: zbn_time
    
sensor:
  - platform: template
    name: "zahnbuerste_p_status"
    id: zbp_state
  - platform: template
    name: "zahnbuerste_p_mode"
    id: zbp_mode
  - platform: template
    name: "zahnbuerste_p_quadrant"
    id: zbp_quadrant
  - platform: template
    name: "zahnbuerste_n_status"
    id: zbn_state
  - platform: template
    name: "zahnbuerste_n_mode"
    id: zbn_mode
  - platform: template
    name: "zahnbuerste_n_quadrant"
    id: zbn_quadrant
    

To sort this out, i needed to understand a lot about bluetooth low energy:
BLE has 2 means to transmit data:
unsolicited and requested.
The toothbrush is using both, but not all data is transmitted in both means.
The unsolicited data is sent ~ every second. It contains several bytes of information, e.g. the time, the quadrant of your teeth, the brushing-mode, etc.
The requested data is sorted by UUIDs. Every UUID is usually one datapoint, e.g. time or batery SoC.
If i have understood the ble stuff correctly, the client (–> ESPhome) can not ony ask for the information on requested data, but as well subscribe to updates on this. If the value changes, the server (–> toothbrush) can tell the client that the data has changed.
Anyway: Requested data sensors (with “ble_client” on ESPhome) did not work. Unsolicited data (“esp32_ble_tracker”) did work.

TL;DR
The code above spawns several sensors in home assistant that show the brushing time, brush mode, quadrant and brush state for each brush.
I put these on a separate dashboard in home assistand and used an old smartphone with the HA-app to show just this dashboard to replicate the functionality of the hw-timer.

13 Likes

I like this code! Is it also possible to get information if you are brushing to hard? Then i can make my lights red :stuck_out_tongue:

1 Like

Hey fkzle!
If you wan’t to share the ESP32 code I would be very happy :grin: Have been looking for something exactly like what you have done!

Best regards
Andreas

Hey @AndreasJH ,
since i used ESPHome, what you see above is (nearly) all my sourcecode. Only my Wifi-credentials are missing :wink:

@brammolenaar ,
if i found this correctly, you can use data.data[4] for pressure information. Since i did not need this, i am not sure if you get some analogue value or just digital “pressure too high” info.

Br

Thanks for sharing, I’m defintely going to try and implement this on my setup :slight_smile:

Thx! I’m gonna test that

@fkzle Could you be even more spesific, did you just insert the code and compiled it in ESPHome and uploaded to a Nodemcu?
How did you get the Bluetooth mac?

So what does the data look like?
What are you getting out of it?

Is the data the same on all models? Or which one do you have?

@Hellis81
I don’t know what the original source for the packet format is, but you find dozens of implementations online if you search for “oralb toothbrush github” or something like that. One example is:

AFAIK, the packet looks the same on all brush types.
You get:

  • information if brush is running
  • mode
  • time
  • pressure
  • quadrant you should be brushing.

@gulllfrode
You need to use a board with an ESP32, because the 8266 lacks bluetooth functionality.
The mac addresses were shown in the log of esphome.
IIRC you need to define a “ble_client” and set the log level to debug?
Details can be found here: BLE Client — ESPHome
(bottom of the page).

Thank you very much for these! But I need your help now, because I can not figure it out. How can we create a sensor that will show daily brush time? History stats show to much time if I select on-off time.

Thanks for this for my ESPHome. Here’s my slightly modified code:

esp32_ble_tracker:
  scan_parameters:
    active: false
  on_ble_advertise:
    - mac_address: xx:xx:xx:xx:x:xx
      then:
        - lambda: |-
            for (auto data : x.get_manufacturer_datas()) {
                ESP_LOGD("ble_adv", "    - %s: (length %i)", data.uuid.to_string().c_str(), data.data.size());
                if(data.data.size() >= 10) {
                    char time[6];
                    sprintf(time, "%02d:%02d", data.data[5], data.data[6]);
                    std::string times(time);
                    switch( data.data[3] )
                    {
                      case 0:
                        id(toothbrush_state).publish_state(std::string("Unknown"));
                        break;
                      case 1:
                        id(toothbrush_state).publish_state(std::string("Initializing"));
                        break;
                      case 2:
                        id(toothbrush_state).publish_state(std::string("Idle"));
                        break;
                      case 3:
                        id(toothbrush_state).publish_state(std::string("Running"));
                        break;
                      case 4:
                        id(toothbrush_state).publish_state(std::string("Charging"));
                        break;
                      case 5:
                        id(toothbrush_state).publish_state(std::string("Setup"));
                        break;
                      case 6:
                        id(toothbrush_state).publish_state(std::string("Flight Menu"));
                        break;
                      case 113:
                        id(toothbrush_state).publish_state(std::string("Final Test"));
                        break;
                      case 114:
                        id(toothbrush_state).publish_state(std::string("PCB Test"));
                        break;
                      case 115:
                        id(toothbrush_state).publish_state(std::string("Sleeping"));
                        break;
                      case 116:
                        id(toothbrush_state).publish_state(std::string("Transport"));
                        break;
                      default:
                        id(toothbrush_state).publish_state(std::string("Unknown"));
                    };
                    switch( data.data[7] )
                    {
                      case 0:
                        id(toothbrush_mode).publish_state(std::string("Off"));
                        break;
                      case 1:
                        id(toothbrush_mode).publish_state(std::string("Daily Clean"));
                        break;
                      case 2:
                        id(toothbrush_mode).publish_state(std::string("Sensitive"));
                        break;
                      case 3:
                        id(toothbrush_mode).publish_state(std::string("Massage"));
                        break;
                      case 4:
                        id(toothbrush_mode).publish_state(std::string("Whitening"));
                        break;
                      case 5:
                        id(toothbrush_mode).publish_state(std::string("Deep Clean"));
                        break;
                      case 6:
                        id(toothbrush_mode).publish_state(std::string("Tongue Cleaning"));
                        break;
                      case 7:
                        id(toothbrush_mode).publish_state(std::string("Turbo"));
                        break;
                      default:
                        id(toothbrush_mode).publish_state(std::string("Unknown"));
                    }
                    id(toothbrush_quadrant).publish_state(esphome::to_string(data.data[8]));
                    id(toothbrush_time).publish_state(times);
                }
            }

text_sensor:
  - platform: template
    name: "toothbrush_time"
    id: toothbrush_time
  - platform: template
    name: "toothbrush_state"
    id: toothbrush_state
  - platform: template
    name: "toothbrush_mode"
    id: toothbrush_mode
  - platform: template
    name: "toothbrush_quadrant"
    id: toothbrush_quadrant