diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 05c5e46e44e..e9a33c27d34 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -35,8 +35,9 @@ ENTITY_ID_ALL_COVERS = group.ENTITY_ID_FORMAT.format('all_covers') ENTITY_ID_FORMAT = DOMAIN + '.{}' DEVICE_CLASSES = [ - 'window', # Window control + 'damper', 'garage', # Garage door control + 'window', # Window control ] DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) @@ -140,7 +141,7 @@ def stop_cover_tilt(hass, entity_id=None): async def async_setup(hass, config): """Track states and offer events for covers.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) await component.async_setup(config) @@ -195,6 +196,16 @@ async def async_setup(hass, config): return True +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class CoverDevice(Entity): """Representation a cover.""" diff --git a/homeassistant/components/cover/deconz.py b/homeassistant/components/cover/deconz.py new file mode 100644 index 00000000000..9fe65596336 --- /dev/null +++ b/homeassistant/components/cover/deconz.py @@ -0,0 +1,146 @@ +""" +Support for deCONZ covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.deconz/ +""" +from homeassistant.components.deconz.const import ( + COVER_TYPES, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, + DECONZ_DOMAIN) +from homeassistant.components.cover import ( + ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, + SUPPORT_SET_POSITION) +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Unsupported way of setting up deCONZ covers.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up covers for deCONZ component. + + Covers are based on same device class as lights in deCONZ. + """ + @callback + def async_add_cover(lights): + """Add cover from deCONZ.""" + entities = [] + for light in lights: + if light.type in COVER_TYPES: + entities.append(DeconzCover(light)) + async_add_entities(entities, True) + + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_cover)) + + async_add_cover(hass.data[DATA_DECONZ].lights.values()) + + +class DeconzCover(CoverDevice): + """Representation of a deCONZ cover.""" + + def __init__(self, cover): + """Set up cover and add update callback to get data from websocket.""" + self._cover = cover + self._features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + + async def async_added_to_hass(self): + """Subscribe to covers events.""" + self._cover.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._cover.deconz_id + + async def async_will_remove_from_hass(self) -> None: + """Disconnect cover object when removed.""" + self._cover.remove_callback(self.async_update_callback) + self._cover = None + + @callback + def async_update_callback(self, reason): + """Update the cover's state.""" + self.async_schedule_update_ha_state() + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + if self.is_closed: + return 0 + return int(self._cover.brightness / 255 * 100) + + @property + def is_closed(self): + """Return if the cover is closed.""" + return not self._cover.state + + @property + def name(self): + """Return the name of the cover.""" + return self._cover.name + + @property + def unique_id(self): + """Return a unique identifier for this cover.""" + return self._cover.uniqueid + + @property + def device_class(self): + """Return the class of the cover.""" + return 'damper' + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + @property + def available(self): + """Return True if light is available.""" + return self._cover.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + data = {'on': False} + if position > 0: + data['on'] = True + data['bri'] = int(position / 100 * 255) + await self._cover.async_set_state(data) + + async def async_open_cover(self, **kwargs): + """Open cover.""" + data = {ATTR_POSITION: 100} + await self.async_set_cover_position(**data) + + async def async_close_cover(self, **kwargs): + """Close cover.""" + data = {ATTR_POSITION: 0} + await self.async_set_cover_position(**data) + + @property + def device_info(self): + """Return a device description for device registry.""" + if (self._cover.uniqueid is None or + self._cover.uniqueid.count(':') != 7): + return None + serial = self._cover.uniqueid.split('-', 1)[0] + bridgeid = self.hass.data[DATA_DECONZ].config.bridgeid + return { + 'connections': {(CONNECTION_ZIGBEE, serial)}, + 'identifiers': {(DECONZ_DOMAIN, serial)}, + 'manufacturer': self._cover.manufacturer, + 'model': self._cover.modelid, + 'name': self._cover.name, + 'sw_version': self._cover.swversion, + 'via_hub': (DECONZ_DOMAIN, bridgeid), + } diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 82f4233a7da..56b03c89a37 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -26,6 +26,9 @@ from .const import ( REQUIREMENTS = ['pydeconz==47'] +SUPPORTED_PLATFORMS = ['binary_sensor', 'cover', + 'light', 'scene', 'sensor', 'switch'] + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_API_KEY): cv.string, @@ -104,7 +107,7 @@ async def async_setup_entry(hass, config_entry): hass.data[DATA_DECONZ_EVENT] = [] hass.data[DATA_DECONZ_UNSUB] = [] - for component in ['binary_sensor', 'light', 'scene', 'sensor', 'switch']: + for component in SUPPORTED_PLATFORMS: hass.async_create_task(hass.config_entries.async_forward_entry_setup( config_entry, component)) @@ -228,7 +231,7 @@ async def async_unload_entry(hass, config_entry): hass.services.async_remove(DOMAIN, SERVICE_DECONZ) deconz.close() - for component in ['binary_sensor', 'light', 'scene', 'sensor', 'switch']: + for component in SUPPORTED_PLATFORMS: await hass.config_entries.async_forward_entry_unload( config_entry, component) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index e629d57f201..617d231f92e 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -16,6 +16,8 @@ CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' ATTR_DARK = 'dark' ATTR_ON = 'on' +COVER_TYPES = ["Level controllable output"] + POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] SIRENS = ["Warning device"] SWITCH_TYPES = POWER_PLUGS + SIRENS diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 3fb6e1dff00..d3bec079a4c 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/light.deconz/ """ from homeassistant.components.deconz.const import ( CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DATA_DECONZ, - DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DECONZ_DOMAIN, SWITCH_TYPES) + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DECONZ_DOMAIN, + COVER_TYPES, SWITCH_TYPES) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, @@ -33,7 +34,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Add light from deCONZ.""" entities = [] for light in lights: - if light.type not in SWITCH_TYPES: + if light.type not in COVER_TYPES + SWITCH_TYPES: entities.append(DeconzLight(light)) async_add_entities(entities, True) diff --git a/tests/components/cover/test_deconz.py b/tests/components/cover/test_deconz.py new file mode 100644 index 00000000000..60de9cffdc1 --- /dev/null +++ b/tests/components/cover/test_deconz.py @@ -0,0 +1,84 @@ +"""deCONZ cover platform tests.""" +from unittest.mock import Mock, patch + +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.components.deconz.const import COVER_TYPES +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import mock_coro + +SUPPORTED_COVERS = { + "1": { + "id": "Cover 1 id", + "name": "Cover 1 name", + "type": "Level controllable output", + "state": {} + } +} + +UNSUPPORTED_COVER = { + "1": { + "id": "Cover id", + "name": "Unsupported switch", + "type": "Not a cover", + "state": {} + } +} + + +async def setup_bridge(hass, data): + """Load the deCONZ cover platform.""" + from pydeconz import DeconzSession + loop = Mock() + session = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + bridge = DeconzSession(loop, session, **entry.data) + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(data)): + await bridge.async_load_parameters() + hass.data[deconz.DOMAIN] = bridge + hass.data[deconz.DATA_DECONZ_UNSUB] = [] + hass.data[deconz.DATA_DECONZ_ID] = {} + config_entry = config_entries.ConfigEntry( + 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test', + config_entries.CONN_CLASS_LOCAL_PUSH) + await hass.config_entries.async_forward_entry_setup(config_entry, 'cover') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_no_switches(hass): + """Test that no cover entities are created.""" + data = {} + await setup_bridge(hass, data) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 + + +async def test_cover(hass): + """Test that all supported cover entities are created.""" + await setup_bridge(hass, {"lights": SUPPORTED_COVERS}) + assert "cover.cover_1_name" in hass.data[deconz.DATA_DECONZ_ID] + assert len(SUPPORTED_COVERS) == len(COVER_TYPES) + assert len(hass.states.async_all()) == 2 + + +async def test_add_new_cover(hass): + """Test successful creation of cover entity.""" + data = {} + await setup_bridge(hass, data) + cover = Mock() + cover.name = 'name' + cover.type = "Level controllable output" + cover.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_light', [cover]) + await hass.async_block_till_done() + assert "cover.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_unsupported_cover(hass): + """Test that unsupported covers are not created.""" + await setup_bridge(hass, {"lights": UNSUPPORTED_COVER}) + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 049a3b961b6..cfda1232e93 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -112,17 +112,21 @@ async def test_setup_entry_successful(hass): assert hass.data[deconz.DOMAIN] assert hass.data[deconz.DATA_DECONZ_ID] == {} assert len(hass.data[deconz.DATA_DECONZ_UNSUB]) == 1 - assert len(mock_add_job.mock_calls) == 5 - assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == 5 + assert len(mock_add_job.mock_calls) == \ + len(deconz.SUPPORTED_PLATFORMS) + assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == \ + len(deconz.SUPPORTED_PLATFORMS) assert mock_config_entries.async_forward_entry_setup.mock_calls[0][1] == \ (entry, 'binary_sensor') assert mock_config_entries.async_forward_entry_setup.mock_calls[1][1] == \ - (entry, 'light') + (entry, 'cover') assert mock_config_entries.async_forward_entry_setup.mock_calls[2][1] == \ - (entry, 'scene') + (entry, 'light') assert mock_config_entries.async_forward_entry_setup.mock_calls[3][1] == \ - (entry, 'sensor') + (entry, 'scene') assert mock_config_entries.async_forward_entry_setup.mock_calls[4][1] == \ + (entry, 'sensor') + assert mock_config_entries.async_forward_entry_setup.mock_calls[5][1] == \ (entry, 'switch') diff --git a/tests/components/switch/test_deconz.py b/tests/components/switch/test_deconz.py index 7d2bea7a9fa..6833cab33d7 100644 --- a/tests/components/switch/test_deconz.py +++ b/tests/components/switch/test_deconz.py @@ -71,7 +71,7 @@ async def test_no_switches(hass): async def test_switch(hass): - """Test that all supported switch entities and switch group are created.""" + """Test that all supported switch entities are created.""" await setup_bridge(hass, {"lights": SUPPORTED_SWITCHES}) assert "switch.switch_1_name" in hass.data[deconz.DATA_DECONZ_ID] assert "switch.switch_2_name" in hass.data[deconz.DATA_DECONZ_ID]