diff --git a/.coveragerc b/.coveragerc index 5a78ec8093f28d..c4aea0e140a37a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -470,6 +470,7 @@ omit = homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py homeassistant/components/lirc.py + homeassistant/components/lock/kiwi.py homeassistant/components/lock/lockitron.py homeassistant/components/lock/nello.py homeassistant/components/lock/nuki.py diff --git a/homeassistant/components/alarm_control_panel/arlo.py b/homeassistant/components/alarm_control_panel/arlo.py index 333bde9ee36a75..20887157cb4dff 100644 --- a/homeassistant/components/alarm_control_panel/arlo.py +++ b/homeassistant/components/alarm_control_panel/arlo.py @@ -4,15 +4,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.arlo/ """ -import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.alarm_control_panel import ( AlarmControlPanel, PLATFORM_SCHEMA) -from homeassistant.components.arlo import (DATA_ARLO, CONF_ATTRIBUTION) +from homeassistant.components.arlo import ( + DATA_ARLO, CONF_ATTRIBUTION, SIGNAL_UPDATE_ARLO) from homeassistant.const import ( ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) @@ -36,21 +38,20 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Arlo Alarm Control Panels.""" - data = hass.data[DATA_ARLO] + arlo = hass.data[DATA_ARLO] - if not data.base_stations: + if not arlo.base_stations: return home_mode_name = config.get(CONF_HOME_MODE_NAME) away_mode_name = config.get(CONF_AWAY_MODE_NAME) base_stations = [] - for base_station in data.base_stations: + for base_station in arlo.base_stations: base_stations.append(ArloBaseStation(base_station, home_mode_name, away_mode_name)) - async_add_devices(base_stations, True) + add_devices(base_stations, True) class ArloBaseStation(AlarmControlPanel): @@ -68,6 +69,16 @@ def icon(self): """Return icon.""" return ICON + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + @property def state(self): """Return the state of the device.""" @@ -75,30 +86,22 @@ def state(self): def update(self): """Update the state of the device.""" - # PyArlo sometimes returns None for mode. So retry 3 times before - # returning None. - num_retries = 3 - i = 0 - while i < num_retries: - mode = self._base_station.mode - if mode: - self._state = self._get_state_from_mode(mode) - return - i += 1 - self._state = None - - @asyncio.coroutine - def async_alarm_disarm(self, code=None): + _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name) + mode = self._base_station.mode + if mode: + self._state = self._get_state_from_mode(mode) + else: + self._state = None + + async def async_alarm_disarm(self, code=None): """Send disarm command.""" self._base_station.mode = DISARMED - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code=None): """Send arm away command. Uses custom mode.""" self._base_station.mode = self._away_mode_name - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code=None): """Send arm home command. Uses custom mode.""" self._base_station.mode = self._home_mode_name @@ -125,4 +128,4 @@ def _get_state_from_mode(self, mode): return STATE_ALARM_ARMED_HOME elif mode == self._away_mode_name: return STATE_ALARM_ARMED_AWAY - return None + return mode diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 7e51ec8c045e17..206ea4005e6bae 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -5,14 +5,18 @@ https://home-assistant.io/components/arlo/ """ import logging +from datetime import timedelta import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import config_validation as cv -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.dispatcher import dispatcher_send -REQUIREMENTS = ['pyarlo==0.1.2'] +REQUIREMENTS = ['pyarlo==0.1.6'] _LOGGER = logging.getLogger(__name__) @@ -25,10 +29,16 @@ NOTIFICATION_ID = 'arlo_notification' NOTIFICATION_TITLE = 'Arlo Component Setup' +SCAN_INTERVAL = timedelta(seconds=60) + +SIGNAL_UPDATE_ARLO = "arlo_update" + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, }), }, extra=vol.ALLOW_EXTRA) @@ -38,6 +48,7 @@ def setup(hass, config): conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) + scan_interval = conf.get(CONF_SCAN_INTERVAL) try: from pyarlo import PyArlo @@ -45,7 +56,17 @@ def setup(hass, config): arlo = PyArlo(username, password, preload=False) if not arlo.is_connected: return False + + # assign refresh period to base station thread + arlo_base_station = next(( + station for station in arlo.base_stations), None) + + if arlo_base_station is None: + return False + + arlo_base_station.refresh_rate = scan_interval.total_seconds() hass.data[DATA_ARLO] = arlo + except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex)) hass.components.persistent_notification.create( @@ -55,4 +76,17 @@ def setup(hass, config): title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) return False + + def hub_refresh(event_time): + """Call ArloHub to refresh information.""" + _LOGGER.info("Updating Arlo Hub component") + hass.data[DATA_ARLO].update(update_cameras=True, + update_base_station=True) + dispatcher_send(hass, SIGNAL_UPDATE_ARLO) + + # register service + hass.services.register(DOMAIN, 'update', hub_refresh) + + # register scan interval for ArloHub + track_time_interval(hass, hub_refresh, scan_interval) return True diff --git a/homeassistant/components/binary_sensor/tahoma.py b/homeassistant/components/binary_sensor/tahoma.py new file mode 100644 index 00000000000000..efcfb629f39601 --- /dev/null +++ b/homeassistant/components/binary_sensor/tahoma.py @@ -0,0 +1,98 @@ +""" +Support for Tahoma binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tahoma/ +""" + +import logging +from datetime import timedelta + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice) +from homeassistant.components.tahoma import ( + DOMAIN as TAHOMA_DOMAIN, TahomaDevice) +from homeassistant.const import (STATE_OFF, STATE_ON, ATTR_BATTERY_LEVEL) + +DEPENDENCIES = ['tahoma'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=120) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tahoma controller devices.""" + _LOGGER.debug("Setup Tahoma Binary sensor platform") + controller = hass.data[TAHOMA_DOMAIN]['controller'] + devices = [] + for device in hass.data[TAHOMA_DOMAIN]['devices']['smoke']: + devices.append(TahomaBinarySensor(device, controller)) + add_devices(devices, True) + + +class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): + """Representation of a Tahoma Binary Sensor.""" + + def __init__(self, tahoma_device, controller): + """Initialize the sensor.""" + super().__init__(tahoma_device, controller) + + self._state = None + self._icon = None + self._battery = None + + @property + def is_on(self): + """Return the state of the sensor.""" + return bool(self._state == STATE_ON) + + @property + def device_class(self): + """Return the class of the device.""" + if self.tahoma_device.type == 'rtds:RTDSSmokeSensor': + return 'smoke' + return None + + @property + def icon(self): + """Icon for device by its type.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attr = {} + super_attr = super().device_state_attributes + if super_attr is not None: + attr.update(super_attr) + + if self._battery is not None: + attr[ATTR_BATTERY_LEVEL] = self._battery + return attr + + def update(self): + """Update the state.""" + self.controller.get_states([self.tahoma_device]) + if self.tahoma_device.type == 'rtds:RTDSSmokeSensor': + if self.tahoma_device.active_states['core:SmokeState']\ + == 'notDetected': + self._state = STATE_OFF + else: + self._state = STATE_ON + + if 'core:SensorDefectState' in self.tahoma_device.active_states: + # Set to 'lowBattery' for low battery warning. + self._battery = self.tahoma_device.active_states[ + 'core:SensorDefectState'] + else: + self._battery = None + + if self._state == STATE_ON: + self._icon = "mdi:fire" + elif self._battery == 'lowBattery': + self._icon = "mdi:battery-alert" + else: + self._icon = None + + _LOGGER.debug("Update %s, state: %s", self._name, self._state) diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index f3e70c2bdd74c9..1a98ade55183ea 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -4,23 +4,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.arlo/ """ -import asyncio import logging -from datetime import timedelta import voluptuous as vol +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO +from homeassistant.components.arlo import ( + DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=90) - ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' @@ -44,22 +43,19 @@ } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FFMPEG_ARGUMENTS): - cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Arlo IP Camera.""" - arlo = hass.data.get(DATA_ARLO) - if not arlo: - return False + arlo = hass.data[DATA_ARLO] cameras = [] for camera in arlo.cameras: cameras.append(ArloCam(hass, camera, config)) - add_devices(cameras, True) + add_devices(cameras) class ArloCam(Camera): @@ -74,31 +70,41 @@ def __init__(self, hass, camera, device_info): self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) self._last_refresh = None - if self._camera.base_station: - self._camera.base_station.refresh_rate = \ - SCAN_INTERVAL.total_seconds() self.attrs = {} def camera_image(self): """Return a still image response from the camera.""" - return self._camera.last_image + return self._camera.last_image_from_cache + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state() - @asyncio.coroutine - def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" from haffmpeg import CameraMjpeg video = self._camera.last_video if not video: + error_msg = \ + 'Video not found for {0}. Is it older than {1} days?'.format( + self.name, self._camera.min_days_vdo_cache) + _LOGGER.error(error_msg) return stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) - yield from stream.open_camera( + await stream.open_camera( video.video_url, extra_cmd=self._ffmpeg_arguments) - yield from async_aiohttp_proxy_stream( + await async_aiohttp_proxy_stream( self.hass, request, stream, 'multipart/x-mixed-replace;boundary=ffserver') - yield from stream.close() + await stream.close() @property def name(self): @@ -132,11 +138,6 @@ def brand(self): """Return the camera brand.""" return DEFAULT_BRAND - @property - def should_poll(self): - """Camera should poll periodically.""" - return True - @property def motion_detection_enabled(self): """Return the camera motion detection status.""" @@ -164,7 +165,3 @@ def disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" self._motion_status = False self.set_base_station_mode(ARLO_MODE_DISARMED) - - def update(self): - """Add an attribute-update task to the executor pool.""" - self._camera.update() diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index cf8b7dfad48c0e..04566e902e04a5 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -14,6 +14,15 @@ _LOGGER = logging.getLogger(__name__) +ATTR_CLOSURE = 'closure' +ATTR_MEM_POS = 'memorized_position' +ATTR_RSSI_LEVEL = 'rssi_level' +ATTR_OPEN_CLOSE = 'open_close' +ATTR_STATUS = 'status' +ATTR_LOCK_TIMER = 'priority_lock_timer' +ATTR_LOCK_LEVEL = 'priority_lock_level' +ATTR_LOCK_ORIG = 'priority_lock_originator' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tahoma covers.""" @@ -27,27 +36,84 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class TahomaCover(TahomaDevice, CoverDevice): """Representation a Tahoma Cover.""" + def __init__(self, tahoma_device, controller): + """Initialize the device.""" + super().__init__(tahoma_device, controller) + + self._closure = 0 + # 100 equals open + self._position = 100 + self._closed = False + self._icon = None + # Can be 0 and bigger + self._lock_timer = 0 + # Can be 'comfortLevel1' + self._lock_level = None + # Can be 'wind' + self._lock_originator = None + def update(self): """Update method.""" self.controller.get_states([self.tahoma_device]) - @property - def current_cover_position(self): - """ - Return current position of cover. + if 'core:ClosureState' in self.tahoma_device.active_states: + self._closure = \ + self.tahoma_device.active_states['core:ClosureState'] + else: + self._closure = None + if 'core:PriorityLockTimerState' in self.tahoma_device.active_states: + self._lock_timer = \ + self.tahoma_device.active_states['core:PriorityLockTimerState'] + else: + self._lock_timer = None + if 'io:PriorityLockLevelState' in self.tahoma_device.active_states: + self._lock_level = \ + self.tahoma_device.active_states['io:PriorityLockLevelState'] + else: + self._lock_level = None + if 'io:PriorityLockOriginatorState' in \ + self.tahoma_device.active_states: + self._lock_originator = \ + self.tahoma_device.active_states[ + 'io:PriorityLockOriginatorState'] + else: + self._lock_originator = None + + # Define which icon to use + if self._lock_timer > 0: + if self._lock_originator == 'wind': + self._icon = 'mdi:weather-windy' + else: + self._icon = 'mdi:lock-alert' + else: + self._icon = None - 0 is closed, 100 is fully open. - """ - try: - position = 100 - \ + # Define current position. + # _position: 0 is closed, 100 is fully open. + # 'core:ClosureState': 100 is closed, 0 is fully open. + if 'core:ClosureState' in self.tahoma_device.active_states: + self._position = 100 - \ self.tahoma_device.active_states['core:ClosureState'] - if position <= 5: - return 0 - if position >= 95: - return 100 - return position - except KeyError: - return None + if self._position <= 5: + self._position = 0 + if self._position >= 95: + self._position = 100 + self._closed = self._position == 0 + else: + self._position = None + if 'core:OpenClosedState' in self.tahoma_device.active_states: + self._closed = \ + self.tahoma_device.active_states['core:OpenClosedState']\ + == 'closed' + else: + self._closed = False + + _LOGGER.debug("Update %s, position: %d", self._name, self._position) + + @property + def current_cover_position(self): + """Return current position of cover.""" + return self._position def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -56,8 +122,7 @@ def set_cover_position(self, **kwargs): @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - return self.current_cover_position == 0 + return self._closed @property def device_class(self): @@ -66,13 +131,56 @@ def device_class(self): return 'window' return None + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attr = {} + super_attr = super().device_state_attributes + if super_attr is not None: + attr.update(super_attr) + + if self._closure is not None: + attr[ATTR_CLOSURE] = self._closure + if 'core:Memorized1PositionState' in self.tahoma_device.active_states: + attr[ATTR_MEM_POS] = self.tahoma_device.active_states[ + 'core:Memorized1PositionState'] + if 'core:RSSILevelState' in self.tahoma_device.active_states: + attr[ATTR_RSSI_LEVEL] = self.tahoma_device.active_states[ + 'core:RSSILevelState'] + if 'core:OpenClosedState' in self.tahoma_device.active_states: + attr[ATTR_OPEN_CLOSE] = self.tahoma_device.active_states[ + 'core:OpenClosedState'] + if 'core:StatusState' in self.tahoma_device.active_states: + attr[ATTR_STATUS] = self.tahoma_device.active_states[ + 'core:StatusState'] + if self._lock_timer is not None: + attr[ATTR_LOCK_TIMER] = self._lock_timer + if self._lock_level is not None: + attr[ATTR_LOCK_LEVEL] = self._lock_level + if self._lock_originator is not None: + attr[ATTR_LOCK_ORIG] = self._lock_originator + return attr + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + def open_cover(self, **kwargs): """Open the cover.""" - self.apply_action('open') + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + # The commands open and close seem to be reversed. + self.apply_action('close') + else: + self.apply_action('open') def close_cover(self, **kwargs): """Close the cover.""" - self.apply_action('close') + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + # The commands open and close seem to be reversed. + self.apply_action('open') + else: + self.apply_action('close') def stop_cover(self, **kwargs): """Stop the cover.""" @@ -83,5 +191,9 @@ def stop_cover(self, **kwargs): ('rts:BlindRTSComponent', 'io:ExteriorVenetianBlindIOComponent'): self.apply_action('my') + elif self.tahoma_device.type in \ + ('io:VerticalExteriorAwningIOComponent', + 'io:HorizontalAwningIOComponent'): + self.apply_action('stop') else: self.apply_action('stopIdentify') diff --git a/homeassistant/components/lock/kiwi.py b/homeassistant/components/lock/kiwi.py new file mode 100644 index 00000000000000..78ea45525f284c --- /dev/null +++ b/homeassistant/components/lock/kiwi.py @@ -0,0 +1,110 @@ +""" +Support for the KIWI.KI lock platform. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/lock.kiwi/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, ATTR_ID, ATTR_LONGITUDE, ATTR_LATITUDE, + STATE_LOCKED, STATE_UNLOCKED) +from homeassistant.helpers.event import async_call_later +from homeassistant.core import callback + +REQUIREMENTS = ['kiwiki-client==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TYPE = 'hardware_type' +ATTR_PERMISSION = 'permission' +ATTR_CAN_INVITE = 'can_invite_others' + +UNLOCK_MAINTAIN_TIME = 5 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the KIWI lock platform.""" + from kiwiki import KiwiClient, KiwiException + try: + kiwi = KiwiClient(config[CONF_USERNAME], config[CONF_PASSWORD]) + except KiwiException as exc: + _LOGGER.error(exc) + return + available_locks = kiwi.get_locks() + if not available_locks: + # No locks found; abort setup routine. + _LOGGER.info("No KIWI locks found in your account.") + return + add_devices([KiwiLock(lock, kiwi) for lock in available_locks], True) + + +class KiwiLock(LockDevice): + """Representation of a Kiwi lock.""" + + def __init__(self, kiwi_lock, client): + """Initialize the lock.""" + self._sensor = kiwi_lock + self._client = client + self.lock_id = kiwi_lock['sensor_id'] + self._state = STATE_LOCKED + + address = kiwi_lock.get('address') + address.update({ + ATTR_LATITUDE: address.pop('lat', None), + ATTR_LONGITUDE: address.pop('lng', None) + }) + + self._device_attrs = { + ATTR_ID: self.lock_id, + ATTR_TYPE: kiwi_lock.get('hardware_type'), + ATTR_PERMISSION: kiwi_lock.get('highest_permission'), + ATTR_CAN_INVITE: kiwi_lock.get('can_invite'), + **address + } + + @property + def name(self): + """Return the name of the lock.""" + name = self._sensor.get('name') + specifier = self._sensor['address'].get('specifier') + return name or specifier + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state == STATE_LOCKED + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return self._device_attrs + + @callback + def clear_unlock_state(self, _): + """Clear unlock state automatically.""" + self._state = STATE_LOCKED + self.async_schedule_update_ha_state() + + def unlock(self, **kwargs): + """Unlock the device.""" + from kiwiki import KiwiException + try: + self._client.open_door(self.lock_id) + except KiwiException: + _LOGGER.error("failed to open door") + else: + self._state = STATE_UNLOCKED + self.hass.add_job( + async_call_later, self.hass, UNLOCK_MAINTAIN_TIME, + self.clear_unlock_state + ) diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index ca7ff17a16ab93..53a95f7924c7aa 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -80,8 +80,11 @@ def _handle_service(service): host, port) return - groups = [SnapcastGroupDevice(group) for group in server.groups] - clients = [SnapcastClientDevice(client) for client in server.clients] + # Note: Host part is needed, when using multiple snapservers + hpid = '{}:{}'.format(host, port) + + groups = [SnapcastGroupDevice(group, hpid) for group in server.groups] + clients = [SnapcastClientDevice(client, hpid) for client in server.clients] devices = groups + clients hass.data[DATA_KEY] = devices async_add_devices(devices) @@ -90,10 +93,12 @@ def _handle_service(service): class SnapcastGroupDevice(MediaPlayerDevice): """Representation of a Snapcast group device.""" - def __init__(self, group): + def __init__(self, group, uid_part): """Initialize the Snapcast group device.""" group.set_callback(self.schedule_update_ha_state) self._group = group + self._uid = '{}{}_{}'.format(GROUP_PREFIX, uid_part, + self._group.identifier) @property def state(self): @@ -107,7 +112,7 @@ def state(self): @property def unique_id(self): """Return the ID of snapcast group.""" - return '{}{}'.format(GROUP_PREFIX, self._group.identifier) + return self._uid @property def name(self): @@ -185,15 +190,21 @@ def async_restore(self): class SnapcastClientDevice(MediaPlayerDevice): """Representation of a Snapcast client device.""" - def __init__(self, client): + def __init__(self, client, uid_part): """Initialize the Snapcast client device.""" client.set_callback(self.schedule_update_ha_state) self._client = client + self._uid = '{}{}_{}'.format(CLIENT_PREFIX, uid_part, + self._client.identifier) @property def unique_id(self): - """Return the ID of this snapcast client.""" - return '{}{}'.format(CLIENT_PREFIX, self._client.identifier) + """ + Return the ID of this snapcast client. + + Note: Host part is needed, when using multiple snapservers + """ + return self._uid @property def name(self): diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 16a0b80d1fd33d..3ca1c483ee097d 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -7,6 +7,7 @@ from concurrent.futures import ThreadPoolExecutor import logging import socket +from datetime import datetime, timedelta import voluptuous as vol @@ -19,7 +20,7 @@ async_dispatcher_connect from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-nest==4.0.1'] +REQUIREMENTS = ['python-nest==4.0.2'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -36,14 +37,23 @@ ATTR_HOME_MODE = 'home_mode' ATTR_STRUCTURE = 'structure' +ATTR_TRIP_ID = 'trip_id' +ATTR_ETA = 'eta' +ATTR_ETA_WINDOW = 'eta_window' + +HOME_MODE_AWAY = 'away' +HOME_MODE_HOME = 'home' SENSOR_SCHEMA = vol.Schema({ vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list) }) AWAY_SCHEMA = vol.Schema({ - vol.Required(ATTR_HOME_MODE): cv.string, - vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, cv.string) + vol.Required(ATTR_HOME_MODE): vol.In([HOME_MODE_AWAY, HOME_MODE_HOME]), + vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, cv.string), + vol.Optional(ATTR_TRIP_ID): cv.string, + vol.Optional(ATTR_ETA): cv.time_period, + vol.Optional(ATTR_ETA_WINDOW): cv.time_period }) CONFIG_SCHEMA = vol.Schema({ @@ -148,7 +158,11 @@ async def async_setup_nest(hass, nest, config, pin=None): hass, component, DOMAIN, discovered, config) def set_mode(service): - """Set the home/away mode for a Nest structure.""" + """ + Set the home/away mode for a Nest structure. + + You can set optional eta information when set mode to away. + """ if ATTR_STRUCTURE in service.data: structures = service.data[ATTR_STRUCTURE] else: @@ -158,6 +172,19 @@ def set_mode(service): if structure.name in structures: _LOGGER.info("Setting mode for %s", structure.name) structure.away = service.data[ATTR_HOME_MODE] + + if service.data[ATTR_HOME_MODE] == HOME_MODE_AWAY \ + and ATTR_ETA in service.data: + now = datetime.utcnow() + eta_begin = now + service.data[ATTR_ETA] + eta_window = service.data.get(ATTR_ETA_WINDOW, + timedelta(minutes=1)) + eta_end = eta_begin + eta_window + trip_id = service.data.get( + ATTR_TRIP_ID, "trip_{}".format(int(now.timestamp()))) + _LOGGER.info("Setting eta for %s, eta window starts at " + "%s ends at %s", trip_id, eta_begin, eta_end) + structure.set_eta(trip_id, eta_begin, eta_end) else: _LOGGER.error("Invalid structure %s", service.data[ATTR_STRUCTURE]) diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py index 97b7ac2290940d..18029691dc7bae 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -4,17 +4,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.arlo/ """ -import asyncio import logging -from datetime import timedelta import voluptuous as vol +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.components.arlo import ( - CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO) + CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -22,8 +22,6 @@ DEPENDENCIES = ['arlo'] -SCAN_INTERVAL = timedelta(seconds=90) - # sensor_type [ description, unit, icon ] SENSOR_TYPES = { 'last_capture': ['Last', None, 'run-fast'], @@ -39,8 +37,7 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Arlo IP sensor.""" arlo = hass.data.get(DATA_ARLO) if not arlo: @@ -50,24 +47,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for sensor_type in config.get(CONF_MONITORED_CONDITIONS): if sensor_type == 'total_cameras': sensors.append(ArloSensor( - hass, SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) + SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) else: for camera in arlo.cameras: name = '{0} {1}'.format( SENSOR_TYPES[sensor_type][0], camera.name) - sensors.append(ArloSensor(hass, name, camera, sensor_type)) + sensors.append(ArloSensor(name, camera, sensor_type)) - async_add_devices(sensors, True) + add_devices(sensors, True) class ArloSensor(Entity): """An implementation of a Netgear Arlo IP sensor.""" - def __init__(self, hass, name, device, sensor_type): + def __init__(self, name, device, sensor_type): """Initialize an Arlo sensor.""" - super().__init__() self._name = name - self._hass = hass self._data = device self._sensor_type = sensor_type self._state = None @@ -78,6 +73,16 @@ def name(self): """Return the name of this camera.""" return self._name + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + @property def state(self): """Return the state of the sensor.""" @@ -98,18 +103,7 @@ def unit_of_measurement(self): def update(self): """Get the latest data and updates the state.""" - try: - base_station = self._data.base_station - except (AttributeError, IndexError): - return - - if not base_station: - return - - base_station.refresh_rate = SCAN_INTERVAL.total_seconds() - - self._data.update() - + _LOGGER.debug("Updating Arlo sensor %s", self.name) if self._sensor_type == 'total_cameras': self._state = len(self._data.cameras) @@ -118,9 +112,13 @@ def update(self): elif self._sensor_type == 'last_capture': try: - video = self._data.videos()[0] + video = self._data.last_video self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") except (AttributeError, IndexError): + error_msg = \ + 'Video not found for {0}. Older than {1} days?'.format( + self.name, self._data.min_days_vdo_cache) + _LOGGER.debug(error_msg) self._state = None elif self._sensor_type == 'battery_level': diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index 88464675c21587..ea7a943881e00a 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -23,7 +23,7 @@ # color_status: "gray", "green", "yellow", "red" 'color_status'] -STRUCTURE_SENSOR_TYPES = ['eta'] +STRUCTURE_SENSOR_TYPES = ['eta', 'security_state'] _VALID_SENSOR_TYPES = SENSOR_TYPES + TEMP_SENSOR_TYPES + PROTECT_SENSOR_TYPES \ + STRUCTURE_SENSOR_TYPES diff --git a/homeassistant/components/sensor/tahoma.py b/homeassistant/components/sensor/tahoma.py index aedecfe61e564c..4e5e1b53aef241 100644 --- a/homeassistant/components/sensor/tahoma.py +++ b/homeassistant/components/sensor/tahoma.py @@ -11,12 +11,16 @@ from homeassistant.helpers.entity import Entity from homeassistant.components.tahoma import ( DOMAIN as TAHOMA_DOMAIN, TahomaDevice) +from homeassistant.const import (ATTR_BATTERY_LEVEL) DEPENDENCIES = ['tahoma'] _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=120) + +ATTR_RSSI_LEVEL = 'rssi_level' +ATTR_STATUS = 'status' def setup_platform(hass, config, add_devices, discovery_info=None): @@ -53,6 +57,25 @@ def unit_of_measurement(self): elif self.tahoma_device.type == 'Humidity Sensor': return '%' + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attr = {} + super_attr = super().device_state_attributes + if super_attr is not None: + attr.update(super_attr) + + if 'core:RSSILevelState' in self.tahoma_device.active_states: + attr[ATTR_RSSI_LEVEL] = \ + self.tahoma_device.active_states['core:RSSILevelState'] + if 'core:SensorDefectState' in self.tahoma_device.active_states: + attr[ATTR_BATTERY_LEVEL] = \ + self.tahoma_device.active_states['core:SensorDefectState'] + if 'core:StatusState' in self.tahoma_device.active_states: + attr[ATTR_STATUS] = \ + self.tahoma_device.active_states['core:StatusState'] + return attr + def update(self): """Update the state.""" self.controller.get_states([self.tahoma_device]) @@ -62,3 +85,5 @@ def update(self): if self.tahoma_device.type == 'io:SomfyContactIOSystemSensor': self.current_value = self.tahoma_device.active_states[ 'core:ContactState'] + + _LOGGER.debug("Update %s, value: %d", self._name, self.current_value) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 19bf19a799a266..6b8bded59b83a8 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -576,3 +576,28 @@ shopping_list: name: description: The name of the item to mark as completed. example: Beer + +nest: + set_mode: + description: > + Set the home/away mode for a Nest structure. + Set to away mode will also set Estimated Arrival Time if provided. + Set ETA will cause the thermostat to begin warming or cooling the home before the user arrives. + After ETA set other Automation can read ETA sensor as a signal to prepare the home for + the user's arrival. + fields: + home_mode: + description: home or away + example: home + structure: + description: Optional structure name. Default set all structures managed by Home Assistant. + example: My Home + eta: + description: Optional Estimated Arrival Time from now. + example: 0:10 + eta_window: + description: Optional ETA window. Default is 1 minute. + example: 0:5 + trip_id: + description: Optional identity of a trip. Using the same trip_ID will update the estimation. + example: trip_back_home diff --git a/homeassistant/components/switch/tahoma.py b/homeassistant/components/switch/tahoma.py index aa3554a494c695..3f26e1ac7a8e30 100644 --- a/homeassistant/components/switch/tahoma.py +++ b/homeassistant/components/switch/tahoma.py @@ -12,11 +12,18 @@ from homeassistant.components.switch import SwitchDevice from homeassistant.components.tahoma import ( DOMAIN as TAHOMA_DOMAIN, TahomaDevice) +from homeassistant.const import (STATE_OFF, STATE_ON) DEPENDENCIES = ['tahoma'] _LOGGER = logging.getLogger(__name__) +ATTR_RSSI_LEVEL = 'rssi_level' +ATTR_STATUS = 'status' +ATTR_LOCK_TIMER = 'priority_lock_timer' +ATTR_LOCK_LEVEL = 'priority_lock_level' +ATTR_LOCK_ORIG = 'priority_lock_originator' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Tahoma switches.""" @@ -30,6 +37,27 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class TahomaSwitch(TahomaDevice, SwitchDevice): """Representation a Tahoma Switch.""" + def __init__(self, tahoma_device, controller): + """Initialize the switch.""" + super().__init__(tahoma_device, controller) + self._state = STATE_OFF + self._skip_update = False + + def update(self): + """Update method.""" + # Postpone the immediate state check for changes that take time. + if self._skip_update: + self._skip_update = False + return + + self.controller.get_states([self.tahoma_device]) + if self.tahoma_device.type == 'io:OnOffLightIOComponent': + if self.tahoma_device.active_states['core:OnOffState'] == 'on': + self._state = STATE_ON + else: + self._state = STATE_OFF + _LOGGER.debug("Update %s, state: %s", self._name, self._state) + @property def device_class(self): """Return the class of the device.""" @@ -39,7 +67,23 @@ def device_class(self): def turn_on(self, **kwargs): """Send the on command.""" - self.toggle() + _LOGGER.debug("Turn on: %s", self._name) + if self.tahoma_device.type == 'rts:GarageDoor4TRTSComponent': + self.toggle() + else: + self.apply_action('on') + self._skip_update = True + self._state = STATE_ON + + def turn_off(self, **kwargs): + """Send the off command.""" + _LOGGER.debug("Turn off: %s", self._name) + if self.tahoma_device.type == 'rts:GarageDoor4TRTSComponent': + return + else: + self.apply_action('off') + self._skip_update = True + self._state = STATE_OFF def toggle(self, **kwargs): """Click the switch.""" @@ -48,4 +92,33 @@ def toggle(self, **kwargs): @property def is_on(self): """Get whether the switch is in on state.""" - return False + if self.tahoma_device.type == 'rts:GarageDoor4TRTSComponent': + return False + return bool(self._state == STATE_ON) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attr = {} + super_attr = super().device_state_attributes + if super_attr is not None: + attr.update(super_attr) + + if 'core:RSSILevelState' in self.tahoma_device.active_states: + attr[ATTR_RSSI_LEVEL] = \ + self.tahoma_device.active_states['core:RSSILevelState'] + if 'core:StatusState' in self.tahoma_device.active_states: + attr[ATTR_STATUS] = \ + self.tahoma_device.active_states['core:StatusState'] + if 'core:PriorityLockTimerState' in self.tahoma_device.active_states: + attr[ATTR_LOCK_TIMER] = \ + self.tahoma_device.active_states['core:PriorityLockTimerState'] + if 'io:PriorityLockLevelState' in self.tahoma_device.active_states: + attr[ATTR_LOCK_LEVEL] = \ + self.tahoma_device.active_states['io:PriorityLockLevelState'] + if 'io:PriorityLockOriginatorState' in \ + self.tahoma_device.active_states: + attr[ATTR_LOCK_ORIG] = \ + self.tahoma_device.active_states[ + 'io:PriorityLockOriginatorState'] + return attr diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 84edd9afd40990..1eb5506d83eb4a 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -32,7 +32,7 @@ }, extra=vol.ALLOW_EXTRA) TAHOMA_COMPONENTS = [ - 'scene', 'sensor', 'cover', 'switch' + 'scene', 'sensor', 'cover', 'switch', 'binary_sensor' ] TAHOMA_TYPES = { @@ -48,6 +48,10 @@ 'io:WindowOpenerVeluxIOComponent': 'cover', 'io:LightIOSystemSensor': 'sensor', 'rts:GarageDoor4TRTSComponent': 'switch', + 'io:VerticalExteriorAwningIOComponent': 'cover', + 'io:HorizontalAwningIOComponent': 'cover', + 'io:OnOffLightIOComponent': 'switch', + 'rtds:RTDSSmokeSensor': 'smoke', } diff --git a/requirements_all.txt b/requirements_all.txt index 478a0cb54794cd..aa346e66ef134e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -482,6 +482,9 @@ keyring==12.2.1 # homeassistant.scripts.keyring keyrings.alt==3.1 +# homeassistant.components.lock.kiwi +kiwiki-client==0.1.1 + # homeassistant.components.konnected konnected==0.1.2 @@ -734,7 +737,7 @@ pyairvisual==1.0.0 pyalarmdotcom==0.3.2 # homeassistant.components.arlo -pyarlo==0.1.2 +pyarlo==0.1.6 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.5 @@ -1051,7 +1054,7 @@ python-mpd2==1.0.0 python-mystrom==0.4.4 # homeassistant.components.nest -python-nest==4.0.1 +python-nest==4.0.2 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1 diff --git a/tests/components/media_player/test_samsungtv.py b/tests/components/media_player/test_samsungtv.py index c3753eb53b52b7..b5baf8b078b6ff 100644 --- a/tests/components/media_player/test_samsungtv.py +++ b/tests/components/media_player/test_samsungtv.py @@ -29,7 +29,15 @@ } -class PackageException(Exception): +class AccessDenied(Exception): + """Dummy Exception.""" + + +class ConnectionClosed(Exception): + """Dummy Exception.""" + + +class UnhandledResponse(Exception): """Dummy Exception.""" @@ -45,9 +53,9 @@ def setUp(self, samsung_mock, wol_mock): self.hass.block_till_done() self.device = SamsungTVDevice(**WORKING_CONFIG) self.device._exceptions_class = mock.Mock() - self.device._exceptions_class.UnhandledResponse = PackageException - self.device._exceptions_class.AccessDenied = PackageException - self.device._exceptions_class.ConnectionClosed = PackageException + self.device._exceptions_class.UnhandledResponse = UnhandledResponse + self.device._exceptions_class.AccessDenied = AccessDenied + self.device._exceptions_class.ConnectionClosed = ConnectionClosed def tearDown(self): """Tear down test data.""" @@ -123,22 +131,46 @@ def test_send_key(self): def test_send_key_broken_pipe(self): """Testing broken pipe Exception.""" _remote = mock.Mock() - self.device.get_remote = mock.Mock() _remote.control = mock.Mock( - side_effect=BrokenPipeError("Boom")) - self.device.get_remote.return_value = _remote - self.device.send_key("HELLO") + side_effect=BrokenPipeError('Boom')) + self.device.get_remote = mock.Mock(return_value=_remote) + self.device.send_key('HELLO') + self.assertIsNone(self.device._remote) + self.assertEqual(STATE_ON, self.device._state) + + def test_send_key_connection_closed_retry_succeed(self): + """Test retry on connection closed.""" + _remote = mock.Mock() + _remote.control = mock.Mock(side_effect=[ + self.device._exceptions_class.ConnectionClosed('Boom'), + mock.DEFAULT]) + self.device.get_remote = mock.Mock(return_value=_remote) + command = 'HELLO' + self.device.send_key(command) + self.assertEqual(STATE_ON, self.device._state) + # verify that _remote.control() get called twice because of retry logic + expected = [mock.call(command), + mock.call(command)] + self.assertEqual(expected, _remote.control.call_args_list) + + def test_send_key_unhandled_response(self): + """Testing unhandled response exception.""" + _remote = mock.Mock() + _remote.control = mock.Mock( + side_effect=self.device._exceptions_class.UnhandledResponse('Boom') + ) + self.device.get_remote = mock.Mock(return_value=_remote) + self.device.send_key('HELLO') self.assertIsNone(self.device._remote) self.assertEqual(STATE_ON, self.device._state) def test_send_key_os_error(self): """Testing broken pipe Exception.""" _remote = mock.Mock() - self.device.get_remote = mock.Mock() _remote.control = mock.Mock( - side_effect=OSError("Boom")) - self.device.get_remote.return_value = _remote - self.device.send_key("HELLO") + side_effect=OSError('Boom')) + self.device.get_remote = mock.Mock(return_value=_remote) + self.device.send_key('HELLO') self.assertIsNone(self.device._remote) self.assertEqual(STATE_OFF, self.device._state)