Support CO2/PM2.5/Light sensors in HomeKit (#13804)

* Support co2/light/air sensor in HomeKit
* Add tests
* Added tests
* changed device_class lux to light
pull/13836/head
Yonsm 2018-04-12 21:01:41 +08:00 committed by cdce8p
parent f47572d3c0
commit c863b9614c
7 changed files with 318 additions and 47 deletions

View File

@ -11,7 +11,7 @@ import voluptuous as vol
from homeassistant.components.cover import SUPPORT_SET_POSITION
from homeassistant.const import (
ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT,
CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT,
ATTR_DEVICE_CLASS, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import FILTER_SCHEMA
@ -19,7 +19,9 @@ from homeassistant.util import get_local_ip
from homeassistant.util.decorator import Registry
from .const import (
DOMAIN, HOMEKIT_FILE, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FILTER,
DEFAULT_PORT, DEFAULT_AUTO_START, SERVICE_HOMEKIT_START)
DEFAULT_PORT, DEFAULT_AUTO_START, SERVICE_HOMEKIT_START,
DEVICE_CLASS_CO2, DEVICE_CLASS_LIGHT, DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE)
from .util import (
validate_entity_config, show_setup_message)
@ -103,10 +105,22 @@ def get_accessory(hass, state, aid, config):
elif state.domain == 'sensor':
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if unit == TEMP_CELSIUS or unit == TEMP_FAHRENHEIT:
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
if device_class == DEVICE_CLASS_TEMPERATURE or unit == TEMP_CELSIUS \
or unit == TEMP_FAHRENHEIT:
a_type = 'TemperatureSensor'
elif unit == '%':
elif device_class == DEVICE_CLASS_HUMIDITY or unit == '%':
a_type = 'HumiditySensor'
elif device_class == DEVICE_CLASS_PM25 \
or DEVICE_CLASS_PM25 in state.entity_id:
a_type = 'AirQualitySensor'
elif device_class == DEVICE_CLASS_CO2 \
or DEVICE_CLASS_CO2 in state.entity_id:
a_type = 'CarbonDioxideSensor'
elif device_class == DEVICE_CLASS_LIGHT or unit == 'lm' or \
unit == 'lux':
a_type = 'LightSensor'
elif state.domain == 'switch' or state.domain == 'remote' \
or state.domain == 'input_boolean' or state.domain == 'script':

View File

@ -34,13 +34,13 @@ CATEGORY_WINDOW_COVERING = 'WINDOW_COVERING'
# #### Services ####
SERV_ACCESSORY_INFO = 'AccessoryInformation'
SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor'
SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor'
SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor'
SERV_CONTACT_SENSOR = 'ContactSensor'
SERV_HUMIDITY_SENSOR = 'HumiditySensor'
# CurrentRelativeHumidity | StatusActive, StatusFault, StatusTampered,
# StatusLowBattery, Name
SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity
SERV_LEAK_SENSOR = 'LeakSensor'
SERV_LIGHT_SENSOR = 'LightSensor'
SERV_LIGHTBULB = 'Lightbulb' # On | Brightness, Hue, Saturation, Name
SERV_LOCK = 'LockMechanism'
SERV_MOTION_SENSOR = 'MotionSensor'
@ -50,17 +50,21 @@ SERV_SMOKE_SENSOR = 'SmokeSensor'
SERV_SWITCH = 'Switch'
SERV_TEMPERATURE_SENSOR = 'TemperatureSensor'
SERV_THERMOSTAT = 'Thermostat'
SERV_WINDOW_COVERING = 'WindowCovering'
# CurrentPosition, TargetPosition, PositionState
SERV_WINDOW_COVERING = 'WindowCovering' # CurrentPosition, TargetPosition
# #### Characteristics ####
CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity'
CHAR_AIR_QUALITY = 'AirQuality'
CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100]
CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected'
CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel'
CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel'
CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected'
CHAR_COLOR_TEMPERATURE = 'ColorTemperature'
CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState'
CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature'
CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel'
CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState'
CHAR_CURRENT_POSITION = 'CurrentPosition' # Int | [0, 100]
CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' # percent
@ -93,8 +97,12 @@ PROP_CELSIUS = {'minValue': -273, 'maxValue': 999}
# #### Device Class ####
DEVICE_CLASS_CO2 = 'co2'
DEVICE_CLASS_GAS = 'gas'
DEVICE_CLASS_HUMIDITY = 'humidity'
DEVICE_CLASS_LIGHT = 'light'
DEVICE_CLASS_MOISTURE = 'moisture'
DEVICE_CLASS_MOTION = 'motion'
DEVICE_CLASS_OCCUPANCY = 'occupancy'
DEVICE_CLASS_OPENING = 'opening'
DEVICE_CLASS_PM25 = 'pm25'
DEVICE_CLASS_SMOKE = 'smoke'
DEVICE_CLASS_TEMPERATURE = 'temperature'

78
homeassistant/components/homekit/type_sensors.py Executable file → Normal file
View File

@ -10,6 +10,9 @@ from .accessories import HomeAccessory, add_preload_service, setup_char
from .const import (
CATEGORY_SENSOR, SERV_HUMIDITY_SENSOR, SERV_TEMPERATURE_SENSOR,
CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS,
SERV_AIR_QUALITY_SENSOR, CHAR_AIR_QUALITY, CHAR_AIR_PARTICULATE_DENSITY,
CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL,
SERV_LIGHT_SENSOR, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL,
DEVICE_CLASS_CO2, SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED,
DEVICE_CLASS_GAS, SERV_CARBON_MONOXIDE_SENSOR,
CHAR_CARBON_MONOXIDE_DETECTED,
@ -18,7 +21,8 @@ from .const import (
DEVICE_CLASS_OCCUPANCY, SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED,
DEVICE_CLASS_OPENING, SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE,
DEVICE_CLASS_SMOKE, SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED)
from .util import convert_to_float, temperature_to_homekit
from .util import (
convert_to_float, temperature_to_homekit, density_to_air_quality)
_LOGGER = logging.getLogger(__name__)
@ -81,6 +85,78 @@ class HumiditySensor(HomeAccessory):
self.entity_id, humidity)
@TYPES.register('AirQualitySensor')
class AirQualitySensor(HomeAccessory):
"""Generate a AirQualitySensor accessory as air quality sensor."""
def __init__(self, *args, config):
"""Initialize a AirQualitySensor accessory object."""
super().__init__(*args, category=CATEGORY_SENSOR)
serv_air_quality = add_preload_service(self, SERV_AIR_QUALITY_SENSOR,
[CHAR_AIR_PARTICULATE_DENSITY])
self.char_quality = setup_char(
CHAR_AIR_QUALITY, serv_air_quality, value=0)
self.char_density = setup_char(
CHAR_AIR_PARTICULATE_DENSITY, serv_air_quality, value=0)
def update_state(self, new_state):
"""Update accessory after state change."""
density = convert_to_float(new_state.state)
if density is not None:
self.char_density.set_value(density)
self.char_quality.set_value(density_to_air_quality(density))
_LOGGER.debug('%s: Set to %d', self.entity_id, density)
@TYPES.register('CarbonDioxideSensor')
class CarbonDioxideSensor(HomeAccessory):
"""Generate a CarbonDioxideSensor accessory as CO2 sensor."""
def __init__(self, *args, config):
"""Initialize a CarbonDioxideSensor accessory object."""
super().__init__(*args, category=CATEGORY_SENSOR)
serv_co2 = add_preload_service(self, SERV_CARBON_DIOXIDE_SENSOR, [
CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL])
self.char_co2 = setup_char(
CHAR_CARBON_DIOXIDE_LEVEL, serv_co2, value=0)
self.char_peak = setup_char(
CHAR_CARBON_DIOXIDE_PEAK_LEVEL, serv_co2, value=0)
self.char_detected = setup_char(
CHAR_CARBON_DIOXIDE_DETECTED, serv_co2, value=0)
def update_state(self, new_state):
"""Update accessory after state change."""
co2 = convert_to_float(new_state.state)
if co2 is not None:
self.char_co2.set_value(co2)
if co2 > self.char_peak.value:
self.char_peak.set_value(co2)
self.char_detected.set_value(co2 > 1000)
_LOGGER.debug('%s: Set to %d', self.entity_id, co2)
@TYPES.register('LightSensor')
class LightSensor(HomeAccessory):
"""Generate a LightSensor accessory as light sensor."""
def __init__(self, *args, config):
"""Initialize a LightSensor accessory object."""
super().__init__(*args, category=CATEGORY_SENSOR)
serv_light = add_preload_service(self, SERV_LIGHT_SENSOR)
self.char_light = setup_char(
CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, serv_light, value=0)
def update_state(self, new_state):
"""Update accessory after state change."""
luminance = convert_to_float(new_state.state)
if luminance is not None:
self.char_light.set_value(luminance)
_LOGGER.debug('%s: Set to %d', self.entity_id, luminance)
@TYPES.register('BinarySensor')
class BinarySensor(HomeAccessory):
"""Generate a BinarySensor accessory as binary sensor."""

View File

@ -64,3 +64,16 @@ def temperature_to_homekit(temperature, unit):
def temperature_to_states(temperature, unit):
"""Convert temperature back from Celsius to Home Assistant unit."""
return round(temp_util.convert(temperature, TEMP_CELSIUS, unit), 1)
def density_to_air_quality(density):
"""Map PM2.5 density to HomeKit AirQuality level."""
if density <= 35:
return 1
elif density <= 75:
return 2
elif density <= 115:
return 3
elif density <= 150:
return 4
return 5

View File

@ -41,6 +41,13 @@ class TestGetAccessories(unittest.TestCase):
"""Test if mock type was called."""
self.assertTrue(self.mock_type.called)
def test_sensor_temperature(self):
"""Test temperature sensor with device class temperature."""
with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}):
state = State('sensor.temperature', '23',
{ATTR_DEVICE_CLASS: 'temperature'})
get_accessory(None, state, 2, {})
def test_sensor_temperature_celsius(self):
"""Test temperature sensor with Celsius as unit."""
with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}):
@ -56,12 +63,66 @@ class TestGetAccessories(unittest.TestCase):
get_accessory(None, state, 2, {})
def test_sensor_humidity(self):
"""Test humidity sensor with device class humidity."""
with patch.dict(TYPES, {'HumiditySensor': self.mock_type}):
state = State('sensor.humidity', '20',
{ATTR_DEVICE_CLASS: 'humidity'})
get_accessory(None, state, 2, {})
def test_sensor_humidity_unit(self):
"""Test humidity sensor with % as unit."""
with patch.dict(TYPES, {'HumiditySensor': self.mock_type}):
state = State('sensor.humidity', '20',
{ATTR_UNIT_OF_MEASUREMENT: '%'})
get_accessory(None, state, 2, {})
def test_air_quality_sensor(self):
"""Test air quality sensor with pm25 class."""
with patch.dict(TYPES, {'AirQualitySensor': self.mock_type}):
state = State('sensor.air_quality', '40',
{ATTR_DEVICE_CLASS: 'pm25'})
get_accessory(None, state, 2, {})
def test_air_quality_sensor_entity_id(self):
"""Test air quality sensor with entity_id contains pm25."""
with patch.dict(TYPES, {'AirQualitySensor': self.mock_type}):
state = State('sensor.air_quality_pm25', '40', {})
get_accessory(None, state, 2, {})
def test_co2_sensor(self):
"""Test co2 sensor with device class co2."""
with patch.dict(TYPES, {'CarbonDioxideSensor': self.mock_type}):
state = State('sensor.airmeter', '500',
{ATTR_DEVICE_CLASS: 'co2'})
get_accessory(None, state, 2, {})
def test_co2_sensor_entity_id(self):
"""Test co2 sensor with entity_id contains co2."""
with patch.dict(TYPES, {'CarbonDioxideSensor': self.mock_type}):
state = State('sensor.airmeter_co2', '500', {})
get_accessory(None, state, 2, {})
def test_light_sensor(self):
"""Test light sensor with device class lux."""
with patch.dict(TYPES, {'LightSensor': self.mock_type}):
state = State('sensor.light', '900',
{ATTR_DEVICE_CLASS: 'light'})
get_accessory(None, state, 2, {})
def test_light_sensor_unit_lm(self):
"""Test light sensor with lm as unit."""
with patch.dict(TYPES, {'LightSensor': self.mock_type}):
state = State('sensor.light', '900',
{ATTR_UNIT_OF_MEASUREMENT: 'lm'})
get_accessory(None, state, 2, {})
def test_light_sensor_unit_lux(self):
"""Test light sensor with lux as unit."""
with patch.dict(TYPES, {'LightSensor': self.mock_type}):
state = State('sensor.light', '900',
{ATTR_UNIT_OF_MEASUREMENT: 'lux'})
get_accessory(None, state, 2, {})
def test_binary_sensor(self):
"""Test binary sensor with opening class."""
with patch.dict(TYPES, {'BinarySensor': self.mock_type}):

View File

@ -3,7 +3,8 @@ import unittest
from homeassistant.components.homekit.const import PROP_CELSIUS
from homeassistant.components.homekit.type_sensors import (
TemperatureSensor, HumiditySensor, BinarySensor, BINARY_SENSOR_SERVICE_MAP)
TemperatureSensor, HumiditySensor, AirQualitySensor, CarbonDioxideSensor,
LightSensor, BinarySensor, BINARY_SENSOR_SERVICE_MAP)
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT, ATTR_DEVICE_CLASS, STATE_UNKNOWN, STATE_ON,
STATE_OFF, STATE_HOME, STATE_NOT_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT)
@ -40,6 +41,7 @@ class TestHomekitSensors(unittest.TestCase):
self.hass.states.set(entity_id, STATE_UNKNOWN,
{ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
self.hass.block_till_done()
self.assertEqual(acc.char_temp.value, 0.0)
self.hass.states.set(entity_id, '20',
{ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
@ -63,14 +65,95 @@ class TestHomekitSensors(unittest.TestCase):
self.assertEqual(acc.char_humidity.value, 0)
self.hass.states.set(entity_id, STATE_UNKNOWN,
{ATTR_UNIT_OF_MEASUREMENT: "%"})
self.hass.states.set(entity_id, STATE_UNKNOWN)
self.hass.block_till_done()
self.assertEqual(acc.char_humidity.value, 0)
self.hass.states.set(entity_id, '20', {ATTR_UNIT_OF_MEASUREMENT: "%"})
self.hass.states.set(entity_id, '20')
self.hass.block_till_done()
self.assertEqual(acc.char_humidity.value, 20)
def test_air_quality(self):
"""Test if accessory is updated after state change."""
entity_id = 'sensor.air_quality'
acc = AirQualitySensor(self.hass, 'Air Quality', entity_id,
2, config=None)
acc.run()
self.assertEqual(acc.aid, 2)
self.assertEqual(acc.category, 10) # Sensor
self.assertEqual(acc.char_density.value, 0)
self.assertEqual(acc.char_quality.value, 0)
self.hass.states.set(entity_id, STATE_UNKNOWN)
self.hass.block_till_done()
self.assertEqual(acc.char_density.value, 0)
self.assertEqual(acc.char_quality.value, 0)
self.hass.states.set(entity_id, '34')
self.hass.block_till_done()
self.assertEqual(acc.char_density.value, 34)
self.assertEqual(acc.char_quality.value, 1)
self.hass.states.set(entity_id, '200')
self.hass.block_till_done()
self.assertEqual(acc.char_density.value, 200)
self.assertEqual(acc.char_quality.value, 5)
def test_co2(self):
"""Test if accessory is updated after state change."""
entity_id = 'sensor.co2'
acc = CarbonDioxideSensor(self.hass, 'CO2', entity_id, 2, config=None)
acc.run()
self.assertEqual(acc.aid, 2)
self.assertEqual(acc.category, 10) # Sensor
self.assertEqual(acc.char_co2.value, 0)
self.assertEqual(acc.char_peak.value, 0)
self.assertEqual(acc.char_detected.value, 0)
self.hass.states.set(entity_id, STATE_UNKNOWN)
self.hass.block_till_done()
self.assertEqual(acc.char_co2.value, 0)
self.assertEqual(acc.char_peak.value, 0)
self.assertEqual(acc.char_detected.value, 0)
self.hass.states.set(entity_id, '1100')
self.hass.block_till_done()
self.assertEqual(acc.char_co2.value, 1100)
self.assertEqual(acc.char_peak.value, 1100)
self.assertEqual(acc.char_detected.value, 1)
self.hass.states.set(entity_id, '800')
self.hass.block_till_done()
self.assertEqual(acc.char_co2.value, 800)
self.assertEqual(acc.char_peak.value, 1100)
self.assertEqual(acc.char_detected.value, 0)
def test_light(self):
"""Test if accessory is updated after state change."""
entity_id = 'sensor.light'
acc = LightSensor(self.hass, 'Light', entity_id, 2, config=None)
acc.run()
self.assertEqual(acc.aid, 2)
self.assertEqual(acc.category, 10) # Sensor
self.assertEqual(acc.char_light.value, 0.0001)
self.hass.states.set(entity_id, STATE_UNKNOWN)
self.hass.block_till_done()
self.assertEqual(acc.char_light.value, 0.0001)
self.hass.states.set(entity_id, '300')
self.hass.block_till_done()
self.assertEqual(acc.char_light.value, 300)
def test_binary(self):
"""Test if accessory is updated after state change."""
entity_id = 'binary_sensor.opening'

View File

@ -2,13 +2,15 @@
import unittest
import voluptuous as vol
import pytest
from homeassistant.core import callback
from homeassistant.components.homekit.accessories import HomeBridge
from homeassistant.components.homekit.const import HOMEKIT_NOTIFY_ID
from homeassistant.components.homekit.util import (
show_setup_message, dismiss_setup_message, convert_to_float,
temperature_to_homekit, temperature_to_states, ATTR_CODE)
temperature_to_homekit, temperature_to_states, ATTR_CODE,
density_to_air_quality)
from homeassistant.components.homekit.util import validate_entity_config \
as vec
from homeassistant.components.persistent_notification import (
@ -20,6 +22,52 @@ from homeassistant.const import (
from tests.common import get_test_home_assistant
def test_validate_entity_config():
"""Test validate entities."""
configs = [{'invalid_entity_id': {}}, {'demo.test': 1},
{'demo.test': 'test'}, {'demo.test': [1, 2]},
{'demo.test': None}]
for conf in configs:
with pytest.raises(vol.Invalid):
vec(conf)
assert vec({}) == {}
assert vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}) == \
{'alarm_control_panel.demo': {ATTR_CODE: '1234'}}
def test_convert_to_float():
"""Test convert_to_float method."""
assert convert_to_float(12) == 12
assert convert_to_float(12.4) == 12.4
assert convert_to_float(STATE_UNKNOWN) is None
assert convert_to_float(None) is None
def test_temperature_to_homekit():
"""Test temperature conversion from HA to HomeKit."""
assert temperature_to_homekit(20.46, TEMP_CELSIUS) == 20.5
assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.4
def test_temperature_to_states():
"""Test temperature conversion from HomeKit to HA."""
assert temperature_to_states(20, TEMP_CELSIUS) == 20.0
assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.4
def test_density_to_air_quality():
"""Test map PM2.5 density to HomeKit AirQuality level."""
assert density_to_air_quality(0) == 1
assert density_to_air_quality(35) == 1
assert density_to_air_quality(35.1) == 2
assert density_to_air_quality(75) == 2
assert density_to_air_quality(115) == 3
assert density_to_air_quality(150) == 4
assert density_to_air_quality(300) == 5
class TestUtil(unittest.TestCase):
"""Test all HomeKit util methods."""
@ -39,21 +87,6 @@ class TestUtil(unittest.TestCase):
"""Stop down everything that was started."""
self.hass.stop()
def test_validate_entity_config(self):
"""Test validate entities."""
configs = [{'invalid_entity_id': {}}, {'demo.test': 1},
{'demo.test': 'test'}, {'demo.test': [1, 2]},
{'demo.test': None}]
for conf in configs:
with self.assertRaises(vol.Invalid):
vec(conf)
self.assertEqual(vec({}), {})
self.assertEqual(
vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}),
{'alarm_control_panel.demo': {ATTR_CODE: '1234'}})
def test_show_setup_msg(self):
"""Test show setup message as persistence notification."""
bridge = HomeBridge(self.hass)
@ -83,20 +116,3 @@ class TestUtil(unittest.TestCase):
self.assertEqual(
data[ATTR_SERVICE_DATA].get(ATTR_NOTIFICATION_ID, None),
HOMEKIT_NOTIFY_ID)
def test_convert_to_float(self):
"""Test convert_to_float method."""
self.assertEqual(convert_to_float(12), 12)
self.assertEqual(convert_to_float(12.4), 12.4)
self.assertIsNone(convert_to_float(STATE_UNKNOWN))
self.assertIsNone(convert_to_float(None))
def test_temperature_to_homekit(self):
"""Test temperature conversion from HA to HomeKit."""
self.assertEqual(temperature_to_homekit(20.46, TEMP_CELSIUS), 20.5)
self.assertEqual(temperature_to_homekit(92.1, TEMP_FAHRENHEIT), 33.4)
def test_temperature_to_states(self):
"""Test temperature conversion from HomeKit to HA."""
self.assertEqual(temperature_to_states(20, TEMP_CELSIUS), 20.0)
self.assertEqual(temperature_to_states(20.2, TEMP_FAHRENHEIT), 68.4)