├── README.md ├── components └── medisana_bs444 │ ├── Scale.cpp │ ├── Scale.h │ ├── __init__.py │ ├── medisanabs444.cpp │ ├── medisanabs444.h │ └── sensor.py └── medisana_bs444.yaml /README.md: -------------------------------------------------------------------------------- 1 | # weegschaal 2 | 3 | ## Use Medisana BS444 scale with ESPHome on ESP32 in Home Assistant 4 | 5 | BS430 users have a look at https://github.com/bwynants/weegschaal/tree/ESP32-BLE-Arduino 6 | 7 | ### how to setup 8 | 9 | Add a reference to the code on github 10 | 11 | external_components: 12 | - source: 13 | type: git 14 | url: https://github.com/bwynants/weegschaal 15 | ref: main 16 | components: [ medisana_bs444 ] 17 | 18 | or local on your esphome directory 19 | 20 | external_components: 21 | - source: 22 | type: local 23 | path: components 24 | 25 | add esphome ble tracker component 26 | 27 | esp32_ble_tracker: 28 | scan_parameters: 29 | interval: 1100ms 30 | window: 1100ms 31 | active: false 32 | 33 | add esphome ble client component and set the correct MAC address 34 | 35 | ble_client: 36 | - mac_address: "00:00:00:00:00:00" 37 | id: medisanabs44_ble_id 38 | 39 | sensor: 40 | - platform: medisana_bs444 41 | ble_client_id: medisanabs44_ble_id 42 | timeoffset: true # BS410 and BS444 needs timeoffset, set to false for other scales 43 | weight_1: 44 | name: "Weight User 1" 45 | kcal_1: 46 | name: "kcal User 1" 47 | fat_1: 48 | name: "Fat User 1" 49 | tbw_1: 50 | name: "Water User 1" 51 | muscle_1: 52 | name: "Muscle User 1" 53 | bone_1: 54 | name: "Bone User 1" 55 | bmi_1: 56 | name: "BMI User 1" 57 | 58 | weight_2: 59 | name: "Weight User 2" 60 | kcal_2: 61 | name: "kcal User 2" 62 | fat_2: 63 | name: "Fat User 2" 64 | tbw_2: 65 | name: "Water User 2" 66 | muscle_2: 67 | name: "Muscle User 2" 68 | bone_2: 69 | name: "Bone User 2" 70 | bmi_2: 71 | name: "BMI User 2" 72 | 73 | 74 | up till 8 users can be specified.... 75 | 76 | ## add homeassistant time 77 | 78 | time: 79 | - platform: homeassistant 80 | id: homeassistant_time 81 | 82 | ## support 83 | 84 | propably more medisana scales work BS410/BS430/BS440, but i only have the BS444 to test 85 | 86 | ## credits 87 | 88 | based on reverse engineering from https://github.com/keptenkurk/BS440 89 | -------------------------------------------------------------------------------- /components/medisana_bs444/Scale.cpp: -------------------------------------------------------------------------------- 1 | #include "esphome.h" 2 | #ifdef USE_TIME 3 | #include "esphome/core/time.h" 4 | #endif 5 | #include "Scale.h" 6 | 7 | namespace esphome 8 | { 9 | namespace medisana_bs444 10 | { 11 | 12 | esp32_ble::ESPBTUUID Serv_SCALE = esp32_ble::ESPBTUUID::from_raw("000078b2-0000-1000-8000-00805f9b34fb"); // the service 13 | 14 | esp32_ble::ESPBTUUID Char_person = esp32_ble::ESPBTUUID::from_raw("00008a82-0000-1000-8000-00805f9b34fb"); // person data handle 22 15 | esp32_ble::ESPBTUUID Char_weight = esp32_ble::ESPBTUUID::from_raw("00008a21-0000-1000-8000-00805f9b34fb"); // weight data handle 25 16 | esp32_ble::ESPBTUUID Char_body = esp32_ble::ESPBTUUID::from_raw("00008a22-0000-1000-8000-00805f9b34fb"); // body data handle 28 17 | 18 | esp32_ble::ESPBTUUID Char_command = esp32_ble::ESPBTUUID::from_raw("00008a81-0000-1000-8000-00805f9b34fb"); // command register handle 31 19 | 20 | // timeoffset used by BS410 and BS444 21 | time_t time_offset = 1262304000; 22 | 23 | /*******************************************************************************/ 24 | std::string timeAsString(time_t time) 25 | { 26 | return ESPTime::from_epoch_local(time).strftime("%Y-%m-%dT%H:%M:%S"); 27 | } 28 | 29 | time_t sanitize_timestamp(time_t timestamp, bool use_timeoffset) 30 | { 31 | time_t retTS = 0; 32 | if (use_timeoffset) 33 | { 34 | // Fail-safe: The timestamp will only be sanitized if it will be 35 | // below below the maximum unix timestamp (2147483647). 36 | if (timestamp + time_offset < std::numeric_limits::max()) 37 | retTS = timestamp + time_offset; 38 | else 39 | retTS = timestamp; 40 | 41 | } 42 | else 43 | retTS= timestamp; 44 | 45 | // If the non-sanitized timestamp is already above the maximum unix timestamp, 46 | // 0 will be taken instead. 47 | if (timestamp >= std::numeric_limits::max()) 48 | retTS = 0; 49 | 50 | return retTS; 51 | } 52 | 53 | void convertTimestampToLittleEndian(time_t timestamp, uint8_t *byteArray) 54 | { 55 | // Convert timestamp to little-endian order 56 | byteArray[0] = static_cast(timestamp & 0xFF); 57 | byteArray[1] = static_cast((timestamp >> 8) & 0xFF); 58 | byteArray[2] = static_cast((timestamp >> 16) & 0xFF); 59 | byteArray[3] = static_cast((timestamp >> 24) & 0xFF); 60 | } 61 | 62 | std::string Person::toString() const 63 | { 64 | std::stringstream str; 65 | if (valid) 66 | { 67 | // Print the date and time in a user-readable format 68 | str << "Person: " << person; 69 | str << "; gender: " << (male ? "male" : "female"); 70 | str << "; age: " << age; 71 | str << "; size: " << size; 72 | str << "; activity: " << (highActivity ? "high" : "normal"); 73 | } 74 | else 75 | str << "invalid"; 76 | return str.str(); 77 | } 78 | 79 | Person Person::decode(const uint8_t *values) 80 | { 81 | /* 82 | decodePerson 83 | Handle: 0x25 (Person) 84 | Value: 85 | Byte Data Value/Return Interpretation pattern 86 | 0 fixed byte (validity check) [0x84] B (integer, lenght 1) 87 | 1 -pad byte- x (pad byte) 88 | 2 person [1..8] B ((integer, lenght 1) 89 | 3 -pad byte- x (pad byte) 90 | 4 gender (1=male, 2=female) [1|2] B (integer, lenght 1) 91 | 5 age [0..255 years] B (integer, lenght 1) 92 | 6 size [0..255 cm] B (integer, lenght 1) 93 | 7 -pad byte- x (pad byte) 94 | 8 activity (0=normal, 3=high) [0|3] B (integer, lenght 1) 95 | --> Interpretation pattern: BxBxBBBxB 96 | */ 97 | Person result; 98 | 99 | result.valid = (values[0] == 0x84); 100 | result.person = values[2]; 101 | result.male = (values[4] == 1); 102 | result.age = values[5]; 103 | result.size = values[6] / 100.0; 104 | result.highActivity = (values[8] == 3); 105 | return result; 106 | } 107 | 108 | std::string Weight::toString(const Person &person) 109 | { 110 | std::stringstream str; 111 | if (valid) 112 | { 113 | str << "Person: " << this->person; 114 | str << "; Time:" << timeAsString(timestamp); 115 | str << "; weight: " << weight; 116 | if (person.valid) 117 | { 118 | // Normale BMI formule: gewicht / lengte^2 119 | // Nieuwe BMI formule: 1,3 * gewicht / lengte^2,5 120 | str << "; bmi: " << (weight / (person.size * person.size)); 121 | } 122 | } 123 | else 124 | str << "invalid"; 125 | return str.str(); 126 | } 127 | 128 | Weight Weight::decode(const uint8_t *values, bool useTimeoffset) 129 | { 130 | /* 131 | decodeWeight 132 | Handle: 0x1b (Weight) 133 | Value: 134 | Byte Data Value/Return Interpretation pattern 135 | 0 fixed byte (validity check) [0x1d] B (integer, length 1) 136 | 1 weight [5,0..180,0 kg] H (integer, length 2) 137 | 2 weight 138 | 3 -pad byte- x (pad byte) 139 | 4 -pad byte- x (pad byte) 140 | 5 timestamp Unix, date & time I (integer, length 4) 141 | 6 timestamp 142 | 7 timestamp 143 | 8 timestamp 144 | 9 -pad byte- x (pad byte) 145 | 10 -pad byte- x (pad byte) 146 | 11 -pad byte- x (pad byte) 147 | 12 -pad byte- x (pad byte) 148 | 13 person [1..8] B (integer, length 1) 149 | --> Interpretation pattern: BHxxIxxxxB 150 | */ 151 | Weight result; 152 | 153 | result.valid = (values[0] == 0x1d); 154 | result.weight = ((values[2] << 8) | values[1]) / 100.0; 155 | result.timestamp = sanitize_timestamp((values[8] << 24) | (values[7] << 16) | (values[6] << 8) | values[5], useTimeoffset); 156 | result.person = values[13]; 157 | 158 | return result; 159 | } 160 | 161 | std::string Body::toString() 162 | { 163 | std::stringstream str; 164 | if (valid) 165 | { 166 | str << "Person: " << person; 167 | str << "; Time:" << timeAsString(timestamp); 168 | str << "; kcal: " << kcal; 169 | str << "; fat: " << fat; 170 | str << "; tbw: " << tbw; 171 | str << "; muscle: " << muscle; 172 | str << "; bone: " << bone; 173 | } 174 | else 175 | str << "invalid"; 176 | return str.str(); 177 | } 178 | 179 | Body Body::decode(const uint8_t *values, bool useTimeoffset) 180 | { 181 | /* 182 | decodeBody 183 | Handle: 0x1e (Body) 184 | Value: 185 | Byte Data Value/Return Interpretation pattern 186 | 0 fixed byte (validity check) [0x6f] B (integer, lenght 1) 187 | 1 timestamp Unix, date & time I (integer, length 4) 188 | 2 timestamp 189 | 3 timestamp 190 | 4 timestamp 191 | 5 person [1..8] B (integer, lenght 1) 192 | 6 kcal [0..65025 Kcal] H (integer, length 2) 193 | 7 kcal 194 | 8 fat (percentage of body fat) [0..100,0 %] H (integer, length 2) 195 | 9 fat (percentage of body fat) 196 | 10 tbw (percentage of water) [0..100,0 %] H (integer, length 2) 197 | 11 tbw (percentage of water) 198 | 12 muscle (percentage of muscle) [0..100,0 %] H (integer, length 2) 199 | 13 muscle (percentage of muscle) 200 | 14 bone (bone weight) [0..100,0 %] H (integer, length 2) 201 | 15 bone (bone weight) 202 | --> Interpretation pattern: BIBBHHHHH 203 | Notes: For kcal, fat, tbw, muscle, bone: First nibble = 0xf 204 | */ 205 | Body result; 206 | 207 | result.valid = (values[0] == 0x6f); 208 | result.timestamp = sanitize_timestamp((values[4] << 24) | (values[3] << 16) | (values[2] << 8) | values[1], useTimeoffset); 209 | result.person = (values[5]); 210 | result.kcal = (values[7] << 8 | values[6]); 211 | result.fat = (0x0fff & (values[9] << 8 | values[8])) / 10.0; 212 | result.tbw = (0x0fff & (values[11] << 8 | values[10])) / 10.0; 213 | result.muscle = (0x0fff & (values[13] << 8 | values[12])) / 10.0; 214 | result.bone = (0x0fff & (values[15] << 8 | values[14])) / 10.0; 215 | 216 | return result; 217 | } 218 | } // namespace medisana_bs444 219 | } // namespace esphome 220 | -------------------------------------------------------------------------------- /components/medisana_bs444/Scale.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include "esphome/components/esp32_ble/ble_uuid.h" 7 | 8 | namespace esphome 9 | { 10 | namespace medisana_bs444 11 | { 12 | 13 | extern esp32_ble::ESPBTUUID Serv_SCALE; // the service 14 | 15 | extern esp32_ble::ESPBTUUID Char_person; // person data handle 22 16 | extern esp32_ble::ESPBTUUID Char_weight; // weight data handle 25 17 | extern esp32_ble::ESPBTUUID Char_body; // body data handle 28 18 | 19 | extern esp32_ble::ESPBTUUID Char_command; // command register handle 31 20 | 21 | // On some scales (e.g. BS410 and BS444, maybe others as well), time=0 22 | // equals 1/1/2010. However, goal is to have unix-timestamps. Thus, the 23 | // function converts the "scale-timestamp" to unix-timestamp by adding 24 | // the time-offset (most scales: 1262304000 = 01.01.2010) to the timestamp. 25 | 26 | // Assuming time_offset is a constant defined somewhere in the code 27 | extern time_t time_offset; 28 | 29 | time_t sanitize_timestamp(time_t timestamp, bool useTimeoffset); 30 | 31 | void convertTimestampToLittleEndian(time_t timestamp, uint8_t *byteArray); 32 | 33 | std::string timeAsString(time_t time); 34 | 35 | class Person 36 | { 37 | public: 38 | bool valid = false; 39 | u_int32_t person = 255; 40 | bool male; 41 | u_int32_t age; 42 | double size; 43 | bool highActivity; 44 | 45 | std::string toString() const; 46 | static Person decode(const uint8_t *values); 47 | 48 | friend bool operator<(const Person &l, const Person &r) 49 | { 50 | return std::tie(l.valid, l.person) < std::tie(r.valid, r.person); 51 | } 52 | friend bool operator==(const Person &l, const Person &r) 53 | { 54 | return (l.valid == r.valid) && (l.person == r.person); 55 | } 56 | }; 57 | inline bool operator>(const Person &lhs, const Person &rhs) { return rhs < lhs; } 58 | inline bool operator<=(const Person &lhs, const Person &rhs) { return !(lhs > rhs); } 59 | inline bool operator>=(const Person &lhs, const Person &rhs) { return !(lhs < rhs); } 60 | inline bool operator!=(const Person &lhs, const Person &rhs) { return !(lhs == rhs); } 61 | 62 | class Weight 63 | { 64 | public: 65 | bool valid = false; 66 | time_t timestamp = 0; 67 | u_int32_t person; 68 | double weight; 69 | 70 | std::string toString(const Person &person = Person()); 71 | static Weight decode(const uint8_t *values, bool useTimeoffset); 72 | 73 | friend bool operator<(const Weight &l, const Weight &r) 74 | { 75 | return std::tie(l.valid, l.timestamp) < std::tie(r.valid, r.timestamp); 76 | } 77 | friend bool operator==(const Weight &l, const Weight &r) 78 | { 79 | return (l.valid == r.valid) && (l.timestamp == r.timestamp); 80 | } 81 | }; 82 | inline bool operator>(const Weight &lhs, const Weight &rhs) { return rhs < lhs; } 83 | inline bool operator<=(const Weight &lhs, const Weight &rhs) { return !(lhs > rhs); } 84 | inline bool operator>=(const Weight &lhs, const Weight &rhs) { return !(lhs < rhs); } 85 | inline bool operator!=(const Weight &lhs, const Weight &rhs) { return !(lhs == rhs); } 86 | 87 | class Body 88 | { 89 | public: 90 | bool valid = false; 91 | time_t timestamp = 0; 92 | u_int32_t person; 93 | u_int32_t kcal; 94 | double fat; 95 | double tbw; 96 | double muscle; 97 | double bone; 98 | std::string toString(); 99 | static Body decode(const uint8_t *values, bool useTimeoffset); 100 | 101 | friend bool operator<(const Body &l, const Body &r) 102 | { 103 | return std::tie(l.valid, l.timestamp) < std::tie(r.valid, r.timestamp); 104 | } 105 | 106 | friend bool operator==(const Body &l, const Body &r) 107 | { 108 | return (l.valid == r.valid) && (l.timestamp == r.timestamp); 109 | } 110 | }; 111 | inline bool operator>(const Body &lhs, const Body &rhs) { return rhs < lhs; } 112 | inline bool operator<=(const Body &lhs, const Body &rhs) { return !(lhs > rhs); } 113 | inline bool operator>=(const Body &lhs, const Body &rhs) { return !(lhs < rhs); } 114 | inline bool operator!=(const Body &lhs, const Body &rhs) { return !(lhs == rhs); } 115 | } // namespace medisana_bs444 116 | } // namespace esphome 117 | -------------------------------------------------------------------------------- /components/medisana_bs444/__init__.py: -------------------------------------------------------------------------------- 1 | CODEOWNERS = ["@bwynants"] 2 | -------------------------------------------------------------------------------- /components/medisana_bs444/medisanabs444.cpp: -------------------------------------------------------------------------------- 1 | #include "medisanabs444.h" 2 | #ifdef USE_ESP32 3 | 4 | namespace esphome 5 | { 6 | namespace medisana_bs444 7 | { 8 | 9 | /******************************* BS444 Scale *******************************************/ 10 | /** 11 | * Scan for BLE servers and find the first one that advertises the service we are looking for. 12 | * 13 | * The scale submits relevant data by use of Indications. 14 | * Indications are messages conveying the data for certain characteristics. 15 | * Characteristics have a two-byte shortcut for it (the handle) an a 16 byte 16 | * (globally unique) identifier (the UUID). * 17 | 18 | * Relevant characteristics: 19 | * Description Handle UUID Data 20 | * (2 byte) (16 byte, globally unique) 21 | * Person 0x26 00008a82-0000-1000-8000-00805f9b34fb person, gender, age, size, activity 22 | * Weight 0x1c 00008a21-0000-1000-8000-00805f9b34fb weight, time, person 23 | * Body 0x1f 00008a22-0000-1000-8000-00805f9b34fb time, person, kcal, fat, tbw, muscle, bone * 24 | * 25 | * Writing the command 0200 to a handle tells the device that you register to the 26 | * indications of this characteristic. Writing 0000 will stop it. * 27 | * 28 | * A data packet of a characteristic (hex data string) will report with a 29 | * handle 1 less than the handle of the characteristic. E.g. writing 0200 30 | * to 0x26 (Person) will report back with the handle 0x25.* 31 | * 32 | * The last 30 measurements per person will be stored in the scale and upon 33 | * communication, the history for this user will be dumped. So you will 34 | * receive 30 values like this: 35 | * handle=0x25, value=0x845302800134b6e0000000000000000000000000 36 | * handle=0x1b, value=0x1d8c1e00fe6e0aa056451100ff020900000000 37 | * handle=0x1e, value=0x6f6e0aa05602440ab8f07ff26bf11ef0000000 38 | * 39 | */ 40 | 41 | static const char *TAG = "MedisanaBS444"; 42 | 43 | void MedisanaBS444::dump_config() 44 | { 45 | ESP_LOGCONFIG(TAG, "MedisanaBS444:"); 46 | ESP_LOGCONFIG(TAG, " MAC address : %s", this->parent()->address_str().c_str()); 47 | ESP_LOGCONFIG(TAG, " timeoffset : %d", this->use_timeoffset_); 48 | for (uint8_t i = 0; i < 8; i++) 49 | { 50 | ESP_LOGCONFIG(TAG, "User_%d:", i); 51 | if (this->weight_sensor_[i]) 52 | LOG_SENSOR(TAG, " weight", this->weight_sensor_[i]); 53 | if (this->bmi_sensor_[i]) 54 | LOG_SENSOR(TAG, " BMI", this->bmi_sensor_[i]); 55 | if (this->kcal_sensor_[i]) 56 | LOG_SENSOR(TAG, " kcal", this->kcal_sensor_[i]); 57 | if (this->fat_sensor_[i]) 58 | LOG_SENSOR(TAG, " fat", this->fat_sensor_[i]); 59 | if (this->tbw_sensor_[i]) 60 | LOG_SENSOR(TAG, " tbw", this->tbw_sensor_[i]); 61 | if (this->muscle_sensor_[i]) 62 | LOG_SENSOR(TAG, " muscle", this->muscle_sensor_[i]); 63 | if (this->bone_sensor_[i]) 64 | LOG_SENSOR(TAG, " bone", this->bone_sensor_[i]); 65 | } 66 | } 67 | 68 | #ifdef USE_TIME 69 | void MedisanaBS444::set_time_id(time::RealTimeClock *time_id) 70 | { 71 | this->time_id_ = time_id; 72 | } 73 | #endif 74 | 75 | time_t MedisanaBS444::now() 76 | { 77 | #ifdef USE_TIME 78 | if (this->time_id_) 79 | return time_id_->now().timestamp; 80 | #endif 81 | return millis() / 1000; // some stupid value..... 82 | } 83 | 84 | void MedisanaBS444::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, 85 | esp_ble_gattc_cb_param_t *param) 86 | { 87 | switch (event) 88 | { 89 | case ESP_GATTC_OPEN_EVT: 90 | { 91 | ESP_LOGD(TAG, "ESP_GATTC_OPEN_EVT!"); 92 | if (param->open.status == ESP_GATT_OK) 93 | { 94 | ESP_LOGI(TAG, "Connected successfully!"); 95 | } 96 | break; 97 | } 98 | 99 | case ESP_GATTC_DISCONNECT_EVT: 100 | { 101 | ESP_LOGD(TAG, "ESP_GATTC_DISCONNECT_EVT!"); 102 | this->node_state = esp32_ble_tracker::ClientState::IDLE; 103 | if (mPerson.valid) 104 | { 105 | // this is a measurement 106 | ESP_LOGI(TAG, "Person %s:", mPerson.toString().c_str()); 107 | if ((mPerson.person >= 1) && (mPerson.person <= 8)) 108 | { 109 | uint8_t index = mPerson.person - 1; 110 | 111 | if (mWeight.valid && (mWeight.person == mPerson.person)) 112 | { 113 | ESP_LOGI(TAG, "Weight %s:", mWeight.toString(mPerson).c_str()); 114 | if (this->weight_sensor_[index]) 115 | this->weight_sensor_[index]->publish_state(mWeight.weight); 116 | if (this->bmi_sensor_[index] && mPerson.size) 117 | this->bmi_sensor_[index]->publish_state(mWeight.weight / (mPerson.size * mPerson.size)); 118 | } 119 | if (mBody.valid && (mBody.person == mPerson.person)) 120 | { 121 | ESP_LOGI(TAG, "Body %s:", mBody.toString().c_str()); 122 | if (this->kcal_sensor_[index]) 123 | this->kcal_sensor_[index]->publish_state(mBody.kcal); 124 | if (this->fat_sensor_[index]) 125 | this->fat_sensor_[index]->publish_state(mBody.fat); 126 | if (this->tbw_sensor_[index]) 127 | this->tbw_sensor_[index]->publish_state(mBody.tbw); 128 | if (this->muscle_sensor_[index]) 129 | this->muscle_sensor_[index]->publish_state(mBody.muscle); 130 | if (this->bone_sensor_[index]) 131 | this->bone_sensor_[index]->publish_state(mBody.bone); 132 | } 133 | } 134 | } 135 | 136 | break; 137 | } 138 | 139 | case ESP_GATTC_SEARCH_CMPL_EVT: 140 | { 141 | ESP_LOGD(TAG, "ESP_GATTC_SEARCH_CMPL_EVT!"); 142 | // reset 143 | mPerson = Person(); 144 | mBody = Body(); 145 | mWeight = Weight(); 146 | registered_notifications_ = 0; 147 | for (const auto &characteristic: mCharacteristics) 148 | { 149 | auto *chr = this->parent()->get_characteristic(mServiceUUID, characteristic); 150 | if (chr == nullptr) 151 | { 152 | ESP_LOGE(TAG, "No sensor read characteristic found at service %s char %s", mServiceUUID.to_string().c_str(), 153 | characteristic.to_string().c_str()); 154 | break; 155 | } 156 | 157 | auto status_notify = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), this->parent()->get_remote_bda(), chr->handle); 158 | if (status_notify) 159 | { 160 | ESP_LOGE(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status_notify); 161 | } 162 | else 163 | { 164 | mCharacteristicHandles[registered_notifications_] = chr->handle; 165 | registered_notifications_++; 166 | } 167 | } 168 | 169 | ESP_LOGD(TAG, "All characteristic found at service %s", mServiceUUID.to_string().c_str()); 170 | break; 171 | } 172 | 173 | case ESP_GATTC_READ_CHAR_EVT: 174 | { 175 | ESP_LOGD(TAG, "ESP_GATTC_READ_CHAR_EVT!"); 176 | if (param->read.conn_id != this->parent()->get_conn_id()) 177 | break; 178 | if (param->read.status != ESP_GATT_OK) 179 | { 180 | ESP_LOGW(TAG, "Error reading char at handle %d, status=%d", param->read.handle, param->read.status); 181 | break; 182 | } 183 | break; 184 | } 185 | 186 | case ESP_GATTC_REG_FOR_NOTIFY_EVT: 187 | { 188 | ESP_LOGD(TAG, "ESP_GATTC_REG_FOR_NOTIFY_EVT!"); 189 | if (--registered_notifications_ == 0) 190 | { 191 | // all notify requests are handled 192 | this->node_state = esp32_ble_tracker::ClientState::ESTABLISHED; 193 | 194 | const uint8_t indicationOn[] = {0x2, 0x0}; 195 | //for (uint8_t i = 0; i < 3; i++) 196 | for(const auto&handle :mCharacteristicHandles) 197 | { 198 | // send indicate for these handles 199 | auto status = esp_ble_gattc_write_char_descr(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), 200 | handle + 1, sizeof(indicationOn), (uint8_t *)indicationOn, 201 | ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); 202 | if (status) 203 | { 204 | ESP_LOGE(TAG, "Error sending write request for characteristic handle, status=%d", status); 205 | } 206 | else 207 | { 208 | ESP_LOGD(TAG, "notification on for characteristic handle 0x%x", handle); 209 | } 210 | } 211 | 212 | auto *write_chr = this->parent()->get_characteristic(mServiceUUID, Char_command); 213 | if (write_chr == nullptr) 214 | { 215 | ESP_LOGE(TAG, "No write characteristic found at service %s char %s", mServiceUUID.to_string().c_str(), 216 | Char_command.to_string().c_str()); 217 | break; 218 | } 219 | 220 | uint8_t byteArray[5] = {2, 0, 0, 0, 0}; 221 | convertTimestampToLittleEndian(now() - (use_timeoffset_ ? time_offset : 0), &byteArray[1]); 222 | 223 | auto status = esp_ble_gattc_write_char_descr(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), 224 | write_chr->handle, sizeof(byteArray), (uint8_t *)byteArray, 225 | ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); 226 | if (status) 227 | { 228 | ESP_LOGE(TAG, "Error sending datetimestap, status=%d", status); 229 | } 230 | ESP_LOGD(TAG, "request to send data sent"); 231 | } 232 | break; 233 | } 234 | 235 | case ESP_GATTC_NOTIFY_EVT: 236 | { 237 | ESP_LOGD(TAG, "ESP_GATTC_NOTIFY_EVT! 0x%x, %d", param->notify.handle, param->notify.value_len); 238 | if (mCharacteristicHandles[0] == param->notify.handle) 239 | { 240 | mPerson = Person::decode(param->notify.value); 241 | ESP_LOGI(TAG, "data person %s:", mPerson.toString().c_str()); 242 | } 243 | else if (mCharacteristicHandles[1] == param->notify.handle) 244 | { 245 | auto data = Weight::decode(param->notify.value, use_timeoffset_); 246 | if (data.timestamp <= now()) 247 | { 248 | ESP_LOGI(TAG, "data weight %s:", data.toString().c_str()); 249 | if (!mWeight.valid || (mWeight < data)) 250 | mWeight = data; 251 | else 252 | ESP_LOGI(TAG, "Skipped weight!"); 253 | } 254 | else 255 | ESP_LOGE(TAG, "Skipped future event!"); 256 | } 257 | else if (mCharacteristicHandles[2] == param->notify.handle) 258 | { 259 | auto data = Body::decode(param->notify.value, use_timeoffset_); 260 | if (data.timestamp <= now()) 261 | { 262 | ESP_LOGI(TAG, "data body %s:", data.toString().c_str()); 263 | if (!mBody.valid || (mBody < data)) 264 | mBody = data; 265 | else 266 | ESP_LOGI(TAG, "Skipped body!"); 267 | } 268 | else 269 | ESP_LOGE(TAG, "Skipped future event!"); 270 | } 271 | else 272 | ESP_LOGE(TAG, "Skipped future event!"); 273 | break; 274 | } 275 | 276 | default: 277 | break; 278 | } 279 | } 280 | 281 | } // namespace medisana_bs444 282 | } // namespace esphome 283 | 284 | #endif // USE_ESP32 285 | -------------------------------------------------------------------------------- /components/medisana_bs444/medisanabs444.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #ifdef USE_ESP32 4 | 5 | #include "esphome/core/component.h" 6 | 7 | #include "esphome/components/sensor/sensor.h" 8 | #include "esphome/components/ble_client/ble_client.h" 9 | #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" 10 | 11 | #ifdef USE_TIME 12 | #include "esphome/components/time/real_time_clock.h" 13 | #include "esphome/core/time.h" 14 | #endif 15 | 16 | #include "Scale.h" 17 | 18 | /******************************* BS444 Scale *******************************************/ 19 | /** 20 | * Scan for BLE servers and find the first one that advertises the service we are looking for. 21 | * 22 | * The scale submits relevant data by use of Indications. 23 | * Indications are messages conveying the data for certain characteristics. 24 | * Characteristics have a two-byte shortcut for it (the handle) an a 16 byte 25 | * (globally unique) identifier (the UUID). * 26 | 27 | * Relevant characteristics: 28 | * Description Handle UUID Data 29 | * (2 byte) (16 byte, globally unique) 30 | * Person 0x26 00008a82-0000-1000-8000-00805f9b34fb person, gender, age, size, activity 31 | * Weight 0x1c 00008a21-0000-1000-8000-00805f9b34fb weight, time, person 32 | * Body 0x1f 00008a22-0000-1000-8000-00805f9b34fb time, person, kcal, fat, tbw, muscle, bone * 33 | * 34 | * Writing the command 0200 to a handle tells the device that you register to the 35 | * indications of this characteristic. Writing 0000 will stop it. * 36 | * 37 | * A data packet of a characteristic (hex data string) will report with a 38 | * handle 1 less than the handle of the characteristic. E.g. writing 0200 39 | * to 0x26 (Person) will report back with the handle 0x25.* 40 | * 41 | * The last 30 measurements per person will be stored in the scale and upon 42 | * communication, the history for this user will be dumped. So you will 43 | * receive 30 values like this: 44 | * handle=0x25, value=0x845302800134b6e0000000000000000000000000 45 | * handle=0x1b, value=0x1d8c1e00fe6e0aa056451100ff020900000000 46 | * handle=0x1e, value=0x6f6e0aa05602440ab8f07ff26bf11ef0000000 47 | * 48 | */ 49 | 50 | namespace esphome 51 | { 52 | namespace medisana_bs444 53 | { 54 | 55 | class MedisanaBS444 : public Component, public esphome::ble_client::BLEClientNode 56 | { 57 | 58 | private: 59 | // The service(es) we are interested in 60 | esp32_ble::ESPBTUUID mServiceUUID = Serv_SCALE; 61 | // The characteristic of the remote service we are interested in. 62 | esp32_ble::ESPBTUUID mCharacteristics[3] = {Char_person, Char_weight, Char_body}; 63 | uint16_t mCharacteristicHandles[3] = {0, 0, 0}; 64 | // last read values 65 | Person mPerson; 66 | Weight mWeight; 67 | Body mBody; 68 | 69 | public: 70 | MedisanaBS444() = default; 71 | 72 | void dump_config() override; 73 | 74 | protected: 75 | time_t now(); 76 | 77 | void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); 78 | 79 | public: 80 | void set_weight(uint8_t i, sensor::Sensor *sensor) { weight_sensor_[i] = sensor; } 81 | void set_bmi(uint8_t i, sensor::Sensor *sensor) { bmi_sensor_[i] = sensor; } 82 | void set_kcal(uint8_t i, sensor::Sensor *sensor) { kcal_sensor_[i] = sensor; } 83 | void set_fat(uint8_t i, sensor::Sensor *sensor) { fat_sensor_[i] = sensor; } 84 | void set_tbw(uint8_t i, sensor::Sensor *sensor) { tbw_sensor_[i] = sensor; } 85 | void set_muscle(uint8_t i, sensor::Sensor *sensor) { muscle_sensor_[i] = sensor; } 86 | void set_bone(uint8_t i, sensor::Sensor *sensor) { bone_sensor_[i] = sensor; } 87 | protected: 88 | sensor::Sensor *weight_sensor_[8]{nullptr}; 89 | sensor::Sensor *bmi_sensor_[8]{nullptr}; 90 | sensor::Sensor *kcal_sensor_[8]{nullptr}; 91 | sensor::Sensor *fat_sensor_[8]{nullptr}; 92 | sensor::Sensor *tbw_sensor_[8]{nullptr}; 93 | sensor::Sensor *muscle_sensor_[8]{nullptr}; 94 | sensor::Sensor *bone_sensor_[8]{nullptr}; 95 | 96 | public: 97 | void use_timeoffset(bool use_timeoffset) { use_timeoffset_ = use_timeoffset; } 98 | protected: 99 | bool use_timeoffset_ = false; 100 | 101 | #ifdef USE_TIME 102 | public: 103 | void set_time_id(time::RealTimeClock *time_id); 104 | protected: 105 | time::RealTimeClock* time_id_ = nullptr; 106 | #endif 107 | 108 | private: 109 | u_int32_t registered_notifications_ = 0; 110 | }; 111 | } // namespace medisana_bs444 112 | } // namespace esphome 113 | #endif // USE_ESP32 114 | -------------------------------------------------------------------------------- /components/medisana_bs444/sensor.py: -------------------------------------------------------------------------------- 1 | import esphome.codegen as cg 2 | import esphome.config_validation as cv 3 | from esphome.components import sensor, ble_client, time 4 | 5 | from esphome.const import ( 6 | STATE_CLASS_MEASUREMENT, 7 | UNIT_KILOGRAM, 8 | UNIT_EMPTY, 9 | UNIT_PERCENT, 10 | CONF_ID, 11 | CONF_WEIGHT, 12 | CONF_TIME_ID, 13 | ICON_SCALE_BATHROOM, 14 | ICON_PERCENT, 15 | ICON_EMPTY, 16 | DEVICE_CLASS_WEIGHT, 17 | ) 18 | 19 | UNIT_KILOCALORIERS="kcal" 20 | 21 | CONF_BMI="bmi" 22 | CONF_KILOCALORIERS="kcal" 23 | CONF_FAT="fat" 24 | CONF_TBW="tbw" 25 | CONF_MUSCLE="muscle" 26 | CONF_BONE="bone" 27 | CONF_TIME_OFFSET = "timeoffset" 28 | 29 | DEPENDENCIES = ["esp32", "ble_client", "time"] 30 | 31 | medisana_bs444_ns = cg.esphome_ns.namespace("medisana_bs444") 32 | MedisanaBS444 = medisana_bs444_ns.class_( 33 | "MedisanaBS444", cg.Component, ble_client.BLEClientNode 34 | ) 35 | 36 | # Generate schema for 8 persons 37 | MEASUREMENTS = cv.Schema({ 38 | 39 | }); 40 | 41 | for x in range(1, 8): 42 | MEASUREMENTS = MEASUREMENTS.extend( 43 | cv.Schema( 44 | { 45 | cv.Optional("%s_%s" %(CONF_WEIGHT,x)): sensor.sensor_schema( 46 | unit_of_measurement=UNIT_KILOGRAM, 47 | icon=ICON_SCALE_BATHROOM, 48 | accuracy_decimals=1, 49 | device_class=DEVICE_CLASS_WEIGHT, 50 | state_class=STATE_CLASS_MEASUREMENT, 51 | ), 52 | cv.Optional("%s_%s" %(CONF_BMI,x)): sensor.sensor_schema( 53 | unit_of_measurement=UNIT_EMPTY, 54 | icon=ICON_SCALE_BATHROOM, 55 | accuracy_decimals=1, 56 | state_class=STATE_CLASS_MEASUREMENT, 57 | ), 58 | cv.Optional("%s_%s" %(CONF_BMI,x)): sensor.sensor_schema( 59 | unit_of_measurement=UNIT_EMPTY, 60 | icon=ICON_SCALE_BATHROOM, 61 | accuracy_decimals=1, 62 | state_class=STATE_CLASS_MEASUREMENT, 63 | ), 64 | cv.Optional("%s_%s" %(CONF_KILOCALORIERS,x)): sensor.sensor_schema( 65 | unit_of_measurement=UNIT_KILOCALORIERS, 66 | icon=ICON_EMPTY, 67 | accuracy_decimals=1, 68 | state_class=STATE_CLASS_MEASUREMENT, 69 | ), 70 | cv.Optional("%s_%s" %(CONF_FAT,x)): sensor.sensor_schema( 71 | unit_of_measurement=UNIT_PERCENT, 72 | icon=ICON_PERCENT, 73 | accuracy_decimals=1, 74 | state_class=STATE_CLASS_MEASUREMENT, 75 | ), 76 | cv.Optional("%s_%s" %(CONF_TBW,x)): sensor.sensor_schema( 77 | unit_of_measurement=UNIT_PERCENT, 78 | icon=ICON_PERCENT, 79 | accuracy_decimals=1, 80 | state_class=STATE_CLASS_MEASUREMENT, 81 | ), 82 | cv.Optional("%s_%s" %(CONF_MUSCLE,x)): sensor.sensor_schema( 83 | unit_of_measurement=UNIT_PERCENT, 84 | icon=ICON_PERCENT, 85 | accuracy_decimals=1, 86 | state_class=STATE_CLASS_MEASUREMENT, 87 | ), 88 | cv.Optional("%s_%s" %(CONF_BONE,x)): sensor.sensor_schema( 89 | unit_of_measurement=UNIT_PERCENT, 90 | icon=ICON_PERCENT, 91 | accuracy_decimals=1, 92 | state_class=STATE_CLASS_MEASUREMENT, 93 | ), 94 | } 95 | ) 96 | ) 97 | 98 | CONFIG_SCHEMA = cv.All( 99 | cv.Schema( 100 | { 101 | cv.GenerateID(): cv.declare_id(MedisanaBS444), 102 | cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), 103 | cv.Optional(CONF_TIME_OFFSET, default=True): cv.boolean, 104 | } 105 | ) 106 | .extend(MEASUREMENTS) 107 | .extend(cv.COMPONENT_SCHEMA) 108 | .extend(ble_client.BLE_CLIENT_SCHEMA), 109 | ) 110 | 111 | async def to_code(config): 112 | var = cg.new_Pvariable(config[CONF_ID]) 113 | await cg.register_component(var, config) 114 | await ble_client.register_ble_node(var, config) 115 | if CONF_TIME_ID in config: 116 | time_ = await cg.get_variable(config[CONF_TIME_ID]) 117 | cg.add(var.set_time_id(time_)) 118 | cg.add(var.use_timeoffset(config[CONF_TIME_OFFSET])) 119 | 120 | for x in range(1, 8): 121 | CONF_VAL = "%s_%s" %(CONF_WEIGHT,x) 122 | if CONF_VAL in config: 123 | sens = await sensor.new_sensor(config[CONF_VAL]) 124 | cg.add(var.set_weight(x-1, sens)) 125 | CONF_VAL = "%s_%s" %(CONF_BMI,x) 126 | if CONF_VAL in config: 127 | sens = await sensor.new_sensor(config[CONF_VAL]) 128 | cg.add(var.set_bmi(x-1, sens)) 129 | CONF_VAL = "%s_%s" %(CONF_KILOCALORIERS,x) 130 | if CONF_VAL in config: 131 | sens = await sensor.new_sensor(config[CONF_VAL]) 132 | cg.add(var.set_kcal(x-1, sens)) 133 | CONF_VAL = "%s_%s" %(CONF_FAT,x) 134 | if CONF_VAL in config: 135 | sens = await sensor.new_sensor(config[CONF_VAL]) 136 | cg.add(var.set_fat(x-1, sens)) 137 | CONF_VAL = "%s_%s" %(CONF_TBW,x) 138 | if CONF_VAL in config: 139 | sens = await sensor.new_sensor(config[CONF_VAL]) 140 | cg.add(var.set_tbw(x-1, sens)) 141 | CONF_VAL = "%s_%s" %(CONF_MUSCLE,x) 142 | if CONF_VAL in config: 143 | sens = await sensor.new_sensor(config[CONF_VAL]) 144 | cg.add(var.set_muscle(x-1, sens)) 145 | CONF_VAL = "%s_%s" %(CONF_BONE,x) 146 | if CONF_VAL in config: 147 | sens = await sensor.new_sensor(config[CONF_VAL]) 148 | cg.add(var.set_bone(x-1, sens)) 149 | -------------------------------------------------------------------------------- /medisana_bs444.yaml: -------------------------------------------------------------------------------- 1 | substitutions: 2 | device_name: "medisana-bs444" 3 | 4 | external_components: 5 | - source: 6 | type: git 7 | url: https://github.com/bwynants/weegschaal 8 | ref: main 9 | components: [ medisana_bs444 ] 10 | 11 | esphome: 12 | name: ${device_name} 13 | friendly_name: Medisana BS444 14 | project: 15 | name: bwynants.$device_name 16 | version: "0.10" 17 | 18 | esp32: 19 | board: esp32dev 20 | framework: 21 | type: esp-idf 22 | 23 | # Enable logging 24 | logger: 25 | level: INFO 26 | 27 | # Enable Home Assistant API 28 | api: 29 | encryption: 30 | key: !secret api_encryption 31 | 32 | ota: 33 | password: !secret ota_password 34 | 35 | wifi: 36 | ssid: !secret wifi_ssid 37 | password: !secret wifi_password 38 | 39 | # Enable fallback hotspot (captive portal) in case wifi connection fails 40 | ap: 41 | ssid: ${device_name} Fallback Hotspot 42 | ap_timeout: 15s 43 | 44 | captive_portal: 45 | 46 | web_server: 47 | port: 80 48 | 49 | esp32_ble_tracker: 50 | scan_parameters: 51 | interval: 1100ms 52 | window: 1100ms 53 | active: false 54 | 55 | ble_client: 56 | - mac_address: !secret medisana_scale 57 | id: medisababs44_ble_id 58 | 59 | sensor: 60 | - platform: medisana_bs444 61 | ble_client_id: medisababs44_ble_id 62 | timeoffset: true # BS444 needs timeoffset 63 | weight_1: 64 | name: "Weight user 1" 65 | kcal_1: 66 | name: "kcal user 1" 67 | fat_1: 68 | name: "Fat user 1" 69 | tbw_1: 70 | name: "Water user 1" 71 | muscle_1: 72 | name: "Muscle user 1" 73 | bone_1: 74 | name: "Bone user 1" 75 | bmi_1: 76 | name: "BMI user 1" 77 | 78 | weight_2: 79 | name: "Weight user 2" 80 | kcal_2: 81 | name: "kcal user 2" 82 | fat_2: 83 | name: "Fat user 2" 84 | tbw_2: 85 | name: "Water user 2" 86 | muscle_2: 87 | name: "Muscle user 2" 88 | bone_2: 89 | name: "Bone user 2" 90 | bmi_2: 91 | name: "BMI user 2" 92 | 93 | - platform: wifi_signal 94 | name: Wi-Fi Signal 95 | update_interval: 60s 96 | entity_category: diagnostic 97 | 98 | 99 | button: 100 | # A reboot button is always useful 101 | - platform: restart 102 | entity_category: diagnostic 103 | name: Restart 104 | 105 | text_sensor: 106 | # to find the ble mac address set debug level VERBOSE 107 | # - platform: ble_scanner 108 | # name: "BLE Devices Scanner" 109 | 110 | - platform: wifi_info 111 | ip_address: 112 | name: IP Address 113 | disabled_by_default: true 114 | entity_category: diagnostic 115 | 116 | time: 117 | - platform: homeassistant 118 | id: homeassistant_time 119 | timezone: Europe/Brussels 120 | --------------------------------------------------------------------------------