ESPHome 2026.3.0-dev
Loading...
Searching...
No Matches
thermopro_ble.cpp
Go to the documentation of this file.
1#include "thermopro_ble.h"
2#include "esphome/core/log.h"
3
4#ifdef USE_ESP32
5
7
8// this size must be large enough to hold the largest data frame
9// of all supported devices
10static constexpr std::size_t MAX_DATA_SIZE = 24;
11
12struct DeviceParserMapping {
13 const char *prefix;
14 DeviceParser parser;
15};
16
17static float tp96_battery(uint16_t voltage);
18
19static optional<ParseResult> parse_tp972(const uint8_t *data, std::size_t data_size);
20static optional<ParseResult> parse_tp96(const uint8_t *data, std::size_t data_size);
21static optional<ParseResult> parse_tp3(const uint8_t *data, std::size_t data_size);
22
23static const char *const TAG = "thermopro_ble";
24
25static const struct DeviceParserMapping DEVICE_PARSER_MAP[] = {
26 {"TP972", parse_tp972}, {"TP970", parse_tp96}, {"TP96", parse_tp96}, {"TP3", parse_tp3}};
27
29 ESP_LOGCONFIG(TAG, "ThermoPro BLE");
30 LOG_SENSOR(" ", "Temperature", this->temperature_);
31 LOG_SENSOR(" ", "External temperature", this->external_temperature_);
32 LOG_SENSOR(" ", "Humidity", this->humidity_);
33 LOG_SENSOR(" ", "Battery Level", this->battery_level_);
34}
35
37 // check for matching mac address
38 if (device.address_uint64() != this->address_) {
39 ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
40 return false;
41 }
42
43 // check for valid device type
45 if (this->device_parser_ == nullptr) {
46 ESP_LOGVV(TAG, "parse_device(): invalid device type.");
47 return false;
48 }
49
50 char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
51 ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str_to(addr_buf));
52
53 // publish signal strength
54 float signal_strength = float(device.get_rssi());
55 if (this->signal_strength_ != nullptr)
56 this->signal_strength_->publish_state(signal_strength);
57
58 bool success = false;
59 for (auto &service_data : device.get_manufacturer_datas()) {
60 // check maximum data size
61 std::size_t data_size = service_data.data.size() + 2;
62 if (data_size > MAX_DATA_SIZE) {
63 ESP_LOGVV(TAG, "parse_device(): maximum data size exceeded!");
64 continue;
65 }
66
67 // reconstruct whole record from 2 byte uuid and data
68 esp_bt_uuid_t uuid = service_data.uuid.get_uuid();
69 uint8_t data[MAX_DATA_SIZE] = {static_cast<uint8_t>(uuid.uuid.uuid16), static_cast<uint8_t>(uuid.uuid.uuid16 >> 8)};
70 std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2);
71
72 // dispatch data to parser
73 optional<ParseResult> result = this->device_parser_(data, data_size);
74 if (!result.has_value()) {
75 continue;
76 }
77
78 // publish sensor values
79 if (result->temperature.has_value() && this->temperature_ != nullptr)
80 this->temperature_->publish_state(*result->temperature);
81 if (result->external_temperature.has_value() && this->external_temperature_ != nullptr)
82 this->external_temperature_->publish_state(*result->external_temperature);
83 if (result->humidity.has_value() && this->humidity_ != nullptr)
84 this->humidity_->publish_state(*result->humidity);
85 if (result->battery_level.has_value() && this->battery_level_ != nullptr)
86 this->battery_level_->publish_state(*result->battery_level);
87
88 success = true;
89 }
90
91 return success;
92}
93
94void ThermoProBLE::update_device_type_(const std::string &device_name) {
95 // check for changed device name (should only happen on initial call)
96 if (this->device_name_ == device_name) {
97 return;
98 }
99
100 // remember device name
101 this->device_name_ = device_name;
102
103 // try to find device parser
104 for (const auto &mapping : DEVICE_PARSER_MAP) {
105 if (device_name.starts_with(mapping.prefix)) {
106 this->device_parser_ = mapping.parser;
107 return;
108 }
109 }
110
111 // device type unknown
112 this->device_parser_ = nullptr;
113 ESP_LOGVV(TAG, "update_device_type_(): unknown device type %s.", device_name.c_str());
114}
115
116static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) {
117 return static_cast<uint16_t>(data[offset + 0]) | (static_cast<uint16_t>(data[offset + 1]) << 8);
118}
119
120static inline int16_t read_int16(const uint8_t *data, std::size_t offset) {
121 return static_cast<int16_t>(read_uint16(data, offset));
122}
123
124static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) {
125 return static_cast<uint32_t>(data[offset + 0]) | (static_cast<uint32_t>(data[offset + 1]) << 8) |
126 (static_cast<uint32_t>(data[offset + 2]) << 16) | (static_cast<uint32_t>(data[offset + 3]) << 24);
127}
128
129// Battery calculation used with permission from:
130// https://github.com/Bluetooth-Devices/thermopro-ble/blob/main/src/thermopro_ble/parser.py
131//
132// TP96x battery values appear to be a voltage reading, probably in millivolts.
133// This means that calculating battery life from it is a non-linear function.
134// Examining the curve, it looked fairly close to a curve from the tanh function.
135// So, I created a script to use Tensorflow to optimize an equation in the format
136// A*tanh(B*x+C)+D
137// Where A,B,C,D are the variables to optimize for. This yielded the below function
138static float tp96_battery(uint16_t voltage) {
139 float level = 52.317286f * tanh(static_cast<float>(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f;
140 return std::max(0.0f, std::min(level, 100.0f));
141}
142
143static optional<ParseResult> parse_tp972(const uint8_t *data, std::size_t data_size) {
144 if (data_size != 23) {
145 ESP_LOGVV(TAG, "parse_tp972(): payload has wrong size of %d (!= 23)!", data_size);
146 return {};
147 }
148
149 ParseResult result;
150
151 // ambient temperature, 2 bytes, 16-bit unsigned integer, -54 °C offset
152 result.external_temperature = static_cast<float>(read_uint16(data, 1)) - 54.0f;
153
154 // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage)
155 result.battery_level = tp96_battery(read_uint16(data, 3));
156
157 // internal temperature, 4 bytes, float, -54 °C offset
158 result.temperature = static_cast<float>(read_uint32(data, 9)) - 54.0f;
159
160 return result;
161}
162
163static optional<ParseResult> parse_tp96(const uint8_t *data, std::size_t data_size) {
164 if (data_size != 7) {
165 ESP_LOGVV(TAG, "parse_tp96(): payload has wrong size of %d (!= 7)!", data_size);
166 return {};
167 }
168
169 ParseResult result;
170
171 // internal temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset
172 result.temperature = static_cast<float>(read_uint16(data, 1)) - 30.0f;
173
174 // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage)
175 result.battery_level = tp96_battery(read_uint16(data, 3));
176
177 // ambient temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset
178 result.external_temperature = static_cast<float>(read_uint16(data, 5)) - 30.0f;
179
180 return result;
181}
182
183static optional<ParseResult> parse_tp3(const uint8_t *data, std::size_t data_size) {
184 if (data_size < 6) {
185 ESP_LOGVV(TAG, "parse_tp3(): payload has wrong size of %d (< 6)!", data_size);
186 return {};
187 }
188
189 ParseResult result;
190
191 // temperature, 2 bytes, 16-bit signed integer, 0.1 °C
192 result.temperature = static_cast<float>(read_int16(data, 1)) * 0.1f;
193
194 // humidity, 1 byte, 8-bit unsigned integer, 1.0 %
195 result.humidity = static_cast<float>(data[3]);
196
197 // battery level, 2 bits (0-2)
198 result.battery_level = static_cast<float>(data[4] & 0x3) * 50.0;
199
200 return result;
201}
202
203} // namespace esphome::thermopro_ble
204
205#endif
const char * address_str_to(std::span< char, MAC_ADDRESS_PRETTY_BUFFER_SIZE > buf) const
Format MAC address into provided buffer, returns pointer to buffer for convenience.
const std::vector< ServiceData > & get_manufacturer_datas() const
bool has_value() const
Definition optional.h:92
void publish_state(float state)
Publish a new state to the front-end.
Definition sensor.cpp:65
void update_device_type_(const std::string &device_name)
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override
optional< ParseResult >(*)(const uint8_t *data, std::size_t data_size) DeviceParser