From 1d2e0f74ead0aadd9654d73039ee5ef4dd4af708 Mon Sep 17 00:00:00 2001 From: Sean Brogan Date: Mon, 28 Feb 2022 14:30:33 -0800 Subject: [PATCH] Add Mopeka BLE and Mopeka Pro Check BLE Sensor (#2618) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 2 + esphome/components/mopeka_ble/__init__.py | 23 +++ esphome/components/mopeka_ble/mopeka_ble.cpp | 50 +++++++ esphome/components/mopeka_ble/mopeka_ble.h | 22 +++ .../components/mopeka_pro_check/__init__.py | 1 + .../mopeka_pro_check/mopeka_pro_check.cpp | 136 ++++++++++++++++++ .../mopeka_pro_check/mopeka_pro_check.h | 58 ++++++++ esphome/components/mopeka_pro_check/sensor.py | 131 +++++++++++++++++ tests/test2.yaml | 14 ++ 9 files changed, 437 insertions(+) create mode 100644 esphome/components/mopeka_ble/__init__.py create mode 100644 esphome/components/mopeka_ble/mopeka_ble.cpp create mode 100644 esphome/components/mopeka_ble/mopeka_ble.h create mode 100644 esphome/components/mopeka_pro_check/__init__.py create mode 100644 esphome/components/mopeka_pro_check/mopeka_pro_check.cpp create mode 100644 esphome/components/mopeka_pro_check/mopeka_pro_check.h create mode 100644 esphome/components/mopeka_pro_check/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 6e3801d1f3..c111fa7816 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -126,6 +126,8 @@ esphome/components/modbus_controller/select/* @martgras @stegm esphome/components/modbus_controller/sensor/* @martgras esphome/components/modbus_controller/switch/* @martgras esphome/components/modbus_controller/text_sensor/* @martgras +esphome/components/mopeka_ble/* @spbrogan +esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mpu6886/* @fabaff esphome/components/network/* @esphome/core esphome/components/nextion/* @senexcrenshaw diff --git a/esphome/components/mopeka_ble/__init__.py b/esphome/components/mopeka_ble/__init__.py new file mode 100644 index 0000000000..47396435a8 --- /dev/null +++ b/esphome/components/mopeka_ble/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import esp32_ble_tracker +from esphome.const import CONF_ID + +CODEOWNERS = ["@spbrogan"] +DEPENDENCIES = ["esp32_ble_tracker"] + +mopeka_ble_ns = cg.esphome_ns.namespace("mopeka_ble") +MopekaListener = mopeka_ble_ns.class_( + "MopekaListener", esp32_ble_tracker.ESPBTDeviceListener +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MopekaListener), + } +).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await esp32_ble_tracker.register_ble_device(var, config) diff --git a/esphome/components/mopeka_ble/mopeka_ble.cpp b/esphome/components/mopeka_ble/mopeka_ble.cpp new file mode 100644 index 0000000000..844d3a7dfd --- /dev/null +++ b/esphome/components/mopeka_ble/mopeka_ble.cpp @@ -0,0 +1,50 @@ +#include "mopeka_ble.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +namespace esphome { +namespace mopeka_ble { + +static const char *const TAG = "mopeka_ble"; +static const uint8_t MANUFACTURER_DATA_LENGTH = 10; +static const uint16_t MANUFACTURER_ID = 0x0059; + +/** + * Parse all incoming BLE payloads to see if it is a Mopeka BLE advertisement. + * Currently this supports the following products: + * + * Mopeka Pro Check. + * If the sync button is pressed, report the MAC so a user can add this as a sensor. + */ + +bool MopekaListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { + const auto &manu_datas = device.get_manufacturer_datas(); + + if (manu_datas.size() != 1) { + return false; + } + + const auto &manu_data = manu_datas[0]; + + if (manu_data.data.size() != MANUFACTURER_DATA_LENGTH) { + return false; + } + + if (manu_data.uuid != esp32_ble_tracker::ESPBTUUID::from_uint16(MANUFACTURER_ID)) { + return false; + } + + if (this->parse_sync_button_(manu_data.data)) { + // button pressed + ESP_LOGI(TAG, "SENSOR FOUND: %s", device.address_str().c_str()); + } + return false; +} + +bool MopekaListener::parse_sync_button_(const std::vector &message) { return (message[2] & 0x80) != 0; } + +} // namespace mopeka_ble +} // namespace esphome + +#endif diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h new file mode 100644 index 0000000000..7b797a3bbe --- /dev/null +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" + +#ifdef USE_ESP32 + +namespace esphome { +namespace mopeka_ble { + +class MopekaListener : public esp32_ble_tracker::ESPBTDeviceListener { + public: + bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + + protected: + bool parse_sync_button_(const std::vector &message); +}; + +} // namespace mopeka_ble +} // namespace esphome + +#endif diff --git a/esphome/components/mopeka_pro_check/__init__.py b/esphome/components/mopeka_pro_check/__init__.py new file mode 100644 index 0000000000..c57f60f521 --- /dev/null +++ b/esphome/components/mopeka_pro_check/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@spbrogan"] diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp new file mode 100644 index 0000000000..bcfe0a80ce --- /dev/null +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -0,0 +1,136 @@ +#include "mopeka_pro_check.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +namespace esphome { +namespace mopeka_pro_check { + +static const char *const TAG = "mopeka_pro_check"; +static const uint8_t MANUFACTURER_DATA_LENGTH = 10; +static const uint16_t MANUFACTURER_ID = 0x0059; +static const double MOPEKA_LPG_COEF[] = {0.573045, -0.002822, -0.00000535}; // Magic numbers provided by Mopeka + +void MopekaProCheck::dump_config() { + ESP_LOGCONFIG(TAG, "Mopeka Pro Check"); + LOG_SENSOR(" ", "Level", this->level_); + LOG_SENSOR(" ", "Temperature", this->temperature_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_); + LOG_SENSOR(" ", "Reading Distance", this->distance_); +} + +/** + * Main parse function that gets called for all ble advertisements. + * Check if advertisement is for our sensor and if so decode it and + * update the sensor state data. + */ +bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) { + return false; + } + + ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str().c_str()); + + const auto &manu_datas = device.get_manufacturer_datas(); + + if (manu_datas.size() != 1) { + ESP_LOGE(TAG, "Unexpected manu_datas size (%d)", manu_datas.size()); + return false; + } + + const auto &manu_data = manu_datas[0]; + + ESP_LOGVV(TAG, "Manufacturer data:"); + for (const uint8_t byte : manu_data.data) { + ESP_LOGVV(TAG, "0x%02x", byte); + } + + if (manu_data.data.size() != MANUFACTURER_DATA_LENGTH) { + ESP_LOGE(TAG, "Unexpected manu_data size (%d)", manu_data.data.size()); + return false; + } + + // Now parse the data - See Datasheet for definition + + if (static_cast(manu_data.data[0]) != STANDARD_BOTTOM_UP) { + ESP_LOGE(TAG, "Unsupported Sensor Type (0x%X)", manu_data.data[0]); + return false; + } + + // Get battery level first + if (this->battery_level_ != nullptr) { + uint8_t level = this->parse_battery_level_(manu_data.data); + this->battery_level_->publish_state(level); + } + + // Get distance and level if either are sensors + if ((this->distance_ != nullptr) || (this->level_ != nullptr)) { + uint32_t distance_value = this->parse_distance_(manu_data.data); + SensorReadQuality quality_value = this->parse_read_quality_(manu_data.data); + ESP_LOGD(TAG, "Distance Sensor: Quality (0x%X) Distance (%dmm)", quality_value, distance_value); + if (quality_value < QUALITY_HIGH) { + ESP_LOGW(TAG, "Poor read quality."); + } + if (quality_value < QUALITY_MED) { + // if really bad reading set to 0 + ESP_LOGW(TAG, "Setting distance to 0"); + distance_value = 0; + } + + // update distance sensor + if (this->distance_ != nullptr) { + this->distance_->publish_state(distance_value); + } + + // update level sensor + if (this->level_ != nullptr) { + uint8_t tank_level = 0; + if (distance_value >= this->full_mm_) { + tank_level = 100; // cap at 100% + } else if (distance_value > this->empty_mm_) { + tank_level = ((100.0f / (this->full_mm_ - this->empty_mm_)) * (distance_value - this->empty_mm_)); + } + this->level_->publish_state(tank_level); + } + } + + // Get temperature of sensor + if (this->temperature_ != nullptr) { + uint8_t temp_in_c = this->parse_temperature_(manu_data.data); + this->temperature_->publish_state(temp_in_c); + } + + return true; +} + +uint8_t MopekaProCheck::parse_battery_level_(const std::vector &message) { + float v = (float) ((message[1] & 0x7F) / 32.0f); + // convert voltage and scale for CR2032 + float percent = (v - 2.2f) / 0.65f * 100.0f; + if (percent < 0.0f) { + return 0; + } + if (percent > 100.0f) { + return 100; + } + return (uint8_t) percent; +} + +uint32_t MopekaProCheck::parse_distance_(const std::vector &message) { + uint16_t raw = (message[4] * 256) + message[3]; + double raw_level = raw & 0x3FFF; + double raw_t = (message[2] & 0x7F); + + return (uint32_t)(raw_level * (MOPEKA_LPG_COEF[0] + MOPEKA_LPG_COEF[1] * raw_t + MOPEKA_LPG_COEF[2] * raw_t * raw_t)); +} + +uint8_t MopekaProCheck::parse_temperature_(const std::vector &message) { return (message[2] & 0x7F) - 40; } + +SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector &message) { + return static_cast(message[4] >> 6); +} + +} // namespace mopeka_pro_check +} // namespace esphome + +#endif diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h new file mode 100644 index 0000000000..59d33f7763 --- /dev/null +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -0,0 +1,58 @@ +#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 { +namespace mopeka_pro_check { + +enum SensorType { + STANDARD_BOTTOM_UP = 0x03, + TOP_DOWN_AIR_ABOVE = 0x04, + BOTTOM_UP_WATER = 0x05 + // all other values are reserved +}; + +// Sensor read quality. If sensor is poorly placed or tank level +// gets too low the read quality will show and the distanace +// measurement may be inaccurate. +enum SensorReadQuality { QUALITY_HIGH = 0x3, QUALITY_MED = 0x2, QUALITY_LOW = 0x1, QUALITY_NONE = 0x0 }; + +class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { + public: + void set_address(uint64_t address) { address_ = address; }; + + bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_level(sensor::Sensor *level) { level_ = level; }; + void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; }; + void set_battery_level(sensor::Sensor *bat) { battery_level_ = bat; }; + void set_distance(sensor::Sensor *distance) { distance_ = distance; }; + void set_tank_full(float full) { full_mm_ = full; }; + void set_tank_empty(float empty) { empty_mm_ = empty; }; + + protected: + uint64_t address_; + sensor::Sensor *level_{nullptr}; + sensor::Sensor *temperature_{nullptr}; + sensor::Sensor *distance_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; + + uint32_t full_mm_; + uint32_t empty_mm_; + + uint8_t parse_battery_level_(const std::vector &message); + uint32_t parse_distance_(const std::vector &message); + uint8_t parse_temperature_(const std::vector &message); + SensorReadQuality parse_read_quality_(const std::vector &message); +}; + +} // namespace mopeka_pro_check +} // namespace esphome + +#endif diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py new file mode 100644 index 0000000000..4cd90227ab --- /dev/null +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -0,0 +1,131 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor, esp32_ble_tracker +from esphome.const import ( + CONF_DISTANCE, + CONF_MAC_ADDRESS, + CONF_ID, + ICON_THERMOMETER, + ICON_RULER, + UNIT_PERCENT, + CONF_LEVEL, + CONF_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE, + UNIT_CELSIUS, + STATE_CLASS_MEASUREMENT, + CONF_BATTERY_LEVEL, + DEVICE_CLASS_BATTERY, +) + +CONF_TANK_TYPE = "tank_type" +CONF_CUSTOM_DISTANCE_FULL = "custom_distance_full" +CONF_CUSTOM_DISTANCE_EMPTY = "custom_distance_empty" + +ICON_PROPANE_TANK = "mdi:propane-tank" + +TANK_TYPE_CUSTOM = "CUSTOM" + +UNIT_MILLIMETER = "mm" + + +def small_distance(value): + """small_distance is stored in mm""" + meters = cv.distance(value) + return meters * 1000 + + +# +# Map of standard tank types to their +# empty and full distance values. +# Format is - tank name: (empty distance in mm, full distance in mm) +# +CONF_SUPPORTED_TANKS_MAP = { + TANK_TYPE_CUSTOM: (0, 100), + "20LB_V": (38, 254), # empty/full readings for 20lb US tank + "30LB_V": (38, 381), + "40LB_V": (38, 508), +} + +CODEOWNERS = ["@spbrogan"] +DEPENDENCIES = ["esp32_ble_tracker"] + +mopeka_pro_check_ns = cg.esphome_ns.namespace("mopeka_pro_check") +MopekaProCheck = mopeka_pro_check_ns.class_( + "MopekaProCheck", esp32_ble_tracker.ESPBTDeviceListener, cg.Component +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MopekaProCheck), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_CUSTOM_DISTANCE_FULL): small_distance, + cv.Optional(CONF_CUSTOM_DISTANCE_EMPTY): small_distance, + cv.Required(CONF_TANK_TYPE): cv.enum(CONF_SUPPORTED_TANKS_MAP, upper=True), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PROPANE_TANK, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER, + icon=ICON_RULER, + accuracy_decimals=0, + 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, + ), + } + ) + .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 config[CONF_TANK_TYPE] == TANK_TYPE_CUSTOM: + # Support custom tank min/max + if CONF_CUSTOM_DISTANCE_EMPTY in config: + cg.add(var.set_tank_empty(config[CONF_CUSTOM_DISTANCE_EMPTY])) + else: + cg.add(var.set_tank_empty(CONF_SUPPORTED_TANKS_MAP[TANK_TYPE_CUSTOM][0])) + if CONF_CUSTOM_DISTANCE_FULL in config: + cg.add(var.set_tank_full(config[CONF_CUSTOM_DISTANCE_FULL])) + else: + cg.add(var.set_tank_full(CONF_SUPPORTED_TANKS_MAP[TANK_TYPE_CUSTOM][1])) + else: + # Set the Tank empty and full based on map - User is requesting standard tank + t = config[CONF_TANK_TYPE] + cg.add(var.set_tank_empty(CONF_SUPPORTED_TANKS_MAP[t][0])) + cg.add(var.set_tank_full(CONF_SUPPORTED_TANKS_MAP[t][1])) + + if CONF_TEMPERATURE in config: + sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) + cg.add(var.set_temperature(sens)) + if CONF_LEVEL in config: + sens = await sensor.new_sensor(config[CONF_LEVEL]) + cg.add(var.set_level(sens)) + if CONF_DISTANCE in config: + sens = await sensor.new_sensor(config[CONF_DISTANCE]) + cg.add(var.set_distance(sens)) + if CONF_BATTERY_LEVEL in config: + sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) + cg.add(var.set_battery_level(sens)) diff --git a/tests/test2.yaml b/tests/test2.yaml index 76b9775c54..ec3ccff70c 100644 --- a/tests/test2.yaml +++ b/tests/test2.yaml @@ -331,6 +331,19 @@ sensor: name: "RD200 Radon" radon_long_term: name: "RD200 Radon Long Term" + - platform: mopeka_pro_check + mac_address: D3:75:F2:DC:16:91 + tank_type: CUSTOM + custom_distance_full: 40cm + custom_distance_empty: 10mm + temperature: + name: "Propane test temp" + level: + name: "Propane test level" + distance: + name: "Propane test distance" + battery_level: + name: "Propane test battery level" time: - platform: homeassistant @@ -442,6 +455,7 @@ ruuvi_ble: xiaomi_ble: +mopeka_ble: #esp32_ble_beacon: # type: iBeacon