diff --git a/.coveragerc b/.coveragerc index e97d197ca94..4751ddce219 100644 --- a/.coveragerc +++ b/.coveragerc @@ -88,7 +88,7 @@ omit = homeassistant/components/hive.py homeassistant/components/*/hive.py - homeassistant/components/homematic.py + homeassistant/components/homematic/__init__.py homeassistant/components/*/homematic.py homeassistant/components/insteon_local.py @@ -264,6 +264,7 @@ omit = homeassistant/components/*/zoneminder.py homeassistant/components/alarm_control_panel/alarmdotcom.py + homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/egardia.py homeassistant/components/alarm_control_panel/ialarm.py @@ -283,8 +284,10 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/caldav.py homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py + homeassistant/components/camera/canary.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py homeassistant/components/camera/mjpeg.py @@ -362,6 +365,7 @@ omit = homeassistant/components/light/decora.py homeassistant/components/light/decora_wifi.py homeassistant/components/light/flux_led.py + homeassistant/components/light/greenwave.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py homeassistant/components/light/lifx.py @@ -425,6 +429,7 @@ omit = homeassistant/components/media_player/sonos.py homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py + homeassistant/components/media_player/ue_smart_radio.py homeassistant/components/media_player/vizio.py homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py @@ -500,6 +505,7 @@ omit = homeassistant/components/sensor/deluge.py homeassistant/components/sensor/deutsche_bahn.py homeassistant/components/sensor/dht.py + homeassistant/components/sensor/discogs.py homeassistant/components/sensor/dnsip.py homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py @@ -528,7 +534,6 @@ omit = homeassistant/components/sensor/haveibeenpwned.py homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/htu21d.py - homeassistant/components/sensor/hydroquebec.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/imap_email_content.py homeassistant/components/sensor/influxdb.py diff --git a/CODEOWNERS b/CODEOWNERS index ac0f794482a..37a2494c182 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -53,10 +53,11 @@ homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/monoprice.py @etsinko homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth +homeassistant/components/plant.py @ChristianKuehnel homeassistant/components/sensor/airvisual.py @bachya homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 -homeassistant/components/sensor/miflora.py @danielhiversen +homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/sytadin.py @gautric homeassistant/components/sensor/tibber.py @danielhiversen homeassistant/components/sensor/waqi.py @andrey-git diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index a8852b910c2..b7301e13bea 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -230,7 +230,7 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: def cmdline() -> List[str]: """Collect path and arguments to re-execute the current hass instance.""" - if sys.argv[0].endswith(os.path.sep + '__main__.py'): + if os.path.basename(sys.argv[0]) == '__main__.py': modulepath = os.path.dirname(sys.argv[0]) os.environ['PYTHONPATH'] = os.path.dirname(modulepath) return [sys.executable] + [arg for arg in sys.argv if diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index 3b58eb0b71d..d5fbbec5998 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -7,30 +7,21 @@ https://home-assistant.io/components/alarm_control_panel.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.components.alarm_control_panel as alarm - -from homeassistant.components.alarmdecoder import (DATA_AD, - SIGNAL_PANEL_MESSAGE) - +from homeassistant.components.alarmdecoder import ( + DATA_AD, SIGNAL_PANEL_MESSAGE) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN, STATE_ALARM_TRIGGERED) + STATE_ALARM_TRIGGERED) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['alarmdecoder'] -@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 for AlarmDecoder alarm panels.""" - _LOGGER.debug("AlarmDecoderAlarmPanel: setup") - - device = AlarmDecoderAlarmPanel("Alarm Panel", hass) - - async_add_devices([device]) + add_devices([AlarmDecoderAlarmPanel()]) return True @@ -38,38 +29,35 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): """Representation of an AlarmDecoder-based alarm panel.""" - def __init__(self, name, hass): + def __init__(self): """Initialize the alarm panel.""" self._display = "" - self._name = name - self._state = STATE_UNKNOWN - - _LOGGER.debug("Setting up panel") + self._name = "Alarm Panel" + self._state = None @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) - @callback def _message_callback(self, message): if message.alarm_sounding or message.fire_alarm: if self._state != STATE_ALARM_TRIGGERED: self._state = STATE_ALARM_TRIGGERED - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() elif message.armed_away: if self._state != STATE_ALARM_ARMED_AWAY: self._state = STATE_ALARM_ARMED_AWAY - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() elif message.armed_home: if self._state != STATE_ALARM_ARMED_HOME: self._state = STATE_ALARM_ARMED_HOME - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() else: if self._state != STATE_ALARM_DISARMED: self._state = STATE_ALARM_DISARMED - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() @property def name(self): @@ -91,26 +79,20 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): """Return the state of the device.""" return self._state - @asyncio.coroutine - def async_alarm_disarm(self, code=None): + def alarm_disarm(self, code=None): """Send disarm command.""" - _LOGGER.debug("alarm_disarm: %s", code) if code: _LOGGER.debug("alarm_disarm: sending %s1", str(code)) self.hass.data[DATA_AD].send("{!s}1".format(code)) - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): + def alarm_arm_away(self, code=None): """Send arm away command.""" - _LOGGER.debug("alarm_arm_away: %s", code) if code: _LOGGER.debug("alarm_arm_away: sending %s2", str(code)) self.hass.data[DATA_AD].send("{!s}2".format(code)) - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): + def alarm_arm_home(self, code=None): """Send arm home command.""" - _LOGGER.debug("alarm_arm_home: %s", code) if code: _LOGGER.debug("alarm_arm_home: sending %s3", str(code)) self.hass.data[DATA_AD].send("{!s}3".format(code)) diff --git a/homeassistant/components/alarm_control_panel/canary.py b/homeassistant/components/alarm_control_panel/canary.py new file mode 100644 index 00000000000..fb5c4c37e8d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/canary.py @@ -0,0 +1,92 @@ +""" +Support for Canary alarm. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.canary/ +""" +import logging + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.canary import DATA_CANARY +from homeassistant.const import STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, \ + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_HOME + +DEPENDENCIES = ['canary'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary alarms.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + devices.append(CanaryAlarm(data, location.location_id)) + + add_devices(devices, True) + + +class CanaryAlarm(AlarmControlPanel): + """Representation of a Canary alarm control panel.""" + + def __init__(self, data, location_id): + """Initialize a Canary security camera.""" + self._data = data + self._location_id = location_id + + @property + def name(self): + """Return the name of the alarm.""" + location = self._data.get_location(self._location_id) + return location.name + + @property + def state(self): + """Return the state of the device.""" + from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, \ + LOCATION_MODE_NIGHT + + location = self._data.get_location(self._location_id) + + if location.is_private: + return STATE_ALARM_DISARMED + + mode = location.mode + if mode.name == LOCATION_MODE_AWAY: + return STATE_ALARM_ARMED_AWAY + elif mode.name == LOCATION_MODE_HOME: + return STATE_ALARM_ARMED_HOME + elif mode.name == LOCATION_MODE_NIGHT: + return STATE_ALARM_ARMED_NIGHT + else: + return None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + location = self._data.get_location(self._location_id) + return { + 'private': location.is_private + } + + def alarm_disarm(self, code=None): + """Send disarm command.""" + location = self._data.get_location(self._location_id) + self._data.set_location_mode(self._location_id, location.mode.name, + True) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + from canary.api import LOCATION_MODE_HOME + self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + from canary.api import LOCATION_MODE_AWAY + self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + from canary.api import LOCATION_MODE_NIGHT + self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 7719ab884bc..82c26c98104 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -116,12 +116,20 @@ class EgardiaAlarm(alarm.AlarmControlPanel): """Return the state of the device.""" return self._status + @property + def should_poll(self): + """Poll if no report server is enabled.""" + if not self._rs_enabled: + return True + return False + def handle_system_status_event(self, event): """Handle egardia_system_status_event.""" if event.data.get('status') is not None: statuscode = event.data.get('status') status = self.lookupstatusfromcode(statuscode) self.parsestatus(status) + self.schedule_update_ha_state() def listen_to_system_status(self): """Subscribe to egardia_system_status event.""" @@ -161,9 +169,8 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def update(self): """Update the alarm status.""" - if not self._rs_enabled: - status = self._egardiasystem.getstate() - self.parsestatus(status) + status = self._egardiasystem.getstate() + self.parsestatus(status) def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 6f22d6a358c..5c1323989d4 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -14,7 +14,9 @@ from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME, + STATE_ALARM_ARMED_CUSTOM_BYPASS) + REQUIREMENTS = ['total_connect_client==0.16'] @@ -76,6 +78,8 @@ class TotalConnect(alarm.AlarmControlPanel): state = STATE_ALARM_ARMED_AWAY elif status == self._client.ARMED_STAY_NIGHT: state = STATE_ALARM_ARMED_NIGHT + elif status == self._client.ARMED_CUSTOM_BYPASS: + state = STATE_ALARM_ARMED_CUSTOM_BYPASS elif status == self._client.ARMING: state = STATE_ALARM_ARMING elif status == self._client.DISARMING: diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index 011cc3ad21d..6e30a83d96a 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -4,16 +4,13 @@ Support for AlarmDecoder devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/alarmdecoder/ """ -import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.discovery import load_platform REQUIREMENTS = ['alarmdecoder==0.12.3'] @@ -71,9 +68,9 @@ ZONE_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_DEVICE): vol.Any(DEVICE_SOCKET_SCHEMA, - DEVICE_SERIAL_SCHEMA, - DEVICE_USB_SCHEMA), + vol.Required(CONF_DEVICE): vol.Any( + DEVICE_SOCKET_SCHEMA, DEVICE_SERIAL_SCHEMA, + DEVICE_USB_SCHEMA), vol.Optional(CONF_PANEL_DISPLAY, default=DEFAULT_PANEL_DISPLAY): cv.boolean, vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, @@ -81,8 +78,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +def setup(hass, config): """Set up for the AlarmDecoder devices.""" from alarmdecoder import AlarmDecoder from alarmdecoder.devices import (SocketDevice, SerialDevice, USBDevice) @@ -99,32 +95,25 @@ def async_setup(hass, config): path = DEFAULT_DEVICE_PATH baud = DEFAULT_DEVICE_BAUD - sync_connect = asyncio.Future(loop=hass.loop) - - def handle_open(device): - """Handle the successful connection.""" - _LOGGER.info("Established a connection with the alarmdecoder") - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - sync_connect.set_result(True) - - @callback def stop_alarmdecoder(event): """Handle the shutdown of AlarmDecoder.""" _LOGGER.debug("Shutting down alarmdecoder") controller.close() - @callback def handle_message(sender, message): """Handle message from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, message) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_PANEL_MESSAGE, message) def zone_fault_callback(sender, zone): """Handle zone fault from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_FAULT, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_FAULT, zone) def zone_restore_callback(sender, zone): """Handle zone restore from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_RESTORE, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_RESTORE, zone) controller = False if device_type == 'socket': @@ -139,7 +128,6 @@ def async_setup(hass, config): AlarmDecoder(USBDevice.find()) return False - controller.on_open += handle_open controller.on_message += handle_message controller.on_zone_fault += zone_fault_callback controller.on_zone_restore += zone_restore_callback @@ -148,21 +136,16 @@ def async_setup(hass, config): controller.open(baud) - result = yield from sync_connect + _LOGGER.debug("Established a connection with the alarmdecoder") + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - if not result: - return False - - hass.async_add_job( - async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, - config)) + load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) if zones: - hass.async_add_job(async_load_platform( - hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config)) + load_platform( + hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config) if display: - hass.async_add_job(async_load_platform( - hass, 'sensor', DOMAIN, conf, config)) + load_platform(hass, 'sensor', DOMAIN, conf, config) return True diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index c8eb1841c0d..bb6bfa0e9db 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.8'] +REQUIREMENTS = ['pyatv==0.3.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 9e48a30d04a..a0c141914ed 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -34,6 +34,7 @@ DEVICE_CLASSES = [ 'plug', # On means plugged in, Off means unplugged 'power', # Power, over-current, etc 'presence', # On means home, Off means away + 'problem', # On means there is a problem, Off means the status is OK 'safety', # Generic on=unsafe, off=safe 'smoke', # Smoke detector 'sound', # On means sound detected, Off means no sound diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index bc05e4d84d8..f42d0de4bb0 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -7,39 +7,29 @@ https://home-assistant.io/components/binary_sensor.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.binary_sensor import BinarySensorDevice - -from homeassistant.components.alarmdecoder import (ZONE_SCHEMA, - CONF_ZONES, - CONF_ZONE_NAME, - CONF_ZONE_TYPE, - SIGNAL_ZONE_FAULT, - SIGNAL_ZONE_RESTORE) - +from homeassistant.components.alarmdecoder import ( + ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE, + SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE) DEPENDENCIES = ['alarmdecoder'] _LOGGER = logging.getLogger(__name__) -@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 AlarmDecoder binary sensor devices.""" configured_zones = discovery_info[CONF_ZONES] devices = [] - for zone_num in configured_zones: device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] - device = AlarmDecoderBinarySensor( - hass, zone_num, zone_name, zone_type) + device = AlarmDecoderBinarySensor(zone_num, zone_name, zone_type) devices.append(device) - async_add_devices(devices) + add_devices(devices) return True @@ -47,7 +37,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AlarmDecoderBinarySensor(BinarySensorDevice): """Representation of an AlarmDecoder binary sensor.""" - def __init__(self, hass, zone_number, zone_name, zone_type): + def __init__(self, zone_number, zone_name, zone_type): """Initialize the binary_sensor.""" self._zone_number = zone_number self._zone_type = zone_type @@ -55,16 +45,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): self._name = zone_name self._type = zone_type - _LOGGER.debug("Setup up zone: %s", self._name) - @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_FAULT, self._fault_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_FAULT, self._fault_callback) - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_RESTORE, self._restore_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_RESTORE, self._restore_callback) @property def name(self): @@ -97,16 +85,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """Return the class of this sensor, from DEVICE_CLASSES.""" return self._zone_type - @callback def _fault_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 1 - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() - @callback def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 0 - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py index 7ba88f76611..d689f030d8a 100755 --- a/homeassistant/components/binary_sensor/concord232.py +++ b/homeassistant/components/binary_sensor/concord232.py @@ -118,7 +118,7 @@ class Concord232ZoneSensor(BinarySensorDevice): def is_on(self): """Return true if the binary sensor is on.""" # True means "faulted" or "open" or "abnormal state" - return bool(self._zone['state'] == 'Normal') + return bool(self._zone['state'] != 'Normal') def update(self): """Get updated stats from API.""" diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index fd6269e3630..247ea0b231a 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -4,24 +4,31 @@ Support for ISY994 binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.isy994/ """ + +import asyncio import logging +from datetime import timedelta from typing import Callable # noqa +from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN import homeassistant.components.isy994 as isy from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -VALUE_TO_STATE = { - False: STATE_OFF, - True: STATE_ON, -} - UOM = ['2', '78'] STATES = [STATE_OFF, STATE_ON, 'true', 'false'] +ISY_DEVICE_TYPES = { + 'moisture': ['16.8', '16.13', '16.14'], + 'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'], + 'motion': ['16.1', '16.4', '16.5', '16.3'] +} + # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, @@ -32,10 +39,46 @@ def setup_platform(hass, config: ConfigType, return False devices = [] + devices_by_nid = {} + child_nodes = [] for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, states=STATES): - devices.append(ISYBinarySensorDevice(node)) + if node.parent_node is None: + device = ISYBinarySensorDevice(node) + devices.append(device) + devices_by_nid[node.nid] = device + else: + # We'll process the child nodes last, to ensure all parent nodes + # have been processed + child_nodes.append(node) + + for node in child_nodes: + try: + parent_device = devices_by_nid[node.parent_node.nid] + except KeyError: + _LOGGER.error("Node %s has a parent node %s, but no device " + "was created for the parent. Skipping.", + node.nid, node.parent_nid) + else: + device_type = _detect_device_type(node) + if device_type in ['moisture', 'opening']: + subnode_id = int(node.nid[-1]) + # Leak and door/window sensors work the same way with negative + # nodes and heartbeat nodes + if subnode_id == 4: + # Subnode 4 is the heartbeat node, which we will represent + # as a separate binary_sensor + device = ISYBinarySensorHeartbeat(node, parent_device) + parent_device.add_heartbeat_device(device) + devices.append(device) + elif subnode_id == 2: + parent_device.add_negative_node(node) + else: + # We don't yet have any special logic for other sensor types, + # so add the nodes as individual devices + device = ISYBinarySensorDevice(node) + devices.append(device) for program in isy.PROGRAMS.get(DOMAIN, []): try: @@ -48,23 +91,282 @@ def setup_platform(hass, config: ConfigType, add_devices(devices) +def _detect_device_type(node) -> str: + try: + device_type = node.type + except AttributeError: + # The type attribute didn't exist in the ISY's API response + return None + + split_type = device_type.split('.') + for device_class, ids in ISY_DEVICE_TYPES.items(): + if '{}.{}'.format(split_type[0], split_type[1]) in ids: + return device_class + + return None + + +def _is_val_unknown(val): + """Determine if a number value represents UNKNOWN from PyISY.""" + return val == -1*float('inf') + + class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): - """Representation of an ISY994 binary sensor device.""" + """Representation of an ISY994 binary sensor device. + + Often times, a single device is represented by multiple nodes in the ISY, + allowing for different nuances in how those devices report their on and + off events. This class turns those multiple nodes in to a single Hass + entity and handles both ways that ISY binary sensors can work. + """ def __init__(self, node) -> None: """Initialize the ISY994 binary sensor device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) + self._negative_node = None + self._heartbeat_device = None + self._device_class_from_type = _detect_device_type(self._node) + # pylint: disable=protected-access + if _is_val_unknown(self._node.status._val): + self._computed_state = None + else: + self._computed_state = bool(self._node.status._val) + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe(self._positive_node_control_handler) + + if self._negative_node is not None: + self._negative_node.controlEvents.subscribe( + self._negative_node_control_handler) + + def add_heartbeat_device(self, device) -> None: + """Register a heartbeat device for this sensor. + + The heartbeat node beats on its own, but we can gain a little + reliability by considering any node activity for this sensor + to be a heartbeat as well. + """ + self._heartbeat_device = device + + def _heartbeat(self) -> None: + """Send a heartbeat to our heartbeat device, if we have one.""" + if self._heartbeat_device is not None: + self._heartbeat_device.heartbeat() + + def add_negative_node(self, child) -> None: + """Add a negative node to this binary sensor device. + + The negative node is a node that can receive the 'off' events + for the sensor, depending on device configuration and type. + """ + self._negative_node = child + + # pylint: disable=protected-access + if not _is_val_unknown(self._negative_node.status._val): + # If the negative node has a value, it means the negative node is + # in use for this device. Therefore, we cannot determine the state + # of the sensor until we receive our first ON event. + self._computed_state = None + + def _negative_node_control_handler(self, event: object) -> None: + """Handle an "On" control event from the "negative" node.""" + if event == 'DON': + _LOGGER.debug("Sensor %s turning Off via the Negative node " + "sending a DON command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + def _positive_node_control_handler(self, event: object) -> None: + """Handle On and Off control event coming from the primary node. + + Depending on device configuration, sometimes only On events + will come to this node, with the negative node representing Off + events + """ + if event == 'DON': + _LOGGER.debug("Sensor %s turning On via the Primary node " + "sending a DON command", self.name) + self._computed_state = True + self.schedule_update_ha_state() + self._heartbeat() + if event == 'DOF': + _LOGGER.debug("Sensor %s turning Off via the Primary node " + "sending a DOF command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore primary node status updates. + + We listen directly to the Control events on all nodes for this + device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of the device. + + Insteon leak sensors set their primary node to On when the state is + DRY, not WET, so we invert the binary state if the user indicates + that it is a moisture sensor. + """ + if self._computed_state is None: + # Do this first so we don't invert None on moisture sensors + return None + + if self.device_class == 'moisture': + return not self._computed_state + + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Return the class of this device. + + This was discovered by parsing the device type code during init + """ + return self._device_class_from_type + + +class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice): + """Representation of the battery state of an ISY994 sensor.""" + + def __init__(self, node, parent_device) -> None: + """Initialize the ISY994 binary sensor device.""" + super().__init__(node) + self._computed_state = None + self._parent_device = parent_device + self._heartbeat_timer = None + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe( + self._heartbeat_node_control_handler) + + # Start the timer on bootup, so we can change from UNKNOWN to ON + self._restart_timer() + + def _heartbeat_node_control_handler(self, event: object) -> None: + """Update the heartbeat timestamp when an On event is sent.""" + if event == 'DON': + self.heartbeat() + + def heartbeat(self): + """Mark the device as online, and restart the 25 hour timer. + + This gets called when the heartbeat node beats, but also when the + parent sensor sends any events, as we can trust that to mean the device + is online. This mitigates the risk of false positives due to a single + missed heartbeat event. + """ + self._computed_state = False + self._restart_timer() + self.schedule_update_ha_state() + + def _restart_timer(self): + """Restart the 25 hour timer.""" + try: + self._heartbeat_timer() + self._heartbeat_timer = None + except TypeError: + # No heartbeat timer is active + pass + + # pylint: disable=unused-argument + @callback + def timer_elapsed(now) -> None: + """Heartbeat missed; set state to indicate dead battery.""" + self._computed_state = True + self._heartbeat_timer = None + self.schedule_update_ha_state() + + point_in_time = dt_util.utcnow() + timedelta(hours=25) + _LOGGER.debug("Timer starting. Now: %s Then: %s", + dt_util.utcnow(), point_in_time) + + self._heartbeat_timer = async_track_point_in_utc_time( + self.hass, timer_elapsed, point_in_time) + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore node status updates. + + We listen directly to the Control events for this device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of this sensor.""" + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Get the class of this device.""" + return 'battery' + + @property + def device_state_attributes(self): + """Get the state attributes for the device.""" + attr = super().device_state_attributes + attr['parent_entity_id'] = self._parent_device.entity_id + return attr + + +class ISYBinarySensorProgram(isy.ISYDevice, BinarySensorDevice): + """Representation of an ISY994 binary sensor program. + + This does not need all of the subnode logic in the device version of binary + sensors. + """ + + def __init__(self, name, node) -> None: + """Initialize the ISY994 binary sensor program.""" + super().__init__(node) + self._name = name @property def is_on(self) -> bool: """Get whether the ISY994 binary sensor device is on.""" return bool(self.value) - - -class ISYBinarySensorProgram(ISYBinarySensorDevice): - """Representation of an ISY994 binary sensor program.""" - - def __init__(self, name, node) -> None: - """Initialize the ISY994 binary sensor program.""" - ISYBinarySensorDevice.__init__(self, node) - self._name = name diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 5ca037767f2..36e8868661d 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -9,40 +9,48 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) from homeassistant.const import ( - CONF_NAME, CONF_ENTITY_ID, CONF_TYPE, STATE_UNKNOWN, - ATTR_ENTITY_ID, CONF_DEVICE_CLASS) + ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + STATE_UNKNOWN) from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change _LOGGER = logging.getLogger(__name__) ATTR_HYSTERESIS = 'hysteresis' +ATTR_LOWER = 'lower' +ATTR_POSITION = 'position' ATTR_SENSOR_VALUE = 'sensor_value' -ATTR_THRESHOLD = 'threshold' ATTR_TYPE = 'type' +ATTR_UPPER = 'upper' CONF_HYSTERESIS = 'hysteresis' CONF_LOWER = 'lower' -CONF_THRESHOLD = 'threshold' CONF_UPPER = 'upper' DEFAULT_NAME = 'Threshold' DEFAULT_HYSTERESIS = 0.0 -SENSOR_TYPES = [CONF_LOWER, CONF_UPPER] +POSITION_ABOVE = 'above' +POSITION_BELOW = 'below' +POSITION_IN_RANGE = 'in_range' +POSITION_UNKNOWN = 'unknown' + +TYPE_LOWER = 'lower' +TYPE_RANGE = 'range' +TYPE_UPPER = 'upper' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_THRESHOLD): vol.Coerce(float), - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional( - CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): + vol.Coerce(float), + vol.Optional(CONF_LOWER): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UPPER): vol.Coerce(float), }) @@ -51,47 +59,44 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Threshold sensor.""" entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) - threshold = config.get(CONF_THRESHOLD) + lower = config.get(CONF_LOWER) + upper = config.get(CONF_UPPER) hysteresis = config.get(CONF_HYSTERESIS) - limit_type = config.get(CONF_TYPE) device_class = config.get(CONF_DEVICE_CLASS) async_add_devices([ThresholdSensor( - hass, entity_id, name, threshold, - hysteresis, limit_type, device_class) - ], True) - - return True + hass, entity_id, name, lower, upper, hysteresis, device_class)], True) class ThresholdSensor(BinarySensorDevice): """Representation of a Threshold sensor.""" - def __init__(self, hass, entity_id, name, threshold, - hysteresis, limit_type, device_class): + def __init__(self, hass, entity_id, name, lower, upper, hysteresis, + device_class): """Initialize the Threshold sensor.""" self._hass = hass self._entity_id = entity_id - self.is_upper = limit_type == 'upper' self._name = name - self._threshold = threshold + self._threshold_lower = lower + self._threshold_upper = upper self._hysteresis = hysteresis self._device_class = device_class - self._state = False - self.sensor_value = 0 - @callback + self._state_position = None + self._state = False + self.sensor_value = None + # pylint: disable=invalid-name + @callback def async_threshold_sensor_state_listener( entity, old_state, new_state): """Handle sensor state changes.""" - if new_state.state == STATE_UNKNOWN: - return - try: - self.sensor_value = float(new_state.state) - except ValueError: - _LOGGER.error("State is not numerical") + self.sensor_value = None if new_state.state == STATE_UNKNOWN \ + else float(new_state.state) + except (ValueError, TypeError): + self.sensor_value = None + _LOGGER.warning("State is not numerical") hass.async_add_job(self.async_update_ha_state, True) @@ -118,23 +123,67 @@ class ThresholdSensor(BinarySensorDevice): """Return the sensor class of the sensor.""" return self._device_class + @property + def threshold_type(self): + """Return the type of threshold this sensor represents.""" + if self._threshold_lower and self._threshold_upper: + return TYPE_RANGE + elif self._threshold_lower: + return TYPE_LOWER + elif self._threshold_upper: + return TYPE_UPPER + @property def device_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, - ATTR_SENSOR_VALUE: self.sensor_value, - ATTR_THRESHOLD: self._threshold, ATTR_HYSTERESIS: self._hysteresis, - ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER, + ATTR_LOWER: self._threshold_lower, + ATTR_POSITION: self._state_position, + ATTR_SENSOR_VALUE: self.sensor_value, + ATTR_TYPE: self.threshold_type, + ATTR_UPPER: self._threshold_upper, } @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" - if self._hysteresis == 0 and self.sensor_value == self._threshold: + def below(threshold): + """Determine if the sensor value is below a threshold.""" + return self.sensor_value < (threshold - self._hysteresis) + + def above(threshold): + """Determine if the sensor value is above a threshold.""" + return self.sensor_value > (threshold + self._hysteresis) + + if self.sensor_value is None: + self._state_position = POSITION_UNKNOWN self._state = False - elif self.sensor_value > (self._threshold + self._hysteresis): - self._state = self.is_upper - elif self.sensor_value < (self._threshold - self._hysteresis): - self._state = not self.is_upper + + elif self.threshold_type == TYPE_LOWER: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = True + elif above(self._threshold_lower): + self._state_position = POSITION_ABOVE + self._state = False + + elif self.threshold_type == TYPE_UPPER: + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = True + elif below(self._threshold_upper): + self._state_position = POSITION_BELOW + self._state = False + + elif self.threshold_type == TYPE_RANGE: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = False + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = False + elif above(self._threshold_lower) and below(self._threshold_upper): + self._state_position = POSITION_IN_RANGE + self._state = True diff --git a/homeassistant/components/binary_sensor/vera.py b/homeassistant/components/binary_sensor/vera.py index e16f4e17fa0..e87886376bc 100644 --- a/homeassistant/components/binary_sensor/vera.py +++ b/homeassistant/components/binary_sensor/vera.py @@ -19,8 +19,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Perform the setup for Vera controller devices.""" add_devices( - VeraBinarySensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['binary_sensor']) + VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['binary_sensor']) class VeraBinarySensor(VeraDevice, BinarySensorDevice): diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py new file mode 100644 index 00000000000..36894dcab61 --- /dev/null +++ b/homeassistant/components/calendar/caldav.py @@ -0,0 +1,230 @@ +""" +Support for WebDav Calendar. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.caldav/ +""" +import logging +import re +from datetime import datetime, timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) +from homeassistant.util import dt, Throttle + +REQUIREMENTS = ['caldav==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEVICE_ID = 'device_id' +CONF_CALENDARS = 'calendars' +CONF_CUSTOM_CALENDARS = 'custom_calendars' +CONF_CALENDAR = 'calendar' +CONF_SEARCH = 'search' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + # pylint: disable=no-value-for-parameter + vol.Required(CONF_URL): vol.Url(), + vol.Optional(CONF_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + cv.string + ])), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_CUSTOM_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CALENDAR): cv.string, + vol.Required(CONF_SEARCH): cv.string + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, disc_info=None): + """Set up the WebDav Calendar platform.""" + import caldav + + client = caldav.DAVClient(config.get(CONF_URL), + None, + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)) + + # Retrieve all the remote calendars + calendars = client.principal().calendars() + + calendar_devices = [] + for calendar in list(calendars): + # If a calendar name was given in the configuration, + # ignore all the others + if (config.get(CONF_CALENDARS) + and calendar.name not in config.get(CONF_CALENDARS)): + _LOGGER.debug("Ignoring calendar '%s'", calendar.name) + continue + + # Create additional calendars based on custom filtering + # rules + for cust_calendar in config.get(CONF_CUSTOM_CALENDARS): + # Check that the base calendar matches + if cust_calendar.get(CONF_CALENDAR) != calendar.name: + continue + + device_data = { + CONF_NAME: cust_calendar.get(CONF_NAME), + CONF_DEVICE_ID: "{} {}".format( + cust_calendar.get(CONF_CALENDAR), + cust_calendar.get(CONF_NAME)), + } + + calendar_devices.append( + WebDavCalendarEventDevice(hass, + device_data, + calendar, + True, + cust_calendar.get(CONF_SEARCH)) + ) + + # Create a default calendar if there was no custom one + if not config.get(CONF_CUSTOM_CALENDARS): + device_data = { + CONF_NAME: calendar.name, + CONF_DEVICE_ID: calendar.name + } + calendar_devices.append( + WebDavCalendarEventDevice(hass, device_data, calendar) + ) + + # Finally add all the calendars we've created + add_devices(calendar_devices) + + +class WebDavCalendarEventDevice(CalendarEventDevice): + """A device for getting the next Task from a WebDav Calendar.""" + + def __init__(self, + hass, + device_data, + calendar, + all_day=False, + search=None): + """Create the WebDav Calendar Event Device.""" + self.data = WebDavCalendarData(calendar, all_day, search) + super().__init__(hass, device_data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + return attributes + + +class WebDavCalendarData(object): + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, calendar, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + self.calendar = calendar + self.include_all_day = include_all_day + self.search = search + self.event = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = self.calendar.date_search( + dt.start_of_local_day(), + dt.start_of_local_day() + timedelta(days=1) + ) + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + results.sort(key=lambda x: self.to_datetime( + x.instance.vevent.dtstart.value + )) + + vevent = next(( + event.instance.vevent for event in results + if (self.is_matching(event.instance.vevent, self.search) + and (not self.is_all_day(event.instance.vevent) + or self.include_all_day) + and not self.is_over(event.instance.vevent))), None) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(results), + self.calendar.name, + ) + self.event = None + return True + + # Populate the entity attributes with the event values + self.event = { + "summary": vevent.summary.value, + "start": self.get_hass_date(vevent.dtstart.value), + "end": self.get_hass_date(vevent.dtend.value), + "location": self.get_attr_value(vevent, "location"), + "description": self.get_attr_value(vevent, "description") + } + return True + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter critera.""" + if search is None: + return True + + pattern = re.compile(search) + return (hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value)) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt.now() > WebDavCalendarData.to_datetime(vevent.dtend.value) + + @staticmethod + def get_hass_date(obj): + """Return if the event matches.""" + if isinstance(obj, datetime): + return {"dateTime": obj.isoformat()} + + return {"date": obj.isoformat()} + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return obj + return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min)) + + @staticmethod + def get_attr_value(obj, attribute): + """Return the value of the attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None diff --git a/homeassistant/components/camera/canary.py b/homeassistant/components/camera/canary.py new file mode 100644 index 00000000000..302758eee94 --- /dev/null +++ b/homeassistant/components/camera/canary.py @@ -0,0 +1,95 @@ +""" +Support for Canary camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.canary/ +""" +import logging + +import requests + +from homeassistant.components.camera import Camera +from homeassistant.components.canary import DATA_CANARY, DEFAULT_TIMEOUT + +DEPENDENCIES = ['canary'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_MOTION_START_TIME = "motion_start_time" +ATTR_MOTION_END_TIME = "motion_end_time" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + entries = data.get_motion_entries(location.location_id) + if entries: + devices.append(CanaryCamera(data, location.location_id, + DEFAULT_TIMEOUT)) + + add_devices(devices, True) + + +class CanaryCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, location_id, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._location_id = location_id + self._timeout = timeout + + self._location = None + self._motion_entry = None + self._image_content = None + + def camera_image(self): + """Update the status of the camera and return bytes of camera image.""" + self.update() + return self._image_content + + @property + def name(self): + """Return the name of this device.""" + return self._location.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._location.is_recording + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + if self._motion_entry is None: + return None + + return { + ATTR_MOTION_START_TIME: self._motion_entry.start_time, + ATTR_MOTION_END_TIME: self._motion_entry.end_time, + } + + def update(self): + """Update the status of the camera.""" + self._data.update() + self._location = self._data.get_location(self._location_id) + + entries = self._data.get_motion_entries(self._location_id) + if entries: + current = entries[0] + previous = self._motion_entry + + if previous is None or previous.entry_id != current.entry_id: + self._motion_entry = current + self._image_content = requests.get( + current.thumbnails[0].image_url, + timeout=self._timeout).content + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return not self._location.is_recording diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py new file mode 100644 index 00000000000..8ab7218e201 --- /dev/null +++ b/homeassistant/components/canary.py @@ -0,0 +1,117 @@ +""" +Support for Canary. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/canary/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import ConnectTimeout, HTTPError + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +REQUIREMENTS = ['py-canary==0.2.3'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'canary_notification' +NOTIFICATION_TITLE = 'Canary Setup' + +DOMAIN = 'canary' +DATA_CANARY = 'canary' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +DEFAULT_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +CANARY_COMPONENTS = [ + 'alarm_control_panel', 'camera', 'sensor' +] + + +def setup(hass, config): + """Set up the Canary component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + timeout = conf.get(CONF_TIMEOUT) + + try: + hass.data[DATA_CANARY] = CanaryData(username, password, timeout) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Canary service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component in CANARY_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class CanaryData(object): + """Get the latest data and update the states.""" + + def __init__(self, username, password, timeout): + """Init the Canary data object.""" + from canary.api import Api + self._api = Api(username, password, timeout) + + self._locations_by_id = {} + self._readings_by_device_id = {} + self._entries_by_location_id = {} + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Get the latest data from py-canary.""" + for location in self._api.get_locations(): + location_id = location.location_id + + self._locations_by_id[location_id] = location + self._entries_by_location_id[location_id] = self._api.get_entries( + location_id, entry_type="motion", limit=1) + + for device in location.devices: + if device.is_online: + self._readings_by_device_id[device.device_id] = \ + self._api.get_latest_readings(device.device_id) + + @property + def locations(self): + """Return a list of locations.""" + return self._locations_by_id.values() + + def get_motion_entries(self, location_id): + """Return a list of motion entries based on location_id.""" + return self._entries_by_location_id.get(location_id, []) + + def get_location(self, location_id): + """Return a location based on location_id.""" + return self._locations_by_id.get(location_id, []) + + def get_readings(self, device_id): + """Return a list of readings based on device_id.""" + return self._readings_by_device_id.get(device_id, []) + + def set_location_mode(self, location_id, mode_name, is_private=False): + """Set location mode.""" + self._api.set_location_mode(location_id, mode_name, is_private) + self.update(no_throttle=True) diff --git a/homeassistant/components/climate/hive.py b/homeassistant/components/climate/hive.py index 267657d56ce..8305e772869 100644 --- a/homeassistant/components/climate/hive.py +++ b/homeassistant/components/climate/hive.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/climate.hive/ """ from homeassistant.components.climate import ( ClimateDevice, STATE_AUTO, STATE_HEAT, STATE_OFF, STATE_ON, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) + SUPPORT_AUX_HEAT, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.components.hive import DATA_HIVE @@ -16,7 +16,9 @@ HIVE_TO_HASS_STATE = {'SCHEDULE': STATE_AUTO, 'MANUAL': STATE_HEAT, HASS_TO_HIVE_STATE = {STATE_AUTO: 'SCHEDULE', STATE_HEAT: 'MANUAL', STATE_ON: 'ON', STATE_OFF: 'OFF'} -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_OPERATION_MODE | + SUPPORT_AUX_HEAT) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -134,6 +136,43 @@ class HiveClimateEntity(ClimateDevice): for entity in self.session.entities: entity.handle_update(self.data_updatesource) + @property + def is_aux_heat_on(self): + """Return true if auxiliary heater is on.""" + boost_status = None + if self.device_type == "Heating": + boost_status = self.session.heating.get_boost(self.node_id) + elif self.device_type == "HotWater": + boost_status = self.session.hotwater.get_boost(self.node_id) + return boost_status == "ON" + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + target_boost_time = 30 + if self.device_type == "Heating": + curtemp = self.session.heating.current_temperature(self.node_id) + curtemp = round(curtemp * 2) / 2 + target_boost_temperature = curtemp + 0.5 + self.session.heating.turn_boost_on(self.node_id, + target_boost_time, + target_boost_temperature) + elif self.device_type == "HotWater": + self.session.hotwater.turn_boost_on(self.node_id, + target_boost_time) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + if self.device_type == "Heating": + self.session.heating.turn_boost_off(self.node_id) + elif self.device_type == "HotWater": + self.session.hotwater.turn_boost_off(self.node_id) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + def update(self): """Update all Node data frome Hive.""" self.session.core.update_data(self.node_id) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index 624729249aa..ed23d91587c 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -13,11 +13,12 @@ import async_timeout import voluptuous as vol from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, STATE_OFF, TEMP_CELSIUS, + TEMP_FAHRENHEIT) from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, - SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE, SUPPORT_SWING_MODE, + SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, SUPPORT_AUX_HEAT) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv @@ -41,9 +42,13 @@ _FETCH_FIELDS = ','.join([ 'acState', 'connectionStatus{isAlive}', 'temperatureUnit']) _INITIAL_FETCH_FIELDS = 'id,' + _FETCH_FIELDS -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | - SUPPORT_FAN_MODE | SUPPORT_AWAY_MODE | SUPPORT_SWING_MODE | - SUPPORT_AUX_HEAT) +FIELD_TO_FLAG = { + 'fanLevel': SUPPORT_FAN_MODE, + 'mode': SUPPORT_OPERATION_MODE, + 'swing': SUPPORT_SWING_MODE, + 'targetTemperature': SUPPORT_TARGET_TEMPERATURE, + 'on': SUPPORT_AUX_HEAT, +} @asyncio.coroutine @@ -85,7 +90,14 @@ class SensiboClimate(ClimateDevice): @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return self._supported_features + + @property + def state(self): + """Return the current state.""" + if not self.is_aux_heat_on: + return STATE_OFF + return super().state def _do_update(self, data): self._name = data['room']['name'] @@ -106,6 +118,10 @@ class SensiboClimate(ClimateDevice): else: self._temperature_unit = self.unit_of_measurement self._temperatures_list = [] + self._supported_features = 0 + for key in self._ac_states: + if key in FIELD_TO_FLAG: + self._supported_features |= FIELD_TO_FLAG[key] @property def device_state_attributes(self): @@ -196,13 +212,13 @@ class SensiboClimate(ClimateDevice): def min_temp(self): """Return the minimum temperature.""" return self._temperatures_list[0] \ - if len(self._temperatures_list) else super.min_temp() + if len(self._temperatures_list) else super().min_temp() @property def max_temp(self): """Return the maximum temperature.""" return self._temperatures_list[-1] \ - if len(self._temperatures_list) else super.max_temp() + if len(self._temperatures_list) else super().max_temp() @asyncio.coroutine def async_set_temperature(self, **kwargs): diff --git a/homeassistant/components/climate/vera.py b/homeassistant/components/climate/vera.py index 4644f86cba2..c9d22e41d81 100644 --- a/homeassistant/components/climate/vera.py +++ b/homeassistant/components/climate/vera.py @@ -32,8 +32,8 @@ SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up of Vera thermostats.""" add_devices_callback( - VeraThermostat(device, VERA_CONTROLLER) for - device in VERA_DEVICES['climate']) + VeraThermostat(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['climate']) class VeraThermostat(VeraDevice, ClimateDevice): diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 9bd91d22beb..58a2152f898 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -16,7 +16,7 @@ from homeassistant.components.alexa import smart_home from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS -REQUIREMENTS = ['warrant==0.5.0'] +REQUIREMENTS = ['warrant==0.6.1'] _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' MODE_DEV = 'development' -DEFAULT_MODE = MODE_DEV +DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] ALEXA_SCHEMA = vol.Schema({ @@ -42,10 +42,10 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([MODE_DEV] + list(SERVERS)), # Change to optional when we include real servers - vol.Required(CONF_COGNITO_CLIENT_ID): str, - vol.Required(CONF_USER_POOL_ID): str, - vol.Required(CONF_REGION): str, - vol.Required(CONF_RELAYER): str, + vol.Optional(CONF_COGNITO_CLIENT_ID): str, + vol.Optional(CONF_USER_POOL_ID): str, + vol.Optional(CONF_REGION): str, + vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA }), }, extra=vol.ALLOW_EXTRA) @@ -117,10 +117,6 @@ class Cloud: @property def subscription_expired(self): """Return a boolen if the subscription has expired.""" - # For now, don't enforce subscriptions to exist - if 'custom:sub-exp' not in self.claims: - return False - return dt_util.utcnow() > self.expiration_date @property diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 95bf5596835..9cad3ec77f3 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -68,11 +68,14 @@ def register(cloud, email, password): from botocore.exceptions import ClientError cognito = _cognito(cloud) + # Workaround for bug in Warrant. PR with fix: + # https://github.com/capless/warrant/pull/82 + cognito.add_base_attributes() try: if cloud.cognito_email_based: - cognito.register(email, password, email=email) + cognito.register(email, password) else: - cognito.register(_generate_username(email), password, email=email) + cognito.register(_generate_username(email), password) except ClientError as err: raise _map_aws_exception(err) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 440e4179eea..b13ec6d1e45 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -4,13 +4,12 @@ CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 SERVERS = { - # Example entry: - # 'production': { - # 'cognito_client_id': '', - # 'user_pool_id': '', - # 'region': '', - # 'relayer': '' - # } + 'production': { + 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', + 'user_pool_id': 'us-east-1_87ll5WOP8', + 'region': 'us-east-1', + 'relayer': 'wss://cloud.hass.io:8000/websocket' + } } MESSAGE_EXPIRATION = """ diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 64eccfaa2b8..6ede91e9b66 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,4 +1,4 @@ -"""Provide configuration end points for Z-Wave.""" +"""Provide configuration end points for Automations.""" import asyncio from homeassistant.components.config import EditIdBasedConfigView diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 1e83038278c..4dd1c9be364 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -69,7 +69,10 @@ class ISYCoverDevice(isy.ISYDevice, CoverDevice): @property def state(self) -> str: """Get the state of the ISY994 cover device.""" - return VALUE_TO_STATE.get(self.value, STATE_OPEN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover device.""" diff --git a/homeassistant/components/cover/tellstick.py b/homeassistant/components/cover/tellstick.py new file mode 100755 index 00000000000..56a5a24b409 --- /dev/null +++ b/homeassistant/components/cover/tellstick.py @@ -0,0 +1,65 @@ +""" +Support for Tellstick covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.tellstick/ +""" + + +from homeassistant.components.cover import CoverDevice +from homeassistant.components.tellstick import ( + DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, ATTR_DISCOVER_CONFIG, + DATA_TELLSTICK, TellstickDevice) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tellstick covers.""" + if (discovery_info is None or + discovery_info[ATTR_DISCOVER_DEVICES] is None): + return + + signal_repetitions = discovery_info.get( + ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS) + + add_devices([TellstickCover(hass.data[DATA_TELLSTICK][tellcore_id], + signal_repetitions) + for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]], + True) + + +class TellstickCover(TellstickDevice, CoverDevice): + """Representation of a Tellstick cover.""" + + @property + def is_closed(self): + """Return the current position of the cover is not possible.""" + return None + + @property + def assumed_state(self): + """Return True if unable to access real state of the entity.""" + return True + + def close_cover(self, **kwargs): + """Close the cover.""" + self._tellcore_device.down() + + def open_cover(self, **kwargs): + """Open the cover.""" + self._tellcore_device.up() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._tellcore_device.stop() + + def _parse_tellcore_data(self, tellcore_data): + """Turn the value received from tellcore into something useful.""" + pass + + def _parse_ha_data(self, kwargs): + """Turn the value from HA into something useful.""" + pass + + def _update_model(self, new_state, data): + """Update the device entity state to match the arguments.""" + pass diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py index 05be125ec6f..6cf269b75b3 100644 --- a/homeassistant/components/cover/vera.py +++ b/homeassistant/components/cover/vera.py @@ -18,8 +18,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera covers.""" add_devices( - VeraCover(device, VERA_CONTROLLER) for - device in VERA_DEVICES['cover']) + VeraCover(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['cover']) class VeraCover(VeraDevice, CoverDevice): diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index f2d2a4c74b5..495e377077f 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -67,6 +67,15 @@ _IP_NEIGH_REGEX = re.compile( r'\s?(router)?' r'(?P(\w+))') +_ARP_CMD = 'arp -n' +_ARP_REGEX = re.compile( + r'.+\s' + + r'\((?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' + + r'.+\s' + + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' + + r'\s' + + r'.*') + # pylint: disable=unused-argument def get_scanner(hass, config): @@ -76,7 +85,22 @@ def get_scanner(hass, config): return scanner if scanner.success_init else None -AsusWrtResult = namedtuple('AsusWrtResult', 'neighbors leases') +def _parse_lines(lines, regex): + """Parse the lines using the given regular expression. + + If a line can't be parsed it is logged and skipped in the output. + """ + results = [] + for line in lines: + match = regex.search(line) + if not match: + _LOGGER.debug("Could not parse row: %s", line) + continue + results.append(match.groupdict()) + return results + + +Device = namedtuple('Device', ['mac', 'ip', 'name']) class AsusWrtDeviceScanner(DeviceScanner): @@ -121,16 +145,13 @@ class AsusWrtDeviceScanner(DeviceScanner): def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [client['mac'] for client in self.last_results] + return list(self.last_results.keys()) def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - if not self.last_results: + if device not in self.last_results: return None - for client in self.last_results: - if client['mac'] == device: - return client['host'] - return None + return self.last_results[device].name def _update_info(self): """Ensure the information from the ASUSWRT router is up to date. @@ -145,72 +166,71 @@ class AsusWrtDeviceScanner(DeviceScanner): if not data: return False - active_clients = [client for client in data.values() if - client['status'] == 'REACHABLE' or - client['status'] == 'DELAY' or - client['status'] == 'STALE' or - client['status'] == 'IN_ASSOCLIST'] - self.last_results = active_clients + self.last_results = data return True def get_asuswrt_data(self): - """Retrieve data from ASUSWRT and return parsed result.""" - result = self.connection.get_result() - - if not result: - return {} + """Retrieve data from ASUSWRT. + Calls various commands on the router and returns the superset of all + responses. Some commands will not work on some routers. + """ devices = {} - if self.mode == 'ap': - for lease in result.leases: - match = _WL_REGEX.search(lease.decode('utf-8')) + devices.update(self._get_wl()) + devices.update(self._get_arp()) + devices.update(self._get_neigh()) + if not self.mode == 'ap': + devices.update(self._get_leases()) + return devices - if not match: - _LOGGER.warning("Could not parse wl row: %s", lease) - continue + def _get_wl(self): + lines = self.connection.run_command(_WL_CMD) + if not lines: + return {} + result = _parse_lines(lines, _WL_REGEX) + devices = {} + for device in result: + mac = device['mac'].upper() + devices[mac] = Device(mac, None, None) + return devices + def _get_leases(self): + lines = self.connection.run_command(_LEASES_CMD) + if not lines: + return {} + lines = [line for line in lines if not line.startswith('duid ')] + result = _parse_lines(lines, _LEASES_REGEX) + devices = {} + for device in result: + # For leases where the client doesn't set a hostname, ensure it + # is blank and not '*', which breaks entity_id down the line. + host = device['host'] + if host == '*': host = '' + mac = device['mac'].upper() + devices[mac] = Device(mac, device['ip'], host) + return devices - devices[match.group('mac').upper()] = { - 'host': host, - 'status': 'IN_ASSOCLIST', - 'ip': '', - 'mac': match.group('mac').upper(), - } - - else: - for lease in result.leases: - if lease.startswith(b'duid '): - continue - match = _LEASES_REGEX.search(lease.decode('utf-8')) - - if not match: - _LOGGER.warning("Could not parse lease row: %s", lease) - continue - - # For leases where the client doesn't set a hostname, ensure it - # is blank and not '*', which breaks entity_id down the line. - host = match.group('host') - if host == '*': - host = '' - - devices[match.group('mac')] = { - 'host': host, - 'status': '', - 'ip': match.group('ip'), - 'mac': match.group('mac').upper(), - } - - for neighbor in result.neighbors: - match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8')) - if not match: - _LOGGER.warning("Could not parse neighbor row: %s", - neighbor) - continue - if match.group('mac') in devices: - devices[match.group('mac')]['status'] = ( - match.group('status')) + def _get_neigh(self): + lines = self.connection.run_command(_IP_NEIGH_CMD) + if not lines: + return {} + result = _parse_lines(lines, _IP_NEIGH_REGEX) + devices = {} + for device in result: + mac = device['mac'].upper() + devices[mac] = Device(mac, None, None) + return devices + def _get_arp(self): + lines = self.connection.run_command(_ARP_CMD) + if not lines: + return {} + result = _parse_lines(lines, _ARP_REGEX) + devices = {} + for device in result: + mac = device['mac'].upper() + devices[mac] = Device(mac, device['ip'], None) return devices @@ -247,8 +267,8 @@ class SshConnection(_Connection): self._ssh_key = ssh_key self._ap = ap - def get_result(self): - """Retrieve a single AsusWrtResult through an SSH connection. + def run_command(self, command): + """Run commands through an SSH connection. Connect to the SSH server if not currently connected, otherwise use the existing connection. @@ -258,19 +278,10 @@ class SshConnection(_Connection): try: if not self.connected: self.connect() - if self._ap: - neighbors = [''] - self._ssh.sendline(_WL_CMD) - self._ssh.prompt() - leases_result = self._ssh.before.split(b'\n')[1:-1] - else: - self._ssh.sendline(_IP_NEIGH_CMD) - self._ssh.prompt() - neighbors = self._ssh.before.split(b'\n')[1:-1] - self._ssh.sendline(_LEASES_CMD) - self._ssh.prompt() - leases_result = self._ssh.before.split(b'\n')[1:-1] - return AsusWrtResult(neighbors, leases_result) + self._ssh.sendline(command) + self._ssh.prompt() + lines = self._ssh.before.split(b'\n')[1:-1] + return [line.decode('utf-8') for line in lines] except exceptions.EOF as err: _LOGGER.error("Connection refused. SSH enabled?") self.disconnect() @@ -326,8 +337,8 @@ class TelnetConnection(_Connection): self._ap = ap self._prompt_string = None - def get_result(self): - """Retrieve a single AsusWrtResult through a Telnet connection. + def run_command(self, command): + """Run a command through a Telnet connection. Connect to the Telnet server if not currently connected, otherwise use the existing connection. @@ -336,18 +347,9 @@ class TelnetConnection(_Connection): if not self.connected: self.connect() - self._telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii')) - neighbors = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - if self._ap: - self._telnet.write('{}\n'.format(_WL_CMD).encode('ascii')) - leases_result = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - else: - self._telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) - leases_result = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - return AsusWrtResult(neighbors, leases_result) + self._telnet.write('{}\n'.format(command).encode('ascii')) + return (self._telnet.read_until(self._prompt_string). + split(b'\n')[1:-1]) except EOFError: _LOGGER.error("Unexpected response from router") self.disconnect() diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index b88245ac9a5..1952e6d676d 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -5,23 +5,37 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.gpslogger/ """ import asyncio -from functools import partial import logging +from hmac import compare_digest -from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY -from homeassistant.components.http import HomeAssistantView +from aiohttp.web import Request, HTTPUnauthorized # NOQA +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, HTTP_UNPROCESSABLE_ENTITY +) +from homeassistant.components.http import ( + CONF_API_PASSWORD, HomeAssistantView +) # pylint: disable=unused-import from homeassistant.components.device_tracker import ( # NOQA - DOMAIN, PLATFORM_SCHEMA) + DOMAIN, PLATFORM_SCHEMA +) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['http'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_PASSWORD): cv.string, +}) -def setup_scanner(hass, config, see, discovery_info=None): + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up an endpoint for the GPSLogger application.""" - hass.http.register_view(GPSLoggerView(see)) + hass.http.register_view(GPSLoggerView(async_see, config)) return True @@ -32,26 +46,36 @@ class GPSLoggerView(HomeAssistantView): url = '/api/gpslogger' name = 'api:gpslogger' - def __init__(self, see): + def __init__(self, async_see, config): """Initialize GPSLogger url endpoints.""" - self.see = see + self.async_see = async_see + self._password = config.get(CONF_PASSWORD) + # this component does not require external authentication if + # password is set + self.requires_auth = self._password is None @asyncio.coroutine - def get(self, request): + def get(self, request: Request): """Handle for GPSLogger message received as GET.""" - res = yield from self._handle(request.app['hass'], request.query) - return res + hass = request.app['hass'] + data = request.query + + if self._password is not None: + authenticated = CONF_API_PASSWORD in data and compare_digest( + self._password, + data[CONF_API_PASSWORD] + ) + if not authenticated: + raise HTTPUnauthorized() - @asyncio.coroutine - def _handle(self, hass, data): - """Handle GPSLogger requests.""" if 'latitude' not in data or 'longitude' not in data: return ('Latitude and longitude not specified.', HTTP_UNPROCESSABLE_ENTITY) if 'device' not in data: _LOGGER.error("Device id not specified") - return ('Device id not specified.', HTTP_UNPROCESSABLE_ENTITY) + return ('Device id not specified.', + HTTP_UNPROCESSABLE_ENTITY) device = data['device'].replace('-', '') gps_location = (data['latitude'], data['longitude']) @@ -75,10 +99,11 @@ class GPSLoggerView(HomeAssistantView): if 'activity' in data: attrs['activity'] = data['activity'] - yield from hass.async_add_job( - partial(self.see, dev_id=device, - gps=gps_location, battery=battery, - gps_accuracy=accuracy, - attributes=attrs)) + hass.async_add_job(self.async_see( + dev_id=device, + gps=gps_location, battery=battery, + gps_accuracy=accuracy, + attributes=attrs + )) return 'Setting location for {}'.format(device) diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py index 319c19d7b73..9437486a0aa 100644 --- a/homeassistant/components/device_tracker/meraki.py +++ b/homeassistant/components/device_tracker/meraki.py @@ -94,8 +94,26 @@ class MerakiView(HomeAssistantView): def _handle(self, hass, data): for i in data["data"]["observations"]: data["data"]["secret"] = "hidden" + + lat = i["location"]["lat"] + lng = i["location"]["lng"] + try: + accuracy = int(float(i["location"]["unc"])) + except ValueError: + accuracy = 0 + mac = i["clientMac"] _LOGGER.debug("clientMac: %s", mac) + + if lat == "NaN" or lng == "NaN": + _LOGGER.debug( + "No coordinates received, skipping location for: " + mac + ) + gps_location = None + accuracy = None + else: + gps_location = (lat, lng) + attrs = {} if i.get('os', False): attrs['os'] = i['os'] @@ -110,7 +128,9 @@ class MerakiView(HomeAssistantView): if i.get('ssid', False): attrs['ssid'] = i['ssid'] hass.async_add_job(self.async_see( + gps=gps_location, mac=mac, source_type=SOURCE_TYPE_ROUTER, + gps_accuracy=accuracy, attributes=attrs )) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index f27a950a49f..377686b6905 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -19,7 +19,7 @@ from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pytile==1.0.0'] +REQUIREMENTS = ['pytile==1.1.0'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' DEFAULT_ICON = 'mdi:bluetooth' @@ -29,14 +29,15 @@ ATTR_ALTITUDE = 'altitude' ATTR_CONNECTION_STATE = 'connection_state' ATTR_IS_DEAD = 'is_dead' ATTR_IS_LOST = 'is_lost' -ATTR_LAST_SEEN = 'last_seen' -ATTR_LAST_UPDATED = 'last_updated' ATTR_RING_STATE = 'ring_state' ATTR_VOIP_STATE = 'voip_state' +CONF_SHOW_INACTIVE = 'show_inactive' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SHOW_INACTIVE, default=False): cv.boolean, vol.Optional(CONF_MONITORED_VARIABLES): vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), }) @@ -79,6 +80,7 @@ class TileDeviceScanner(DeviceScanner): _LOGGER.debug('Client UUID: %s', self._client.client_uuid) _LOGGER.debug('User UUID: %s', self._client.user_uuid) + self._show_inactive = config.get(CONF_SHOW_INACTIVE) self._types = config.get(CONF_MONITORED_VARIABLES) self.devices = {} @@ -91,29 +93,25 @@ class TileDeviceScanner(DeviceScanner): def _update_info(self, now=None) -> None: """Update the device info.""" - device_data = self._client.get_tiles(type_whitelist=self._types) + self.devices = self._client.get_tiles( + type_whitelist=self._types, show_inactive=self._show_inactive) - try: - self.devices = device_data['result'] - except KeyError: + if not self.devices: _LOGGER.warning('No Tiles found') - _LOGGER.debug(device_data) return - for info in self.devices.values(): - dev_id = 'tile_{0}'.format(slugify(info['name'])) - lat = info['tileState']['latitude'] - lon = info['tileState']['longitude'] + for dev in self.devices: + dev_id = 'tile_{0}'.format(slugify(dev['name'])) + lat = dev['tileState']['latitude'] + lon = dev['tileState']['longitude'] attrs = { - ATTR_ALTITUDE: info['tileState']['altitude'], - ATTR_CONNECTION_STATE: info['tileState']['connection_state'], - ATTR_IS_DEAD: info['is_dead'], - ATTR_IS_LOST: info['tileState']['is_lost'], - ATTR_LAST_SEEN: info['tileState']['timestamp'], - ATTR_LAST_UPDATED: device_data['timestamp_ms'], - ATTR_RING_STATE: info['tileState']['ring_state'], - ATTR_VOIP_STATE: info['tileState']['voip_state'], + ATTR_ALTITUDE: dev['tileState']['altitude'], + ATTR_CONNECTION_STATE: dev['tileState']['connection_state'], + ATTR_IS_DEAD: dev['is_dead'], + ATTR_IS_LOST: dev['tileState']['is_lost'], + ATTR_RING_STATE: dev['tileState']['ring_state'], + ATTR_VOIP_STATE: dev['tileState']['voip_state'], } self.see( diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 5d362f21cef..dde33aa10a2 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -36,6 +36,7 @@ SERVICE_APPLE_TV = 'apple_tv' SERVICE_WINK = 'wink' SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' +SERVICE_HUE = 'philips_hue' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -48,7 +49,7 @@ SERVICE_HANDLERS = { SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), - 'philips_hue': ('light', 'hue'), + SERVICE_HUE: ('hue', None), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index e5430555910..7101f4a9527 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -31,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.2'] +REQUIREMENTS = ['python-miio==0.3.3'] ATTR_TEMPERATURE = 'temperature' ATTR_HUMIDITY = 'humidity' diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3d669ddc4d1..21900e2265f 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171206.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20171223.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -35,7 +35,7 @@ CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' -JS_DEFAULT_OPTION = 'es5' +JS_DEFAULT_OPTION = 'auto' JS_OPTIONS = ['es5', 'latest', 'auto'] DEFAULT_THEME_COLOR = '#03A9F4' @@ -49,7 +49,7 @@ MANIFEST_JSON = { 'lang': 'en-US', 'name': 'Home Assistant', 'short_name': 'Assistant', - 'start_url': '/', + 'start_url': '/states', 'theme_color': DEFAULT_THEME_COLOR } @@ -299,8 +299,13 @@ def async_setup(hass, config): hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) if is_dev: - hass.http.register_static_path( - "/home-assistant-polymer", repo_path, False) + for subpath in ["src", "build-translations", "build-temp", "build", + "hass_frontend", "bower_components", "panels"]: + hass.http.register_static_path( + "/home-assistant-polymer/{}".format(subpath), + os.path.join(repo_path, subpath), + False) + hass.http.register_static_path( "/static/translations", os.path.join(repo_path, "build-translations/output"), False) diff --git a/homeassistant/components/hive.py b/homeassistant/components/hive.py index 277800502c1..bf5196d6582 100644 --- a/homeassistant/components/hive.py +++ b/homeassistant/components/hive.py @@ -12,7 +12,7 @@ from homeassistant.const import (CONF_PASSWORD, CONF_SCAN_INTERVAL, import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['pyhiveapi==0.2.5'] +REQUIREMENTS = ['pyhiveapi==0.2.10'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'hive' diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic/__init__.py similarity index 79% rename from homeassistant/components/homematic.py rename to homeassistant/components/homematic/__init__.py index 5e8cd3dc58e..08e8455b302 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic/__init__.py @@ -5,25 +5,26 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ import asyncio -import os -import logging from datetime import timedelta from functools import partial +import logging +import os +import socket import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, CONF_USERNAME, CONF_PASSWORD, - CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) + EVENT_HOMEASSISTANT_STOP, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, + CONF_HOSTS, CONF_HOST, ATTR_ENTITY_ID, STATE_UNKNOWN) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval -from homeassistant.config import load_yaml_config_file - -REQUIREMENTS = ['pyhomematic==0.1.35'] +import homeassistant.helpers.config_validation as cv +from homeassistant.loader import bind_hass +REQUIREMENTS = ['pyhomematic==0.1.36'] DOMAIN = 'homematic' +_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL_HUB = timedelta(seconds=300) SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) @@ -41,9 +42,11 @@ ATTR_CHANNEL = 'channel' ATTR_NAME = 'name' ATTR_ADDRESS = 'address' ATTR_VALUE = 'value' -ATTR_PROXY = 'proxy' +ATTR_INTERFACE = 'interface' ATTR_ERRORCODE = 'error' ATTR_MESSAGE = 'message' +ATTR_MODE = 'mode' +ATTR_TIME = 'time' EVENT_KEYPRESS = 'homematic.keypress' EVENT_IMPULSE = 'homematic.impulse' @@ -51,8 +54,9 @@ EVENT_ERROR = 'homematic.error' SERVICE_VIRTUALKEY = 'virtualkey' SERVICE_RECONNECT = 'reconnect' -SERVICE_SET_VAR_VALUE = 'set_var_value' -SERVICE_SET_DEV_VALUE = 'set_dev_value' +SERVICE_SET_VARIABLE_VALUE = 'set_variable_value' +SERVICE_SET_DEVICE_VALUE = 'set_device_value' +SERVICE_SET_INSTALL_MODE = 'set_install_mode' HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ @@ -73,9 +77,9 @@ HM_DEVICE_TYPES = { 'ThermostatGroup'], DISCOVER_BINARY_SENSORS: [ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', - 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', - 'HMWIOSwitch', 'MaxShutterContact', 'Rain', 'WiredSensor', - 'PresenceIP'], + 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', + 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', + 'WiredSensor', 'PresenceIP'], DISCOVER_COVER: ['Blind', 'KeyBlind'] } @@ -90,12 +94,14 @@ HM_ATTRIBUTE_SUPPORT = { 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], 'BATTERY_STATE': ['battery', {}], - 'CONTROL_MODE': ['mode', {0: 'Auto', - 1: 'Manual', - 2: 'Away', - 3: 'Boost', - 4: 'Comfort', - 5: 'Lowering'}], + 'CONTROL_MODE': ['mode', { + 0: 'Auto', + 1: 'Manual', + 2: 'Away', + 3: 'Boost', + 4: 'Comfort', + 5: 'Lowering' + }], 'POWER': ['power', {}], 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], @@ -114,8 +120,6 @@ HM_IMPULSE_EVENTS = [ 'SEQUENCE_OK', ] -_LOGGER = logging.getLogger(__name__) - CONF_RESOLVENAMES_OPTIONS = [ 'metadata', 'json', @@ -124,12 +128,12 @@ CONF_RESOLVENAMES_OPTIONS = [ ] DATA_HOMEMATIC = 'homematic' -DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' +DATA_CONF = 'homematic_conf' +CONF_INTERFACES = 'interfaces' CONF_LOCAL_IP = 'local_ip' CONF_LOCAL_PORT = 'local_port' -CONF_IP = 'ip' CONF_PORT = 'port' CONF_PATH = 'path' CONF_CALLBACK_IP = 'callback_ip' @@ -146,37 +150,35 @@ DEFAULT_PORT = 2001 DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' -DEFAULT_VARIABLES = False -DEFAULT_DEVICES = True -DEFAULT_PRIMARY = False DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'homematic', vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_ADDRESS): cv.string, - vol.Required(ATTR_PROXY): cv.string, + vol.Required(ATTR_INTERFACE): cv.string, vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int), vol.Optional(ATTR_PARAM): cv.string, }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOSTS): {cv.match_all: { - vol.Required(CONF_IP): cv.string, + vol.Optional(CONF_INTERFACES, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES): - cv.boolean, vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES): vol.In(CONF_RESOLVENAMES_OPTIONS), - vol.Optional(CONF_DEVICES, default=DEFAULT_DEVICES): cv.boolean, - vol.Optional(CONF_PRIMARY, default=DEFAULT_PRIMARY): cv.boolean, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_CALLBACK_IP): cv.string, vol.Optional(CONF_CALLBACK_PORT): cv.port, }}, + vol.Optional(CONF_HOSTS, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, }), @@ -186,61 +188,88 @@ SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): cv.string, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) -SCHEMA_SERVICE_SET_VAR_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_VARIABLE_VALUE = vol.Schema({ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.match_all, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) -SCHEMA_SERVICE_SET_DEV_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper), vol.Required(ATTR_VALUE): cv.match_all, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) SCHEMA_SERVICE_RECONNECT = vol.Schema({}) +SCHEMA_SERVICE_SET_INSTALL_MODE = vol.Schema({ + vol.Required(ATTR_INTERFACE): cv.string, + vol.Optional(ATTR_TIME, default=60): cv.positive_int, + vol.Optional(ATTR_MODE, default=1): + vol.All(vol.Coerce(int), vol.In([1, 2])), + vol.Optional(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), +}) -def virtualkey(hass, address, channel, param, proxy=None): + +@bind_hass +def virtualkey(hass, address, channel, param, interface=None): """Send virtual keypress to homematic controlller.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data) -def set_var_value(hass, entity_id, value): +@bind_hass +def set_variable_value(hass, entity_id, value): """Change value of a Homematic system variable.""" data = { ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value, } - hass.services.call(DOMAIN, SERVICE_SET_VAR_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_VARIABLE_VALUE, data) -def set_dev_value(hass, address, channel, param, value, proxy=None): - """Call setValue XML-RPC method of supplied proxy.""" +@bind_hass +def set_device_value(hass, address, channel, param, value, interface=None): + """Call setValue XML-RPC method of supplied interface.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, ATTR_VALUE: value, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } - hass.services.call(DOMAIN, SERVICE_SET_DEV_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) +@bind_hass +def set_install_mode(hass, interface, mode=None, time=None, address=None): + """Call setInstallMode XML-RPC method of supplied inteface.""" + data = { + key: value for key, value in ( + (ATTR_INTERFACE, interface), + (ATTR_MODE, mode), + (ATTR_TIME, time), + (ATTR_ADDRESS, address) + ) if value + } + + hass.services.call(DOMAIN, SERVICE_SET_INSTALL_MODE, data) + + +@bind_hass def reconnect(hass): """Reconnect to CCU/Homegear.""" hass.services.call(DOMAIN, SERVICE_RECONNECT, {}) @@ -250,31 +279,32 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DEVINIT] = {} + conf = config[DOMAIN] + hass.data[DATA_CONF] = remotes = {} hass.data[DATA_STORE] = set() # Create hosts-dictionary for pyhomematic - remotes = {} - hosts = {} - for rname, rconfig in config[DOMAIN][CONF_HOSTS].items(): - server = rconfig.get(CONF_IP) + for rname, rconfig in conf[CONF_INTERFACES].items(): + remotes[rname] = { + 'ip': socket.gethostbyname(rconfig.get(CONF_HOST)), + 'port': rconfig.get(CONF_PORT), + 'path': rconfig.get(CONF_PATH), + 'resolvenames': rconfig.get(CONF_RESOLVENAMES), + 'username': rconfig.get(CONF_USERNAME), + 'password': rconfig.get(CONF_PASSWORD), + 'callbackip': rconfig.get(CONF_CALLBACK_IP), + 'callbackport': rconfig.get(CONF_CALLBACK_PORT), + 'connect': True, + } - remotes[rname] = {} - remotes[rname][CONF_IP] = server - remotes[rname][CONF_PORT] = rconfig.get(CONF_PORT) - remotes[rname][CONF_PATH] = rconfig.get(CONF_PATH) - remotes[rname][CONF_RESOLVENAMES] = rconfig.get(CONF_RESOLVENAMES) - remotes[rname][CONF_USERNAME] = rconfig.get(CONF_USERNAME) - remotes[rname][CONF_PASSWORD] = rconfig.get(CONF_PASSWORD) - remotes[rname]['callbackip'] = rconfig.get(CONF_CALLBACK_IP) - remotes[rname]['callbackport'] = rconfig.get(CONF_CALLBACK_PORT) - - if server not in hosts or rconfig.get(CONF_PRIMARY): - hosts[server] = { - CONF_VARIABLES: rconfig.get(CONF_VARIABLES), - CONF_NAME: rname, - } - hass.data[DATA_DEVINIT][rname] = rconfig.get(CONF_DEVICES) + for sname, sconfig in conf[CONF_HOSTS].items(): + remotes[sname] = { + 'ip': socket.gethostbyname(sconfig.get(CONF_HOST)), + 'port': DEFAULT_PORT, + 'username': sconfig.get(CONF_USERNAME), + 'password': sconfig.get(CONF_PASSWORD), + 'connect': False, + } # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) @@ -295,9 +325,8 @@ def setup(hass, config): # Init homematic hubs entity_hubs = [] - for _, hub_data in hosts.items(): - entity_hubs.append(HMHub( - hass, homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + for hub_name in conf[CONF_HOSTS].keys(): + entity_hubs.append(HMHub(hass, homematic, hub_name)) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -331,8 +360,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey, - descriptions[DOMAIN][SERVICE_VIRTUALKEY], - schema=SCHEMA_SERVICE_VIRTUALKEY) + descriptions[SERVICE_VIRTUALKEY], schema=SCHEMA_SERVICE_VIRTUALKEY) def _service_handle_value(service): """Service to call setValue method for HomeMatic system variable.""" @@ -354,9 +382,9 @@ def setup(hass, config): hub.hm_set_variable(name, value) hass.services.register( - DOMAIN, SERVICE_SET_VAR_VALUE, _service_handle_value, - descriptions[DOMAIN][SERVICE_SET_VAR_VALUE], - schema=SCHEMA_SERVICE_SET_VAR_VALUE) + DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value, + descriptions[SERVICE_SET_VARIABLE_VALUE], + schema=SCHEMA_SERVICE_SET_VARIABLE_VALUE) def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" @@ -364,8 +392,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, - descriptions[DOMAIN][SERVICE_RECONNECT], - schema=SCHEMA_SERVICE_RECONNECT) + descriptions[SERVICE_RECONNECT], schema=SCHEMA_SERVICE_RECONNECT) def _service_handle_device(service): """Service to call setValue method for HomeMatic devices.""" @@ -383,9 +410,23 @@ def setup(hass, config): hmdevice.setValue(param, value, channel) hass.services.register( - DOMAIN, SERVICE_SET_DEV_VALUE, _service_handle_device, - descriptions[DOMAIN][SERVICE_SET_DEV_VALUE], - schema=SCHEMA_SERVICE_SET_DEV_VALUE) + DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device, + descriptions[SERVICE_SET_DEVICE_VALUE], + schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) + + def _service_handle_install_mode(service): + """Service to set interface into install mode.""" + interface = service.data.get(ATTR_INTERFACE) + mode = service.data.get(ATTR_MODE) + time = service.data.get(ATTR_TIME) + address = service.data.get(ATTR_ADDRESS) + + homematic.setInstallMode(interface, t=time, mode=mode, address=address) + + hass.services.register( + DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode, + descriptions[SERVICE_SET_INSTALL_MODE], + schema=SCHEMA_SERVICE_SET_INSTALL_MODE) return True @@ -395,10 +436,10 @@ def _system_callback_handler(hass, config, src, *args): # New devices available at hub if src == 'newDevices': (interface_id, dev_descriptions) = args - proxy = interface_id.split('-')[-1] + interface = interface_id.split('-')[-1] # Device support active? - if not hass.data[DATA_DEVINIT][proxy]: + if not hass.data[DATA_CONF][interface]['connect']: return addresses = [] @@ -410,9 +451,9 @@ def _system_callback_handler(hass, config, src, *args): # Register EVENTS # Search all devices with an EVENTNODE that includes data - bound_event_callback = partial(_hm_event_handler, hass, proxy) + bound_event_callback = partial(_hm_event_handler, hass, interface) for dev in addresses: - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(dev) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev) if hmdevice.EVENTNODE: hmdevice.setEventCallback( @@ -429,7 +470,7 @@ def _system_callback_handler(hass, config, src, *args): ('climate', DISCOVER_CLIMATE)): # Get all devices of a specific type found_devices = _get_devices( - hass, discovery_type, addresses, proxy) + hass, discovery_type, addresses, interface) # When devices of this type are found # they are setup in HASS and an discovery event is fired @@ -448,12 +489,12 @@ def _system_callback_handler(hass, config, src, *args): }) -def _get_devices(hass, discovery_type, keys, proxy): +def _get_devices(hass, discovery_type, keys, interface): """Get the HomeMatic devices for given discovery_type.""" device_arr = [] for key in keys: - device = hass.data[DATA_HOMEMATIC].devices[proxy][key] + device = hass.data[DATA_HOMEMATIC].devices[interface][key] class_name = device.__class__.__name__ metadata = {} @@ -485,7 +526,7 @@ def _get_devices(hass, discovery_type, keys, proxy): device_dict = { CONF_PLATFORM: "homematic", ATTR_ADDRESS: key, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, ATTR_NAME: name, ATTR_CHANNEL: channel } @@ -521,12 +562,12 @@ def _create_ha_name(name, channel, param, count): return "{} {} {}".format(name, channel, param) -def _hm_event_handler(hass, proxy, device, caller, attribute, value): +def _hm_event_handler(hass, interface, device, caller, attribute, value): """Handle all pyhomematic device events.""" try: channel = int(device.split(":")[1]) address = device.split(":")[0] - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(address) except (TypeError, ValueError): _LOGGER.error("Event handling channel convert error!") return @@ -561,14 +602,14 @@ def _hm_event_handler(hass, proxy, device, caller, attribute, value): def _device_from_servicecall(hass, service): """Extract HomeMatic device from service call.""" address = service.data.get(ATTR_ADDRESS) - proxy = service.data.get(ATTR_PROXY) + interface = service.data.get(ATTR_INTERFACE) if address == 'BIDCOS-RF': address = 'BidCoS-RF' - if proxy: - return hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + if interface: + return hass.data[DATA_HOMEMATIC].devices[interface].get(address) - for _, devices in hass.data[DATA_HOMEMATIC].devices.items(): + for devices in hass.data[DATA_HOMEMATIC].devices.values(): if address in devices: return devices[address] @@ -576,25 +617,23 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, hass, homematic, name, use_variables): + def __init__(self, hass, homematic, name): """Initialize HomeMatic hub.""" self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) self._homematic = homematic self._variables = {} self._name = name - self._state = STATE_UNKNOWN - self._use_variables = use_variables + self._state = None # Load data - track_time_interval( - self.hass, self._update_hub, SCAN_INTERVAL_HUB) + self.hass.helpers.event.track_time_interval( + self._update_hub, SCAN_INTERVAL_HUB) self.hass.add_job(self._update_hub, None) - if self._use_variables: - track_time_interval( - self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - self.hass.add_job(self._update_variables, None) + self.hass.helpers.event.track_time_interval( + self._update_variables, SCAN_INTERVAL_VARIABLES) + self.hass.add_job(self._update_variables, None) @property def name(self): @@ -672,7 +711,7 @@ class HMDevice(Entity): """Initialize a generic HomeMatic device.""" self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) - self._proxy = config.get(ATTR_PROXY) + self._interface = config.get(ATTR_INTERFACE) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} @@ -700,11 +739,6 @@ class HMDevice(Entity): """Return the name of the device.""" return self._name - @property - def assumed_state(self): - """Return true if unable to access real state of the device.""" - return not self._available - @property def available(self): """Return true if device is available.""" @@ -715,10 +749,6 @@ class HMDevice(Entity): """Return device specific state attributes.""" attr = {} - # No data available - if not self.available: - return attr - # Generate a dictionary with attributes for node, data in HM_ATTRIBUTE_SUPPORT.items(): # Is an attribute and exists for this object @@ -728,7 +758,7 @@ class HMDevice(Entity): # Static attributes attr['id'] = self._hmdevice.ADDRESS - attr['proxy'] = self._proxy + attr['interface'] = self._interface return attr @@ -739,7 +769,8 @@ class HMDevice(Entity): # Initialize self._homematic = self.hass.data[DATA_HOMEMATIC] - self._hmdevice = self._homematic.devices[self._proxy][self._address] + self._hmdevice = \ + self._homematic.devices[self._interface][self._address] self._connected = True try: @@ -773,6 +804,9 @@ class HMDevice(Entity): if attribute == 'UNREACH': self._available = bool(value) has_changed = True + elif not self.available: + self._available = False + has_changed = True # If it has changed data point, update HASS if has_changed: diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml new file mode 100644 index 00000000000..bf4d99af9e7 --- /dev/null +++ b/homeassistant/components/homematic/services.yaml @@ -0,0 +1,68 @@ +# Describes the format for available component services + +virtualkey: + description: Press a virtual key from CCU/Homegear or simulate keypress. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote. + example: BidCoS-RF + channel: + description: Channel for calling a keypress. + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT. + example: PRESS_LONG + interface: + description: (Optional) for set a interface value. + example: Interfaces name from config + +set_variable_value: + description: Set the name of a node. + fields: + entity_id: + description: Name(s) of homematic central to set value. + example: 'homematic.ccu2' + name: + description: Name of the variable to set. + example: 'testvariable' + value: + description: New value + example: 1 + +set_device_value: + description: Set a device property on RPC XML interface. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote + example: BidCoS-RF + channel: + description: Channel for calling a keypress + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT + example: PRESS_LONG + interface: + description: (Optional) for set a interface value + example: Interfaces name from config + value: + description: New value + example: 1 + +reconnect: + description: Reconnect to all Homematic Hubs. + +set_install_mode: + description: Set a RPC XML interface into installation mode. + fields: + interface: + description: Select the given interface into install mode + example: Interfaces name from config + mode: + description: (Default 1) 1= Normal mode / 2= Remove exists old links + example: 1 + time: + description: (Default 60) Time in seconds to run in install mode + example: 1 + address: + description: (Optional) Address of homematic device or BidCoS-RF to learn + example: LEQ3948571 diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py new file mode 100644 index 00000000000..6147f706658 --- /dev/null +++ b/homeassistant/components/hue.py @@ -0,0 +1,246 @@ +""" +This component provides basic support for the Philips Hue system. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hue/ +""" +import json +import logging +import os +import socket + +import voluptuous as vol + +from homeassistant.components.discovery import SERVICE_HUE +from homeassistant.config import load_yaml_config_file +from homeassistant.const import CONF_FILENAME, CONF_HOST +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ['phue==1.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "hue" +SERVICE_HUE_SCENE = "hue_activate_scene" + +CONF_BRIDGES = "bridges" + +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False + +PHUE_CONFIG_FILE = 'phue.conf' + +CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" +DEFAULT_ALLOW_IN_EMULATED_HUE = True + +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = True + +BRIDGE_CONFIG_SCHEMA = vol.Schema([{ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string, + vol.Optional(CONF_ALLOW_UNREACHABLE, + default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean, + vol.Optional(CONF_ALLOW_IN_EMULATED_HUE, + default=DEFAULT_ALLOW_IN_EMULATED_HUE): cv.boolean, + vol.Optional(CONF_ALLOW_HUE_GROUPS, + default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, +}]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_BRIDGES, default=[]): BRIDGE_CONFIG_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + +ATTR_GROUP_NAME = "group_name" +ATTR_SCENE_NAME = "scene_name" +SCENE_SCHEMA = vol.Schema({ + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, +}) + +CONFIG_INSTRUCTIONS = """ +Press the button on the bridge to register Philips Hue with Home Assistant. + +![Location of button on bridge](/static/images/config_philips_hue.jpg) +""" + + +def setup(hass, config): + """Set up the Hue platform.""" + config = config.get(DOMAIN) + if config is None: + config = {} + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + discovery.listen( + hass, + SERVICE_HUE, + lambda service, discovery_info: + bridge_discovered(hass, service, discovery_info)) + + bridges = config.get(CONF_BRIDGES, []) + for bridge in bridges: + filename = bridge.get(CONF_FILENAME) + allow_unreachable = bridge.get(CONF_ALLOW_UNREACHABLE) + allow_in_emulated_hue = bridge.get(CONF_ALLOW_IN_EMULATED_HUE) + allow_hue_groups = bridge.get(CONF_ALLOW_HUE_GROUPS) + + host = bridge.get(CONF_HOST) + + if host is None: + host = _find_host_from_config(hass, filename) + + if host is None: + _LOGGER.error("No host found in configuration") + return False + + setup_bridge(host, hass, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) + + return True + + +def bridge_discovered(hass, service, discovery_info): + """Dispatcher for Hue discovery events.""" + if "HASS Bridge" in discovery_info.get('name', ''): + return + + host = discovery_info.get('host') + serial = discovery_info.get('serial') + + filename = 'phue-{}.conf'.format(serial) + setup_bridge(host, hass, filename) + + +def setup_bridge(host, hass, filename=None, allow_unreachable=False, + allow_in_emulated_hue=True, allow_hue_groups=True): + """Set up a given Hue bridge.""" + # Only register a device once + if socket.gethostbyname(host) in hass.data[DOMAIN]: + return + + bridge = HueBridge(host, hass, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) + bridge.setup() + + +def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): + """Attempt to detect host based on existing configuration.""" + path = hass.config.path(filename) + + if not os.path.isfile(path): + return None + + try: + with open(path) as inp: + return next(iter(json.load(inp).keys())) + except (ValueError, AttributeError, StopIteration): + # ValueError if can't parse as JSON + # AttributeError if JSON value is not a dict + # StopIteration if no keys + return None + + +class HueBridge(object): + """Manages a single Hue bridge.""" + + def __init__(self, host, hass, filename, allow_unreachable=False, + allow_in_emulated_hue=True, allow_hue_groups=True): + """Initialize the system.""" + self.host = host + self.hass = hass + self.filename = filename + self.allow_unreachable = allow_unreachable + self.allow_in_emulated_hue = allow_in_emulated_hue + self.allow_hue_groups = allow_hue_groups + + self.bridge = None + self.lights = {} + self.lightgroups = {} + + self.configured = False + self.config_request_id = None + + hass.data[DOMAIN][socket.gethostbyname(host)] = self + + def setup(self): + """Set up a phue bridge based on host parameter.""" + import phue + + try: + self.bridge = phue.Bridge( + self.host, + config_file_path=self.hass.config.path(self.filename)) + except ConnectionRefusedError: # Wrong host was given + _LOGGER.error("Error connecting to the Hue bridge at %s", + self.host) + return + except phue.PhueRegistrationException: + _LOGGER.warning("Connected to Hue at %s but not registered.", + self.host) + self.request_configuration() + return + + # If we came here and configuring this host, mark as done + if self.config_request_id: + request_id = self.config_request_id + self.config_request_id = None + configurator = self.hass.components.configurator + configurator.request_done(request_id) + + self.configured = True + + discovery.load_platform( + self.hass, 'light', DOMAIN, + {'bridge_id': socket.gethostbyname(self.host)}) + + # create a service for calling run_scene directly on the bridge, + # used to simplify automation rules. + def hue_activate_scene(call): + """Service to call directly into bridge to set scenes.""" + group_name = call.data[ATTR_GROUP_NAME] + scene_name = call.data[ATTR_SCENE_NAME] + self.bridge.run_scene(group_name, scene_name) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + self.hass.services.register( + DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, + descriptions.get(SERVICE_HUE_SCENE), + schema=SCENE_SCHEMA) + + def request_configuration(self): + """Request configuration steps from the user.""" + configurator = self.hass.components.configurator + + # We got an error if this method is called while we are configuring + if self.config_request_id: + configurator.notify_errors( + self.config_request_id, + "Failed to register, please try again.") + return + + self.config_request_id = configurator.request_config( + "Philips Hue", + lambda data: self.setup(), + description=CONFIG_INSTRUCTIONS, + entity_picture="/static/images/logo_philips_hue.png", + submit_caption="I have pressed the button" + ) + + def get_api(self): + """Return the full api dictionary from phue.""" + return self.bridge.get_api() + + def set_light(self, light_id, command): + """Adjust properties of one or more lights. See phue for details.""" + return self.bridge.set_light(light_id, command) + + def set_group(self, light_id, command): + """Change light settings for a group. See phue for detail.""" + return self.bridge.set_group(light_id, command) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 7686eb7dc7d..af1846c7bf8 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -4,6 +4,7 @@ Support the ISY-994 controllers. For configuration details please visit the documentation for this component at https://home-assistant.io/components/isy994/ """ +import asyncio from collections import namedtuple import logging from urllib.parse import urlparse @@ -17,7 +18,7 @@ from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict # noqa -REQUIREMENTS = ['PyISY==1.0.8'] +REQUIREMENTS = ['PyISY==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -91,6 +92,34 @@ def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: return filtered_nodes +def _is_node_a_sensor(node, path: str, sensor_identifier: str) -> bool: + """Determine if the given node is a sensor.""" + if not isinstance(node, PYISY.Nodes.Node): + return False + + if sensor_identifier in path or sensor_identifier in node.name: + return True + + # This method is most reliable but only works on 5.x firmware + try: + if node.node_def_id == 'BinaryAlarm': + return True + except AttributeError: + pass + + # This method works on all firmwares, but only for Insteon devices + try: + device_type = node.type + except AttributeError: + # Node has no type; most likely not an Insteon device + pass + else: + split_type = device_type.split('.') + return split_type[0] == '16' # 16 represents Insteon binary sensors + + return False + + def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: """Categorize the ISY994 nodes.""" global SENSOR_NODES @@ -106,7 +135,7 @@ def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: hidden = hidden_identifier in path or hidden_identifier in node.name if hidden: node.name += hidden_identifier - if sensor_identifier in path or sensor_identifier in node.name: + if _is_node_a_sensor(node, path, sensor_identifier): SENSOR_NODES.append(node) elif isinstance(node, PYISY.Nodes.Node): NODES.append(node) @@ -227,15 +256,31 @@ class ISYDevice(Entity): def __init__(self, node) -> None: """Initialize the insteon device.""" self._node = node + self._change_handler = None + self._control_handler = None + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node change events.""" self._change_handler = self._node.status.subscribe( 'changed', self.on_update) + if hasattr(self._node, 'controlEvents'): + self._control_handler = self._node.controlEvents.subscribe( + self.on_control) + # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" self.schedule_update_ha_state() + def on_control(self, event: object) -> None: + """Handle a control event from the ISY994 Node.""" + self.hass.bus.fire('isy994_control', { + 'entity_id': self.entity_id, + 'control': event + }) + @property def domain(self) -> str: """Get the domain of the device.""" @@ -270,6 +315,21 @@ class ISYDevice(Entity): # pylint: disable=protected-access return self._node.status._val + def is_unknown(self) -> bool: + """Get whether or not the value of this Entity's node is unknown. + + PyISY reports unknown values as -inf + """ + return self.value == -1 * float('inf') + + @property + def state(self): + """Return the state of the ISY device.""" + if self.is_unknown(): + return None + else: + return super().state + @property def device_state_attributes(self) -> Dict: """Get the state attributes for the device.""" diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index 5a81f6d2a9e..d737c555873 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -21,7 +21,7 @@ REQUIREMENTS = ['evdev==0.6.1'] _LOGGER = logging.getLogger(__name__) DEVICE_DESCRIPTOR = 'device_descriptor' -DEVICE_ID_GROUP = 'Device descriptor or name' +DEVICE_ID_GROUP = 'Device description' DEVICE_NAME = 'device_name' DOMAIN = 'keyboard_remote' @@ -36,12 +36,13 @@ KEYBOARD_REMOTE_DISCONNECTED = 'keyboard_remote_disconnected' TYPE = 'type' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, - vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, - vol.Optional(TYPE, default='key_up'): - vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')), - }), + DOMAIN: + vol.All(cv.ensure_list, [vol.Schema({ + vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, + vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, + vol.Optional(TYPE, default='key_up'): + vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')) + })]) }, extra=vol.ALLOW_EXTRA) @@ -49,11 +50,6 @@ def setup(hass, config): """Set up the keyboard_remote.""" config = config.get(DOMAIN) - if not config.get(DEVICE_DESCRIPTOR) and\ - not config.get(DEVICE_NAME): - _LOGGER.error("No device_descriptor or device_name found") - return - keyboard_remote = KeyboardRemote( hass, config @@ -63,7 +59,7 @@ def setup(hass, config): keyboard_remote.run() def _stop_keyboard_remote(_event): - keyboard_remote.stopped.set() + keyboard_remote.stop() hass.bus.listen_once( EVENT_HOMEASSISTANT_START, @@ -77,19 +73,21 @@ def setup(hass, config): return True -class KeyboardRemote(threading.Thread): +class KeyboardRemoteThread(threading.Thread): """This interfaces with the inputdevice using evdev.""" - def __init__(self, hass, config): - """Construct a KeyboardRemote interface object.""" - from evdev import InputDevice, list_devices + def __init__(self, hass, device_name, device_descriptor, key_value): + """Construct a thread listening for events on one device.""" + self.hass = hass + self.device_name = device_name + self.device_descriptor = device_descriptor + self.key_value = key_value - self.device_descriptor = config.get(DEVICE_DESCRIPTOR) - self.device_name = config.get(DEVICE_NAME) if self.device_descriptor: self.device_id = self.device_descriptor else: self.device_id = self.device_name + self.dev = self._get_keyboard_device() if self.dev is not None: _LOGGER.debug("Keyboard connected, %s", self.device_id) @@ -103,6 +101,7 @@ class KeyboardRemote(threading.Thread): id_folder = '/dev/input/by-id/' if os.path.isdir(id_folder): + from evdev import InputDevice, list_devices device_names = [InputDevice(file_name).name for file_name in list_devices()] _LOGGER.debug( @@ -116,7 +115,6 @@ class KeyboardRemote(threading.Thread): threading.Thread.__init__(self) self.stopped = threading.Event() self.hass = hass - self.key_value = KEY_VALUE.get(config.get(TYPE, 'key_up')) def _get_keyboard_device(self): """Get the keyboard device.""" @@ -145,7 +143,7 @@ class KeyboardRemote(threading.Thread): while not self.stopped.isSet(): # Sleeps to ease load on processor - time.sleep(.1) + time.sleep(.05) if self.dev is None: self.dev = self._get_keyboard_device() @@ -178,3 +176,32 @@ class KeyboardRemote(threading.Thread): KEYBOARD_REMOTE_COMMAND_RECEIVED, {KEY_CODE: event.code} ) + + +class KeyboardRemote(object): + """Sets up one thread per device.""" + + def __init__(self, hass, config): + """Construct a KeyboardRemote interface object.""" + self.threads = [] + for dev_block in config: + device_descriptor = dev_block.get(DEVICE_DESCRIPTOR) + device_name = dev_block.get(DEVICE_NAME) + key_value = KEY_VALUE.get(dev_block.get(TYPE, 'key_up')) + + if device_descriptor is not None\ + or device_name is not None: + thread = KeyboardRemoteThread(hass, device_name, + device_descriptor, + key_value) + self.threads.append(thread) + + def run(self): + """Run all event listener threads.""" + for thread in self.threads: + thread.start() + + def stop(self): + """Stop all event listener threads.""" + for thread in self.threads: + thread.stopped.set() diff --git a/homeassistant/components/light/greenwave.py b/homeassistant/components/light/greenwave.py new file mode 100644 index 00000000000..0e99a49eaa9 --- /dev/null +++ b/homeassistant/components/light/greenwave.py @@ -0,0 +1,112 @@ +""" +Support for Greenwave Reality (TCP Connected) lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.greenwave/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, Light, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS) +from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv + +SUPPORTED_FEATURES = (SUPPORT_BRIGHTNESS) + +REQUIREMENTS = ['greenwavereality==0.2.9'] +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required("version"): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Greenwave Reality Platform.""" + import greenwavereality as greenwave + import os + host = config.get(CONF_HOST) + tokenfile = hass.config.path('.greenwave') + if config.get("version") == 3: + if os.path.exists(tokenfile): + tokenfile = open(tokenfile) + token = tokenfile.read() + tokenfile.close() + else: + token = greenwave.grab_token(host, 'hass', 'homeassistant') + tokenfile = open(tokenfile, "w+") + tokenfile.write(token) + tokenfile.close() + else: + token = None + doc = greenwave.grab_xml(host, token) + add_devices(GreenwaveLight(device, host, token) for device in doc) + + +class GreenwaveLight(Light): + """Representation of an Greenwave Reality Light.""" + + def __init__(self, light, host, token): + """Initialize a Greenwave Reality Light.""" + import greenwavereality as greenwave + self._did = light['did'] + self._name = light['name'] + self._state = int(light['state']) + self._brightness = greenwave.hass_brightness(light) + self._host = host + self._online = greenwave.check_online(light) + self.token = token + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES + + @property + def available(self): + """Return True if entity is available.""" + return self._online + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + import greenwavereality as greenwave + temp_brightness = int((kwargs.get(ATTR_BRIGHTNESS, 255) + / 255) * 100) + greenwave.set_brightness(self._host, self._did, + temp_brightness, self.token) + greenwave.turn_on(self._host, self._did, self.token) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + import greenwavereality as greenwave + greenwave.turn_off(self._host, self._did, self.token) + + def update(self): + """Fetch new state data for this light.""" + import greenwavereality as greenwave + doc = greenwave.grab_xml(self._host, self.token) + + for device in doc: + if device['did'] == self._did: + self._state = int(device['state']) + self._brightness = greenwave.hass_brightness(device) + self._online = greenwave.check_online(device) + self._name = device['name'] diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py index 95bd0b6988d..3356d637be8 100644 --- a/homeassistant/components/light/hive.py +++ b/homeassistant/components/light/hive.py @@ -4,8 +4,10 @@ Support for the Hive devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hive/ """ +import colorsys from homeassistant.components.hive import DATA_HIVE from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) @@ -46,19 +48,24 @@ class HiveDeviceLight(Light): """Return the display name of this light.""" return self.node_name + @property + def brightness(self): + """Brightness of the light (an integer in the range 1-255).""" + return self.session.light.get_brightness(self.node_id) + @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" if self.light_device_type == "tuneablelight" \ or self.light_device_type == "colourtuneablelight": - return self.session.light.get_min_colour_temp(self.node_id) + return self.session.light.get_min_color_temp(self.node_id) @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" if self.light_device_type == "tuneablelight" \ or self.light_device_type == "colourtuneablelight": - return self.session.light.get_max_colour_temp(self.node_id) + return self.session.light.get_max_color_temp(self.node_id) @property def color_temp(self): @@ -68,9 +75,10 @@ class HiveDeviceLight(Light): return self.session.light.get_color_temp(self.node_id) @property - def brightness(self): - """Brightness of the light (an integer in the range 1-255).""" - return self.session.light.get_brightness(self.node_id) + def rgb_color(self) -> tuple: + """Return the RBG color value.""" + if self.light_device_type == "colourtuneablelight": + return self.session.light.get_color(self.node_id) @property def is_on(self): @@ -81,6 +89,7 @@ class HiveDeviceLight(Light): """Instruct the light to turn on.""" new_brightness = None new_color_temp = None + new_color = None if ATTR_BRIGHTNESS in kwargs: tmp_new_brightness = kwargs.get(ATTR_BRIGHTNESS) percentage_brightness = ((tmp_new_brightness / 255) * 100) @@ -90,13 +99,19 @@ class HiveDeviceLight(Light): if ATTR_COLOR_TEMP in kwargs: tmp_new_color_temp = kwargs.get(ATTR_COLOR_TEMP) new_color_temp = round(1000000 / tmp_new_color_temp) + if ATTR_RGB_COLOR in kwargs: + get_new_color = kwargs.get(ATTR_RGB_COLOR) + tmp_new_color = colorsys.rgb_to_hsv(get_new_color[0], + get_new_color[1], + get_new_color[2]) + hue = int(round(tmp_new_color[0] * 360)) + saturation = int(round(tmp_new_color[1] * 100)) + value = int(round((tmp_new_color[2] / 255) * 100)) + new_color = (hue, saturation, value) - if new_brightness is not None: - self.session.light.set_brightness(self.node_id, new_brightness) - elif new_color_temp is not None: - self.session.light.set_colour_temp(self.node_id, new_color_temp) - else: - self.session.light.turn_on(self.node_id) + self.session.light.turn_on(self.node_id, self.light_device_type, + new_brightness, new_color_temp, + new_color) for entity in self.session.entities: entity.handle_update(self.data_updatesource) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index fe7dd765d01..f5c910ea116 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -1,19 +1,21 @@ """ -Support for Hue lights. +This component provides light support for the Philips Hue system. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hue/ """ -import json -import logging -import os -import random -import socket from datetime import timedelta +import logging +import random +import re +import socket import voluptuous as vol +import homeassistant.components.hue as hue + import homeassistant.util as util +from homeassistant.util import yaml import homeassistant.util.color as color_util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, @@ -21,30 +23,17 @@ from homeassistant.components.light import ( FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file -from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME) +from homeassistant.const import CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE_HIDDEN import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['phue==1.0'] +DEPENDENCIES = ['hue'] -# Track previously setup bridges -_CONFIGURED_BRIDGES = {} -# Map ip to request id for configuring -_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -CONF_ALLOW_UNREACHABLE = 'allow_unreachable' - -DEFAULT_ALLOW_UNREACHABLE = False -DOMAIN = "light" -SERVICE_HUE_SCENE = "hue_activate_scene" - MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) -PHUE_CONFIG_FILE = 'phue.conf' - SUPPORT_HUE_ON_OFF = (SUPPORT_FLASH | SUPPORT_TRANSITION) SUPPORT_HUE_DIMMABLE = (SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS) SUPPORT_HUE_COLOR_TEMP = (SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP) @@ -60,10 +49,14 @@ SUPPORT_HUE = { 'Color temperature light': SUPPORT_HUE_COLOR_TEMP } -CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" -DEFAULT_ALLOW_IN_EMULATED_HUE = True +ATTR_IS_HUE_GROUP = 'is_hue_group' -CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +# Legacy configuration, will be removed in 0.60 +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False +CONF_ALLOW_IN_EMULATED_HUE = 'allow_in_emulated_hue' +DEFAULT_ALLOW_IN_EMULATED_HUE = True +CONF_ALLOW_HUE_GROUPS = 'allow_hue_groups' DEFAULT_ALLOW_HUE_GROUPS = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -75,236 +68,158 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, }) -ATTR_GROUP_NAME = "group_name" -ATTR_SCENE_NAME = "scene_name" -SCENE_SCHEMA = vol.Schema({ - vol.Required(ATTR_GROUP_NAME): cv.string, - vol.Required(ATTR_SCENE_NAME): cv.string, -}) +MIGRATION_ID = 'light_hue_config_migration' +MIGRATION_TITLE = 'Philips Hue Configuration Migration' +MIGRATION_INSTRUCTIONS = """ +Configuration for the Philips Hue component has changed; action required. -ATTR_IS_HUE_GROUP = "is_hue_group" +You have configured at least one bridge: -CONFIG_INSTRUCTIONS = """ -Press the button on the bridge to register Philips Hue with Home Assistant. + hue: +{config} -![Location of button on bridge](/static/images/config_philips_hue.jpg) +This configuration is deprecated, please check the +[Hue component](https://home-assistant.io/components/hue/) page for more +information. """ -def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): - """Attempt to detect host based on existing configuration.""" - path = hass.config.path(filename) - - if not os.path.isfile(path): - return None - - try: - with open(path) as inp: - return next(json.loads(''.join(inp)).keys().__iter__()) - except (ValueError, AttributeError, StopIteration): - # ValueError if can't parse as JSON - # AttributeError if JSON value is not a dict - # StopIteration if no keys - return None - - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Hue lights.""" - # Default needed in case of discovery - filename = config.get(CONF_FILENAME, PHUE_CONFIG_FILE) - allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_UNREACHABLE) - allow_in_emulated_hue = config.get(CONF_ALLOW_IN_EMULATED_HUE, - DEFAULT_ALLOW_IN_EMULATED_HUE) - allow_hue_groups = config.get(CONF_ALLOW_HUE_GROUPS) - - if discovery_info is not None: - if "HASS Bridge" in discovery_info.get('name', ''): - _LOGGER.info("Emulated hue found, will not add") - return False - - host = discovery_info.get('host') - else: - host = config.get(CONF_HOST, None) - - if host is None: - host = _find_host_from_config(hass, filename) - - if host is None: - _LOGGER.error("No host found in configuration") - return False - - # Only act if we are not already configuring this host - if host in _CONFIGURING or \ - socket.gethostbyname(host) in _CONFIGURED_BRIDGES: + if discovery_info is None or 'bridge_id' not in discovery_info: return - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) + if config is not None and len(config) > 0: + # Legacy configuration, will be removed in 0.60 + config_str = yaml.dump([config]) + # Indent so it renders in a fixed-width font + config_str = re.sub('(?m)^', ' ', config_str) + hass.components.persistent_notification.async_create( + MIGRATION_INSTRUCTIONS.format(config=config_str), + title=MIGRATION_TITLE, + notification_id=MIGRATION_ID) + + bridge_id = discovery_info['bridge_id'] + bridge = hass.data[hue.DOMAIN][bridge_id] + unthrottled_update_lights(hass, bridge, add_devices) -def setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups): - """Set up a phue bridge based on host parameter.""" +@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) +def update_lights(hass, bridge, add_devices): + """Update the Hue light objects with latest info from the bridge.""" + return unthrottled_update_lights(hass, bridge, add_devices) + + +def unthrottled_update_lights(hass, bridge, add_devices): + """Internal version of update_lights.""" import phue + if not bridge.configured: + return + try: - bridge = phue.Bridge( - host, - config_file_path=hass.config.path(filename)) - except ConnectionRefusedError: # Wrong host was given - _LOGGER.error("Error connecting to the Hue bridge at %s", host) - + api = bridge.get_api() + except phue.PhueRequestTimeout: + _LOGGER.warning('Timeout trying to reach the bridge') + return + except ConnectionRefusedError: + _LOGGER.error('The bridge refused the connection') + return + except socket.error: + # socket.error when we cannot reach Hue + _LOGGER.exception('Cannot reach the bridge') return - except phue.PhueRegistrationException: - _LOGGER.warning("Connected to Hue at %s but not registered.", host) + bridge_type = get_bridge_type(api) - request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups) + new_lights = process_lights( + hass, api, bridge, bridge_type, + lambda **kw: update_lights(hass, bridge, add_devices, **kw)) + if bridge.allow_hue_groups: + new_lightgroups = process_groups( + hass, api, bridge, bridge_type, + lambda **kw: update_lights(hass, bridge, add_devices, **kw)) + new_lights.extend(new_lightgroups) - return + if new_lights: + add_devices(new_lights) - # If we came here and configuring this host, mark as done - if host in _CONFIGURING: - request_id = _CONFIGURING.pop(host) - configurator = hass.components.configurator - configurator.request_done(request_id) - lights = {} - lightgroups = {} - skip_groups = not allow_hue_groups +def get_bridge_type(api): + """Return the bridge type.""" + api_name = api.get('config').get('name') + if api_name in ('RaspBee-GW', 'deCONZ-GW'): + return 'deconz' + else: + return 'hue' - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_lights(): - """Update the Hue light objects with latest info from the bridge.""" - nonlocal skip_groups - try: - api = bridge.get_api() - except phue.PhueRequestTimeout: - _LOGGER.warning("Timeout trying to reach the bridge") - return - except ConnectionRefusedError: - _LOGGER.error("The bridge refused the connection") - return - except socket.error: - # socket.error when we cannot reach Hue - _LOGGER.exception("Cannot reach the bridge") - return +def process_lights(hass, api, bridge, bridge_type, update_lights_cb): + """Set up HueLight objects for all lights.""" + api_lights = api.get('lights') - api_lights = api.get('lights') + if not isinstance(api_lights, dict): + _LOGGER.error('Got unexpected result from Hue API') + return [] - if not isinstance(api_lights, dict): - _LOGGER.error("Got unexpected result from Hue API") - return + new_lights = [] - if skip_groups: - api_groups = {} + for light_id, info in api_lights.items(): + if light_id not in bridge.lights: + bridge.lights[light_id] = HueLight( + int(light_id), info, bridge, + update_lights_cb, + bridge_type, bridge.allow_unreachable, + bridge.allow_in_emulated_hue) + new_lights.append(bridge.lights[light_id]) else: - api_groups = api.get('groups') + bridge.lights[light_id].info = info + bridge.lights[light_id].schedule_update_ha_state() - if not isinstance(api_groups, dict): - _LOGGER.error("Got unexpected result from Hue API") - return + return new_lights - new_lights = [] - api_name = api.get('config').get('name') - if api_name in ('RaspBee-GW', 'deCONZ-GW'): - bridge_type = 'deconz' +def process_groups(hass, api, bridge, bridge_type, update_lights_cb): + """Set up HueLight objects for all groups.""" + api_groups = api.get('groups') + + if not isinstance(api_groups, dict): + _LOGGER.error('Got unexpected result from Hue API') + return [] + + new_lights = [] + + for lightgroup_id, info in api_groups.items(): + if 'state' not in info: + _LOGGER.warning('Group info does not contain state. ' + 'Please update your hub.') + return [] + + if lightgroup_id not in bridge.lightgroups: + bridge.lightgroups[lightgroup_id] = HueLight( + int(lightgroup_id), info, bridge, + update_lights_cb, + bridge_type, bridge.allow_unreachable, + bridge.allow_in_emulated_hue, True) + new_lights.append(bridge.lightgroups[lightgroup_id]) else: - bridge_type = 'hue' + bridge.lightgroups[lightgroup_id].info = info + bridge.lightgroups[lightgroup_id].schedule_update_ha_state() - for light_id, info in api_lights.items(): - if light_id not in lights: - lights[light_id] = HueLight(int(light_id), info, - bridge, update_lights, - bridge_type, allow_unreachable, - allow_in_emulated_hue) - new_lights.append(lights[light_id]) - else: - lights[light_id].info = info - lights[light_id].schedule_update_ha_state() - - for lightgroup_id, info in api_groups.items(): - if 'state' not in info: - _LOGGER.warning("Group info does not contain state. " - "Please update your hub.") - skip_groups = True - break - - if lightgroup_id not in lightgroups: - lightgroups[lightgroup_id] = HueLight( - int(lightgroup_id), info, bridge, update_lights, - bridge_type, allow_unreachable, allow_in_emulated_hue, - True) - new_lights.append(lightgroups[lightgroup_id]) - else: - lightgroups[lightgroup_id].info = info - lightgroups[lightgroup_id].schedule_update_ha_state() - - if new_lights: - add_devices(new_lights) - - _CONFIGURED_BRIDGES[socket.gethostbyname(host)] = True - - # create a service for calling run_scene directly on the bridge, - # used to simplify automation rules. - def hue_activate_scene(call): - """Service to call directly into bridge to set scenes.""" - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - bridge.run_scene(group_name, scene_name) - - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, - descriptions.get(SERVICE_HUE_SCENE), - schema=SCENE_SCHEMA) - - update_lights() - - -def request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if host in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[host], "Failed to register, please try again.") - - return - - # pylint: disable=unused-argument - def hue_configuration_callback(data): - """Set up actions to do when our configuration callback is called.""" - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) - - _CONFIGURING[host] = configurator.request_config( - "Philips Hue", hue_configuration_callback, - description=CONFIG_INSTRUCTIONS, - entity_picture="/static/images/logo_philips_hue.png", - submit_caption="I have pressed the button" - ) + return new_lights class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light_id, info, bridge, update_lights, + def __init__(self, light_id, info, bridge, update_lights_cb, bridge_type, allow_unreachable, allow_in_emulated_hue, is_group=False): """Initialize the light.""" self.light_id = light_id self.info = info self.bridge = bridge - self.update_lights = update_lights + self.update_lights = update_lights_cb self.bridge_type = bridge_type self.allow_unreachable = allow_unreachable self.is_group = is_group @@ -381,14 +296,15 @@ class HueLight(Light): command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_XY_COLOR in kwargs: - if self.info.get('manufacturername') == "OSRAM": - hue, sat = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - command['hue'] = hue + if self.info.get('manufacturername') == 'OSRAM': + color_hue, sat = color_util.color_xy_to_hs( + *kwargs[ATTR_XY_COLOR]) + command['hue'] = color_hue command['sat'] = sat else: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: - if self.info.get('manufacturername') == "OSRAM": + if self.info.get('manufacturername') == 'OSRAM': hsv = color_util.color_RGB_to_hsv( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) command['hue'] = hsv[0] diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index ad2cf204463..06a00954d3b 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -33,7 +33,7 @@ import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.6.0', 'aiolifx_effects==0.1.2'] +REQUIREMENTS = ['aiolifx==0.6.1', 'aiolifx_effects==0.1.2'] UDP_BROADCAST_PORT = 56700 @@ -157,20 +157,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return True -def lifxwhite(device): - """Return whether this is a white-only bulb.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return not features["color"] - return False - - -def lifxmultizone(device): - """Return whether this is a multizone bulb/strip.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return features["multizone"] - return False +def lifx_features(device): + """Return a feature map for this device, or a default map if unknown.""" + return aiolifx().products.features_map.get(device.product) or \ + aiolifx().products.features_map.get(1) def find_hsbk(**kwargs): @@ -342,12 +332,12 @@ class LIFXManager(object): device.retry_count = MESSAGE_RETRIES device.unregister_timeout = UNAVAILABLE_GRACE - if lifxwhite(device): - entity = LIFXWhite(device, self.effects_conductor) - elif lifxmultizone(device): + if lifx_features(device)["multizone"]: entity = LIFXStrip(device, self.effects_conductor) - else: + elif lifx_features(device)["color"]: entity = LIFXColor(device, self.effects_conductor) + else: + entity = LIFXWhite(device, self.effects_conductor) _LOGGER.debug("%s register READY", entity.who) self.entities[device.mac_addr] = entity @@ -427,6 +417,29 @@ class LIFXLight(Light): """Return a string identifying the device.""" return "%s (%s)" % (self.device.ip_addr, self.name) + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['max_kelvin'] + return math.floor(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['min_kelvin'] + return math.ceil(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def supported_features(self): + """Flag supported features.""" + support = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_EFFECT + + device_features = lifx_features(self.device) + if device_features['min_kelvin'] != device_features['max_kelvin']: + support |= SUPPORT_COLOR_TEMP + + return support + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -571,22 +584,6 @@ class LIFXLight(Light): class LIFXWhite(LIFXLight): """Representation of a white-only LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(6500)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2700)) - - @property - def supported_features(self): - """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT) - @property def effect_list(self): """Return the list of supported effects for this light.""" @@ -599,21 +596,12 @@ class LIFXWhite(LIFXLight): class LIFXColor(LIFXLight): """Representation of a color LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(9000)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2500)) - @property def supported_features(self): """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT | SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR) + support = super().supported_features + support |= SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR + return support @property def effect_list(self): diff --git a/homeassistant/components/light/mochad.py b/homeassistant/components/light/mochad.py index fffaa293188..efc62b05434 100644 --- a/homeassistant/components/light/mochad.py +++ b/homeassistant/components/light/mochad.py @@ -12,13 +12,15 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA) from homeassistant.components import mochad -from homeassistant.const import (CONF_NAME, CONF_PLATFORM, CONF_DEVICES, - CONF_ADDRESS) +from homeassistant.const import ( + CONF_NAME, CONF_PLATFORM, CONF_DEVICES, CONF_ADDRESS) from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['mochad'] _LOGGER = logging.getLogger(__name__) +CONF_BRIGHTNESS_LEVELS = 'brightness_levels' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): mochad.DOMAIN, @@ -26,6 +28,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): cv.x10_address, vol.Optional(mochad.CONF_COMM_TYPE): cv.string, + vol.Optional(CONF_BRIGHTNESS_LEVELS, default=32): + vol.All(vol.Coerce(int), vol.In([32, 64, 256])), }] }) @@ -54,6 +58,7 @@ class MochadLight(Light): comm_type=self._comm_type) self._brightness = 0 self._state = self._get_device_status() + self._brightness_levels = dev.get(CONF_BRIGHTNESS_LEVELS) - 1 @property def brightness(self): @@ -62,7 +67,8 @@ class MochadLight(Light): def _get_device_status(self): """Get the status of the light from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property @@ -85,15 +91,47 @@ class MochadLight(Light): """X10 devices are normally 1-way so we have to assume the state.""" return True + def _calculate_brightness_value(self, value): + return int(value * (float(self._brightness_levels) / 255.0)) + + def _adjust_brightness(self, brightness): + if self._brightness > brightness: + bdelta = self._brightness - brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("dim {}".format(mochad_brightness)) + self._controller.read_data() + elif self._brightness < brightness: + bdelta = brightness - self._brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("bright {}".format(mochad_brightness)) + self._controller.read_data() + def turn_on(self, **kwargs): """Send the command to turn the light on.""" - self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) - self.device.send_cmd("xdim {}".format(self._brightness)) - self._controller.read_data() + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + with mochad.REQ_LOCK: + if self._brightness_levels > 32: + out_brightness = self._calculate_brightness_value(brightness) + self.device.send_cmd('xdim {}'.format(out_brightness)) + self._controller.read_data() + else: + self.device.send_cmd("on") + self._controller.read_data() + # There is no persistence for X10 modules so a fresh on command + # will be full brightness + if self._brightness == 0: + self._brightness = 255 + self._adjust_brightness(brightness) + self._brightness = brightness self._state = True def turn_off(self, **kwargs): """Send the command to turn the light on.""" - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() + # There is no persistence for X10 modules so we need to prepare + # to track a fresh on command will full brightness + if self._brightness_levels == 31: + self._brightness = 0 self._state = False diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index f6a544950c0..692a5fb86ec 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -72,6 +72,7 @@ class TPLinkSmartBulb(Light): if name is not None: self._name = name self._state = None + self._available = True self._color_temp = None self._brightness = None self._rgb = None @@ -83,6 +84,11 @@ class TPLinkSmartBulb(Light): """Return the name of the Smart Bulb, if any.""" return self._name + @property + def available(self) -> bool: + """Return if bulb is available.""" + return self._available + @property def device_state_attributes(self): """Return the state attributes of the device.""" @@ -132,6 +138,7 @@ class TPLinkSmartBulb(Light): """Update the TP-Link Bulb's state.""" from pyHS100 import SmartDeviceException try: + self._available = True if self._supported_features == 0: self.get_features() self._state = ( @@ -163,8 +170,10 @@ class TPLinkSmartBulb(Light): except KeyError: # device returned no daily/monthly history pass + except (SmartDeviceException, OSError) as ex: - _LOGGER.warning('Could not read state for %s: %s', self._name, ex) + _LOGGER.warning("Could not read state for %s: %s", self._name, ex) + self._available = False @property def supported_features(self): diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index dc8e7f4c996..bb2fa44c15c 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -160,6 +160,7 @@ class TradfriLight(Light): self._rgb_color = None self._features = SUPPORTED_FEATURES self._temp_supported = False + self._available = True self._refresh(light) @@ -196,6 +197,11 @@ class TradfriLight(Light): """Start thread when added to hass.""" self._async_start_observe() + @property + def available(self): + """Return True if entity is available.""" + return self._available + @property def should_poll(self): """No polling needed for tradfri light.""" @@ -299,6 +305,7 @@ class TradfriLight(Light): self._light = light # Caching of LightControl and light object + self._available = light.reachable self._light_control = light.light_control self._light_data = light.light_control.lights[0] self._name = light.name diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index b3be93d82e2..102ca814882 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -21,7 +21,8 @@ DEPENDENCIES = ['vera'] def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera lights.""" add_devices( - VeraLight(device, VERA_CONTROLLER) for device in VERA_DEVICES['light']) + VeraLight(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['light']) class VeraLight(VeraDevice, Light): diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index ddffed52271..b35b5a3740e 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.2'] +REQUIREMENTS = ['python-miio==0.3.3'] # The light does not accept cct values < 1 CCT_MIN = 1 diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index edbb8a34f24..63272b90b1f 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -66,7 +66,10 @@ class ISYLockDevice(isy.ISYDevice, LockDevice): @property def state(self) -> str: """Get the state of the lock.""" - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: """Send the lock command to the ISY994 device.""" diff --git a/homeassistant/components/lock/sesame.py b/homeassistant/components/lock/sesame.py index 02b049618d2..5bc40435486 100644 --- a/homeassistant/components/lock/sesame.py +++ b/homeassistant/components/lock/sesame.py @@ -25,46 +25,53 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument -def setup_platform(hass, config: ConfigType, - add_devices: Callable[[list], None], discovery_info=None): +def setup_platform( + hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): """Set up the Sesame platform.""" import pysesame email = config.get(CONF_EMAIL) password = config.get(CONF_PASSWORD) - add_devices([SesameDevice(sesame) for - sesame in pysesame.get_sesames(email, password)]) + add_devices([SesameDevice(sesame) for sesame in + pysesame.get_sesames(email, password)], + update_before_add=True) class SesameDevice(LockDevice): """Representation of a Sesame device.""" - _sesame = None - def __init__(self, sesame: object) -> None: """Initialize the Sesame device.""" self._sesame = sesame + # Cached properties from pysesame object. + self._device_id = None + self._nickname = None + self._is_unlocked = False + self._api_enabled = False + self._battery = -1 + @property def name(self) -> str: """Return the name of the device.""" - return self._sesame.nickname + return self._nickname @property def available(self) -> bool: """Return True if entity is available.""" - return self._sesame.api_enabled + return self._api_enabled @property def is_locked(self) -> bool: """Return True if the device is currently locked, else False.""" - return not self._sesame.is_unlocked + return not self._is_unlocked @property def state(self) -> str: """Get the state of the device.""" - if self._sesame.is_unlocked: + if self._is_unlocked: return STATE_UNLOCKED return STATE_LOCKED @@ -79,11 +86,16 @@ class SesameDevice(LockDevice): def update(self) -> None: """Update the internal state of the device.""" self._sesame.update_state() + self._nickname = self._sesame.nickname + self._api_enabled = self._sesame.api_enabled + self._is_unlocked = self._sesame.is_unlocked + self._device_id = self._sesame.device_id + self._battery = self._sesame.battery @property def device_state_attributes(self) -> dict: """Return the state attributes.""" attributes = {} - attributes[ATTR_DEVICE_ID] = self._sesame.device_id - attributes[ATTR_BATTERY_LEVEL] = self._sesame.battery + attributes[ATTR_DEVICE_ID] = self._device_id + attributes[ATTR_BATTERY_LEVEL] = self._battery return attributes diff --git a/homeassistant/components/lock/vera.py b/homeassistant/components/lock/vera.py index 04962566821..b3aae5e159f 100644 --- a/homeassistant/components/lock/vera.py +++ b/homeassistant/components/lock/vera.py @@ -19,8 +19,8 @@ DEPENDENCIES = ['vera'] def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Vera locks.""" add_devices( - VeraLock(device, VERA_CONTROLLER) for - device in VERA_DEVICES['lock']) + VeraLock(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['lock']) class VeraLock(VeraDevice, LockDevice): diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 63a271acdd5..1dc0861d737 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -135,9 +135,8 @@ class LogbookView(HomeAssistantView): hass = request.app['hass'] events = yield from hass.async_add_job( - _get_events, hass, start_day, end_day) - events = _exclude_events(events, self.config) - return self.json(humanify(events)) + _get_events, hass, self.config, start_day, end_day) + return self.json(events) class Entry(object): @@ -274,7 +273,7 @@ def humanify(events): entity_id) -def _get_events(hass, start_day, end_day): +def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" from homeassistant.components.recorder.models import Events from homeassistant.components.recorder.util import ( @@ -285,7 +284,8 @@ def _get_events(hass, start_day, end_day): Events.time_fired).filter( (Events.time_fired > start_day) & (Events.time_fired < end_day)) - return execute(query) + events = execute(query) + return humanify(_exclude_events(events, config)) def _exclude_events(events, config): diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 9d5e88282ae..669390b3b90 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.11.26'] +REQUIREMENTS = ['youtube_dl==2017.12.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 15698ec5022..8093f0d3dbe 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -20,8 +20,9 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_ON, STATE_OFF, STATE_PLAYING, STATE_PAUSED, CONF_NAME) import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['liveboxplaytv==2.0.0'] +REQUIREMENTS = ['liveboxplaytv==2.0.2', 'pyteleloisirs==3.3'] _LOGGER = logging.getLogger(__name__) @@ -76,19 +77,32 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): self._channel_list = {} self._current_channel = None self._current_program = None + self._media_duration = None + self._media_remaining_time = None self._media_image_url = None + self._media_last_updated = None @asyncio.coroutine def async_update(self): """Retrieve the latest data.""" + import pyteleloisirs try: self._state = self.refresh_state() # Update current channel channel = self._client.channel if channel is not None: - self._current_program = yield from \ - self._client.async_get_current_program_name() self._current_channel = channel + program = yield from \ + self._client.async_get_current_program() + if program and self._current_program != program.get('name'): + self._current_program = program.get('name') + # Media progress info + self._media_duration = \ + pyteleloisirs.get_program_duration(program) + rtime = pyteleloisirs.get_remaining_time(program) + if rtime != self._media_remaining_time: + self._media_remaining_time = rtime + self._media_last_updated = dt_util.utcnow() # Set media image to current program if a thumbnail is # available. Otherwise we'll use the channel's image. img_size = 800 @@ -100,7 +114,6 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): chan_img_url = \ self._client.get_current_channel_image(img_size) self._media_image_url = chan_img_url - self.refresh_channel_list() except requests.ConnectionError: self._state = None @@ -149,8 +162,25 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): if self._current_program: return '{}: {}'.format(self._current_channel, self._current_program) - else: - return self._current_channel + return self._current_channel + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._media_remaining_time + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._media_last_updated @property def supported_features(self): diff --git a/homeassistant/components/media_player/monoprice.py b/homeassistant/components/media_player/monoprice.py index 10b4b8414d8..a2b5d91945a 100644 --- a/homeassistant/components/media_player/monoprice.py +++ b/homeassistant/components/media_player/monoprice.py @@ -5,18 +5,21 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.monoprice/ """ import logging +from os import path import voluptuous as vol -from homeassistant.const import (CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON) +from homeassistant.const import (ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, + STATE_OFF, STATE_ON) +from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_VOLUME_MUTE, - SUPPORT_SELECT_SOURCE, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, - SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) + DOMAIN, MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA, + SUPPORT_VOLUME_MUTE, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_ON, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) -REQUIREMENTS = ['pymonoprice==0.2'] +REQUIREMENTS = ['pymonoprice==0.3'] _LOGGER = logging.getLogger(__name__) @@ -35,6 +38,11 @@ SOURCE_SCHEMA = vol.Schema({ CONF_ZONES = 'zones' CONF_SOURCES = 'sources' +DATA_MONOPRICE = 'monoprice' + +SERVICE_SNAPSHOT = 'snapshot' +SERVICE_RESTORE = 'restore' + # Valid zone ids: 11-16 or 21-26 or 31-36 ZONE_IDS = vol.All(vol.Coerce(int), vol.Any(vol.Range(min=11, max=16), vol.Range(min=21, max=26), @@ -56,9 +64,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = config.get(CONF_PORT) from serial import SerialException - from pymonoprice import Monoprice + from pymonoprice import get_monoprice try: - monoprice = Monoprice(port) + monoprice = get_monoprice(port) except SerialException: _LOGGER.error('Error connecting to Monoprice controller.') return @@ -66,10 +74,41 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sources = {source_id: extra[CONF_NAME] for source_id, extra in config[CONF_SOURCES].items()} + hass.data[DATA_MONOPRICE] = [] for zone_id, extra in config[CONF_ZONES].items(): _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) - add_devices([MonopriceZone(monoprice, sources, - zone_id, extra[CONF_NAME])], True) + hass.data[DATA_MONOPRICE].append(MonopriceZone(monoprice, sources, + zone_id, + extra[CONF_NAME])) + + add_devices(hass.data[DATA_MONOPRICE], True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if entity_ids: + devices = [device for device in hass.data[DATA_MONOPRICE] + if device.entity_id in entity_ids] + else: + devices = hass.data[DATA_MONOPRICE] + + for device in devices: + if service.service == SERVICE_SNAPSHOT: + device.snapshot() + elif service.service == SERVICE_RESTORE: + device.restore() + + hass.services.register( + DOMAIN, SERVICE_SNAPSHOT, service_handle, + descriptions.get(SERVICE_SNAPSHOT), schema=MEDIA_PLAYER_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_RESTORE, service_handle, + descriptions.get(SERVICE_RESTORE), schema=MEDIA_PLAYER_SCHEMA) class MonopriceZone(MediaPlayerDevice): @@ -90,6 +129,7 @@ class MonopriceZone(MediaPlayerDevice): self._zone_id = zone_id self._name = zone_name + self._snapshot = None self._state = None self._volume = None self._source = None @@ -152,6 +192,16 @@ class MonopriceZone(MediaPlayerDevice): """List of available input sources.""" return self._source_names + def snapshot(self): + """Save zone's current state.""" + self._snapshot = self._monoprice.zone_status(self._zone_id) + + def restore(self): + """Restore saved state.""" + if self._snapshot: + self._monoprice.restore_zone(self._snapshot) + self.schedule_update_ha_state(True) + def select_source(self, source): """Set input source.""" if source not in self._source_name_id: diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 9b984813ff6..c6f3042f2ba 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -227,7 +227,7 @@ def request_configuration(host, hass, config, add_devices_callback): _CONFIGURING[host] = configurator.request_config( 'Plex Media Server', plex_configuration_callback, - description=('Enter the X-Plex-Token'), + description='Enter the X-Plex-Token', entity_picture='/static/images/logo_plex_mediaserver.png', submit_caption='Confirm', fields=[{ @@ -273,8 +273,23 @@ class PlexClient(MediaPlayerDevice): self.plex_sessions = plex_sessions self.update_devices = update_devices self.update_sessions = update_sessions - - self._clear_media() + # General + self._media_content_id = None + self._media_content_rating = None + self._media_content_type = None + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_position = None + # Music + self._media_album_artist = None + self._media_album_name = None + self._media_artist = None + self._media_track = None + # TV Show + self._media_episode = None + self._media_season = None + self._media_series_title = None self.refresh(device, session) @@ -296,7 +311,7 @@ class PlexClient(MediaPlayerDevice): 'media_player', prefix, self.name.lower().replace('-', '_')) - def _clear_media(self): + def _clear_media_details(self): """Set all Media Items to None.""" # General self._media_content_id = None @@ -316,10 +331,13 @@ class PlexClient(MediaPlayerDevice): self._media_season = None self._media_series_title = None + # Clear library Name + self._app_name = '' + def refresh(self, device, session): """Refresh key device data.""" # new data refresh - self._clear_media() + self._clear_media_details() if session: # Not being triggered by Chrome or FireTablet Plex App self._session = session @@ -355,6 +373,36 @@ class PlexClient(MediaPlayerDevice): self._media_content_id = self._session.ratingKey self._media_content_rating = self._session.contentRating + self._set_player_state() + + if self._is_player_active and self._session is not None: + self._session_type = self._session.type + self._media_duration = self._session.duration + # title (movie name, tv episode name, music song name) + self._media_title = self._session.title + # media type + self._set_media_type() + self._app_name = self._session.section().title \ + if self._session.section() is not None else '' + self._set_media_image() + else: + self._session_type = None + + def _set_media_image(self): + thumb_url = self._session.thumbUrl + if (self.media_content_type is MEDIA_TYPE_TVSHOW + and not self.config.get(CONF_USE_EPISODE_ART)): + thumb_url = self._server.url( + self._session.grandparentThumb) + + if thumb_url is None: + _LOGGER.debug("Using media art because media thumb " + "was not found: %s", self.entity_id) + thumb_url = self._server.url(self._session.art) + + self._media_image_url = thumb_url + + def _set_player_state(self): if self._player_state == 'playing': self._is_player_active = True self._state = STATE_PLAYING @@ -368,35 +416,10 @@ class PlexClient(MediaPlayerDevice): self._is_player_active = False self._state = STATE_OFF - if self._is_player_active and self._session is not None: - self._session_type = self._session.type - self._media_duration = self._session.duration - else: - self._session_type = None - - # media type - if self._session_type == 'clip': - _LOGGER.debug("Clip content type detected, compatibility may " - "vary: %s", self.entity_id) + def _set_media_type(self): + if self._session_type in ['clip', 'episode']: self._media_content_type = MEDIA_TYPE_TVSHOW - elif self._session_type == 'episode': - self._media_content_type = MEDIA_TYPE_TVSHOW - elif self._session_type == 'movie': - self._media_content_type = MEDIA_TYPE_VIDEO - elif self._session_type == 'track': - self._media_content_type = MEDIA_TYPE_MUSIC - # title (movie name, tv episode name, music song name) - if self._session and self._is_player_active: - self._media_title = self._session.title - - # Movies - if (self.media_content_type == MEDIA_TYPE_VIDEO and - self._session.year is not None): - self._media_title += ' (' + str(self._session.year) + ')' - - # TV Show - if self._media_content_type is MEDIA_TYPE_TVSHOW: # season number (00) if callable(self._session.seasons): self._media_season = self._session.seasons()[0].index.zfill(2) @@ -410,8 +433,14 @@ class PlexClient(MediaPlayerDevice): if self._session.index is not None: self._media_episode = str(self._session.index).zfill(2) - # Music - if self._media_content_type == MEDIA_TYPE_MUSIC: + elif self._session_type == 'movie': + self._media_content_type = MEDIA_TYPE_VIDEO + if self._session.year is not None and \ + self._media_title is not None: + self._media_title += ' (' + str(self._session.year) + ')' + + elif self._session_type == 'track': + self._media_content_type = MEDIA_TYPE_MUSIC self._media_album_name = self._session.parentTitle self._media_album_artist = self._session.grandparentTitle self._media_track = self._session.index @@ -422,33 +451,11 @@ class PlexClient(MediaPlayerDevice): "was not found: %s", self.entity_id) self._media_artist = self._media_album_artist - # set app name to library name - if (self._session is not None - and self._session.section() is not None): - self._app_name = self._session.section().title - else: - self._app_name = '' - - # media image url - if self._session is not None: - thumb_url = self._session.thumbUrl - if (self.media_content_type is MEDIA_TYPE_TVSHOW - and not self.config.get(CONF_USE_EPISODE_ART)): - thumb_url = self._server.url( - self._session.grandparentThumb) - - if thumb_url is None: - _LOGGER.debug("Using media art because media thumb " - "was not found: %s", self.entity_id) - thumb_url = self._server.url(self._session.art) - - self._media_image_url = thumb_url - def force_idle(self): """Force client to idle.""" self._state = STATE_IDLE self._session = None - self._clear_media() + self._clear_media_details() @property def unique_id(self): @@ -792,9 +799,10 @@ class PlexClient(MediaPlayerDevice): @property def device_state_attributes(self): """Return the scene state attributes.""" - attr = {} - attr['media_content_rating'] = self._media_content_rating - attr['session_username'] = self._session_username - attr['media_library_name'] = self._app_name + attr = { + 'media_content_rating': self._media_content_rating, + 'session_username': self._session_username, + 'media_library_name': self._app_name + } return attr diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 721b095c083..d42bd9ea012 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -190,7 +190,10 @@ class SamsungTVDevice(MediaPlayerDevice): else: self.send_key('KEY_POWEROFF') # Force closing of remote session to provide instant UI feedback - self.get_remote().close() + try: + self.get_remote().close() + except OSError: + _LOGGER.debug("Could not establish connection.") def volume_up(self): """Volume up the media player.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index f2d7b8e07dd..0ed5f9d2732 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -107,6 +107,20 @@ media_seek: description: Position to seek to. The format is platform dependent. example: 100 +monoprice_snapshot: + description: Take a snapshot of the media player zone. + fields: + entity_id: + description: Name(s) of entities that will be snapshot. Platform dependent. + example: 'media_player.living_room' + +monoprice_restore: + description: Restore a snapshot of the media player zone. + fields: + entity_id: + description: Name(s) of entities that will be restored. Platform dependent. + example: 'media_player.living_room' + play_media: description: Send the media player the command for playing media. fields: @@ -215,6 +229,18 @@ sonos_clear_sleep_timer: description: Name(s) of entities that will have the timer cleared. example: 'media_player.living_room_sonos' +sonos_set_option: + description: Set Sonos sound options. + fields: + entity_id: + description: Name(s) of entities that will have options set. + example: 'media_player.living_room_sonos' + night_sound: + description: Enable Night Sound mode + example: 'true' + speech_enhance: + description: Enable Speech Enhancement mode + example: 'true' soundtouch_play_everywhere: description: Play on all Bose Soundtouch devices. diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 47786e793ca..3bd3a722b46 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_STOP, - SUPPORT_PLAY) + SUPPORT_PLAY, SUPPORT_SHUFFLE_SET) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF, ATTR_ENTITY_ID, CONF_HOSTS, ATTR_TIME) @@ -27,7 +27,7 @@ from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.12'] +REQUIREMENTS = ['SoCo==0.13'] _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ _REQUESTS_LOGGER.setLevel(logging.ERROR) SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -52,6 +52,7 @@ SERVICE_RESTORE = 'sonos_restore' SERVICE_SET_TIMER = 'sonos_set_sleep_timer' SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer' SERVICE_UPDATE_ALARM = 'sonos_update_alarm' +SERVICE_SET_OPTION = 'sonos_set_option' DATA_SONOS = 'sonos' @@ -69,6 +70,8 @@ ATTR_ENABLED = 'enabled' ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones' ATTR_MASTER = 'master' ATTR_WITH_GROUP = 'with_group' +ATTR_NIGHT_SOUND = 'night_sound' +ATTR_SPEECH_ENHANCE = 'speech_enhance' ATTR_IS_COORDINATOR = 'is_coordinator' @@ -105,6 +108,11 @@ SONOS_UPDATE_ALARM_SCHEMA = SONOS_SCHEMA.extend({ vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }) +SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ + vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, + vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" @@ -140,7 +148,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hosts = hosts.split(',') if isinstance(hosts, str) else hosts players = [] for host in hosts: - players.append(soco.SoCo(socket.gethostbyname(host))) + try: + players.append(soco.SoCo(socket.gethostbyname(host))) + except OSError: + _LOGGER.warning("Failed to initialize '%s'", host) if not players: players = soco.discover( @@ -189,6 +200,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device.clear_sleep_timer() elif service.service == SERVICE_UPDATE_ALARM: device.update_alarm(**service.data) + elif service.service == SERVICE_SET_OPTION: + device.update_option(**service.data) device.schedule_update_ha_state(True) @@ -221,6 +234,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_UPDATE_ALARM), schema=SONOS_UPDATE_ALARM_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_SET_OPTION, service_handle, + descriptions.get(SERVICE_SET_OPTION), + schema=SONOS_SET_OPTION_SCHEMA) + def _parse_timespan(timespan): """Parse a time-span into number of seconds.""" @@ -331,8 +349,11 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = False self._support_next_track = False self._support_play = False + self._support_shuffle_set = True self._support_stop = False self._support_pause = False + self._night_sound = None + self._speech_enhance = None self._current_track_uri = None self._current_track_is_radio_stream = False self._queue = None @@ -450,8 +471,11 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = False self._support_next_track = False self._support_play = False + self._support_shuffle_set = False self._support_stop = False self._support_pause = False + self._night_sound = None + self._speech_enhance = None self._is_playing_tv = False self._is_playing_line_in = False self._source_name = None @@ -524,6 +548,9 @@ class SonosDevice(MediaPlayerDevice): media_position_updated_at = None source_name = None + night_sound = self._player.night_mode + speech_enhance = self._player.dialog_mode + is_radio_stream = \ current_media_uri.startswith('x-sonosapi-stream:') or \ current_media_uri.startswith('x-rincon-mp3radio:') @@ -536,6 +563,7 @@ class SonosDevice(MediaPlayerDevice): support_play = False support_stop = True support_pause = False + support_shuffle_set = False if is_playing_tv: media_artist = SUPPORT_SOURCE_TV @@ -558,6 +586,7 @@ class SonosDevice(MediaPlayerDevice): support_play = True support_stop = True support_pause = False + support_shuffle_set = False source_name = 'Radio' # Check if currently playing radio station is in favorites @@ -622,6 +651,7 @@ class SonosDevice(MediaPlayerDevice): support_play = True support_stop = True support_pause = True + support_shuffle_set = True position_info = self._player.avTransport.GetPositionInfo( [('InstanceID', 0), @@ -694,8 +724,11 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = support_previous_track self._support_next_track = support_next_track self._support_play = support_play + self._support_shuffle_set = support_shuffle_set self._support_stop = support_stop self._support_pause = support_pause + self._night_sound = night_sound + self._speech_enhance = speech_enhance self._is_playing_tv = is_playing_tv self._is_playing_line_in = is_playing_line_in self._source_name = source_name @@ -762,6 +795,11 @@ class SonosDevice(MediaPlayerDevice): """Return true if volume is muted.""" return self._player_volume_muted + @property + def shuffle(self): + """Shuffling state.""" + return True if self._player.play_mode == 'SHUFFLE' else False + @property def media_content_id(self): """Content ID of current playing media.""" @@ -834,6 +872,16 @@ class SonosDevice(MediaPlayerDevice): return self._media_title + @property + def night_sound(self): + """Get status of Night Sound.""" + return self._night_sound + + @property + def speech_enhance(self): + """Get status of Speech Enhancement.""" + return self._speech_enhance + @property def supported_features(self): """Flag media player features that are supported.""" @@ -850,7 +898,8 @@ class SonosDevice(MediaPlayerDevice): if not self._support_play: supported = supported ^ SUPPORT_PLAY - + if not self._support_shuffle_set: + supported = supported ^ SUPPORT_SHUFFLE_SET if not self._support_stop: supported = supported ^ SUPPORT_STOP @@ -874,6 +923,11 @@ class SonosDevice(MediaPlayerDevice): """Set volume level, range 0..1.""" self._player.volume = str(int(volume * 100)) + @soco_error + def set_shuffle(self, shuffle): + """Enable/Disable shuffle mode.""" + self._player.play_mode = 'SHUFFLE' if shuffle else 'NORMAL' + @soco_error def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" @@ -932,7 +986,6 @@ class SonosDevice(MediaPlayerDevice): self._player.stop() self._player.clear_queue() - self._player.play_mode = 'NORMAL' self._player.add_to_queue(didl) @property @@ -1160,7 +1213,24 @@ class SonosDevice(MediaPlayerDevice): a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] a.save() + @soco_error + def update_option(self, **data): + """Modify playback options.""" + if ATTR_NIGHT_SOUND in data and self.night_sound is not None: + self.soco.night_mode = data[ATTR_NIGHT_SOUND] + + if ATTR_SPEECH_ENHANCE in data and self.speech_enhance is not None: + self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE] + @property def device_state_attributes(self): """Return device specific state attributes.""" - return {ATTR_IS_COORDINATOR: self.is_coordinator} + attributes = {ATTR_IS_COORDINATOR: self.is_coordinator} + + if self.night_sound is not None: + attributes[ATTR_NIGHT_SOUND] = self.night_sound + + if self.speech_enhance is not None: + attributes[ATTR_SPEECH_ENHANCE] = self.speech_enhance + + return attributes diff --git a/homeassistant/components/media_player/ue_smart_radio.py b/homeassistant/components/media_player/ue_smart_radio.py new file mode 100644 index 00000000000..2684a819417 --- /dev/null +++ b/homeassistant/components/media_player/ue_smart_radio.py @@ -0,0 +1,207 @@ +""" +Support for Logitech UE Smart Radios. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.ue_smart_radio/ +""" + +import logging +import voluptuous as vol +import requests + +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_OFF, STATE_IDLE, STATE_PLAYING, + STATE_PAUSED) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:radio" +URL = "http://decibel.logitechmusic.com/jsonrpc.js" + +SUPPORT_UE_SMART_RADIO = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + +PLAYBACK_DICT = {"play": STATE_PLAYING, + "pause": STATE_PAUSED, + "stop": STATE_IDLE} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def send_request(payload, session): + """Send request to radio.""" + try: + request = requests.post(URL, + cookies={"sdi_squeezenetwork_session": + session}, + json=payload, timeout=5) + except requests.exceptions.Timeout: + _LOGGER.error("Timed out when sending request") + except requests.exceptions.ConnectionError: + _LOGGER.error("An error occurred while connecting") + else: + return request.json() + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Logitech UE Smart Radio platform.""" + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + session_request = requests.post("https://www.uesmartradio.com/user/login", + data={"email": email, "password": + password}) + session = session_request.cookies["sdi_squeezenetwork_session"] + + player_request = send_request({"params": ["", ["serverstatus"]]}, session) + player_id = player_request["result"]["players_loop"][0]["playerid"] + player_name = player_request["result"]["players_loop"][0]["name"] + + add_devices([UERadioDevice(session, player_id, player_name)]) + + +class UERadioDevice(MediaPlayerDevice): + """Representation of a Logitech UE Smart Radio device.""" + + def __init__(self, session, player_id, player_name): + """Initialize the Logitech UE Smart Radio device.""" + self._session = session + self._player_id = player_id + self._name = player_name + self._state = None + self._volume = 0 + self._last_volume = 0 + self._media_title = None + self._media_artist = None + self._media_artwork_url = None + + def send_command(self, command): + """Send command to radio.""" + send_request({"method": "slim.request", "params": + [self._player_id, command]}, self._session) + + def update(self): + """Get the latest details from the device.""" + request = send_request({ + "method": "slim.request", "params": + [self._player_id, ["status", "-", 1, + "tags:cgABbehldiqtyrSuoKLN"]]}, self._session) + + if request["error"] is not None: + self._state = None + return + + if request["result"]["power"] == 0: + self._state = STATE_OFF + else: + self._state = PLAYBACK_DICT[request["result"]["mode"]] + + media_info = request["result"]["playlist_loop"][0] + + self._volume = request["result"]["mixer volume"] / 100 + self._media_artwork_url = media_info["artwork_url"] + self._media_title = media_info["title"] + if "artist" in media_info: + self._media_artist = media_info["artist"] + else: + self._media_artist = media_info.get("remote_title") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return True if self._volume <= 0 else False + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def supported_features(self): + """Flag of features that are supported.""" + return SUPPORT_UE_SMART_RADIO + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_image_url(self): + """Image URL of current playing media.""" + return self._media_artwork_url + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_title(self): + """Title of current playing media.""" + return self._media_title + + def turn_on(self): + """Turn on specified media player or all.""" + self.send_command(["power", 1]) + + def turn_off(self): + """Turn off specified media player or all.""" + self.send_command(["power", 0]) + + def media_play(self): + """Send the media player the command for play/pause.""" + self.send_command(["play"]) + + def media_pause(self): + """Send the media player the command for pause.""" + self.send_command(["pause"]) + + def media_stop(self): + """Send the media player the stop command.""" + self.send_command(["stop"]) + + def media_previous_track(self): + """Send the media player the command for prev track.""" + self.send_command(["button", "rew"]) + + def media_next_track(self): + """Send the media player the command for next track.""" + self.send_command(["button", "fwd"]) + + def mute_volume(self, mute): + """Send mute command.""" + if mute: + self._last_volume = self._volume + self.send_command(["mixer", "volume", 0]) + else: + self.send_command(["mixer", "volume", self._last_volume * 100]) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.send_command(["mixer", "volume", volume * 100]) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 0abdb90e67a..9d3e0b90fa4 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -322,17 +322,17 @@ class LgWebOSDevice(MediaPlayerDevice): def select_source(self, source): """Select input source.""" - source = self._source_list.get(source) - if source is None: + source_dict = self._source_list.get(source) + if source_dict is None: _LOGGER.warning("Source %s not found for %s", source, self.name) return - self._current_source_id = self._source_list[source]['id'] - if source.get('title'): - self._current_source = self._source_list[source]['title'] - self._client.launch_app(self._source_list[source]['id']) - elif source.get('label'): - self._current_source = self._source_list[source]['label'] - self._client.set_input(self._source_list[source]['id']) + self._current_source_id = source_dict['id'] + if source_dict.get('title'): + self._current_source = source_dict['title'] + self._client.launch_app(source_dict['id']) + elif source_dict.get('label'): + self._current_source = source_dict['label'] + self._client.set_input(source_dict['id']) def media_play(self): """Send play command.""" diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index bfcffff6bb4..b42a5ae474c 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -36,7 +36,7 @@ SUPPORTED_FEATURES = ( KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' INTERVAL_SECONDS = 'interval_seconds' -REQUIREMENTS = ['pymusiccast==0.1.5'] +REQUIREMENTS = ['pymusiccast==0.1.6'] DEFAULT_PORT = 5005 DEFAULT_INTERVAL = 480 diff --git a/homeassistant/components/mochad.py b/homeassistant/components/mochad.py index 165c43f488f..3cc4eda7675 100644 --- a/homeassistant/components/mochad.py +++ b/homeassistant/components/mochad.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mochad/ """ import logging +import threading import voluptuous as vol @@ -23,6 +24,8 @@ CONF_COMM_TYPE = 'comm_type' DOMAIN = 'mochad' +REQ_LOCK = threading.Lock() + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_HOST, default='localhost'): cv.string, diff --git a/homeassistant/components/scene/vera.py b/homeassistant/components/scene/vera.py new file mode 100644 index 00000000000..3dbb68d214f --- /dev/null +++ b/homeassistant/components/scene/vera.py @@ -0,0 +1,60 @@ +""" +Support for Vera scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.vera/ +""" +import logging + +from homeassistant.util import slugify +from homeassistant.components.scene import Scene +from homeassistant.components.vera import ( + VERA_CONTROLLER, VERA_SCENES, VERA_ID_FORMAT) + +DEPENDENCIES = ['vera'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vera scenes.""" + add_devices( + [VeraScene(scene, hass.data[VERA_CONTROLLER]) + for scene in hass.data[VERA_SCENES]], True) + + +class VeraScene(Scene): + """Representation of a Vera scene entity.""" + + def __init__(self, vera_scene, controller): + """Initialize the scene.""" + self.vera_scene = vera_scene + self.controller = controller + + self._name = self.vera_scene.name + # Append device id to prevent name clashes in HA. + self.vera_id = VERA_ID_FORMAT.format( + slugify(vera_scene.name), vera_scene.scene_id) + + def update(self): + """Update the scene status.""" + self.vera_scene.refresh() + + def activate(self, **kwargs): + """Activate the scene.""" + self.vera_scene.activate() + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'vera_scene_id': self.vera_scene.vera_scene_id} + + @property + def should_poll(self): + """Return that polling is not necessary.""" + return False diff --git a/homeassistant/components/sensor/alarmdecoder.py b/homeassistant/components/sensor/alarmdecoder.py index 6b026298db0..ce709eee94c 100644 --- a/homeassistant/components/sensor/alarmdecoder.py +++ b/homeassistant/components/sensor/alarmdecoder.py @@ -7,25 +7,21 @@ https://home-assistant.io/components/sensor.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.components.alarmdecoder import (SIGNAL_PANEL_MESSAGE) -from homeassistant.const import (STATE_UNKNOWN) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['alarmdecoder'] -@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 for AlarmDecoder sensor devices.""" - _LOGGER.debug("AlarmDecoderSensor: async_setup_platform") + _LOGGER.debug("AlarmDecoderSensor: setup_platform") device = AlarmDecoderSensor(hass) - async_add_devices([device]) + add_devices([device]) class AlarmDecoderSensor(Entity): @@ -34,23 +30,20 @@ class AlarmDecoderSensor(Entity): def __init__(self, hass): """Initialize the alarm panel.""" self._display = "" - self._state = STATE_UNKNOWN + self._state = None self._icon = 'mdi:alarm-check' self._name = 'Alarm Panel Display' - _LOGGER.debug("Setting up panel") - @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) - @callback def _message_callback(self, message): if self._display != message.text: self._display = message.text - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/sensor/canary.py b/homeassistant/components/sensor/canary.py new file mode 100644 index 00000000000..b0d2c27ae5d --- /dev/null +++ b/homeassistant/components/sensor/canary.py @@ -0,0 +1,85 @@ +""" +Support for Canary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.canary/ +""" +from homeassistant.components.canary import DATA_CANARY +from homeassistant.const import TEMP_FAHRENHEIT, TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['canary'] + +SENSOR_VALUE_PRECISION = 1 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + from canary.api import SensorType + for location in data.locations: + for device in location.devices: + if device.is_online: + for sensor_type in SensorType: + devices.append(CanarySensor(data, sensor_type, location, + device)) + + add_devices(devices, True) + + +class CanarySensor(Entity): + """Representation of a Canary sensor.""" + + def __init__(self, data, sensor_type, location, device): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._device_id = device.device_id + self._is_celsius = location.is_celsius + self._sensor_value = None + + sensor_type_name = sensor_type.value.replace("_", " ").title() + self._name = '{} {} {}'.format(location.name, + device.name, + sensor_type_name) + + @property + def name(self): + """Return the name of the Canary sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._sensor_value + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "sensor_canary_{}_{}".format(self._device_id, + self._sensor_type.value) + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + from canary.api import SensorType + if self._sensor_type == SensorType.TEMPERATURE: + return TEMP_CELSIUS if self._is_celsius else TEMP_FAHRENHEIT + elif self._sensor_type == SensorType.HUMIDITY: + return "%" + elif self._sensor_type == SensorType.AIR_QUALITY: + return "" + return None + + def update(self): + """Get the latest state of the sensor.""" + self._data.update() + + readings = self._data.get_readings(self._device_id) + value = next(( + reading.value for reading in readings + if reading.sensor_type == self._sensor_type), None) + if value is not None: + self._sensor_value = round(float(value), SENSOR_VALUE_PRECISION) diff --git a/homeassistant/components/sensor/discogs.py b/homeassistant/components/sensor/discogs.py new file mode 100644 index 00000000000..2920dc025d7 --- /dev/null +++ b/homeassistant/components/sensor/discogs.py @@ -0,0 +1,97 @@ +""" +Show the amount of records in a user's Discogs collection. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.discogs/ +""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['discogs_client==2.2.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_IDENTITY = 'identity' + +CONF_ATTRIBUTION = "Data provided by Discogs" + +DEFAULT_NAME = 'Discogs' + +ICON = 'mdi:album' + +SCAN_INTERVAL = timedelta(hours=2) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Discogs sensor.""" + import discogs_client + + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + try: + discogs = discogs_client.Client(SERVER_SOFTWARE, user_token=token) + identity = discogs.identity() + except discogs_client.exceptions.HTTPError: + _LOGGER.error("API token is not valid") + return + + async_add_devices([DiscogsSensor(identity, name)], True) + + +class DiscogsSensor(Entity): + """Get a user's number of records in collection.""" + + def __init__(self, identity, name): + """Initialize the Discogs sensor.""" + self._identity = identity + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'records' + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_IDENTITY: self._identity.name, + } + + @asyncio.coroutine + def async_update(self): + """Set state to the amount of records in user's collection.""" + self._state = self._identity.num_collection diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index 0f24905c5f5..c14a33dce01 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -31,6 +31,7 @@ CONF_COST = 'cost' CONF_CURRENT_VALUES = 'current_values' DEFAULT_PERIOD = 'year' +DEFAULT_UTC_OFFSET = '0' SENSOR_TYPES = { CONF_INSTANT: ['Energy Usage', 'W'], @@ -50,7 +51,7 @@ SENSORS_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_APPTOKEN): cv.string, - vol.Optional(CONF_UTC_OFFSET): cv.string, + vol.Optional(CONF_UTC_OFFSET, default=DEFAULT_UTC_OFFSET): cv.string, vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA] }) diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index d857ce57fce..d4dea54514a 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -7,10 +7,10 @@ https://www.hydroquebec.com/portail/en/group/clientele/portrait-de-consommation For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.hydroquebec/ """ +import asyncio import logging from datetime import timedelta -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyhydroquebec==1.3.1'] +REQUIREMENTS = ['pyhydroquebec==2.0.2'] _LOGGER = logging.getLogger(__name__) @@ -93,7 +93,8 @@ DAILY_MAP = (('yesterday_total_consumption', 'consoTotalQuot'), ('yesterday_higher_price_consumption', 'consoHautQuot')) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HydroQuebec sensor.""" # Create a data fetcher to support all of the configured sensors. Then make # the first call to init the data. @@ -102,13 +103,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) contract = config.get(CONF_CONTRACT) - try: - hydroquebec_data = HydroquebecData(username, password, contract) - _LOGGER.info("Contract list: %s", - ", ".join(hydroquebec_data.get_contract_list())) - except requests.exceptions.HTTPError as error: - _LOGGER.error("Failt login: %s", error) - return False + hydroquebec_data = HydroquebecData(username, password, contract) + contracts = yield from hydroquebec_data.get_contract_list() + _LOGGER.info("Contract list: %s", + ", ".join(contracts)) name = config.get(CONF_NAME) @@ -116,7 +114,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in config[CONF_MONITORED_VARIABLES]: sensors.append(HydroQuebecSensor(hydroquebec_data, variable, name)) - add_devices(sensors) + async_add_devices(sensors, True) class HydroQuebecSensor(Entity): @@ -152,10 +150,11 @@ class HydroQuebecSensor(Entity): """Icon to use in the frontend, if any.""" return self._icon - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data from Hydroquebec and update the state.""" - self.hydroquebec_data.update() - if self.type in self.hydroquebec_data.data: + yield from self.hydroquebec_data.async_update() + if self.hydroquebec_data.data.get(self.type) is not None: self._state = round(self.hydroquebec_data.data[self.type], 2) @@ -170,23 +169,24 @@ class HydroquebecData(object): self._contract = contract self.data = {} + @asyncio.coroutine def get_contract_list(self): """Return the contract list.""" # Fetch data - self._fetch_data() + yield from self._fetch_data() return self.client.get_contracts() + @asyncio.coroutine + @Throttle(MIN_TIME_BETWEEN_UPDATES) def _fetch_data(self): """Fetch latest data from HydroQuebec.""" - from pyhydroquebec.client import PyHydroQuebecError try: - self.client.fetch_data() - except PyHydroQuebecError as exp: + yield from self.client.fetch_data() + except BaseException as exp: _LOGGER.error("Error on receive last Hydroquebec data: %s", exp) - return - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + @asyncio.coroutine + def async_update(self): """Return the latest collected data from HydroQuebec.""" - self._fetch_data() + yield from self._fetch_data() self.data = self.client.get_data(self._contract)[self._contract] diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index f64fa6191e2..e961c63a1b5 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -282,6 +282,9 @@ class ISYSensorDevice(isy.ISYDevice): @property def state(self) -> str: """Get the state of the ISY994 sensor device.""" + if self.is_unknown(): + return None + if len(self._node.uom) == 1: if self._node.uom[0] in UOM_TO_STATES: states = UOM_TO_STATES.get(self._node.uom[0]) diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index e317e89030f..8c5fcc15ec2 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -5,85 +5,94 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.luftdaten/ """ import asyncio -import json -import logging from datetime import timedelta +import logging -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS, - TEMP_CELSIUS) -from homeassistant.helpers.entity import Entity + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS) +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['luftdaten==0.1.1'] _LOGGER = logging.getLogger(__name__) +ATTR_SENSOR_ID = 'sensor_id' + +CONF_ATTRIBUTION = "Data provided by luftdaten.info" + + VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' SENSOR_TEMPERATURE = 'temperature' SENSOR_HUMIDITY = 'humidity' SENSOR_PM10 = 'P1' SENSOR_PM2_5 = 'P2' +SENSOR_PRESSURE = 'pressure' SENSOR_TYPES = { SENSOR_TEMPERATURE: ['Temperature', TEMP_CELSIUS], SENSOR_HUMIDITY: ['Humidity', '%'], + SENSOR_PRESSURE: ['Pressure', 'Pa'], SENSOR_PM10: ['PM10', VOLUME_MICROGRAMS_PER_CUBIC_METER], SENSOR_PM2_5: ['PM2.5', VOLUME_MICROGRAMS_PER_CUBIC_METER] } -DEFAULT_NAME = 'Luftdaten Sensor' -DEFAULT_RESOURCE = 'https://api.luftdaten.info/v1/sensor/' -DEFAULT_VERIFY_SSL = True +DEFAULT_NAME = 'Luftdaten' CONF_SENSORID = 'sensorid' -SCAN_INTERVAL = timedelta(minutes=3) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENSORID): cv.positive_int, vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean }) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Luftdaten sensor.""" + from luftdaten import Luftdaten + name = config.get(CONF_NAME) - sensorid = config.get(CONF_SENSORID) - verify_ssl = config.get(CONF_VERIFY_SSL) + sensor_id = config.get(CONF_SENSORID) - resource = '{}{}/'.format(config.get(CONF_RESOURCE), sensorid) + session = async_get_clientsession(hass) + luftdaten = LuftdatenData(Luftdaten(sensor_id, hass.loop, session)) - rest_client = LuftdatenData(resource, verify_ssl) - rest_client.update() + yield from luftdaten.async_update() - if rest_client.data is None: - _LOGGER.error("Unable to fetch Luftdaten data") - return False + if luftdaten.data is None: + _LOGGER.error("Sensor is not available: %s", sensor_id) + return devices = [] for variable in config[CONF_MONITORED_CONDITIONS]: - devices.append(LuftdatenSensor(rest_client, name, variable)) + if luftdaten.data.values[variable] is None: + _LOGGER.warning("It might be that sensor %s is not providing " + "measurements for %s", sensor_id, variable) + devices.append(LuftdatenSensor(luftdaten, name, variable, sensor_id)) - async_add_devices(devices, True) + async_add_devices(devices) class LuftdatenSensor(Entity): - """Implementation of a LuftdatenSensor sensor.""" + """Implementation of a Luftdaten sensor.""" - def __init__(self, rest_client, name, sensor_type): - """Initialize the LuftdatenSensor sensor.""" - self.rest_client = rest_client + def __init__(self, luftdaten, name, sensor_type, sensor_id): + """Initialize the Luftdaten sensor.""" + self.luftdaten = luftdaten self._name = name self._state = None + self._sensor_id = sensor_id self.sensor_type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -95,48 +104,50 @@ class LuftdatenSensor(Entity): @property def state(self): """Return the state of the device.""" - return self._state + return self.luftdaten.data.values[self.sensor_type] @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - def update(self): - """Get the latest data from REST API and update the state.""" - self.rest_client.update() - value = self.rest_client.data + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.luftdaten.data.meta is None: + return - if value is None: - self._state = None - else: - parsed_json = json.loads(value) + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_SENSOR_ID: self._sensor_id, + 'lat': self.luftdaten.data.meta['latitude'], + 'long': self.luftdaten.data.meta['longitude'], + } + return attr - log_entries_count = len(parsed_json) - 1 - latest_log_entry = parsed_json[log_entries_count] - sensordata_values = latest_log_entry['sensordatavalues'] - for sensordata_value in sensordata_values: - if sensordata_value['value_type'] == self.sensor_type: - self._state = sensordata_value['value'] + @asyncio.coroutine + def async_update(self): + """Get the latest data from luftdaten.info and update the state.""" + try: + yield from self.luftdaten.async_update() + except TypeError: + pass class LuftdatenData(object): """Class for handling the data retrieval.""" - def __init__(self, resource, verify_ssl): + def __init__(self, data): """Initialize the data object.""" - self._request = requests.Request('GET', resource).prepare() - self._verify_ssl = verify_ssl - self.data = None + self.data = data + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + @asyncio.coroutine + def async_update(self): + """Get the latest data from luftdaten.info.""" + from luftdaten.exceptions import LuftdatenError - def update(self): - """Get the latest data from Luftdaten service.""" try: - with requests.Session() as sess: - response = sess.send( - self._request, timeout=10, verify=self._verify_ssl) - - self.data = response.text - except requests.exceptions.RequestException: - _LOGGER.error("Error fetching data: %s", self._request) - self.data = None + yield from self.data.async_get_data() + except LuftdatenError: + _LOGGER.error("Unable to retrieve data from luftdaten.info") diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 063c4e8068e..77d77949ebd 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -12,15 +12,15 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC) + CONF_FORCE_UPDATE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC +) -REQUIREMENTS = ['miflora==0.1.16'] +REQUIREMENTS = ['miflora==0.2.0'] _LOGGER = logging.getLogger(__name__) CONF_ADAPTER = 'adapter' CONF_CACHE = 'cache_value' -CONF_FORCE_UPDATE = 'force_update' CONF_MEDIAN = 'median' CONF_RETRIES = 'retries' CONF_TIMEOUT = 'timeout' @@ -60,11 +60,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MiFlora sensor.""" from miflora import miflora_poller + from miflora.backends.gatttool import GatttoolBackend cache = config.get(CONF_CACHE) poller = miflora_poller.MiFloraPoller( config.get(CONF_MAC), cache_timeout=cache, - adapter=config.get(CONF_ADAPTER)) + adapter=config.get(CONF_ADAPTER), backend=GatttoolBackend) force_update = config.get(CONF_FORCE_UPDATE) median = config.get(CONF_MEDIAN) poller.ble_timeout = config.get(CONF_TIMEOUT) diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 70b1294c13f..bf7de94b5d7 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) + CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, + CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.entity import Entity import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv @@ -22,7 +23,6 @@ from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -CONF_FORCE_UPDATE = 'force_update' CONF_EXPIRE_AFTER = 'expire_after' DEFAULT_NAME = 'MQTT Sensor' diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 85b388a1919..71b72b0a671 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -45,7 +45,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) tools = octoprint_api.get_tools() - _LOGGER.error(str(tools)) if "Temperatures" in monitored_conditions: if not tools: diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index 0a75d0395ec..a40aeee55e5 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -10,7 +10,8 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_TOKEN) + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_TOKEN, + CONF_SSL) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -24,6 +25,7 @@ CONF_SERVER = 'server' DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Plex' DEFAULT_PORT = 32400 +DEFAULT_SSL = False MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) @@ -35,6 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SERVER): cv.string, vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, }) @@ -48,11 +51,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): plex_host = config.get(CONF_HOST) plex_port = config.get(CONF_PORT) plex_token = config.get(CONF_TOKEN) - plex_url = 'http://{}:{}'.format(plex_host, plex_port) - add_devices([PlexSensor( - name, plex_url, plex_user, plex_password, plex_server, - plex_token)], True) + plex_url = '{}://{}:{}'.format('https' if config.get(CONF_SSL) else 'http', + plex_host, plex_port) + + import plexapi.exceptions + + try: + add_devices([PlexSensor( + name, plex_url, plex_user, plex_password, plex_server, + plex_token)], True) + except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized, + plexapi.exceptions.NotFound) as error: + _LOGGER.error(error) + return class PlexSensor(Entity): diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 86362e8f2d9..19f5a1c271e 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -13,10 +13,11 @@ from requests.auth import HTTPBasicAuth, HTTPDigestAuth from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, - CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VERIFY_SSL, CONF_USERNAME, - CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, CONF_HEADERS) + CONF_AUTHENTICATION, CONF_FORCE_UPDATE, CONF_HEADERS, CONF_NAME, + CONF_METHOD, CONF_PASSWORD, CONF_PAYLOAD, CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -25,6 +26,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Sensor' DEFAULT_VERIFY_SSL = True +DEFAULT_FORCE_UPDATE = False CONF_JSON_ATTRS = 'json_attributes' METHODS = ['POST', 'GET'] @@ -43,6 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, }) @@ -59,6 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): unit = config.get(CONF_UNIT_OF_MEASUREMENT) value_template = config.get(CONF_VALUE_TEMPLATE) json_attrs = config.get(CONF_JSON_ATTRS) + force_update = config.get(CONF_FORCE_UPDATE) if value_template is not None: value_template.hass = hass @@ -74,14 +78,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rest.update() add_devices([RestSensor( - hass, rest, name, unit, value_template, json_attrs)], True) + hass, rest, name, unit, value_template, json_attrs, force_update + )], True) class RestSensor(Entity): """Implementation of a REST sensor.""" - def __init__(self, hass, rest, name, - unit_of_measurement, value_template, json_attrs): + def __init__(self, hass, rest, name, unit_of_measurement, + value_template, json_attrs, force_update): """Initialize the REST sensor.""" self._hass = hass self.rest = rest @@ -91,6 +96,7 @@ class RestSensor(Entity): self._value_template = value_template self._json_attrs = json_attrs self._attributes = None + self._force_update = force_update @property def name(self): @@ -112,6 +118,11 @@ class RestSensor(Entity): """Return the state of the device.""" return self._state + @property + def force_update(self): + """Force update.""" + return self._force_update + def update(self): """Get the latest data from REST API and update the state.""" self.rest.update() diff --git a/homeassistant/components/sensor/ripple.py b/homeassistant/components/sensor/ripple.py index 6f92a1a3390..d516706fdc0 100644 --- a/homeassistant/components/sensor/ripple.py +++ b/homeassistant/components/sensor/ripple.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-ripple-api==0.0.2'] +REQUIREMENTS = ['python-ripple-api==0.0.3'] CONF_ADDRESS = 'address' CONF_ATTRIBUTION = "Data provided by ripple.com" @@ -71,4 +71,6 @@ class RippleSensor(Entity): def update(self): """Get the latest state of the sensor.""" from pyripple import get_balance - self._state = get_balance(self.address) + balance = get_balance(self.address) + if balance is not None: + self._state = balance diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index 3d86d940f4d..720158e1029 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.5'] +REQUIREMENTS = ['shodan==1.7.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index a6932e2aebb..19281d36d88 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -175,15 +175,20 @@ class StatisticsSensor(Entity): self._purge_old() if not self.is_binary: - try: + try: # require only one data point self.mean = round(statistics.mean(self.states), 2) self.median = round(statistics.median(self.states), 2) + except statistics.StatisticsError as err: + _LOGGER.error(err) + self.mean = self.median = STATE_UNKNOWN + + try: # require at least two data points self.stdev = round(statistics.stdev(self.states), 2) self.variance = round(statistics.variance(self.states), 2) except statistics.StatisticsError as err: _LOGGER.error(err) - self.mean = self.median = STATE_UNKNOWN self.stdev = self.variance = STATE_UNKNOWN + if self.states: self.total = round(sum(self.states), 2) self.min = min(self.states) diff --git a/homeassistant/components/sensor/steam_online.py b/homeassistant/components/sensor/steam_online.py index 8645d4ee7c6..88cb786e66d 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -21,12 +21,13 @@ CONF_ACCOUNTS = 'accounts' ICON = 'mdi:steam' -STATE_ONLINE = 'Online' -STATE_BUSY = 'Busy' -STATE_AWAY = 'Away' -STATE_SNOOZE = 'Snooze' -STATE_TRADE = 'Trade' -STATE_PLAY = 'Play' +STATE_OFFLINE = 'offline' +STATE_ONLINE = 'online' +STATE_BUSY = 'busy' +STATE_AWAY = 'away' +STATE_SNOOZE = 'snooze' +STATE_LOOKING_TO_TRADE = 'looking_to_trade' +STATE_LOOKING_TO_PLAY = 'looking_to_play' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -40,17 +41,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Steam platform.""" import steam as steamod steamod.api.key.set(config.get(CONF_API_KEY)) + # Initialize steammods app list before creating sensors + # to benefit from internal caching of the list. + steam_app_list = steamod.apps.app_list() add_devices( [SteamSensor(account, - steamod) for account in config.get(CONF_ACCOUNTS)], True) + steamod, + steam_app_list) + for account in config.get(CONF_ACCOUNTS)], True) class SteamSensor(Entity): """A class for the Steam account.""" - def __init__(self, account, steamod): + def __init__(self, account, steamod, steam_app_list): """Initialize the sensor.""" self._steamod = steamod + self._steam_app_list = steam_app_list self._account = account self._profile = None self._game = self._state = self._name = self._avatar = None @@ -75,28 +82,39 @@ class SteamSensor(Entity): """Update device state.""" try: self._profile = self._steamod.user.profile(self._account) - if self._profile.current_game[2] is None: - self._game = 'None' - else: - self._game = self._profile.current_game[2] + self._game = self._get_current_game() self._state = { 1: STATE_ONLINE, 2: STATE_BUSY, 3: STATE_AWAY, 4: STATE_SNOOZE, - 5: STATE_TRADE, - 6: STATE_PLAY, - }.get(self._profile.status, 'Offline') + 5: STATE_LOOKING_TO_TRADE, + 6: STATE_LOOKING_TO_PLAY, + }.get(self._profile.status, STATE_OFFLINE) self._name = self._profile.persona self._avatar = self._profile.avatar_medium except self._steamod.api.HTTPTimeoutError as error: _LOGGER.warning(error) self._game = self._state = self._name = self._avatar = None + def _get_current_game(self): + game_id = self._profile.current_game[0] + game_extra_info = self._profile.current_game[2] + + if game_extra_info: + return game_extra_info + + if game_id and game_id in self._steam_app_list: + # The app list always returns a tuple + # with the game id and the game name + return self._steam_app_list[game_id][1] + + return None + @property def device_state_attributes(self): """Return the state attributes.""" - return {'game': self._game} + return {'game': self._game} if self._game else None @property def entity_picture(self): diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 324d3029c99..8e6f7b404fd 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.4.1'] +REQUIREMENTS = ['psutil==5.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index f901bd27dca..c81c208e33e 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -25,8 +25,8 @@ SCAN_INTERVAL = timedelta(seconds=5) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera controller devices.""" add_devices( - VeraSensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['sensor']) + VeraSensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['sensor']) class VeraSensor(VeraDevice, Entity): diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index 622261941d6..32b228ca1f9 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -6,8 +6,10 @@ https://home-assistant.io/components/sensor.volvooncall/ """ import logging +from math import floor -from homeassistant.components.volvooncall import VolvoEntity, RESOURCES +from homeassistant.components.volvooncall import ( + VolvoEntity, RESOURCES, CONF_SCANDINAVIAN_MILES) _LOGGER = logging.getLogger(__name__) @@ -26,14 +28,37 @@ class VolvoSensor(VolvoEntity): def state(self): """Return the state of the sensor.""" val = getattr(self.vehicle, self._attribute) + + if val is None: + return val + if self._attribute == 'odometer': - return round(val / 1000) # km - return val + val /= 1000 # m -> km + + if 'mil' in self.unit_of_measurement: + val /= 10 # km -> mil + + if self._attribute == 'average_fuel_consumption': + val /= 10 # L/1000km -> L/100km + if 'mil' in self.unit_of_measurement: + return round(val, 2) + else: + return round(val, 1) + elif self._attribute == 'distance_to_empty': + return int(floor(val)) + else: + return int(round(val)) @property def unit_of_measurement(self): """Return the unit of measurement.""" - return RESOURCES[self._attribute][3] + unit = RESOURCES[self._attribute][3] + if self._state.config[CONF_SCANDINAVIAN_MILES] and 'km' in unit: + if self._attribute == 'average_fuel_consumption': + return 'L/mil' + else: + return unit.replace('km', 'mil') + return unit @property def icon(self): diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index c532c0dfd20..90a1bbbc613 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -32,55 +32,6 @@ foursquare: description: Vertical accuracy of the user's location, in meters. example: 1 -homematic: - virtualkey: - description: Press a virtual key from CCU/Homegear or simulate keypress. - fields: - address: - description: Address of homematic device or BidCoS-RF for virtual remote. - example: BidCoS-RF - channel: - description: Channel for calling a keypress. - example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT. - example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value. - example: Hosts name from config - set_var_value: - description: Set the name of a node. - fields: - entity_id: - description: Name(s) of homematic central to set value. - example: 'homematic.ccu2' - name: - description: Name of the variable to set. - example: 'testvariable' - value: - description: New value - example: 1 - set_dev_value: - description: Set a device property on RPC XML interface. - fields: - address: - description: Address of homematic device or BidCoS-RF for virtual remote - example: BidCoS-RF - channel: - description: Channel for calling a keypress - example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT - example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value - example: Hosts name from config - value: - description: New value - example: 1 - reconnect: - description: Reconnect to all Homematic Hubs. - microsoft_face: create_group: description: Create a new person group. diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 1f64f78e9c8..a302f25bd00 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -15,7 +15,7 @@ DEPENDENCIES = ['mqtt'] CONF_INTENTS = 'intents' CONF_ACTION = 'action' -INTENT_TOPIC = 'hermes/nlu/intentParsed' +INTENT_TOPIC = 'hermes/intent/#' _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,8 @@ INTENT_SCHEMA = vol.Schema({ vol.Required('slotName'): str, vol.Required('value'): { vol.Required('kind'): str, - vol.Required('value'): cv.match_all + vol.Optional('value'): cv.match_all, + vol.Optional('rawValue'): cv.match_all } }] }, extra=vol.ALLOW_EXTRA) @@ -59,8 +60,12 @@ def async_setup(hass, config): return intent_type = request['intent']['intentName'].split('__')[-1] - slots = {slot['slotName']: {'value': slot['value']['value']} - for slot in request.get('slots', [])} + slots = {} + for slot in request.get('slots', []): + if 'value' in slot['value']: + slots[slot['slotName']] = {'value': slot['value']['value']} + else: + slots[slot['slotName']] = {'value': slot['rawValue']} try: yield from intent.async_handle( diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index b930bedc2c7..0f1ec62eaee 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -69,7 +69,10 @@ class ISYSwitchDevice(isy.ISYDevice, SwitchDevice): @property def state(self) -> str: """Get the state of the ISY994 device.""" - return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) def turn_off(self, **kwargs) -> None: """Send the turn on command to the ISY994 switch.""" diff --git a/homeassistant/components/switch/mochad.py b/homeassistant/components/switch/mochad.py index a67b27a6a91..da8f96dc1f0 100644 --- a/homeassistant/components/switch/mochad.py +++ b/homeassistant/components/switch/mochad.py @@ -60,18 +60,21 @@ class MochadSwitch(SwitchDevice): def turn_on(self, **kwargs): """Turn the switch on.""" self._state = True - self.device.send_cmd('on') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('on') + self._controller.read_data() def turn_off(self, **kwargs): """Turn the switch off.""" self._state = False - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() def _get_device_status(self): """Get the status of the switch from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index c731b336dfb..211ff54d5a4 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -141,10 +141,17 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): self._verify_register = ( verify_register if verify_register else self._register) self._register_type = register_type - self._state_on = ( - state_on if state_on else self._command_on) - self._state_off = ( - state_off if state_off else self._command_off) + + if state_on is not None: + self._state_on = state_on + else: + self._state_on = self._command_on + + if state_off is not None: + self._state_off = state_off + else: + self._state_off = self._command_off + self._is_on = None def turn_on(self, **kwargs): diff --git a/homeassistant/components/switch/vera.py b/homeassistant/components/switch/vera.py index 1e92612b9a9..d7c284e4ccf 100644 --- a/homeassistant/components/switch/vera.py +++ b/homeassistant/components/switch/vera.py @@ -19,8 +19,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera switches.""" add_devices( - VeraSwitch(device, VERA_CONTROLLER) for - device in VERA_DEVICES['switch']) + VeraSwitch(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['switch']) class VeraSwitch(VeraDevice, SwitchDevice): diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 534c4ac0a32..49a400f4a23 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.2'] +REQUIREMENTS = ['python-miio==0.3.3'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index bcef0d3fb85..0eef2c4ece1 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.3'] +REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.4'] _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,7 @@ def _discover(hass, config, component_name, found_tellcore_devices): def setup(hass, config): """Set up the Tellstick component.""" - from tellcore.constants import TELLSTICK_DIM + from tellcore.constants import (TELLSTICK_DIM, TELLSTICK_UP) from tellcore.telldus import AsyncioCallbackDispatcher from tellcore.telldus import TelldusCore from tellcorenet import TellCoreClient @@ -102,16 +102,22 @@ def setup(hass, config): hass.data[DATA_TELLSTICK] = {device.id: device for device in tellcore_devices} - # Discover the switches - _discover(hass, config, 'switch', - [device.id for device in tellcore_devices - if not device.methods(TELLSTICK_DIM)]) - # Discover the lights _discover(hass, config, 'light', [device.id for device in tellcore_devices if device.methods(TELLSTICK_DIM)]) + # Discover the cover + _discover(hass, config, 'cover', + [device.id for device in tellcore_devices + if device.methods(TELLSTICK_UP)]) + + # Discover the switches + _discover(hass, config, 'switch', + [device.id for device in tellcore_devices + if (not device.methods(TELLSTICK_UP) and + not device.methods(TELLSTICK_DIM))]) + @callback def async_handle_callback(tellcore_id, tellcore_command, tellcore_data, cid): diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index a2265706d87..3fc000f8027 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.3.2'] +REQUIREMENTS = ['python-miio==0.3.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 7418ca812a1..b15c4ddabfd 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,13 +19,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.38'] +REQUIREMENTS = ['pyvera==0.2.39'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'vera' -VERA_CONTROLLER = None +VERA_CONTROLLER = 'vera_controller' CONF_CONTROLLER = 'vera_controller_url' @@ -34,7 +34,8 @@ VERA_ID_FORMAT = '{}_{}' ATTR_CURRENT_POWER_W = "current_power_w" ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" -VERA_DEVICES = defaultdict(list) +VERA_DEVICES = 'vera_devices' +VERA_SCENES = 'vera_scenes' VERA_ID_LIST_SCHEMA = vol.Schema([int]) @@ -47,20 +48,20 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) VERA_COMPONENTS = [ - 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'climate', 'cover' + 'binary_sensor', 'sensor', 'light', 'switch', + 'lock', 'climate', 'cover', 'scene' ] # pylint: disable=unused-argument, too-many-function-args def setup(hass, base_config): """Set up for Vera devices.""" - global VERA_CONTROLLER import pyvera as veraApi def stop_subscription(event): """Shutdown Vera subscriptions and subscription thread on exit.""" _LOGGER.info("Shutting down subscriptions") - VERA_CONTROLLER.stop() + hass.data[VERA_CONTROLLER].stop() config = base_config.get(DOMAIN) @@ -70,11 +71,14 @@ def setup(hass, base_config): exclude_ids = config.get(CONF_EXCLUDE) # Initialize the Vera controller. - VERA_CONTROLLER, _ = veraApi.init_controller(base_url) + controller, _ = veraApi.init_controller(base_url) + hass.data[VERA_CONTROLLER] = controller hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) try: - all_devices = VERA_CONTROLLER.get_devices() + all_devices = controller.get_devices() + + all_scenes = controller.get_scenes() except RequestException: # There was a network related error connecting to the Vera controller. _LOGGER.exception("Error communicating with Vera API") @@ -84,12 +88,19 @@ def setup(hass, base_config): devices = [device for device in all_devices if device.device_id not in exclude_ids] + vera_devices = defaultdict(list) for device in devices: device_type = map_vera_device(device, light_ids) if device_type is None: continue - VERA_DEVICES[device_type].append(device) + vera_devices[device_type].append(device) + hass.data[VERA_DEVICES] = vera_devices + + vera_scenes = [] + for scene in all_scenes: + vera_scenes.append(scene) + hass.data[VERA_SCENES] = vera_scenes for component in VERA_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, base_config) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 4cee6ea2139..dcd4ed518d0 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -26,11 +26,13 @@ REQUIREMENTS = ['volvooncall==0.4.0'] _LOGGER = logging.getLogger(__name__) -CONF_UPDATE_INTERVAL = 'update_interval' MIN_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) + +CONF_UPDATE_INTERVAL = 'update_interval' CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' +CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) @@ -41,6 +43,8 @@ RESOURCES = {'position': ('device_tracker',), 'fuel_amount': ('sensor', 'Fuel amount', 'mdi:gas-station', 'L'), 'fuel_amount_level': ( 'sensor', 'Fuel level', 'mdi:water-percent', '%'), + 'average_fuel_consumption': ( + 'sensor', 'Fuel consumption', 'mdi:gas-station', 'L/100 km'), 'distance_to_empty': ('sensor', 'Range', 'mdi:ruler', 'km'), 'washer_fluid_level': ('binary_sensor', 'Washer fluid'), 'brake_fluid': ('binary_sensor', 'Brake Fluid'), @@ -61,6 +65,7 @@ CONFIG_SCHEMA = vol.Schema({ cv.ensure_list, [vol.In(RESOURCES)]), vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, + vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -123,7 +128,8 @@ class VolvoData: """Initialize the component state.""" self.entities = {} self.vehicles = {} - self.names = config[DOMAIN].get(CONF_NAME) + self.config = config[DOMAIN] + self.names = self.config.get(CONF_NAME) def vehicle_name(self, vehicle): """Provide a friendly name for a vehicle.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index c4c96804fca..fee7572a2c2 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -110,6 +110,9 @@ sensor: tts: - platform: google +# Cloud +cloud: + group: !include groups.yaml automation: !include automations.yaml script: !include scripts.yaml diff --git a/homeassistant/const.py b/homeassistant/const.py index 85047f0482e..be085bd75f1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 60 +MINOR_VERSION = 61 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) @@ -75,6 +75,7 @@ CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' CONF_FOR = 'for' +CONF_FORCE_UPDATE = 'force_update' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 46eeef45f14..6a527021c77 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -149,7 +149,7 @@ def async_load_platform(hass, component, platform, discovered=None, Use `listen_platform` to register a callback for these events. Warning: Do not yield from this inside a setup method to avoid a dead lock. - Use `hass.loop.async_add_job(async_load_platform(..))` instead. + Use `hass.async_add_job(async_load_platform(..))` instead. This method is a coroutine. """ diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2e7acb212e2..3080160dfce 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,8 +5,8 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.5 -yarl==0.15.0 +aiohttp==2.3.6 +yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 646edcf1c35..cb3ebeb7ee6 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -299,7 +299,7 @@ class Throttle(object): return None # Check if method is never called or no_throttle is given - force = not throttle[1] or kwargs.pop('no_throttle', False) + force = kwargs.pop('no_throttle', False) or not throttle[1] try: if force or utcnow() - throttle[1] > self.min_time: diff --git a/requirements_all.txt b/requirements_all.txt index 1a00666d4ba..66f5abffd09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,8 +6,8 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.5 -yarl==0.15.0 +aiohttp==2.3.6 +yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 @@ -23,7 +23,7 @@ certifi>=2017.4.17 DoorBirdPy==0.1.0 # homeassistant.components.isy994 -PyISY==1.0.8 +PyISY==1.1.0 # homeassistant.components.notify.html5 PyJWT==1.5.3 @@ -44,7 +44,7 @@ PyXiaomiGateway==0.6.0 RtmAPI==0.7.0 # homeassistant.components.media_player.sonos -SoCo==0.12 +SoCo==0.13 # homeassistant.components.sensor.travisci TravisPy==0.3.5 @@ -72,7 +72,7 @@ aiohttp_cors==0.5.3 aioimaplib==0.7.13 # homeassistant.components.light.lifx -aiolifx==0.6.0 +aiolifx==0.6.1 # homeassistant.components.light.lifx aiolifx_effects==0.1.2 @@ -160,6 +160,9 @@ broadlink==0.5 # homeassistant.components.weather.buienradar buienradar==0.9 +# homeassistant.components.calendar.caldav +caldav==0.5.0 + # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 @@ -205,6 +208,9 @@ denonavr==0.5.5 # homeassistant.components.media_player.directv directpy==0.2 +# homeassistant.components.sensor.discogs +discogs_client==2.2.1 + # homeassistant.components.notify.discord discord.py==0.16.12 @@ -309,6 +315,9 @@ googlemaps==2.5.1 # homeassistant.components.sensor.gpsd gps3==0.33.3 +# homeassistant.components.light.greenwave +greenwavereality==0.2.9 + # homeassistant.components.media_player.gstreamer gstreamer-player==1.1.0 @@ -337,7 +346,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171206.0 +home-assistant-frontend==20171223.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a @@ -435,12 +444,15 @@ limitlessled==1.0.8 linode-api==4.1.4b2 # homeassistant.components.media_player.liveboxplaytv -liveboxplaytv==2.0.0 +liveboxplaytv==2.0.2 # homeassistant.components.lametric # homeassistant.components.notify.lametric lmnotify==0.0.4 +# homeassistant.components.sensor.luftdaten +luftdaten==0.1.1 + # homeassistant.components.sensor.lyft lyft_rides==0.2 @@ -458,7 +470,7 @@ messagebird==1.2.0 mficlient==0.3.0 # homeassistant.components.sensor.miflora -miflora==0.1.16 +miflora==0.2.0 # homeassistant.components.upnp miniupnpc==2.0.2 @@ -533,7 +545,7 @@ pdunehd==1.3 # homeassistant.components.media_player.pandora pexpect==4.0.1 -# homeassistant.components.light.hue +# homeassistant.components.hue phue==1.0 # homeassistant.components.rpi_pfio @@ -569,7 +581,7 @@ proliphix==0.4.1 prometheus_client==0.0.21 # homeassistant.components.sensor.systemmonitor -psutil==5.4.1 +psutil==5.4.2 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -584,6 +596,9 @@ pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm pwmled==1.2.1 +# homeassistant.components.canary +py-canary==0.2.3 + # homeassistant.components.sensor.cpuspeed py-cpuinfo==3.3.0 @@ -625,7 +640,7 @@ pyasn1-modules==0.1.5 pyasn1==0.3.7 # homeassistant.components.apple_tv -pyatv==0.3.8 +pyatv==0.3.9 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox @@ -686,13 +701,13 @@ pyharmony==1.0.18 pyhik==0.1.4 # homeassistant.components.hive -pyhiveapi==0.2.5 +pyhiveapi==0.2.10 # homeassistant.components.homematic -pyhomematic==0.1.35 +pyhomematic==0.1.36 # homeassistant.components.sensor.hydroquebec -pyhydroquebec==1.3.1 +pyhydroquebec==2.0.2 # homeassistant.components.alarm_control_panel.ialarm pyialarm==0.2 @@ -747,10 +762,10 @@ pymochad==0.1.1 pymodbus==1.3.1 # homeassistant.components.media_player.monoprice -pymonoprice==0.2 +pymonoprice==0.3 # homeassistant.components.media_player.yamaha_musiccast -pymusiccast==0.1.5 +pymusiccast==0.1.6 # homeassistant.components.cover.myq pymyq==0.0.8 @@ -810,6 +825,9 @@ pysma==0.1.3 # homeassistant.components.switch.snmp pysnmp==4.4.2 +# homeassistant.components.media_player.liveboxplaytv +pyteleloisirs==3.3 + # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner pythinkingcleaner==0.0.3 @@ -855,7 +873,7 @@ python-juicenet==0.0.5 # homeassistant.components.light.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.2 +python-miio==0.3.3 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 @@ -874,7 +892,7 @@ python-nmap==0.6.1 python-pushover==0.3 # homeassistant.components.sensor.ripple -python-ripple-api==0.0.2 +python-ripple-api==0.0.3 # homeassistant.components.media_player.roku python-roku==3.1.3 @@ -913,7 +931,7 @@ pythonegardia==1.0.22 pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile -pytile==1.0.0 +pytile==1.1.0 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 @@ -928,7 +946,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.38 +pyvera==0.2.39 # homeassistant.components.media_player.vizio pyvizio==0.0.2 @@ -1013,7 +1031,7 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.5 +shodan==1.7.7 # homeassistant.components.notify.simplepush simplepush==1.1.4 @@ -1072,7 +1090,7 @@ tank_utility==1.4.0 tapsaff==0.1.3 # homeassistant.components.tellstick -tellcore-net==0.3 +tellcore-net==0.4 # homeassistant.components.tellstick tellcore-py==1.1.2 @@ -1143,7 +1161,7 @@ wakeonlan==0.2.2 waqiasync==1.0.0 # homeassistant.components.cloud -warrant==0.5.0 +warrant==0.6.1 # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 @@ -1181,7 +1199,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.11.26 +youtube_dl==2017.12.10 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72325d6305b..ad9fae671cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -24,7 +24,7 @@ freezegun>=0.3.8 PyJWT==1.5.3 # homeassistant.components.media_player.sonos -SoCo==0.12 +SoCo==0.13 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.4 @@ -36,6 +36,9 @@ aiohttp_cors==0.5.3 # homeassistant.components.notify.apns apns2==0.3.0 +# homeassistant.components.calendar.caldav +caldav==0.5.0 + # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.1.1 @@ -74,7 +77,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171206.0 +home-assistant-frontend==20171223.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -115,12 +118,18 @@ pmsensor==0.4 # homeassistant.components.prometheus prometheus_client==0.0.21 +# homeassistant.components.canary +py-canary==0.2.3 + # homeassistant.components.zwave pydispatcher==2.0.5 # homeassistant.components.litejet pylitejet==0.1 +# homeassistant.components.media_player.monoprice +pymonoprice==0.3 + # homeassistant.components.alarm_control_panel.nx584 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 @@ -176,7 +185,7 @@ vultr==0.1.2 wakeonlan==0.2.2 # homeassistant.components.cloud -warrant==0.5.0 +warrant==0.6.1 # homeassistant.components.sensor.yahoo_finance yahoo-finance==1.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fbd60ffdadc..5f4d789fa77 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -38,6 +38,7 @@ TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', 'apns2', + 'caldav', 'coinmarketcap', 'defusedxml', 'dsmr_parser', @@ -61,9 +62,11 @@ TEST_REQUIREMENTS = ( 'pilight', 'pmsensor', 'prometheus_client', + 'py-canary', 'pydispatcher', 'PyJWT', 'pylitejet', + 'pymonoprice', 'pynx584', 'python-forecastio', 'pyunifi', diff --git a/setup.py b/setup.py index d79f11732ad..fe60a15e32e 100755 --- a/setup.py +++ b/setup.py @@ -53,8 +53,8 @@ REQUIRES = [ 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.3.5', # If updated, check if yarl also needs an update! - 'yarl==0.15.0', + 'aiohttp==2.3.6', # If updated, check if yarl also needs an update! + 'yarl==0.16.0', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', diff --git a/tests/components/binary_sensor/test_threshold.py b/tests/components/binary_sensor/test_threshold.py index d8c49de1cc0..38573b295d3 100644 --- a/tests/components/binary_sensor/test_threshold.py +++ b/tests/components/binary_sensor/test_threshold.py @@ -2,7 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS) from tests.common import get_test_home_assistant @@ -23,8 +24,7 @@ class TestThresholdSensor(unittest.TestCase): config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', - 'type': 'upper', + 'upper': '15', 'entity_id': 'sensor.test_monitored', } } @@ -37,12 +37,14 @@ class TestThresholdSensor(unittest.TestCase): state = self.hass.states.get('binary_sensor.threshold') - self.assertEqual('upper', state.attributes.get('type')) self.assertEqual('sensor.test_monitored', state.attributes.get('entity_id')) self.assertEqual(16, state.attributes.get('sensor_value')) - self.assertEqual(float(config['binary_sensor']['threshold']), - state.attributes.get('threshold')) + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('upper', state.attributes.get('type')) assert state.state == 'on' @@ -65,9 +67,7 @@ class TestThresholdSensor(unittest.TestCase): config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', - 'name': 'Test_threshold', - 'type': 'lower', + 'lower': '15', 'entity_id': 'sensor.test_monitored', } } @@ -77,8 +77,12 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 16) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) self.assertEqual('lower', state.attributes.get('type')) assert state.state == 'off' @@ -86,26 +90,17 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 14) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' - self.hass.states.set('sensor.test_monitored', 15) - self.hass.block_till_done() - - state = self.hass.states.get('binary_sensor.test_threshold') - - assert state.state == 'off' - def test_sensor_hysteresis(self): """Test if source is above threshold using hysteresis.""" config = { 'binary_sensor': { 'platform': 'threshold', - 'threshold': '15', + 'upper': '15', 'hysteresis': '2.5', - 'name': 'Test_threshold', - 'type': 'upper', 'entity_id': 'sensor.test_monitored', } } @@ -115,34 +110,226 @@ class TestThresholdSensor(unittest.TestCase): self.hass.states.set('sensor.test_monitored', 20) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(2.5, state.attributes.get('hysteresis')) + self.assertEqual('upper', state.attributes.get('type')) assert state.state == 'on' self.hass.states.set('sensor.test_monitored', 13) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' self.hass.states.set('sensor.test_monitored', 12) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'off' self.hass.states.set('sensor.test_monitored', 17) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'off' self.hass.states.set('sensor.test_monitored', 18) self.hass.block_till_done() - state = self.hass.states.get('binary_sensor.test_threshold') + state = self.hass.states.get('binary_sensor.threshold') assert state.state == 'on' + + def test_sensor_in_range_no_hysteresis(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 9) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 21) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + def test_sensor_in_range_with_hysteresis(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'hysteresis': '2', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(float(config['binary_sensor']['hysteresis']), + state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 8) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 7) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 12) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('below', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 13) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 22) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 23) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 18) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('above', state.attributes.get('position')) + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 17) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('in_range', state.attributes.get('position')) + assert state.state == 'on' + + def test_sensor_in_range_unknown_state(self): + """Test if source is within the range.""" + config = { + 'binary_sensor': { + 'platform': 'threshold', + 'lower': '10', + 'upper': '20', + 'entity_id': 'sensor.test_monitored', + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 16, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('sensor.test_monitored', + state.attributes.get('entity_id')) + self.assertEqual(16, state.attributes.get('sensor_value')) + self.assertEqual('in_range', state.attributes.get('position')) + self.assertEqual(float(config['binary_sensor']['lower']), + state.attributes.get('lower')) + self.assertEqual(float(config['binary_sensor']['upper']), + state.attributes.get('upper')) + self.assertEqual(0.0, state.attributes.get('hysteresis')) + self.assertEqual('range', state.attributes.get('type')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', STATE_UNKNOWN) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.threshold') + + self.assertEqual('unknown', state.attributes.get('position')) + assert state.state == 'off' diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py new file mode 100644 index 00000000000..7234d40c410 --- /dev/null +++ b/tests/components/calendar/test_caldav.py @@ -0,0 +1,305 @@ +"""The tests for the webdav calendar component.""" +# pylint: disable=protected-access +import datetime +import logging +import unittest +from unittest.mock import (patch, Mock, MagicMock) + +import homeassistant.components.calendar as calendar_base +import homeassistant.components.calendar.caldav as caldav +from caldav.objects import Event +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.util import dt +from tests.common import get_test_home_assistant + +TEST_PLATFORM = {calendar_base.DOMAIN: {CONF_PLATFORM: 'test'}} + +_LOGGER = logging.getLogger(__name__) + + +DEVICE_DATA = { + "name": "Private Calendar", + "device_id": "Private Calendar" +} + +EVENTS = [ + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:1 +DTSTAMP:20171125T000000Z +DTSTART:20171127T170000Z +DTEND:20171127T180000Z +SUMMARY:This is a normal event +LOCATION:Hamburg +DESCRIPTION:Surprisingly rainy +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Dynamics.//CalDAV Client//EN +BEGIN:VEVENT +UID:2 +DTSTAMP:20171125T000000Z +DTSTART:20171127T100000Z +DTEND:20171127T110000Z +SUMMARY:This is an offset event !!-02:00 +LOCATION:Hamburg +DESCRIPTION:Surprisingly shiny +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:3 +DTSTAMP:20171125T000000Z +DTSTART:20171127 +DTEND:20171128 +SUMMARY:This is an all day event +LOCATION:Hamburg +DESCRIPTION:What a beautiful day +END:VEVENT +END:VCALENDAR +""" +] + + +def _local_datetime(hours, minutes): + """Build a datetime object for testing in the correct timezone.""" + return dt.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) + + +def _mocked_dav_client(*args, **kwargs): + """Mock requests.get invocations.""" + calendars = [ + _mock_calendar("First"), + _mock_calendar("Second") + ] + principal = Mock() + principal.calendars = MagicMock(return_value=calendars) + + client = Mock() + client.principal = MagicMock(return_value=principal) + return client + + +def _mock_calendar(name): + events = [] + for idx, event in enumerate(EVENTS): + events.append(Event(None, "%d.ics" % idx, event, None, str(idx))) + + calendar = Mock() + calendar.date_search = MagicMock(return_value=events) + calendar.name = name + return calendar + + +class TestComponentsWebDavCalendar(unittest.TestCase): + """Test the WebDav calendar.""" + + hass = None # HomeAssistant + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.calendar = _mock_calendar("Private") + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component(self, req_mock): + """Test setup component with calendars.""" + def _add_device(devices): + assert len(devices) == 2 + assert devices[0].name == "First" + assert devices[0].dev_id == "First" + self.assertFalse(devices[0].data.include_all_day) + assert devices[1].name == "Second" + assert devices[1].dev_id == "Second" + self.assertFalse(devices[1].data.include_all_day) + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_no_calendar_matching(self, req_mock): + """Test setup component with wrong calendar.""" + def _add_device(devices): + assert not devices + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "calendars": ["none"], + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_a_calendar_match(self, req_mock): + """Test setup component with right calendar.""" + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "Second" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "calendars": ["Second"], + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_one_custom_calendar(self, req_mock): + """Test setup component with custom calendars.""" + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "HomeOffice" + assert devices[0].dev_id == "Second HomeOffice" + self.assertTrue(devices[0].data.include_all_day) + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "custom_calendars": [ + { + "name": "HomeOffice", + "calendar": "Second", + "filter": "HomeOffice" + }] + }, + _add_device) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_ongoing_event(self, mock_now): + """Test that the ongoing event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30)) + def test_ongoing_event_with_offset(self, mock_now): + """Test that the offset is taken into account.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.state, STATE_OFF) + self.assertEqual(cal.device_state_attributes, { + "message": "This is an offset event", + "all_day": False, + "offset_reached": True, + "start_time": "2017-11-27 10:00:00", + "end_time": "2017-11-27 11:00:00", + "location": "Hamburg", + "description": "Surprisingly shiny" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_matching_filter(self, mock_now): + """Test that the matching event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a normal event") + + self.assertEqual(cal.state, STATE_OFF) + self.assertFalse(cal.offset_reached()) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_matching_filter_real_regexp(self, mock_now): + """Test that the event matching the regexp is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "^This.*event") + + self.assertEqual(cal.state, STATE_OFF) + self.assertFalse(cal.offset_reached()) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(20, 00)) + def test_filter_matching_past_event(self, mock_now): + """Test that the matching past event is not returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a normal event") + + self.assertEqual(cal.data.event, None) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_no_result_with_filtering(self, mock_now): + """Test that nothing is returned since nothing matches.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a non-existing event") + + self.assertEqual(cal.data.event, None) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_all_day_event_returned(self, mock_now): + """Test that the event lasting the whole day is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + True) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is an all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day" + }) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index c05fdabf465..c5bb6f7fda7 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -132,15 +132,6 @@ def test_write_user_info(): } -@asyncio.coroutine -def test_subscription_not_expired_without_sub_in_claim(): - """Test that we do not enforce subscriptions yet.""" - cl = cloud.Cloud(None, cloud.MODE_DEV) - cl.id_token = jwt.encode({}, 'test') - - assert not cl.subscription_expired - - @asyncio.coroutine def test_subscription_expired(): """Test subscription being expired.""" diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index a6827d165cd..0159eec2eff 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -144,7 +144,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): update_mock.start() self.addCleanup(update_mock.stop) asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.get_result() + asuswrt.connection.run_command('ls') self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, @@ -170,7 +170,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): update_mock.start() self.addCleanup(update_mock.stop) asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.get_result() + asuswrt.connection.run_command('ls') self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, @@ -225,9 +225,9 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): update_mock.start() self.addCleanup(update_mock.stop) asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - asuswrt.connection.get_result() - self.assertEqual(telnet.read_until.call_count, 5) - self.assertEqual(telnet.write.call_count, 4) + asuswrt.connection.run_command('ls') + self.assertEqual(telnet.read_until.call_count, 4) + self.assertEqual(telnet.write.call_count, 3) self.assertEqual( telnet.read_until.call_args_list[0], mock.call(b'login: ') diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py new file mode 100644 index 00000000000..7955cecba04 --- /dev/null +++ b/tests/components/light/test_hue.py @@ -0,0 +1,541 @@ +"""Philips Hue lights platform tests.""" + +import logging +import unittest +import unittest.mock as mock +from unittest.mock import call, MagicMock, patch + +from homeassistant.components import hue +import homeassistant.components.light.hue as hue_light + +from tests.common import get_test_home_assistant, MockDependency + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup(unittest.TestCase): + """Test the Hue light platform.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + def setup_mocks_for_update_lights(self): + """Set up all mocks for update_lights tests.""" + self.mock_bridge = MagicMock() + self.mock_bridge.allow_hue_groups = False + self.mock_api = MagicMock() + self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() + self.mock_lights = [] + self.mock_groups = [] + self.mock_add_devices = MagicMock() + + def setup_mocks_for_process_lights(self): + """Set up all mocks for process_lights tests.""" + self.mock_bridge = self.create_mock_bridge('host') + self.mock_api = MagicMock() + self.mock_api.get.return_value = {} + self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() + + def setup_mocks_for_process_groups(self): + """Set up all mocks for process_groups tests.""" + self.mock_bridge = self.create_mock_bridge('host') + self.mock_bridge.get_group.return_value = { + 'name': 'Group 0', 'state': {'any_on': True}} + + self.mock_api = MagicMock() + self.mock_api.get.return_value = {} + self.mock_bridge.get_api.return_value = self.mock_api + + self.mock_bridge_type = MagicMock() + + def create_mock_bridge(self, host, allow_hue_groups=True): + """Return a mock HueBridge with reasonable defaults.""" + mock_bridge = MagicMock() + mock_bridge.host = host + mock_bridge.allow_hue_groups = allow_hue_groups + mock_bridge.lights = {} + mock_bridge.lightgroups = {} + return mock_bridge + + def create_mock_lights(self, lights): + """Return a dict suitable for mocking api.get('lights').""" + mock_bridge_lights = lights + + for light_id, info in mock_bridge_lights.items(): + if 'state' not in info: + info['state'] = {'on': False} + + return mock_bridge_lights + + def test_setup_platform_no_discovery_info(self): + """Test setup_platform without discovery info.""" + self.hass.data[hue.DOMAIN] = {} + mock_add_devices = MagicMock() + + hue_light.setup_platform(self.hass, {}, mock_add_devices) + + mock_add_devices.assert_not_called() + + def test_setup_platform_no_bridge_id(self): + """Test setup_platform without a bridge.""" + self.hass.data[hue.DOMAIN] = {} + mock_add_devices = MagicMock() + + hue_light.setup_platform(self.hass, {}, mock_add_devices, {}) + + mock_add_devices.assert_not_called() + + def test_setup_platform_one_bridge(self): + """Test setup_platform with one bridge.""" + mock_bridge = MagicMock() + self.hass.data[hue.DOMAIN] = {'10.0.0.1': mock_bridge} + mock_add_devices = MagicMock() + + with patch('homeassistant.components.light.hue.' + + 'unthrottled_update_lights') as mock_update_lights: + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '10.0.0.1'}) + mock_update_lights.assert_called_once_with( + self.hass, mock_bridge, mock_add_devices) + + def test_setup_platform_multiple_bridges(self): + """Test setup_platform wuth multiple bridges.""" + mock_bridge = MagicMock() + mock_bridge2 = MagicMock() + self.hass.data[hue.DOMAIN] = { + '10.0.0.1': mock_bridge, + '192.168.0.10': mock_bridge2, + } + mock_add_devices = MagicMock() + + with patch('homeassistant.components.light.hue.' + + 'unthrottled_update_lights') as mock_update_lights: + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '10.0.0.1'}) + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '192.168.0.10'}) + + mock_update_lights.assert_has_calls([ + call(self.hass, mock_bridge, mock_add_devices), + call(self.hass, mock_bridge2, mock_add_devices), + ]) + + @MockDependency('phue') + def test_update_lights_with_no_lights(self, mock_phue): + """Test the update_lights function when no lights are found.""" + self.setup_mocks_for_update_lights() + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=[]) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_not_called() + + @MockDependency('phue') + def test_update_lights_with_some_lights(self, mock_phue): + """Test the update_lights function with some lights.""" + self.setup_mocks_for_update_lights() + self.mock_lights = ['some', 'light'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + @MockDependency('phue') + def test_update_lights_no_groups(self, mock_phue): + """Test the update_lights function when no groups are found.""" + self.setup_mocks_for_update_lights() + self.mock_bridge.allow_hue_groups = True + self.mock_lights = ['some', 'light'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + @MockDependency('phue') + def test_update_lights_with_lights_and_groups(self, mock_phue): + """Test the update_lights function with both lights and groups.""" + self.setup_mocks_for_update_lights() + self.mock_bridge.allow_hue_groups = True + self.mock_lights = ['some', 'light'] + self.mock_groups = ['and', 'groups'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + @MockDependency('phue') + def test_update_lights_with_two_bridges(self, mock_phue): + """Test the update_lights function with two bridges.""" + self.setup_mocks_for_update_lights() + + mock_bridge_one = self.create_mock_bridge('one', False) + mock_bridge_one_lights = self.create_mock_lights( + {1: {'name': 'b1l1'}, 2: {'name': 'b1l2'}}) + + mock_bridge_two = self.create_mock_bridge('two', False) + mock_bridge_two_lights = self.create_mock_lights( + {1: {'name': 'b2l1'}, 3: {'name': 'b2l3'}}) + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.HueLight.' + 'schedule_update_ha_state'): + mock_api = MagicMock() + mock_api.get.return_value = mock_bridge_one_lights + with patch.object(mock_bridge_one, 'get_api', + return_value=mock_api): + hue_light.unthrottled_update_lights( + self.hass, mock_bridge_one, self.mock_add_devices) + + mock_api = MagicMock() + mock_api.get.return_value = mock_bridge_two_lights + with patch.object(mock_bridge_two, 'get_api', + return_value=mock_api): + hue_light.unthrottled_update_lights( + self.hass, mock_bridge_two, self.mock_add_devices) + + self.assertEquals(sorted(mock_bridge_one.lights.keys()), [1, 2]) + self.assertEquals(sorted(mock_bridge_two.lights.keys()), [1, 3]) + + self.assertEquals(len(self.mock_add_devices.mock_calls), 2) + + # first call + name, args, kwargs = self.mock_add_devices.mock_calls[0] + self.assertEquals(len(args), 1) + self.assertEquals(len(kwargs), 0) + + # one argument, a list of lights in bridge one; each of them is an + # object of type HueLight so we can't straight up compare them + lights = args[0] + self.assertEquals( + lights[0].unique_id, + '{}.b1l1.Light.1'.format(hue_light.HueLight)) + self.assertEquals( + lights[1].unique_id, + '{}.b1l2.Light.2'.format(hue_light.HueLight)) + + # second call works the same + name, args, kwargs = self.mock_add_devices.mock_calls[1] + self.assertEquals(len(args), 1) + self.assertEquals(len(kwargs), 0) + + lights = args[0] + self.assertEquals( + lights[0].unique_id, + '{}.b2l1.Light.1'.format(hue_light.HueLight)) + self.assertEquals( + lights[1].unique_id, + '{}.b2l3.Light.3'.format(hue_light.HueLight)) + + def test_process_lights_api_error(self): + """Test the process_lights function when the bridge errors out.""" + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = None + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals(self.mock_bridge.lights, {}) + + def test_process_lights_no_lights(self): + """Test the process_lights function when bridge returns no lights.""" + self.setup_mocks_for_process_lights() + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals(self.mock_bridge.lights, {}) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_lights_some_lights(self, mock_hue_light): + """Test the process_lights function with multiple groups.""" + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 2) + mock_hue_light.assert_has_calls([ + call( + 1, {'state': 'on'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + ]) + self.assertEquals(len(self.mock_bridge.lights), 2) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_lights_new_light(self, mock_hue_light): + """ + Test the process_lights function with new groups. + + Test what happens when we already have a light and a new one shows up. + """ + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + self.mock_bridge.lights = {1: MagicMock()} + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 1) + mock_hue_light.assert_has_calls([ + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + ]) + self.assertEquals(len(self.mock_bridge.lights), 2) + self.mock_bridge.lights[1]\ + .schedule_update_ha_state.assert_called_once_with() + + def test_process_groups_api_error(self): + """Test the process_groups function when the bridge errors out.""" + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = None + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals(self.mock_bridge.lightgroups, {}) + + def test_process_groups_no_state(self): + """Test the process_groups function when bridge returns no status.""" + self.setup_mocks_for_process_groups() + self.mock_bridge.get_group.return_value = {'name': 'Group 0'} + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals(self.mock_bridge.lightgroups, {}) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_groups_some_groups(self, mock_hue_light): + """Test the process_groups function with multiple groups.""" + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 2) + mock_hue_light.assert_has_calls([ + call( + 1, {'state': 'on'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + ]) + self.assertEquals(len(self.mock_bridge.lightgroups), 2) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_groups_new_group(self, mock_hue_light): + """ + Test the process_groups function with new groups. + + Test what happens when we already have a light and a new one shows up. + """ + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + self.mock_bridge.lightgroups = {1: MagicMock()} + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 1) + mock_hue_light.assert_has_calls([ + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + ]) + self.assertEquals(len(self.mock_bridge.lightgroups), 2) + self.mock_bridge.lightgroups[1]\ + .schedule_update_ha_state.assert_called_once_with() + + +class TestHueLight(unittest.TestCase): + """Test the HueLight class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + self.light_id = 42 + self.mock_info = MagicMock() + self.mock_bridge = MagicMock() + self.mock_update_lights = MagicMock() + self.mock_bridge_type = MagicMock() + self.mock_allow_unreachable = MagicMock() + self.mock_is_group = MagicMock() + self.mock_allow_in_emulated_hue = MagicMock() + self.mock_is_group = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + def buildLight( + self, light_id=None, info=None, update_lights=None, is_group=None): + """Helper to build a HueLight object with minimal fuss.""" + return hue_light.HueLight( + light_id if light_id is not None else self.light_id, + info if info is not None else self.mock_info, + self.mock_bridge, + (update_lights + if update_lights is not None + else self.mock_update_lights), + self.mock_bridge_type, + self.mock_allow_unreachable, self.mock_allow_in_emulated_hue, + is_group if is_group is not None else self.mock_is_group) + + def test_unique_id_for_light(self): + """Test the unique_id method with lights.""" + class_name = "" + + light = self.buildLight(info={'uniqueid': 'foobar'}) + self.assertEquals( + class_name+'.foobar', + light.unique_id) + + light = self.buildLight(info={}) + self.assertEquals( + class_name+'.Unnamed Device.Light.42', + light.unique_id) + + light = self.buildLight(info={'name': 'my-name'}) + self.assertEquals( + class_name+'.my-name.Light.42', + light.unique_id) + + light = self.buildLight(info={'type': 'my-type'}) + self.assertEquals( + class_name+'.Unnamed Device.my-type.42', + light.unique_id) + + light = self.buildLight(info={'name': 'a name', 'type': 'my-type'}) + self.assertEquals( + class_name+'.a name.my-type.42', + light.unique_id) + + def test_unique_id_for_group(self): + """Test the unique_id method with groups.""" + class_name = "" + + light = self.buildLight(info={'uniqueid': 'foobar'}, is_group=True) + self.assertEquals( + class_name+'.foobar', + light.unique_id) + + light = self.buildLight(info={}, is_group=True) + self.assertEquals( + class_name+'.Unnamed Device.Group.42', + light.unique_id) + + light = self.buildLight(info={'name': 'my-name'}, is_group=True) + self.assertEquals( + class_name+'.my-name.Group.42', + light.unique_id) + + light = self.buildLight(info={'type': 'my-type'}, is_group=True) + self.assertEquals( + class_name+'.Unnamed Device.my-type.42', + light.unique_id) + + light = self.buildLight( + info={'name': 'a name', 'type': 'my-type'}, + is_group=True) + self.assertEquals( + class_name+'.a name.my-type.42', + light.unique_id) diff --git a/tests/components/light/test_mochad.py b/tests/components/light/test_mochad.py index e69ebdb4aef..5c82ab06085 100644 --- a/tests/components/light/test_mochad.py +++ b/tests/components/light/test_mochad.py @@ -60,7 +60,8 @@ class TestMochadLight(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() controller_mock = mock.MagicMock() - dev_dict = {'address': 'a1', 'name': 'fake_light'} + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 32} self.light = mochad.MochadLight(self.hass, controller_mock, dev_dict) @@ -72,6 +73,39 @@ class TestMochadLight(unittest.TestCase): """Test the name.""" self.assertEqual('fake_light', self.light.name) + def test_turn_on_with_no_brightness(self): + """Test turn_on.""" + self.light.turn_on() + self.light.device.send_cmd.assert_called_once_with('on') + + def test_turn_on_with_brightness(self): + """Test turn_on.""" + self.light.turn_on(brightness=45) + self.light.device.send_cmd.assert_has_calls( + [mock.call('on'), mock.call('dim 25')]) + + def test_turn_off(self): + """Test turn_off.""" + self.light.turn_off() + self.light.device.send_cmd.assert_called_once_with('off') + + +class TestMochadLight256Levels(unittest.TestCase): + """Test for mochad light platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + controller_mock = mock.MagicMock() + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 256} + self.light = mochad.MochadLight(self.hass, controller_mock, + dev_dict) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + def test_turn_on_with_no_brightness(self): """Test turn_on.""" self.light.turn_on() @@ -86,3 +120,35 @@ class TestMochadLight(unittest.TestCase): """Test turn_off.""" self.light.turn_off() self.light.device.send_cmd.assert_called_once_with('off') + + +class TestMochadLight64Levels(unittest.TestCase): + """Test for mochad light platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + controller_mock = mock.MagicMock() + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 64} + self.light = mochad.MochadLight(self.hass, controller_mock, + dev_dict) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_turn_on_with_no_brightness(self): + """Test turn_on.""" + self.light.turn_on() + self.light.device.send_cmd.assert_called_once_with('xdim 63') + + def test_turn_on_with_brightness(self): + """Test turn_on.""" + self.light.turn_on(brightness=45) + self.light.device.send_cmd.assert_called_once_with('xdim 11') + + def test_turn_off(self): + """Test turn_off.""" + self.light.turn_off() + self.light.device.send_cmd.assert_called_once_with('off') diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py index 2bcd02e69aa..399cdc67ca6 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -1,27 +1,30 @@ """The tests for Monoprice Media player platform.""" import unittest +from unittest import mock import voluptuous as vol from collections import defaultdict - from homeassistant.components.media_player import ( - SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, + DOMAIN, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE) from homeassistant.const import STATE_ON, STATE_OFF +import tests.common from homeassistant.components.media_player.monoprice import ( - MonopriceZone, PLATFORM_SCHEMA) + DATA_MONOPRICE, PLATFORM_SCHEMA, SERVICE_SNAPSHOT, + SERVICE_RESTORE, setup_platform) -class MockState(object): - """Mock for zone state object.""" +class AttrDict(dict): + """Helper class for mocking attributes.""" - def __init__(self): - """Init zone state.""" - self.power = True - self.volume = 0 - self.mute = True - self.source = 1 + def __setattr__(self, name, value): + """Set attribute.""" + self[name] = value + + def __getattr__(self, item): + """Get attribute.""" + return self[item] class MockMonoprice(object): @@ -29,11 +32,16 @@ class MockMonoprice(object): def __init__(self): """Init mock object.""" - self.zones = defaultdict(lambda *a: MockState()) + self.zones = defaultdict(lambda: AttrDict(power=True, + volume=0, + mute=True, + source=1)) def zone_status(self, zone_id): """Get zone status.""" - return self.zones[zone_id] + status = self.zones[zone_id] + status.zone = zone_id + return AttrDict(status) def set_source(self, zone_id, source_idx): """Set source for zone.""" @@ -51,6 +59,10 @@ class MockMonoprice(object): """Set volume for zone.""" self.zones[zone_id].volume = volume + def restore_zone(self, zone): + """Restore zone status.""" + self.zones[zone.zone] = AttrDict(zone) + class TestMonopriceSchema(unittest.TestCase): """Test Monoprice schema.""" @@ -147,11 +159,144 @@ class TestMonopriceMediaPlayer(unittest.TestCase): def setUp(self): """Set up the test case.""" self.monoprice = MockMonoprice() + self.hass = tests.common.get_test_home_assistant() + self.hass.start() # Note, source dictionary is unsorted! - self.media_player = MonopriceZone(self.monoprice, {1: 'one', - 3: 'three', - 2: 'two'}, - 12, 'Zone name') + with mock.patch('pymonoprice.get_monoprice', + new=lambda *a: self.monoprice): + setup_platform(self.hass, { + 'platform': 'monoprice', + 'port': '/dev/ttyS0', + 'name': 'Name', + 'zones': {12: {'name': 'Zone name'}}, + 'sources': {1: {'name': 'one'}, + 3: {'name': 'three'}, + 2: {'name': 'two'}}, + }, lambda *args, **kwargs: None, {}) + self.hass.block_till_done() + self.media_player = self.hass.data[DATA_MONOPRICE][0] + self.media_player.hass = self.hass + self.media_player.entity_id = 'media_player.zone_1' + + def tearDown(self): + """Tear down the test case.""" + self.hass.stop() + + def test_setup_platform(self, *args): + """Test setting up platform.""" + # Two services must be registered + self.assertTrue(self.hass.services.has_service(DOMAIN, + SERVICE_RESTORE)) + self.assertTrue(self.hass.services.has_service(DOMAIN, + SERVICE_SNAPSHOT)) + self.assertEqual(len(self.hass.data[DATA_MONOPRICE]), 1) + self.assertEqual(self.hass.data[DATA_MONOPRICE][0].name, 'Zone name') + + def test_service_calls_with_entity_id(self): + """Test snapshot save/restore service calls.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + # Saving default values + self.hass.services.call(DOMAIN, SERVICE_SNAPSHOT, + {'entity_id': 'media_player.zone_1'}, + blocking=True) + # self.hass.block_till_done() + + # Changing media player to new state + self.media_player.set_volume_level(1) + self.media_player.select_source('two') + self.media_player.mute_volume(False) + self.media_player.turn_off() + + # Checking that values were indeed changed + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_OFF, self.media_player.state) + self.assertEqual(1.0, self.media_player.volume_level, 0.0001) + self.assertFalse(self.media_player.is_volume_muted) + self.assertEqual('two', self.media_player.source) + + # Restoring wrong media player to its previous state + # Nothing should be done + self.hass.services.call(DOMAIN, SERVICE_RESTORE, + {'entity_id': 'not_existing'}, + blocking=True) + # self.hass.block_till_done() + + # Checking that values were not (!) restored + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_OFF, self.media_player.state) + self.assertEqual(1.0, self.media_player.volume_level, 0.0001) + self.assertFalse(self.media_player.is_volume_muted) + self.assertEqual('two', self.media_player.source) + + # Restoring media player to its previous state + self.hass.services.call(DOMAIN, SERVICE_RESTORE, + {'entity_id': 'media_player.zone_1'}, + blocking=True) + self.hass.block_till_done() + + # Checking that values were restored + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + def test_service_calls_without_entity_id(self): + """Test snapshot save/restore service calls.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + # Restoring media player + # since there is no snapshot, nothing should be done + self.hass.services.call(DOMAIN, SERVICE_RESTORE, blocking=True) + self.hass.block_till_done() + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + # Saving default values + self.hass.services.call(DOMAIN, SERVICE_SNAPSHOT, blocking=True) + self.hass.block_till_done() + + # Changing media player to new state + self.media_player.set_volume_level(1) + self.media_player.select_source('two') + self.media_player.mute_volume(False) + self.media_player.turn_off() + + # Checking that values were indeed changed + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_OFF, self.media_player.state) + self.assertEqual(1.0, self.media_player.volume_level, 0.0001) + self.assertFalse(self.media_player.is_volume_muted) + self.assertEqual('two', self.media_player.source) + + # Restoring media player to its previous state + self.hass.services.call(DOMAIN, SERVICE_RESTORE, blocking=True) + self.hass.block_till_done() + + # Checking that values were restored + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) def test_update(self): """Test updating values from monoprice.""" diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 8c62c6c84e9..815204e718a 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -281,6 +281,20 @@ class TestSonosMediaPlayer(unittest.TestCase): self.assertEqual(unjoinMock.call_count, 1) self.assertEqual(unjoinMock.call_args, mock.call()) + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_set_shuffle(self, shuffle_set_mock, *args): + """Ensuring soco methods called for sonos_snapshot service.""" + sonos.setup_platform(self.hass, {}, fake_add_device, { + 'host': '192.0.2.1' + }) + device = self.hass.data[sonos.DATA_SONOS][-1] + device.hass = self.hass + + device.set_shuffle(True) + self.assertEqual(shuffle_set_mock.call_count, 1) + self.assertEqual(device._player.play_mode, 'SHUFFLE') + @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch.object(SoCoMock, 'set_sleep_timer') @@ -375,3 +389,21 @@ class TestSonosMediaPlayer(unittest.TestCase): device.restore() self.assertEqual(restoreMock.call_count, 1) self.assertEqual(restoreMock.call_args, mock.call(False)) + + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_sonos_set_option(self, option_mock, *args): + """Ensuring soco methods called for sonos_set_option service.""" + sonos.setup_platform(self.hass, {}, fake_add_device, { + 'host': '192.0.2.1' + }) + device = self.hass.data[sonos.DATA_SONOS][-1] + device.hass = self.hass + + option_mock.return_value = True + device._snapshot_coordinator = mock.MagicMock() + device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') + + device.update_option(night_sound=True, speech_enhance=True) + + self.assertEqual(option_mock.call_count, 1) diff --git a/tests/components/media_player/test_webostv.py b/tests/components/media_player/test_webostv.py new file mode 100644 index 00000000000..8017ad6cd54 --- /dev/null +++ b/tests/components/media_player/test_webostv.py @@ -0,0 +1,60 @@ +"""The tests for the LG webOS media player platform.""" +import unittest +from unittest import mock + +from homeassistant.components.media_player import webostv + + +class FakeLgWebOSDevice(webostv.LgWebOSDevice): + """A fake device without the client setup required for the real one.""" + + def __init__(self, *args, **kwargs): + """Initialise parameters needed for tests with fake values.""" + self._source_list = {} + self._client = mock.MagicMock() + self._name = 'fake_device' + self._current_source = None + + +class TestLgWebOSDevice(unittest.TestCase): + """Test the LgWebOSDevice class.""" + + def setUp(self): + """Configure a fake device for each test.""" + self.device = FakeLgWebOSDevice() + + def test_select_source_with_empty_source_list(self): + """Ensure we don't call client methods when we don't have sources.""" + self.device.select_source('nonexistent') + assert 0 == self.device._client.launch_app.call_count + assert 0 == self.device._client.set_input.call_count + + def test_select_source_with_titled_entry(self): + """Test that a titled source is treated as an app.""" + self.device._source_list = { + 'existent': { + 'id': 'existent_id', + 'title': 'existent_title', + }, + } + + self.device.select_source('existent') + + assert 'existent_title' == self.device._current_source + assert [mock.call('existent_id')] == ( + self.device._client.launch_app.call_args_list) + + def test_select_source_with_labelled_entry(self): + """Test that a labelled source is treated as an input source.""" + self.device._source_list = { + 'existent': { + 'id': 'existent_id', + 'label': 'existent_label', + }, + } + + self.device.select_source('existent') + + assert 'existent_label' == self.device._current_source + assert [mock.call('existent_id')] == ( + self.device._client.set_input.call_args_list) diff --git a/tests/components/sensor/test_canary.py b/tests/components/sensor/test_canary.py new file mode 100644 index 00000000000..99df05f36a4 --- /dev/null +++ b/tests/components/sensor/test_canary.py @@ -0,0 +1,125 @@ +"""The tests for the Canary sensor platform.""" +import copy +import unittest +from unittest.mock import patch, Mock + +from canary.api import SensorType +from homeassistant.components import canary as base_canary +from homeassistant.components.canary import DATA_CANARY +from homeassistant.components.sensor import canary +from homeassistant.components.sensor.canary import CanarySensor +from tests.common import (get_test_home_assistant) +from tests.components.test_canary import mock_device, mock_reading, \ + mock_location + +VALID_CONFIG = { + "canary": { + "username": "foo@bar.org", + "password": "bar", + } +} + + +class TestCanarySensorSetup(unittest.TestCase): + """Test the Canary platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = copy.deepcopy(VALID_CONFIG) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.canary.CanaryData') + def test_setup_sensors(self, mock_canary): + """Test the sensor setup.""" + base_canary.setup(self.hass, self.config) + + online_device_at_home = mock_device(20, "Dining Room", True) + offline_device_at_home = mock_device(21, "Front Yard", False) + online_device_at_work = mock_device(22, "Office", True) + + self.hass.data[DATA_CANARY] = mock_canary() + self.hass.data[DATA_CANARY].locations = [ + mock_location("Home", True, devices=[online_device_at_home, + offline_device_at_home]), + mock_location("Work", True, devices=[online_device_at_work]), + ] + + canary.setup_platform(self.hass, self.config, self.add_devices, None) + + self.assertEqual(6, len(self.DEVICES)) + + def test_celsius_temperature_sensor(self): + """Test temperature sensor with celsius.""" + device = mock_device(10, "Family Room") + location = mock_location("Home", True) + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.TEMPERATURE, 21.1234)] + + sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device) + sensor.update() + + self.assertEqual("Home Family Room Temperature", sensor.name) + self.assertEqual("sensor_canary_10_temperature", sensor.unique_id) + self.assertEqual("°C", sensor.unit_of_measurement) + self.assertEqual(21.1, sensor.state) + + def test_fahrenheit_temperature_sensor(self): + """Test temperature sensor with fahrenheit.""" + device = mock_device(10, "Family Room") + location = mock_location("Home", False) + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.TEMPERATURE, 21.1567)] + + sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device) + sensor.update() + + self.assertEqual("Home Family Room Temperature", sensor.name) + self.assertEqual("°F", sensor.unit_of_measurement) + self.assertEqual(21.2, sensor.state) + + def test_humidity_sensor(self): + """Test humidity sensor.""" + device = mock_device(10, "Family Room") + location = mock_location("Home") + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.HUMIDITY, 50.4567)] + + sensor = CanarySensor(data, SensorType.HUMIDITY, location, device) + sensor.update() + + self.assertEqual("Home Family Room Humidity", sensor.name) + self.assertEqual("%", sensor.unit_of_measurement) + self.assertEqual(50.5, sensor.state) + + def test_air_quality_sensor(self): + """Test air quality sensor.""" + device = mock_device(10, "Family Room") + location = mock_location("Home") + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.AIR_QUALITY, 50.4567)] + + sensor = CanarySensor(data, SensorType.AIR_QUALITY, location, device) + sensor.update() + + self.assertEqual("Home Family Room Air Quality", sensor.name) + self.assertEqual("", sensor.unit_of_measurement) + self.assertEqual(50.5, sensor.state) diff --git a/tests/components/sensor/test_hydroquebec.py b/tests/components/sensor/test_hydroquebec.py new file mode 100644 index 00000000000..f2ca97313d3 --- /dev/null +++ b/tests/components/sensor/test_hydroquebec.py @@ -0,0 +1,89 @@ +"""The test for the hydroquebec sensor platform.""" +import asyncio +import logging +import sys +from unittest.mock import MagicMock + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.sensor import hydroquebec +from tests.common import assert_setup_component + + +CONTRACT = "123456789" + + +class HydroQuebecClientMock(): + """Fake Hydroquebec client.""" + + def __init__(self, username, password, contract=None): + """Fake Hydroquebec client init.""" + pass + + def get_data(self, contract): + """Return fake hydroquebec data.""" + return {CONTRACT: {"balance": 160.12}} + + def get_contracts(self): + """Return fake hydroquebec contracts.""" + return [CONTRACT] + + @asyncio.coroutine + def fetch_data(self): + """Return fake fetching data.""" + pass + + +class HydroQuebecClientMockError(HydroQuebecClientMock): + """Fake Hydroquebec client error.""" + + @asyncio.coroutine + def fetch_data(self): + """Return fake fetching data.""" + raise hydroquebec.PyHydroQuebecError("Fake Error") + + +class PyHydroQuebecErrorMock(BaseException): + """Fake PyHydroquebec Error.""" + + +@asyncio.coroutine +def test_hydroquebec_sensor(loop, hass): + """Test the Hydroquebec number sensor.""" + sys.modules['pyhydroquebec'] = MagicMock() + sys.modules['pyhydroquebec.client'] = MagicMock() + sys.modules['pyhydroquebec.client.PyHydroQuebecError'] = \ + PyHydroQuebecErrorMock + import pyhydroquebec.client + pyhydroquebec.HydroQuebecClient = HydroQuebecClientMock + pyhydroquebec.client.PyHydroQuebecError = PyHydroQuebecErrorMock + config = { + 'sensor': { + 'platform': 'hydroquebec', + 'name': 'hydro', + 'contract': CONTRACT, + 'username': 'myusername', + 'password': 'password', + 'monitored_variables': [ + 'balance', + ], + } + } + with assert_setup_component(1): + yield from async_setup_component(hass, 'sensor', config) + state = hass.states.get('sensor.hydro_balance') + assert state.state == "160.12" + assert state.attributes.get('unit_of_measurement') == "CAD" + + +@asyncio.coroutine +def test_error(hass, caplog): + """Test the Hydroquebec sensor errors.""" + caplog.set_level(logging.ERROR) + sys.modules['pyhydroquebec'] = MagicMock() + sys.modules['pyhydroquebec.client'] = MagicMock() + import pyhydroquebec.client + pyhydroquebec.HydroQuebecClient = HydroQuebecClientMockError + pyhydroquebec.client.PyHydroQuebecError = BaseException + hydro_data = hydroquebec.HydroquebecData('username', 'password') + yield from hydro_data._fetch_data() + assert "Error on receive last Hydroquebec data: " in caplog.text diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index 1bda8ab82f3..eddab8caf4d 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -132,10 +132,12 @@ class TestRestSensor(unittest.TestCase): self.unit_of_measurement = 'MB' self.value_template = template('{{ value_json.key }}') self.value_template.hass = self.hass + self.force_update = False - self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, - self.value_template, []) + self.sensor = rest.RestSensor( + self.hass, self.rest, self.name, self.unit_of_measurement, + self.value_template, [], self.force_update + ) def tearDown(self): """Stop everything that was started.""" @@ -154,6 +156,11 @@ class TestRestSensor(unittest.TestCase): self.assertEqual( self.unit_of_measurement, self.sensor.unit_of_measurement) + def test_force_update(self): + """Test the unit of measurement.""" + self.assertEqual( + self.force_update, self.sensor.force_update) + def test_state(self): """Test the initial state.""" self.sensor.update() @@ -182,7 +189,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( 'plain_state')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, []) + self.unit_of_measurement, None, [], + self.force_update) self.sensor.update() self.assertEqual('plain_state', self.sensor.state) self.assertTrue(self.sensor.available) @@ -193,7 +201,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( '{ "key": "some_json_value" }')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual('some_json_value', self.sensor.device_state_attributes['key']) @@ -205,7 +214,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( '["list", "of", "things"]')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual({}, self.sensor.device_state_attributes) self.assertTrue(mock_logger.warning.called) @@ -217,7 +227,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( 'This is text rather than JSON data.')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual({}, self.sensor.device_state_attributes) self.assertTrue(mock_logger.warning.called) @@ -230,12 +241,14 @@ class TestRestSensor(unittest.TestCase): '{ "key": "json_state_updated_value" }')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, self.unit_of_measurement, - self.value_template, ['key']) + self.value_template, ['key'], + self.force_update) self.sensor.update() self.assertEqual('json_state_updated_value', self.sensor.state) self.assertEqual('json_state_updated_value', - self.sensor.device_state_attributes['key']) + self.sensor.device_state_attributes['key'], + self.force_update) class TestRestData(unittest.TestCase): diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index bfb8fb61f9b..48ebf720633 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -3,7 +3,8 @@ import unittest import statistics from homeassistant.setup import setup_component -from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN) from homeassistant.util import dt as dt_util from tests.common import get_test_home_assistant from unittest.mock import patch @@ -106,6 +107,38 @@ class TestStatisticsSensor(unittest.TestCase): self.assertEqual(3.8, state.attributes.get('min_value')) self.assertEqual(14, state.attributes.get('max_value')) + def test_sampling_size_1(self): + """Test validity of stats requiring only one sample.""" + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'sampling_size': 1, + } + }) + + for value in self.values[-3:]: # just the last 3 will do + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test_mean') + + # require only one data point + self.assertEqual(self.values[-1], state.attributes.get('min_value')) + self.assertEqual(self.values[-1], state.attributes.get('max_value')) + self.assertEqual(self.values[-1], state.attributes.get('mean')) + self.assertEqual(self.values[-1], state.attributes.get('median')) + self.assertEqual(self.values[-1], state.attributes.get('total')) + self.assertEqual(0, state.attributes.get('change')) + self.assertEqual(0, state.attributes.get('average_change')) + + # require at least two data points + self.assertEqual(STATE_UNKNOWN, state.attributes.get('variance')) + self.assertEqual(STATE_UNKNOWN, + state.attributes.get('standard_deviation')) + def test_max_age(self): """Test value deprecation.""" mock_data = { diff --git a/tests/components/test_canary.py b/tests/components/test_canary.py new file mode 100644 index 00000000000..67122813fb7 --- /dev/null +++ b/tests/components/test_canary.py @@ -0,0 +1,85 @@ +"""The tests for the Canary component.""" +import unittest +from unittest.mock import patch, MagicMock, PropertyMock + +import homeassistant.components.canary as canary +from homeassistant import setup +from tests.common import ( + get_test_home_assistant) + + +def mock_device(device_id, name, is_online=True): + """Mock Canary Device class.""" + device = MagicMock() + type(device).device_id = PropertyMock(return_value=device_id) + type(device).name = PropertyMock(return_value=name) + type(device).is_online = PropertyMock(return_value=is_online) + return device + + +def mock_location(name, is_celsius=True, devices=[]): + """Mock Canary Location class.""" + location = MagicMock() + type(location).name = PropertyMock(return_value=name) + type(location).is_celsius = PropertyMock(return_value=is_celsius) + type(location).devices = PropertyMock(return_value=devices) + return location + + +def mock_reading(sensor_type, sensor_value): + """Mock Canary Reading class.""" + reading = MagicMock() + type(reading).sensor_type = PropertyMock(return_value=sensor_type) + type(reading).value = PropertyMock(return_value=sensor_value) + return reading + + +class TestCanary(unittest.TestCase): + """Tests the Canary component.""" + + def setUp(self): + """Initialize values for this test case class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.canary.CanaryData.update') + @patch('canary.api.Api.login') + def test_setup_with_valid_config(self, mock_login, mock_update): + """Test setup component.""" + config = { + "canary": { + "username": "foo@bar.org", + "password": "bar", + } + } + + self.assertTrue( + setup.setup_component(self.hass, canary.DOMAIN, config)) + + mock_update.assert_called_once_with() + mock_login.assert_called_once_with() + + def test_setup_with_missing_password(self): + """Test setup component.""" + config = { + "canary": { + "username": "foo@bar.org", + } + } + + self.assertFalse( + setup.setup_component(self.hass, canary.DOMAIN, config)) + + def test_setup_with_missing_username(self): + """Test setup component.""" + config = { + "canary": { + "password": "bar", + } + } + + self.assertFalse( + setup.setup_component(self.hass, canary.DOMAIN, config)) diff --git a/tests/components/test_hue.py b/tests/components/test_hue.py new file mode 100644 index 00000000000..227295594db --- /dev/null +++ b/tests/components/test_hue.py @@ -0,0 +1,402 @@ +"""Generic Philips Hue component tests.""" + +import logging +import unittest +from unittest.mock import call, MagicMock, patch + +from homeassistant.components import configurator, hue +from homeassistant.const import CONF_FILENAME, CONF_HOST +from homeassistant.setup import setup_component + +from tests.common import ( + assert_setup_component, get_test_home_assistant, get_test_config_dir, + MockDependency +) + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup(unittest.TestCase): + """Test the Hue component.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + @MockDependency('phue') + def test_setup_no_domain(self, mock_phue): + """If it's not in the config we won't even try.""" + with assert_setup_component(0): + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {})) + mock_phue.Bridge.assert_not_called() + self.assertEquals({}, self.hass.data[hue.DOMAIN]) + + @MockDependency('phue') + def test_setup_no_host(self, mock_phue): + """No host specified in any way.""" + with assert_setup_component(1): + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {hue.DOMAIN: {}})) + mock_phue.Bridge.assert_not_called() + self.assertEquals({}, self.hass.data[hue.DOMAIN]) + + @MockDependency('phue') + def test_setup_with_host(self, mock_phue): + """Host specified in the config file.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: 'localhost'}]}})) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_setup_with_phue_conf(self, mock_phue): + """No host in the config file, but one is cached in phue.conf.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch( + 'homeassistant.components.hue._find_host_from_config', + return_value='localhost'): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_FILENAME: 'phue.conf'}]}})) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_setup_with_multiple_hosts(self, mock_phue): + """Multiple hosts specified in the config file.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: 'localhost'}, + {CONF_HOST: '192.168.0.1'}]}})) + + mock_bridge.assert_has_calls([ + call( + 'localhost', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)), + call( + '192.168.0.1', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE))]) + mock_load.mock_bridge.assert_not_called() + mock_load.assert_has_calls([ + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}), + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.0.1'}), + ], any_order=True) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(2, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_bridge_discovered(self, mock_phue): + """Bridge discovery.""" + mock_bridge = mock_phue.Bridge + mock_service = MagicMock() + discovery_info = {'host': '192.168.0.10', 'serial': 'foobar'} + + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {})) + hue.bridge_discovered(self.hass, mock_service, discovery_info) + + mock_bridge.assert_called_once_with( + '192.168.0.10', + config_file_path=get_test_config_dir('phue-foobar.conf')) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.0.10'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_bridge_configure_and_discovered(self, mock_phue): + """Bridge is in the config file, then we discover it.""" + mock_bridge = mock_phue.Bridge + mock_service = MagicMock() + discovery_info = {'host': '192.168.1.10', 'serial': 'foobar'} + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + # First we set up the component from config + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: '192.168.1.10'}]}})) + + mock_bridge.assert_called_once_with( + '192.168.1.10', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + calls_to_mock_load = [ + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.1.10'}), + ] + mock_load.assert_has_calls(calls_to_mock_load) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + # Then we discover the same bridge + hue.bridge_discovered(self.hass, mock_service, discovery_info) + + # No additional calls + mock_bridge.assert_called_once_with( + '192.168.1.10', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + mock_load.assert_has_calls(calls_to_mock_load) + + # Still only one + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + +class TestHueBridge(unittest.TestCase): + """Test the HueBridge class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.data[hue.DOMAIN] = {} + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + @MockDependency('phue') + def test_setup_bridge_connection_refused(self, mock_phue): + """Test a registration failed with a connection refused exception.""" + mock_bridge = mock_phue.Bridge + mock_bridge.side_effect = ConnectionRefusedError() + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertTrue(bridge.config_request_id is None) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + + @MockDependency('phue') + def test_setup_bridge_registration_exception(self, mock_phue): + """Test a registration failed with an exception.""" + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = mock_phue.PhueRegistrationException(1, 2) + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + self.assertTrue(isinstance(bridge.config_request_id, str)) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + + @MockDependency('phue') + def test_setup_bridge_registration_succeeds(self, mock_phue): + """Test a registration success sequence.""" + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, registration is done + None, + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertTrue(bridge.configured) + self.assertTrue(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # Make sure the request is done + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configured', self.hass.states.all()[0].state) + + @MockDependency('phue') + def test_setup_bridge_registration_fails(self, mock_phue): + """ + Test a registration failure sequence. + + This may happen when we start the registration process, the user + responds to the request but the bridge has become unreachable. + """ + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, the bridge has gone away + ConnectionRefusedError(), + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # The request should still be pending + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configure', self.hass.states.all()[0].state) + + @MockDependency('phue') + def test_setup_bridge_registration_retry(self, mock_phue): + """ + Test a registration retry sequence. + + This may happen when we start the registration process, the user + responds to the request but we fail to confirm it with the bridge. + """ + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, for whatever reason authentication fails + mock_phue.PhueRegistrationException(1, 2), + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # Make sure the request is done + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configure', self.hass.states.all()[0].state) + self.assertEqual( + 'Failed to register, please try again.', + self.hass.states.all()[0].attributes.get(configurator.ATTR_ERRORS)) + + @MockDependency('phue') + def test_hue_activate_scene(self, mock_phue): + """Test the hue_activate_scene service.""" + with patch('homeassistant.helpers.discovery.load_platform'): + bridge = hue.HueBridge('localhost', self.hass, + hue.PHUE_CONFIG_FILE) + bridge.setup() + + # No args + self.hass.services.call(hue.DOMAIN, hue.SERVICE_HUE_SCENE, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + # Only one arg + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_GROUP_NAME: 'group'}, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_SCENE_NAME: 'scene'}, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + # Both required args + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_GROUP_NAME: 'group', hue.ATTR_SCENE_NAME: 'scene'}, + blocking=True) + bridge.bridge.run_scene.assert_called_once_with('group', 'scene') diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index 5e49bbd0382..a3e6fac0295 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -34,7 +34,7 @@ def test_snips_call_action(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') - async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed', + async_fire_mqtt_message(hass, 'hermes/intent/activateLights', EXAMPLE_MSG) yield from hass.async_block_till_done() assert len(intents) == 1 diff --git a/tox.ini b/tox.ini index f3e58ce8889..32f80b95dc1 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ deps = -c{toxinidir}/homeassistant/package_constraints.txt [testenv:pylint] -basepython = python3 +basepython = {env:PYTHON3_PATH:python3} ignore_errors = True deps = -r{toxinidir}/requirements_all.txt @@ -28,7 +28,7 @@ commands = pylint homeassistant [testenv:lint] -basepython = python3 +basepython = {env:PYTHON3_PATH:python3} deps = -r{toxinidir}/requirements_test.txt commands = @@ -37,7 +37,7 @@ commands = pydocstyle homeassistant tests [testenv:typing] -basepython = python3 +basepython = {env:PYTHON3_PATH:python3} deps = -r{toxinidir}/requirements_test.txt commands =