Add Mopeka BLE and Mopeka Pro Check BLE Sensor (#2618)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
Sean Brogan 2022-02-28 14:30:33 -08:00 committed by GitHub
parent bf60e40d0b
commit 1d2e0f74ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 437 additions and 0 deletions

View file

@ -126,6 +126,8 @@ esphome/components/modbus_controller/select/* @martgras @stegm
esphome/components/modbus_controller/sensor/* @martgras esphome/components/modbus_controller/sensor/* @martgras
esphome/components/modbus_controller/switch/* @martgras esphome/components/modbus_controller/switch/* @martgras
esphome/components/modbus_controller/text_sensor/* @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/mpu6886/* @fabaff
esphome/components/network/* @esphome/core esphome/components/network/* @esphome/core
esphome/components/nextion/* @senexcrenshaw esphome/components/nextion/* @senexcrenshaw

View file

@ -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)

View file

@ -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<uint8_t> &message) { return (message[2] & 0x80) != 0; }
} // namespace mopeka_ble
} // namespace esphome
#endif

View file

@ -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<uint8_t> &message);
};
} // namespace mopeka_ble
} // namespace esphome
#endif

View file

@ -0,0 +1 @@
CODEOWNERS = ["@spbrogan"]

View file

@ -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<SensorType>(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<uint8_t> &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<uint8_t> &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<uint8_t> &message) { return (message[2] & 0x7F) - 40; }
SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector<uint8_t> &message) {
return static_cast<SensorReadQuality>(message[4] >> 6);
}
} // namespace mopeka_pro_check
} // namespace esphome
#endif

View file

@ -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<uint8_t> &message);
uint32_t parse_distance_(const std::vector<uint8_t> &message);
uint8_t parse_temperature_(const std::vector<uint8_t> &message);
SensorReadQuality parse_read_quality_(const std::vector<uint8_t> &message);
};
} // namespace mopeka_pro_check
} // namespace esphome
#endif

View file

@ -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))

View file

@ -331,6 +331,19 @@ sensor:
name: "RD200 Radon" name: "RD200 Radon"
radon_long_term: radon_long_term:
name: "RD200 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: time:
- platform: homeassistant - platform: homeassistant
@ -442,6 +455,7 @@ ruuvi_ble:
xiaomi_ble: xiaomi_ble:
mopeka_ble:
#esp32_ble_beacon: #esp32_ble_beacon:
# type: iBeacon # type: iBeacon