diff --git a/CODEOWNERS b/CODEOWNERS index d6ec7b882..c6332e393 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -484,6 +484,7 @@ esphome/components/template/datetime/* @rfdarter esphome/components/template/event/* @nohat esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse +esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core esphome/components/tinyusb/* @kbx81 diff --git a/esphome/components/thermopro_ble/__init__.py b/esphome/components/thermopro_ble/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py new file mode 100644 index 000000000..de6322962 --- /dev/null +++ b/esphome/components/thermopro_ble/sensor.py @@ -0,0 +1,97 @@ +import esphome.codegen as cg +from esphome.components import esp32_ble_tracker, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_EXTERNAL_TEMPERATURE, + CONF_HUMIDITY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_DECIBEL_MILLIWATT, + UNIT_PERCENT, +) + +CODEOWNERS = ["@sittner"] + +DEPENDENCIES = ["esp32_ble_tracker"] + +thermopro_ble_ns = cg.esphome_ns.namespace("thermopro_ble") +ThermoProBLE = thermopro_ble_ns.class_( + "ThermoProBLE", esp32_ble_tracker.ESPBTDeviceListener, cg.Component +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ThermoProBLE), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_EXTERNAL_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await esp32_ble_tracker.register_ble_device(var, config) + + cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature(sens)) + if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): + sens = await sensor.new_sensor(external_temperature_config) + cg.add(var.set_external_temperature(sens)) + if humidity_config := config.get(CONF_HUMIDITY): + sens = await sensor.new_sensor(humidity_config) + cg.add(var.set_humidity(sens)) + if battery_level_config := config.get(CONF_BATTERY_LEVEL): + sens = await sensor.new_sensor(battery_level_config) + cg.add(var.set_battery_level(sens)) + if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): + sens = await sensor.new_sensor(signal_strength_config) + cg.add(var.set_signal_strength(sens)) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp new file mode 100644 index 000000000..4b43c9b39 --- /dev/null +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -0,0 +1,204 @@ +#include "thermopro_ble.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +namespace esphome::thermopro_ble { + +// this size must be large enough to hold the largest data frame +// of all supported devices +static constexpr std::size_t MAX_DATA_SIZE = 24; + +struct DeviceParserMapping { + const char *prefix; + DeviceParser parser; +}; + +static float tp96_battery(uint16_t voltage); + +static optional parse_tp972(const uint8_t *data, std::size_t data_size); +static optional parse_tp96(const uint8_t *data, std::size_t data_size); +static optional parse_tp3(const uint8_t *data, std::size_t data_size); + +static const char *const TAG = "thermopro_ble"; + +static const struct DeviceParserMapping DEVICE_PARSER_MAP[] = { + {"TP972", parse_tp972}, {"TP970", parse_tp96}, {"TP96", parse_tp96}, {"TP3", parse_tp3}}; + +void ThermoProBLE::dump_config() { + ESP_LOGCONFIG(TAG, "ThermoPro BLE"); + LOG_SENSOR(" ", "Temperature", this->temperature_); + LOG_SENSOR(" ", "External temperature", this->external_temperature_); + LOG_SENSOR(" ", "Humidity", this->humidity_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_); +} + +bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { + // check for matching mac address + if (device.address_uint64() != this->address_) { + ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); + return false; + } + + // check for valid device type + update_device_type_(device.get_name()); + if (this->device_parser_ == nullptr) { + ESP_LOGVV(TAG, "parse_device(): invalid device type."); + return false; + } + + ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str().c_str()); + + // publish signal strength + float signal_strength = float(device.get_rssi()); + if (this->signal_strength_ != nullptr) + this->signal_strength_->publish_state(signal_strength); + + bool success = false; + for (auto &service_data : device.get_manufacturer_datas()) { + // check maximum data size + std::size_t data_size = service_data.data.size() + 2; + if (data_size > MAX_DATA_SIZE) { + ESP_LOGVV(TAG, "parse_device(): maximum data size exceeded!"); + continue; + } + + // reconstruct whole record from 2 byte uuid and data + esp_bt_uuid_t uuid = service_data.uuid.get_uuid(); + uint8_t data[MAX_DATA_SIZE] = {static_cast(uuid.uuid.uuid16), static_cast(uuid.uuid.uuid16 >> 8)}; + std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2); + + // dispatch data to parser + optional result = this->device_parser_(data, data_size); + if (!result.has_value()) { + continue; + } + + // publish sensor values + if (result->temperature.has_value() && this->temperature_ != nullptr) + this->temperature_->publish_state(*result->temperature); + if (result->external_temperature.has_value() && this->external_temperature_ != nullptr) + this->external_temperature_->publish_state(*result->external_temperature); + if (result->humidity.has_value() && this->humidity_ != nullptr) + this->humidity_->publish_state(*result->humidity); + if (result->battery_level.has_value() && this->battery_level_ != nullptr) + this->battery_level_->publish_state(*result->battery_level); + + success = true; + } + + return success; +} + +void ThermoProBLE::update_device_type_(const std::string &device_name) { + // check for changed device name (should only happen on initial call) + if (this->device_name_ == device_name) { + return; + } + + // remember device name + this->device_name_ = device_name; + + // try to find device parser + for (const auto &mapping : DEVICE_PARSER_MAP) { + if (device_name.starts_with(mapping.prefix)) { + this->device_parser_ = mapping.parser; + return; + } + } + + // device type unknown + this->device_parser_ = nullptr; + ESP_LOGVV(TAG, "update_device_type_(): unknown device type %s.", device_name.c_str()); +} + +static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) { + return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8); +} + +static inline int16_t read_int16(const uint8_t *data, std::size_t offset) { + return static_cast(read_uint16(data, offset)); +} + +static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { + return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); +} + +// Battery calculation used with permission from: +// https://github.com/Bluetooth-Devices/thermopro-ble/blob/main/src/thermopro_ble/parser.py +// +// TP96x battery values appear to be a voltage reading, probably in millivolts. +// This means that calculating battery life from it is a non-linear function. +// Examining the curve, it looked fairly close to a curve from the tanh function. +// So, I created a script to use Tensorflow to optimize an equation in the format +// A*tanh(B*x+C)+D +// Where A,B,C,D are the variables to optimize for. This yielded the below function +static float tp96_battery(uint16_t voltage) { + float level = 52.317286f * tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; + return std::max(0.0f, std::min(level, 100.0f)); +} + +static optional parse_tp972(const uint8_t *data, std::size_t data_size) { + if (data_size != 23) { + ESP_LOGVV(TAG, "parse_tp972(): payload has wrong size of %d (!= 23)!", data_size); + return {}; + } + + ParseResult result; + + // ambient temperature, 2 bytes, 16-bit unsigned integer, -54 °C offset + result.external_temperature = static_cast(read_uint16(data, 1)) - 54.0f; + + // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) + result.battery_level = tp96_battery(read_uint16(data, 3)); + + // internal temperature, 4 bytes, float, -54 °C offset + result.temperature = static_cast(read_uint32(data, 9)) - 54.0f; + + return result; +} + +static optional parse_tp96(const uint8_t *data, std::size_t data_size) { + if (data_size != 7) { + ESP_LOGVV(TAG, "parse_tp96(): payload has wrong size of %d (!= 7)!", data_size); + return {}; + } + + ParseResult result; + + // internal temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset + result.temperature = static_cast(read_uint16(data, 1)) - 30.0f; + + // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) + result.battery_level = tp96_battery(read_uint16(data, 3)); + + // ambient temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset + result.external_temperature = static_cast(read_uint16(data, 5)) - 30.0f; + + return result; +} + +static optional parse_tp3(const uint8_t *data, std::size_t data_size) { + if (data_size < 6) { + ESP_LOGVV(TAG, "parse_tp3(): payload has wrong size of %d (< 6)!", data_size); + return {}; + } + + ParseResult result; + + // temperature, 2 bytes, 16-bit signed integer, 0.1 °C + result.temperature = static_cast(read_int16(data, 1)) * 0.1f; + + // humidity, 1 byte, 8-bit unsigned integer, 1.0 % + result.humidity = static_cast(data[3]); + + // battery level, 2 bits (0-2) + result.battery_level = static_cast(data[4] & 0x3) * 50.0; + + return result; +} + +} // namespace esphome::thermopro_ble + +#endif diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h new file mode 100644 index 000000000..38bed8210 --- /dev/null +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" + +#ifdef USE_ESP32 + +namespace esphome::thermopro_ble { + +struct ParseResult { + optional temperature; + optional external_temperature; + optional humidity; + optional battery_level; +}; + +using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); + +class ThermoProBLE : public Component, public esp32_ble_tracker::ESPBTDeviceListener { + public: + void set_address(uint64_t address) { this->address_ = address; }; + + bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + void dump_config() override; + void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } + void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } + void set_external_temperature(sensor::Sensor *external_temperature) { + this->external_temperature_ = external_temperature; + } + void set_humidity(sensor::Sensor *humidity) { this->humidity_ = humidity; } + void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; } + + protected: + uint64_t address_; + std::string device_name_; + DeviceParser device_parser_{nullptr}; + sensor::Sensor *signal_strength_{nullptr}; + sensor::Sensor *temperature_{nullptr}; + sensor::Sensor *external_temperature_{nullptr}; + sensor::Sensor *humidity_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; + + void update_device_type_(const std::string &device_name); +}; + +} // namespace esphome::thermopro_ble + +#endif diff --git a/tests/components/thermopro_ble/common.yaml b/tests/components/thermopro_ble/common.yaml new file mode 100644 index 000000000..297725e1c --- /dev/null +++ b/tests/components/thermopro_ble/common.yaml @@ -0,0 +1,13 @@ +esp32_ble_tracker: + +sensor: + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: "ThermoPro Temperature" + humidity: + name: "ThermoPro Humidity" + battery_level: + name: "ThermoPro Battery Level" + signal_strength: + name: "ThermoPro Signal Strength" diff --git a/tests/components/thermopro_ble/test.esp32-idf.yaml b/tests/components/thermopro_ble/test.esp32-idf.yaml new file mode 100644 index 000000000..7a6541ae7 --- /dev/null +++ b/tests/components/thermopro_ble/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + +<<: !include common.yaml