diff --git a/esphome/components/bthome/__init__.py b/esphome/components/bthome/__init__.py new file mode 100644 index 0000000000..6c465dc506 --- /dev/null +++ b/esphome/components/bthome/__init__.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import esp32_ble_tracker +from esphome.const import CONF_MAC_ADDRESS, CONF_ID + + +CODEOWNERS = ["@badrpc"] +DEPENDENCIES = ["esp32_ble_tracker"] +MULTI_CONF = True + +CONF_ENCRYPTION_KEY = "encryption_key" + +bthome_ns = cg.esphome_ns.namespace("bthome") +BTHome = bthome_ns.class_("BTHome", esp32_ble_tracker.ESPBTDeviceListener, cg.Component) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BTHome), + cv.Optional(CONF_ENCRYPTION_KEY): cv.bind_key, + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + } + ) + .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)) + cg.add(var.set_encryption_key(config[CONF_ENCRYPTION_KEY])) diff --git a/esphome/components/bthome/binary_sensor.py b/esphome/components/bthome/binary_sensor.py new file mode 100644 index 0000000000..23eb893e3f --- /dev/null +++ b/esphome/components/bthome/binary_sensor.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import binary_sensor +from esphome.const import ( + DEVICE_CLASS_WINDOW, + CONF_ID, +) + +from . import BTHome + + +CONF_WINDOW = "window" + + +DEPENDENCIES = ["bthome"] + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(BTHome), + cv.Optional(CONF_WINDOW): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_WINDOW + ), + } +) + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_ID]) + + if CONF_WINDOW in config: + sens = await binary_sensor.new_binary_sensor(config[CONF_WINDOW]) + cg.add(parent.set_window(sens)) diff --git a/esphome/components/bthome/bthome.cpp b/esphome/components/bthome/bthome.cpp new file mode 100644 index 0000000000..f53b83e42f --- /dev/null +++ b/esphome/components/bthome/bthome.cpp @@ -0,0 +1,536 @@ +#include "bthome.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +#include "mbedtls/ccm.h" + +namespace esphome { +namespace bthome { + +// 0x00 packet id uint8 (1 byte) 0009 9 +// 0x01 battery uint8 (1 byte) 1 0161 97 % +// 0x02 temperature sint16 (2 bytes) 0.01 02CA09 25.06 °C +// 0x03 humidity uint16 (2 bytes) 0.01 03BF13 50.55 % +// 0x04 pressure uint24 (3 bytes) 0.01 04138A01 1008.83 hPa +// 0x05 illuminance uint24 (3 bytes) 0.01 05138A14 13460.67 lux +// 0x06 mass (kg) uint16 (2 byte) 0.01 065E1F 80.3 kg +// 0x07 mass (lb) uint16 (2 byte) 0.01 073E1D 74.86 lb +// 0x08 dewpoint sint16 (2 bytes) 0.01 08CA06 17.38 °C +// 0x09 count uint (1 bytes) 1 0960 96 +// 0x0A energy uint24 (3 bytes) 0.001 0A138A14 1346.067 kWh +// 0x0B power uint24 (3 bytes) 0.01 0B021B00 69.14 W +// 0x0C voltage uint16 (2 bytes) 0.001 0C020C 3.074 V +// 0x0D pm2.5 uint16 (2 bytes) 1 0D120C 3090 ug/m3 +// 0x0E pm10 uint16 (2 bytes) 1 0E021C 7170 ug/m3 +// 0x0F generic boolean uint8 (1 byte) 0F01 0 (False = Off) 1 (True = On) +// 0x10 power uint8 (1 byte) 1001 0 (False = Off) 1 (True = On) +// 0x11 opening uint8 (1 byte) 1100 0 (False = Closed) 1 (True = Open) +// 0x12 co2 uint16 (2 bytes) 1 12E204 1250 ppm +// 0x13 tvoc uint16 (2 bytes) 1 133301 307 ug/m3 +// 0x14 moisture uint16 (2 bytes) 0.01 14020C 30.74 % +// 0x15 battery uint8 (1 byte) 1501 0 (False = Normal) 1 (True = Low) +// 0x16 battery charging uint8 (1 byte) 1601 0 (False = Not Charging) 1 (True = Charging) +// 0x17 carbon monoxide uint8 (1 byte) 1700 0 (False = Not detected) 1 (True = Detected) +// 0x18 cold uint8 (1 byte) 1801 0 (False = Normal) 1 (True = Cold) +// 0x19 connectivity uint8 (1 byte) 1900 0 (False = Disconnected) 1 (True = Connected) +// 0x1A door uint8 (1 byte) 1A00 0 (False = Closed) 1 (True = Open) +// 0x1B garage door uint8 (1 byte) 1B01 0 (False = Closed) 1 (True = Open) +// 0x1C gas uint8 (1 byte) 1C01 0 (False = Clear) 1 (True = Detected) +// 0x1D heat uint8 (1 byte) 1D00 0 (False = Normal) 1 (True = Hot) +// 0x1E light uint8 (1 byte) 1E01 0 (False = No light) 1 (True = Light detected) +// 0x1F lock uint8 (1 byte) 1F01 0 (False = Locked) 1 (True = Unlocked) +// 0x20 moisture uint8 (1 byte) 2001 0 (False = Dry) 1 (True = Wet) +// 0x21 motion uint8 (1 byte) 2100 0 (False = Clear) 1 (True = Detected) +// 0x22 moving uint8 (1 byte) 2201 0 (False = Not moving) 1 (True = Moving) +// 0x23 occupancy uint8 (1 byte) 2301 0 (False = Clear) 1 (True = Detected) +// 0x24 plug uint8 (1 byte) 2400 0 (False = Unplugged) 1 (True = Plugged in) +// 0x25 presence uint8 (1 byte) 2500 0 (False = Away) 1 (True = Home) +// 0x26 problem uint8 (1 byte) 2601 0 (False = OK) 1 (True = Problem) +// 0x27 running uint8 (1 byte) 2701 0 (False = Not Running) 1 (True = Running) +// 0x28 safety uint8 (1 byte) 2800 0 (False = Unsafe) 1 (True = Safe) +// 0x29 smoke uint8 (1 byte) 2901 0 (False = Clear) 1 (True = Detected) +// 0x2A sound uint8 (1 byte) 2A00 0 (False = Clear) 1 (True = Detected) +// 0x2B tamper uint8 (1 byte) 2B00 0 (False = Off) 1 (True = On) +// 0x2C vibration uint8 (1 byte) 2C01 0 (False = Clear) 1 (True = Detected) +// 0x2D window uint8 (1 byte) 2D01 0 (False = Closed) 1 (True = Open) +// 0x2E humidity uint8 (1 byte) 1 2E23 35 % +// 0x2F moisture uint8 (1 byte) 1 2F23 35 % +// 0x3A button 0x00 None 3A00 0x01 press 3A01 press 0x02 double_press 3A02 double_press 0x03 triple_press 3A03 triple_press 0x04 long_press 3A04 long_press 0x05 long_double_press 3A05 long_double_press 0x06 long_triple_press 3A06 long_triple_press 0x80 hold_press 3A80 hold_press +// 0x3C dimmer 0x00 None 3C0000 0x01 rotate left # steps 3C0103 rotate left 3 steps 0x02 rotate right # steps 3C020A rotate right 10 steps +// 0x3D count uint (2 bytes) 1 3D0960 24585 +// 0x3E count uint (4 bytes) 1 3E2A2C0960 1611213866 +// 0x3F rotation sint16 (2 bytes) 0.1 3F020C 307.4 ° +// 0x40 distance (mm) uint16 (2 bytes) 1 400C00 12 mm +// 0x41 distance (m) uint16 (2 bytes) 0.1 414E00 7.8 m +// 0x42 duration uint24 (3 bytes) 0.001 424E3400 13.390 s +// 0x43 current uint16 (2 bytes) 0.001 434E34 13.39 A +// 0x44 speed uint16 (2 bytes) 0.01 444E34 133.90 m/s +// 0x45 temperature sint16 (2 bytes) 0.1 451101 27.3 °C +// 0x46 UV index uint8 (1 byte) 0.1 4632 5.0 +// 0x47 volume uint16 (2 bytes) 0.1 478756 2215.1 L +// 0x48 volume uint16 (2 bytes) 1 48DC87 34780 mL +// 0x49 volume Flow Rate uint16 (2 bytes) 0.001 49DC87 34.780 m3/hr +// 0x4A voltage uint16 (2 bytes) 0.1 4A020C 307.4 V +// 0x4B gas uint24 (3 bytes) 0.001 4B138A14 1346.067 m3 +// 0x4C gas uint32 (4 bytes) 0.001 4C41018A01 25821.505 m3 +// 0x4D energy uint32 (4 bytes) 0.001 4d12138a14 344593.170 kWh +// 0x4E volume uint32 (4 bytes) 0.001 4E87562A01 19551.879 L +// 0x4F water uint32 (4 bytes) 0.001 4F87562A01 19551.879 +// 0x50 timestamp uint48 (4 bytes) - 505d396164 see below +// 0x51 acceleration uint16 (2 bytes) 0.001 518756 22.151 m/s² +// 0x52 gyroscope uint16 (2 bytes) 0.001 528756 22.151 °/s +// 0x53 text see below - 530C48656C6C6F 20576F726C6421 Hello World! +// 0x54 raw see below - 540C48656C6C6F 20576F726C6421 48656c6c6f20 576f726c6421 +// 0x55 volume storage uint32 (4 bytes) 0.001 5587562A01 19551.879 L +// 0xF0 device type id uint16 (2 bytes) F00100 1 +// 0xF1 firmware version uint32 (4 bytes) F100010204 4.2.1.0 +// 0xF2 firmware version uint24 (3 bytes) F2000106 6.1.0 + +static const char *const TAG = "bthome"; + +const OIDUInt8<0x00> oid_pid; +const OIDUFixedPoint<0x01, 1> oid_battery_percent; +const OIDSFixedPoint<0x02, 2, 1, 100> oid_temperature_celsius_x100; +const OIDUFixedPoint<0x03, 2, 1, 100> oid_humidity_percent_x100; +const OIDUFixedPoint<0x04, 3, 1, 100> oid_pressure_hpa_x100; +const OIDUFixedPoint<0x05, 3, 1, 100> oid_illuminance_lux_x100; +const OIDUFixedPoint<0x06, 2, 1, 100> oid_mass_kg_x100; +const OIDUFixedPoint<0x07, 2, 45359237, 100000000> oid_mass_lb_x100; // Converts to kg. +const OIDSFixedPoint<0x08, 2> oid_dewpoint_celsius_x100; +const OIDUFixedPoint<0x09, 1> oid_counter8; +const OIDUFixedPoint<0x0a, 3, 1, 1000> oid_energy_kwh_x1000; +const OIDUFixedPoint<0x0b, 3, 1, 100> oid_power_w_x100; +const OIDUFixedPoint<0x0c, 2, 1, 1000> oid_voltage_v_x1000; +const OIDUFixedPoint<0x0d, 2> oid_pm2_5_ug_m3; +const OIDUFixedPoint<0x0e, 2> oid_pm10_ug_m3; +const OIDBool<0x0f> oid_bool; +const OIDBool<0x10> oid_power; +const OIDBool<0x11> oid_opening; +const OIDUFixedPoint<0x12, 2> oid_co2_concentration_ppm; +const OIDUFixedPoint<0x13, 2> oid_tvoc_ug_m3; +const OIDUFixedPoint<0x14, 2, 1, 100> oid_moisture_percent_x100; +const OIDBool<0x15> oid_battery; +const OIDBool<0x16> oid_battery_charging; +const OIDBool<0x17> oid_carbon_monoxide; +const OIDBool<0x18> oid_cold; +const OIDBool<0x19> oid_connectivity; +const OIDBool<0x1a> oid_door; +const OIDBool<0x1b> oid_garage_door; +const OIDBool<0x1c> oid_gas; +const OIDBool<0x1d> oid_heat; +const OIDBool<0x1e> oid_light; +const OIDBool<0x1f> oid_lock; +const OIDBool<0x20> oid_moisture; +const OIDBool<0x21> oid_motion; +const OIDBool<0x22> oid_moving; +const OIDBool<0x23> oid_occupancy; +const OIDBool<0x24> oid_plug; +const OIDBool<0x25> oid_presence; +const OIDBool<0x26> oid_problem; +const OIDBool<0x27> oid_running; +const OIDBool<0x28> oid_safety; +const OIDBool<0x29> oid_smoke; +const OIDBool<0x2a> oid_sound; +const OIDBool<0x2b> oid_tamper; +const OIDBool<0x2c> oid_vibration; +const OIDBool<0x2d> oid_window; +const OIDUFixedPoint<0x2e, 1> oid_humidity_percent; +const OIDUFixedPoint<0x2f, 1> oid_moisture_percent; +// 0x30 - 0x39 +const OIDUFixedPoint<0x3a, 1> oid_button_event; +// 0x3b +// 0x3C dimmer 0x00 None 3C0000 0x01 rotate left # steps 3C0103 rotate left 3 steps 0x02 rotate right # steps 3C020A rotate right 10 steps +const OIDUFixedPoint<0x3d, 2> oid_counter16; +const OIDUFixedPoint<0x3e, 4> oid_counter32; +const OIDUFixedPoint<0x3f, 2, 1, 10> oid_angle_degrees_x10; +// 0x40 distance (mm) uint16 (2 bytes) 1 400C00 12 mm +// 0x41 distance (m) uint16 (2 bytes) 0.1 414E00 7.8 m +// 0x42 duration uint24 (3 bytes) 0.001 424E3400 13.390 s +// 0x43 current uint16 (2 bytes) 0.001 434E34 13.39 A +// 0x44 speed uint16 (2 bytes) 0.01 444E34 133.90 m/s +// 0x45 temperature sint16 (2 bytes) 0.1 451101 27.3 °C +// 0x46 UV index uint8 (1 byte) 0.1 4632 5.0 +// 0x47 volume uint16 (2 bytes) 0.1 478756 2215.1 L +// 0x48 volume uint16 (2 bytes) 1 48DC87 34780 mL +// 0x49 volume Flow Rate uint16 (2 bytes) 0.001 49DC87 34.780 m3/hr +// 0x4A voltage uint16 (2 bytes) 0.1 4A020C 307.4 V +// 0x4B gas uint24 (3 bytes) 0.001 4B138A14 1346.067 m3 +// 0x4C gas uint32 (4 bytes) 0.001 4C41018A01 25821.505 m3 +// 0x4D energy uint32 (4 bytes) 0.001 4d12138a14 344593.170 kWh +// 0x4E volume uint32 (4 bytes) 0.001 4E87562A01 19551.879 L +// 0x4F water uint32 (4 bytes) 0.001 4F87562A01 19551.879 +// 0x50 timestamp uint48 (4 bytes) - 505d396164 see below +// 0x51 acceleration uint16 (2 bytes) 0.001 518756 22.151 m/s² +// 0x52 gyroscope uint16 (2 bytes) 0.001 528756 22.151 °/s +// 0x53 text see below - 530C48656C6C6F 20576F726C6421 Hello World! +// 0x54 raw see below - 540C48656C6C6F 20576F726C6421 48656c6c6f20 576f726c6421 +// 0x55 volume storage uint32 (4 bytes) 0.001 5587562A01 19551.879 L +// 0x56 - 0xef +// 0xF0 device type id uint16 (2 bytes) F00100 1 +// 0xF1 firmware version uint32 (4 bytes) F100010204 4.2.1.0 +// 0xF2 firmware version uint24 (3 bytes) F2000106 6.1.0 + +#define OID_ENTRY(oid) [oid.oid_] = oid.scan + +static scan_func_t* oids[256] = { + OID_ENTRY(oid_pid), + OID_ENTRY(oid_battery_percent), + OID_ENTRY(oid_temperature_celsius_x100), + OID_ENTRY(oid_humidity_percent_x100), + OID_ENTRY(oid_pressure_hpa_x100), + OID_ENTRY(oid_illuminance_lux_x100), + OID_ENTRY(oid_mass_kg_x100), + OID_ENTRY(oid_mass_lb_x100), + OID_ENTRY(oid_dewpoint_celsius_x100), + OID_ENTRY(oid_counter8), + OID_ENTRY(oid_energy_kwh_x1000), + OID_ENTRY(oid_power_w_x100), + OID_ENTRY(oid_voltage_v_x1000), + OID_ENTRY(oid_pm2_5_ug_m3), + OID_ENTRY(oid_pm10_ug_m3), + OID_ENTRY(oid_bool), + OID_ENTRY(oid_power), + OID_ENTRY(oid_opening), + OID_ENTRY(oid_co2_concentration_ppm), + OID_ENTRY(oid_tvoc_ug_m3), + OID_ENTRY(oid_moisture_percent_x100), + OID_ENTRY(oid_battery), + OID_ENTRY(oid_battery_charging), + OID_ENTRY(oid_carbon_monoxide), + OID_ENTRY(oid_cold), + OID_ENTRY(oid_connectivity), + OID_ENTRY(oid_door), + OID_ENTRY(oid_garage_door), + OID_ENTRY(oid_gas), + OID_ENTRY(oid_heat), + OID_ENTRY(oid_light), + OID_ENTRY(oid_lock), + OID_ENTRY(oid_moisture), + OID_ENTRY(oid_motion), + OID_ENTRY(oid_moving), + OID_ENTRY(oid_occupancy), + OID_ENTRY(oid_plug), + OID_ENTRY(oid_presence), + OID_ENTRY(oid_problem), + OID_ENTRY(oid_running), + OID_ENTRY(oid_safety), + OID_ENTRY(oid_smoke), + OID_ENTRY(oid_sound), + OID_ENTRY(oid_tamper), + OID_ENTRY(oid_vibration), + OID_ENTRY(oid_window), + [0x2e] = nullptr, + [0x2f] = nullptr, + [0x30] = nullptr, + [0x31] = nullptr, + [0x32] = nullptr, + [0x33] = nullptr, + [0x34] = nullptr, + [0x35] = nullptr, + [0x36] = nullptr, + [0x37] = nullptr, + [0x38] = nullptr, + [0x39] = nullptr, + OID_ENTRY(oid_button_event), + [0x3b] = nullptr, + [0x3c] = nullptr, + OID_ENTRY(oid_counter16), + OID_ENTRY(oid_counter32), + OID_ENTRY(oid_angle_degrees_x10), +}; + +uint32_t read_uint(size_t size, const uint8_t *data) { + if (size < 1 || size > 4) { + ESP_LOGE(TAG, "read_uint() called with invalid size %zu, must be in range [1, 4]", size); + return 0; + } + uint32_t val = 0; + int shift = 0; + for (int i=0; i 4) { + ESP_LOGE(TAG, "read_sint() called with invalid size %zu, must be in range [1, 4]", size); + return 0; + } + bool positive = true; + if (data[size-1] > 127) { + positive = false; + } + + int32_t val = 0; + int shift = (size-1)*8; + for (int i=size-1; i>=0; i--, shift-=8) { + if (positive) { + val |= data[i] << shift; + } else { + val |= (0xff - data[i]) << shift; + } + } + if (!positive) { + val = -val - 1; + } + return val; +} + +// BTHome Device Information +// From https://bthome.io/format/ +// +// The first byte after the UUID is the BTHome device info byte, which has +// several bits indicating the capabilities of the device. +// +// bit 0: “Encryption flag” +// The Encryption flag is telling the receiver wether the device is +// sending non-encrypted data (bit 0 = 0) or encrypted data (bit 0 = 1). +// bit 1: “Reserved for future use” +// bit 2: “Trigger based device flag” +// The trigger based device flag is telling the receiver that it should +// expect that the device is sending BLE advertisements at a regular +// interval (bit 2 = 0) or at an irregular interval (bit 2 = 1), e.g. +// only when someone pushes a button. This can be useful information +// for a receiver, e.g. to prevent the device from going to +// unavailable. +// bit 3-4: “Reserved for future use” +// bit 5-7: “BTHome Version” +// This represents the BTHome verion. Currently only BTHome version 1 +// or 2 are allowed, where 2 is the latest version (bit 5-7 = 010). +// +struct device_information { + bool encryption; + bool trigger_based; + uint8_t bthome_version; + + device_information(uint8_t v) { + encryption = v & 0b000000001; + trigger_based = (v & 0b00000100) >> 2; + bthome_version = (v & 0b11100000) >> 5; + } +}; + +// +// data is a full data received from device. Must be > 9 bytes log. +// mac_address has to be 6 bytes long. +// cleartext has to be at least data_len - 9 bytes long. +int decrypt(const uint8_t *data, ssize_t data_len, const uint8_t *mac_address, uint16_t uuid, const uint8_t *key, ssize_t keybits, uint8_t *cleartext) { + mbedtls_ccm_context ctx; + mbedtls_ccm_init(&ctx); + int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, key, keybits); + if (ret) { + ESP_LOGE(TAG, "mbedtls_ccm_setkey() failed: %04x", ret); + mbedtls_ccm_free(&ctx); + return ret; + } + + // Nonce: + // MAC address 6 bytes + // UUID 2 bytes + // Device data 1 byte + // Counter 4 bytes + uint8_t nonce[] = { + [0x00] = mac_address[0x00], + [0x01] = mac_address[0x01], + [0x02] = mac_address[0x02], + [0x03] = mac_address[0x03], + [0x04] = mac_address[0x04], + [0x05] = mac_address[0x05], + [0x06] = (uint8_t) (uuid & 0xff), + [0x07] = (uint8_t) ((uuid >> 8) & 0xff), + [0x08] = data[0x00], + [0x09] = data[data_len-8], + [0x0A] = data[data_len-7], + [0x0B] = data[data_len-6], + [0x0C] = data[data_len-5], + }; + + uint8_t tag[] = { + [0x00] = data[data_len-4], + [0x01] = data[data_len-3], + [0x02] = data[data_len-2], + [0x03] = data[data_len-1], + }; + + ret = mbedtls_ccm_auth_decrypt(&ctx, data_len-9, nonce, sizeof(nonce)/sizeof(nonce[0]), NULL, 0, data+1, cleartext, tag, sizeof(tag)/sizeof(tag[0])); + if (ret != 0) { + ESP_LOGE(TAG, "mbedtls_ccm_auth_decrypt() failed: %04x", ret); + mbedtls_ccm_free(&ctx); + return ret; + } + + mbedtls_ccm_free(&ctx); + return 0; +} + +void BTHome::dump_config() { + ESP_LOGCONFIG(TAG, "BTHome"); + if (this->encrypted_) { + // ESP_LOGCONFIG(TAG, " Encryption key: %s", format_hex_pretty(this->encryption_key_, 16).c_str()); + ESP_LOGCONFIG(TAG, " Encryption key set"); + } else { + ESP_LOGCONFIG(TAG, " Encryption key not set"); + } +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Window", this->window_); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Illuminance", this->illuminance_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_); +#endif +} + +bool BTHome::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { + if (device.address_uint64() != address_) { + ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); + return false; + } + ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str().c_str()); + + bool success = false; + for (auto &service_data : device.get_service_datas()) { + const auto BTHomeServiceDataUUID = 0xfcd2; + if (service_data.uuid != esp32_ble::ESPBTUUID::from_uint16(BTHomeServiceDataUUID)) { + continue; + } + if (service_data.data.size() < 1) { + ESP_LOGW(TAG, "BTHome service data (UUID %#04x) without data (size %zd)", BTHomeServiceDataUUID, service_data.data.size()); + continue; + } + device_information di(service_data.data[0]); + if (di.bthome_version != 2) { + ESP_LOGW(TAG, "BTHome version %d is not supported (only version 2 is supported)", di.bthome_version); + continue; + } + const uint8_t *data; + size_t data_len; + uint8_t cleartext[service_data.data.size()-9]; + if (di.encryption) { + if (!this->encrypted_) { + ESP_LOGW(TAG, "Encrypted packet but encryption key is not set"); + continue; + } + uint32_t counter = + service_data.data[service_data.data.size()-8] + + ((uint32_t)(service_data.data[service_data.data.size()-7]) << 8) + + ((uint32_t)(service_data.data[service_data.data.size()-6]) << 16) + + ((uint32_t)(service_data.data[service_data.data.size()-5]) << 24); + if (this->last_pid_ != -1 && counter <= this->last_counter_) { + ESP_LOGV(TAG, "Packet counter already seen, skipping"); + continue; + } + this->last_counter_ = counter; + const auto keybits = sizeof(this->encryption_key_) / sizeof(this->encryption_key_[0]) * 8; + int ret = decrypt(service_data.data.data(), service_data.data.size(), device.address(), BTHomeServiceDataUUID, this->encryption_key_, keybits, cleartext); + if (ret) { + ESP_LOGE(TAG, "Could not decrypt packet, error code: %04x", ret); + continue; + } + data = cleartext; + data_len = sizeof(cleartext) / sizeof(cleartext[0]); + } else { + if (this->encrypted_) { + ESP_LOGW(TAG, "Ignoring unencrypted packet when encryption key is set"); + continue; + } + data = service_data.data.data() + 1; + data_len = service_data.data.size() - 1; + } + for (const uint8_t *p = data; p < data + data_len;) { + auto oid = *p++; + ESP_LOGVV(TAG, "OID %#02x", oid); + + if (oid == oid_pid.oid_) { + UInt32Value v = oid_pid.read(p, data + data_len - p); + if (v.value == this->last_pid_) { + ESP_LOGV(TAG, "Packet ID %d already seen, skipping", v.value); + break; + } + this->last_pid_ = v.value; + p = v.next_ptr; + continue; + } + + const uint8_t *next_ptr = this->publish(oid, p, data + data_len - p); + if (next_ptr != nullptr) { + p = next_ptr; + continue; + } + + auto scan = oids[oid]; + if (scan == NULL) { + ESP_LOGW(TAG, "Unknown OID %#02x - parsing aborted", oid); + break; + } + ESP_LOGD(TAG, "Skipping OID %#02x", oid); + ScanResult sr = scan(p, data + data_len - p); + p = sr.next_ptr; + } + success = true; + } + + return success; +} + +const uint8_t *BTHome::publish(uint8_t oid, const uint8_t *data, size_t size) { + for (auto pub : this->publishers_) { + if (pub->oid() == oid) { + return pub->publish(data, size); + } + } + return nullptr; +} + +void BTHome::set_publisher(Publisher *publisher) { + for (int i = 0; i < this->publishers_.size(); i++) { + if (this->publishers_[i]->oid() == publisher->oid()) { + auto old = this->publishers_[i]; + this->publishers_[i] = publisher; + delete old; + return; + } + } + this->publishers_.push_back(publisher); +} + +void BTHome::set_encryption_key(const std::string &encryption_key) { + memset(this->encryption_key_, 0, 16); + if (encryption_key.size() != 32) { + return; + } + char temp[3] = {0}; + for (int i = 0; i < 16; i++) { + strncpy(temp, &(encryption_key.c_str()[i * 2]), 2); + this->encryption_key_[i] = std::strtoul(temp, nullptr, 16); + } + this->encrypted_ = true; +} + +#ifdef USE_BINARY_SENSOR +void BTHome::set_window(binary_sensor::BinarySensor *window) { + this->window_ = window; + this->set_publisher(oid_window.new_publisher(window)); +} +#endif + +#ifdef USE_SENSOR +void BTHome::set_angle(sensor::Sensor *angle) { + this->angle_ = angle; + this->set_publisher(oid_angle_degrees_x10.new_publisher(angle)); +} +void BTHome::set_illuminance(sensor::Sensor *illuminance) { + this->illuminance_ = illuminance; + this->set_publisher(oid_illuminance_lux_x100.new_publisher(illuminance)); +} +void BTHome::set_battery_level(sensor::Sensor *battery_level) { + this->battery_level_ = battery_level; + this->set_publisher(oid_battery_percent.new_publisher(battery_level)); +} +#endif + +} // namespace bthome +} // namespace esphome + +#endif diff --git a/esphome/components/bthome/bthome.h b/esphome/components/bthome/bthome.h new file mode 100644 index 0000000000..c91869d232 --- /dev/null +++ b/esphome/components/bthome/bthome.h @@ -0,0 +1,244 @@ +#pragma once + +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/core/component.h" + +#ifdef USE_ESP32 + +namespace esphome { +namespace bthome { + +uint32_t read_uint(size_t size, const uint8_t *data); +int32_t read_sint(size_t size, const uint8_t *data); + +struct BoolValue { + bool value; + const uint8_t *next_ptr; +}; + +struct UInt32Value { + uint32_t value; + const uint8_t *next_ptr; +}; + +struct FloatValue { + float value; + const uint8_t *next_ptr; +}; + +class Publisher { + public: + Publisher(uint8_t oid): oid_(oid) {} + virtual ~Publisher() {} + uint8_t oid() const { return this->oid_; } + virtual const uint8_t *publish(const uint8_t *data, size_t size) = 0; + + protected: + uint8_t oid_; +}; + +class SensorPublisher: public Publisher { + public: + SensorPublisher(uint8_t oid, FloatValue (*read)(const uint8_t *data, size_t size), sensor::Sensor* sensor): Publisher(oid), sensor_(sensor), read_(read) {} + virtual ~SensorPublisher() {} + + virtual const uint8_t *publish(const uint8_t *data, size_t size) { + FloatValue v = this->read_(data, size); + sensor_->publish_state(v.value); + return v.next_ptr; + } + + protected: + sensor::Sensor *sensor_; + FloatValue (*read_)(const uint8_t *data, size_t size); +}; + +class BinarySensorPublisher: public Publisher { + public: + BinarySensorPublisher(uint8_t oid, BoolValue (*read)(const uint8_t *data, size_t size), binary_sensor::BinarySensor* sensor): Publisher(oid), sensor_(sensor), read_(read) {} + virtual ~BinarySensorPublisher() {} + + virtual const uint8_t *publish(const uint8_t *data, size_t size) { + BoolValue v = this->read_(data, size); + sensor_->publish_state(v.value); + return v.next_ptr; + } + + protected: + binary_sensor::BinarySensor *sensor_; + BoolValue (*read_)(const uint8_t *data, size_t size); +}; + +struct ScanResult { + const uint8_t *value_ptr; + size_t value_size; + const uint8_t *next_ptr; +}; + +typedef ScanResult scan_func_t(const uint8_t *data, size_t size); + +template +class OIDFixedSize { + public: + static ScanResult scan(const uint8_t *data, size_t size) { + if (size < size_bytes) { + return ScanResult { + .value_ptr = nullptr, + .value_size = 0, + .next_ptr = data + size, + }; + } + return ScanResult { + .value_ptr = data, + .value_size = size_bytes, + .next_ptr = data + size_bytes, + }; + } + + static constexpr uint8_t oid_ = {oid}; + static constexpr size_t size_bytes_ = {size_bytes}; +}; + +template +class OIDBool: public OIDFixedSize { + public: + static BoolValue read(const uint8_t *data, size_t size) { + ScanResult sr = OIDBool::scan(data, size); + if (sr.value_ptr == nullptr || sr.value_size == 0) { + return BoolValue { + .value = false, + .next_ptr = sr.next_ptr, + }; + } + return BoolValue { + .value = read_uint(sr.value_size, sr.value_ptr) != 0, + .next_ptr = sr.next_ptr, + }; + } + + BinarySensorPublisher *new_publisher(binary_sensor::BinarySensor *sensor) const { + return new BinarySensorPublisher(oid, OIDBool::read, sensor); + } +}; + +template +class OIDUInt: public OIDFixedSize { + public: + static UInt32Value read(const uint8_t *data, size_t size) { + ScanResult sr = OIDUInt::scan(data, size); + if (sr.value_ptr == nullptr || sr.value_size == 0) { + return UInt32Value { + .value = 0, + .next_ptr = sr.next_ptr, + }; + } + return UInt32Value { + .value = read_uint(sr.value_size, sr.value_ptr), + .next_ptr = sr.next_ptr, + }; + } +}; + +template +class OIDSFixedPoint: public OIDFixedSize { + public: + static FloatValue read(const uint8_t *data, size_t size) { + ScanResult sr = OIDSFixedPoint::scan(data, size); + if (sr.value_ptr == nullptr || sr.value_size == 0) { + return FloatValue { + .value = 0.0, + .next_ptr = sr.next_ptr, + }; + } + return FloatValue { + .value = read_sint(sr.value_size, sr.value_ptr) * f_num / f_denom, + .next_ptr = sr.next_ptr, + }; + } + + static constexpr int f_num_ = {f_num}; + static constexpr int f_denom_ = {f_denom}; +}; + +template +class OIDUFixedPoint: public OIDFixedSize { + public: + static FloatValue read(const uint8_t *data, size_t size) { + ScanResult sr = OIDUFixedPoint::scan(data, size); + if (sr.value_ptr == nullptr || sr.value_size == 0) { + return FloatValue { + .value = 0.0, + .next_ptr = sr.next_ptr, + }; + } + return FloatValue { + .value = static_cast(read_uint(sr.value_size, sr.value_ptr)) * f_num / f_denom, + .next_ptr = sr.next_ptr, + }; + } + + SensorPublisher *new_publisher(sensor::Sensor *sensor) const { + return new SensorPublisher(oid, OIDUFixedPoint::read, sensor); + } + + static constexpr int f_num_ = {f_num}; + static constexpr int f_denom_ = {f_denom}; +}; + +template +class OIDUInt8: public OIDUInt { +}; + +class BTHome : public Component, public esp32_ble_tracker::ESPBTDeviceListener { + public: + void set_address(uint64_t address) { this->address_ = address; }; + void set_encryption_key(const std::string &encryption_key); + + bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + +#ifdef USE_BINARY_SENSOR + void set_window(binary_sensor::BinarySensor *window); +#endif + +#ifdef USE_SENSOR + void set_angle(sensor::Sensor *angle); + void set_illuminance(sensor::Sensor *illuminance); + void set_battery_level(sensor::Sensor *battery_level); +#endif + + protected: + const uint8_t *publish(uint8_t oid, const uint8_t *data, size_t size); + void set_publisher(Publisher *publisher); + + uint64_t address_; + bool encrypted_{false}; + uint8_t encryption_key_[16]; + + int16_t last_pid_{-1}; // -1 indicates that no packets have been received yet. + uint32_t last_counter_{0}; + + std::vector publishers_; + +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *window_{nullptr}; +#endif +#ifdef USE_SENSOR + sensor::Sensor *angle_{nullptr}; + sensor::Sensor *illuminance_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; +#endif +}; + +} // namespace bthome +} // namespace esphome + +#endif diff --git a/esphome/components/bthome/sensor.py b/esphome/components/bthome/sensor.py new file mode 100644 index 0000000000..95e12d5df6 --- /dev/null +++ b/esphome/components/bthome/sensor.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor +from esphome.const import ( + CONF_ANGLE, + CONF_BATTERY_LEVEL, + CONF_ID, + CONF_ILLUMINANCE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ILLUMINANCE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREES, + UNIT_LUX, + UNIT_PERCENT, +) + +from . import BTHome + +DEPENDENCIES = ["bthome"] + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(BTHome), + cv.Optional(CONF_ANGLE): sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=1, + # device_class=DEVICE_CLASS_OPENING, + 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_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=2, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_ID]) + + if CONF_ANGLE in config: + sens = await sensor.new_sensor(config[CONF_ANGLE]) + cg.add(parent.set_angle(sens)) + + if CONF_BATTERY_LEVEL in config: + sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) + cg.add(parent.set_battery_level(sens)) + + if CONF_ILLUMINANCE in config: + sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) + cg.add(parent.set_illuminance(sens))