mirror of
https://github.com/esphome/esphome.git
synced 2026-01-10 04:00:51 -07:00
[thermopro_ble] Add thermopro ble support (#11835)
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
@@ -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
|
||||
|
||||
0
esphome/components/thermopro_ble/__init__.py
Normal file
0
esphome/components/thermopro_ble/__init__.py
Normal file
97
esphome/components/thermopro_ble/sensor.py
Normal file
97
esphome/components/thermopro_ble/sensor.py
Normal file
@@ -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))
|
||||
204
esphome/components/thermopro_ble/thermopro_ble.cpp
Normal file
204
esphome/components/thermopro_ble/thermopro_ble.cpp
Normal file
@@ -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<ParseResult> parse_tp972(const uint8_t *data, std::size_t data_size);
|
||||
static optional<ParseResult> parse_tp96(const uint8_t *data, std::size_t data_size);
|
||||
static optional<ParseResult> 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<uint8_t>(uuid.uuid.uuid16), static_cast<uint8_t>(uuid.uuid.uuid16 >> 8)};
|
||||
std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2);
|
||||
|
||||
// dispatch data to parser
|
||||
optional<ParseResult> 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<uint16_t>(data[offset + 0]) | (static_cast<uint16_t>(data[offset + 1]) << 8);
|
||||
}
|
||||
|
||||
static inline int16_t read_int16(const uint8_t *data, std::size_t offset) {
|
||||
return static_cast<int16_t>(read_uint16(data, offset));
|
||||
}
|
||||
|
||||
static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) {
|
||||
return static_cast<uint32_t>(data[offset + 0]) | (static_cast<uint32_t>(data[offset + 1]) << 8) |
|
||||
(static_cast<uint32_t>(data[offset + 2]) << 16) | (static_cast<uint32_t>(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<float>(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f;
|
||||
return std::max(0.0f, std::min(level, 100.0f));
|
||||
}
|
||||
|
||||
static optional<ParseResult> 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<float>(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<float>(read_uint32(data, 9)) - 54.0f;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static optional<ParseResult> 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<float>(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<float>(read_uint16(data, 5)) - 30.0f;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static optional<ParseResult> 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<float>(read_int16(data, 1)) * 0.1f;
|
||||
|
||||
// humidity, 1 byte, 8-bit unsigned integer, 1.0 %
|
||||
result.humidity = static_cast<float>(data[3]);
|
||||
|
||||
// battery level, 2 bits (0-2)
|
||||
result.battery_level = static_cast<float>(data[4] & 0x3) * 50.0;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace esphome::thermopro_ble
|
||||
|
||||
#endif
|
||||
49
esphome/components/thermopro_ble/thermopro_ble.h
Normal file
49
esphome/components/thermopro_ble/thermopro_ble.h
Normal file
@@ -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<float> temperature;
|
||||
optional<float> external_temperature;
|
||||
optional<float> humidity;
|
||||
optional<float> battery_level;
|
||||
};
|
||||
|
||||
using DeviceParser = optional<ParseResult> (*)(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
|
||||
13
tests/components/thermopro_ble/common.yaml
Normal file
13
tests/components/thermopro_ble/common.yaml
Normal file
@@ -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"
|
||||
4
tests/components/thermopro_ble/test.esp32-idf.yaml
Normal file
4
tests/components/thermopro_ble/test.esp32-idf.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
Reference in New Issue
Block a user