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 = (