From 09e3bf94eb128c5c62dc29070f73e8d13b127085 Mon Sep 17 00:00:00 2001
From: arcsur <arcsur@users.noreply.github.com>
Date: Tue, 23 Jan 2018 15:51:52 +0800
Subject: [PATCH] BME680 Sensor Component (#11695)

* Adding BME680 Sensor Component

* Flake8 lint fixes

* PyLint fixes

* Fix for log line

* Updating requirements for testing

* Fix PyLint Log format errors and add to coveragerc ommisions as requires sensor connected

* Regenerated requirements_all.txt

* Added Pylint exception for import error of system specific library

* Refactored async_add_platform to move IO out to avoid heavy yield usage
---
 .coveragerc                               |   1 +
 homeassistant/components/sensor/bme680.py | 373 ++++++++++++++++++++++
 requirements_all.txt                      |   4 +
 script/gen_requirements_all.py            |   1 +
 4 files changed, 379 insertions(+)
 create mode 100644 homeassistant/components/sensor/bme680.py

diff --git a/.coveragerc b/.coveragerc
index ef421af6875..de32f6d61a8 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -513,6 +513,7 @@ omit =
     homeassistant/components/sensor/bitcoin.py
     homeassistant/components/sensor/blockchain.py
     homeassistant/components/sensor/bme280.py
+    homeassistant/components/sensor/bme680.py
     homeassistant/components/sensor/bom.py
     homeassistant/components/sensor/broadlink.py
     homeassistant/components/sensor/buienradar.py
diff --git a/homeassistant/components/sensor/bme680.py b/homeassistant/components/sensor/bme680.py
new file mode 100644
index 00000000000..1935367e5d3
--- /dev/null
+++ b/homeassistant/components/sensor/bme680.py
@@ -0,0 +1,373 @@
+"""
+Support for BME680 Sensor over SMBus.
+
+Temperature, humidity, pressure and volitile gas support.
+Air Qaulity calucaltion based on humidity and volatile gas.
+
+"""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+    TEMP_FAHRENHEIT, CONF_NAME, CONF_MONITORED_CONDITIONS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util.temperature import celsius_to_fahrenheit
+
+REQUIREMENTS = ['bme680==1.0.4',
+                'smbus-cffi==0.5.1']
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_I2C_ADDRESS = 'i2c_address'
+CONF_I2C_BUS = 'i2c_bus'
+CONF_OVERSAMPLING_TEMP = 'oversampling_temperature'
+CONF_OVERSAMPLING_PRES = 'oversampling_pressure'
+CONF_OVERSAMPLING_HUM = 'oversampling_humidity'
+CONF_FILTER_SIZE = 'filter_size'
+CONF_GAS_HEATER_TEMP = 'gas_heater_temperature'
+CONF_GAS_HEATER_DURATION = 'gas_heater_duration'
+CONF_AQ_BURN_IN_TIME = 'aq_burn_in_time'
+CONF_AQ_HUM_BASELINE = 'aq_humidity_baseline'
+CONF_AQ_HUM_WEIGHTING = 'aq_humidity_bias'
+
+
+DEFAULT_NAME = 'BME680 Sensor'
+DEFAULT_I2C_ADDRESS = 0x77
+DEFAULT_I2C_BUS = 1
+DEFAULT_OVERSAMPLING_TEMP = 8  # Temperature oversampling x 8
+DEFAULT_OVERSAMPLING_PRES = 4  # Pressure oversampling x 4
+DEFAULT_OVERSAMPLING_HUM = 2  # Humidity oversampling x 2
+DEFAULT_FILTER_SIZE = 3  # IIR Filter Size
+DEFAULT_GAS_HEATER_TEMP = 320  # Temperature in celsius 200 - 400
+DEFAULT_GAS_HEATER_DURATION = 150  # Heater duration in ms 1 - 4032
+DEFAULT_AQ_BURN_IN_TIME = 300  # 300 second burn in time for AQ gas measurement
+DEFAULT_AQ_HUM_BASELINE = 40  # 40%, an optimal indoor humidity.
+DEFAULT_AQ_HUM_WEIGHTING = 25  # 25% Weighting of humidity to gas in AQ score
+
+SENSOR_TEMP = 'temperature'
+SENSOR_HUMID = 'humidity'
+SENSOR_PRESS = 'pressure'
+SENSOR_GAS = 'gas'
+SENSOR_AQ = 'airquality'
+SENSOR_TYPES = {
+    SENSOR_TEMP: ['Temperature', None],
+    SENSOR_HUMID: ['Humidity', '%'],
+    SENSOR_PRESS: ['Pressure', 'mb'],
+    SENSOR_GAS: ['Gas Resistance', 'Ohms'],
+    SENSOR_AQ: ['Air Quality', '%']
+}
+DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ]
+OVERSAMPLING_VALUES = set([0, 1, 2, 4, 8, 16])
+FILTER_VALUES = set([0, 1, 3, 7, 15, 31, 63, 127])
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+    vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string,
+    vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED):
+        vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+    vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): cv.positive_int,
+    vol.Optional(CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP):
+        vol.All(vol.Coerce(int), vol.In(OVERSAMPLING_VALUES)),
+    vol.Optional(CONF_OVERSAMPLING_PRES, default=DEFAULT_OVERSAMPLING_PRES):
+        vol.All(vol.Coerce(int), vol.In(OVERSAMPLING_VALUES)),
+    vol.Optional(CONF_OVERSAMPLING_HUM, default=DEFAULT_OVERSAMPLING_HUM):
+        vol.All(vol.Coerce(int), vol.In(OVERSAMPLING_VALUES)),
+    vol.Optional(CONF_FILTER_SIZE, default=DEFAULT_FILTER_SIZE):
+        vol.All(vol.Coerce(int), vol.In(FILTER_VALUES)),
+    vol.Optional(CONF_GAS_HEATER_TEMP, default=DEFAULT_GAS_HEATER_TEMP):
+        vol.All(vol.Coerce(int), vol.Range(200, 400)),
+    vol.Optional(CONF_GAS_HEATER_DURATION,
+                 default=DEFAULT_GAS_HEATER_DURATION):
+        vol.All(vol.Coerce(int), vol.Range(1, 4032)),
+    vol.Optional(CONF_AQ_BURN_IN_TIME, default=DEFAULT_AQ_BURN_IN_TIME):
+        cv.positive_int,
+    vol.Optional(CONF_AQ_HUM_BASELINE, default=DEFAULT_AQ_HUM_BASELINE):
+        vol.All(vol.Coerce(int), vol.Range(1, 100)),
+    vol.Optional(CONF_AQ_HUM_WEIGHTING, default=DEFAULT_AQ_HUM_WEIGHTING):
+        vol.All(vol.Coerce(int), vol.Range(1, 100)),
+})
+
+
+@asyncio.coroutine
+def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
+    """Set up the BME680 sensor."""
+    SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit
+    name = config.get(CONF_NAME)
+
+    sensor_handler = yield from hass.async_add_job(_setup_bme680, config)
+    if sensor_handler is None:
+        return False
+
+    dev = []
+    try:
+        for variable in config[CONF_MONITORED_CONDITIONS]:
+            dev.append(BME680Sensor(
+                sensor_handler, variable, SENSOR_TYPES[variable][1], name))
+    except KeyError:
+        pass
+
+    async_add_devices(dev)
+    return True
+
+
+# pylint: disable=import-error
+def _setup_bme680(config):
+    """Set up and configure the BME680 sensor."""
+    from smbus import SMBus
+    import bme680
+    from time import sleep
+
+    sensor_handler = None
+    sensor = None
+    try:
+        i2c_address = config.get(CONF_I2C_ADDRESS)
+        bus = SMBus(config.get(CONF_I2C_BUS))
+        sensor = bme680.BME680(i2c_address, bus)
+
+        # Configure Oversampling
+        os_lookup = {
+            0: bme680.OS_NONE,
+            1: bme680.OS_1X,
+            2: bme680.OS_2X,
+            4: bme680.OS_4X,
+            8: bme680.OS_8X,
+            16: bme680.OS_16X
+        }
+        sensor.set_temperature_oversample(
+            os_lookup[config.get(CONF_OVERSAMPLING_TEMP)]
+        )
+        sensor.set_humidity_oversample(
+            os_lookup[config.get(CONF_OVERSAMPLING_HUM)]
+        )
+        sensor.set_pressure_oversample(
+            os_lookup[config.get(CONF_OVERSAMPLING_PRES)]
+        )
+
+        # Configure IIR Filter
+        filter_lookup = {
+            0: bme680.FILTER_SIZE_0,
+            1: bme680.FILTER_SIZE_1,
+            3: bme680.FILTER_SIZE_3,
+            7: bme680.FILTER_SIZE_7,
+            15: bme680.FILTER_SIZE_15,
+            31: bme680.FILTER_SIZE_31,
+            63: bme680.FILTER_SIZE_63,
+            127: bme680.FILTER_SIZE_127
+        }
+        sensor.set_filter(
+            filter_lookup[config.get(CONF_FILTER_SIZE)]
+        )
+
+        # Configure the Gas Heater
+        if (
+                SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] or
+                SENSOR_AQ in config[CONF_MONITORED_CONDITIONS]
+        ):
+            sensor.set_gas_status(bme680.ENABLE_GAS_MEAS)
+            sensor.set_gas_heater_duration(config[CONF_GAS_HEATER_DURATION])
+            sensor.set_gas_heater_temperature(config[CONF_GAS_HEATER_TEMP])
+            sensor.select_gas_heater_profile(0)
+        else:
+            sensor.set_gas_status(bme680.DISABLE_GAS_MEAS)
+    except (RuntimeError, IOError):
+        _LOGGER.error("BME680 sensor not detected at %s", i2c_address)
+        return None
+
+    sensor_handler = BME680Handler(
+        sensor,
+        True if (
+            SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] or
+            SENSOR_AQ in config[CONF_MONITORED_CONDITIONS]
+        ) else False,
+        config[CONF_AQ_BURN_IN_TIME],
+        config[CONF_AQ_HUM_BASELINE],
+        config[CONF_AQ_HUM_WEIGHTING]
+    )
+    sleep(0.5)  # Wait for device to stabilize
+    if not sensor_handler.sensor_data.temperature:
+        _LOGGER.error("BME680 sensor failed to Initialize")
+        return None
+
+    return sensor_handler
+
+
+class BME680Handler:
+    """BME680 sensor working in i2C bus."""
+
+    class SensorData:
+        """Sensor data representation."""
+
+        def __init__(self):
+            """Initialize the sensor data object."""
+            self.temperature = None
+            self.humidity = None
+            self.pressure = None
+            self.gas_resistance = None
+            self.air_quality = None
+
+    def __init__(
+            self, sensor, gas_measurement=False,
+            burn_in_time=300, hum_baseline=40, hum_weighting=25
+    ):
+        """Initialize the sensor handler."""
+        self.sensor_data = BME680Handler.SensorData()
+        self._sensor = sensor
+        self._gas_sensor_running = False
+        self._hum_baseline = hum_baseline
+        self._hum_weighting = hum_weighting
+        self._gas_baseline = None
+
+        if gas_measurement:
+            import threading
+            threading.Thread(
+                target=self._run_gas_sensor,
+                kwargs={'burn_in_time': burn_in_time},
+                name='BME680Handler_run_gas_sensor'
+            ).start()
+        self.update(first_read=True)
+
+    def _run_gas_sensor(self, burn_in_time):
+        """Calibrate the Air Quality Gas Baseline."""
+        if not self._gas_sensor_running:
+            self._gas_sensor_running = True
+            import time
+
+            # Pause to allow inital data read for device validation.
+            time.sleep(1)
+
+            start_time = time.time()
+            curr_time = time.time()
+            burn_in_data = []
+
+            _LOGGER.info(("Beginning %d second gas sensor burn in for "
+                          "Air Quality"), burn_in_time)
+            while curr_time - start_time < burn_in_time:
+                curr_time = time.time()
+                if (
+                        self._sensor.get_sensor_data() and
+                        self._sensor.data.heat_stable
+                ):
+                    gas_resistance = self._sensor.data.gas_resistance
+                    burn_in_data.append(gas_resistance)
+                    self.sensor_data.gas_resistance = gas_resistance
+                    _LOGGER.debug(("AQ Gas Resistance Baseline reading %2f "
+                                   "Ohms"), gas_resistance)
+                    time.sleep(1)
+
+            _LOGGER.debug(("AQ Gas Resistance Burn In Data (Size: %d): "
+                           "\n\t%s"), len(burn_in_data), burn_in_data)
+            self._gas_baseline = sum(burn_in_data[-50:]) / 50.0
+            _LOGGER.info("Completed gas sensor burn in for Air Quality")
+            _LOGGER.info("AQ Gas Resistance Baseline: %f", self._gas_baseline)
+            while True:
+                if (
+                        self._sensor.get_sensor_data() and
+                        self._sensor.data.heat_stable
+                ):
+                    self.sensor_data.gas_resistance = (
+                        self._sensor.data.gas_resistance
+                    )
+                    self.sensor_data.air_quality = self._calculate_aq_score()
+                    time.sleep(1)
+        else:
+            return
+
+    def update(self, first_read=False):
+        """Read sensor data."""
+        if first_read:
+            # Attempt first read, it almost always fails first attempt
+            self._sensor.get_sensor_data()
+        if self._sensor.get_sensor_data():
+            self.sensor_data.temperature = self._sensor.data.temperature
+            self.sensor_data.humidity = self._sensor.data.humidity
+            self.sensor_data.pressure = self._sensor.data.pressure
+
+    def _calculate_aq_score(self):
+        """Calculate the Air Quality Score."""
+        hum_baseline = self._hum_baseline
+        hum_weighting = self._hum_weighting
+        gas_baseline = self._gas_baseline
+
+        gas_resistance = self.sensor_data.gas_resistance
+        gas_offset = gas_baseline - gas_resistance
+
+        hum = self.sensor_data.humidity
+        hum_offset = hum - hum_baseline
+
+        # Calculate hum_score as the distance from the hum_baseline.
+        if hum_offset > 0:
+            hum_score = (
+                (100 - hum_baseline - hum_offset) /
+                (100 - hum_baseline) *
+                hum_weighting
+            )
+        else:
+            hum_score = (
+                (hum_baseline + hum_offset) /
+                hum_baseline *
+                hum_weighting
+            )
+
+        # Calculate gas_score as the distance from the gas_baseline.
+        if gas_offset > 0:
+            gas_score = (gas_resistance / gas_baseline) * (100 - hum_weighting)
+        else:
+            gas_score = 100 - hum_weighting
+
+        # Calculate air quality score.
+        return hum_score + gas_score
+
+
+class BME680Sensor(Entity):
+    """Implementation of the BME680 sensor."""
+
+    def __init__(self, bme680_client, sensor_type, temp_unit, name):
+        """Initialize the sensor."""
+        self.client_name = name
+        self._name = SENSOR_TYPES[sensor_type][0]
+        self.bme680_client = bme680_client
+        self.temp_unit = temp_unit
+        self.type = sensor_type
+        self._state = None
+        self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+    @property
+    def name(self):
+        """Return the name of the sensor."""
+        return '{} {}'.format(self.client_name, self._name)
+
+    @property
+    def state(self):
+        """Return the state of the sensor."""
+        return self._state
+
+    @property
+    def unit_of_measurement(self):
+        """Return the unit of measurement of the sensor."""
+        return self._unit_of_measurement
+
+    @asyncio.coroutine
+    def async_update(self):
+        """Get the latest data from the BME680 and update the states."""
+        yield from self.hass.async_add_job(self.bme680_client.update)
+        if self.type == SENSOR_TEMP:
+            temperature = round(self.bme680_client.sensor_data.temperature, 1)
+            if self.temp_unit == TEMP_FAHRENHEIT:
+                temperature = round(celsius_to_fahrenheit(temperature), 1)
+            self._state = temperature
+        elif self.type == SENSOR_HUMID:
+            self._state = round(self.bme680_client.sensor_data.humidity, 1)
+        elif self.type == SENSOR_PRESS:
+            self._state = round(self.bme680_client.sensor_data.pressure, 1)
+        elif self.type == SENSOR_GAS:
+            self._state = int(
+                round(self.bme680_client.sensor_data.gas_resistance, 0)
+            )
+        elif self.type == SENSOR_AQ:
+            aq_score = self.bme680_client.sensor_data.air_quality
+            if aq_score is not None:
+                self._state = round(aq_score, 1)
diff --git a/requirements_all.txt b/requirements_all.txt
index fca1e88cc6f..0a42ea57372 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -143,6 +143,9 @@ blockchain==1.4.0
 # homeassistant.components.light.decora
 # bluepy==1.1.4
 
+# homeassistant.components.sensor.bme680
+# bme680==1.0.4
+
 # homeassistant.components.notify.aws_lambda
 # homeassistant.components.notify.aws_sns
 # homeassistant.components.notify.aws_sqs
@@ -1086,6 +1089,7 @@ sleepyq==0.6
 # homeassistant.components.raspihats
 # homeassistant.components.sensor.bh1750
 # homeassistant.components.sensor.bme280
+# homeassistant.components.sensor.bme680
 # homeassistant.components.sensor.envirophat
 # homeassistant.components.sensor.htu21d
 # smbus-cffi==0.5.1
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 5f4d789fa77..48d19049316 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -32,6 +32,7 @@ COMMENT_REQUIREMENTS = (
     'i2csense',
     'credstash',
     'pytradfri',
+    'bme680',
 )
 
 TEST_REQUIREMENTS = (
-- 
GitLab